21 KiB
Visual Spending Visualization - Developer Guide
This document provides details on how to customize and extend the Visual Spending sunburst chart. The visualization is built using Apache ECharts and displays spending data in a hierarchical, interactive format.
Table of Contents
- Project Structure
- Chart Configuration
- Label Customization
- Hover Effects
- Details Box
- Color Management
- Drilling Down & Interactivity
- Data Structure
- Layout & Positioning
- Advanced Configuration
Project Structure
The project consists of four main files:
- index.html: Contains the basic HTML structure and imports libraries
- styles.css: Handles styling for the visualization and details box
- app.js: Contains the core functionality and visualization logic
- server.js: A simple Node.js server to serve the application
Chart Configuration
The chart is created and configured in the renderChart function in app.js. The main configuration is defined in the option object:
option = {
backgroundColor: '#fff',
grid: {
left: '10%',
containLabel: true
},
series: {
type: 'sunburst',
radius: [0, '95%'],
center: ['40%', '50%'],
nodeClick: 'rootToNode',
// ... other options
},
// ... graphic elements, etc.
};
Key Configuration Properties
type: 'sunburst': Specifies the chart typeradius: Controls the inner and outer radius of the chartcenter: Positions the chart in the container [x, y]nodeClick: Determines behavior when clicking nodes ('rootToNode', 'link', false)gap: Space between segments (default: 0)sort: How to sort data (null, 'asc', 'desc', or function)
Label Customization
Labels are configured at multiple levels:
Global Label Configuration
label: {
show: true,
formatter: function(param) {
// Logic to format labels
if (param.depth === 0) {
if (param.name.length > 10) {
return param.name.replace(/(.{1,10})(?: |$)/g, "$1\n").trim();
}
return param.name;
} else {
return '';
}
},
minAngle: 5, // Only show labels in segments covering at least 5 degrees
align: 'center', // Horizontal alignment: 'left', 'center', 'right'
verticalAlign: 'middle', // Vertical alignment: 'top', 'middle', 'bottom'
position: 'inside' // Position: 'inside', 'outside'
}
Per-Level Label Configuration
Each level in the sunburst can have its own label configuration:
levels: [
{}, // Level 0 (uses defaults)
{
// Level 1 (Categories)
r0: '15%', // Inner radius
r: '35%', // Outer radius
label: {
show: true,
rotate: 'tangential', // 'radial', 'tangential', or 0-360 degrees
fontSize: 12,
lineHeight: 15,
align: 'center',
verticalAlign: 'middle',
position: 'inside'
}
},
// ... other levels
]
Label Rotation Options
rotate: 'tangential': Text follows the curve of the segmentrotate: 'radial': Text radiates outward from centerrotate: 30: Rotate by a specific angle (in degrees)
Showing/Hiding Labels
You can use a function to conditionally show/hide labels based on any criteria:
label: {
show: function(param) {
// Only show for segments larger than 5% of total
return (param.value / totalValue) > 0.05;
}
}
Hover Effects
Hover effects are defined in the emphasis property:
emphasis: {
focus: 'ancestor', // 'self', 'series', 'ancestor', 'descendant'
label: {
show: function(param) {
// Show labels for specific depths on hover
return param.depth === 3 || (param.depth === 2 && !param.data.children);
},
position: function(param) {
// Position labels differently based on depth
if (param.depth === 3 || (param.depth === 2 && !param.data.children)) {
return 'outside';
}
return 'inside';
},
formatter: function(param) {
// Format hover labels
if (param.depth > 0) {
if (param.name.length > 15) {
return param.name.slice(0, 12) + '...';
}
return param.name;
}
return param.name;
},
fontSize: 11,
fontWeight: 'bold',
backgroundColor: 'rgba(255,255,255,0.85)', // Label background
borderRadius: 4, // Rounded corners
padding: [3, 5], // [vertical, horizontal] padding
align: 'left',
verticalAlign: 'middle',
distance: 10, // Distance from segment (for 'outside')
borderColor: '#fff',
borderWidth: 1,
shadowBlur: 3,
shadowColor: 'rgba(0,0,0,0.2)'
},
itemStyle: {
shadowBlur: 10, // Highlight shadow
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
Focus Types
The focus property controls what gets highlighted:
'self': Only highlight the hovered segment'ancestor': Highlight the segment and its ancestors'descendant': Highlight the segment and its descendants'series': Highlight the entire series
Hover Label Animation
To adjust how labels animate on hover:
- For smoother transitions, use CSS transitions on the label elements
- Adjust
distanceto control how far labels move - Set
animation: trueandanimationDuration: 300(milliseconds) for animated transitions
Details Box
The details box is updated via event handlers in the setupHoverEvents function:
function setupHoverEvents() {
const hoverNameElement = document.getElementById('hover-name');
const hoverAmountElement = document.getElementById('hover-amount');
const topItemsElement = document.getElementById('top-items');
myChart.on('mouseover', function(params) {
if (params.data) {
// Update details box content
hoverNameElement.textContent = params.name;
hoverAmountElement.textContent = params.value.toLocaleString() + ' RUB';
// Show top items/subcategories
topItemsElement.innerHTML = '';
if (params.data.children && params.data.children.length > 0) {
// Sort and display children
// ...
}
}
});
}
Customizing the Details Box
HTML Structure (index.html)
<div id="details-box">
<h3>Details</h3>
<div id="hover-name">Hover over a segment to see details</div>
<div id="hover-amount"></div>
<h4>Top Items:</h4>
<div id="top-items"></div>
</div>
CSS Styling (styles.css)
#details-box {
position: absolute;
top: 10px;
right: 20px;
width: 280px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.15);
padding: 15px;
z-index: 10;
min-height: 200px;
max-height: 500px;
overflow-y: auto;
opacity: 0.95;
border: 1px solid #eee;
}
/* Additional styling for elements inside the box */
Modifying Top Items Display
To change how the top items are displayed:
// Inside mouseover event handler
if (params.data.children && params.data.children.length > 0) {
// Sort children by value (or any other criteria)
const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value);
// Limit to top N (e.g., top 10)
const topChildren = sortedChildren.slice(0, 10);
// Create elements for each item
topChildren.forEach(child => {
const itemDiv = document.createElement('div');
itemDiv.className = 'top-item';
// Customize what data is displayed
const nameSpan = document.createElement('span');
nameSpan.className = 'top-item-name';
nameSpan.textContent = child.name;
const amountSpan = document.createElement('span');
amountSpan.className = 'top-item-amount';
// Format values as needed
const percentage = ((child.value / params.value) * 100).toFixed(1);
amountSpan.textContent = `${child.value.toLocaleString()} ₽ (${percentage}%)`;
itemDiv.appendChild(nameSpan);
itemDiv.appendChild(amountSpan);
topItemsElement.appendChild(itemDiv);
});
}
Color Management
Default Colors
The initial colors are defined in the transformToSunburst function:
const colors = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72',
'#d56358', '#82b1ff', '#f19143', '#addf84', '#6f7787'
];
Color Assignment on Click
When clicking on a segment, colors are reassigned to children:
myChart.on('click', function(params) {
if (params.depth >= 0) {
const colorPalette = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
];
// ... find target data ...
// Recolor children
targetData.children.forEach((child, i) => {
const color = colorPalette[i % colorPalette.length];
child.itemStyle = {
color: color
};
// Handle grandchildren with color gradients
if (child.children && child.children.length > 0) {
const microColors = generateColorGradient(color, child.children.length);
child.children.forEach((micro, j) => {
micro.itemStyle = {
color: microColors[j]
};
});
}
});
// Update data
// ...
}
});
Color Gradient Generation
The generateColorGradient function creates variations of a base color:
function generateColorGradient(baseColor, steps) {
const result = [];
const base = tinycolor(baseColor);
for (let i = 0; i < steps; i++) {
let color;
if (i % 3 === 0) {
color = base.clone().lighten(15); // Lighter version
} else if (i % 3 === 1) {
color = base.clone().darken(10); // Darker version
} else {
color = base.clone().saturate(20); // More saturated
}
result.push(color.toString());
}
return result;
}
Custom Color Schemes
To use a different color scheme:
- Replace the color arrays with your preferred colors
- For thematic colors, consider using ColorBrewer schemes or other predefined palettes
- Use TinyColor functions to generate related colors:
lighten()/darken()saturate()/desaturate()spin()(shift hue)complement()(get complementary color)
Drilling Down & Interactivity
Click Behavior
The click behavior is controlled by the nodeClick option and event handlers:
// In the chart options
nodeClick: 'rootToNode', // 'rootToNode', 'link', or false
// Event handler
myChart.on('click', function(params) {
// Handle the click event
});
Options for nodeClick:
'rootToNode': Drill down to show the clicked node as root'link': Use the link option of nodes (if defined)false: Disable click interaction
Custom Drill Down
For full control over drill down behavior, set nodeClick: false and handle clicks manually:
myChart.on('click', function(params) {
// Update the chart based on what was clicked
// You can use a data structure to track the current view state
// And update the displayed data accordingly
});
Adding Click-to-Reset
To add a "click center to reset" feature:
// Add a click event listener to the center area
myChart.getZr().on('click', function(event) {
// Get clicked position
const x = event.offsetX;
const y = event.offsetY;
// Check if click is in the center area
const centerX = myChart.getWidth() * 0.4; // Match your 'center' setting
const centerY = myChart.getHeight() * 0.5;
const innerRadius = myChart.getWidth() * 0.15; // Match your inner radius
const distance = Math.sqrt(
Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2)
);
if (distance < innerRadius) {
// Reset the chart to its initial state
resetChart();
}
});
function resetChart() {
// Reset the chart data to initial state
option.series.data = JSON.parse(JSON.stringify(originalData));
myChart.setOption(option, { replaceMerge: ['series'] });
}
Data Structure
CSV Data Format
The app reads data from CSV files with the following structure:
transaction_date,processing_date,transaction_description,comment,mcc,card_info,account,merchant_name,location,info_source,amount_original,currency_original,amount_rub,category,subcategory,microcategory,simple_name
Key fields used in the visualization:
category: First level of the hierarchysubcategory: Second level of the hierarchymicrocategory: Third level of the hierarchyamount_rub: The amount spent in Russian rubles
Transformed Data Structure
The data is transformed into a hierarchical structure by transformToSunburst:
// Sample output
{
total: 426571.09, // Total spending
data: [
{
name: "Food",
value: 120000,
children: [
{
name: "Groceries",
value: 80000,
children: [
{ name: "Fruits", value: 20000 },
{ name: "Vegetables", value: 15000 },
// ...
]
},
// More subcategories...
]
},
// More categories...
]
}
Adding Custom Data
To add metadata or custom properties:
// In the transformation function
const categoryNode = {
name: categoryObj.name,
value: categoryObj.value,
children: [],
// Custom properties
metadata: {
avgTransaction: categoryObj.value / transactionCount,
lastTransaction: lastTransactionDate,
// ...any other custom data
}
};
Layout & Positioning
Chart Size and Position
series: {
type: 'sunburst',
radius: [0, '95%'], // [innerRadius, outerRadius]
center: ['40%', '50%'], // [x, y] as percentages
}
Ring Size Adjustment
The rings are sized using the levels configuration:
levels: [
{}, // Level 0 (uses defaults)
{
r0: '15%', // Inner radius of this level
r: '35%', // Outer radius of this level
// ...
},
{
r0: '35%', // Should match the outer radius of previous level
r: '65%',
// ...
},
{
r0: '65%',
r: '72%', // Just 7% width for the outermost ring
// ...
}
]
Central Text Positioning
The text in the center is positioned using graphic elements:
graphic: [
{
type: 'text',
left: '40%', // Should match center[0]
top: '50%', // Vertical position
style: {
text: totalAmount.toLocaleString(),
// ... styling
},
z: 100 // Higher values appear on top
},
{
type: 'text',
left: '40%',
top: '58%',
style: {
text: 'RUB',
// ... styling
},
z: 100
}
]
Advanced Configuration
Custom Tooltips
tooltip: {
trigger: 'item', // 'item' or 'axis'
formatter: function(info) {
// Customize tooltip content
const value = info.value.toLocaleString();
const name = info.name;
const percentage = ((info.value / totalValue) * 100).toFixed(1);
// Can return string or HTML
return `
<div style="font-weight:bold">${name}</div>
<div>Amount: ${value} RUB</div>
<div>Percentage: ${percentage}%</div>
`;
},
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#ccc',
borderWidth: 1,
padding: 10,
textStyle: {
color: '#333'
}
}
Animation Settings
animation: true,
animationThreshold: 1000, // Don't animate if too many data points
animationDuration: 1000,
animationEasing: 'cubicOut', // 'linear', 'quadraticIn/Out', 'cubicIn/Out', etc.
animationDelay: 0,
Custom Sorting
// Sort segments by value (descending)
sort: function(a, b) {
return b.value - a.value;
}
// Sort alphabetically
sort: function(a, b) {
return a.name.localeCompare(b.name);
}
// Or use predefined options:
sort: 'desc' // 'desc', 'asc', or null (for data order)
Adding Event Listeners
// After setting up the chart
myChart.on('mouseover', function(params) { /* ... */ });
myChart.on('mouseout', function(params) { /* ... */ });
myChart.on('click', function(params) { /* ... */ });
// For lower-level events
const zr = myChart.getZr();
zr.on('click', function(event) { /* ... */ });
zr.on('mousemove', function(event) { /* ... */ });
Responsiveness
// Handle window resize
window.addEventListener('resize', function() {
myChart.resize();
});
// For more complex responsive behavior
window.addEventListener('resize', function() {
const width = window.innerWidth;
if (width < 768) {
// Mobile view
option.series.center = ['50%', '50%'];
option.series.radius = [0, '90%'];
// Update other settings for small screens
} else {
// Desktop view
option.series.center = ['40%', '50%'];
option.series.radius = [0, '95%'];
}
myChart.setOption(option);
myChart.resize();
});
Adding Custom Legend
// In chart options
legend: {
type: 'scroll',
orient: 'vertical',
right: 10,
top: 20,
bottom: 20,
data: categoryNames // Array of category names
}
// Or create a custom legend outside the chart
function createCustomLegend() {
const legendContainer = document.createElement('div');
legendContainer.className = 'custom-legend';
// Add legend items
sunburstData.data.forEach((category, index) => {
const item = document.createElement('div');
item.className = 'legend-item';
const colorBox = document.createElement('span');
colorBox.className = 'color-box';
colorBox.style.backgroundColor = colors[index % colors.length];
const label = document.createElement('span');
label.textContent = `${category.name} (${category.value.toLocaleString()} RUB)`;
item.appendChild(colorBox);
item.appendChild(label);
legendContainer.appendChild(item);
// Add click handler to highlight corresponding segment
item.addEventListener('click', function() {
// Find and highlight the corresponding segment
});
});
document.querySelector('.content-wrapper').appendChild(legendContainer);
}
Performance Optimization
For large datasets:
// Limit data size
function simplifyData(data, threshold) {
// If there are too many small items, group them as "Other"
const result = [];
const other = { name: 'Other', value: 0, children: [] };
data.forEach(item => {
if (item.value / totalValue > threshold) {
result.push(item);
} else {
other.value += item.value;
other.children.push(item);
}
});
if (other.value > 0) {
result.push(other);
}
return result;
}
// Usage
const simplifiedData = simplifyData(originalData, 0.01); // Group items < 1%
Troubleshooting
Common Issues
- Labels not appearing: Check
minAnglesetting - labels only show on segments larger than the minimum angle - Click not working: Ensure
nodeClickis set correctly and event handlers are properly configured - Incorrect colors: Check that color assignments in the transformation and click handlers are working
- Chart not updating: Make sure to use
replaceMergeoption when updating data:myChart.setOption(option, { replaceMerge: ['series'] })
Debugging
For debugging, you can add console logging in key functions:
myChart.on('click', function(params) {
console.log('Click params:', params);
// Continue with normal handling
});
// Or inspect the current options
console.log('Current options:', myChart.getOption());