// 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 drill-down navigation if (e.state && e.state.drillDown !== undefined) { const stateIndex = e.state.drillDown; if (stateIndex >= 0 && stateIndex < drillDownHistory.length) { historyIndex = stateIndex; navigateToHistoryState(drillDownHistory[historyIndex]); } } else { // No state or initial state - go to root if (drillDownHistory.length > 0) { historyIndex = 0; navigateToHistoryState(drillDownHistory[0]); } } }); // Function to parse CSV data async function parseCSV(file) { const response = await fetch(file); const data = await response.text(); // Split the CSV into rows const rows = data.split('\n'); // Extract headers and remove quotes const headers = rows[0].split(',').map(header => header.replace(/"/g, '')); // Parse the data rows const result = []; for (let i = 1; i < rows.length; i++) { if (!rows[i].trim()) continue; // Handle commas within quoted fields const row = []; let inQuote = false; let currentValue = ''; for (let j = 0; j < rows[i].length; j++) { const char = rows[i][j]; if (char === '"') { inQuote = !inQuote; } else if (char === ',' && !inQuote) { row.push(currentValue.replace(/"/g, '')); currentValue = ''; } else { currentValue += char; } } // Push the last value row.push(currentValue.replace(/"/g, '')); // Create an object from headers and row values const obj = {}; for (let j = 0; j < headers.length; j++) { obj[headers[j]] = row[j]; } result.push(obj); } return result; } // Function to transform data into a sunburst format function transformToSunburst(data) { // Calculate total spending let totalSpending = 0; // Group by categories const categories = []; const categoryMap = {}; // Store raw transactions for each microcategory to display in details const transactionMap = {}; // Predefined colors for categories const colors = [ '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#2d8041', '#fc8452', '#7b4d90', '#ea7ccc', '#4cae72', '#d56358', '#82b1ff', '#f19143', '#addf84', '#6f7787' ]; // Debug counter let lekarstvaCount = 0; let emptyMicroCount = 0; data.forEach(item => { const category = item.category || ''; const subcategory = item.subcategory || ''; // Get original microcategory value - important for transaction filtering later const originalMicrocategory = item.microcategory || ''; const amount = Math.abs(parseFloat(item.amount_rub)); // For display in the chart, we might use simple_name for transactions >=1000RUB let displayMicrocategory = originalMicrocategory; if (displayMicrocategory === '' && amount >= 1000 && item.simple_name) { displayMicrocategory = item.simple_name; } // Debug Лекарства category /*if (subcategory === "Лекарства") { lekarstvaCount++; console.log(`Transaction in Лекарства: ${item.simple_name}, originalMicro="${originalMicrocategory}", displayMicro="${displayMicrocategory}", amount=${amount}`); if (originalMicrocategory === '') { emptyMicroCount++; } }*/ const transactionKey = `${category}|${subcategory}|${displayMicrocategory}`; if (!isNaN(amount)) { totalSpending += amount; // Create transaction object and include displayMicrocategory property const transactionObj = { name: item.simple_name || 'Transaction', value: amount, date: item.date, microCategory: originalMicrocategory, // Store ORIGINAL value hasNoMicroCategory: originalMicrocategory === '', // Flag for easy filtering (legacy) displayMicrocategory: displayMicrocategory, originalRow: item // Store full CSV row for modal display }; // Save transaction data for detail box with ORIGINAL microcategory if (!transactionMap[transactionKey]) { transactionMap[transactionKey] = []; } transactionMap[transactionKey].push(transactionObj); if (!categoryMap[category] && category !== '') { categoryMap[category] = { name: category, value: 0, children: {}, itemStyle: {}, transactions: [] // Will be populated with ALL transactions }; categories.push(categoryMap[category]); } if (category !== '') { if (!categoryMap[category].children[subcategory] && subcategory !== '') { categoryMap[category].children[subcategory] = { name: subcategory, value: 0, children: {}, itemStyle: {}, transactions: [] // Will be populated with ALL transactions }; } // Add transaction to category directly if (categoryMap[category].transactions) { categoryMap[category].transactions.push(transactionObj); } if (subcategory !== '') { // Add transaction to subcategory directly if (categoryMap[category].children[subcategory].transactions) { categoryMap[category].children[subcategory].transactions.push(transactionObj); } if (!categoryMap[category].children[subcategory].children[displayMicrocategory] && displayMicrocategory !== '') { categoryMap[category].children[subcategory].children[displayMicrocategory] = { name: displayMicrocategory, value: 0, itemStyle: {}, transactions: [] }; } // Add transaction to microcategory if there is one if (displayMicrocategory !== '' && categoryMap[category].children[subcategory].children[displayMicrocategory].transactions) { categoryMap[category].children[subcategory].children[displayMicrocategory].transactions.push(transactionObj); } categoryMap[category].value += amount; categoryMap[category].children[subcategory].value += amount; if (displayMicrocategory !== '') { categoryMap[category].children[subcategory].children[displayMicrocategory].value += amount; } } else { categoryMap[category].value += amount; } } } }); console.log(`Found ${lekarstvaCount} transactions in Лекарства category, ${emptyMicroCount} with empty microcategory`); // Define fixed order for top categories const categoryOrder = [ 'Квартира', 'Еда', 'Технологии', 'Развлечения', 'Семьи', 'Здоровье', 'Логистика', 'Расходники', 'Красота' ]; // Sort categories by the predefined order categories.sort((a, b) => { const indexA = categoryOrder.indexOf(a.name); const indexB = categoryOrder.indexOf(b.name); // If both categories are in our predefined list, sort by that order if (indexA !== -1 && indexB !== -1) { return indexA - indexB; } // If only a is in the list, it comes first else if (indexA !== -1) { return -1; } // If only b is in the list, it comes first else if (indexB !== -1) { return 1; } // For categories not in our list, sort by value (largest to smallest) else { return b.value - a.value; } }); // Convert the map to an array structure for ECharts const result = []; // Assign colors to categories categories.forEach((category, index) => { const colorIndex = index % colors.length; const baseColor = colors[colorIndex]; const categoryNode = { name: category.name, value: category.value, children: [], itemStyle: { color: baseColor }, transactions: category.transactions }; // Get subcategories and sort by value const subcategories = []; for (const subcatKey in category.children) { subcategories.push(category.children[subcatKey]); } subcategories.sort((a, b) => b.value - a.value); // Generate color variations for subcategories based on their size const subcatColors = generateColorGradient(baseColor, subcategories.length || 1); // Process each subcategory subcategories.forEach((subcategory, subIndex) => { // Adjust subcategory color based on its relative size within category const subcatColor = subcatColors[subIndex]; const subcategoryNode = { name: subcategory.name, value: subcategory.value, children: [], itemStyle: { color: subcatColor }, transactions: subcategory.transactions }; // Get microcategories and sort by value const microcategories = []; for (const microKey in subcategory.children) { microcategories.push(subcategory.children[microKey]); } microcategories.sort((a, b) => b.value - a.value); // Generate color variations for microcategories based on their size const microColors = generateColorGradient(subcatColor, microcategories.length || 1); // Add microcategories to subcategory microcategories.forEach((micro, microIndex) => { subcategoryNode.children.push({ name: micro.name, value: micro.value, itemStyle: { color: microColors[microIndex] }, transactions: micro.transactions }); }); if (subcategoryNode.children.length > 0) { categoryNode.children.push(subcategoryNode); } else { categoryNode.children.push({ name: subcategory.name, value: subcategory.value, itemStyle: { color: subcatColor }, transactions: subcategory.transactions }); } }); result.push(categoryNode); }); return { total: totalSpending, data: result }; } // Build ECharts stacked bar option for timeline view function buildTimelineOption() { const months = availableMonths; const xLabels = months.map(m => formatMonthLabel(m)); // Collect per-category totals for each month const categoryTotals = {}; // { categoryName: [amountForMonth0, amountForMonth1, ...] } months.forEach((month, mi) => { const data = monthDataCache[month]; if (!data) return; data.forEach(item => { const cat = item.category || ''; if (!cat) return; const amount = Math.abs(parseFloat(item.amount_rub)); if (isNaN(amount)) return; if (!categoryTotals[cat]) { categoryTotals[cat] = new Array(months.length).fill(0); } categoryTotals[cat][mi] += amount; }); }); // Build series in categoryOrder, then append any unknown categories const seriesList = []; const usedCategories = new Set(); categoryOrder.forEach((catName, ci) => { if (!categoryTotals[catName]) return; usedCategories.add(catName); seriesList.push({ name: catName, type: 'bar', stack: 'total', barMaxWidth: 50, barCategoryGap: '35%', data: categoryTotals[catName], itemStyle: { color: categoryColors[ci] } }); }); // Unknown categories (not in categoryOrder) Object.keys(categoryTotals).forEach(catName => { if (usedCategories.has(catName)) return; seriesList.push({ name: catName, type: 'bar', stack: 'total', barMaxWidth: 50, barCategoryGap: '35%', data: categoryTotals[catName] }); }); return { backgroundColor: '#fff', tooltip: { show: false }, grid: { left: 30, right: '28%', top: 30, bottom: 30, containLabel: true }, xAxis: { type: 'category', data: xLabels, axisLabel: { fontSize: 11 } }, yAxis: { type: 'value', axisLabel: { formatter: function(val) { if (val >= 1000) return Math.round(val / 1000) + 'k'; return val; }, fontSize: 11 } }, series: seriesList }; } // Update details panel for timeline view function updateTimelineDetails(monthIndex) { const detailsHeader = document.getElementById('details-header'); const topItemsEl = document.getElementById('top-items'); // Reset header to clean state (remove any eye buttons from sunburst mode) detailsHeader.innerHTML = ''; const hoverName = detailsHeader.querySelector('.hover-name'); const hoverAmount = detailsHeader.querySelector('.hover-amount'); let monthLabel, data; if (monthIndex === null || monthIndex === undefined) { monthLabel = 'Все месяцы'; data = []; for (const month of availableMonths) { if (monthDataCache[month]) data.push(...monthDataCache[month]); } } else { const month = availableMonths[monthIndex]; monthLabel = formatMonthLabel(month); data = monthDataCache[month] || []; } // Compute category totals const catTotals = {}; let total = 0; data.forEach(item => { const cat = item.category || ''; if (!cat) return; const amount = Math.abs(parseFloat(item.amount_rub)); if (isNaN(amount)) return; catTotals[cat] = (catTotals[cat] || 0) + amount; total += amount; }); hoverName.textContent = monthLabel; hoverAmount.textContent = formatRUB(total) + '\u202F₽'; // Sort by value descending const sorted = Object.entries(catTotals).sort((a, b) => b[1] - a[1]); topItemsEl.innerHTML = ''; sorted.forEach(([cat, amount]) => { const pct = ((amount / total) * 100).toFixed(1); const colorIdx = categoryOrder.indexOf(cat); const color = colorIdx !== -1 ? categoryColors[colorIdx] : '#999'; const item = document.createElement('div'); item.className = 'top-item'; item.innerHTML = `${cat}${pct}%${formatRUB(amount)}\u202F₽`; topItemsEl.appendChild(item); }); } // 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'); // Resize after layout change, then render stacked bar myChart.resize(); myChart.setOption(buildTimelineOption(), true); // Hover events update the details panel myChart.on('mouseover', function(params) { if (params.componentType === 'series') { updateTimelineDetails(params.dataIndex); } }); myChart.on('globalout', function() { updateTimelineDetails(null); }); // Show aggregate by default updateTimelineDetails(null); // Click handler: clicking a bar switches to month view for that month myChart.on('click', function(params) { if (params.componentType === 'series') { const monthIndex = params.dataIndex; if (monthIndex >= 0 && monthIndex < availableMonths.length) { selectedMonthIndices.clear(); selectedMonthIndices.add(monthIndex); currentMonthIndex = monthIndex; switchView('month'); selectMonth(monthIndex); saveMonthSelectionState(); } } }); } else { container.classList.remove('timeline-mode'); // Remove all chart event listeners myChart.off('click'); myChart.off('mouseover'); myChart.off('mouseout'); myChart.off('globalout'); // 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}
Amount: ${value}\u202F₽
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 })); } 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 = []; } // 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', () => { switchView(btn.dataset.view); }); }); } // 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 window.addEventListener('resize', function() { if (currentView === 'month') { adjustChartSize(); } myChart.resize(); }); // 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; // 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; // 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 mobile, show on desktop (with conditions) if (isMobile) { 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) { // Stacked layout: center the chart since details box is below 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 = ''; 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 = '
No details available
'; } } // Variable to store currently hovered data for the header eye button let currentHoveredData = null; // Helper function to update the details header function updateDetailsHeader(name, value, color, data) { currentHoveredData = data; detailsHeader.innerHTML = ` ${name} ${formatRUB(value)}\u202F₽ `; // 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 = ` ${displayName} ${formatRUB(sunburstData.total)}\u202F₽ `; // Add click handler to header eye button const headerEyeBtn = detailsHeader.querySelector('.header-eye-btn'); if (headerEyeBtn) { headerEyeBtn.addEventListener('click', (e) => { e.stopPropagation(); openTransactionModal(totalData); }); } // Show items - sort by value for root level, use fixed order for drilled-down const itemsToDisplay = contextName ? [...sunburstData.data] // Drilled down: use fixed subcategory order : [...sunburstData.data].sort((a, b) => b.value - a.value); // Root level: sort by value displayDetailsItems(itemsToDisplay, sunburstData.total); } // Show default view initially showDefaultView(); // Track if we're inside a section to handle sector exit properly let isInsideSection = false; // Add general mousemove event listener to detect when outside chart circle chartDom.addEventListener('mousemove', function(e) { // Get mouse position relative to chart container const rect = chartDom.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // Track mouse position for eye button hide logic lastMouseX = mouseX; lastMouseY = mouseY; // If not inside the chart circle and we were inside a section, show default view if (!isInsideChart(mouseX, mouseY) && isInsideSection) { isInsideSection = false; // Reset details immediately when leaving a section showDefaultView(); } }); // Add mouseover event listener for sectors myChart.on('mouseover', function(params) { if (params.data) { // Show floating eye button at end of sector label showChartEyeButton(params); // Compute depth from treePathInfo instead of using params.depth let depth = params.treePathInfo ? params.treePathInfo.length - 1 : 0; // Debug log for every mouseover event using computed depth console.log('Hovered node:', { depth: depth, name: params.name, transactionsLength: (params.data.transactions ? params.data.transactions.length : 0), childrenLength: (params.data.children ? params.data.children.length : 0) }); // Handle subcategory nodes (depth 2): show microcategories and transactions without microcategory if (depth === 2) { isInsideSection = true; console.log('DEBUG: Hovered subcategory node:', params.name, 'Transactions:', params.data.transactions, 'Children:', params.data.children); const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); updateDetailsHeader(params.name, params.value, itemColor, params.data); const MAX_DETAIL_ITEMS = 10; let itemsToShow = []; if (params.data.children && params.data.children.length > 0) { itemsToShow = [...params.data.children]; } if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) { const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); // Create a slightly transparent effect by mixing with white const modifiedColor = tinycolor.mix(currentColor, '#ffffff', 60).toString(); const remaining = MAX_DETAIL_ITEMS - itemsToShow.length; const transactionsWithoutMicro = (params.data.transactions || []) .filter(t => t.displayMicrocategory === '') .map(t => ({ ...t, itemStyle: { color: modifiedColor } })); itemsToShow = itemsToShow.concat(transactionsWithoutMicro.slice(0, remaining)); } displayDetailsItems(itemsToShow, params.value); return; } // Handle category nodes (depth 1): show subcategories and transactions without microcategory if (depth === 1) { isInsideSection = true; const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); updateDetailsHeader(params.name, params.value, itemColor, params.data); const MAX_DETAIL_ITEMS = 10; let itemsToShow = []; if (params.data.children && params.data.children.length > 0) { itemsToShow = [...params.data.children]; } if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) { const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); // Create a slightly transparent effect by mixing with white const modifiedColor = tinycolor.mix(currentColor, '#ffffff', 30).toString(); const remaining = MAX_DETAIL_ITEMS - itemsToShow.length; const transactionsWithoutMicro = (params.data.transactions || []) .filter(t => t.displayMicrocategory === '') .map(t => ({ ...t, itemStyle: { color: modifiedColor } })); itemsToShow = itemsToShow.concat(transactionsWithoutMicro.slice(0, remaining)); } displayDetailsItems(itemsToShow, params.value); return; } // For other depths, continue with existing behaviour isInsideSection = true; const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); updateDetailsHeader(params.name, params.value, itemColor, params.data); const MAX_DETAIL_ITEMS = 10; let itemsToShow = []; if (params.data.children && params.data.children.length > 0) { const sortedChildren = [...params.data.children]; const allMicrocategories = []; const displayedNames = new Set(); for (const child of sortedChildren) { if (child.children && child.children.length > 0) { const childMicros = [...child.children].sort((a, b) => b.value - a.value); for (const micro of childMicros) { if (!displayedNames.has(micro.name)) { allMicrocategories.push(micro); displayedNames.add(micro.name); if (micro.transactions) { micro.transactions.forEach(t => { displayedNames.add(t.name); }); } } } } } if (allMicrocategories.length > 0) { itemsToShow = allMicrocategories.slice(0, MAX_DETAIL_ITEMS); } else { itemsToShow = sortedChildren.slice(0, MAX_DETAIL_ITEMS); } if (itemsToShow.length < MAX_DETAIL_ITEMS) { const allTransactions = []; for (const subcategory of sortedChildren) { if (subcategory.transactions && subcategory.transactions.length > 0) { subcategory.transactions.forEach(transaction => { if (!displayedNames.has(transaction.name)) { allTransactions.push({ name: transaction.name, value: transaction.value, displayMicrocategory: transaction.displayMicrocategory, itemStyle: { color: subcategory.itemStyle ? subcategory.itemStyle.color : '#cccccc' } }); displayedNames.add(transaction.name); } }); } } allTransactions.sort((a, b) => b.value - a.value); const transactionsWithoutMicro = allTransactions.filter(t => t.displayMicrocategory === ''); if (transactionsWithoutMicro.length > 0) { const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); // Create a slightly transparent effect by mixing with white const modifiedColor = tinycolor.mix(currentColor, '#ffffff', 30).toString(); const remaining = MAX_DETAIL_ITEMS - itemsToShow.length; const filteredTransactions = transactionsWithoutMicro .filter(t => !allMicrocategories.some(m => m.name === t.name)) .map(t => ({ ...t, itemStyle: { color: modifiedColor } })); itemsToShow = itemsToShow.concat(filteredTransactions.slice(0, remaining)); } } } else if (params.data.transactions) { const sortedTransactions = [...params.data.transactions].sort((a, b) => b.value - a.value); const coloredTransactions = sortedTransactions.map(t => ({ ...t, color: params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc') })); itemsToShow = coloredTransactions.slice(0, MAX_DETAIL_ITEMS); } displayDetailsItems(itemsToShow, params.value); } }); // When mouse leaves a section but is still within the chart, we'll handle it with mousemove myChart.on('mouseout', function(params) { if (params.data) { isOverChartSector = false; isInsideSection = false; // Hide the floating eye button (unless hovering over it) hideChartEyeButton(); // Reset details with a small delay to allow mouseover of next sector to fire first setTimeout(() => { if (!isOverEyeButton && !isInsideSection) { showDefaultView(); } }, 50); } }); // Also reset when mouse leaves the chart container entirely chartDom.addEventListener('mouseleave', function(e) { // Don't reset if moving to the eye button if (e.relatedTarget === chartEyeBtn || (e.relatedTarget && chartEyeBtn.contains(e.relatedTarget))) { return; } isInsideSection = false; showDefaultView(); }); // Add back the downplay event handler - this is triggered when sections lose emphasis myChart.on('downplay', function(params) { // Reset to default view when a section is no longer emphasized (unless hovering eye button or still in section) setTimeout(() => { if (!isOverEyeButton && !isInsideSection) { showDefaultView(); } }, 50); }); } // CSV column labels for human-readable display const csvColumnLabels = { transaction_date: 'Date', processing_date: 'Processing Date', transaction_description: 'Description', comment: 'Comment', mcc: 'MCC', card_info: 'Card', account: 'Account', merchant_name: 'Merchant', location: 'Location', info_source: 'Source', amount_original: 'Original Amount', currency_original: 'Original Currency', amount_rub: 'Amount (₽)', category: 'Category', subcategory: 'Subcategory', microcategory: 'Microcategory', simple_name: 'Name' }; // Preferred column order (most important first) const csvColumnOrder = ['transaction_date', 'simple_name', 'amount_rub', 'category', 'subcategory', 'microcategory', 'transaction_description', 'merchant_name', 'account', 'card_info', 'location', 'amount_original', 'currency_original', 'mcc', 'info_source', 'processing_date', 'comment']; // Modal state let currentTransactions = []; let currentColumns = []; let currentSortColumn = null; let currentSortDirection = 'desc'; // Open transaction modal function openTransactionModal(item) { const modal = document.getElementById('transaction-modal'); const modalTitle = document.getElementById('modal-title'); // Set title modalTitle.textContent = item.name; // Gather transactions let transactions = []; if (item.transactions && item.transactions.length > 0) { transactions = item.transactions.filter(t => t.originalRow); } else if (item.originalRow) { transactions = [item]; } if (transactions.length === 0) { const modalTbody = document.getElementById('modal-tbody'); const modalThead = document.getElementById('modal-thead'); modalThead.innerHTML = ''; modalTbody.innerHTML = 'No transaction data available'; 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(); } } } // 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(); } }; }