visual-spending/DEV.md
Anton Volnuhin cfb07ce1c0 Initial
2025-03-19 20:41:30 +03:00

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

  1. Project Structure
  2. Chart Configuration
  3. Label Customization
  4. Hover Effects
  5. Details Box
  6. Color Management
  7. Drilling Down & Interactivity
  8. Data Structure
  9. Layout & Positioning
  10. 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 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

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 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:

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 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:

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:

  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:

// 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 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:

// 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

  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:

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());