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
430
app.js
430
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 = '<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() + ' ₽';
|
||||
|
||||
|
||||
// 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 = `
|
||||
<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>
|
||||
@ -1256,7 +1467,7 @@ function setupHoverEvents(sunburstData) {
|
||||
</span>
|
||||
<span class="hover-amount">${sunburstData.total.toLocaleString()} ₽</span>
|
||||
`;
|
||||
|
||||
|
||||
// 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 = `
|
||||
<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);
|
||||
}
|
||||
|
||||
22
index.html
22
index.html
@ -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">×</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>
|
||||
178
styles.css
178
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user