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:
Anton Volnuhin 2026-01-30 18:41:36 +03:00
parent 6cf5d30d4d
commit 77c8987099
3 changed files with 585 additions and 45 deletions

402
app.js
View File

@ -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,7 +597,7 @@ 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
@ -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,6 +693,7 @@ function renderChart(data) {
const transactionCategory = {
name: group.name,
value: group.value,
transactions: group.transactions, // Preserve for modal
itemStyle: {
color: transactionColors[j]
},
@ -706,7 +711,8 @@ function renderChart(data) {
value: transaction.value,
itemStyle: {
color: individualColors[k]
}
},
originalRow: transaction.originalRow
});
});
}
@ -741,6 +747,7 @@ function renderChart(data) {
const transactionCategory = {
name: group.name,
value: group.value,
transactions: group.transactions, // Preserve for modal
itemStyle: {
color: color
},
@ -758,7 +765,8 @@ function renderChart(data) {
value: transaction.value,
itemStyle: {
color: transactionColors[j]
}
},
originalRow: transaction.originalRow
});
});
}
@ -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
@ -1228,6 +1412,17 @@ function setupHoverEvents(sunburstData) {
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 = '<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');
amountSpan.className = 'top-item-amount';
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
function showDefaultView() {
currentHoveredData = null;
detailsHeader.innerHTML = `
<span class="hover-name">
<span class="color-circle" style="background-color: #ffffff;"></span>
@ -1274,6 +1485,10 @@ function setupHoverEvents(sunburstData) {
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 = `
<span class="hover-name">
<span class="color-circle" style="background-color: ${itemColor};"></span>
${params.name}
</span>
<span class="hover-amount">${params.value.toLocaleString()} </span>
`;
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 = `
<span class="hover-name">
<span class="color-circle" style="background-color: ${itemColor};"></span>
${params.name}
</span>
<span class="hover-amount">${params.value.toLocaleString()} </span>
`;
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 = `
<span class="hover-name">
<span class="color-circle" style="background-color: ${itemColor};"></span>
${params.name}
</span>
<span class="hover-amount">${params.value.toLocaleString()} </span>
`;
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 = '<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);
}

View File

@ -21,6 +21,12 @@
</div>
<div class="content-wrapper">
<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">
<h3>Детали</h3>
<div id="details-header" class="details-header">
@ -32,6 +38,22 @@
</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">&times;</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>
</body>
</html>

View File

@ -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;
}
}