visual-spending/app.js
Anton Volnuhin 6cf5d30d4d Fix sunburst center-click reset showing wrong month data
The center-click handler used closure-captured variables (russianMonth and
originalData) from the initial renderChart call. When switching months via
selectMonth, these values were never updated, causing clicks on the center
to always restore December's data.

Changed originalData from a local variable to a module-level variable
(originalSunburstData) that gets updated when months change.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:59:56 +03:00

1444 lines
61 KiB
JavaScript

// 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
};
// 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}<br/>Amount: ${value} RUB<br/>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 - recolor on drill down
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,
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,
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]
}
});
});
}
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,
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]
}
});
});
}
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,
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]
}
});
});
}
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;
}
// 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 = '<div>No details available</div>';
}
}
// Show the default view with top categories
function showDefaultView() {
detailsHeader.innerHTML = `
<span class="hover-name">
<span class="color-circle" style="background-color: #ffffff;"></span>
Total
</span>
<span class="hover-amount">${sunburstData.total.toLocaleString()} ₽</span>
`;
// 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;
// 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) {
// 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');
detailsHeader.innerHTML = `
<span class="hover-name">
<span class="color-circle" style="background-color: ${itemColor};"></span>
${params.name}
</span>
<span class="hover-amount">${params.value.toLocaleString()} ₽</span>
`;
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');
detailsHeader.innerHTML = `
<span class="hover-name">
<span class="color-circle" style="background-color: ${itemColor};"></span>
${params.name}
</span>
<span class="hover-amount">${params.value.toLocaleString()} ₽</span>
`;
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');
detailsHeader.innerHTML = `
<span class="hover-name">
<span class="color-circle" style="background-color: ${itemColor};"></span>
${params.name}
</span>
<span class="hover-amount">${params.value.toLocaleString()} ₽</span>
`;
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;
// 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();
});
}