// 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) // Function to parse CSV data async function parseCSV(file) { const response = await fetch(file); const data = await response.text(); // Split the CSV into rows const rows = data.split('\n'); // Extract headers and remove quotes const headers = rows[0].split(',').map(header => header.replace(/"/g, '')); // Parse the data rows const result = []; for (let i = 1; i < rows.length; i++) { if (!rows[i].trim()) continue; // Handle commas within quoted fields const row = []; let inQuote = false; let currentValue = ''; for (let j = 0; j < rows[i].length; j++) { const char = rows[i][j]; if (char === '"') { inQuote = !inQuote; } else if (char === ',' && !inQuote) { row.push(currentValue.replace(/"/g, '')); currentValue = ''; } else { currentValue += char; } } // Push the last value row.push(currentValue.replace(/"/g, '')); // Create an object from headers and row values const obj = {}; for (let j = 0; j < headers.length; j++) { obj[headers[j]] = row[j]; } result.push(obj); } return result; } // Function to transform data into a sunburst format function transformToSunburst(data) { // Calculate total spending let totalSpending = 0; // Group by categories const categories = []; const categoryMap = {}; // Store raw transactions for each microcategory to display in details const transactionMap = {}; // Predefined colors for categories const colors = [ '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#2d8041', '#fc8452', '#7b4d90', '#ea7ccc', '#4cae72', '#d56358', '#82b1ff', '#f19143', '#addf84', '#6f7787' ]; // Debug counter let lekarstvaCount = 0; let emptyMicroCount = 0; data.forEach(item => { const category = item.category || ''; const subcategory = item.subcategory || ''; // Get original microcategory value - important for transaction filtering later const originalMicrocategory = item.microcategory || ''; const amount = Math.abs(parseFloat(item.amount_rub)); // For display in the chart, we might use simple_name for transactions >=1000RUB let displayMicrocategory = originalMicrocategory; if (displayMicrocategory === '' && amount >= 1000 && item.simple_name) { displayMicrocategory = item.simple_name; } // Debug Лекарства category /*if (subcategory === "Лекарства") { lekarstvaCount++; console.log(`Transaction in Лекарства: ${item.simple_name}, originalMicro="${originalMicrocategory}", displayMicro="${displayMicrocategory}", amount=${amount}`); if (originalMicrocategory === '') { emptyMicroCount++; } }*/ const transactionKey = `${category}|${subcategory}|${displayMicrocategory}`; if (!isNaN(amount)) { totalSpending += amount; // Create transaction object and include displayMicrocategory property const transactionObj = { name: item.simple_name || 'Transaction', value: amount, date: item.date, microCategory: originalMicrocategory, // Store ORIGINAL value hasNoMicroCategory: originalMicrocategory === '', // Flag for easy filtering (legacy) displayMicrocategory: displayMicrocategory, originalRow: item // Store full CSV row for modal display }; // Save transaction data for detail box with ORIGINAL microcategory if (!transactionMap[transactionKey]) { transactionMap[transactionKey] = []; } transactionMap[transactionKey].push(transactionObj); if (!categoryMap[category] && category !== '') { categoryMap[category] = { name: category, value: 0, children: {}, itemStyle: {}, transactions: [] // Will be populated with ALL transactions }; categories.push(categoryMap[category]); } if (category !== '') { if (!categoryMap[category].children[subcategory] && subcategory !== '') { categoryMap[category].children[subcategory] = { name: subcategory, value: 0, children: {}, itemStyle: {}, transactions: [] // Will be populated with ALL transactions }; } // Add transaction to category directly if (categoryMap[category].transactions) { categoryMap[category].transactions.push(transactionObj); } if (subcategory !== '') { // Add transaction to subcategory directly if (categoryMap[category].children[subcategory].transactions) { categoryMap[category].children[subcategory].transactions.push(transactionObj); } if (!categoryMap[category].children[subcategory].children[displayMicrocategory] && displayMicrocategory !== '') { categoryMap[category].children[subcategory].children[displayMicrocategory] = { name: displayMicrocategory, value: 0, itemStyle: {}, transactions: [] }; } // Add transaction to microcategory if there is one if (displayMicrocategory !== '' && categoryMap[category].children[subcategory].children[displayMicrocategory].transactions) { categoryMap[category].children[subcategory].children[displayMicrocategory].transactions.push(transactionObj); } categoryMap[category].value += amount; categoryMap[category].children[subcategory].value += amount; if (displayMicrocategory !== '') { categoryMap[category].children[subcategory].children[displayMicrocategory].value += amount; } } else { categoryMap[category].value += amount; } } } }); console.log(`Found ${lekarstvaCount} transactions in Лекарства category, ${emptyMicroCount} with empty microcategory`); // Define fixed order for top categories const categoryOrder = [ 'Квартира', 'Еда', 'Технологии', 'Развлечения', 'Семьи', 'Здоровье', 'Логистика', 'Расходники', 'Красота' ]; // Sort categories by the predefined order categories.sort((a, b) => { const indexA = categoryOrder.indexOf(a.name); const indexB = categoryOrder.indexOf(b.name); // If both categories are in our predefined list, sort by that order if (indexA !== -1 && indexB !== -1) { return indexA - indexB; } // If only a is in the list, it comes first else if (indexA !== -1) { return -1; } // If only b is in the list, it comes first else if (indexB !== -1) { return 1; } // For categories not in our list, sort by value (largest to smallest) else { return b.value - a.value; } }); // Convert the map to an array structure for ECharts const result = []; // Assign colors to categories categories.forEach((category, index) => { const colorIndex = index % colors.length; const baseColor = colors[colorIndex]; const categoryNode = { name: category.name, value: category.value, children: [], itemStyle: { color: baseColor }, transactions: category.transactions }; // Get subcategories and sort by value const subcategories = []; for (const subcatKey in category.children) { subcategories.push(category.children[subcatKey]); } subcategories.sort((a, b) => b.value - a.value); // Generate color variations for subcategories based on their size const subcatColors = generateColorGradient(baseColor, subcategories.length || 1); // Process each subcategory subcategories.forEach((subcategory, subIndex) => { // Adjust subcategory color based on its relative size within category const subcatColor = subcatColors[subIndex]; const subcategoryNode = { name: subcategory.name, value: subcategory.value, children: [], itemStyle: { color: subcatColor }, transactions: subcategory.transactions }; // Get microcategories and sort by value const microcategories = []; for (const microKey in subcategory.children) { microcategories.push(subcategory.children[microKey]); } microcategories.sort((a, b) => b.value - a.value); // Generate color variations for microcategories based on their size const microColors = generateColorGradient(subcatColor, microcategories.length || 1); // Add microcategories to subcategory microcategories.forEach((micro, microIndex) => { subcategoryNode.children.push({ name: micro.name, value: micro.value, itemStyle: { color: microColors[microIndex] }, transactions: micro.transactions }); }); if (subcategoryNode.children.length > 0) { categoryNode.children.push(subcategoryNode); } else { categoryNode.children.push({ name: subcategory.name, value: subcategory.value, itemStyle: { color: subcatColor }, transactions: subcategory.transactions }); } }); result.push(categoryNode); }); return { total: totalSpending, data: result }; } // Function to get Russian month name from YYYY-MM format function getRussianMonthName(dateStr) { const monthNum = parseInt(dateStr.split('-')[1]); const russianMonths = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' ]; return russianMonths[monthNum - 1]; } // Function to render the chart function renderChart(data) { const sunburstData = transformToSunburst(data); // Store the original data for resetting (module-level variable) originalSunburstData = JSON.parse(JSON.stringify(sunburstData)); // Get the currently selected month const selectedMonth = document.getElementById('month-select').value; const russianMonth = getRussianMonthName(selectedMonth); // Calculate the correct center position first const screenWidth = window.innerWidth; let centerPosition; if (screenWidth >= 1000) { // For screens 1000px and wider, keep centered at 50% centerPosition = 50; } else if (screenWidth >= 640) { // Gradual transition between 640-1000px const transitionProgress = (screenWidth - 640) / 360; // 0 to 1 centerPosition = 40 + (transitionProgress * 10); // 40 to 50 } else { // For smaller screens centerPosition = 40; } option = { backgroundColor: '#fff', grid: { left: '10%', containLabel: true }, animation: true, //animationThreshold: 2000, //animationDuration: 1000, //animationEasing: 'cubicOut', //animationDurationUpdate: 500, //animationEasingUpdate: 'cubicInOut', series: { type: 'sunburst', radius: [0, '95%'], center: [`${centerPosition}%`, '50%'], startAngle: 0, nodeClick: false, data: sunburstData.data, sort: null, // Use 'null' to maintain the sorting we did in the data transformation label: { show: true, formatter: function(param) { if (param.depth === 0) { // No word wrapping for top-level categories return param.name; } else { return ''; } }, minAngle: 5, align: 'center', verticalAlign: 'middle', position: 'inside' }, itemStyle: { borderWidth: 1, borderColor: '#fff' }, levels: [ {}, { // First level - Categories r0: '20%', r: '45%', label: { show: true, rotate: 'radial', fontSize: 13, lineHeight: 15, verticalAlign: 'center', position: 'inside', formatter: function(param) { // No special formatting for level 1 return param.name; } }, itemStyle: { borderWidth: 2 } }, { // Second level - Subcategories r0: '45%', r: '70%', label: { show: function(param) { // Show labels for sectors that are at least 5% of the total return param.percent >= 0.05; }, fontSize: 11, align: 'center', position: 'inside', distance: 5, formatter: function(param) { // If there's only one word, never wrap it if (!param.name.includes(' ')) { return param.name; } // If the text contains spaces, consider word wrapping for better visibility const words = param.name.split(' '); // Skip wrapping for single words or very small sectors // Estimate sector size from value percentage if (words.length === 1 || param.percent < 0.03) { return param.name; } // Process words to keep short prepositions (< 4 chars) with the next word const processedWords = []; let i = 0; while (i < words.length) { if (i < words.length - 1 && words[i].length < 4) { // Combine short word with the next word processedWords.push(words[i] + ' ' + words[i+1]); i += 2; } else { processedWords.push(words[i]); i++; } } // Skip wrapping if we're down to just one processed word if (processedWords.length === 1) { return processedWords[0]; } // If only 2 processed words, put one on each line if (processedWords.length == 2) { return processedWords[0] + '\n' + processedWords[1]; } // If 3 processed words, put each word on its own line else if (processedWords.length == 3) { return processedWords[0] + '\n' + processedWords[1] + '\n' + processedWords[2]; } // For more words, split more aggressively else if (processedWords.length > 3) { // Try to create 3 relatively even lines const part1 = Math.floor(processedWords.length / 3); const part2 = Math.floor(processedWords.length * 2 / 3); return processedWords.slice(0, part1).join(' ') + '\n' + processedWords.slice(part1, part2).join(' ') + '\n' + processedWords.slice(part2).join(' '); } return param.name; } }, itemStyle: { borderWidth: 1 }, emphasis: { label: { show: true, distance: 20 } } }, { // Third level - Microcategories - a bit wider than before r0: '70%', r: '75%', label: { // Only show labels conditionally based on segment size show: function(param) { // Show label if segment is wide enough (>1%) return param.percent > 0.000; }, position: 'outside', padding: 3, minAngle: 3, // Add this - default is 5, reducing it will show more labels silent: false, fontSize: 10, formatter: function(param) { // If there's only one word, never wrap it if (!param.name.includes(' ')) { return param.name; } // If the text contains spaces, consider word wrapping for better visibility const words = param.name.split(' '); // Skip wrapping for single words or very small sectors // Estimate sector size from value percentage if (words.length === 1 || param.percent < 0.02) { return param.name; } // Process words to keep short prepositions (< 4 chars) with the next word const processedWords = []; let i = 0; while (i < words.length) { if (i < words.length - 1 && words[i].length < 4) { // Combine short word with the next word processedWords.push(words[i] + ' ' + words[i+1]); i += 2; } else { processedWords.push(words[i]); i++; } } // Skip wrapping if we're down to just one processed word if (processedWords.length === 1) { return processedWords[0]; } // If only 2 processed words, put one on each line if (processedWords.length == 2) { return processedWords[0] + '\n' + processedWords[1]; } // If 3 processed words, put each word on its own line else if (processedWords.length == 3) { return processedWords[0] + '\n' + processedWords[1] + '\n' + processedWords[2]; } // For more words, split more aggressively else if (processedWords.length > 3) { // Try to create 3 relatively even lines const part1 = Math.floor(processedWords.length / 3); const part2 = Math.floor(processedWords.length * 2 / 3); return processedWords.slice(0, part1).join(' ') + '\n' + processedWords.slice(part1, part2).join(' ') + '\n' + processedWords.slice(part2).join(' '); } return param.name; } }, itemStyle: { borderWidth: 3 } } ], emphasis: { focus: 'relative' }, // Add more space between wedges gap: 2, tooltip: { trigger: 'item', formatter: function(info) { const value = info.value.toLocaleString(); const name = info.name; // Calculate percentage of total const percentage = ((info.value / sunburstData.total) * 100).toFixed(1); return `${name}
Amount: ${value} RUB
Percentage: ${percentage}%`; } } }, graphic: { elements: [ { type: 'text', left: 'center', top: 'middle', z: 100, style: { text: russianMonth + '\n' + sunburstData.total.toFixed(0).toLocaleString() + ' ₽', fontWeight: 'bold', fontSize: 18, textAlign: 'left', fill: '#000' } } ] } }; // Handle chart events - drill-down on click myChart.off('click'); myChart.on('click', function(params) { if (!params.data) return; // Skip if no data const colorPalette = [ '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72' ]; // If we have children or transactions, drill down if ((params.data.children && params.data.children.length > 0) || (params.data.transactions && params.data.transactions.length > 0)) { // Transform the data structure for drill-down const newData = []; // Handle different cases based on the type of node clicked if (params.data.children && params.data.children.length > 0) { // Case 1: Node has children (category or subcategory) const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value); // Process each child sortedChildren.forEach((child, i) => { const color = colorPalette[i % colorPalette.length]; 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); }); } // Update the chart with the new data structure option.series.data = newData; // Update the center text to show the drilled-down category const russianMonth = getRussianMonthName(document.getElementById('month-select').value); option.graphic.elements[0].style.text = `${russianMonth}\n${params.name}\n${params.value.toFixed(0).toLocaleString()} ₽`; myChart.setOption(option, { replaceMerge: ['series'] }); // Update hover events with the new data structure setupHoverEvents({ total: params.value, data: newData }); } }); myChart.setOption(option); // Add click handler for the center to reset view 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) { // Reset to original view - use module-level originalSunburstData const currentMonth = document.getElementById('month-select').value; const currentRussianMonth = getRussianMonthName(currentMonth); option.series.data = originalSunburstData.data; option.graphic.elements[0].style.text = currentRussianMonth + '\n' + originalSunburstData.total.toFixed(0).toLocaleString() + ' ₽'; myChart.setOption(option, { replaceMerge: ['series'] }); setupHoverEvents(originalSunburstData); } }); // Set up hover events for the details box setupHoverEvents(sunburstData); // Ensure chart is properly sized after rendering adjustChartSize(); myChart.resize(); } // Function to generate a color gradient function generateColorGradient(baseColor, steps) { const result = []; const base = tinycolor(baseColor); // Get the base hue value (0-360) const baseHue = base.toHsl().h; // Create a more dramatic gradient based on size for (let i = 0; i < steps; i++) { // Calculate percentage position in the sequence (0 to 1) const position = i / (steps - 1 || 1); let color = base.clone(); // Modify hue - shift around the color wheel based on size // Smaller items (position closer to 0): shift hue towards cooler colors (-30 degrees) // Larger items (position closer to 1): shift hue towards warmer colors (+30 degrees) const hueShift = 15-(position*15); // Ranges from -30 to +30 // Apply HSL transformations const hsl = color.toHsl(); //hsl.h = (baseHue + hueShift) % 360; // Keep hue within 0-360 range // Also adjust saturation and lightness for even more distinction /* if (position < 0.5) { // Smaller items: more saturated, darker hsl.s = Math.min(1, hsl.s * (1.3 - position * 0.6)); // Increase saturation up to 30% hsl.l = Math.max(0.2, hsl.l * (0.85 + position * 0.3)); // Slightly darker } else { // Larger items: slightly less saturated, brighter hsl.s = Math.min(1, hsl.s * (0.9 + position * 0.2)); // Slightly reduce saturation hsl.l = Math.min(0.9, hsl.l * (1.1 + (position - 0.5) * 0.2)); // Brighter }*/ result.push(tinycolor(hsl).toString()); } return result; } // Load TinyColor library for color manipulation function loadTinyColor() { return new Promise((resolve, reject) => { if (window.tinycolor) { resolve(); return; } const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/tinycolor/1.4.2/tinycolor.min.js'; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } // Global state for month navigation let availableMonths = []; let currentMonthIndex = 0; let monthDataCache = {}; // Cache for month data previews // Predefined colors for categories (same as in transformToSunburst) const categoryColors = [ '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#2d8041', '#fc8452', '#7b4d90', '#ea7ccc', '#4cae72', '#d56358', '#82b1ff', '#f19143', '#addf84', '#6f7787' ]; // Fixed order for categories const categoryOrder = [ 'Квартира', 'Еда', 'Технологии', 'Развлечения', 'Семьи', 'Здоровье', 'Логистика', 'Расходники', 'Красота' ]; // 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(#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(${gradientStops.join(', ')})`; } // Format month for display: "2025-01" -> "Январь'25" function formatMonthLabel(dateStr) { const [year, month] = dateStr.split('-'); const shortYear = year.slice(-2); const russianMonths = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' ]; const monthName = russianMonths[parseInt(month) - 1]; return `${monthName}'${shortYear}`; } // Load all available month files async function loadAvailableMonths() { // Load TinyColor first await loadTinyColor(); // Fetch available months from server const response = await fetch('/api/months'); availableMonths = await response.json(); if (availableMonths.length === 0) { console.error('No month data files found'); return; } const select = document.getElementById('month-select'); const monthList = document.getElementById('month-list'); // Clear existing options select.innerHTML = ''; monthList.innerHTML = ''; // Populate hidden select (for compatibility with renderChart) availableMonths.forEach(month => { const option = document.createElement('option'); option.value = month; option.textContent = month; select.appendChild(option); }); // Load data for all months and create buttons with previews await Promise.all(availableMonths.map(async (month, index) => { const data = await parseCSV(`altcats-${month}.csv`); monthDataCache[month] = data; })); // Create month buttons with previews availableMonths.forEach((month, index) => { const btn = document.createElement('button'); btn.className = 'month-btn'; btn.dataset.month = month; btn.dataset.index = index; // Create preview circle const preview = document.createElement('div'); preview.className = 'month-preview'; preview.style.background = generateMonthPreviewGradient(monthDataCache[month]); // Create label const label = document.createElement('span'); label.className = 'month-label'; label.textContent = formatMonthLabel(month); btn.appendChild(preview); btn.appendChild(label); btn.addEventListener('click', () => selectMonth(index)); monthList.appendChild(btn); }); // Load the most recent month by default currentMonthIndex = availableMonths.length - 1; await selectMonth(currentMonthIndex); // Set up arrow button handlers document.getElementById('prev-month').addEventListener('click', () => { if (currentMonthIndex > 0) { selectMonth(currentMonthIndex - 1); } }); document.getElementById('next-month').addEventListener('click', () => { if (currentMonthIndex < availableMonths.length - 1) { selectMonth(currentMonthIndex + 1); } }); } // Select and load a specific month async function selectMonth(index) { currentMonthIndex = index; const month = availableMonths[index]; // Update hidden select for compatibility const select = document.getElementById('month-select'); select.value = month; // Update month button active states updateMonthNavigator(); // Load and render data const data = 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)); // Update only the series data and preserve layout const oldData = option.series.data; const newData = sunburstData.data; // Map old values to new data to preserve positions newData.forEach((newItem, idx) => { if (oldData[idx]) { newItem.layoutId = oldData[idx].name; } }); // Update the data option.series.data = newData; // Update the total amount in the center text const russianMonth = getRussianMonthName(month); option.graphic.elements[0].style.text = russianMonth + '\n' + sunburstData.total.toFixed(0).toLocaleString() + ' ₽'; myChart.setOption({ series: [{ type: 'sunburst', data: newData, layoutAnimation: true, animationDuration: 500, animationEasing: 'cubicInOut' }], graphic: option.graphic }, { lazyUpdate: false, silent: false }); // Update hover events setupHoverEvents(sunburstData); } else { // Initial render renderChart(data); } // Scroll selected month button into view const activeBtn = document.querySelector('.month-btn.active'); if (activeBtn) { activeBtn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); } } // Update month navigator UI state function updateMonthNavigator() { // Update button active states const buttons = document.querySelectorAll('.month-btn'); buttons.forEach((btn, index) => { if (index === currentMonthIndex) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); // Update arrow disabled states const prevBtn = document.getElementById('prev-month'); const nextBtn = document.getElementById('next-month'); prevBtn.disabled = currentMonthIndex === 0; nextBtn.disabled = currentMonthIndex === availableMonths.length - 1; } // Initialize the visualization async function initVisualization() { await loadAvailableMonths(); // Ensure the chart is properly sized on initial load myChart.resize(); } // Start the application initVisualization(); // Handle window resize window.addEventListener('resize', function() { adjustChartSize(); myChart.resize(); // Recalculate chart center and radius for hover detection will be done automatically by setupHoverEvents }); // Function to adjust chart size based on screen width function adjustChartSize() { // Check if option is defined if (!option) return; const screenWidth = window.innerWidth; const detailsBox = document.getElementById('details-box'); const detailsWidth = detailsBox.offsetWidth; // Calculate center position with a smooth transition let centerPosition; if (screenWidth < 950) option.series.levels[3].label.show=false; else option.series.levels[3].label.show=true; centerPosition = 50; // Update chart center position option.series.center = [`${centerPosition}%`, '50%']; option.graphic.elements[0].left = 'center'; option.graphic.elements[0].top = 'middle'; myChart.setOption(option); } // Add mouseover handler to update details box function setupHoverEvents(sunburstData) { 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; // Calculate the center angle of a sector based on its data function getSectorLayoutFromChart(params) { // Use ECharts internal data model to get actual rendered layout try { const seriesModel = myChart.getModel().getSeriesByIndex(0); if (!seriesModel) return null; const data = seriesModel.getData(); if (!data) return null; // Get the layout for this specific data item const layout = data.getItemLayout(params.dataIndex); if (layout) { return { startAngle: layout.startAngle, endAngle: layout.endAngle, r: layout.r, // outer radius r0: layout.r0, // inner radius cx: layout.cx, // center x cy: layout.cy // center y }; } } catch (e) { console.log('Could not get layout from chart model:', e); } return null; } // Track current hovered sector for persistent button display let currentHoveredSectorParams = null; // Position and show the floating eye button near the hovered sector function showChartEyeButton(params) { if (!chartEyeBtn || !params.event) return; // Clear any pending hide timeout if (hideButtonTimeout) { clearTimeout(hideButtonTimeout); hideButtonTimeout = null; } currentHoveredSectorData = params.data; currentHoveredSectorParams = params; isOverChartSector = true; // Get actual layout from ECharts internal model const layout = getSectorLayoutFromChart(params); if (layout && layout.cx !== undefined) { // Calculate mid-angle of the sector const midAngle = (layout.startAngle + layout.endAngle) / 2; // Normalize angle to determine which side of the chart let normalizedAngle = midAngle % (2 * Math.PI); if (normalizedAngle < 0) normalizedAngle += 2 * Math.PI; // Left side: angle between π/2 and 3π/2 (90° to 270°) // On left side, labels read from edge to center, so button goes near inner edge // On right side, labels read from center to edge, so button goes near outer edge const isLeftSide = normalizedAngle > Math.PI / 2 && normalizedAngle < 3 * Math.PI / 2; // Position button at the appropriate edge based on label reading direction const buttonRadius = isLeftSide ? layout.r0 + 15 // Inner edge for left side (labels read inward) : layout.r - 15; // Outer edge for right side (labels read outward) // Calculate button position at the sector's mid-angle const buttonX = layout.cx + buttonRadius * Math.cos(midAngle); const buttonY = layout.cy + buttonRadius * Math.sin(midAngle); // Calculate rotation for the icon to match sector orientation let rotationDeg = (midAngle * 180 / Math.PI); if (isLeftSide) { rotationDeg += 180; } // Center the 24px button chartEyeBtn.style.left = (buttonX - 12) + 'px'; chartEyeBtn.style.top = (buttonY - 12) + 'px'; chartEyeBtn.style.transform = `rotate(${rotationDeg}deg)`; chartEyeBtn.style.display = 'flex'; } else { // Fallback: hide the button if we can't get layout chartEyeBtn.style.display = 'none'; } } // Hide the floating eye button with a delay function hideChartEyeButton(force = false) { // Don't hide if mouse is over the button if (isOverEyeButton && !force) return; // Clear existing timeout if (hideButtonTimeout) { clearTimeout(hideButtonTimeout); } // Use longer delay if mouse is still in chart area (gives time for sector re-hover) const stillInChart = isInsideChart(lastMouseX, lastMouseY); const delay = stillInChart ? 400 : 200; hideButtonTimeout = setTimeout(() => { // Double-check: don't hide if now over button or still over a chart sector if (!isOverEyeButton && !isOverChartSector && chartEyeBtn) { chartEyeBtn.style.display = 'none'; currentHoveredSectorData = null; currentHoveredSectorParams = null; } }, delay); } // Handle click on floating eye button if (chartEyeBtn) { chartEyeBtn.addEventListener('click', (e) => { e.stopPropagation(); if (currentHoveredSectorData) { openTransactionModal(currentHoveredSectorData); // Hide button after opening modal chartEyeBtn.style.display = 'none'; isOverEyeButton = false; } }); // Track when mouse is over the button chartEyeBtn.addEventListener('mouseenter', () => { isOverEyeButton = true; if (hideButtonTimeout) { clearTimeout(hideButtonTimeout); hideButtonTimeout = null; } // Keep the sector highlighted while hovering over eye button if (currentHoveredSectorParams && currentHoveredSectorParams.dataIndex !== undefined) { myChart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: currentHoveredSectorParams.dataIndex }); } }); chartEyeBtn.addEventListener('mouseleave', (e) => { isOverEyeButton = false; // Check if we're still in the chart area const rect = chartDom.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; if (isInsideChart(mouseX, mouseY)) { // Still in chart - wait longer for ECharts to potentially re-trigger mouseover // The delay in hideChartEyeButton will handle this isOverChartSector = true; // Assume still over sector until proven otherwise } else { // Leaving chart area - remove highlight if (currentHoveredSectorParams && currentHoveredSectorParams.dataIndex !== undefined) { myChart.dispatchAction({ type: 'downplay', seriesIndex: 0, dataIndex: currentHoveredSectorParams.dataIndex }); } } hideChartEyeButton(); }); } // Function to display items in the details box function displayDetailsItems(items, parentValue) { // Clear previous top items 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 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 = item.value.toLocaleString() + ' ₽'; // Add percentage if we have a parent value if (parentValue) { const percentage = ((item.value / parentValue) * 100).toFixed(1); amountSpan.textContent += ` (${percentage}%)`; } itemDiv.appendChild(amountSpan); topItemsElement.appendChild(itemDiv); }); } 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} ${value.toLocaleString()} ₽ `; } // Show the default view with top categories function showDefaultView() { currentHoveredData = null; detailsHeader.innerHTML = ` Total ${sunburstData.total.toLocaleString()} ₽ `; // Show top categories as default items displayDetailsItems(sunburstData.data, 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) { 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].sort((a, b) => b.value - a.value); } 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) { 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].sort((a, b) => b.value - a.value); } 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].sort((a, b) => b.value - a.value); 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) { isInsideSection = false; isOverChartSector = false; // Hide the floating eye button (unless hovering over it) hideChartEyeButton(); // Reset details with a small delay to allow eye button mouseenter to fire first setTimeout(() => { if (!isOverEyeButton) { showDefaultView(); } }, 50); } }); // Add back the downplay event handler - this is triggered when sections lose emphasis myChart.on('downplay', function(params) { // Reset to default view when a section is no longer emphasized (unless hovering eye button) setTimeout(() => { if (!isOverEyeButton) { showDefaultView(); } }, 50); }); } // CSV column labels for human-readable display const csvColumnLabels = { transaction_date: 'Date', processing_date: 'Processing Date', transaction_description: 'Description', comment: 'Comment', mcc: 'MCC', card_info: 'Card', account: 'Account', merchant_name: 'Merchant', location: 'Location', info_source: 'Source', amount_original: 'Original Amount', currency_original: 'Original Currency', amount_rub: 'Amount (₽)', category: 'Category', subcategory: 'Subcategory', microcategory: 'Microcategory', simple_name: 'Name' }; // Preferred column order (most important first) const csvColumnOrder = ['transaction_date', 'simple_name', 'amount_rub', 'category', 'subcategory', 'microcategory', 'transaction_description', 'merchant_name', 'account', 'card_info', 'location', 'amount_original', 'currency_original', 'mcc', 'info_source', 'processing_date', 'comment']; // Open transaction modal function openTransactionModal(item) { const modal = document.getElementById('transaction-modal'); const modalTitle = document.getElementById('modal-title'); const modalThead = document.getElementById('modal-thead'); const modalTbody = document.getElementById('modal-tbody'); // Set title modalTitle.textContent = item.name; // Gather transactions let transactions = []; if (item.transactions && item.transactions.length > 0) { transactions = item.transactions.filter(t => t.originalRow); } else if (item.originalRow) { transactions = [item]; } // Clear previous content modalThead.innerHTML = ''; modalTbody.innerHTML = ''; if (transactions.length === 0) { modalTbody.innerHTML = 'No transaction data available'; modal.style.display = 'flex'; setupModalListeners(); return; } // Sort by date descending transactions.sort((a, b) => { const dateA = a.originalRow?.transaction_date || a.date || ''; const dateB = b.originalRow?.transaction_date || b.date || ''; return dateB.localeCompare(dateA); }); // Get all columns from the first transaction's originalRow const sampleRow = transactions[0].originalRow; const availableColumns = csvColumnOrder.filter(col => col in sampleRow); // Build header const headerRow = document.createElement('tr'); availableColumns.forEach(col => { const th = document.createElement('th'); th.textContent = csvColumnLabels[col] || col; headerRow.appendChild(th); }); modalThead.appendChild(headerRow); // Build rows transactions.forEach(transaction => { const row = document.createElement('tr'); availableColumns.forEach(col => { const td = document.createElement('td'); let value = transaction.originalRow[col]; // Format amount values if ((col === 'amount' || col === 'original_amount') && typeof value === 'number') { value = value.toLocaleString(); } td.textContent = value !== undefined && value !== null ? value : ''; td.title = td.textContent; // Show full text on hover row.appendChild(td); }); modalTbody.appendChild(row); }); modal.style.display = 'flex'; setupModalListeners(); } // Close transaction modal function closeTransactionModal() { const modal = document.getElementById('transaction-modal'); modal.style.display = 'none'; document.removeEventListener('keydown', handleModalEscape); } // Handle Escape key to close modal function handleModalEscape(e) { if (e.key === 'Escape') { closeTransactionModal(); } } // Setup modal close listeners function setupModalListeners() { const modal = document.getElementById('transaction-modal'); const closeBtn = document.getElementById('modal-close'); // Close on X button click closeBtn.onclick = closeTransactionModal; // Close on backdrop click modal.onclick = function(e) { if (e.target === modal) { closeTransactionModal(); } }; // Close on Escape key document.addEventListener('keydown', handleModalEscape); }