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>
1444 lines
61 KiB
JavaScript
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();
|
|
});
|
|
}
|