# 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 1. [Project Structure](#project-structure) 2. [Chart Configuration](#chart-configuration) 3. [Label Customization](#label-customization) 4. [Hover Effects](#hover-effects) 5. [Details Box](#details-box) 6. [Color Management](#color-management) 7. [Drilling Down & Interactivity](#drilling-down--interactivity) 8. [Data Structure](#data-structure) 9. [Layout & Positioning](#layout--positioning) 10. [Advanced Configuration](#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: ```javascript 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 type - `radius`: Controls the inner and outer radius of the chart - `center`: 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 ```javascript 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: ```javascript 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 segment - `rotate: 'radial'`: Text radiates outward from center - `rotate: 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: ```javascript 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: ```javascript 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 `distance` to control how far labels move - Set `animation: true` and `animationDuration: 300` (milliseconds) for animated transitions ## Details Box The details box is updated via event handlers in the `setupHoverEvents` function: ```javascript 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) ```html

Details

Hover over a segment to see details

Top Items:

``` #### CSS Styling (styles.css) ```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: ```javascript // 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: ```javascript 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: ```javascript 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: ```javascript 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: 1. Replace the color arrays with your preferred colors 2. For thematic colors, consider using ColorBrewer schemes or other predefined palettes 3. 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: ```javascript // 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: ```javascript 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: ```javascript // 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 hierarchy - `subcategory`: Second level of the hierarchy - `microcategory`: Third level of the hierarchy - `amount_rub`: The amount spent in Russian rubles ### Transformed Data Structure The data is transformed into a hierarchical structure by `transformToSunburst`: ```javascript // 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: ```javascript // 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 ```javascript 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: ```javascript 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: ```javascript 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 ```javascript 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 `
${name}
Amount: ${value} RUB
Percentage: ${percentage}%
`; }, backgroundColor: 'rgba(255, 255, 255, 0.9)', borderColor: '#ccc', borderWidth: 1, padding: 10, textStyle: { color: '#333' } } ``` ### Animation Settings ```javascript 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 ```javascript // 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 ```javascript // 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 ```javascript // 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 ```javascript // 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: ```javascript // 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 1. **Labels not appearing**: Check `minAngle` setting - labels only show on segments larger than the minimum angle 2. **Click not working**: Ensure `nodeClick` is set correctly and event handlers are properly configured 3. **Incorrect colors**: Check that color assignments in the transformation and click handlers are working 4. **Chart not updating**: Make sure to use `replaceMerge` option when updating data: `myChart.setOption(option, { replaceMerge: ['series'] })` ### Debugging For debugging, you can add console logging in key functions: ```javascript 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()); ```