visual-spending/app.js
Anton Volnuhin bda882d89a Fix eye button tap on touch devices in details panel
On touch devices, tapping an eye button in the details panel would
trigger chart mouseout → showDefaultView() which rebuilt the DOM
before the click event fired. Added pointerdown/pointerup guards
on the details box to prevent the race condition.

Also added @media (pointer: coarse) to always show eye buttons
on touch devices regardless of viewport width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 01:01:00 +03:00

3705 lines
146 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Format RUB amount: no decimals, narrow no-break space as thousands separator
function formatRUB(amount) {
return Math.round(amount)
.toLocaleString('ru-RU')
.replace(/\u00a0/g, '\u202F');
}
// Update HTML center label (month in gray, category optional, amount in black)
function updateCenterLabel(month, amount, category = null) {
const labelEl = document.getElementById('center-label');
if (!labelEl) return;
labelEl.querySelector('.center-month').textContent = month;
labelEl.querySelector('.center-category').textContent = category || '';
labelEl.querySelector('.center-amount-num').textContent = formatRUB(amount);
labelEl.querySelector('.center-rub').textContent = '\u202F₽';
// Show/hide home icon based on drill-down state
updateDrilledDownState();
}
// Update drilled-down visual state (home icon visibility)
function updateDrilledDownState() {
const labelEl = document.getElementById('center-label');
if (!labelEl) return;
if (historyIndex > 0) {
labelEl.classList.add('drilled-down');
} else {
labelEl.classList.remove('drilled-down');
}
}
// 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
let currentView = 'month'; // 'month' | 'timeline'
// 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, '');
}
saveMonthSelectionState();
}
// Reset history (called when changing months)
function resetHistory() {
drillDownHistory = [];
historyIndex = -1;
currentDrillPath = [];
}
// Navigate to a specific history state
function navigateToHistoryState(state) {
// Use multi-month label if multiple months selected, otherwise single month name
const monthLabel = selectedMonthIndices.size > 1
? generateSelectedMonthsLabel()
: getRussianMonthName(document.getElementById('month-select').value);
option.series.data = state.data;
myChart.setOption(option, { replaceMerge: ['series'] });
updateCenterLabel(monthLabel, state.total, state.contextName);
setupHoverEvents({ total: state.total, data: state.data }, state.contextName);
// Restore drill path and update mini-charts
currentDrillPath = state.path ? [...state.path] : [];
updateAllMonthPreviews();
saveMonthSelectionState();
}
// 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]);
}
}
// Go directly to root level
function goToRoot() {
if (drillDownHistory.length > 0 && historyIndex > 0) {
historyIndex = 0;
navigateToHistoryState(drillDownHistory[0]);
// Update browser history to reflect root state
history.replaceState({ drillDown: 0 }, '');
}
}
// 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 navigation
if (e.state && e.state.timelineDrill !== undefined) {
// Timeline drill-down navigation (possibly with legend selection)
if (currentView !== 'timeline') {
currentView = 'timeline';
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === 'timeline');
});
document.querySelector('.container').classList.add('timeline-mode');
myChart.resize();
}
renderTimelineChart(e.state.timelineDrill, 'none', e.state.legendSelected || null);
return;
}
if (e.state && e.state.monthView) {
// Month view navigation (from view switch)
if (currentView !== 'month') {
switchView('month');
}
return;
}
if (e.state && e.state.drillDown !== undefined) {
// Donut drill-down navigation
if (currentView !== 'month') {
// Switch from timeline to month view, then re-render donut from scratch
switchView('month');
return;
}
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
};
}
// Build ECharts stacked bar option for timeline view
// drillPath: array like [] (categories), ['Cat'] (subcategories), ['Cat','Sub'] (microcategories)
function buildTimelineOption(drillPath) {
drillPath = drillPath || [];
const depth = drillPath.length; // 0=category, 1=subcategory, 2=microcategory
const months = availableMonths;
const xLabels = months.map(m => formatMonthLabel(m));
const seriesList = [];
const legendData = [];
const emphLabel = {
show: true, position: 'top',
formatter: (p) => p.value >= 1000 ? Math.round(p.value / 1000) + 'к' : '',
fontSize: 14, fontWeight: 'bold',
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
color: '#555', backgroundColor: 'rgba(255,255,255,0.9)', borderRadius: 2, padding: [2, 4]
};
// Hierarchy fields in order: category → subcategory → microcategory
const hierarchyFields = ['category', 'subcategory', 'microcategory'];
// Collect totals grouped by the next level in the hierarchy
const groupTotals = {};
months.forEach((month, mi) => {
const data = monthDataCache[month];
if (!data) return;
data.forEach(item => {
const amount = Math.abs(parseFloat(item.amount_rub));
if (isNaN(amount)) return;
// Filter: item must match all path segments
for (let i = 0; i < depth; i++) {
const field = hierarchyFields[i];
const itemVal = item[field] || '';
if (itemVal !== drillPath[i]) return;
}
// Group by the next field in the hierarchy
const groupField = hierarchyFields[depth];
const groupName = item[groupField] || 'Другое';
if (!groupTotals[groupName]) {
groupTotals[groupName] = new Array(months.length).fill(0);
}
groupTotals[groupName][mi] += amount;
});
});
// Build series with appropriate ordering and colors per depth level
if (depth === 0) {
// Level 0: use categoryOrder and categoryColors
const usedCategories = new Set();
categoryOrder.forEach((catName, ci) => {
if (!groupTotals[catName]) return;
usedCategories.add(catName);
legendData.push(catName);
seriesList.push({
name: catName,
type: 'bar',
stack: 'total',
barMaxWidth: 50,
barCategoryGap: '35%',
data: groupTotals[catName],
itemStyle: { color: categoryColors[ci] },
label: { show: false },
emphasis: { focus: 'series', label: emphLabel },
blur: { itemStyle: { opacity: 0.15 } }
});
});
Object.keys(groupTotals).forEach(catName => {
if (usedCategories.has(catName)) return;
legendData.push(catName);
seriesList.push({
name: catName,
type: 'bar',
stack: 'total',
barMaxWidth: 50,
barCategoryGap: '35%',
data: groupTotals[catName],
label: { show: false },
emphasis: { focus: 'series', label: emphLabel },
blur: { itemStyle: { opacity: 0.15 } }
});
});
} else if (depth === 1) {
// Level 1: use subcategoryOrder and getSubcategoryColor
const parentCategory = drillPath[0];
const order = subcategoryOrder[parentCategory] || [];
const usedSubs = new Set();
let unknownIdx = 0;
order.forEach((subName, si) => {
if (!groupTotals[subName]) return;
usedSubs.add(subName);
legendData.push(subName);
seriesList.push({
name: subName,
type: 'bar',
stack: 'total',
barMaxWidth: 50,
barCategoryGap: '35%',
data: groupTotals[subName],
itemStyle: { color: getSubcategoryColor(parentCategory, subName, si) },
label: { show: false },
emphasis: { focus: 'series', label: emphLabel },
blur: { itemStyle: { opacity: 0.15 } }
});
});
Object.keys(groupTotals).forEach(subName => {
if (usedSubs.has(subName)) return;
legendData.push(subName);
seriesList.push({
name: subName,
type: 'bar',
stack: 'total',
barMaxWidth: 50,
barCategoryGap: '35%',
data: groupTotals[subName],
itemStyle: { color: getSubcategoryColor(parentCategory, subName, 0, unknownIdx++) },
label: { show: false },
emphasis: { focus: 'series', label: emphLabel },
blur: { itemStyle: { opacity: 0.15 } }
});
});
} else {
// Level 2 (microcategory): auto-colors from defaultColorPalette
let colorIdx = 0;
// Sort by total value descending for consistent ordering
const sortedNames = Object.keys(groupTotals).sort((a, b) => {
const sumA = groupTotals[a].reduce((s, v) => s + v, 0);
const sumB = groupTotals[b].reduce((s, v) => s + v, 0);
return sumB - sumA;
});
sortedNames.forEach(name => {
legendData.push(name);
seriesList.push({
name: name,
type: 'bar',
stack: 'total',
barMaxWidth: 50,
barCategoryGap: '35%',
data: groupTotals[name],
itemStyle: { color: defaultColorPalette[colorIdx % defaultColorPalette.length] },
label: { show: false },
emphasis: { focus: 'series', label: emphLabel },
blur: { itemStyle: { opacity: 0.15 } }
});
colorIdx++;
});
}
// Build per-series data map for dynamic total recalculation
const seriesDataMap = {};
seriesList.forEach(s => { seriesDataMap[s.name] = s.data; });
// Invisible "total" series on top — shows sum labels that update with legend selection
const totalPerMonth = new Array(months.length).fill(0);
seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); });
seriesList.push({
name: '__total__',
type: 'bar',
stack: 'total',
barMaxWidth: 50,
data: totalPerMonth.map(() => 1),
itemStyle: { color: 'transparent' },
label: {
show: true,
position: 'top',
formatter: (params) => Math.round(totalPerMonth[params.dataIndex] / 1000) + 'к',
fontSize: (window.innerWidth <= 850 && window.innerHeight > window.innerWidth) ? 12 : 14,
fontWeight: 'bold',
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
color: '#666'
},
emphasis: { disabled: true },
blur: { label: { show: false }, itemStyle: { opacity: 0 } }
});
const isDrilled = drillPath.length > 0;
const isMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
const chartTitle = isDrilled ? {
text: '← ' + drillPath.join(' '),
left: 'center',
top: 6,
textStyle: {
fontSize: isMobile ? 16 : 22,
fontWeight: 'bold',
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
color: '#333'
},
triggerEvent: true
} : { show: false };
const legendItemWidth = isMobile ? 10 : 12;
const legendItemHeight = isMobile ? 10 : 12;
const legendItemGap = isMobile ? 6 : 8;
const legendPadding = isMobile ? [4, 8] : [8, 12];
const legendFontSize = isMobile ? 11 : 13;
const legendPosition = isMobile
? { bottom: 20, left: 'center' }
: { top: isDrilled ? 42 : 10, right: 10 };
return {
backgroundColor: '#fff',
title: chartTitle,
tooltip: { show: false },
legend: {
data: legendData,
...legendPosition,
orient: isMobile ? 'horizontal' : 'vertical',
itemWidth: legendItemWidth,
itemHeight: legendItemHeight,
textStyle: { fontSize: legendFontSize },
itemGap: legendItemGap,
backgroundColor: isMobile ? 'transparent' : 'rgba(255,255,255,0.85)',
borderRadius: isMobile ? 0 : 6,
padding: legendPadding,
selector: false
},
grid: {
left: isMobile ? 10 : 30,
right: isMobile ? 25 : 180,
top: isMobile ? (isDrilled ? 40 : 10) : (isDrilled ? 55 : 40),
bottom: isMobile ? 100 : 30,
containLabel: true
},
xAxis: {
type: 'category',
data: xLabels,
axisLabel: { fontSize: isMobile ? 11 : 13, rotate: isMobile ? -90 : -45 }
},
yAxis: {
type: 'value',
axisLabel: {
formatter: function(val) {
if (val >= 1000) return Math.round(val / 1000) + 'к\u202F₽';
return val;
},
fontSize: isMobile ? 11 : 13
}
},
series: seriesList,
_seriesDataMap: seriesDataMap,
_monthCount: months.length,
_legendLayout: {
isMobile: isMobile,
orient: isMobile ? 'horizontal' : 'vertical',
top: legendPosition.top,
right: legendPosition.right,
itemWidth: legendItemWidth,
itemHeight: legendItemHeight,
itemGap: legendItemGap,
padding: legendPadding,
fontSize: legendFontSize
}
};
}
let timelineDrillPath = [];
let _lastTimelineMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
// Render (or re-render) the timeline chart, optionally drilled into a category path
// historyAction: 'push' (default, user drill-down), 'replace' (initial/view switch), 'none' (popstate restore)
function renderTimelineChart(drillPath, historyAction, legendSelected) {
_lastTimelineMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
drillPath = drillPath || [];
historyAction = historyAction || 'push';
timelineDrillPath = drillPath;
window._timelineResetLegend = null;
myChart.off('click');
myChart.off('mouseover');
myChart.off('mouseout');
myChart.off('globalout');
myChart.off('legendselectchanged');
// Clean up previous zrender label-click handler
if (window._timelineZrLabelHandler) {
myChart.getZr().off('click', window._timelineZrLabelHandler);
window._timelineZrLabelHandler = null;
}
const tlOption = buildTimelineOption(drillPath);
myChart.clear();
myChart.setOption(tlOption, true);
// Restore legend selection if provided (from popstate)
if (legendSelected) {
window._suppressLegendHistory = true;
Object.keys(legendSelected).forEach(name => {
myChart.dispatchAction({
type: legendSelected[name] === false ? 'legendUnSelect' : 'legendSelect',
name: name
});
});
window._suppressLegendHistory = false;
// Update totals and reset button for the restored selection
updateTotalsAndResetBtn(legendSelected);
}
// Update browser history state
const histState = { timelineDrill: drillPath };
if (legendSelected) histState.legendSelected = legendSelected;
if (historyAction === 'replace') {
history.replaceState(histState, '');
} else if (historyAction === 'push') {
history.pushState(histState, '');
}
// 'none': skip history manipulation (popstate restore)
saveMonthSelectionState();
// Highlight entire series on hover so all bars show emphasis.label
// Also highlight the corresponding legend item
const legendNames = tlOption.legend.data;
let hlSeries = null;
function updateLegend(activeName) {
myChart.setOption({
legend: {
data: legendNames.map(name => {
if (!activeName) return { name, textStyle: { fontWeight: 'normal', color: '#333' }, itemStyle: { opacity: 1 } };
if (name === activeName) return { name, textStyle: { fontWeight: 'bold', color: '#333' }, itemStyle: { opacity: 1 } };
return { name, textStyle: { fontWeight: 'normal', color: '#ccc' }, itemStyle: { opacity: 0.15 } };
})
}
});
}
myChart.on('mouseover', function(params) {
if (params.componentType !== 'series' || params.seriesName === '__total__') return;
if (hlSeries === params.seriesName) return;
if (hlSeries) myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries });
hlSeries = params.seriesName;
myChart.dispatchAction({ type: 'highlight', seriesName: params.seriesName });
updateLegend(params.seriesName);
});
myChart.on('mouseout', function(params) {
if (params.componentType !== 'series' || !hlSeries) return;
myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries });
hlSeries = null;
updateLegend(null);
});
myChart.on('globalout', function() {
if (hlSeries) {
myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries });
hlSeries = null;
updateLegend(null);
}
});
// Track whether all series are selected
function updateTotalsAndResetBtn(selected) {
const dataMap = tlOption._seriesDataMap;
const n = tlOption._monthCount;
const visibleTotals = new Array(n).fill(0);
const allNames = tlOption.legend.data;
const legendLayout = tlOption._legendLayout;
let allSelected = true;
Object.keys(dataMap).forEach(name => {
if (selected[name] !== false) {
dataMap[name].forEach((v, i) => { visibleTotals[i] += v; });
} else {
allSelected = false;
}
});
const resetFontSize = Math.max(11, legendLayout.fontSize - 1);
const resetLegendSelection = function() {
window._suppressLegendHistory = true;
allNames.forEach(name => {
myChart.dispatchAction({ type: 'legendSelect', name: name });
});
window._suppressLegendHistory = false;
const resetSelected = {};
allNames.forEach(name => { resetSelected[name] = true; });
updateTotalsAndResetBtn(resetSelected);
history.pushState({ timelineDrill: drillPath }, '');
};
window._timelineResetLegend = allSelected ? null : resetLegendSelection;
const resetGraphic = {
type: 'text',
id: 'resetBtn',
z: 100,
zlevel: 1,
style: {
text: allSelected ? '' : '× сбросить',
fontSize: resetFontSize,
fill: '#999',
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif'
},
cursor: 'pointer',
onmouseover: function() {
if (this && this.setStyle) this.setStyle({ fill: '#000' });
},
onmouseout: function() {
if (this && this.setStyle) this.setStyle({ fill: '#999' });
},
onclick: resetLegendSelection
};
const legendView = (myChart._componentsViews || []).find(v => v && v.__model && v.__model.mainType === 'legend');
if (legendLayout.isMobile) {
// Mobile: center reset button below horizontal legend
let resetTop;
if (legendView && legendView.group) {
const groupRect = legendView.group.getBoundingRect();
resetTop = legendView.group.y + groupRect.y + groupRect.height + 2;
}
resetGraphic.left = 'center';
if (resetTop !== undefined) {
resetGraphic.top = resetTop;
} else {
resetGraphic.bottom = 2;
}
} else {
// Desktop: position below vertical legend, left-aligned with legend box
const legendFont = `${legendLayout.fontSize}px sans-serif`;
const resetXOffset = 3;
const maxLegendTextWidth = allNames.reduce((maxWidth, name) => {
return Math.max(maxWidth, echarts.format.getTextRect(name, legendFont).width);
}, 0);
const iconTextGap = 5;
const legendBoxWidth =
legendLayout.padding[1] * 2 +
legendLayout.itemWidth +
iconTextGap +
maxLegendTextWidth;
const resetTextWidth = echarts.format.getTextRect('× сбросить', `${resetFontSize}px sans-serif`).width;
const resetRight =
legendLayout.right +
legendBoxWidth -
legendLayout.padding[1] -
resetTextWidth -
resetXOffset;
const resetLeft = legendView && legendView.group ? legendView.group.x : null;
let resetTop;
if (legendView && legendView.group && legendView._contentGroup) {
const legendItems = legendView._contentGroup.children();
if (legendItems.length > 0) {
const lastItem = legendItems[legendItems.length - 1];
const prevItem = legendItems.length > 1 ? legendItems[legendItems.length - 2] : null;
const rowStep = prevItem ? (lastItem.y - prevItem.y) : (legendLayout.itemHeight + legendLayout.itemGap);
const lastRect = lastItem.getBoundingRect();
resetTop =
legendView.group.y +
(legendView._contentGroup.y || 0) +
lastItem.y +
lastRect.y +
rowStep;
}
}
if (resetTop === undefined) {
const nextRowTop =
legendLayout.top +
legendLayout.padding[0] +
allNames.length * (legendLayout.itemHeight + legendLayout.itemGap);
resetTop = nextRowTop + 11;
}
resetGraphic.top = resetTop;
if (resetLeft !== null) {
resetGraphic.left = resetLeft + resetXOffset;
} else {
resetGraphic.right = resetRight;
}
}
myChart.setOption({
series: [{ name: '__total__', label: {
formatter: (p) => Math.round(visibleTotals[p.dataIndex] / 1000) + 'к'
}}],
graphic: [resetGraphic]
});
}
// Update total labels when legend selection changes (cmd-click / opt-click / legend click)
myChart.on('legendselectchanged', function(params) {
updateTotalsAndResetBtn(params.selected);
// Push legend selection to browser history (skip during popstate restore)
if (!window._suppressLegendHistory) {
history.pushState({ timelineDrill: drillPath, legendSelected: params.selected }, '');
}
});
// Click handler: drill deeper or go up via title
myChart.on('click', function(params) {
if (params.componentType === 'title') {
// Go up one level
renderTimelineChart(drillPath.slice(0, -1));
return;
}
if (params.componentType === 'series' && params.seriesName !== '__total__') {
const nativeEvent = params.event && params.event.event;
// Cmd-click: select only this series (hide all others)
if (nativeEvent && nativeEvent.metaKey) {
const allNames = tlOption.legend.data;
window._suppressLegendHistory = true;
allNames.forEach(name => {
myChart.dispatchAction({
type: name === params.seriesName ? 'legendSelect' : 'legendUnSelect',
name: name
});
});
window._suppressLegendHistory = false;
// Build selected map and push once
const selected = {};
allNames.forEach(name => { selected[name] = name === params.seriesName; });
updateTotalsAndResetBtn(selected);
history.pushState({ timelineDrill: drillPath, legendSelected: selected }, '');
return;
}
// Opt-click: toggle this series off (single action — legendselectchanged handles push)
if (nativeEvent && nativeEvent.altKey) {
myChart.dispatchAction({ type: 'legendToggleSelect', name: params.seriesName });
return;
}
// Plain click: drill deeper if not at max depth (2 = microcategory level)
if (drillPath.length < 2) {
renderTimelineChart([...drillPath, params.seriesName]);
}
}
});
// Zrender-level click: detect clicks in the x-axis label area → switch to that month
window._timelineZrLabelHandler = function(e) {
const y = e.offsetY;
const yBottom = myChart.convertToPixel({yAxisIndex: 0}, 0);
// Click must be below the bar area (in the label zone) but above the legend
if (y > yBottom + 5 && y < yBottom + 60) {
const x = e.offsetX;
// Find nearest bar index
let bestIdx = -1, bestDist = Infinity;
for (let i = 0; i < availableMonths.length; i++) {
const barX = myChart.convertToPixel({xAxisIndex: 0}, i);
const dist = Math.abs(x - barX);
if (dist < bestDist) { bestDist = dist; bestIdx = i; }
}
// Accept if within half the bar spacing
const spacing = availableMonths.length > 1
? Math.abs(myChart.convertToPixel({xAxisIndex: 0}, 1) - myChart.convertToPixel({xAxisIndex: 0}, 0))
: 100;
if (bestIdx >= 0 && bestDist < spacing * 0.6) {
history.pushState({ monthView: true }, '');
switchView('month');
selectSingleMonth(bestIdx);
}
}
};
myChart.getZr().on('click', window._timelineZrLabelHandler);
}
// Switch between month (sunburst) and timeline (stacked bar) views
function switchView(viewName) {
if (viewName === currentView) return;
currentView = viewName;
// Update switcher button active state
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === viewName);
});
const container = document.querySelector('.container');
if (viewName === 'timeline') {
container.classList.add('timeline-mode');
// Remove all chart event listeners
myChart.off('click');
myChart.off('mouseover');
myChart.off('mouseout');
myChart.off('globalout');
myChart.off('legendselectchanged');
// Resize after layout change, then render stacked bar
myChart.resize();
renderTimelineChart(timelineDrillPath, 'replace');
// Ensure resize after CSS layout fully settles (needed for deferred restore)
requestAnimationFrame(() => myChart.resize());
} else {
container.classList.remove('timeline-mode');
window._timelineResetLegend = null;
// Remove all chart event listeners
myChart.off('click');
myChart.off('mouseover');
myChart.off('mouseout');
myChart.off('globalout');
myChart.off('legendselectchanged');
// Clear chart completely so no bar chart remnants remain
myChart.clear();
option = null;
// Resize after layout change, then re-render sunburst from scratch
myChart.resize();
renderSelectedMonths();
}
saveMonthSelectionState();
}
// 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);
const restoredPath = [...currentDrillPath];
// 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 label
const selectedMonth = document.getElementById('month-select').value;
const russianMonth = selectedMonthIndices.size > 1
? generateSelectedMonthsLabel()
: getRussianMonthName(selectedMonth);
// Calculate the correct center position first
const screenWidth = window.innerWidth;
let centerPosition;
if (screenWidth <= 850) {
// Stacked layout: center the chart since details box is below
centerPosition = 50;
} else if (screenWidth >= 1000) {
// For screens 1000px and wider, keep centered at 50%
centerPosition = 50;
} else {
// Gradual transition between 850-1000px (side-by-side layout)
const transitionProgress = (screenWidth - 850) / 150; // 0 to 1
centerPosition = 40 + (transitionProgress * 10); // 40 to 50
}
// Mobile: scale chart to fill container, hide outside labels
const isMobile = screenWidth <= 500;
// Mobile: extend layers to fill container, keeping same hole size as desktop
// Hole stays at 20%, layers scaled to fill remaining 80% (vs 55% on desktop)
const level1Inner = '20%';
const level1Outer = isMobile ? '56%' : '45%';
const level2Outer = isMobile ? '93%' : '70%';
const level3Outer = isMobile ? '100%' : '75%';
const outerRadius = isMobile ? '100%' : '95%';
// Smaller font sizes on mobile, bolder for readability
const level1FontSize = isMobile ? 10 : 13;
const level1LineHeight = isMobile ? 12 : 15;
const level1FontWeight = isMobile ? 600 : 500;
const level2FontSize = isMobile ? 9 : 11;
const level2FontWeight = isMobile ? 600 : 400;
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, outerRadius],
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: level1Inner,
r: level1Outer,
label: {
show: true,
rotate: 'radial',
fontSize: level1FontSize,
lineHeight: level1LineHeight,
fontWeight: level1FontWeight,
verticalAlign: 'center',
position: 'inside',
formatter: function(param) {
// No special formatting for level 1
return param.name;
}
},
itemStyle: {
borderWidth: 2
}
},
{
// Second level - Subcategories
r0: level1Outer,
r: level2Outer,
label: {
show: function(param) {
// Show labels for sectors that are at least 5% of the total
return param.percent >= 0.05;
},
fontSize: level2FontSize,
fontWeight: level2FontWeight,
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
r0: level2Outer,
r: level3Outer,
label: {
// Only show labels conditionally based on segment size
// On mobile, hide outside labels to maximize chart size
show: isMobile ? false : function(param) {
// Show label if segment is wide enough (>1%)
return param.percent > 0.000;
},
position: isMobile ? 'inside' : '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 = formatRUB(info.value);
const name = info.name;
// Calculate percentage of total
const percentage = ((info.value / sunburstData.total) * 100).toFixed(1);
return `${name}<br/>Amount: ${value}\u202F₽<br/>Percentage: ${percentage}%`;
}
}
},
graphic: {
elements: [] // Center label is now HTML overlay
}
};
// 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);
const predefinedOrder = subcategoryOrder[params.name] || [];
// Process each child, tracking unknown item count for color assignment
let unknownCount = 0;
sortedChildren.forEach((child, i) => {
const isUnknown = !predefinedOrder.includes(child.name);
const color = getSubcategoryColor(params.name, child.name, i, isUnknown ? unknownCount++ : null);
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 monthLabel = selectedMonthIndices.size > 1
? generateSelectedMonthsLabel()
: getRussianMonthName(document.getElementById('month-select').value);
myChart.setOption(option, { replaceMerge: ['series'] });
updateCenterLabel(monthLabel, params.value, params.name);
// 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);
// Try to restore drill-down path on initial render (e.g., after page reload)
const restoredState = navigateToPath(sunburstData, restoredPath);
if (restoredState) {
option.series.data = restoredState.data;
myChart.setOption(option, { replaceMerge: ['series'] });
saveToHistory(restoredState.data, restoredState.total, restoredState.contextName, false, restoredState.path);
updateCenterLabel(russianMonth, restoredState.total, restoredState.contextName);
setupHoverEvents({ total: restoredState.total, data: restoredState.data }, restoredState.contextName);
updateAllMonthPreviews();
} else {
// Update HTML center label
updateCenterLabel(russianMonth, sunburstData.total);
// Set up hover events for the details box
setupHoverEvents(sunburstData);
}
// 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
}
});
// 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 selectedMonthIndices = new Set(); // Track all selected months for multi-selection
let monthDataCache = {}; // Cache for month data previews
const MONTH_SELECTION_STORAGE_KEY = 'visual-spending:month-selection';
function saveMonthSelectionState() {
try {
const selectedMonths = [...selectedMonthIndices]
.map(index => availableMonths[index])
.filter(Boolean);
const currentMonth = availableMonths[currentMonthIndex] || null;
localStorage.setItem(MONTH_SELECTION_STORAGE_KEY, JSON.stringify({
currentMonth,
selectedMonths,
drillPath: [...currentDrillPath],
view: currentView,
timelineDrillPath: [...timelineDrillPath]
}));
} catch (error) {
// Ignore storage failures (private mode, disabled storage, etc.)
}
}
function restoreMonthSelectionState() {
try {
const raw = localStorage.getItem(MONTH_SELECTION_STORAGE_KEY);
if (!raw) return false;
const saved = JSON.parse(raw);
if (!saved || !Array.isArray(saved.selectedMonths) || saved.selectedMonths.length === 0) {
return false;
}
const restoredIndices = saved.selectedMonths
.map(month => availableMonths.indexOf(month))
.filter(index => index >= 0);
if (restoredIndices.length === 0) {
return false;
}
selectedMonthIndices = new Set(restoredIndices);
const restoredCurrentIndex = availableMonths.indexOf(saved.currentMonth);
if (restoredCurrentIndex >= 0 && selectedMonthIndices.has(restoredCurrentIndex)) {
currentMonthIndex = restoredCurrentIndex;
} else {
currentMonthIndex = Math.max(...selectedMonthIndices);
}
if (Array.isArray(saved.drillPath)) {
currentDrillPath = [...saved.drillPath];
} else {
currentDrillPath = [];
}
// Restore timeline drill path if saved
if (Array.isArray(saved.timelineDrillPath)) {
timelineDrillPath = [...saved.timelineDrillPath];
}
// Defer timeline restore so chart is initialized first
if (saved.view === 'timeline') {
requestAnimationFrame(() => switchView('timeline'));
}
return true;
} catch (error) {
return false;
}
}
// 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
// unknownIndex: for unknown subcategories, pass the count of unknown items before this one
function getSubcategoryColor(category, subcategory, index, unknownIndex = null) {
if (subcategoryColors[category] && subcategoryColors[category][subcategory]) {
return subcategoryColors[category][subcategory];
}
// For unknown subcategories, use colors starting after the predefined ones
const predefinedCount = subcategoryOrder[category] ? subcategoryOrder[category].length : 0;
// Use unknownIndex if provided, otherwise calculate from index minus predefined items that might be before
const colorIndex = unknownIndex !== null ? unknownIndex : index;
return defaultColorPalette[(predefinedCount + colorIndex) % 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;
const predefinedOrder = parentCategory ? (subcategoryOrder[parentCategory] || []) : [];
let unknownCount = 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
let color;
if (level === 'subcategory' && parentCategory) {
const isUnknown = !predefinedOrder.includes(key);
color = getSubcategoryColor(parentCategory, key, index, isUnknown ? unknownCount++ : null);
} else {
color = 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', (event) => {
if (event.metaKey || event.ctrlKey) {
// Cmd/Ctrl+click: toggle individual month
toggleMonthSelection(index);
} else if (event.shiftKey) {
// Shift+click: select interval from current to clicked
selectMonthInterval(index);
} else {
selectSingleMonth(index);
}
});
monthList.appendChild(btn);
});
// Restore previous month selection when possible, otherwise default to latest month
if (restoreMonthSelectionState()) {
if (selectedMonthIndices.size > 1) {
await renderSelectedMonths();
} else {
await selectMonth(currentMonthIndex);
}
} else {
currentMonthIndex = availableMonths.length - 1;
selectedMonthIndices.clear();
selectedMonthIndices.add(currentMonthIndex);
await selectMonth(currentMonthIndex);
saveMonthSelectionState();
}
// Set up arrow button handlers
document.getElementById('prev-month').addEventListener('click', (event) => {
if (currentMonthIndex > 0) {
if (event.metaKey || event.ctrlKey) {
// Cmd/Ctrl+arrow: toggle previous month
toggleMonthSelection(currentMonthIndex - 1);
} else if (event.shiftKey) {
// Shift+arrow: extend selection to previous month
selectMonthInterval(currentMonthIndex - 1);
} else {
selectSingleMonth(currentMonthIndex - 1);
}
}
});
document.getElementById('next-month').addEventListener('click', (event) => {
if (currentMonthIndex < availableMonths.length - 1) {
if (event.metaKey || event.ctrlKey) {
// Cmd/Ctrl+arrow: toggle next month
toggleMonthSelection(currentMonthIndex + 1);
} else if (event.shiftKey) {
// Shift+arrow: extend selection to next month
selectMonthInterval(currentMonthIndex + 1);
} else {
selectSingleMonth(currentMonthIndex + 1);
}
}
});
// Set up home icon click handler (go back to root)
const homeIcon = document.querySelector('.center-home');
if (homeIcon) {
homeIcon.addEventListener('click', () => {
if (historyIndex > 0) {
history.back();
}
});
}
// Set up view switcher
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
btn.addEventListener('click', () => {
const target = btn.dataset.view;
if (target !== currentView) {
history.pushState(target === 'timeline' ? { timelineDrill: timelineDrillPath } : { monthView: true }, '');
}
switchView(target);
});
});
}
// Transform children data for drill-down (extracted from click handler)
function transformDrillDownData(parentNode, parentCategoryName) {
const newData = [];
const sortedChildren = sortBySubcategoryOrder(parentNode.children, parentCategoryName);
const predefinedOrder = subcategoryOrder[parentCategoryName] || [];
let unknownCount = 0;
sortedChildren.forEach((child, i) => {
const isUnknown = !predefinedOrder.includes(child.name);
const color = getSubcategoryColor(parentCategoryName, child.name, i, isUnknown ? unknownCount++ : null);
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
};
}
// Toggle a month in/out of multi-selection (Cmd/Ctrl+click behavior)
async function toggleMonthSelection(index) {
if (selectedMonthIndices.has(index)) {
// Don't allow deselecting the last month
if (selectedMonthIndices.size > 1) {
selectedMonthIndices.delete(index);
// If we removed the current navigation index, update it to another selected month
if (index === currentMonthIndex) {
currentMonthIndex = Math.max(...selectedMonthIndices);
}
}
} else {
selectedMonthIndices.add(index);
currentMonthIndex = index; // Update navigation index to the newly added month
}
await renderSelectedMonths();
saveMonthSelectionState();
}
// Select a single month (normal click behavior)
async function selectSingleMonth(index) {
selectedMonthIndices.clear();
selectedMonthIndices.add(index);
await selectMonth(index);
saveMonthSelectionState();
}
// Select interval from current month to target (Shift+click behavior)
async function selectMonthInterval(targetIndex) {
const start = Math.min(currentMonthIndex, targetIndex);
const end = Math.max(currentMonthIndex, targetIndex);
selectedMonthIndices.clear();
for (let i = start; i <= end; i++) {
selectedMonthIndices.add(i);
}
currentMonthIndex = targetIndex;
await renderSelectedMonths();
saveMonthSelectionState();
}
// Merge transaction data from multiple months
function mergeMonthsData(monthIndices) {
const allTransactions = [];
for (const index of monthIndices) {
const month = availableMonths[index];
const data = monthDataCache[month];
if (data) {
allTransactions.push(...data);
}
}
return allTransactions;
}
// Generate label for selected months
function generateSelectedMonthsLabel() {
if (selectedMonthIndices.size === 1) {
const index = [...selectedMonthIndices][0];
return getRussianMonthName(availableMonths[index]);
}
const sortedIndices = [...selectedMonthIndices].sort((a, b) => a - b);
// Check if consecutive
let isConsecutive = true;
for (let i = 1; i < sortedIndices.length; i++) {
if (sortedIndices[i] !== sortedIndices[i - 1] + 1) {
isConsecutive = false;
break;
}
}
if (isConsecutive && sortedIndices.length > 1) {
// Range: "Январь - Март"
const firstMonth = getRussianMonthName(availableMonths[sortedIndices[0]]);
const lastMonth = getRussianMonthName(availableMonths[sortedIndices[sortedIndices.length - 1]]);
return `${firstMonth} ${lastMonth}`;
} else {
// Non-consecutive: "3 месяца"
const count = sortedIndices.length;
// Russian plural rules for "месяц"
if (count === 1) return '1 месяц';
if (count >= 2 && count <= 4) return `${count} месяца`;
return `${count} месяцев`;
}
}
// Render chart with data from all selected months
async function renderSelectedMonths() {
// Merge data from all selected months
const mergedData = mergeMonthsData(selectedMonthIndices);
const savedPath = [...currentDrillPath];
// Update UI
updateMonthNavigator();
// Update hidden select for compatibility (use first selected month)
const sortedIndices = [...selectedMonthIndices].sort((a, b) => a - b);
const select = document.getElementById('month-select');
select.value = availableMonths[sortedIndices[0]];
// Generate month label
const monthLabel = generateSelectedMonthsLabel();
// Transform merged data to sunburst
const sunburstData = transformToSunburst(mergedData);
// Update the module-level original data for center-click reset
originalSunburstData = JSON.parse(JSON.stringify(sunburstData));
// Reset history for the new selection
resetHistory();
// Try to restore the same drill-down path in merged view
const navigatedState = navigateToPath(sunburstData, savedPath);
let targetData, targetTotal, targetName, targetPath;
if (navigatedState) {
saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
targetData = navigatedState.data;
targetTotal = navigatedState.total;
targetName = navigatedState.contextName;
targetPath = navigatedState.path;
saveToHistory(targetData, targetTotal, targetName, false, targetPath);
} else {
targetData = sunburstData.data;
targetTotal = sunburstData.total;
targetName = null;
targetPath = [];
saveToHistory(targetData, targetTotal, targetName, true, targetPath);
}
// Update the chart
if (option && option.series && option.series.data) {
option.series.data = targetData;
myChart.setOption({
series: [{
type: 'sunburst',
data: targetData,
layoutAnimation: true,
animationDuration: 500,
animationEasing: 'cubicInOut'
}]
}, {
lazyUpdate: false,
silent: false
});
updateCenterLabel(monthLabel, targetTotal, targetName);
setupHoverEvents({ total: targetTotal, data: targetData }, targetName);
updateAllMonthPreviews();
} else {
// Initial render
renderChart(mergedData);
}
// Scroll to show the current navigation month
const activeBtn = document.querySelector('.month-btn.active');
if (activeBtn) {
activeBtn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
saveMonthSelectionState();
}
// Select and load a specific month
async function selectMonth(index) {
const month = availableMonths[index];
// If clicking on the already-selected month while drilled down, reset to root
if (index === currentMonthIndex && currentDrillPath.length > 0 && drillDownHistory.length > 0) {
goToRoot();
return;
}
currentMonthIndex = 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
// First save the root state so we can go back to it
saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
targetData = navigatedState.data;
targetTotal = navigatedState.total;
targetName = navigatedState.contextName;
targetPath = navigatedState.path;
// Save the drilled-down state
saveToHistory(targetData, targetTotal, targetName, false, targetPath);
} else {
// Stay at root level
targetData = sunburstData.data;
targetTotal = sunburstData.total;
targetName = null;
targetPath = [];
// Save initial state
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);
myChart.setOption({
series: [{
type: 'sunburst',
data: targetData,
layoutAnimation: true,
animationDuration: 500,
animationEasing: 'cubicInOut'
}]
}, {
lazyUpdate: false,
silent: false
});
updateCenterLabel(russianMonth, targetTotal, targetName);
// 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' });
}
saveMonthSelectionState();
}
// Update month navigator UI state
function updateMonthNavigator() {
// Update button active states
const buttons = document.querySelectorAll('.month-btn');
buttons.forEach((btn, index) => {
if (selectedMonthIndices.has(index)) {
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
let _resizeDebounceTimer = null;
window.addEventListener('resize', function() {
if (currentView === 'month') {
adjustChartSize();
}
myChart.resize();
// Re-render timeline when crossing the mobile breakpoint
if (currentView === 'timeline') {
const nowMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
if (nowMobile !== _lastTimelineMobile) {
_lastTimelineMobile = nowMobile;
clearTimeout(_resizeDebounceTimer);
_resizeDebounceTimer = setTimeout(function() {
const currentOption = myChart.getOption();
const legendSelected = currentOption && currentOption.legend
&& currentOption.legend[0] && currentOption.legend[0].selected
? currentOption.legend[0].selected
: null;
renderTimelineChart(timelineDrillPath, 'replace', legendSelected);
}, 150);
}
}
});
// Function to adjust chart size based on screen width
function adjustChartSize() {
// Check if option is defined
if (!option) return;
const screenWidth = window.innerWidth;
const isMobile = screenWidth <= 500;
const isLandscapePhone = window.innerHeight < window.innerWidth && window.innerHeight <= 500;
const isCompact = isMobile || isLandscapePhone;
// Compact mode (portrait phones + landscape phones): extend layers to fill container
// Hole stays at 20%, layers scaled to fill remaining 80% (vs 55% on desktop)
const level1Inner = '20%';
const level1Outer = isCompact ? '56%' : '45%';
const level2Outer = isCompact ? '93%' : '70%';
const level3Outer = isCompact ? '100%' : '75%';
const outerRadius = isCompact ? '100%' : '95%';
// Smaller font sizes on compact, bolder for readability
const level1FontSize = isCompact ? 10 : 13;
const level1LineHeight = isCompact ? 12 : 15;
const level1FontWeight = isCompact ? 600 : 500;
const level2FontSize = isCompact ? 9 : 11;
const level2FontWeight = isCompact ? 600 : 400;
// Update layer proportions
option.series.levels[1].r0 = level1Inner;
option.series.levels[1].r = level1Outer;
option.series.levels[2].r0 = level1Outer;
option.series.levels[2].r = level2Outer;
option.series.levels[3].r0 = level2Outer;
option.series.levels[3].r = level3Outer;
option.series.radius = [0, outerRadius];
// Update font sizes and weights
option.series.levels[1].label.fontSize = level1FontSize;
option.series.levels[1].label.lineHeight = level1LineHeight;
option.series.levels[1].label.fontWeight = level1FontWeight;
option.series.levels[2].label.fontSize = level2FontSize;
option.series.levels[2].label.fontWeight = level2FontWeight;
// Update level 3 labels: hide on compact/small screens, show on desktop
if (isCompact) {
option.series.levels[3].label.show = false;
option.series.levels[3].label.position = 'inside';
} else if (screenWidth < 950) {
option.series.levels[3].label.show = false;
option.series.levels[3].label.position = 'outside';
} else {
option.series.levels[3].label.show = function(param) {
return param.percent > 0.000;
};
option.series.levels[3].label.position = 'outside';
}
// Calculate center position
let centerPosition;
if (screenWidth <= 850 || isCompact) {
// Stacked layout or compact landscape: center the chart
centerPosition = 50;
} else if (screenWidth >= 1000) {
centerPosition = 50;
} else {
// Gradual transition between 850-1000px (side-by-side layout)
const transitionProgress = (screenWidth - 850) / 150;
centerPosition = 40 + (transitionProgress * 10);
}
// Update chart center position
option.series.center = [`${centerPosition}%`, '50%'];
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 = formatRUB(item.value) + '\u202F₽';
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">${formatRUB(value)}\u202F₽</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">${formatRUB(sunburstData.total)}\u202F₽</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;
// Prevent details panel reset when user is interacting with it (touch devices)
let isInteractingWithDetails = false;
const detailsBox = document.getElementById('details-box');
if (detailsBox) {
detailsBox.addEventListener('pointerdown', () => {
isInteractingWithDetails = true;
});
// Clear after a short delay to allow click handlers to fire
detailsBox.addEventListener('pointerup', () => {
setTimeout(() => { isInteractingWithDetails = false; }, 300);
});
}
// 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 && !isInteractingWithDetails) {
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 && !isInteractingWithDetails) {
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;
}
if (isInteractingWithDetails) 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 && !isInteractingWithDetails) {
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 = formatRUB(value);
}
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();
} else if (currentView === 'timeline' && typeof window._timelineResetLegend === 'function') {
window._timelineResetLegend();
e.preventDefault();
}
}
}
// 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();
}
};
}