visual-spending/app.js
2025-03-20 17:52:19 +03:00

842 lines
33 KiB
JavaScript

// Initialize the chart
const chartDom = document.getElementById('chart-container');
const myChart = echarts.init(chartDom);
let option;
// Function to parse CSV data
async function parseCSV(file) {
const response = await fetch(file);
const data = await response.text();
// Split the CSV into rows
const rows = data.split('\n');
// Extract headers and remove quotes
const headers = rows[0].split(',').map(header => header.replace(/"/g, ''));
// Parse the data rows
const result = [];
for (let i = 1; i < rows.length; i++) {
if (!rows[i].trim()) continue;
// Handle commas within quoted fields
const row = [];
let inQuote = false;
let currentValue = '';
for (let j = 0; j < rows[i].length; j++) {
const char = rows[i][j];
if (char === '"') {
inQuote = !inQuote;
} else if (char === ',' && !inQuote) {
row.push(currentValue.replace(/"/g, ''));
currentValue = '';
} else {
currentValue += char;
}
}
// Push the last value
row.push(currentValue.replace(/"/g, ''));
// Create an object from headers and row values
const obj = {};
for (let j = 0; j < headers.length; j++) {
obj[headers[j]] = row[j];
}
result.push(obj);
}
return result;
}
// Function to transform data into a sunburst format
function transformToSunburst(data) {
// Calculate total spending
let totalSpending = 0;
// Group by categories
const categories = [];
const categoryMap = {};
// 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'
];
data.forEach(item => {
const category = item.category || '';
const subcategory = item.subcategory || '';
const microcategory = item.microcategory || '';
const amount = Math.abs(parseFloat(item.amount_rub));
const transactionKey = `${category}|${subcategory}|${microcategory}`;
if (!isNaN(amount)) {
totalSpending += amount;
// Save transaction data for detail box
if (!transactionMap[transactionKey]) {
transactionMap[transactionKey] = [];
}
transactionMap[transactionKey].push({
name: item.simple_name || 'Transaction',
value: amount,
date: item.date
});
if (!categoryMap[category] && category !== '') {
categoryMap[category] = {
name: category,
value: 0,
children: {},
itemStyle: {},
transactions: transactionMap[transactionKey] // Store transactions
};
categories.push(categoryMap[category]);
}
if (category !== '') {
if (!categoryMap[category].children[subcategory] && subcategory !== '') {
categoryMap[category].children[subcategory] = {
name: subcategory,
value: 0,
children: {},
itemStyle: {},
transactions: transactionMap[transactionKey] // Store transactions
};
}
if (subcategory !== '') {
if (!categoryMap[category].children[subcategory].children[microcategory] && microcategory !== '') {
categoryMap[category].children[subcategory].children[microcategory] = {
name: microcategory,
value: 0,
itemStyle: {},
transactions: transactionMap[transactionKey] // Store transactions
};
}
categoryMap[category].value += amount;
categoryMap[category].children[subcategory].value += amount;
if (microcategory !== '') {
categoryMap[category].children[subcategory].children[microcategory].value += amount;
}
} else {
categoryMap[category].value += amount;
}
}
}
});
// Sort categories by value (largest to smallest)
categories.sort((a, b) => 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 render the chart
function renderChart(data) {
const sunburstData = transformToSunburst(data);
option = {
backgroundColor: '#fff',
grid: {
left: '10%', // Move chart to the left
containLabel: true
},
series: {
type: 'sunburst',
radius: [0, '95%'],
center: ['40%', '50%'], // Move chart to the left
nodeClick: 'rootToNode', // To enable drill down on click
data: sunburstData.data,
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: {
show: false,
position: 'outside',
padding: 3,
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: [
{
type: 'text',
left: '37%', // Align with new center position
top: '49%',
style: {
text: sunburstData.total.toFixed(0).toLocaleString()+" ₽",
fontWeight: 'bold',
fontSize: 18,
textAlign: 'center',
textVerticalAlign: 'middle',
position: 'center',
width: 20,
height: 40
},
z: 100 // Ensure it's on top
}
]
};
// Handle chart events - recolor on drill down
myChart.off('click');
myChart.on('click', function(params) {
if (params.depth >= 0) { // Handle clicks on any level, not just depth 0
const colorPalette = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
];
// Clone the data to modify it
const currentData = JSON.parse(JSON.stringify(option.series.data));
// Find the path to the clicked item
let targetData;
let categoryIndex = -1;
if (params.depth === 0) {
// If clicking on a top-level category
categoryIndex = currentData.findIndex(item => item.name === params.name);
if (categoryIndex !== -1) {
targetData = currentData[categoryIndex];
}
} else if (params.depth === 1) {
// If clicking on a subcategory, find its parent category first
for (let i = 0; i < currentData.length; i++) {
if (currentData[i].children) {
const subIndex = currentData[i].children.findIndex(sub => sub.name === params.name);
if (subIndex !== -1) {
targetData = currentData[i].children[subIndex];
categoryIndex = i;
break;
}
}
}
}
if (targetData && targetData.children && targetData.children.length > 0) {
// Sort children by value before recoloring
targetData.children.sort((a, b) => b.value - a.value);
// Recolor children with unique colors
targetData.children.forEach((child, i) => {
const color = colorPalette[i % colorPalette.length];
child.itemStyle = {
color: color
};
// If the child has children (microcategories), color them too
if (child.children && child.children.length > 0) {
// Sort microcategories by value
child.children.sort((a, b) => b.value - a.value);
const microColors = generateColorGradient(color, child.children.length);
child.children.forEach((micro, j) => {
micro.itemStyle = {
color: microColors[j]
};
});
}
});
// Update the chart with modified data
if (params.depth === 0) {
currentData[categoryIndex] = targetData;
} else if (params.depth === 1) {
// Update the subcategory within its parent category
for (let i = 0; i < currentData[categoryIndex].children.length; i++) {
if (currentData[categoryIndex].children[i].name === params.name) {
currentData[categoryIndex].children[i] = targetData;
break;
}
}
}
option.series.data = currentData;
myChart.setOption(option, { replaceMerge: ['series'] });
}
}
});
myChart.setOption(option);
// Set up hover events for the details box
setupHoverEvents(sunburstData);
}
// 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);
});
}
// Load all available month files
async function loadAvailableMonths() {
// Load TinyColor first
await loadTinyColor();
// Include all available months
const months = ['2025-01', '2025-02'];
const select = document.getElementById('month-select');
// Clear existing options
select.innerHTML = '';
months.forEach(month => {
const option = document.createElement('option');
option.value = month;
option.textContent = month;
select.appendChild(option);
});
// Load the most recent month by default
const latestMonth = months[months.length - 1];
const data = await parseCSV(`altcats-${latestMonth}.csv`);
renderChart(data);
// Set the select value to match
select.value = latestMonth;
// Add event listener for month selection
select.addEventListener('change', async (e) => {
const month = e.target.value;
const data = await parseCSV(`altcats-${month}.csv`);
renderChart(data);
});
}
// Initialize the visualization
loadAvailableMonths();
// Handle window resize
window.addEventListener('resize', function() {
myChart.resize();
});
// Add mouseover handler to update details box
function setupHoverEvents(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);
}
// Function to display items in the details box
function displayDetailsItems(items, parentValue) {
// Clear previous top items
topItemsElement.innerHTML = '';
// If we have items to show
if (items.length > 0) {
// Create elements for each item
items.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.className = 'top-item';
const nameSpan = document.createElement('span');
nameSpan.className = 'top-item-name';
// Add colored circle
const colorCircle = document.createElement('span');
colorCircle.className = 'color-circle';
// Use the item's color if available, otherwise use a default
if (item.itemStyle && item.itemStyle.color) {
colorCircle.style.backgroundColor = item.itemStyle.color;
} else if (item.color) {
colorCircle.style.backgroundColor = item.color;
} else {
colorCircle.style.backgroundColor = '#cccccc';
}
nameSpan.appendChild(colorCircle);
nameSpan.appendChild(document.createTextNode(item.name));
itemDiv.appendChild(nameSpan);
const amountSpan = document.createElement('span');
amountSpan.className = 'top-item-amount';
amountSpan.textContent = item.value.toLocaleString() + ' ₽';
// Add percentage if we have a parent value
if (parentValue) {
const percentage = ((item.value / parentValue) * 100).toFixed(1);
amountSpan.textContent += ` (${percentage}%)`;
}
itemDiv.appendChild(amountSpan);
topItemsElement.appendChild(itemDiv);
});
} else {
// No items to show
topItemsElement.innerHTML = '<div>No details available</div>';
}
}
// Show the default view with top categories
function showDefaultView() {
detailsHeader.innerHTML = `
<span class="hover-name">
<span class="color-circle" style="background-color: #ffffff;"></span>
Total
</span>
<span class="hover-amount">${sunburstData.total.toLocaleString()} ₽</span>
`;
// Show top categories as default items
displayDetailsItems(sunburstData.data, sunburstData.total);
}
// Show default view initially
showDefaultView();
// Track if we're inside a section to handle sector exit properly
let isInsideSection = false;
// Variables to track the circular boundary
const chartCenter = option.series.center;
const chartCenterX = chartDom.offsetWidth * (parseFloat(chartCenter[0]) / 100);
const chartCenterY = chartDom.offsetHeight * (parseFloat(chartCenter[1]) / 100);
const chartRadius = Math.min(chartDom.offsetWidth, chartDom.offsetHeight) *
(parseFloat(option.series.radius[1]) / 100);
// 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;
}
// Add general mousemove event listener to detect when outside chart circle
chartDom.addEventListener('mousemove', function(e) {
// Get mouse position relative to chart container
const rect = chartDom.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// If not inside the chart circle and we were inside a section, show default view
if (!isInsideChart(mouseX, mouseY) && isInsideSection) {
isInsideSection = false;
showDefaultView();
}
});
// Add mouseover event listener for sectors
myChart.on('mouseover', function(params) {
// Only process data nodes, not empty areas
if (params.data) {
isInsideSection = true;
// Set up the details header with name and amount
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
detailsHeader.innerHTML = `
<span class="hover-name">
<span class="color-circle" style="background-color: ${itemColor};"></span>
${params.name}
</span>
<span class="hover-amount">${params.value.toLocaleString()} ₽</span>
`;
// Number of items to show in the details box
const MAX_DETAIL_ITEMS = 10;
let itemsToShow = [];
// Check if the node has children (subcategories or microcategories)
if (params.data.children && params.data.children.length > 0) {
// Sort children by value
const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value);
itemsToShow = sortedChildren.slice(0, MAX_DETAIL_ITEMS);
}
// If we have transaction data and not enough children, fill with transactions
if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) {
// Sort transactions by value
const sortedTransactions = [...params.data.transactions].sort((a, b) => b.value - a.value);
// Add color to transactions
const coloredTransactions = sortedTransactions.map(t => {
return {
...t,
color: params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc')
};
});
// Take only what we need to reach MAX_DETAIL_ITEMS
const neededTransactions = coloredTransactions.slice(0, MAX_DETAIL_ITEMS - itemsToShow.length);
itemsToShow = [...itemsToShow, ...neededTransactions];
}
// Display the items
displayDetailsItems(itemsToShow, params.value);
}
});
// When mouse leaves a section but is still within the chart, we'll handle it with mousemove
myChart.on('mouseout', function(params) {
if (params.data) {
isInsideSection = false;
// Reset details immediately when leaving a section
showDefaultView();
}
});
// Add back the downplay event handler - this is triggered when sections lose emphasis
myChart.on('downplay', function(params) {
// Reset to default view when a section is no longer emphasized
showDefaultView();
});
}