- Add eye icon that appears when hovering over chart sectors - Position button at inner edge for left-side sectors, outer edge for right-side - Click eye button to open transaction details modal - Maintain sector highlight and details panel when hovering over button - Override chart cursor to only show pointer on eye button, not sectors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1784 lines
74 KiB
JavaScript
1784 lines
74 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,
|
|
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));
|
|
|
|
// 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 - 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 = [...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,
|
|
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);
|
|
});
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Center the 24px button
|
|
chartEyeBtn.style.left = (buttonX - 12) + 'px';
|
|
chartEyeBtn.style.top = (buttonY - 12) + 'px';
|
|
chartEyeBtn.style.transform = `rotate(${rotationDeg}deg)`;
|
|
chartEyeBtn.style.display = 'flex';
|
|
} else {
|
|
// Fallback: hide the button if we can't get layout
|
|
chartEyeBtn.style.display = '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.display = '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.display = 'none';
|
|
isOverEyeButton = false;
|
|
}
|
|
});
|
|
|
|
// Track when mouse is over the button
|
|
chartEyeBtn.addEventListener('mouseenter', () => {
|
|
isOverEyeButton = true;
|
|
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 eye button for viewing transaction details
|
|
const eyeBtn = document.createElement('button');
|
|
eyeBtn.className = 'eye-btn';
|
|
eyeBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
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() + ' ₽';
|
|
|
|
// 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>';
|
|
}
|
|
}
|
|
|
|
// Variable to store currently hovered data for the header eye button
|
|
let currentHoveredData = null;
|
|
|
|
// Helper function to update the details header
|
|
function updateDetailsHeader(name, value, color, data) {
|
|
currentHoveredData = data;
|
|
detailsHeader.innerHTML = `
|
|
<span class="hover-name">
|
|
<span class="color-circle" style="background-color: ${color};"></span>
|
|
${name}
|
|
</span>
|
|
<span class="hover-amount">${value.toLocaleString()} ₽</span>
|
|
`;
|
|
}
|
|
|
|
// Show the default view with top categories
|
|
function showDefaultView() {
|
|
currentHoveredData = null;
|
|
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;
|
|
|
|
// Track mouse position for eye button hide logic
|
|
lastMouseX = mouseX;
|
|
lastMouseY = mouseY;
|
|
|
|
// 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) {
|
|
// Show floating eye button at end of sector label
|
|
showChartEyeButton(params);
|
|
|
|
// 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');
|
|
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
|
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');
|
|
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
|
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');
|
|
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
|
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;
|
|
isOverChartSector = false;
|
|
// Hide the floating eye button (unless hovering over it)
|
|
hideChartEyeButton();
|
|
// Reset details with a small delay to allow eye button mouseenter to fire first
|
|
setTimeout(() => {
|
|
if (!isOverEyeButton) {
|
|
showDefaultView();
|
|
}
|
|
}, 50);
|
|
}
|
|
});
|
|
|
|
// 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 (unless hovering eye button)
|
|
setTimeout(() => {
|
|
if (!isOverEyeButton) {
|
|
showDefaultView();
|
|
}
|
|
}, 50);
|
|
});
|
|
}
|
|
|
|
// CSV column labels for human-readable display
|
|
const csvColumnLabels = {
|
|
transaction_date: 'Date',
|
|
processing_date: 'Processing Date',
|
|
transaction_description: 'Description',
|
|
comment: 'Comment',
|
|
mcc: 'MCC',
|
|
card_info: 'Card',
|
|
account: 'Account',
|
|
merchant_name: 'Merchant',
|
|
location: 'Location',
|
|
info_source: 'Source',
|
|
amount_original: 'Original Amount',
|
|
currency_original: 'Original Currency',
|
|
amount_rub: 'Amount (₽)',
|
|
category: 'Category',
|
|
subcategory: 'Subcategory',
|
|
microcategory: 'Microcategory',
|
|
simple_name: 'Name'
|
|
};
|
|
|
|
// Preferred column order (most important first)
|
|
const csvColumnOrder = ['transaction_date', 'simple_name', 'amount_rub', 'category', 'subcategory', 'microcategory', 'transaction_description', 'merchant_name', 'account', 'card_info', 'location', 'amount_original', 'currency_original', 'mcc', 'info_source', 'processing_date', 'comment'];
|
|
|
|
// Open transaction modal
|
|
function openTransactionModal(item) {
|
|
const modal = document.getElementById('transaction-modal');
|
|
const modalTitle = document.getElementById('modal-title');
|
|
const modalThead = document.getElementById('modal-thead');
|
|
const modalTbody = document.getElementById('modal-tbody');
|
|
|
|
// Set title
|
|
modalTitle.textContent = item.name;
|
|
|
|
// Gather transactions
|
|
let transactions = [];
|
|
if (item.transactions && item.transactions.length > 0) {
|
|
transactions = item.transactions.filter(t => t.originalRow);
|
|
} else if (item.originalRow) {
|
|
transactions = [item];
|
|
}
|
|
|
|
// Clear previous content
|
|
modalThead.innerHTML = '';
|
|
modalTbody.innerHTML = '';
|
|
|
|
if (transactions.length === 0) {
|
|
modalTbody.innerHTML = '<tr><td colspan="100%">No transaction data available</td></tr>';
|
|
modal.style.display = 'flex';
|
|
setupModalListeners();
|
|
return;
|
|
}
|
|
|
|
// Sort by date descending
|
|
transactions.sort((a, b) => {
|
|
const dateA = a.originalRow?.transaction_date || a.date || '';
|
|
const dateB = b.originalRow?.transaction_date || b.date || '';
|
|
return dateB.localeCompare(dateA);
|
|
});
|
|
|
|
// Get all columns from the first transaction's originalRow
|
|
const sampleRow = transactions[0].originalRow;
|
|
const availableColumns = csvColumnOrder.filter(col => col in sampleRow);
|
|
|
|
// Build header
|
|
const headerRow = document.createElement('tr');
|
|
availableColumns.forEach(col => {
|
|
const th = document.createElement('th');
|
|
th.textContent = csvColumnLabels[col] || col;
|
|
headerRow.appendChild(th);
|
|
});
|
|
modalThead.appendChild(headerRow);
|
|
|
|
// Build rows
|
|
transactions.forEach(transaction => {
|
|
const row = document.createElement('tr');
|
|
availableColumns.forEach(col => {
|
|
const td = document.createElement('td');
|
|
let value = transaction.originalRow[col];
|
|
|
|
// Format amount values
|
|
if ((col === 'amount' || col === 'original_amount') && typeof value === 'number') {
|
|
value = value.toLocaleString();
|
|
}
|
|
|
|
td.textContent = value !== undefined && value !== null ? value : '';
|
|
td.title = td.textContent; // Show full text on hover
|
|
row.appendChild(td);
|
|
});
|
|
modalTbody.appendChild(row);
|
|
});
|
|
|
|
modal.style.display = 'flex';
|
|
setupModalListeners();
|
|
}
|
|
|
|
// Close transaction modal
|
|
function closeTransactionModal() {
|
|
const modal = document.getElementById('transaction-modal');
|
|
modal.style.display = 'none';
|
|
document.removeEventListener('keydown', handleModalEscape);
|
|
}
|
|
|
|
// Handle Escape key to close modal
|
|
function handleModalEscape(e) {
|
|
if (e.key === 'Escape') {
|
|
closeTransactionModal();
|
|
}
|
|
}
|
|
|
|
// Setup modal close listeners
|
|
function setupModalListeners() {
|
|
const modal = document.getElementById('transaction-modal');
|
|
const closeBtn = document.getElementById('modal-close');
|
|
|
|
// Close on X button click
|
|
closeBtn.onclick = closeTransactionModal;
|
|
|
|
// Close on backdrop click
|
|
modal.onclick = function(e) {
|
|
if (e.target === modal) {
|
|
closeTransactionModal();
|
|
}
|
|
};
|
|
|
|
// Close on Escape key
|
|
document.addEventListener('keydown', handleModalEscape);
|
|
}
|