663 lines
26 KiB
JavaScript
663 lines
26 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 = {};
|
|
|
|
// Predefined colors for categories
|
|
const colors = [
|
|
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
|
'#3ba272', '#fc8452', '#9a60b4', '#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));
|
|
|
|
if (!isNaN(amount)) {
|
|
totalSpending += amount;
|
|
|
|
if (!categoryMap[category] && category !== '') {
|
|
categoryMap[category] = {
|
|
name: category,
|
|
value: 0,
|
|
children: {},
|
|
itemStyle: {}
|
|
};
|
|
categories.push(categoryMap[category]);
|
|
}
|
|
|
|
if (category !== '') {
|
|
if (!categoryMap[category].children[subcategory] && subcategory !== '') {
|
|
categoryMap[category].children[subcategory] = {
|
|
name: subcategory,
|
|
value: 0,
|
|
children: {},
|
|
itemStyle: {}
|
|
};
|
|
}
|
|
|
|
if (subcategory !== '') {
|
|
if (!categoryMap[category].children[subcategory].children[microcategory] && microcategory !== '') {
|
|
categoryMap[category].children[subcategory].children[microcategory] = {
|
|
name: microcategory,
|
|
value: 0,
|
|
itemStyle: {}
|
|
};
|
|
}
|
|
|
|
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 categoryNode = {
|
|
name: category.name,
|
|
value: category.value,
|
|
children: [],
|
|
itemStyle: {
|
|
color: colors[colorIndex]
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
|
|
// Process each subcategory
|
|
subcategories.forEach(subcategory => {
|
|
const subcategoryNode = {
|
|
name: subcategory.name,
|
|
value: subcategory.value,
|
|
children: [],
|
|
itemStyle: {
|
|
color: colors[colorIndex]
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
|
|
// Add microcategories to subcategory
|
|
microcategories.forEach(micro => {
|
|
subcategoryNode.children.push({
|
|
name: micro.name,
|
|
value: micro.value,
|
|
itemStyle: {
|
|
color: colors[colorIndex]
|
|
}
|
|
});
|
|
});
|
|
|
|
if (subcategoryNode.children.length > 0) {
|
|
categoryNode.children.push(subcategoryNode);
|
|
} else {
|
|
categoryNode.children.push({
|
|
name: subcategory.name,
|
|
value: subcategory.value,
|
|
itemStyle: {
|
|
color: colors[colorIndex]
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
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: 12,
|
|
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: false,
|
|
fontSize: 11,
|
|
align: 'left',
|
|
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();
|
|
}
|
|
|
|
// Function to generate a color gradient
|
|
function generateColorGradient(baseColor, steps) {
|
|
const result = [];
|
|
const base = tinycolor(baseColor);
|
|
|
|
// Generate lighter shades for better contrast
|
|
for (let i = 0; i < steps; i++) {
|
|
// Create various tints and shades based on the position
|
|
let color;
|
|
if (i % 3 === 0) {
|
|
color = base.clone().lighten(15);
|
|
} else if (i % 3 === 1) {
|
|
color = base.clone().darken(10);
|
|
} else {
|
|
color = base.clone().saturate(20);
|
|
}
|
|
result.push(color.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();
|
|
|
|
// For now, we only have one month
|
|
const months = ['2025-01'];
|
|
const select = document.getElementById('month-select');
|
|
|
|
months.forEach(month => {
|
|
const option = document.createElement('option');
|
|
option.value = month;
|
|
option.textContent = month;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Load the first month by default
|
|
const data = await parseCSV(`altcats-${months[0]}.csv`);
|
|
renderChart(data);
|
|
|
|
// 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() {
|
|
const hoverNameElement = document.getElementById('hover-name');
|
|
const hoverAmountElement = document.getElementById('hover-amount');
|
|
const topItemsElement = document.getElementById('top-items');
|
|
|
|
// Add mouseover event listener
|
|
myChart.on('mouseover', function(params) {
|
|
// Only process data nodes, not empty areas
|
|
if (params.data) {
|
|
// Set the name and amount
|
|
hoverNameElement.textContent = params.name;
|
|
hoverAmountElement.textContent = params.value.toLocaleString() + ' RUB';
|
|
|
|
// Clear previous top items
|
|
topItemsElement.innerHTML = '';
|
|
|
|
// Find top items if there are children
|
|
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);
|
|
|
|
// Display top 10 or fewer
|
|
const topChildren = sortedChildren.slice(0, 10);
|
|
|
|
// Create elements for each top item
|
|
topChildren.forEach(child => {
|
|
const itemDiv = document.createElement('div');
|
|
itemDiv.className = 'top-item';
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'top-item-name';
|
|
nameSpan.textContent = child.name;
|
|
itemDiv.appendChild(nameSpan);
|
|
|
|
const amountSpan = document.createElement('span');
|
|
amountSpan.className = 'top-item-amount';
|
|
amountSpan.textContent = child.value.toLocaleString() + ' ₽';
|
|
itemDiv.appendChild(amountSpan);
|
|
|
|
// Add percentage
|
|
const percentage = ((child.value / params.value) * 100).toFixed(1);
|
|
amountSpan.textContent += ` (${percentage}%)`;
|
|
|
|
topItemsElement.appendChild(itemDiv);
|
|
});
|
|
} else {
|
|
// No children, show a message
|
|
topItemsElement.innerHTML = '<div>No subcategories available</div>';
|
|
}
|
|
}
|
|
});
|
|
|
|
// Reset on mouseout from the chart
|
|
myChart.on('mouseout', function(params) {
|
|
if (!params.data) {
|
|
hoverNameElement.textContent = 'Hover over a segment to see details';
|
|
hoverAmountElement.textContent = '';
|
|
topItemsElement.innerHTML = '';
|
|
}
|
|
});
|
|
}
|