Add floating eye button for viewing transaction details
- Add eye icon that appears when hovering over chart sectors - Position button at inner edge for left-side sectors, outer edge for right-side - Click eye button to open transaction details modal - Maintain sector highlight and details panel when hovering over button - Override chart cursor to only show pointer on eye button, not sectors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6cf5d30d4d
commit
77c8987099
398
app.js
398
app.js
@ -110,7 +110,8 @@ function transformToSunburst(data) {
|
|||||||
date: item.date,
|
date: item.date,
|
||||||
microCategory: originalMicrocategory, // Store ORIGINAL value
|
microCategory: originalMicrocategory, // Store ORIGINAL value
|
||||||
hasNoMicroCategory: originalMicrocategory === '', // Flag for easy filtering (legacy)
|
hasNoMicroCategory: originalMicrocategory === '', // Flag for easy filtering (legacy)
|
||||||
displayMicrocategory: displayMicrocategory
|
displayMicrocategory: displayMicrocategory,
|
||||||
|
originalRow: item // Store full CSV row for modal display
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save transaction data for detail box with ORIGINAL microcategory
|
// Save transaction data for detail box with ORIGINAL microcategory
|
||||||
@ -560,7 +561,7 @@ function renderChart(data) {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
emphasis: {
|
emphasis: {
|
||||||
focus: 'relative',
|
focus: 'relative'
|
||||||
},
|
},
|
||||||
// Add more space between wedges
|
// Add more space between wedges
|
||||||
gap: 2,
|
gap: 2,
|
||||||
@ -596,7 +597,7 @@ function renderChart(data) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle chart events - recolor on drill down
|
// Handle chart events - drill-down on click
|
||||||
myChart.off('click');
|
myChart.off('click');
|
||||||
myChart.on('click', function(params) {
|
myChart.on('click', function(params) {
|
||||||
if (!params.data) return; // Skip if no data
|
if (!params.data) return; // Skip if no data
|
||||||
@ -622,6 +623,7 @@ function renderChart(data) {
|
|||||||
const newCategory = {
|
const newCategory = {
|
||||||
name: child.name,
|
name: child.name,
|
||||||
value: child.value,
|
value: child.value,
|
||||||
|
transactions: child.transactions, // Preserve for modal
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: color
|
color: color
|
||||||
},
|
},
|
||||||
@ -637,6 +639,7 @@ function renderChart(data) {
|
|||||||
const microCategory = {
|
const microCategory = {
|
||||||
name: micro.name,
|
name: micro.name,
|
||||||
value: micro.value,
|
value: micro.value,
|
||||||
|
transactions: micro.transactions, // Preserve for modal
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: microColors[j]
|
color: microColors[j]
|
||||||
},
|
},
|
||||||
@ -654,7 +657,8 @@ function renderChart(data) {
|
|||||||
value: transaction.value,
|
value: transaction.value,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: transactionColors[k]
|
color: transactionColors[k]
|
||||||
}
|
},
|
||||||
|
originalRow: transaction.originalRow
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -689,6 +693,7 @@ function renderChart(data) {
|
|||||||
const transactionCategory = {
|
const transactionCategory = {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
value: group.value,
|
value: group.value,
|
||||||
|
transactions: group.transactions, // Preserve for modal
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: transactionColors[j]
|
color: transactionColors[j]
|
||||||
},
|
},
|
||||||
@ -706,7 +711,8 @@ function renderChart(data) {
|
|||||||
value: transaction.value,
|
value: transaction.value,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: individualColors[k]
|
color: individualColors[k]
|
||||||
}
|
},
|
||||||
|
originalRow: transaction.originalRow
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -741,6 +747,7 @@ function renderChart(data) {
|
|||||||
const transactionCategory = {
|
const transactionCategory = {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
value: group.value,
|
value: group.value,
|
||||||
|
transactions: group.transactions, // Preserve for modal
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: color
|
color: color
|
||||||
},
|
},
|
||||||
@ -758,7 +765,8 @@ function renderChart(data) {
|
|||||||
value: transaction.value,
|
value: transaction.value,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: transactionColors[j]
|
color: transactionColors[j]
|
||||||
}
|
},
|
||||||
|
originalRow: transaction.originalRow
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1196,6 +1204,182 @@ function setupHoverEvents(sunburstData) {
|
|||||||
return distance <= chartRadius;
|
return distance <= chartRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Floating eye button for chart
|
||||||
|
const chartEyeBtn = document.getElementById('chart-eye-btn');
|
||||||
|
let currentHoveredSectorData = null;
|
||||||
|
let hideButtonTimeout = null;
|
||||||
|
let isOverEyeButton = false;
|
||||||
|
let isOverChartSector = false; // Track if mouse is over any chart sector
|
||||||
|
let lastMouseX = 0;
|
||||||
|
let lastMouseY = 0;
|
||||||
|
|
||||||
|
// Calculate the center angle of a sector based on its data
|
||||||
|
function getSectorLayoutFromChart(params) {
|
||||||
|
// Use ECharts internal data model to get actual rendered layout
|
||||||
|
try {
|
||||||
|
const seriesModel = myChart.getModel().getSeriesByIndex(0);
|
||||||
|
if (!seriesModel) return null;
|
||||||
|
|
||||||
|
const data = seriesModel.getData();
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
// Get the layout for this specific data item
|
||||||
|
const layout = data.getItemLayout(params.dataIndex);
|
||||||
|
if (layout) {
|
||||||
|
return {
|
||||||
|
startAngle: layout.startAngle,
|
||||||
|
endAngle: layout.endAngle,
|
||||||
|
r: layout.r, // outer radius
|
||||||
|
r0: layout.r0, // inner radius
|
||||||
|
cx: layout.cx, // center x
|
||||||
|
cy: layout.cy // center y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Could not get layout from chart model:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track current hovered sector for persistent button display
|
||||||
|
let currentHoveredSectorParams = null;
|
||||||
|
|
||||||
|
// Position and show the floating eye button near the hovered sector
|
||||||
|
function showChartEyeButton(params) {
|
||||||
|
if (!chartEyeBtn || !params.event) return;
|
||||||
|
|
||||||
|
// Clear any pending hide timeout
|
||||||
|
if (hideButtonTimeout) {
|
||||||
|
clearTimeout(hideButtonTimeout);
|
||||||
|
hideButtonTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHoveredSectorData = params.data;
|
||||||
|
currentHoveredSectorParams = params;
|
||||||
|
isOverChartSector = true;
|
||||||
|
|
||||||
|
// Get actual layout from ECharts internal model
|
||||||
|
const layout = getSectorLayoutFromChart(params);
|
||||||
|
|
||||||
|
if (layout && layout.cx !== undefined) {
|
||||||
|
// Calculate mid-angle of the sector
|
||||||
|
const midAngle = (layout.startAngle + layout.endAngle) / 2;
|
||||||
|
|
||||||
|
// Normalize angle to determine which side of the chart
|
||||||
|
let normalizedAngle = midAngle % (2 * Math.PI);
|
||||||
|
if (normalizedAngle < 0) normalizedAngle += 2 * Math.PI;
|
||||||
|
|
||||||
|
// Left side: angle between π/2 and 3π/2 (90° to 270°)
|
||||||
|
// On left side, labels read from edge to center, so button goes near inner edge
|
||||||
|
// On right side, labels read from center to edge, so button goes near outer edge
|
||||||
|
const isLeftSide = normalizedAngle > Math.PI / 2 && normalizedAngle < 3 * Math.PI / 2;
|
||||||
|
|
||||||
|
// Position button at the appropriate edge based on label reading direction
|
||||||
|
const buttonRadius = isLeftSide
|
||||||
|
? layout.r0 + 15 // Inner edge for left side (labels read inward)
|
||||||
|
: layout.r - 15; // Outer edge for right side (labels read outward)
|
||||||
|
|
||||||
|
// Calculate button position at the sector's mid-angle
|
||||||
|
const buttonX = layout.cx + buttonRadius * Math.cos(midAngle);
|
||||||
|
const buttonY = layout.cy + buttonRadius * Math.sin(midAngle);
|
||||||
|
|
||||||
|
// Calculate rotation for the icon to match sector orientation
|
||||||
|
let rotationDeg = (midAngle * 180 / Math.PI);
|
||||||
|
if (isLeftSide) {
|
||||||
|
rotationDeg += 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the 24px button
|
||||||
|
chartEyeBtn.style.left = (buttonX - 12) + 'px';
|
||||||
|
chartEyeBtn.style.top = (buttonY - 12) + 'px';
|
||||||
|
chartEyeBtn.style.transform = `rotate(${rotationDeg}deg)`;
|
||||||
|
chartEyeBtn.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
// Fallback: hide the button if we can't get layout
|
||||||
|
chartEyeBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the floating eye button with a delay
|
||||||
|
function hideChartEyeButton(force = false) {
|
||||||
|
// Don't hide if mouse is over the button
|
||||||
|
if (isOverEyeButton && !force) return;
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (hideButtonTimeout) {
|
||||||
|
clearTimeout(hideButtonTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use longer delay if mouse is still in chart area (gives time for sector re-hover)
|
||||||
|
const stillInChart = isInsideChart(lastMouseX, lastMouseY);
|
||||||
|
const delay = stillInChart ? 400 : 200;
|
||||||
|
|
||||||
|
hideButtonTimeout = setTimeout(() => {
|
||||||
|
// Double-check: don't hide if now over button or still over a chart sector
|
||||||
|
if (!isOverEyeButton && !isOverChartSector && chartEyeBtn) {
|
||||||
|
chartEyeBtn.style.display = 'none';
|
||||||
|
currentHoveredSectorData = null;
|
||||||
|
currentHoveredSectorParams = null;
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle click on floating eye button
|
||||||
|
if (chartEyeBtn) {
|
||||||
|
chartEyeBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (currentHoveredSectorData) {
|
||||||
|
openTransactionModal(currentHoveredSectorData);
|
||||||
|
// Hide button after opening modal
|
||||||
|
chartEyeBtn.style.display = 'none';
|
||||||
|
isOverEyeButton = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track when mouse is over the button
|
||||||
|
chartEyeBtn.addEventListener('mouseenter', () => {
|
||||||
|
isOverEyeButton = true;
|
||||||
|
if (hideButtonTimeout) {
|
||||||
|
clearTimeout(hideButtonTimeout);
|
||||||
|
hideButtonTimeout = null;
|
||||||
|
}
|
||||||
|
// Keep the sector highlighted while hovering over eye button
|
||||||
|
if (currentHoveredSectorParams && currentHoveredSectorParams.dataIndex !== undefined) {
|
||||||
|
myChart.dispatchAction({
|
||||||
|
type: 'highlight',
|
||||||
|
seriesIndex: 0,
|
||||||
|
dataIndex: currentHoveredSectorParams.dataIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chartEyeBtn.addEventListener('mouseleave', (e) => {
|
||||||
|
isOverEyeButton = false;
|
||||||
|
|
||||||
|
// Check if we're still in the chart area
|
||||||
|
const rect = chartDom.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
if (isInsideChart(mouseX, mouseY)) {
|
||||||
|
// Still in chart - wait longer for ECharts to potentially re-trigger mouseover
|
||||||
|
// The delay in hideChartEyeButton will handle this
|
||||||
|
isOverChartSector = true; // Assume still over sector until proven otherwise
|
||||||
|
} else {
|
||||||
|
// Leaving chart area - remove highlight
|
||||||
|
if (currentHoveredSectorParams && currentHoveredSectorParams.dataIndex !== undefined) {
|
||||||
|
myChart.dispatchAction({
|
||||||
|
type: 'downplay',
|
||||||
|
seriesIndex: 0,
|
||||||
|
dataIndex: currentHoveredSectorParams.dataIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideChartEyeButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Function to display items in the details box
|
// Function to display items in the details box
|
||||||
function displayDetailsItems(items, parentValue) {
|
function displayDetailsItems(items, parentValue) {
|
||||||
// Clear previous top items
|
// Clear previous top items
|
||||||
@ -1228,6 +1412,17 @@ function setupHoverEvents(sunburstData) {
|
|||||||
nameSpan.appendChild(document.createTextNode(item.name));
|
nameSpan.appendChild(document.createTextNode(item.name));
|
||||||
itemDiv.appendChild(nameSpan);
|
itemDiv.appendChild(nameSpan);
|
||||||
|
|
||||||
|
// Add eye button for viewing transaction details
|
||||||
|
const eyeBtn = document.createElement('button');
|
||||||
|
eyeBtn.className = 'eye-btn';
|
||||||
|
eyeBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||||
|
eyeBtn.title = 'View transaction details';
|
||||||
|
eyeBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openTransactionModal(item);
|
||||||
|
});
|
||||||
|
itemDiv.appendChild(eyeBtn);
|
||||||
|
|
||||||
const amountSpan = document.createElement('span');
|
const amountSpan = document.createElement('span');
|
||||||
amountSpan.className = 'top-item-amount';
|
amountSpan.className = 'top-item-amount';
|
||||||
amountSpan.textContent = item.value.toLocaleString() + ' ₽';
|
amountSpan.textContent = item.value.toLocaleString() + ' ₽';
|
||||||
@ -1247,8 +1442,24 @@ function setupHoverEvents(sunburstData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variable to store currently hovered data for the header eye button
|
||||||
|
let currentHoveredData = null;
|
||||||
|
|
||||||
|
// Helper function to update the details header
|
||||||
|
function updateDetailsHeader(name, value, color, data) {
|
||||||
|
currentHoveredData = data;
|
||||||
|
detailsHeader.innerHTML = `
|
||||||
|
<span class="hover-name">
|
||||||
|
<span class="color-circle" style="background-color: ${color};"></span>
|
||||||
|
${name}
|
||||||
|
</span>
|
||||||
|
<span class="hover-amount">${value.toLocaleString()} ₽</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Show the default view with top categories
|
// Show the default view with top categories
|
||||||
function showDefaultView() {
|
function showDefaultView() {
|
||||||
|
currentHoveredData = null;
|
||||||
detailsHeader.innerHTML = `
|
detailsHeader.innerHTML = `
|
||||||
<span class="hover-name">
|
<span class="hover-name">
|
||||||
<span class="color-circle" style="background-color: #ffffff;"></span>
|
<span class="color-circle" style="background-color: #ffffff;"></span>
|
||||||
@ -1274,6 +1485,10 @@ function setupHoverEvents(sunburstData) {
|
|||||||
const mouseX = e.clientX - rect.left;
|
const mouseX = e.clientX - rect.left;
|
||||||
const mouseY = e.clientY - rect.top;
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Track mouse position for eye button hide logic
|
||||||
|
lastMouseX = mouseX;
|
||||||
|
lastMouseY = mouseY;
|
||||||
|
|
||||||
// If not inside the chart circle and we were inside a section, show default view
|
// If not inside the chart circle and we were inside a section, show default view
|
||||||
if (!isInsideChart(mouseX, mouseY) && isInsideSection) {
|
if (!isInsideChart(mouseX, mouseY) && isInsideSection) {
|
||||||
isInsideSection = false;
|
isInsideSection = false;
|
||||||
@ -1285,6 +1500,9 @@ function setupHoverEvents(sunburstData) {
|
|||||||
// Add mouseover event listener for sectors
|
// Add mouseover event listener for sectors
|
||||||
myChart.on('mouseover', function(params) {
|
myChart.on('mouseover', function(params) {
|
||||||
if (params.data) {
|
if (params.data) {
|
||||||
|
// Show floating eye button at end of sector label
|
||||||
|
showChartEyeButton(params);
|
||||||
|
|
||||||
// Compute depth from treePathInfo instead of using params.depth
|
// Compute depth from treePathInfo instead of using params.depth
|
||||||
let depth = params.treePathInfo ? params.treePathInfo.length - 1 : 0;
|
let depth = params.treePathInfo ? params.treePathInfo.length - 1 : 0;
|
||||||
// Debug log for every mouseover event using computed depth
|
// Debug log for every mouseover event using computed depth
|
||||||
@ -1294,13 +1512,7 @@ function setupHoverEvents(sunburstData) {
|
|||||||
if (depth === 2) {
|
if (depth === 2) {
|
||||||
console.log('DEBUG: Hovered subcategory node:', params.name, 'Transactions:', params.data.transactions, 'Children:', params.data.children);
|
console.log('DEBUG: Hovered subcategory node:', params.name, 'Transactions:', params.data.transactions, 'Children:', params.data.children);
|
||||||
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
||||||
detailsHeader.innerHTML = `
|
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
||||||
<span class="hover-name">
|
|
||||||
<span class="color-circle" style="background-color: ${itemColor};"></span>
|
|
||||||
${params.name}
|
|
||||||
</span>
|
|
||||||
<span class="hover-amount">${params.value.toLocaleString()} ₽</span>
|
|
||||||
`;
|
|
||||||
const MAX_DETAIL_ITEMS = 10;
|
const MAX_DETAIL_ITEMS = 10;
|
||||||
let itemsToShow = [];
|
let itemsToShow = [];
|
||||||
if (params.data.children && params.data.children.length > 0) {
|
if (params.data.children && params.data.children.length > 0) {
|
||||||
@ -1322,13 +1534,7 @@ function setupHoverEvents(sunburstData) {
|
|||||||
// Handle category nodes (depth 1): show subcategories and transactions without microcategory
|
// Handle category nodes (depth 1): show subcategories and transactions without microcategory
|
||||||
if (depth === 1) {
|
if (depth === 1) {
|
||||||
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
||||||
detailsHeader.innerHTML = `
|
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
||||||
<span class="hover-name">
|
|
||||||
<span class="color-circle" style="background-color: ${itemColor};"></span>
|
|
||||||
${params.name}
|
|
||||||
</span>
|
|
||||||
<span class="hover-amount">${params.value.toLocaleString()} ₽</span>
|
|
||||||
`;
|
|
||||||
const MAX_DETAIL_ITEMS = 10;
|
const MAX_DETAIL_ITEMS = 10;
|
||||||
let itemsToShow = [];
|
let itemsToShow = [];
|
||||||
if (params.data.children && params.data.children.length > 0) {
|
if (params.data.children && params.data.children.length > 0) {
|
||||||
@ -1350,13 +1556,7 @@ function setupHoverEvents(sunburstData) {
|
|||||||
// For other depths, continue with existing behaviour
|
// For other depths, continue with existing behaviour
|
||||||
isInsideSection = true;
|
isInsideSection = true;
|
||||||
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
||||||
detailsHeader.innerHTML = `
|
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
||||||
<span class="hover-name">
|
|
||||||
<span class="color-circle" style="background-color: ${itemColor};"></span>
|
|
||||||
${params.name}
|
|
||||||
</span>
|
|
||||||
<span class="hover-amount">${params.value.toLocaleString()} ₽</span>
|
|
||||||
`;
|
|
||||||
const MAX_DETAIL_ITEMS = 10;
|
const MAX_DETAIL_ITEMS = 10;
|
||||||
let itemsToShow = [];
|
let itemsToShow = [];
|
||||||
if (params.data.children && params.data.children.length > 0) {
|
if (params.data.children && params.data.children.length > 0) {
|
||||||
@ -1430,14 +1630,154 @@ function setupHoverEvents(sunburstData) {
|
|||||||
myChart.on('mouseout', function(params) {
|
myChart.on('mouseout', function(params) {
|
||||||
if (params.data) {
|
if (params.data) {
|
||||||
isInsideSection = false;
|
isInsideSection = false;
|
||||||
// Reset details immediately when leaving a section
|
isOverChartSector = false;
|
||||||
|
// Hide the floating eye button (unless hovering over it)
|
||||||
|
hideChartEyeButton();
|
||||||
|
// Reset details with a small delay to allow eye button mouseenter to fire first
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isOverEyeButton) {
|
||||||
showDefaultView();
|
showDefaultView();
|
||||||
}
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add back the downplay event handler - this is triggered when sections lose emphasis
|
// Add back the downplay event handler - this is triggered when sections lose emphasis
|
||||||
myChart.on('downplay', function(params) {
|
myChart.on('downplay', function(params) {
|
||||||
// Reset to default view when a section is no longer emphasized
|
// Reset to default view when a section is no longer emphasized (unless hovering eye button)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isOverEyeButton) {
|
||||||
showDefaultView();
|
showDefaultView();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSV column labels for human-readable display
|
||||||
|
const csvColumnLabels = {
|
||||||
|
transaction_date: 'Date',
|
||||||
|
processing_date: 'Processing Date',
|
||||||
|
transaction_description: 'Description',
|
||||||
|
comment: 'Comment',
|
||||||
|
mcc: 'MCC',
|
||||||
|
card_info: 'Card',
|
||||||
|
account: 'Account',
|
||||||
|
merchant_name: 'Merchant',
|
||||||
|
location: 'Location',
|
||||||
|
info_source: 'Source',
|
||||||
|
amount_original: 'Original Amount',
|
||||||
|
currency_original: 'Original Currency',
|
||||||
|
amount_rub: 'Amount (₽)',
|
||||||
|
category: 'Category',
|
||||||
|
subcategory: 'Subcategory',
|
||||||
|
microcategory: 'Microcategory',
|
||||||
|
simple_name: 'Name'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preferred column order (most important first)
|
||||||
|
const csvColumnOrder = ['transaction_date', 'simple_name', 'amount_rub', 'category', 'subcategory', 'microcategory', 'transaction_description', 'merchant_name', 'account', 'card_info', 'location', 'amount_original', 'currency_original', 'mcc', 'info_source', 'processing_date', 'comment'];
|
||||||
|
|
||||||
|
// Open transaction modal
|
||||||
|
function openTransactionModal(item) {
|
||||||
|
const modal = document.getElementById('transaction-modal');
|
||||||
|
const modalTitle = document.getElementById('modal-title');
|
||||||
|
const modalThead = document.getElementById('modal-thead');
|
||||||
|
const modalTbody = document.getElementById('modal-tbody');
|
||||||
|
|
||||||
|
// Set title
|
||||||
|
modalTitle.textContent = item.name;
|
||||||
|
|
||||||
|
// Gather transactions
|
||||||
|
let transactions = [];
|
||||||
|
if (item.transactions && item.transactions.length > 0) {
|
||||||
|
transactions = item.transactions.filter(t => t.originalRow);
|
||||||
|
} else if (item.originalRow) {
|
||||||
|
transactions = [item];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
|
modalThead.innerHTML = '';
|
||||||
|
modalTbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (transactions.length === 0) {
|
||||||
|
modalTbody.innerHTML = '<tr><td colspan="100%">No transaction data available</td></tr>';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
setupModalListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date descending
|
||||||
|
transactions.sort((a, b) => {
|
||||||
|
const dateA = a.originalRow?.transaction_date || a.date || '';
|
||||||
|
const dateB = b.originalRow?.transaction_date || b.date || '';
|
||||||
|
return dateB.localeCompare(dateA);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all columns from the first transaction's originalRow
|
||||||
|
const sampleRow = transactions[0].originalRow;
|
||||||
|
const availableColumns = csvColumnOrder.filter(col => col in sampleRow);
|
||||||
|
|
||||||
|
// Build header
|
||||||
|
const headerRow = document.createElement('tr');
|
||||||
|
availableColumns.forEach(col => {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = csvColumnLabels[col] || col;
|
||||||
|
headerRow.appendChild(th);
|
||||||
|
});
|
||||||
|
modalThead.appendChild(headerRow);
|
||||||
|
|
||||||
|
// Build rows
|
||||||
|
transactions.forEach(transaction => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
availableColumns.forEach(col => {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
let value = transaction.originalRow[col];
|
||||||
|
|
||||||
|
// Format amount values
|
||||||
|
if ((col === 'amount' || col === 'original_amount') && typeof value === 'number') {
|
||||||
|
value = value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
td.textContent = value !== undefined && value !== null ? value : '';
|
||||||
|
td.title = td.textContent; // Show full text on hover
|
||||||
|
row.appendChild(td);
|
||||||
|
});
|
||||||
|
modalTbody.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
setupModalListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close transaction modal
|
||||||
|
function closeTransactionModal() {
|
||||||
|
const modal = document.getElementById('transaction-modal');
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.removeEventListener('keydown', handleModalEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Escape key to close modal
|
||||||
|
function handleModalEscape(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeTransactionModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup modal close listeners
|
||||||
|
function setupModalListeners() {
|
||||||
|
const modal = document.getElementById('transaction-modal');
|
||||||
|
const closeBtn = document.getElementById('modal-close');
|
||||||
|
|
||||||
|
// Close on X button click
|
||||||
|
closeBtn.onclick = closeTransactionModal;
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
modal.onclick = function(e) {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeTransactionModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
document.addEventListener('keydown', handleModalEscape);
|
||||||
|
}
|
||||||
|
|||||||
22
index.html
22
index.html
@ -21,6 +21,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div id="chart-container"></div>
|
<div id="chart-container"></div>
|
||||||
|
<button id="chart-eye-btn" class="chart-eye-btn" style="display: none;" title="View transactions">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div id="details-box" class="details-box">
|
<div id="details-box" class="details-box">
|
||||||
<h3>Детали</h3>
|
<h3>Детали</h3>
|
||||||
<div id="details-header" class="details-header">
|
<div id="details-header" class="details-header">
|
||||||
@ -32,6 +38,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="transaction-modal" class="modal-overlay" style="display: none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-title">Transaction Details</h2>
|
||||||
|
<button class="modal-close" id="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="modal-table-container">
|
||||||
|
<table id="modal-table" class="transaction-table">
|
||||||
|
<thead id="modal-thead"></thead>
|
||||||
|
<tbody id="modal-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
178
styles.css
178
styles.css
@ -140,6 +140,11 @@ body {
|
|||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Override ECharts default pointer cursor - only eye button should have pointer */
|
||||||
|
#chart-container canvas {
|
||||||
|
cursor: default !important;
|
||||||
|
}
|
||||||
|
|
||||||
#details-box {
|
#details-box {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
@ -312,3 +317,176 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Eye button for transaction details */
|
||||||
|
.eye-btn {
|
||||||
|
opacity: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-item:hover .eye-btn {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye-btn:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating eye button on chart (shown when hovering over sunburst sector) */
|
||||||
|
.chart-eye-btn {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(255, 255, 255, 1)) drop-shadow(0 0 5px rgba(255, 255, 255, 0.9));
|
||||||
|
transition: filter 0.15s;
|
||||||
|
color: #111;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-eye-btn:hover {
|
||||||
|
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 4px rgba(255, 255, 255, 1)) drop-shadow(0 0 6px rgba(255, 255, 255, 1));
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-eye-btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 1200px;
|
||||||
|
height: 80vh;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-table th,
|
||||||
|
.transaction-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-table tbody tr:hover {
|
||||||
|
background-color: #f5f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile modal styles */
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
.modal-content {
|
||||||
|
width: 95vw;
|
||||||
|
height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-table {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-table th,
|
||||||
|
.transaction-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user