Search code examples
javascriptplotlybar-chartplotly.js

Plotly JS stacked percent barchart with independent data in each bar


I'm using Plotly JS to draw a percent stacked bar chart where each "element" of each "bar" is totally independent from the elements of the other bars. Each bar may have a different number of elements. There may be many bars (e.g. 200), and each bar may have many elements (e.g. 50). The color of each element must be set independent from the color of any other element.

To be clear, the following image explains what I mean by "bar" and "element":

Definitions

See this codepen for a fully functional implementation of this.

The problem I'm having is with performance and memory consumption for a larger number of bars. In the above codepen, if you change the first line

const NUM_BARS = 50;

so that there are, say, 200 bars instead of 50, then you will observe the performance degradation.

I suspect the performance is poor because I am using Plotly JS's percent stacked bar chart in a somewhat unnatural way: each element is a separate trace, so there are tons of traces for Plotly to handle.

I'm hoping someone can help me with either or both of the following questions: (1) Is there a better (more performant) way to construct this plot using Plotly JS than how I'm doing it in the above codepen? (2) Is there another JavaScript plotting library that would allow me to construct this type of plot with better performance?


UPDATE: I left out a few small pieces of info in my original post. I need to use a custom HTML hovertemplate for each element. Additionally, I need to pass 2 string variables ("mybar" and "myelement") so that, on hover on an element, I can programmatically use the values of these 2 variables.

I have updated the above codepen accordingly. In particular, I defined

const hovertemplate = `<b>My HTML hover bar${b} element${e} grade ${grade}</b><extra></extra>`;

and then I added the following to each individual trace assignment:

mybar: `mybar${b}`,
myelement: `myelement${e}`,
hovertemplate: hovertemplate

My intention is to use mybar and myelement like this:

document.getElementById('myDiv').on('plotly_click', function (data) {
    myFunction(data.points[0].data.mybar, data.points[0].data.myelement);
});

