// 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; } } } }); // 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] } }; const subcategories = []; for (const subcatKey in category.children) { subcategories.push(category.children[subcatKey]); } subcategories.forEach(subcategory => { const subcategoryNode = { name: subcategory.name, value: subcategory.value, children: [], itemStyle: { color: colors[colorIndex] } }; const microcategories = []; for (const microKey in subcategory.children) { microcategories.push(subcategory.children[microKey]); } 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, label: { show: true, formatter: function(param) { if (param.depth === 0) { // Add line breaks for long category names if (param.name.length > 10) { return param.name.replace(/(.{1,10})(?: |$)/g, "$1\n").trim(); } 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', }, itemStyle: { borderWidth: 2 } }, { // Second level - Subcategories r0: '45%', r: '70%', label: { show: false, fontSize: 11, align: 'left', position: 'inside', distance: 5, }, 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 (param.name.length > 10) { return param.name.slice(0, 8) + '...'; } return param.name; } }, itemStyle: { borderWidth: 3 } } ], emphasis: { focus: 'relative', }, // Add more space between wedges gap: 2, // Allow the chart to sort segments by value sort: null, 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) { // 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) { 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 = ''; } }); }