From 77c89870991b021fe05ce27110eb500fc7d6e390 Mon Sep 17 00:00:00 2001 From: Anton Volnuhin Date: Fri, 30 Jan 2026 18:41:36 +0300 Subject: [PATCH] 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 --- app.js | 430 +++++++++++++++++++++++++++++++++++++++++++++++------ index.html | 22 +++ styles.css | 178 ++++++++++++++++++++++ 3 files changed, 585 insertions(+), 45 deletions(-) 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 = 'No transaction data available'; + 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); +} diff --git a/index.html b/index.html index 6141925..93dd361 100644 --- a/index.html +++ b/index.html @@ -21,6 +21,12 @@
+

Детали

@@ -32,6 +38,22 @@
+ \ No newline at end of file diff --git a/styles.css b/styles.css index 6b95f6d..88669a0 100644 --- a/styles.css +++ b/styles.css @@ -140,6 +140,11 @@ body { 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 { position: absolute; 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; + } +} +