// 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)
let showDefaultView = null; // Reference to reset details panel to default view
// Drill-down history for back/forward navigation
let drillDownHistory = [];
let historyIndex = -1;
let currentDrillPath = []; // Tracks drill-down hierarchy: ["Еда", "Рестораны", ...]
// Save current chart state to history
function saveToHistory(data, total, contextName, isInitial = false, path = []) {
// Remove any forward history when drilling down from middle of history
if (historyIndex < drillDownHistory.length - 1) {
drillDownHistory = drillDownHistory.slice(0, historyIndex + 1);
}
drillDownHistory.push({ data, total, contextName, path: [...path] });
historyIndex = drillDownHistory.length - 1;
// Update current drill path
currentDrillPath = [...path];
// Push to browser history (use replaceState for initial state)
const state = { drillDown: historyIndex };
if (isInitial) {
history.replaceState(state, '');
} else {
history.pushState(state, '');
}
}
// Reset history (called when changing months)
function resetHistory() {
drillDownHistory = [];
historyIndex = -1;
currentDrillPath = [];
}
// Navigate to a specific history state
function navigateToHistoryState(state) {
const russianMonth = getRussianMonthName(document.getElementById('month-select').value);
option.series.data = state.data;
if (state.contextName) {
option.graphic.elements[0].style.text = `${russianMonth}\n${state.contextName}\n${state.total.toFixed(0).toLocaleString()} ₽`;
} else {
option.graphic.elements[0].style.text = russianMonth + '\n' + state.total.toFixed(0).toLocaleString() + ' ₽';
}
myChart.setOption(option, { replaceMerge: ['series'] });
setupHoverEvents({ total: state.total, data: state.data }, state.contextName);
// Restore drill path and update mini-charts
currentDrillPath = state.path ? [...state.path] : [];
updateAllMonthPreviews();
}
// Go back in drill-down history
function navigateBack() {
if (historyIndex > 0) {
historyIndex--;
navigateToHistoryState(drillDownHistory[historyIndex]);
}
}
// Go forward in drill-down history
function navigateForward() {
if (historyIndex < drillDownHistory.length - 1) {
historyIndex++;
navigateToHistoryState(drillDownHistory[historyIndex]);
}
}
// Listen for browser back/forward via popstate
window.addEventListener('popstate', function(e) {
// If a modal is open, close it and push state back to prevent navigation
const rowDetailModal = document.getElementById('row-detail-modal');
const transactionModal = document.getElementById('transaction-modal');
if (rowDetailModal && rowDetailModal.style.display !== 'none') {
closeRowDetailModal();
// Push state back to cancel the back navigation
history.pushState(e.state, '');
return;
}
if (transactionModal && transactionModal.style.display !== 'none') {
closeTransactionModal();
// Push state back to cancel the back navigation
history.pushState(e.state, '');
return;
}
// No modal open - handle drill-down navigation
if (e.state && e.state.drillDown !== undefined) {
const stateIndex = e.state.drillDown;
if (stateIndex >= 0 && stateIndex < drillDownHistory.length) {
historyIndex = stateIndex;
navigateToHistoryState(drillDownHistory[historyIndex]);
}
} else {
// No state or initial state - go to root
if (drillDownHistory.length > 0) {
historyIndex = 0;
navigateToHistoryState(drillDownHistory[0]);
}
}
});
// 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,
originalRow: item // Store full CSV row for modal display
};
// 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));
// Reset and initialize history with the root state
resetHistory();
saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
// 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}
Amount: ${value} RUB
Percentage: ${percentage}%`;
}
}
},
graphic: {
elements: [
{
type: 'text',
left: 'center',
top: 'middle',
z: 100,
style: {
text: russianMonth + '\n' + sunburstData.total.toFixed(0).toLocaleString() + ' ₽',
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
fontWeight: 'bold',
fontSize: 18,
textAlign: 'left',
fill: '#000'
}
}
]
}
};
// Handle chart events - drill-down on click
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 = sortBySubcategoryOrder(params.data.children, params.name);
// Process each child
sortedChildren.forEach((child, i) => {
const color = getSubcategoryColor(params.name, child.name, i);
const newCategory = {
name: child.name,
value: child.value,
transactions: child.transactions, // Preserve for modal
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,
transactions: micro.transactions, // Preserve for modal
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]
},
originalRow: transaction.originalRow
});
});
}
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,
transactions: group.transactions, // Preserve for modal
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]
},
originalRow: transaction.originalRow
});
});
}
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,
transactions: group.transactions, // Preserve for modal
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]
},
originalRow: transaction.originalRow
});
});
}
newData.push(transactionCategory);
});
}
// Build the new path by appending the clicked category
const newPath = [...currentDrillPath, params.name];
saveToHistory(newData, params.value, params.name, false, newPath);
// 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, passing the drilled-down name
setupHoverEvents({ total: params.value, data: newData }, params.name);
// Update mini-chart previews to reflect drill-down
updateAllMonthPreviews();
}
});
myChart.setOption(option);
// Add click handler for the center to go back in history
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 && historyIndex > 0) {
history.back(); // Use browser history - triggers popstate
}
});
// 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 = [
'Квартира', 'Еда', 'Технологии', 'Развлечения', 'Семьи',
'Здоровье', 'Логистика', 'Расходники', 'Красота'
];
// Fixed subcategory colors based on December 2025 data (sorted by value)
// This ensures consistent colors across all months
const subcategoryColors = {
'Квартира': {
'ЖКХ': '#5470c6',
'Лев Петрович': '#91cc75'
},
'Еда': {
'Продукты': '#5470c6',
'Готовая': '#91cc75'
},
'Технологии': {
'Инфраструктура домашнего интернета': '#5470c6',
'Умный дом': '#91cc75',
'AI': '#fac858',
'Мобильная связь': '#ee6666'
},
'Развлечения': {
'Подарки': '#5470c6',
'Хобби Антона': '#91cc75',
'Хобби Залины': '#fac858',
'Подписки': '#ee6666',
'Чтение': '#73c0de',
'Работа Залины': '#3ba272',
'Девайсы': '#fc8452'
},
'Семьи': {
'Залина': '#5470c6',
'Антон': '#91cc75'
},
'Здоровье': {
'Терапия': '#5470c6',
'Лекарства': '#91cc75',
'Спортивное питание': '#fac858'
},
'Логистика': {
'Такси': '#5470c6',
'Доставка': '#91cc75',
'Чаевые': '#fac858'
},
'Расходники': {
'Замена разных фильтров': '#5470c6',
'Санитарное': '#91cc75',
'Батарейки': '#fac858',
'Мелкий ремонт': '#ee6666',
'Средства для посудомоек': '#73c0de'
},
'Красота': {
'Одежда': '#5470c6',
'Салоны красоты': '#91cc75',
'Кремы': '#fac858'
}
};
// Color palette for items not in the predefined mapping
const defaultColorPalette = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
];
// Fixed subcategory order derived from subcategoryColors (December 2025 order)
const subcategoryOrder = {};
for (const category in subcategoryColors) {
subcategoryOrder[category] = Object.keys(subcategoryColors[category]);
}
// Get color for a subcategory, using fixed mapping or fallback to index-based
function getSubcategoryColor(category, subcategory, index) {
if (subcategoryColors[category] && subcategoryColors[category][subcategory]) {
return subcategoryColors[category][subcategory];
}
return defaultColorPalette[index % defaultColorPalette.length];
}
// Sort items by fixed subcategory order, with unknown items at the end sorted by value
function sortBySubcategoryOrder(items, parentCategory, nameGetter = (item) => item.name) {
const order = subcategoryOrder[parentCategory] || [];
return [...items].sort((a, b) => {
const aName = nameGetter(a);
const bName = nameGetter(b);
const aIndex = order.indexOf(aName);
const bIndex = order.indexOf(bName);
// Both in predefined order - sort by that order
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
// Only a is in predefined order - a comes first
if (aIndex !== -1) return -1;
// Only b is in predefined order - b comes first
if (bIndex !== -1) return 1;
// Neither in predefined order - sort by value descending
const aValue = typeof a === 'object' ? (a.value || 0) : 0;
const bValue = typeof b === 'object' ? (b.value || 0) : 0;
return bValue - aValue;
});
}
// 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(from 90deg, #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(from 90deg, ${gradientStops.join(', ')})`;
}
// Find category data at a given path in month's transaction data
function findCategoryDataAtPath(monthData, path) {
if (!path || path.length === 0) {
return null; // No drill-down, use full data
}
// Group transactions by the hierarchy at this path level
const categoryName = path[0];
// Filter transactions belonging to this category
const filteredTransactions = monthData.filter(item => item.category === categoryName);
if (filteredTransactions.length === 0) {
return { empty: true }; // Category doesn't exist in this month
}
if (path.length === 1) {
// Return subcategory breakdown
return { transactions: filteredTransactions, level: 'subcategory' };
}
// Path length >= 2, filter by subcategory
const subcategoryName = path[1];
const subFiltered = filteredTransactions.filter(item => item.subcategory === subcategoryName);
if (subFiltered.length === 0) {
return { empty: true };
}
if (path.length === 2) {
// Return microcategory breakdown
return { transactions: subFiltered, level: 'microcategory' };
}
// Path length >= 3, filter by microcategory
const microcategoryName = path[2];
const microFiltered = subFiltered.filter(item => item.microcategory === microcategoryName);
if (microFiltered.length === 0) {
return { empty: true };
}
// Return transaction-level breakdown
return { transactions: microFiltered, level: 'transaction' };
}
// Get the base color for a drill-down path
function getPathBaseColor(path) {
if (!path || path.length === 0) {
return categoryColors[0];
}
const categoryName = path[0];
const categoryIndex = categoryOrder.indexOf(categoryName);
if (categoryIndex !== -1) {
return categoryColors[categoryIndex];
}
return categoryColors[0];
}
// Generate gradient for a month at the current drill-down path
function generateDrilledDownGradient(monthData, path) {
const result = findCategoryDataAtPath(monthData, path);
// No drill-down - use original function
if (!result) {
return generateMonthPreviewGradient(monthData);
}
// Empty state - category doesn't exist
if (result.empty) {
return 'conic-gradient(from 90deg, #e0e0e0 0deg 360deg)';
}
const { transactions, level } = result;
// Group by the appropriate field based on level
const groupField = level === 'subcategory' ? 'subcategory'
: level === 'microcategory' ? 'microcategory'
: 'simple_name';
const totals = {};
let total = 0;
transactions.forEach(item => {
const key = item[groupField] || '(без категории)';
const amount = Math.abs(parseFloat(item.amount_rub));
if (!isNaN(amount)) {
totals[key] = (totals[key] || 0) + amount;
total += amount;
}
});
if (total === 0) {
return 'conic-gradient(from 90deg, #e0e0e0 0deg 360deg)';
}
// Get the parent category for color lookup and ordering
const parentCategory = path.length > 0 ? path[0] : null;
// Sort by fixed subcategory order (for first level), fallback to value for unknown items
const order = subcategoryOrder[parentCategory] || [];
const sortedKeys = Object.keys(totals).sort((a, b) => {
const aIndex = order.indexOf(a);
const bIndex = order.indexOf(b);
// Both in predefined order - sort by that order
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
// Only a is in predefined order - a comes first
if (aIndex !== -1) return -1;
// Only b is in predefined order - b comes first
if (bIndex !== -1) return 1;
// Neither in predefined order - sort by value descending
return totals[b] - totals[a];
});
const gradientStops = [];
let currentAngle = 0;
sortedKeys.forEach((key, index) => {
const percentage = totals[key] / total;
const angle = percentage * 360;
// Use fixed subcategory colors for first level, fallback to palette for deeper levels
const color = (level === 'subcategory' && parentCategory)
? getSubcategoryColor(parentCategory, key, index)
: defaultColorPalette[index % defaultColorPalette.length];
gradientStops.push(`${color} ${currentAngle}deg ${currentAngle + angle}deg`);
currentAngle += angle;
});
return `conic-gradient(from 90deg, ${gradientStops.join(', ')})`;
}
// Update all month preview gradients based on current drill path
function updateAllMonthPreviews() {
const buttons = document.querySelectorAll('.month-btn');
buttons.forEach(btn => {
const month = btn.dataset.month;
const preview = btn.querySelector('.month-preview');
if (preview && monthDataCache[month]) {
preview.style.background = generateDrilledDownGradient(monthDataCache[month], currentDrillPath);
}
});
}
// 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);
}
});
}
// Transform children data for drill-down (extracted from click handler)
function transformDrillDownData(parentNode, parentCategoryName) {
const newData = [];
const sortedChildren = sortBySubcategoryOrder(parentNode.children, parentCategoryName);
sortedChildren.forEach((child, i) => {
const color = getSubcategoryColor(parentCategoryName, child.name, i);
const newCategory = {
name: child.name,
value: child.value,
transactions: child.transactions,
itemStyle: { color: color },
children: []
};
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,
transactions: micro.transactions,
itemStyle: { color: microColors[j] },
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] },
originalRow: transaction.originalRow
});
});
}
newCategory.children.push(microCategory);
});
}
if (child.transactions) {
const transactionsWithoutMicro = child.transactions.filter(t => t.displayMicrocategory === '');
if (transactionsWithoutMicro.length > 0) {
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);
});
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,
transactions: group.transactions,
itemStyle: { color: transactionColors[j] },
children: []
};
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] },
originalRow: transaction.originalRow
});
});
}
newCategory.children.push(transactionCategory);
});
}
}
newData.push(newCategory);
});
return newData;
}
// Transform transaction-only data for drill-down
function transformTransactionData(node) {
const colorPalette = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
];
const transactionGroups = {};
node.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);
});
const newData = [];
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,
transactions: group.transactions,
itemStyle: { color: color },
children: []
};
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] },
originalRow: transaction.originalRow
});
});
}
newData.push(transactionCategory);
});
return newData;
}
// Navigate to a path in the given sunburst data, returns drilled data or null if path not found
function navigateToPath(sunburstData, path) {
if (!path || path.length === 0) {
return null; // Stay at root
}
// Try to find each level of the path
let currentData = sunburstData.data;
let currentTotal = sunburstData.total;
let currentName = null;
let validPath = [];
for (let i = 0; i < path.length; i++) {
const targetName = path[i];
const found = currentData.find(item => item.name === targetName);
if (!found) {
// Path level not found, return what we have so far
break;
}
validPath.push(targetName);
currentName = targetName;
currentTotal = found.value;
// Transform this level's children to become top-level (same as click handler logic)
if (found.children && found.children.length > 0) {
currentData = transformDrillDownData(found, targetName);
} else if (found.transactions && found.transactions.length > 0) {
currentData = transformTransactionData(found);
} else {
// No more levels to drill into
break;
}
}
if (validPath.length === 0) {
return null; // Couldn't match any part of path
}
return {
data: currentData,
total: currentTotal,
contextName: currentName,
path: validPath
};
}
// 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 = monthDataCache[month] || 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));
// Save the current drill path before modifying state
const savedPath = [...currentDrillPath];
// Reset history for the new month
resetHistory();
// Try to navigate to the same path in the new month
const navigatedState = navigateToPath(sunburstData, savedPath);
let targetData, targetTotal, targetName, targetPath;
if (navigatedState) {
// Successfully navigated to (part of) the path
targetData = navigatedState.data;
targetTotal = navigatedState.total;
targetName = navigatedState.contextName;
targetPath = navigatedState.path;
} else {
// Stay at root level
targetData = sunburstData.data;
targetTotal = sunburstData.total;
targetName = null;
targetPath = [];
}
// Save initial state with the path
saveToHistory(targetData, targetTotal, targetName, true, targetPath);
// Update the data
option.series.data = targetData;
// Update the total amount in the center text
const russianMonth = getRussianMonthName(month);
if (targetName) {
option.graphic.elements[0].style.text = `${russianMonth}\n${targetName}\n${targetTotal.toFixed(0).toLocaleString()} ₽`;
} else {
option.graphic.elements[0].style.text = russianMonth + '\n' + targetTotal.toFixed(0).toLocaleString() + ' ₽';
}
myChart.setOption({
series: [{
type: 'sunburst',
data: targetData,
layoutAnimation: true,
animationDuration: 500,
animationEasing: 'cubicInOut'
}],
graphic: option.graphic
}, {
lazyUpdate: false,
silent: false
});
// Update hover events
setupHoverEvents({ total: targetTotal, data: targetData }, targetName);
// Update mini-chart previews
updateAllMonthPreviews();
} 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, contextName = null) {
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;
}
// Floating eye button for chart
const chartEyeBtn = document.getElementById('chart-eye-btn');
let currentHoveredSectorData = null;
let hideButtonTimeout = null;
let isOverEyeButton = false;
let isOverChartSector = false; // Track if mouse is over any chart sector
let lastMouseX = 0;
let lastMouseY = 0;
let mouseMovingTimeout = null; // Timer to detect mouse inactivity
let isMouseMoving = false; // Track if mouse is actively moving
// Calculate the center angle of a sector based on its data
function getSectorLayoutFromChart(params) {
// Use ECharts internal data model to get actual rendered layout
try {
const seriesModel = myChart.getModel().getSeriesByIndex(0);
if (!seriesModel) return null;
const data = seriesModel.getData();
if (!data) return null;
// Get the layout for this specific data item
const layout = data.getItemLayout(params.dataIndex);
if (layout) {
return {
startAngle: layout.startAngle,
endAngle: layout.endAngle,
r: layout.r, // outer radius
r0: layout.r0, // inner radius
cx: layout.cx, // center x
cy: layout.cy // center y
};
}
} catch (e) {
console.log('Could not get layout from chart model:', e);
}
return null;
}
// Track current hovered sector for persistent button display
let currentHoveredSectorParams = null;
let pendingButtonPosition = null; // Store button position when mouse stops
// Update button visibility based on mouse movement state
function updateButtonVisibility() {
if (!chartEyeBtn) return;
// Show button if mouse is moving OR hovering over the button itself
if ((isMouseMoving || isOverEyeButton) && pendingButtonPosition) {
chartEyeBtn.style.left = pendingButtonPosition.left;
chartEyeBtn.style.top = pendingButtonPosition.top;
chartEyeBtn.style.transform = pendingButtonPosition.transform;
chartEyeBtn.style.opacity = '1';
chartEyeBtn.style.pointerEvents = 'auto';
} else if (!isOverEyeButton) {
chartEyeBtn.style.opacity = '0';
chartEyeBtn.style.pointerEvents = 'none';
}
}
// Handle mouse movement on chart - show button while moving, hide after 0.5s of inactivity
function onChartMouseMove() {
isMouseMoving = true;
updateButtonVisibility();
// Clear existing timeout
if (mouseMovingTimeout) {
clearTimeout(mouseMovingTimeout);
}
// Set timeout to hide button after 0.5 second of no movement
mouseMovingTimeout = setTimeout(() => {
isMouseMoving = false;
updateButtonVisibility();
}, 500);
}
// Add mousemove listener to chart container
chartDom.addEventListener('mousemove', onChartMouseMove);
// Position and show the floating eye button near the hovered sector
function showChartEyeButton(params) {
if (!chartEyeBtn || !params.event) return;
// Clear any pending hide timeout
if (hideButtonTimeout) {
clearTimeout(hideButtonTimeout);
hideButtonTimeout = null;
}
currentHoveredSectorData = params.data;
currentHoveredSectorParams = params;
isOverChartSector = true;
// Get actual layout from ECharts internal model
const layout = getSectorLayoutFromChart(params);
if (layout && layout.cx !== undefined) {
// Calculate mid-angle of the sector
const midAngle = (layout.startAngle + layout.endAngle) / 2;
// Normalize angle to determine which side of the chart
let normalizedAngle = midAngle % (2 * Math.PI);
if (normalizedAngle < 0) normalizedAngle += 2 * Math.PI;
// Left side: angle between π/2 and 3π/2 (90° to 270°)
// On left side, labels read from edge to center, so button goes near inner edge
// On right side, labels read from center to edge, so button goes near outer edge
const isLeftSide = normalizedAngle > Math.PI / 2 && normalizedAngle < 3 * Math.PI / 2;
// Position button at the appropriate edge based on label reading direction
const buttonRadius = isLeftSide
? layout.r0 + 15 // Inner edge for left side (labels read inward)
: layout.r - 15; // Outer edge for right side (labels read outward)
// Calculate button position at the sector's mid-angle
const buttonX = layout.cx + buttonRadius * Math.cos(midAngle);
const buttonY = layout.cy + buttonRadius * Math.sin(midAngle);
// Calculate rotation for the icon to match sector orientation
let rotationDeg = (midAngle * 180 / Math.PI);
if (isLeftSide) {
rotationDeg += 180;
}
// Store the button position
pendingButtonPosition = {
left: (buttonX - 12) + 'px',
top: (buttonY - 12) + 'px',
transform: `rotate(${rotationDeg}deg)`
};
// Only show if mouse is moving or hovering over button
updateButtonVisibility();
} else {
// Fallback: clear pending position if we can't get layout
pendingButtonPosition = null;
chartEyeBtn.style.opacity = '0';
chartEyeBtn.style.pointerEvents = 'none';
}
}
// Hide the floating eye button with a delay
function hideChartEyeButton(force = false) {
// Don't hide if mouse is over the button
if (isOverEyeButton && !force) return;
// Clear existing timeout
if (hideButtonTimeout) {
clearTimeout(hideButtonTimeout);
}
// Use longer delay if mouse is still in chart area (gives time for sector re-hover)
const stillInChart = isInsideChart(lastMouseX, lastMouseY);
const delay = stillInChart ? 400 : 200;
hideButtonTimeout = setTimeout(() => {
// Double-check: don't hide if now over button or still over a chart sector
if (!isOverEyeButton && !isOverChartSector && chartEyeBtn) {
chartEyeBtn.style.opacity = '0';
chartEyeBtn.style.pointerEvents = 'none';
currentHoveredSectorData = null;
currentHoveredSectorParams = null;
}
}, delay);
}
// Handle click on floating eye button
if (chartEyeBtn) {
chartEyeBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (currentHoveredSectorData) {
openTransactionModal(currentHoveredSectorData);
// Hide button after opening modal
chartEyeBtn.style.opacity = '0';
chartEyeBtn.style.pointerEvents = 'none';
isOverEyeButton = false;
}
});
// Track when mouse is over the button
chartEyeBtn.addEventListener('mouseenter', () => {
isOverEyeButton = true;
isInsideSection = true; // Keep details panel showing sector info
if (hideButtonTimeout) {
clearTimeout(hideButtonTimeout);
hideButtonTimeout = null;
}
// Keep the sector highlighted while hovering over eye button
if (currentHoveredSectorParams && currentHoveredSectorParams.dataIndex !== undefined) {
myChart.dispatchAction({
type: 'highlight',
seriesIndex: 0,
dataIndex: currentHoveredSectorParams.dataIndex
});
}
});
chartEyeBtn.addEventListener('mouseleave', (e) => {
isOverEyeButton = false;
// Check if we're still in the chart area
const rect = chartDom.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (isInsideChart(mouseX, mouseY)) {
// Still in chart - wait longer for ECharts to potentially re-trigger mouseover
// The delay in hideChartEyeButton will handle this
isOverChartSector = true; // Assume still over sector until proven otherwise
} else {
// Leaving chart area - remove highlight
if (currentHoveredSectorParams && currentHoveredSectorParams.dataIndex !== undefined) {
myChart.dispatchAction({
type: 'downplay',
seriesIndex: 0,
dataIndex: currentHoveredSectorParams.dataIndex
});
}
}
hideChartEyeButton();
});
}
// 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);
// Add percentage after name if we have a parent value
if (parentValue) {
const percentSpan = document.createElement('span');
percentSpan.className = 'top-item-percent';
const percentage = ((item.value / parentValue) * 100).toFixed(1);
percentSpan.textContent = percentage + '%';
itemDiv.appendChild(percentSpan);
}
// Add eye button for viewing transaction details
const eyeBtn = document.createElement('button');
eyeBtn.className = 'eye-btn';
eyeBtn.innerHTML = '';
eyeBtn.title = 'View transaction details';
eyeBtn.addEventListener('click', (e) => {
e.stopPropagation();
openTransactionModal(item);
});
itemDiv.appendChild(eyeBtn);
const amountSpan = document.createElement('span');
amountSpan.className = 'top-item-amount';
amountSpan.textContent = item.value.toLocaleString() + ' ₽';
itemDiv.appendChild(amountSpan);
topItemsElement.appendChild(itemDiv);
});
} else {
// No items to show
topItemsElement.innerHTML = '