diff --git a/app.js b/app.js index f145f90..a9857cf 100644 --- a/app.js +++ b/app.js @@ -110,7 +110,8 @@ function transformToSunburst(data) { date: item.date, microCategory: originalMicrocategory, // Store ORIGINAL value 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 @@ -560,7 +561,7 @@ function renderChart(data) { } ], emphasis: { - focus: 'relative', + focus: 'relative' }, // Add more space between wedges gap: 2, @@ -596,16 +597,16 @@ function renderChart(data) { } }; - // Handle chart events - recolor on drill down + // Handle chart events - drill-down on click myChart.off('click'); myChart.on('click', function(params) { if (!params.data) return; // Skip if no data - + const colorPalette = [ '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72' ]; - + // If we have children or transactions, drill down if ((params.data.children && params.data.children.length > 0) || (params.data.transactions && params.data.transactions.length > 0)) { // Transform the data structure for drill-down @@ -622,6 +623,7 @@ function renderChart(data) { const newCategory = { name: child.name, value: child.value, + transactions: child.transactions, // Preserve for modal itemStyle: { color: color }, @@ -637,6 +639,7 @@ function renderChart(data) { const microCategory = { name: micro.name, value: micro.value, + transactions: micro.transactions, // Preserve for modal itemStyle: { color: microColors[j] }, @@ -654,7 +657,8 @@ function renderChart(data) { value: transaction.value, itemStyle: { color: transactionColors[k] - } + }, + originalRow: transaction.originalRow }); }); } @@ -689,28 +693,30 @@ function renderChart(data) { const transactionCategory = { name: group.name, value: group.value, + transactions: group.transactions, // Preserve for modal itemStyle: { color: transactionColors[j] }, children: [] // Will hold individual transactions }; - + // Add individual transactions as the third level if (group.transactions.length > 0) { const sortedTransactions = [...group.transactions].sort((a, b) => b.value - a.value); const individualColors = generateColorGradient(transactionColors[j], sortedTransactions.length); - + sortedTransactions.forEach((transaction, k) => { transactionCategory.children.push({ name: transaction.name, value: transaction.value, itemStyle: { color: individualColors[k] - } + }, + originalRow: transaction.originalRow }); }); } - + newCategory.children.push(transactionCategory); }); } @@ -741,28 +747,30 @@ function renderChart(data) { const transactionCategory = { name: group.name, value: group.value, + transactions: group.transactions, // Preserve for modal itemStyle: { color: color }, children: [] // Will hold individual transactions }; - + // Add individual transactions as subcategories if (group.transactions.length > 0) { const sortedTransactions = [...group.transactions].sort((a, b) => b.value - a.value); const transactionColors = generateColorGradient(color, sortedTransactions.length); - + sortedTransactions.forEach((transaction, j) => { transactionCategory.children.push({ name: transaction.name, value: transaction.value, itemStyle: { color: transactionColors[j] - } + }, + originalRow: transaction.originalRow }); }); } - + newData.push(transactionCategory); }); } @@ -1196,6 +1204,182 @@ function setupHoverEvents(sunburstData) { 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 displayDetailsItems(items, parentValue) { // Clear previous top items @@ -1227,17 +1411,28 @@ function setupHoverEvents(sunburstData) { nameSpan.appendChild(colorCircle); nameSpan.appendChild(document.createTextNode(item.name)); itemDiv.appendChild(nameSpan); - + + // Add eye button for viewing transaction details + const eyeBtn = document.createElement('button'); + eyeBtn.className = 'eye-btn'; + eyeBtn.innerHTML = ''; + eyeBtn.title = 'View transaction details'; + eyeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + openTransactionModal(item); + }); + itemDiv.appendChild(eyeBtn); + const amountSpan = document.createElement('span'); amountSpan.className = 'top-item-amount'; amountSpan.textContent = item.value.toLocaleString() + ' ₽'; - + // Add percentage if we have a parent value if (parentValue) { const percentage = ((item.value / parentValue) * 100).toFixed(1); amountSpan.textContent += ` (${percentage}%)`; } - + itemDiv.appendChild(amountSpan); topItemsElement.appendChild(itemDiv); }); @@ -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 = ` + + + ${name} + + ${value.toLocaleString()} ₽ + `; + } + // Show the default view with top categories function showDefaultView() { + currentHoveredData = null; detailsHeader.innerHTML = ` @@ -1256,7 +1467,7 @@ function setupHoverEvents(sunburstData) { ${sunburstData.total.toLocaleString()} ₽ `; - + // Show top categories as default items displayDetailsItems(sunburstData.data, sunburstData.total); } @@ -1273,7 +1484,11 @@ function setupHoverEvents(sunburstData) { const rect = chartDom.getBoundingClientRect(); const mouseX = e.clientX - rect.left; 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 (!isInsideChart(mouseX, mouseY) && isInsideSection) { isInsideSection = false; @@ -1285,6 +1500,9 @@ function setupHoverEvents(sunburstData) { // Add mouseover event listener for sectors myChart.on('mouseover', function(params) { if (params.data) { + // Show floating eye button at end of sector label + showChartEyeButton(params); + // Compute depth from treePathInfo instead of using params.depth let depth = params.treePathInfo ? params.treePathInfo.length - 1 : 0; // Debug log for every mouseover event using computed depth @@ -1294,13 +1512,7 @@ function setupHoverEvents(sunburstData) { if (depth === 2) { 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'); - detailsHeader.innerHTML = ` - - - ${params.name} - - ${params.value.toLocaleString()} ₽ - `; + updateDetailsHeader(params.name, params.value, itemColor, params.data); const MAX_DETAIL_ITEMS = 10; let itemsToShow = []; 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 if (depth === 1) { const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); - detailsHeader.innerHTML = ` - - - ${params.name} - - ${params.value.toLocaleString()} ₽ - `; + updateDetailsHeader(params.name, params.value, itemColor, params.data); const MAX_DETAIL_ITEMS = 10; let itemsToShow = []; if (params.data.children && params.data.children.length > 0) { @@ -1350,13 +1556,7 @@ function setupHoverEvents(sunburstData) { // For other depths, continue with existing behaviour isInsideSection = true; const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); - detailsHeader.innerHTML = ` - - - ${params.name} - - ${params.value.toLocaleString()} ₽ - `; + updateDetailsHeader(params.name, params.value, itemColor, params.data); const MAX_DETAIL_ITEMS = 10; let itemsToShow = []; if (params.data.children && params.data.children.length > 0) { @@ -1430,14 +1630,154 @@ function setupHoverEvents(sunburstData) { myChart.on('mouseout', function(params) { if (params.data) { isInsideSection = false; - // Reset details immediately when leaving a section - showDefaultView(); + 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(); + } + }, 50); } }); - + // Add back the downplay event handler - this is triggered when sections lose emphasis myChart.on('downplay', function(params) { - // Reset to default view when a section is no longer emphasized - showDefaultView(); + // Reset to default view when a section is no longer emphasized (unless hovering eye button) + setTimeout(() => { + if (!isOverEyeButton) { + 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 = '