Solution

  • You could optimize plotly rendering a lot, restructuring the data by eliminating the nulls; you can then combine everything into an array for x, y, name and marker.color. This way you're reducing the dimensionality of the data from 2d to 1d.

    const trace_data_optimized = [trace_data.map( // STEP1: extract just non-null data
        o => {
            let {x, y, marker} = o,
                {color} = marker,
                x1 = [];
            y = y.filter((v, j) => {
                if(v !== null){
                    x1.push(x[j]);
                    return true;
                }
            });
            return {
                ...o,
                x: x1[0],
                y: y[0],
                marker: {...marker, color: (color.filter(v => v !== null))[0]}
            };
        }
    ).reduce( // STEP2: combine everything into one array
        (acc, o) => {
            let {x, y, name, marker} = o,
                {color, line} = marker;
            acc.x.push(x);
            acc.y.push(y);
            acc.marker.color.push(color);
            acc.hovertext.push(name);
            if(acc.marker.line === null){
                acc.marker.line = line;
            }
            return acc;
        },
        {x:[], y:[], hovertext:[], type: "bar", marker:{color:[], line: null}}
    )];
    trace_data_optimized[0].hovertemplate = '%{x}, %{y}<extra>%{hovertext}</extra>'
    

    The result seems identical, but the run time improvement is significant.

    const NUM_BARS = 50;
    const doOriginal = true; // not recommended for NUM_BARS > 100
    
    function random_grade() {
        const grade_range = [-6, 5];
        if(Math.random() < 0.5) {
            return parseFloat( ( Math.random() + 3.0 ) );
        } else {
            return parseFloat((Math.random() * (grade_range[1]-grade_range[0]) + grade_range[0]).toFixed(1));
        }
    }
    
    function random_color() {
        const i = Math.floor(Math.random() * 5);
        const alpha = Math.random() * 0.5 + 0.5;
        if (i == 0) {
            return `rgba(255, 0, 0, ${alpha})`; // red
        } else if (i == 1) {
            return `rgba(255, 165, 0, ${alpha})`; // orange
        } else if (i == 2) {
            return `rgba(255, 255, 0, ${alpha})`; // yellow
        } else if (i == 3) {
            return `rgba(0, 255, 255, ${alpha})`; // cyan
        } else {
            return `rgba(0, 128, 0, ${alpha})`; // green
        }
    }
    
    function get_color_from_grade(grade_value) {
        const grade = parseFloat(grade_value);
        if(grade >= 3.0) {             // greens (A-, A, A+)
            const alpha = 0.5 + 0.5*Math.min((grade-3.0) / (4.0-3.0), 1.0);
            return `rgba(0, 128, 0, ${alpha})`;
        } else if(grade >= 1.5) {      // cyans (B-, B, B+)
            const alpha = 0.5 + 0.5*( (grade-1.5) / (2.5-1.5) );
            return `rgba(0, 139, 139, ${alpha})`;
        } else if(grade >= 0.0) {      // yellows (C-, C, C+)
            const alpha = 0.5 + 0.5*( (grade-0.0) / (1.0-0.0) );
            return `rgba(255, 255, 0, ${alpha})`;
        } else if(grade >= -5.0) {     // oranges (D-, D, D+)
            const alpha = 0.5 + 0.5*( (grade-(-5.0)) / ((-0.5)-(-5.0)) );
            return `rgba(255, 140, 0, ${alpha})`;
            //return `rgba(255, 165, 0, ${alpha})`;
        } else {                       // red
            const alpha = 1.0;
            return `rgba(255, 0, 0, ${alpha})`;
        }
    }
    
    let t0 = Date.now();
    const bars = [];
    for (let b = 0; b < NUM_BARS; b++) {
        bars.push(`bar${b}`);
    }
    
    const trace_data = [];
    
    for (let b = 0; b < NUM_BARS; b++) {
        const traces_for_bar = [];
        const num_elements = Math.max(Math.floor(Math.random() * 50), 1);
        for (let e = 0; e < num_elements; e++) {
    
            // Construct y array, which has 1 corresponding to the
            // present bar, and null for all other bars.
            const y_values = [];
            for (let y = 0; y < NUM_BARS; y++) {
                if (b == y) {
                    y_values.push(1);
                } else {
                    y_values.push(null);
                }
            }
    
            // Randomly generate grade for the present element.
            const grade = random_grade();
    
            // Construct color array, which has a color corresponding
            // to the present bar, and null for all other bars.
            const color_values = [];
            for (let c = 0; c < NUM_BARS; c++) {
                if(b == c) {
                    color_values.push(get_color_from_grade(grade));
                } else {
                    color_values.push(null);
                }
            }
    
            // Construct trace for the present element.
            const trace = {
                x: bars,
                y: y_values,
                marker: {
                    color: color_values,
                    line: {
                        width: 1,
                        color: ["black"]
                    }
                },
                name: `b${b}_e${e}`,
                type: "bar",
                grade: grade
            };
    
            // Add trace to traces for the present bar.
            traces_for_bar.push(trace);
        }
    
        // Sort traces for the present bar so that the
        // colors go from green (bottom) to red (top).
        traces_for_bar.sort((a,b)=>b.grade-a.grade);
    
        // Add traces for the present bar to the full set of all traces.
        trace_data.push(...traces_for_bar);
    }
    
    const trace_data_optimized = [trace_data.map( // STEP1: extract just non-null data
        o => {
            let {x, y, marker} = o,
                {color} = marker,
                x1 = [];
            y = y.filter((v, j) => {
                if(v !== null){
                    x1.push(x[j]);
                    return true;
                }
            });
            return {
                ...o,
                x: x1[0],
                y: y[0],
                marker: {...marker, color: (color.filter(v => v !== null))[0]}
            };
        }
    ).reduce( // STEP2: combine everything into one array
        (acc, o) => {
            let {x, y, name, marker} = o,
                {color, line} = marker;
            acc.x.push(x);
            acc.y.push(y);
            acc.marker.color.push(color);
            acc.hovertext.push(name);
            if(acc.marker.line === null){
                acc.marker.line = line;
            }
            return acc;
        },
        {x:[], y:[], hovertext:[], type: "bar", marker:{color:[], line: null}}
    )];
    trace_data_optimized[0].hovertemplate = '%{x}, %{y}<extra>%{hovertext}</extra>'
    
    let t1 = Date.now();
    const tInitial = (t1-t0)/1000;
    t0 = t1;
    
    var layout = {
        barmode: "stack",
        barnorm: "fraction",
        bargap: 0,
        showlegend: false,
        xaxis: {
            visible: false
        },
        yaxis: {
            showgrid: false,
            visible: false
        }
    };
    
    Plotly.newPlot("myDiv", trace_data_optimized, layout);
    let t2 = Date.now();
    const tOptimized = (t2-t0)/1000;
    document.querySelector('#t1').innerText = 'Plotly optimized data: ' + tOptimized.toFixed(2)+' s';
    
    if(doOriginal){
        t0 = Date.now();
        Plotly.newPlot("myDiv2", trace_data, layout);
        let t3 = Date.now();
        const tOriginal = (t3 - t0) / 1000;
        document.querySelector('#t2').innerText = 'Plotly original: ' + tOriginal.toFixed(2) + ' s';
    
        console.log('data preparation', tInitial)
        console.log('plotly optimized data', tOptimized)
        console.log('plotly original', tOriginal)
    
    }
    else{
        document.querySelector('#myDiv2').style.display = 'none';
        document.querySelector('#t2').style.display = 'none';
        document.querySelector('#hr2').style.display = 'none';
    }
    <div id='myDiv' style="height:50vh;min-height:500px"></div>
    <div style="position:relative;top:-5vh;text-align: center" id="t1"></div>
    <hr>
    <div id='myDiv2' style="height:50vh;min-height:500px"></div>
    <div style="position:relative;top:-5vh;text-align: center" id="t2"></div>
    <hr id="hr2">
    
    <script src='https://cdn.plot.ly/plotly-2.24.1.min.js'></script>

    The same in this codepen which also contains an implementation with highcharts, which seems even slower. Still, I'm not sure the highCharts version is optimal -- I'll have to revisit it early next week, as well as an implementation based on lightningCharts that isn't working yet.