// Initialize the chart const chartDom = document.getElementById('chart-container'); const myChart = echarts.init(chartDom); let option; // 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' ]; data.forEach(item => { const category = item.category || ''; const subcategory = item.subcategory || ''; const microcategory = item.microcategory || ''; const amount = Math.abs(parseFloat(item.amount_rub)); const transactionKey = `${category}|${subcategory}|${microcategory}`; if (!isNaN(amount)) { totalSpending += amount; // Save transaction data for detail box if (!transactionMap[transactionKey]) { transactionMap[transactionKey] = []; } transactionMap[transactionKey].push({ name: item.simple_name || 'Transaction', value: amount, date: item.date }); if (!categoryMap[category] && category !== '') { categoryMap[category] = { name: category, value: 0, children: {}, itemStyle: {}, transactions: transactionMap[transactionKey] // Store transactions }; categories.push(categoryMap[category]); } if (category !== '') { if (!categoryMap[category].children[subcategory] && subcategory !== '') { categoryMap[category].children[subcategory] = { name: subcategory, value: 0, children: {}, itemStyle: {}, transactions: transactionMap[transactionKey] // Store transactions }; } if (subcategory !== '') { if (!categoryMap[category].children[subcategory].children[microcategory] && microcategory !== '') { categoryMap[category].children[subcategory].children[microcategory] = { name: microcategory, value: 0, itemStyle: {}, transactions: transactionMap[transactionKey] // Store transactions }; } categoryMap[category].value += amount; categoryMap[category].children[subcategory].value += amount; if (microcategory !== '') { categoryMap[category].children[subcategory].children[microcategory].value += amount; } } else { categoryMap[category].value += amount; } } } }); // Sort categories by value (largest to smallest) categories.sort((a, b) => 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 render the chart function renderChart(data) { const sunburstData = transformToSunburst(data); option = { backgroundColor: '#fff', grid: { left: '10%', // Move chart to the left containLabel: true }, series: { type: 'sunburst', radius: [0, '95%'], center: ['40%', '50%'], // Move chart to the left nodeClick: 'rootToNode', // To enable drill down on click 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: { show: false, position: 'outside', padding: 3, 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: [ { type: 'text', left: '37%', // Align with new center position top: '49%', style: { text: sunburstData.total.toFixed(0).toLocaleString()+" ₽", fontWeight: 'bold', fontSize: 18, textAlign: 'center', textVerticalAlign: 'middle', position: 'center', width: 20, height: 40 }, z: 100 // Ensure it's on top } ] }; // Handle chart events - recolor on drill down myChart.off('click'); myChart.on('click', function(params) { if (params.depth >= 0) { // Handle clicks on any level, not just depth 0 const colorPalette = [ '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72' ]; // Clone the data to modify it const currentData = JSON.parse(JSON.stringify(option.series.data)); // Find the path to the clicked item let targetData; let categoryIndex = -1; if (params.depth === 0) { // If clicking on a top-level category categoryIndex = currentData.findIndex(item => item.name === params.name); if (categoryIndex !== -1) { targetData = currentData[categoryIndex]; } } else if (params.depth === 1) { // If clicking on a subcategory, find its parent category first for (let i = 0; i < currentData.length; i++) { if (currentData[i].children) { const subIndex = currentData[i].children.findIndex(sub => sub.name === params.name); if (subIndex !== -1) { targetData = currentData[i].children[subIndex]; categoryIndex = i; break; } } } } if (targetData && targetData.children && targetData.children.length > 0) { // Sort children by value before recoloring targetData.children.sort((a, b) => b.value - a.value); // Recolor children with unique colors targetData.children.forEach((child, i) => { const color = colorPalette[i % colorPalette.length]; child.itemStyle = { color: color }; // If the child has children (microcategories), color them too if (child.children && child.children.length > 0) { // Sort microcategories by value child.children.sort((a, b) => b.value - a.value); const microColors = generateColorGradient(color, child.children.length); child.children.forEach((micro, j) => { micro.itemStyle = { color: microColors[j] }; }); } }); // Update the chart with modified data if (params.depth === 0) { currentData[categoryIndex] = targetData; } else if (params.depth === 1) { // Update the subcategory within its parent category for (let i = 0; i < currentData[categoryIndex].children.length; i++) { if (currentData[categoryIndex].children[i].name === params.name) { currentData[categoryIndex].children[i] = targetData; break; } } } option.series.data = currentData; myChart.setOption(option, { replaceMerge: ['series'] }); } } }); myChart.setOption(option); // Set up hover events for the details box setupHoverEvents(sunburstData); } // 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); }); } // Load all available month files async function loadAvailableMonths() { // Load TinyColor first await loadTinyColor(); // Include all available months const months = ['2025-01', '2025-02']; const select = document.getElementById('month-select'); // Clear existing options select.innerHTML = ''; months.forEach(month => { const option = document.createElement('option'); option.value = month; option.textContent = month; select.appendChild(option); }); // Load the most recent month by default const latestMonth = months[months.length - 1]; const data = await parseCSV(`altcats-${latestMonth}.csv`); renderChart(data); // Set the select value to match select.value = latestMonth; // Add event listener for month selection select.addEventListener('change', async (e) => { const month = e.target.value; const data = await parseCSV(`altcats-${month}.csv`); renderChart(data); }); } // Initialize the visualization loadAvailableMonths(); // Handle window resize window.addEventListener('resize', function() { myChart.resize(); }); // 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); } // 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); 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
'; } } // Show the default view with top categories function showDefaultView() { 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; // Variables to track the circular boundary const chartCenter = option.series.center; const chartCenterX = chartDom.offsetWidth * (parseFloat(chartCenter[0]) / 100); const chartCenterY = chartDom.offsetHeight * (parseFloat(chartCenter[1]) / 100); const chartRadius = Math.min(chartDom.offsetWidth, chartDom.offsetHeight) * (parseFloat(option.series.radius[1]) / 100); // 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; } // 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; // If not inside the chart circle and we were inside a section, show default view if (!isInsideChart(mouseX, mouseY) && isInsideSection) { isInsideSection = false; showDefaultView(); } }); // Add mouseover event listener for sectors myChart.on('mouseover', function(params) { // Only process data nodes, not empty areas if (params.data) { isInsideSection = true; // Set up the details header with name and amount const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); detailsHeader.innerHTML = ` ${params.name} ${params.value.toLocaleString()} ₽ `; // Number of items to show in the details box const MAX_DETAIL_ITEMS = 10; let itemsToShow = []; // Check if the node has children (subcategories or microcategories) if (params.data.children && params.data.children.length > 0) { // Sort children by value const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value); itemsToShow = sortedChildren.slice(0, MAX_DETAIL_ITEMS); } // If we have transaction data and not enough children, fill with transactions if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) { // Sort transactions by value const sortedTransactions = [...params.data.transactions].sort((a, b) => b.value - a.value); // Add color to transactions const coloredTransactions = sortedTransactions.map(t => { return { ...t, color: params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc') }; }); // Take only what we need to reach MAX_DETAIL_ITEMS const neededTransactions = coloredTransactions.slice(0, MAX_DETAIL_ITEMS - itemsToShow.length); itemsToShow = [...itemsToShow, ...neededTransactions]; } // Display the 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; // Reset details immediately when leaving a section 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 showDefaultView(); }); }