// 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 = {}; // Predefined colors for categories const colors = [ '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#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)); if (!isNaN(amount)) { totalSpending += amount; if (!categoryMap[category] && category !== '') { categoryMap[category] = { name: category, value: 0, children: {}, itemStyle: {} }; categories.push(categoryMap[category]); } if (category !== '') { if (!categoryMap[category].children[subcategory] && subcategory !== '') { categoryMap[category].children[subcategory] = { name: subcategory, value: 0, children: {}, itemStyle: {} }; } if (subcategory !== '') { if (!categoryMap[category].children[subcategory].children[microcategory] && microcategory !== '') { categoryMap[category].children[subcategory].children[microcategory] = { name: microcategory, value: 0, itemStyle: {} }; } 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 categoryNode = { name: category.name, value: category.value, children: [], itemStyle: { color: colors[colorIndex] } }; // 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); // Process each subcategory subcategories.forEach(subcategory => { const subcategoryNode = { name: subcategory.name, value: subcategory.value, children: [], itemStyle: { color: colors[colorIndex] } }; // 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); // Add microcategories to subcategory microcategories.forEach(micro => { subcategoryNode.children.push({ name: micro.name, value: micro.value, itemStyle: { color: colors[colorIndex] } }); }); if (subcategoryNode.children.length > 0) { categoryNode.children.push(subcategoryNode); } else { categoryNode.children.push({ name: subcategory.name, value: subcategory.value, itemStyle: { color: colors[colorIndex] } }); } }); 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: 12, 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: false, fontSize: 11, align: 'left', 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(); } // Function to generate a color gradient function generateColorGradient(baseColor, steps) { const result = []; const base = tinycolor(baseColor); // Generate lighter shades for better contrast for (let i = 0; i < steps; i++) { // Create various tints and shades based on the position let color; if (i % 3 === 0) { color = base.clone().lighten(15); } else if (i % 3 === 1) { color = base.clone().darken(10); } else { color = base.clone().saturate(20); } result.push(color.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(); // For now, we only have one month const months = ['2025-01']; const select = document.getElementById('month-select'); months.forEach(month => { const option = document.createElement('option'); option.value = month; option.textContent = month; select.appendChild(option); }); // Load the first month by default const data = await parseCSV(`altcats-${months[0]}.csv`); renderChart(data); // 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() { const hoverNameElement = document.getElementById('hover-name'); const hoverAmountElement = document.getElementById('hover-amount'); const topItemsElement = document.getElementById('top-items'); // Add mouseover event listener myChart.on('mouseover', function(params) { // Only process data nodes, not empty areas if (params.data) { // Set the name and amount hoverNameElement.textContent = params.name; hoverAmountElement.textContent = params.value.toLocaleString() + ' RUB'; // Clear previous top items topItemsElement.innerHTML = ''; // Find top items if there are children 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); // Display top 10 or fewer const topChildren = sortedChildren.slice(0, 10); // Create elements for each top item topChildren.forEach(child => { const itemDiv = document.createElement('div'); itemDiv.className = 'top-item'; const nameSpan = document.createElement('span'); nameSpan.className = 'top-item-name'; nameSpan.textContent = child.name; itemDiv.appendChild(nameSpan); const amountSpan = document.createElement('span'); amountSpan.className = 'top-item-amount'; amountSpan.textContent = child.value.toLocaleString() + ' ₽'; itemDiv.appendChild(amountSpan); // Add percentage const percentage = ((child.value / params.value) * 100).toFixed(1); amountSpan.textContent += ` (${percentage}%)`; topItemsElement.appendChild(itemDiv); }); } else { // No children, show a message topItemsElement.innerHTML = '
No subcategories available
'; } } }); // Reset on mouseout from the chart myChart.on('mouseout', function(params) { if (!params.data) { hoverNameElement.textContent = 'Hover over a segment to see details'; hoverAmountElement.textContent = ''; topItemsElement.innerHTML = ''; } }); }