- Add subcategoryOrder derived from subcategoryColors - Add sortBySubcategoryOrder helper function - Update click handler, transformDrillDownData, and mini-chart gradient to use fixed order instead of value-based sorting - Update details box to preserve fixed order for drilled-down views while keeping value-based sorting for root level categories This ensures subcategories maintain consistent positions when switching months, even when their values differ (e.g., "Хобби Залины" always comes before "Подписки" regardless of which has higher spending that month). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2595 lines
103 KiB
JavaScript
2595 lines
103 KiB
JavaScript
// Initialize the chart
|
|
const chartDom = document.getElementById('chart-container');
|
|
const myChart = echarts.init(chartDom);
|
|
let option;
|
|
let originalSunburstData = null; // Stores the original data for the current month (for reset on center click)
|
|
let showDefaultView = null; // Reference to reset details panel to default view
|
|
|
|
// Drill-down history for back/forward navigation
|
|
let drillDownHistory = [];
|
|
let historyIndex = -1;
|
|
let currentDrillPath = []; // Tracks drill-down hierarchy: ["Еда", "Рестораны", ...]
|
|
|
|
// Save current chart state to history
|
|
function saveToHistory(data, total, contextName, isInitial = false, path = []) {
|
|
// Remove any forward history when drilling down from middle of history
|
|
if (historyIndex < drillDownHistory.length - 1) {
|
|
drillDownHistory = drillDownHistory.slice(0, historyIndex + 1);
|
|
}
|
|
drillDownHistory.push({ data, total, contextName, path: [...path] });
|
|
historyIndex = drillDownHistory.length - 1;
|
|
|
|
// Update current drill path
|
|
currentDrillPath = [...path];
|
|
|
|
// Push to browser history (use replaceState for initial state)
|
|
const state = { drillDown: historyIndex };
|
|
if (isInitial) {
|
|
history.replaceState(state, '');
|
|
} else {
|
|
history.pushState(state, '');
|
|
}
|
|
}
|
|
|
|
// Reset history (called when changing months)
|
|
function resetHistory() {
|
|
drillDownHistory = [];
|
|
historyIndex = -1;
|
|
currentDrillPath = [];
|
|
}
|
|
|
|
// Navigate to a specific history state
|
|
function navigateToHistoryState(state) {
|
|
const russianMonth = getRussianMonthName(document.getElementById('month-select').value);
|
|
option.series.data = state.data;
|
|
|
|
if (state.contextName) {
|
|
option.graphic.elements[0].style.text = `${russianMonth}\n${state.contextName}\n${state.total.toFixed(0).toLocaleString()} ₽`;
|
|
} else {
|
|
option.graphic.elements[0].style.text = russianMonth + '\n' + state.total.toFixed(0).toLocaleString() + ' ₽';
|
|
}
|
|
|
|
myChart.setOption(option, { replaceMerge: ['series'] });
|
|
setupHoverEvents({ total: state.total, data: state.data }, state.contextName);
|
|
|
|
// Restore drill path and update mini-charts
|
|
currentDrillPath = state.path ? [...state.path] : [];
|
|
updateAllMonthPreviews();
|
|
}
|
|
|
|
// Go back in drill-down history
|
|
function navigateBack() {
|
|
if (historyIndex > 0) {
|
|
historyIndex--;
|
|
navigateToHistoryState(drillDownHistory[historyIndex]);
|
|
}
|
|
}
|
|
|
|
// Go forward in drill-down history
|
|
function navigateForward() {
|
|
if (historyIndex < drillDownHistory.length - 1) {
|
|
historyIndex++;
|
|
navigateToHistoryState(drillDownHistory[historyIndex]);
|
|
}
|
|
}
|
|
|
|
// Listen for browser back/forward via popstate
|
|
window.addEventListener('popstate', function(e) {
|
|
// If a modal is open, close it and push state back to prevent navigation
|
|
const rowDetailModal = document.getElementById('row-detail-modal');
|
|
const transactionModal = document.getElementById('transaction-modal');
|
|
|
|
if (rowDetailModal && rowDetailModal.style.display !== 'none') {
|
|
closeRowDetailModal();
|
|
// Push state back to cancel the back navigation
|
|
history.pushState(e.state, '');
|
|
return;
|
|
}
|
|
|
|
if (transactionModal && transactionModal.style.display !== 'none') {
|
|
closeTransactionModal();
|
|
// Push state back to cancel the back navigation
|
|
history.pushState(e.state, '');
|
|
return;
|
|
}
|
|
|
|
// No modal open - handle drill-down navigation
|
|
if (e.state && e.state.drillDown !== undefined) {
|
|
const stateIndex = e.state.drillDown;
|
|
if (stateIndex >= 0 && stateIndex < drillDownHistory.length) {
|
|
historyIndex = stateIndex;
|
|
navigateToHistoryState(drillDownHistory[historyIndex]);
|
|
}
|
|
} else {
|
|
// No state or initial state - go to root
|
|
if (drillDownHistory.length > 0) {
|
|
historyIndex = 0;
|
|
navigateToHistoryState(drillDownHistory[0]);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Function to parse CSV data
|
|
async function parseCSV(file) {
|
|
const response = await fetch(file);
|
|
const data = await response.text();
|
|
|
|
// Split the CSV into rows
|
|
const rows = data.split('\n');
|
|
|
|
// Extract headers and remove quotes
|
|
const headers = rows[0].split(',').map(header => header.replace(/"/g, ''));
|
|
|
|
// Parse the data rows
|
|
const result = [];
|
|
for (let i = 1; i < rows.length; i++) {
|
|
if (!rows[i].trim()) continue;
|
|
|
|
// Handle commas within quoted fields
|
|
const row = [];
|
|
let inQuote = false;
|
|
let currentValue = '';
|
|
|
|
for (let j = 0; j < rows[i].length; j++) {
|
|
const char = rows[i][j];
|
|
|
|
if (char === '"') {
|
|
inQuote = !inQuote;
|
|
} else if (char === ',' && !inQuote) {
|
|
row.push(currentValue.replace(/"/g, ''));
|
|
currentValue = '';
|
|
} else {
|
|
currentValue += char;
|
|
}
|
|
}
|
|
|
|
// Push the last value
|
|
row.push(currentValue.replace(/"/g, ''));
|
|
|
|
// Create an object from headers and row values
|
|
const obj = {};
|
|
for (let j = 0; j < headers.length; j++) {
|
|
obj[headers[j]] = row[j];
|
|
}
|
|
|
|
result.push(obj);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Function to transform data into a sunburst format
|
|
function transformToSunburst(data) {
|
|
// Calculate total spending
|
|
let totalSpending = 0;
|
|
|
|
// Group by categories
|
|
const categories = [];
|
|
const categoryMap = {};
|
|
|
|
// Store raw transactions for each microcategory to display in details
|
|
const transactionMap = {};
|
|
|
|
// Predefined colors for categories
|
|
const colors = [
|
|
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
|
'#2d8041', '#fc8452', '#7b4d90', '#ea7ccc', '#4cae72',
|
|
'#d56358', '#82b1ff', '#f19143', '#addf84', '#6f7787'
|
|
];
|
|
|
|
// Debug counter
|
|
let lekarstvaCount = 0;
|
|
let emptyMicroCount = 0;
|
|
|
|
data.forEach(item => {
|
|
const category = item.category || '';
|
|
const subcategory = item.subcategory || '';
|
|
// Get original microcategory value - important for transaction filtering later
|
|
const originalMicrocategory = item.microcategory || '';
|
|
const amount = Math.abs(parseFloat(item.amount_rub));
|
|
|
|
// For display in the chart, we might use simple_name for transactions >=1000RUB
|
|
let displayMicrocategory = originalMicrocategory;
|
|
if (displayMicrocategory === '' && amount >= 1000 && item.simple_name) {
|
|
displayMicrocategory = item.simple_name;
|
|
}
|
|
|
|
// Debug Лекарства category
|
|
/*if (subcategory === "Лекарства") {
|
|
lekarstvaCount++;
|
|
console.log(`Transaction in Лекарства: ${item.simple_name}, originalMicro="${originalMicrocategory}", displayMicro="${displayMicrocategory}", amount=${amount}`);
|
|
if (originalMicrocategory === '') {
|
|
emptyMicroCount++;
|
|
}
|
|
}*/
|
|
|
|
const transactionKey = `${category}|${subcategory}|${displayMicrocategory}`;
|
|
|
|
if (!isNaN(amount)) {
|
|
totalSpending += amount;
|
|
|
|
// Create transaction object and include displayMicrocategory property
|
|
const transactionObj = {
|
|
name: item.simple_name || 'Transaction',
|
|
value: amount,
|
|
date: item.date,
|
|
microCategory: originalMicrocategory, // Store ORIGINAL value
|
|
hasNoMicroCategory: originalMicrocategory === '', // Flag for easy filtering (legacy)
|
|
displayMicrocategory: displayMicrocategory,
|
|
originalRow: item // Store full CSV row for modal display
|
|
};
|
|
|
|
// Save transaction data for detail box with ORIGINAL microcategory
|
|
if (!transactionMap[transactionKey]) {
|
|
transactionMap[transactionKey] = [];
|
|
}
|
|
transactionMap[transactionKey].push(transactionObj);
|
|
|
|
if (!categoryMap[category] && category !== '') {
|
|
categoryMap[category] = {
|
|
name: category,
|
|
value: 0,
|
|
children: {},
|
|
itemStyle: {},
|
|
transactions: [] // Will be populated with ALL transactions
|
|
};
|
|
categories.push(categoryMap[category]);
|
|
}
|
|
|
|
if (category !== '') {
|
|
if (!categoryMap[category].children[subcategory] && subcategory !== '') {
|
|
categoryMap[category].children[subcategory] = {
|
|
name: subcategory,
|
|
value: 0,
|
|
children: {},
|
|
itemStyle: {},
|
|
transactions: [] // Will be populated with ALL transactions
|
|
};
|
|
}
|
|
|
|
// Add transaction to category directly
|
|
if (categoryMap[category].transactions) {
|
|
categoryMap[category].transactions.push(transactionObj);
|
|
}
|
|
|
|
if (subcategory !== '') {
|
|
// Add transaction to subcategory directly
|
|
if (categoryMap[category].children[subcategory].transactions) {
|
|
categoryMap[category].children[subcategory].transactions.push(transactionObj);
|
|
}
|
|
|
|
if (!categoryMap[category].children[subcategory].children[displayMicrocategory] && displayMicrocategory !== '') {
|
|
categoryMap[category].children[subcategory].children[displayMicrocategory] = {
|
|
name: displayMicrocategory,
|
|
value: 0,
|
|
itemStyle: {},
|
|
transactions: []
|
|
};
|
|
}
|
|
|
|
// Add transaction to microcategory if there is one
|
|
if (displayMicrocategory !== '' &&
|
|
categoryMap[category].children[subcategory].children[displayMicrocategory].transactions) {
|
|
categoryMap[category].children[subcategory].children[displayMicrocategory].transactions.push(transactionObj);
|
|
}
|
|
|
|
categoryMap[category].value += amount;
|
|
categoryMap[category].children[subcategory].value += amount;
|
|
|
|
if (displayMicrocategory !== '') {
|
|
categoryMap[category].children[subcategory].children[displayMicrocategory].value += amount;
|
|
}
|
|
} else {
|
|
categoryMap[category].value += amount;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log(`Found ${lekarstvaCount} transactions in Лекарства category, ${emptyMicroCount} with empty microcategory`);
|
|
|
|
// Define fixed order for top categories
|
|
const categoryOrder = [
|
|
'Квартира',
|
|
'Еда',
|
|
'Технологии',
|
|
'Развлечения',
|
|
'Семьи',
|
|
'Здоровье',
|
|
'Логистика',
|
|
'Расходники',
|
|
'Красота'
|
|
];
|
|
|
|
// Sort categories by the predefined order
|
|
categories.sort((a, b) => {
|
|
const indexA = categoryOrder.indexOf(a.name);
|
|
const indexB = categoryOrder.indexOf(b.name);
|
|
|
|
// If both categories are in our predefined list, sort by that order
|
|
if (indexA !== -1 && indexB !== -1) {
|
|
return indexA - indexB;
|
|
}
|
|
// If only a is in the list, it comes first
|
|
else if (indexA !== -1) {
|
|
return -1;
|
|
}
|
|
// If only b is in the list, it comes first
|
|
else if (indexB !== -1) {
|
|
return 1;
|
|
}
|
|
// For categories not in our list, sort by value (largest to smallest)
|
|
else {
|
|
return b.value - a.value;
|
|
}
|
|
});
|
|
|
|
// Convert the map to an array structure for ECharts
|
|
const result = [];
|
|
|
|
// Assign colors to categories
|
|
categories.forEach((category, index) => {
|
|
const colorIndex = index % colors.length;
|
|
const baseColor = colors[colorIndex];
|
|
const categoryNode = {
|
|
name: category.name,
|
|
value: category.value,
|
|
children: [],
|
|
itemStyle: {
|
|
color: baseColor
|
|
},
|
|
transactions: category.transactions
|
|
};
|
|
|
|
// Get subcategories and sort by value
|
|
const subcategories = [];
|
|
for (const subcatKey in category.children) {
|
|
subcategories.push(category.children[subcatKey]);
|
|
}
|
|
subcategories.sort((a, b) => b.value - a.value);
|
|
|
|
// Generate color variations for subcategories based on their size
|
|
const subcatColors = generateColorGradient(baseColor, subcategories.length || 1);
|
|
|
|
// Process each subcategory
|
|
subcategories.forEach((subcategory, subIndex) => {
|
|
// Adjust subcategory color based on its relative size within category
|
|
const subcatColor = subcatColors[subIndex];
|
|
|
|
const subcategoryNode = {
|
|
name: subcategory.name,
|
|
value: subcategory.value,
|
|
children: [],
|
|
itemStyle: {
|
|
color: subcatColor
|
|
},
|
|
transactions: subcategory.transactions
|
|
};
|
|
|
|
// Get microcategories and sort by value
|
|
const microcategories = [];
|
|
for (const microKey in subcategory.children) {
|
|
microcategories.push(subcategory.children[microKey]);
|
|
}
|
|
microcategories.sort((a, b) => b.value - a.value);
|
|
|
|
// Generate color variations for microcategories based on their size
|
|
const microColors = generateColorGradient(subcatColor, microcategories.length || 1);
|
|
|
|
// Add microcategories to subcategory
|
|
microcategories.forEach((micro, microIndex) => {
|
|
subcategoryNode.children.push({
|
|
name: micro.name,
|
|
value: micro.value,
|
|
itemStyle: {
|
|
color: microColors[microIndex]
|
|
},
|
|
transactions: micro.transactions
|
|
});
|
|
});
|
|
|
|
if (subcategoryNode.children.length > 0) {
|
|
categoryNode.children.push(subcategoryNode);
|
|
} else {
|
|
categoryNode.children.push({
|
|
name: subcategory.name,
|
|
value: subcategory.value,
|
|
itemStyle: {
|
|
color: subcatColor
|
|
},
|
|
transactions: subcategory.transactions
|
|
});
|
|
}
|
|
});
|
|
|
|
result.push(categoryNode);
|
|
});
|
|
|
|
return {
|
|
total: totalSpending,
|
|
data: result
|
|
};
|
|
}
|
|
|
|
// Function to get Russian month name from YYYY-MM format
|
|
function getRussianMonthName(dateStr) {
|
|
const monthNum = parseInt(dateStr.split('-')[1]);
|
|
const russianMonths = [
|
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
|
];
|
|
return russianMonths[monthNum - 1];
|
|
}
|
|
|
|
// Function to render the chart
|
|
function renderChart(data) {
|
|
const sunburstData = transformToSunburst(data);
|
|
|
|
// Store the original data for resetting (module-level variable)
|
|
originalSunburstData = JSON.parse(JSON.stringify(sunburstData));
|
|
|
|
// Reset and initialize history with the root state
|
|
resetHistory();
|
|
saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
|
|
|
|
// Get the currently selected month
|
|
const selectedMonth = document.getElementById('month-select').value;
|
|
const russianMonth = getRussianMonthName(selectedMonth);
|
|
|
|
// Calculate the correct center position first
|
|
const screenWidth = window.innerWidth;
|
|
let centerPosition;
|
|
|
|
if (screenWidth >= 1000) {
|
|
// For screens 1000px and wider, keep centered at 50%
|
|
centerPosition = 50;
|
|
} else if (screenWidth >= 640) {
|
|
// Gradual transition between 640-1000px
|
|
const transitionProgress = (screenWidth - 640) / 360; // 0 to 1
|
|
centerPosition = 40 + (transitionProgress * 10); // 40 to 50
|
|
} else {
|
|
// For smaller screens
|
|
centerPosition = 40;
|
|
}
|
|
|
|
option = {
|
|
backgroundColor: '#fff',
|
|
grid: {
|
|
left: '10%',
|
|
containLabel: true
|
|
},
|
|
animation: true,
|
|
//animationThreshold: 2000,
|
|
//animationDuration: 1000,
|
|
//animationEasing: 'cubicOut',
|
|
//animationDurationUpdate: 500,
|
|
//animationEasingUpdate: 'cubicInOut',
|
|
series: {
|
|
type: 'sunburst',
|
|
radius: [0, '95%'],
|
|
center: [`${centerPosition}%`, '50%'],
|
|
startAngle: 0,
|
|
nodeClick: false,
|
|
data: sunburstData.data,
|
|
sort: null, // Use 'null' to maintain the sorting we did in the data transformation
|
|
label: {
|
|
show: true,
|
|
formatter: function(param) {
|
|
if (param.depth === 0) {
|
|
// No word wrapping for top-level categories
|
|
return param.name;
|
|
} else {
|
|
return '';
|
|
}
|
|
},
|
|
minAngle: 5,
|
|
align: 'center',
|
|
verticalAlign: 'middle',
|
|
position: 'inside'
|
|
},
|
|
itemStyle: {
|
|
borderWidth: 1,
|
|
borderColor: '#fff'
|
|
},
|
|
levels: [
|
|
{},
|
|
{
|
|
// First level - Categories
|
|
r0: '20%',
|
|
r: '45%',
|
|
label: {
|
|
show: true,
|
|
rotate: 'radial',
|
|
fontSize: 13,
|
|
lineHeight: 15,
|
|
verticalAlign: 'center',
|
|
position: 'inside',
|
|
formatter: function(param) {
|
|
// No special formatting for level 1
|
|
return param.name;
|
|
}
|
|
},
|
|
itemStyle: {
|
|
borderWidth: 2
|
|
}
|
|
},
|
|
{
|
|
// Second level - Subcategories
|
|
r0: '45%',
|
|
r: '70%',
|
|
label: {
|
|
show: function(param) {
|
|
// Show labels for sectors that are at least 5% of the total
|
|
return param.percent >= 0.05;
|
|
},
|
|
fontSize: 11,
|
|
align: 'center',
|
|
position: 'inside',
|
|
distance: 5,
|
|
|
|
formatter: function(param) {
|
|
// If there's only one word, never wrap it
|
|
if (!param.name.includes(' ')) {
|
|
return param.name;
|
|
}
|
|
|
|
// If the text contains spaces, consider word wrapping for better visibility
|
|
const words = param.name.split(' ');
|
|
|
|
// Skip wrapping for single words or very small sectors
|
|
// Estimate sector size from value percentage
|
|
if (words.length === 1 || param.percent < 0.03) {
|
|
return param.name;
|
|
}
|
|
|
|
// Process words to keep short prepositions (< 4 chars) with the next word
|
|
const processedWords = [];
|
|
let i = 0;
|
|
while (i < words.length) {
|
|
if (i < words.length - 1 && words[i].length < 4) {
|
|
// Combine short word with the next word
|
|
processedWords.push(words[i] + ' ' + words[i+1]);
|
|
i += 2;
|
|
} else {
|
|
processedWords.push(words[i]);
|
|
i++;
|
|
}
|
|
}
|
|
|
|
// Skip wrapping if we're down to just one processed word
|
|
if (processedWords.length === 1) {
|
|
return processedWords[0];
|
|
}
|
|
|
|
// If only 2 processed words, put one on each line
|
|
if (processedWords.length == 2) {
|
|
return processedWords[0] + '\n' + processedWords[1];
|
|
}
|
|
// If 3 processed words, put each word on its own line
|
|
else if (processedWords.length == 3) {
|
|
return processedWords[0] + '\n' + processedWords[1] + '\n' + processedWords[2];
|
|
}
|
|
// For more words, split more aggressively
|
|
else if (processedWords.length > 3) {
|
|
// Try to create 3 relatively even lines
|
|
const part1 = Math.floor(processedWords.length / 3);
|
|
const part2 = Math.floor(processedWords.length * 2 / 3);
|
|
|
|
return processedWords.slice(0, part1).join(' ') + '\n' +
|
|
processedWords.slice(part1, part2).join(' ') + '\n' +
|
|
processedWords.slice(part2).join(' ');
|
|
}
|
|
|
|
return param.name;
|
|
}
|
|
},
|
|
itemStyle: {
|
|
borderWidth: 1
|
|
},
|
|
emphasis: {
|
|
label: {
|
|
show: true,
|
|
distance: 20
|
|
}
|
|
}
|
|
},
|
|
{
|
|
// Third level - Microcategories - a bit wider than before
|
|
r0: '70%',
|
|
r: '75%',
|
|
label: {
|
|
// Only show labels conditionally based on segment size
|
|
show: function(param) {
|
|
// Show label if segment is wide enough (>1%)
|
|
return param.percent > 0.000;
|
|
},
|
|
position: 'outside',
|
|
padding: 3,
|
|
minAngle: 3, // Add this - default is 5, reducing it will show more labels
|
|
silent: false,
|
|
fontSize: 10,
|
|
formatter: function(param) {
|
|
// If there's only one word, never wrap it
|
|
if (!param.name.includes(' ')) {
|
|
return param.name;
|
|
}
|
|
|
|
// If the text contains spaces, consider word wrapping for better visibility
|
|
const words = param.name.split(' ');
|
|
|
|
// Skip wrapping for single words or very small sectors
|
|
// Estimate sector size from value percentage
|
|
if (words.length === 1 || param.percent < 0.02) {
|
|
return param.name;
|
|
}
|
|
|
|
// Process words to keep short prepositions (< 4 chars) with the next word
|
|
const processedWords = [];
|
|
let i = 0;
|
|
while (i < words.length) {
|
|
if (i < words.length - 1 && words[i].length < 4) {
|
|
// Combine short word with the next word
|
|
processedWords.push(words[i] + ' ' + words[i+1]);
|
|
i += 2;
|
|
} else {
|
|
processedWords.push(words[i]);
|
|
i++;
|
|
}
|
|
}
|
|
|
|
// Skip wrapping if we're down to just one processed word
|
|
if (processedWords.length === 1) {
|
|
return processedWords[0];
|
|
}
|
|
|
|
// If only 2 processed words, put one on each line
|
|
if (processedWords.length == 2) {
|
|
return processedWords[0] + '\n' + processedWords[1];
|
|
}
|
|
// If 3 processed words, put each word on its own line
|
|
else if (processedWords.length == 3) {
|
|
return processedWords[0] + '\n' + processedWords[1] + '\n' + processedWords[2];
|
|
}
|
|
// For more words, split more aggressively
|
|
else if (processedWords.length > 3) {
|
|
// Try to create 3 relatively even lines
|
|
const part1 = Math.floor(processedWords.length / 3);
|
|
const part2 = Math.floor(processedWords.length * 2 / 3);
|
|
|
|
return processedWords.slice(0, part1).join(' ') + '\n' +
|
|
processedWords.slice(part1, part2).join(' ') + '\n' +
|
|
processedWords.slice(part2).join(' ');
|
|
}
|
|
|
|
return param.name;
|
|
}
|
|
},
|
|
itemStyle: {
|
|
borderWidth: 3
|
|
}
|
|
}
|
|
],
|
|
emphasis: {
|
|
focus: 'relative'
|
|
},
|
|
// Add more space between wedges
|
|
gap: 2,
|
|
tooltip: {
|
|
trigger: 'item',
|
|
formatter: function(info) {
|
|
const value = info.value.toLocaleString();
|
|
const name = info.name;
|
|
|
|
// Calculate percentage of total
|
|
const percentage = ((info.value / sunburstData.total) * 100).toFixed(1);
|
|
|
|
return `${name}<br/>Amount: ${value} RUB<br/>Percentage: ${percentage}%`;
|
|
}
|
|
}
|
|
},
|
|
graphic: {
|
|
elements: [
|
|
{
|
|
type: 'text',
|
|
left: 'center',
|
|
top: 'middle',
|
|
z: 100,
|
|
style: {
|
|
text: russianMonth + '\n' + sunburstData.total.toFixed(0).toLocaleString() + ' ₽',
|
|
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
|
fontWeight: 'bold',
|
|
fontSize: 18,
|
|
textAlign: 'left',
|
|
fill: '#000'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
// 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
|
|
const newData = [];
|
|
|
|
// Handle different cases based on the type of node clicked
|
|
if (params.data.children && params.data.children.length > 0) {
|
|
// Case 1: Node has children (category or subcategory)
|
|
const sortedChildren = sortBySubcategoryOrder(params.data.children, params.name);
|
|
|
|
// Process each child
|
|
sortedChildren.forEach((child, i) => {
|
|
const color = getSubcategoryColor(params.name, child.name, i);
|
|
const newCategory = {
|
|
name: child.name,
|
|
value: child.value,
|
|
transactions: child.transactions, // Preserve for modal
|
|
itemStyle: {
|
|
color: color
|
|
},
|
|
children: []
|
|
};
|
|
|
|
// If child has children (microcategories), they become subcategories
|
|
if (child.children && child.children.length > 0) {
|
|
const sortedMicros = [...child.children].sort((a, b) => b.value - a.value);
|
|
const microColors = generateColorGradient(color, sortedMicros.length);
|
|
|
|
sortedMicros.forEach((micro, j) => {
|
|
const microCategory = {
|
|
name: micro.name,
|
|
value: micro.value,
|
|
transactions: micro.transactions, // Preserve for modal
|
|
itemStyle: {
|
|
color: microColors[j]
|
|
},
|
|
children: [] // Will hold transactions
|
|
};
|
|
|
|
// If micro has transactions, add them as children
|
|
if (micro.transactions && micro.transactions.length > 0) {
|
|
const sortedTransactions = [...micro.transactions].sort((a, b) => b.value - a.value);
|
|
const transactionColors = generateColorGradient(microColors[j], sortedTransactions.length);
|
|
|
|
sortedTransactions.forEach((transaction, k) => {
|
|
microCategory.children.push({
|
|
name: transaction.name,
|
|
value: transaction.value,
|
|
itemStyle: {
|
|
color: transactionColors[k]
|
|
},
|
|
originalRow: transaction.originalRow
|
|
});
|
|
});
|
|
}
|
|
|
|
newCategory.children.push(microCategory);
|
|
});
|
|
}
|
|
|
|
// Add transactions without microcategory as subcategories
|
|
if (child.transactions) {
|
|
const transactionsWithoutMicro = child.transactions.filter(t => t.displayMicrocategory === '');
|
|
if (transactionsWithoutMicro.length > 0) {
|
|
// Group similar transactions
|
|
const transactionGroups = {};
|
|
transactionsWithoutMicro.forEach(t => {
|
|
if (!transactionGroups[t.name]) {
|
|
transactionGroups[t.name] = {
|
|
name: t.name,
|
|
value: 0,
|
|
transactions: []
|
|
};
|
|
}
|
|
transactionGroups[t.name].value += t.value;
|
|
transactionGroups[t.name].transactions.push(t);
|
|
});
|
|
|
|
// Add transaction groups as subcategories
|
|
const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value);
|
|
const transactionColors = generateColorGradient(color, groups.length);
|
|
|
|
groups.forEach((group, j) => {
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
|
|
newData.push(newCategory);
|
|
});
|
|
} else if (params.data.transactions && params.data.transactions.length > 0) {
|
|
// Case 2: Node has transactions but no children (microcategory)
|
|
// Group transactions by name
|
|
const transactionGroups = {};
|
|
params.data.transactions.forEach(t => {
|
|
if (!transactionGroups[t.name]) {
|
|
transactionGroups[t.name] = {
|
|
name: t.name,
|
|
value: 0,
|
|
transactions: []
|
|
};
|
|
}
|
|
transactionGroups[t.name].value += t.value;
|
|
transactionGroups[t.name].transactions.push(t);
|
|
});
|
|
|
|
// Create categories from transaction groups
|
|
const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value);
|
|
groups.forEach((group, i) => {
|
|
const color = colorPalette[i % colorPalette.length];
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Build the new path by appending the clicked category
|
|
const newPath = [...currentDrillPath, params.name];
|
|
saveToHistory(newData, params.value, params.name, false, newPath);
|
|
|
|
// Update the chart with the new data structure
|
|
option.series.data = newData;
|
|
|
|
// Update the center text to show the drilled-down category
|
|
const russianMonth = getRussianMonthName(document.getElementById('month-select').value);
|
|
option.graphic.elements[0].style.text = `${russianMonth}\n${params.name}\n${params.value.toFixed(0).toLocaleString()} ₽`;
|
|
|
|
myChart.setOption(option, { replaceMerge: ['series'] });
|
|
|
|
// Update hover events with the new data structure, passing the drilled-down name
|
|
setupHoverEvents({ total: params.value, data: newData }, params.name);
|
|
|
|
// Update mini-chart previews to reflect drill-down
|
|
updateAllMonthPreviews();
|
|
}
|
|
});
|
|
|
|
myChart.setOption(option);
|
|
|
|
// Add click handler for the center to go back in history
|
|
const zr = myChart.getZr();
|
|
zr.on('click', function(params) {
|
|
const x = params.offsetX;
|
|
const y = params.offsetY;
|
|
|
|
// Calculate center and inner radius
|
|
const chartWidth = myChart.getWidth();
|
|
const chartHeight = myChart.getHeight();
|
|
const centerX = chartWidth * (parseFloat(option.series.center[0]) / 100);
|
|
const centerY = chartHeight * (parseFloat(option.series.center[1]) / 100);
|
|
const innerRadius = Math.min(chartWidth, chartHeight) * 0.2; // 20% of chart size
|
|
|
|
// Check if click is within the center circle
|
|
const distance = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2));
|
|
if (distance < innerRadius && historyIndex > 0) {
|
|
history.back(); // Use browser history - triggers popstate
|
|
}
|
|
});
|
|
|
|
// Set up hover events for the details box
|
|
setupHoverEvents(sunburstData);
|
|
|
|
// Ensure chart is properly sized after rendering
|
|
adjustChartSize();
|
|
myChart.resize();
|
|
}
|
|
|
|
// Function to generate a color gradient
|
|
function generateColorGradient(baseColor, steps) {
|
|
const result = [];
|
|
const base = tinycolor(baseColor);
|
|
|
|
// Get the base hue value (0-360)
|
|
const baseHue = base.toHsl().h;
|
|
|
|
// Create a more dramatic gradient based on size
|
|
for (let i = 0; i < steps; i++) {
|
|
// Calculate percentage position in the sequence (0 to 1)
|
|
const position = i / (steps - 1 || 1);
|
|
|
|
let color = base.clone();
|
|
|
|
// Modify hue - shift around the color wheel based on size
|
|
// Smaller items (position closer to 0): shift hue towards cooler colors (-30 degrees)
|
|
// Larger items (position closer to 1): shift hue towards warmer colors (+30 degrees)
|
|
const hueShift = 15-(position*15); // Ranges from -30 to +30
|
|
|
|
// Apply HSL transformations
|
|
const hsl = color.toHsl();
|
|
//hsl.h = (baseHue + hueShift) % 360; // Keep hue within 0-360 range
|
|
|
|
// Also adjust saturation and lightness for even more distinction
|
|
/* if (position < 0.5) {
|
|
// Smaller items: more saturated, darker
|
|
hsl.s = Math.min(1, hsl.s * (1.3 - position * 0.6)); // Increase saturation up to 30%
|
|
hsl.l = Math.max(0.2, hsl.l * (0.85 + position * 0.3)); // Slightly darker
|
|
} else {
|
|
// Larger items: slightly less saturated, brighter
|
|
hsl.s = Math.min(1, hsl.s * (0.9 + position * 0.2)); // Slightly reduce saturation
|
|
hsl.l = Math.min(0.9, hsl.l * (1.1 + (position - 0.5) * 0.2)); // Brighter
|
|
}*/
|
|
|
|
result.push(tinycolor(hsl).toString());
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Load TinyColor library for color manipulation
|
|
function loadTinyColor() {
|
|
return new Promise((resolve, reject) => {
|
|
if (window.tinycolor) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/tinycolor/1.4.2/tinycolor.min.js';
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
// Global state for month navigation
|
|
let availableMonths = [];
|
|
let currentMonthIndex = 0;
|
|
let monthDataCache = {}; // Cache for month data previews
|
|
|
|
// Predefined colors for categories (same as in transformToSunburst)
|
|
const categoryColors = [
|
|
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
|
'#2d8041', '#fc8452', '#7b4d90', '#ea7ccc', '#4cae72',
|
|
'#d56358', '#82b1ff', '#f19143', '#addf84', '#6f7787'
|
|
];
|
|
|
|
// Fixed order for categories
|
|
const categoryOrder = [
|
|
'Квартира', 'Еда', 'Технологии', 'Развлечения', 'Семьи',
|
|
'Здоровье', 'Логистика', 'Расходники', 'Красота'
|
|
];
|
|
|
|
// Fixed subcategory colors based on December 2025 data (sorted by value)
|
|
// This ensures consistent colors across all months
|
|
const subcategoryColors = {
|
|
'Квартира': {
|
|
'ЖКХ': '#5470c6',
|
|
'Лев Петрович': '#91cc75'
|
|
},
|
|
'Еда': {
|
|
'Продукты': '#5470c6',
|
|
'Готовая': '#91cc75'
|
|
},
|
|
'Технологии': {
|
|
'Инфраструктура домашнего интернета': '#5470c6',
|
|
'Умный дом': '#91cc75',
|
|
'AI': '#fac858',
|
|
'Мобильная связь': '#ee6666'
|
|
},
|
|
'Развлечения': {
|
|
'Подарки': '#5470c6',
|
|
'Хобби Антона': '#91cc75',
|
|
'Хобби Залины': '#fac858',
|
|
'Подписки': '#ee6666',
|
|
'Чтение': '#73c0de',
|
|
'Работа Залины': '#3ba272',
|
|
'Девайсы': '#fc8452'
|
|
},
|
|
'Семьи': {
|
|
'Залина': '#5470c6',
|
|
'Антон': '#91cc75'
|
|
},
|
|
'Здоровье': {
|
|
'Терапия': '#5470c6',
|
|
'Лекарства': '#91cc75',
|
|
'Спортивное питание': '#fac858'
|
|
},
|
|
'Логистика': {
|
|
'Такси': '#5470c6',
|
|
'Доставка': '#91cc75',
|
|
'Чаевые': '#fac858'
|
|
},
|
|
'Расходники': {
|
|
'Замена разных фильтров': '#5470c6',
|
|
'Санитарное': '#91cc75',
|
|
'Батарейки': '#fac858',
|
|
'Мелкий ремонт': '#ee6666',
|
|
'Средства для посудомоек': '#73c0de'
|
|
},
|
|
'Красота': {
|
|
'Одежда': '#5470c6',
|
|
'Салоны красоты': '#91cc75',
|
|
'Кремы': '#fac858'
|
|
}
|
|
};
|
|
|
|
// Color palette for items not in the predefined mapping
|
|
const defaultColorPalette = [
|
|
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
|
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
|
|
];
|
|
|
|
// Fixed subcategory order derived from subcategoryColors (December 2025 order)
|
|
const subcategoryOrder = {};
|
|
for (const category in subcategoryColors) {
|
|
subcategoryOrder[category] = Object.keys(subcategoryColors[category]);
|
|
}
|
|
|
|
// Get color for a subcategory, using fixed mapping or fallback to index-based
|
|
function getSubcategoryColor(category, subcategory, index) {
|
|
if (subcategoryColors[category] && subcategoryColors[category][subcategory]) {
|
|
return subcategoryColors[category][subcategory];
|
|
}
|
|
return defaultColorPalette[index % defaultColorPalette.length];
|
|
}
|
|
|
|
// Sort items by fixed subcategory order, with unknown items at the end sorted by value
|
|
function sortBySubcategoryOrder(items, parentCategory, nameGetter = (item) => item.name) {
|
|
const order = subcategoryOrder[parentCategory] || [];
|
|
return [...items].sort((a, b) => {
|
|
const aName = nameGetter(a);
|
|
const bName = nameGetter(b);
|
|
const aIndex = order.indexOf(aName);
|
|
const bIndex = order.indexOf(bName);
|
|
|
|
// Both in predefined order - sort by that order
|
|
if (aIndex !== -1 && bIndex !== -1) {
|
|
return aIndex - bIndex;
|
|
}
|
|
// Only a is in predefined order - a comes first
|
|
if (aIndex !== -1) return -1;
|
|
// Only b is in predefined order - b comes first
|
|
if (bIndex !== -1) return 1;
|
|
// Neither in predefined order - sort by value descending
|
|
const aValue = typeof a === 'object' ? (a.value || 0) : 0;
|
|
const bValue = typeof b === 'object' ? (b.value || 0) : 0;
|
|
return bValue - aValue;
|
|
});
|
|
}
|
|
|
|
// Generate conic-gradient CSS for a month's category breakdown
|
|
function generateMonthPreviewGradient(data) {
|
|
// Group by category and sum amounts
|
|
const categoryTotals = {};
|
|
let total = 0;
|
|
|
|
data.forEach(item => {
|
|
const category = item.category || '';
|
|
const amount = Math.abs(parseFloat(item.amount_rub));
|
|
if (!isNaN(amount) && category) {
|
|
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
|
|
total += amount;
|
|
}
|
|
});
|
|
|
|
if (total === 0) return 'conic-gradient(from 90deg, #eee 0deg 360deg)';
|
|
|
|
// Sort categories by predefined order
|
|
const sortedCategories = Object.keys(categoryTotals).sort((a, b) => {
|
|
const indexA = categoryOrder.indexOf(a);
|
|
const indexB = categoryOrder.indexOf(b);
|
|
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
|
if (indexA !== -1) return -1;
|
|
if (indexB !== -1) return 1;
|
|
return categoryTotals[b] - categoryTotals[a];
|
|
});
|
|
|
|
// Build conic-gradient
|
|
const gradientStops = [];
|
|
let currentAngle = 0;
|
|
|
|
sortedCategories.forEach((category, index) => {
|
|
const percentage = categoryTotals[category] / total;
|
|
const angle = percentage * 360;
|
|
const colorIndex = categoryOrder.indexOf(category);
|
|
const color = colorIndex !== -1 ? categoryColors[colorIndex] : categoryColors[index % categoryColors.length];
|
|
|
|
gradientStops.push(`${color} ${currentAngle}deg ${currentAngle + angle}deg`);
|
|
currentAngle += angle;
|
|
});
|
|
|
|
return `conic-gradient(from 90deg, ${gradientStops.join(', ')})`;
|
|
}
|
|
|
|
// Find category data at a given path in month's transaction data
|
|
function findCategoryDataAtPath(monthData, path) {
|
|
if (!path || path.length === 0) {
|
|
return null; // No drill-down, use full data
|
|
}
|
|
|
|
// Group transactions by the hierarchy at this path level
|
|
const categoryName = path[0];
|
|
|
|
// Filter transactions belonging to this category
|
|
const filteredTransactions = monthData.filter(item => item.category === categoryName);
|
|
|
|
if (filteredTransactions.length === 0) {
|
|
return { empty: true }; // Category doesn't exist in this month
|
|
}
|
|
|
|
if (path.length === 1) {
|
|
// Return subcategory breakdown
|
|
return { transactions: filteredTransactions, level: 'subcategory' };
|
|
}
|
|
|
|
// Path length >= 2, filter by subcategory
|
|
const subcategoryName = path[1];
|
|
const subFiltered = filteredTransactions.filter(item => item.subcategory === subcategoryName);
|
|
|
|
if (subFiltered.length === 0) {
|
|
return { empty: true };
|
|
}
|
|
|
|
if (path.length === 2) {
|
|
// Return microcategory breakdown
|
|
return { transactions: subFiltered, level: 'microcategory' };
|
|
}
|
|
|
|
// Path length >= 3, filter by microcategory
|
|
const microcategoryName = path[2];
|
|
const microFiltered = subFiltered.filter(item => item.microcategory === microcategoryName);
|
|
|
|
if (microFiltered.length === 0) {
|
|
return { empty: true };
|
|
}
|
|
|
|
// Return transaction-level breakdown
|
|
return { transactions: microFiltered, level: 'transaction' };
|
|
}
|
|
|
|
// Get the base color for a drill-down path
|
|
function getPathBaseColor(path) {
|
|
if (!path || path.length === 0) {
|
|
return categoryColors[0];
|
|
}
|
|
|
|
const categoryName = path[0];
|
|
const categoryIndex = categoryOrder.indexOf(categoryName);
|
|
|
|
if (categoryIndex !== -1) {
|
|
return categoryColors[categoryIndex];
|
|
}
|
|
|
|
return categoryColors[0];
|
|
}
|
|
|
|
// Generate gradient for a month at the current drill-down path
|
|
function generateDrilledDownGradient(monthData, path) {
|
|
const result = findCategoryDataAtPath(monthData, path);
|
|
|
|
// No drill-down - use original function
|
|
if (!result) {
|
|
return generateMonthPreviewGradient(monthData);
|
|
}
|
|
|
|
// Empty state - category doesn't exist
|
|
if (result.empty) {
|
|
return 'conic-gradient(from 90deg, #e0e0e0 0deg 360deg)';
|
|
}
|
|
|
|
const { transactions, level } = result;
|
|
|
|
// Group by the appropriate field based on level
|
|
const groupField = level === 'subcategory' ? 'subcategory'
|
|
: level === 'microcategory' ? 'microcategory'
|
|
: 'simple_name';
|
|
|
|
const totals = {};
|
|
let total = 0;
|
|
|
|
transactions.forEach(item => {
|
|
const key = item[groupField] || '(без категории)';
|
|
const amount = Math.abs(parseFloat(item.amount_rub));
|
|
if (!isNaN(amount)) {
|
|
totals[key] = (totals[key] || 0) + amount;
|
|
total += amount;
|
|
}
|
|
});
|
|
|
|
if (total === 0) {
|
|
return 'conic-gradient(from 90deg, #e0e0e0 0deg 360deg)';
|
|
}
|
|
|
|
// Get the parent category for color lookup and ordering
|
|
const parentCategory = path.length > 0 ? path[0] : null;
|
|
|
|
// Sort by fixed subcategory order (for first level), fallback to value for unknown items
|
|
const order = subcategoryOrder[parentCategory] || [];
|
|
const sortedKeys = Object.keys(totals).sort((a, b) => {
|
|
const aIndex = order.indexOf(a);
|
|
const bIndex = order.indexOf(b);
|
|
|
|
// Both in predefined order - sort by that order
|
|
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
|
// Only a is in predefined order - a comes first
|
|
if (aIndex !== -1) return -1;
|
|
// Only b is in predefined order - b comes first
|
|
if (bIndex !== -1) return 1;
|
|
// Neither in predefined order - sort by value descending
|
|
return totals[b] - totals[a];
|
|
});
|
|
|
|
const gradientStops = [];
|
|
let currentAngle = 0;
|
|
|
|
sortedKeys.forEach((key, index) => {
|
|
const percentage = totals[key] / total;
|
|
const angle = percentage * 360;
|
|
// Use fixed subcategory colors for first level, fallback to palette for deeper levels
|
|
const color = (level === 'subcategory' && parentCategory)
|
|
? getSubcategoryColor(parentCategory, key, index)
|
|
: defaultColorPalette[index % defaultColorPalette.length];
|
|
|
|
gradientStops.push(`${color} ${currentAngle}deg ${currentAngle + angle}deg`);
|
|
currentAngle += angle;
|
|
});
|
|
|
|
return `conic-gradient(from 90deg, ${gradientStops.join(', ')})`;
|
|
}
|
|
|
|
// Update all month preview gradients based on current drill path
|
|
function updateAllMonthPreviews() {
|
|
const buttons = document.querySelectorAll('.month-btn');
|
|
|
|
buttons.forEach(btn => {
|
|
const month = btn.dataset.month;
|
|
const preview = btn.querySelector('.month-preview');
|
|
|
|
if (preview && monthDataCache[month]) {
|
|
preview.style.background = generateDrilledDownGradient(monthDataCache[month], currentDrillPath);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Format month for display: "2025-01" -> "Январь'25"
|
|
function formatMonthLabel(dateStr) {
|
|
const [year, month] = dateStr.split('-');
|
|
const shortYear = year.slice(-2);
|
|
const russianMonths = [
|
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
|
];
|
|
const monthName = russianMonths[parseInt(month) - 1];
|
|
return `${monthName}'${shortYear}`;
|
|
}
|
|
|
|
// Load all available month files
|
|
async function loadAvailableMonths() {
|
|
// Load TinyColor first
|
|
await loadTinyColor();
|
|
|
|
// Fetch available months from server
|
|
const response = await fetch('/api/months');
|
|
availableMonths = await response.json();
|
|
|
|
if (availableMonths.length === 0) {
|
|
console.error('No month data files found');
|
|
return;
|
|
}
|
|
|
|
const select = document.getElementById('month-select');
|
|
const monthList = document.getElementById('month-list');
|
|
|
|
// Clear existing options
|
|
select.innerHTML = '';
|
|
monthList.innerHTML = '';
|
|
|
|
// Populate hidden select (for compatibility with renderChart)
|
|
availableMonths.forEach(month => {
|
|
const option = document.createElement('option');
|
|
option.value = month;
|
|
option.textContent = month;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Load data for all months and create buttons with previews
|
|
await Promise.all(availableMonths.map(async (month, index) => {
|
|
const data = await parseCSV(`altcats-${month}.csv`);
|
|
monthDataCache[month] = data;
|
|
}));
|
|
|
|
// Create month buttons with previews
|
|
availableMonths.forEach((month, index) => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'month-btn';
|
|
btn.dataset.month = month;
|
|
btn.dataset.index = index;
|
|
|
|
// Create preview circle
|
|
const preview = document.createElement('div');
|
|
preview.className = 'month-preview';
|
|
preview.style.background = generateMonthPreviewGradient(monthDataCache[month]);
|
|
|
|
// Create label
|
|
const label = document.createElement('span');
|
|
label.className = 'month-label';
|
|
label.textContent = formatMonthLabel(month);
|
|
|
|
btn.appendChild(preview);
|
|
btn.appendChild(label);
|
|
btn.addEventListener('click', () => selectMonth(index));
|
|
monthList.appendChild(btn);
|
|
});
|
|
|
|
// Load the most recent month by default
|
|
currentMonthIndex = availableMonths.length - 1;
|
|
await selectMonth(currentMonthIndex);
|
|
|
|
// Set up arrow button handlers
|
|
document.getElementById('prev-month').addEventListener('click', () => {
|
|
if (currentMonthIndex > 0) {
|
|
selectMonth(currentMonthIndex - 1);
|
|
}
|
|
});
|
|
|
|
document.getElementById('next-month').addEventListener('click', () => {
|
|
if (currentMonthIndex < availableMonths.length - 1) {
|
|
selectMonth(currentMonthIndex + 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Transform children data for drill-down (extracted from click handler)
|
|
function transformDrillDownData(parentNode, parentCategoryName) {
|
|
const newData = [];
|
|
const sortedChildren = sortBySubcategoryOrder(parentNode.children, parentCategoryName);
|
|
|
|
sortedChildren.forEach((child, i) => {
|
|
const color = getSubcategoryColor(parentCategoryName, child.name, i);
|
|
const newCategory = {
|
|
name: child.name,
|
|
value: child.value,
|
|
transactions: child.transactions,
|
|
itemStyle: { color: color },
|
|
children: []
|
|
};
|
|
|
|
if (child.children && child.children.length > 0) {
|
|
const sortedMicros = [...child.children].sort((a, b) => b.value - a.value);
|
|
const microColors = generateColorGradient(color, sortedMicros.length);
|
|
|
|
sortedMicros.forEach((micro, j) => {
|
|
const microCategory = {
|
|
name: micro.name,
|
|
value: micro.value,
|
|
transactions: micro.transactions,
|
|
itemStyle: { color: microColors[j] },
|
|
children: []
|
|
};
|
|
|
|
if (micro.transactions && micro.transactions.length > 0) {
|
|
const sortedTransactions = [...micro.transactions].sort((a, b) => b.value - a.value);
|
|
const transactionColors = generateColorGradient(microColors[j], sortedTransactions.length);
|
|
|
|
sortedTransactions.forEach((transaction, k) => {
|
|
microCategory.children.push({
|
|
name: transaction.name,
|
|
value: transaction.value,
|
|
itemStyle: { color: transactionColors[k] },
|
|
originalRow: transaction.originalRow
|
|
});
|
|
});
|
|
}
|
|
|
|
newCategory.children.push(microCategory);
|
|
});
|
|
}
|
|
|
|
if (child.transactions) {
|
|
const transactionsWithoutMicro = child.transactions.filter(t => t.displayMicrocategory === '');
|
|
if (transactionsWithoutMicro.length > 0) {
|
|
const transactionGroups = {};
|
|
transactionsWithoutMicro.forEach(t => {
|
|
if (!transactionGroups[t.name]) {
|
|
transactionGroups[t.name] = { name: t.name, value: 0, transactions: [] };
|
|
}
|
|
transactionGroups[t.name].value += t.value;
|
|
transactionGroups[t.name].transactions.push(t);
|
|
});
|
|
|
|
const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value);
|
|
const transactionColors = generateColorGradient(color, groups.length);
|
|
|
|
groups.forEach((group, j) => {
|
|
const transactionCategory = {
|
|
name: group.name,
|
|
value: group.value,
|
|
transactions: group.transactions,
|
|
itemStyle: { color: transactionColors[j] },
|
|
children: []
|
|
};
|
|
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
|
|
newData.push(newCategory);
|
|
});
|
|
|
|
return newData;
|
|
}
|
|
|
|
// Transform transaction-only data for drill-down
|
|
function transformTransactionData(node) {
|
|
const colorPalette = [
|
|
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
|
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
|
|
];
|
|
|
|
const transactionGroups = {};
|
|
node.transactions.forEach(t => {
|
|
if (!transactionGroups[t.name]) {
|
|
transactionGroups[t.name] = { name: t.name, value: 0, transactions: [] };
|
|
}
|
|
transactionGroups[t.name].value += t.value;
|
|
transactionGroups[t.name].transactions.push(t);
|
|
});
|
|
|
|
const newData = [];
|
|
const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value);
|
|
|
|
groups.forEach((group, i) => {
|
|
const color = colorPalette[i % colorPalette.length];
|
|
const transactionCategory = {
|
|
name: group.name,
|
|
value: group.value,
|
|
transactions: group.transactions,
|
|
itemStyle: { color: color },
|
|
children: []
|
|
};
|
|
|
|
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);
|
|
});
|
|
|
|
return newData;
|
|
}
|
|
|
|
// Navigate to a path in the given sunburst data, returns drilled data or null if path not found
|
|
function navigateToPath(sunburstData, path) {
|
|
if (!path || path.length === 0) {
|
|
return null; // Stay at root
|
|
}
|
|
|
|
// Try to find each level of the path
|
|
let currentData = sunburstData.data;
|
|
let currentTotal = sunburstData.total;
|
|
let currentName = null;
|
|
let validPath = [];
|
|
|
|
for (let i = 0; i < path.length; i++) {
|
|
const targetName = path[i];
|
|
const found = currentData.find(item => item.name === targetName);
|
|
|
|
if (!found) {
|
|
// Path level not found, return what we have so far
|
|
break;
|
|
}
|
|
|
|
validPath.push(targetName);
|
|
currentName = targetName;
|
|
currentTotal = found.value;
|
|
|
|
// Transform this level's children to become top-level (same as click handler logic)
|
|
if (found.children && found.children.length > 0) {
|
|
currentData = transformDrillDownData(found, targetName);
|
|
} else if (found.transactions && found.transactions.length > 0) {
|
|
currentData = transformTransactionData(found);
|
|
} else {
|
|
// No more levels to drill into
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (validPath.length === 0) {
|
|
return null; // Couldn't match any part of path
|
|
}
|
|
|
|
return {
|
|
data: currentData,
|
|
total: currentTotal,
|
|
contextName: currentName,
|
|
path: validPath
|
|
};
|
|
}
|
|
|
|
// Select and load a specific month
|
|
async function selectMonth(index) {
|
|
currentMonthIndex = index;
|
|
const month = availableMonths[index];
|
|
|
|
// Update hidden select for compatibility
|
|
const select = document.getElementById('month-select');
|
|
select.value = month;
|
|
|
|
// Update month button active states
|
|
updateMonthNavigator();
|
|
|
|
// Load and render data
|
|
const data = monthDataCache[month] || await parseCSV(`altcats-${month}.csv`);
|
|
|
|
// Check if chart already has data (for animation)
|
|
if (option && option.series && option.series.data) {
|
|
const sunburstData = transformToSunburst(data);
|
|
|
|
// Update the module-level original data for center-click reset
|
|
originalSunburstData = JSON.parse(JSON.stringify(sunburstData));
|
|
|
|
// Save the current drill path before modifying state
|
|
const savedPath = [...currentDrillPath];
|
|
|
|
// Reset history for the new month
|
|
resetHistory();
|
|
|
|
// Try to navigate to the same path in the new month
|
|
const navigatedState = navigateToPath(sunburstData, savedPath);
|
|
|
|
let targetData, targetTotal, targetName, targetPath;
|
|
|
|
if (navigatedState) {
|
|
// Successfully navigated to (part of) the path
|
|
targetData = navigatedState.data;
|
|
targetTotal = navigatedState.total;
|
|
targetName = navigatedState.contextName;
|
|
targetPath = navigatedState.path;
|
|
} else {
|
|
// Stay at root level
|
|
targetData = sunburstData.data;
|
|
targetTotal = sunburstData.total;
|
|
targetName = null;
|
|
targetPath = [];
|
|
}
|
|
|
|
// Save initial state with the path
|
|
saveToHistory(targetData, targetTotal, targetName, true, targetPath);
|
|
|
|
// Update the data
|
|
option.series.data = targetData;
|
|
|
|
// Update the total amount in the center text
|
|
const russianMonth = getRussianMonthName(month);
|
|
if (targetName) {
|
|
option.graphic.elements[0].style.text = `${russianMonth}\n${targetName}\n${targetTotal.toFixed(0).toLocaleString()} ₽`;
|
|
} else {
|
|
option.graphic.elements[0].style.text = russianMonth + '\n' + targetTotal.toFixed(0).toLocaleString() + ' ₽';
|
|
}
|
|
|
|
myChart.setOption({
|
|
series: [{
|
|
type: 'sunburst',
|
|
data: targetData,
|
|
layoutAnimation: true,
|
|
animationDuration: 500,
|
|
animationEasing: 'cubicInOut'
|
|
}],
|
|
graphic: option.graphic
|
|
}, {
|
|
lazyUpdate: false,
|
|
silent: false
|
|
});
|
|
|
|
// Update hover events
|
|
setupHoverEvents({ total: targetTotal, data: targetData }, targetName);
|
|
|
|
// Update mini-chart previews
|
|
updateAllMonthPreviews();
|
|
} else {
|
|
// Initial render
|
|
renderChart(data);
|
|
}
|
|
|
|
// Scroll selected month button into view
|
|
const activeBtn = document.querySelector('.month-btn.active');
|
|
if (activeBtn) {
|
|
activeBtn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
// Update month navigator UI state
|
|
function updateMonthNavigator() {
|
|
// Update button active states
|
|
const buttons = document.querySelectorAll('.month-btn');
|
|
buttons.forEach((btn, index) => {
|
|
if (index === currentMonthIndex) {
|
|
btn.classList.add('active');
|
|
} else {
|
|
btn.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// Update arrow disabled states
|
|
const prevBtn = document.getElementById('prev-month');
|
|
const nextBtn = document.getElementById('next-month');
|
|
|
|
prevBtn.disabled = currentMonthIndex === 0;
|
|
nextBtn.disabled = currentMonthIndex === availableMonths.length - 1;
|
|
}
|
|
|
|
// Initialize the visualization
|
|
async function initVisualization() {
|
|
await loadAvailableMonths();
|
|
|
|
// Ensure the chart is properly sized on initial load
|
|
myChart.resize();
|
|
}
|
|
|
|
// Start the application
|
|
initVisualization();
|
|
|
|
// Handle window resize
|
|
window.addEventListener('resize', function() {
|
|
adjustChartSize();
|
|
myChart.resize();
|
|
|
|
// Recalculate chart center and radius for hover detection will be done automatically by setupHoverEvents
|
|
});
|
|
|
|
// Function to adjust chart size based on screen width
|
|
function adjustChartSize() {
|
|
// Check if option is defined
|
|
if (!option) return;
|
|
|
|
const screenWidth = window.innerWidth;
|
|
const detailsBox = document.getElementById('details-box');
|
|
const detailsWidth = detailsBox.offsetWidth;
|
|
|
|
// Calculate center position with a smooth transition
|
|
let centerPosition;
|
|
|
|
if (screenWidth < 950)
|
|
option.series.levels[3].label.show=false;
|
|
else option.series.levels[3].label.show=true;
|
|
centerPosition = 50;
|
|
|
|
// Update chart center position
|
|
option.series.center = [`${centerPosition}%`, '50%'];
|
|
option.graphic.elements[0].left = 'center';
|
|
option.graphic.elements[0].top = 'middle';
|
|
|
|
myChart.setOption(option);
|
|
}
|
|
|
|
// Add mouseover handler to update details box
|
|
function setupHoverEvents(sunburstData, contextName = null) {
|
|
const topItemsElement = document.getElementById('top-items');
|
|
|
|
// Create a container for details header with name and amount
|
|
const detailsHeader = document.getElementById('details-header') || document.createElement('div');
|
|
detailsHeader.id = 'details-header';
|
|
detailsHeader.className = 'details-header';
|
|
|
|
if (!document.getElementById('details-header')) {
|
|
const detailsBox = document.querySelector('.details-box');
|
|
detailsBox.insertBefore(detailsHeader, detailsBox.firstChild);
|
|
}
|
|
|
|
// Variables to track the circular boundary - will be recalculated when needed
|
|
let chartCenterX, chartCenterY, chartRadius;
|
|
|
|
// Function to recalculate chart center and radius for hover detection
|
|
function recalculateChartBoundary() {
|
|
const chartCenter = option.series.center;
|
|
chartCenterX = chartDom.offsetWidth * (parseFloat(chartCenter[0]) / 100);
|
|
chartCenterY = chartDom.offsetHeight * (parseFloat(chartCenter[1]) / 100);
|
|
chartRadius = Math.min(chartDom.offsetWidth, chartDom.offsetHeight) *
|
|
(parseFloat(option.series.radius[1]) / 100);
|
|
}
|
|
|
|
// Initial calculation of chart boundary
|
|
recalculateChartBoundary();
|
|
|
|
// Update chart boundary on resize
|
|
window.addEventListener('resize', recalculateChartBoundary);
|
|
|
|
// Check if point is inside the sunburst chart circle
|
|
function isInsideChart(x, y) {
|
|
const dx = x - chartCenterX;
|
|
const dy = y - chartCenterY;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
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;
|
|
let mouseMovingTimeout = null; // Timer to detect mouse inactivity
|
|
let isMouseMoving = false; // Track if mouse is actively moving
|
|
|
|
// 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;
|
|
let pendingButtonPosition = null; // Store button position when mouse stops
|
|
|
|
// Update button visibility based on mouse movement state
|
|
function updateButtonVisibility() {
|
|
if (!chartEyeBtn) return;
|
|
|
|
// Show button if mouse is moving OR hovering over the button itself
|
|
if ((isMouseMoving || isOverEyeButton) && pendingButtonPosition) {
|
|
chartEyeBtn.style.left = pendingButtonPosition.left;
|
|
chartEyeBtn.style.top = pendingButtonPosition.top;
|
|
chartEyeBtn.style.transform = pendingButtonPosition.transform;
|
|
chartEyeBtn.style.opacity = '1';
|
|
chartEyeBtn.style.pointerEvents = 'auto';
|
|
} else if (!isOverEyeButton) {
|
|
chartEyeBtn.style.opacity = '0';
|
|
chartEyeBtn.style.pointerEvents = 'none';
|
|
}
|
|
}
|
|
|
|
// Handle mouse movement on chart - show button while moving, hide after 0.5s of inactivity
|
|
function onChartMouseMove() {
|
|
isMouseMoving = true;
|
|
updateButtonVisibility();
|
|
|
|
// Clear existing timeout
|
|
if (mouseMovingTimeout) {
|
|
clearTimeout(mouseMovingTimeout);
|
|
}
|
|
|
|
// Set timeout to hide button after 0.5 second of no movement
|
|
mouseMovingTimeout = setTimeout(() => {
|
|
isMouseMoving = false;
|
|
updateButtonVisibility();
|
|
}, 500);
|
|
}
|
|
|
|
// Add mousemove listener to chart container
|
|
chartDom.addEventListener('mousemove', onChartMouseMove);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Store the button position
|
|
pendingButtonPosition = {
|
|
left: (buttonX - 12) + 'px',
|
|
top: (buttonY - 12) + 'px',
|
|
transform: `rotate(${rotationDeg}deg)`
|
|
};
|
|
|
|
// Only show if mouse is moving or hovering over button
|
|
updateButtonVisibility();
|
|
} else {
|
|
// Fallback: clear pending position if we can't get layout
|
|
pendingButtonPosition = null;
|
|
chartEyeBtn.style.opacity = '0';
|
|
chartEyeBtn.style.pointerEvents = '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.opacity = '0';
|
|
chartEyeBtn.style.pointerEvents = '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.opacity = '0';
|
|
chartEyeBtn.style.pointerEvents = 'none';
|
|
isOverEyeButton = false;
|
|
}
|
|
});
|
|
|
|
// Track when mouse is over the button
|
|
chartEyeBtn.addEventListener('mouseenter', () => {
|
|
isOverEyeButton = true;
|
|
isInsideSection = true; // Keep details panel showing sector info
|
|
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
|
|
topItemsElement.innerHTML = '';
|
|
|
|
// If we have items to show
|
|
if (items.length > 0) {
|
|
// Create elements for each item
|
|
items.forEach(item => {
|
|
const itemDiv = document.createElement('div');
|
|
itemDiv.className = 'top-item';
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'top-item-name';
|
|
|
|
// Add colored circle
|
|
const colorCircle = document.createElement('span');
|
|
colorCircle.className = 'color-circle';
|
|
|
|
// Use the item's color if available, otherwise use a default
|
|
if (item.itemStyle && item.itemStyle.color) {
|
|
colorCircle.style.backgroundColor = item.itemStyle.color;
|
|
} else if (item.color) {
|
|
colorCircle.style.backgroundColor = item.color;
|
|
} else {
|
|
colorCircle.style.backgroundColor = '#cccccc';
|
|
}
|
|
|
|
nameSpan.appendChild(colorCircle);
|
|
nameSpan.appendChild(document.createTextNode(item.name));
|
|
itemDiv.appendChild(nameSpan);
|
|
|
|
// Add percentage after name if we have a parent value
|
|
if (parentValue) {
|
|
const percentSpan = document.createElement('span');
|
|
percentSpan.className = 'top-item-percent';
|
|
const percentage = ((item.value / parentValue) * 100).toFixed(1);
|
|
percentSpan.textContent = percentage + '%';
|
|
itemDiv.appendChild(percentSpan);
|
|
}
|
|
|
|
// 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() + ' ₽';
|
|
itemDiv.appendChild(amountSpan);
|
|
topItemsElement.appendChild(itemDiv);
|
|
});
|
|
} else {
|
|
// No items to show
|
|
topItemsElement.innerHTML = '<div>No details available</div>';
|
|
}
|
|
}
|
|
|
|
// 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>
|
|
<button class="eye-btn header-eye-btn" title="View transaction details">
|
|
<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>
|
|
</button>
|
|
<span class="hover-amount">${value.toLocaleString()} ₽</span>
|
|
`;
|
|
// Add click handler to header eye button
|
|
const headerEyeBtn = detailsHeader.querySelector('.header-eye-btn');
|
|
if (headerEyeBtn && data) {
|
|
headerEyeBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
openTransactionModal(data);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Show the default view with top categories
|
|
// Assign to global so it can be called from closeTransactionModal
|
|
showDefaultView = function() {
|
|
// Use context name (drilled-down sector) if provided, otherwise use month name
|
|
const selectedMonth = document.getElementById('month-select').value;
|
|
const monthName = getRussianMonthName(selectedMonth);
|
|
const displayName = contextName || monthName;
|
|
|
|
// Create a virtual "data" object for the total view that contains all transactions
|
|
const allTransactions = [];
|
|
sunburstData.data.forEach(category => {
|
|
if (category.transactions) {
|
|
allTransactions.push(...category.transactions);
|
|
}
|
|
});
|
|
const totalData = { name: displayName, transactions: allTransactions };
|
|
currentHoveredData = totalData;
|
|
|
|
detailsHeader.innerHTML = `
|
|
<span class="hover-name">
|
|
<span class="color-circle" style="background-color: #5470c6;"></span>
|
|
${displayName}
|
|
</span>
|
|
<button class="eye-btn header-eye-btn" title="View all transactions">
|
|
<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>
|
|
</button>
|
|
<span class="hover-amount">${sunburstData.total.toLocaleString()} ₽</span>
|
|
`;
|
|
|
|
// Add click handler to header eye button
|
|
const headerEyeBtn = detailsHeader.querySelector('.header-eye-btn');
|
|
if (headerEyeBtn) {
|
|
headerEyeBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
openTransactionModal(totalData);
|
|
});
|
|
}
|
|
|
|
// Show items - sort by value for root level, use fixed order for drilled-down
|
|
const itemsToDisplay = contextName
|
|
? [...sunburstData.data] // Drilled down: use fixed subcategory order
|
|
: [...sunburstData.data].sort((a, b) => b.value - a.value); // Root level: sort by value
|
|
displayDetailsItems(itemsToDisplay, sunburstData.total);
|
|
}
|
|
|
|
// Show default view initially
|
|
showDefaultView();
|
|
|
|
// Track if we're inside a section to handle sector exit properly
|
|
let isInsideSection = false;
|
|
|
|
// Add general mousemove event listener to detect when outside chart circle
|
|
chartDom.addEventListener('mousemove', function(e) {
|
|
// Get mouse position relative to chart container
|
|
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;
|
|
// Reset details immediately when leaving a section
|
|
showDefaultView();
|
|
}
|
|
});
|
|
|
|
// 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
|
|
console.log('Hovered node:', { depth: depth, name: params.name, transactionsLength: (params.data.transactions ? params.data.transactions.length : 0), childrenLength: (params.data.children ? params.data.children.length : 0) });
|
|
|
|
// Handle subcategory nodes (depth 2): show microcategories and transactions without microcategory
|
|
if (depth === 2) {
|
|
isInsideSection = true;
|
|
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');
|
|
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
|
const MAX_DETAIL_ITEMS = 10;
|
|
let itemsToShow = [];
|
|
if (params.data.children && params.data.children.length > 0) {
|
|
itemsToShow = [...params.data.children];
|
|
}
|
|
if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) {
|
|
const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
|
// Create a slightly transparent effect by mixing with white
|
|
const modifiedColor = tinycolor.mix(currentColor, '#ffffff', 60).toString();
|
|
const remaining = MAX_DETAIL_ITEMS - itemsToShow.length;
|
|
const transactionsWithoutMicro = (params.data.transactions || [])
|
|
.filter(t => t.displayMicrocategory === '')
|
|
.map(t => ({ ...t, itemStyle: { color: modifiedColor } }));
|
|
itemsToShow = itemsToShow.concat(transactionsWithoutMicro.slice(0, remaining));
|
|
}
|
|
displayDetailsItems(itemsToShow, params.value);
|
|
return;
|
|
}
|
|
// Handle category nodes (depth 1): show subcategories and transactions without microcategory
|
|
if (depth === 1) {
|
|
isInsideSection = true;
|
|
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
|
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
|
const MAX_DETAIL_ITEMS = 10;
|
|
let itemsToShow = [];
|
|
if (params.data.children && params.data.children.length > 0) {
|
|
itemsToShow = [...params.data.children];
|
|
}
|
|
if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) {
|
|
const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
|
// Create a slightly transparent effect by mixing with white
|
|
const modifiedColor = tinycolor.mix(currentColor, '#ffffff', 30).toString();
|
|
const remaining = MAX_DETAIL_ITEMS - itemsToShow.length;
|
|
const transactionsWithoutMicro = (params.data.transactions || [])
|
|
.filter(t => t.displayMicrocategory === '')
|
|
.map(t => ({ ...t, itemStyle: { color: modifiedColor } }));
|
|
itemsToShow = itemsToShow.concat(transactionsWithoutMicro.slice(0, remaining));
|
|
}
|
|
displayDetailsItems(itemsToShow, params.value);
|
|
return;
|
|
}
|
|
// For other depths, continue with existing behaviour
|
|
isInsideSection = true;
|
|
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
|
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
|
const MAX_DETAIL_ITEMS = 10;
|
|
let itemsToShow = [];
|
|
if (params.data.children && params.data.children.length > 0) {
|
|
const sortedChildren = [...params.data.children];
|
|
const allMicrocategories = [];
|
|
const displayedNames = new Set();
|
|
for (const child of sortedChildren) {
|
|
if (child.children && child.children.length > 0) {
|
|
const childMicros = [...child.children].sort((a, b) => b.value - a.value);
|
|
for (const micro of childMicros) {
|
|
if (!displayedNames.has(micro.name)) {
|
|
allMicrocategories.push(micro);
|
|
displayedNames.add(micro.name);
|
|
if (micro.transactions) {
|
|
micro.transactions.forEach(t => {
|
|
displayedNames.add(t.name);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (allMicrocategories.length > 0) {
|
|
itemsToShow = allMicrocategories.slice(0, MAX_DETAIL_ITEMS);
|
|
} else {
|
|
itemsToShow = sortedChildren.slice(0, MAX_DETAIL_ITEMS);
|
|
}
|
|
if (itemsToShow.length < MAX_DETAIL_ITEMS) {
|
|
const allTransactions = [];
|
|
for (const subcategory of sortedChildren) {
|
|
if (subcategory.transactions && subcategory.transactions.length > 0) {
|
|
subcategory.transactions.forEach(transaction => {
|
|
if (!displayedNames.has(transaction.name)) {
|
|
allTransactions.push({
|
|
name: transaction.name,
|
|
value: transaction.value,
|
|
displayMicrocategory: transaction.displayMicrocategory,
|
|
itemStyle: { color: subcategory.itemStyle ? subcategory.itemStyle.color : '#cccccc' }
|
|
});
|
|
displayedNames.add(transaction.name);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
allTransactions.sort((a, b) => b.value - a.value);
|
|
const transactionsWithoutMicro = allTransactions.filter(t => t.displayMicrocategory === '');
|
|
if (transactionsWithoutMicro.length > 0) {
|
|
const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
|
// Create a slightly transparent effect by mixing with white
|
|
const modifiedColor = tinycolor.mix(currentColor, '#ffffff', 30).toString();
|
|
const remaining = MAX_DETAIL_ITEMS - itemsToShow.length;
|
|
const filteredTransactions = transactionsWithoutMicro
|
|
.filter(t => !allMicrocategories.some(m => m.name === t.name))
|
|
.map(t => ({ ...t, itemStyle: { color: modifiedColor } }));
|
|
itemsToShow = itemsToShow.concat(filteredTransactions.slice(0, remaining));
|
|
}
|
|
}
|
|
} else if (params.data.transactions) {
|
|
const sortedTransactions = [...params.data.transactions].sort((a, b) => b.value - a.value);
|
|
const coloredTransactions = sortedTransactions.map(t => ({
|
|
...t,
|
|
color: params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc')
|
|
}));
|
|
itemsToShow = coloredTransactions.slice(0, MAX_DETAIL_ITEMS);
|
|
}
|
|
displayDetailsItems(itemsToShow, params.value);
|
|
}
|
|
});
|
|
|
|
// When mouse leaves a section but is still within the chart, we'll handle it with mousemove
|
|
myChart.on('mouseout', function(params) {
|
|
if (params.data) {
|
|
isOverChartSector = false;
|
|
isInsideSection = false;
|
|
// Hide the floating eye button (unless hovering over it)
|
|
hideChartEyeButton();
|
|
// Reset details with a small delay to allow mouseover of next sector to fire first
|
|
setTimeout(() => {
|
|
if (!isOverEyeButton && !isInsideSection) {
|
|
showDefaultView();
|
|
}
|
|
}, 50);
|
|
}
|
|
});
|
|
|
|
// Also reset when mouse leaves the chart container entirely
|
|
chartDom.addEventListener('mouseleave', function(e) {
|
|
// Don't reset if moving to the eye button
|
|
if (e.relatedTarget === chartEyeBtn || (e.relatedTarget && chartEyeBtn.contains(e.relatedTarget))) {
|
|
return;
|
|
}
|
|
isInsideSection = false;
|
|
showDefaultView();
|
|
});
|
|
|
|
// 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 (unless hovering eye button or still in section)
|
|
setTimeout(() => {
|
|
if (!isOverEyeButton && !isInsideSection) {
|
|
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'];
|
|
|
|
// Modal state
|
|
let currentTransactions = [];
|
|
let currentColumns = [];
|
|
let currentSortColumn = null;
|
|
let currentSortDirection = 'desc';
|
|
|
|
// Open transaction modal
|
|
function openTransactionModal(item) {
|
|
const modal = document.getElementById('transaction-modal');
|
|
const modalTitle = document.getElementById('modal-title');
|
|
|
|
// 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];
|
|
}
|
|
|
|
if (transactions.length === 0) {
|
|
const modalTbody = document.getElementById('modal-tbody');
|
|
const modalThead = document.getElementById('modal-thead');
|
|
modalThead.innerHTML = '';
|
|
modalTbody.innerHTML = '<tr><td colspan="100%">No transaction data available</td></tr>';
|
|
modal.style.display = 'flex';
|
|
setupModalListeners();
|
|
return;
|
|
}
|
|
|
|
// Get columns from first transaction
|
|
const sampleRow = transactions[0].originalRow;
|
|
currentColumns = csvColumnOrder.filter(col => col in sampleRow);
|
|
currentTransactions = transactions;
|
|
|
|
// Initial sort by amount descending
|
|
currentSortColumn = 'amount_rub';
|
|
currentSortDirection = 'desc';
|
|
|
|
renderTransactionTable();
|
|
modal.style.display = 'flex';
|
|
|
|
// Reset scroll position to top-left
|
|
const tableContainer = document.querySelector('.modal-table-container');
|
|
tableContainer.scrollTop = 0;
|
|
tableContainer.scrollLeft = 0;
|
|
|
|
setupModalListeners();
|
|
}
|
|
|
|
// Render the transaction table with current sort
|
|
function renderTransactionTable() {
|
|
const modalThead = document.getElementById('modal-thead');
|
|
const modalTbody = document.getElementById('modal-tbody');
|
|
|
|
// Sort transactions
|
|
const sortedTransactions = [...currentTransactions].sort((a, b) => {
|
|
const aVal = a.originalRow?.[currentSortColumn] ?? '';
|
|
const bVal = b.originalRow?.[currentSortColumn] ?? '';
|
|
|
|
// Numeric sort for amount columns
|
|
if (currentSortColumn === 'amount_rub' || currentSortColumn === 'amount_original') {
|
|
const aNum = parseFloat(aVal) || 0;
|
|
const bNum = parseFloat(bVal) || 0;
|
|
return currentSortDirection === 'asc' ? aNum - bNum : bNum - aNum;
|
|
}
|
|
|
|
// String sort for other columns
|
|
const aStr = String(aVal);
|
|
const bStr = String(bVal);
|
|
const cmp = aStr.localeCompare(bStr);
|
|
return currentSortDirection === 'asc' ? cmp : -cmp;
|
|
});
|
|
|
|
// Clear content
|
|
modalThead.innerHTML = '';
|
|
modalTbody.innerHTML = '';
|
|
|
|
// Build header with sort indicators
|
|
const headerRow = document.createElement('tr');
|
|
currentColumns.forEach(col => {
|
|
const th = document.createElement('th');
|
|
th.textContent = csvColumnLabels[col] || col;
|
|
th.dataset.column = col;
|
|
|
|
// Add sort class
|
|
if (col === currentSortColumn) {
|
|
th.classList.add(currentSortDirection === 'asc' ? 'sort-asc' : 'sort-desc');
|
|
}
|
|
|
|
// Click to sort
|
|
th.addEventListener('click', () => {
|
|
if (currentSortColumn === col) {
|
|
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
currentSortColumn = col;
|
|
currentSortDirection = 'asc';
|
|
}
|
|
renderTransactionTable();
|
|
});
|
|
|
|
headerRow.appendChild(th);
|
|
});
|
|
modalThead.appendChild(headerRow);
|
|
|
|
// Build rows
|
|
sortedTransactions.forEach(transaction => {
|
|
const row = document.createElement('tr');
|
|
|
|
currentColumns.forEach(col => {
|
|
const td = document.createElement('td');
|
|
let value = transaction.originalRow[col];
|
|
|
|
if ((col === 'amount_rub' || col === 'amount_original') && typeof value === 'number') {
|
|
value = value.toLocaleString();
|
|
}
|
|
|
|
td.textContent = value !== undefined && value !== null ? value : '';
|
|
td.title = td.textContent;
|
|
row.appendChild(td);
|
|
});
|
|
|
|
// Row click opens detail modal
|
|
row.addEventListener('click', () => openRowDetailModal(transaction));
|
|
modalTbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Open the row detail modal
|
|
function openRowDetailModal(transaction) {
|
|
const modal = document.getElementById('row-detail-modal');
|
|
const title = document.getElementById('row-detail-title');
|
|
const body = document.getElementById('row-detail-body');
|
|
|
|
const name = transaction.originalRow?.simple_name || transaction.name || 'Transaction';
|
|
title.textContent = name;
|
|
|
|
body.innerHTML = '';
|
|
|
|
currentColumns.forEach(col => {
|
|
// Skip simple_name since it's already shown in the title
|
|
if (col === 'simple_name') return;
|
|
|
|
const value = transaction.originalRow[col];
|
|
|
|
const itemDiv = document.createElement('div');
|
|
itemDiv.className = 'row-detail-item';
|
|
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.className = 'row-detail-label';
|
|
labelSpan.textContent = csvColumnLabels[col] || col;
|
|
|
|
const valueSpan = document.createElement('span');
|
|
valueSpan.className = 'row-detail-value';
|
|
valueSpan.textContent = value;
|
|
|
|
itemDiv.appendChild(labelSpan);
|
|
itemDiv.appendChild(valueSpan);
|
|
body.appendChild(itemDiv);
|
|
});
|
|
|
|
modal.style.display = 'flex';
|
|
setupRowDetailModalListeners();
|
|
}
|
|
|
|
// Close row detail modal
|
|
function closeRowDetailModal() {
|
|
const modal = document.getElementById('row-detail-modal');
|
|
modal.style.display = 'none';
|
|
}
|
|
|
|
// Setup row detail modal listeners
|
|
function setupRowDetailModalListeners() {
|
|
const modal = document.getElementById('row-detail-modal');
|
|
const closeBtn = document.getElementById('row-detail-close');
|
|
|
|
closeBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
closeRowDetailModal();
|
|
};
|
|
|
|
modal.onclick = function(e) {
|
|
if (e.target === modal) {
|
|
closeRowDetailModal();
|
|
}
|
|
};
|
|
}
|
|
|
|
// Close transaction modal
|
|
function closeTransactionModal() {
|
|
const modal = document.getElementById('transaction-modal');
|
|
modal.style.display = 'none';
|
|
// Remove any chart highlight and reset details panel
|
|
myChart.dispatchAction({ type: 'downplay' });
|
|
if (showDefaultView) showDefaultView();
|
|
}
|
|
|
|
// Global escape key handler for all modals
|
|
function handleGlobalEscape(e) {
|
|
if (e.key === 'Escape') {
|
|
const rowDetailModal = document.getElementById('row-detail-modal');
|
|
const transactionModal = document.getElementById('transaction-modal');
|
|
|
|
// Close row detail first if open, then transaction modal
|
|
if (rowDetailModal.style.display !== 'none') {
|
|
closeRowDetailModal();
|
|
} else if (transactionModal.style.display !== 'none') {
|
|
closeTransactionModal();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Setup global escape listener once
|
|
document.addEventListener('keydown', handleGlobalEscape);
|
|
|
|
// Setup modal close listeners
|
|
function setupModalListeners() {
|
|
const modal = document.getElementById('transaction-modal');
|
|
const closeBtn = document.getElementById('modal-close');
|
|
|
|
closeBtn.onclick = closeTransactionModal;
|
|
|
|
modal.onclick = function(e) {
|
|
if (e.target === modal) {
|
|
closeTransactionModal();
|
|
}
|
|
};
|
|
}
|