javascriptchart.js

Group data differently in bar chart


I am trying to build a bar-graph using chart.js where i add "random" groups of data. Each group should be labeled with a group name and each bar with the property it really represents.

Data is fetched from a DB and the groups may differ in size, depending on the selection criteria of the user.

In Excel it is possible to achieve a result similar to the desired one (padding each new dataset with 0 for each already existing bar). The drawback is that with each new dataset the bars grow thinner and thinner.

Bar-Graph with grouped bar-categories

A similar result is achievable with Chart.js using the same trick. Each new dataset is padded with null so the bars don't get mixed up with the previous ones. By setting skipNull: true the bars don't grow thinner. So far so good, but when you click on a category name in the legend, its data is hidden, but the labels on X-Axis and the gaps left by the hidden bars still remain. It would be very useful to hide/show some of the added categories to enable a closer comparison between two categories.

In a previous SO-Question, the only answer tells it is not possible to achieve my desired result, but this QA is seven years old, so things might have changed.

Another SO-Post achieves a similar result by reordering the data and putting the Bar-Names as labels on top of the bars, but it loses the ability to hide a category. (The legend is hidden to avoid it, if you show the legend and click on one item, it will not hide a category but the n-th element of all categories. Try it out on the demo of the accepted answer, edit line 76)

An alternative approach would be to add a separate "chart-object" to the same canvas sharing the axes, is there any possibility to achieve this?


Solution

  • The bars shouldn't shrink if skipNull option is set true and one adds nulls for the datasets that don't have data at certain x positions; if each dataset is to have an exclusive, contiguous region of the x axis, then its data should have nulls for all positions of the regions allocated to other datasets. Those nulls should be kept in sync whenever new data is added. Here's a snippet implementing those ideas, and also adds an empty x category to separate consecutive datasets:

    const config = {
        type: "bar",
    
        data: {
            labels: ['Cox-Orange', 'GS', 'Golden', 'Redlove'],
            datasets: [
                {
                    label: 'Apples',
                    data: Array.from({length: 4}, () => 100 + 200 * Math.random()),
                    backgroundColor: 'red'
                },
            ]
        },
    
        options: {
            maintainAspectRatio: false,
            barPercentage: 0.99,
            categoryPercentage: 0.95,
            skipNull: true,
            scales:{
                x: {
                    ticks: {
                        minRotation: 90
                    }
                }
            }
        }
    };
    
    function addDataGroup(newGroupLabel, newAxisLabels, newData, color){
        const nNew = newAxisLabels.length,
            datasets = chart.data.datasets,
            nExisting = datasets[datasets.length-1].data.length;
        for(const dataset of datasets){
            dataset.data.push(...Array(nNew + 1).fill(null));
        }
        const newDataset = {
            label: newGroupLabel,
            data: Array(nExisting+1).fill(null).concat(newData),
            backgroundColor: color
        }
        datasets.push(newDataset);
        chart.data.labels.push('', ...newAxisLabels);
        chart.update();
    }
    
    const chart = new Chart('myChart', config);
    setTimeout(
        ()=>{
            addDataGroup('Citrus', ['Oranges', 'Lemon', 'Pomelo', 'Tangerine'],
                Array.from({length: 4}, () => 100 + 200 * Math.random()), 'orange');
        }, 2000
    );
    
    setTimeout(
        ()=>{
            addDataGroup('Cucumber', ['Straight', 'Curved', 'Long'],
                Array.from({length: 3}, () => 100 + 200 * Math.random()), 'green');
        }, 5000
    );
    
    setTimeout(
        ()=>{
            addDataGroup('Exotic', ['Pomelo', 'Papaya', 'Pineapple', 'Banana'],
                Array.from({length: 4}, () => 100 + 200 * Math.random()), 'yellow');
        }, 8000
    );
    <div style="height: 300px">
        <canvas id="myChart">
        </canvas>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    or as jsFiddle.

    As for the x axis adapting to datasets being hidden through the legend, by eliminating the categories that no longer have visible data, that is an interesting proposition. It can be implemented through legend.onClick and then manipulating the chart.data.labels. One has to also adjust the datasets themselves in order to keep the nulls mentioned above in sync with the labels - remove the nulls for a dataset that was just hidden and add them back when the dataset is made visible again:

    const config = {
        type: "bar",
    
        data: {
            labels: ['Cox-Orange', 'GS', 'Golden', 'Redlove'],
            datasets: [
                {
                    label: 'Apples',
                    data: Array.from({length: 4}, () => 100 + 200 * Math.random()),
                    backgroundColor: 'red'
                },
            ]
        },
    
        options: {
            maintainAspectRatio: false,
            barPercentage: 0.99,
            categoryPercentage: 0.95,
            skipNull: true,
            scales:{
                x: {
                    ticks: {
                        minRotation: 90
                    }
                }
            },
            plugins:{
                legend:{
                    labels: {
                        color: 'black'
                    },
                    onClick: toggleGroupWithLabels
                }
            }
        }
    };
    
    const unhiddenData = {data:[], labels:[]};
    // can be linked to the chart object if global variables are inconvenient
    
    function initUpdates(chart){
        if(unhiddenData.data.length === 0){
            if(chart.data.datasets.length > 1){
                throw new Error('Cannot start with multiple datasets');
            }
            else if(chart.data.datasets.length === 1){
                unhiddenData.data.push([...chart.data.datasets[0].data]);
                unhiddenData.labels = [[...chart.data.labels]];
            }
    
        }
    }
    
    function addDataGroup(chart, newGroupLabel, newAxisLabels, newData, color){
        chart.options.plugins.legend.onClick = () => null; // disable legend toggle while updating
        chart.options.plugins.legend.labels.color = 'lightgray'; // visual indication for above
        chart.options.animation.onComplete = () => {
            chart.options.plugins.legend.onClick = toggleGroupWithLabels;
            chart.options.plugins.legend.labels.color = 'black';
            setTimeout(()=>chart.update('none'), 0);
        }
    
        chart.data.datasets.push({label: newGroupLabel, backgroundColor: color, data: []})
    
        unhiddenData.data.push(newData)
        unhiddenData.labels.push(newAxisLabels);
    
        _updateData(chart);
    
        chart.update();
    }
    
    function _updateData(chart){
        const nDatasets = chart.data.datasets.length,
            hidden = Array.from({length: nDatasets}, (_, i)=>chart.getDatasetMeta(i).hidden);
        if(hidden.length < nDatasets){
            hidden.push(true);
        }
        chart.data.labels = [];
        const labels = chart.data.labels;
        for(let i = 0; i < nDatasets; i++){
            if(!hidden[i]){
                if(labels.length > 0){
                    labels.push('');
                }
                labels.push(...unhiddenData.labels[i]);
            }
        }
        for(let i = 0; i < nDatasets; i++){
            if(!hidden[i]){
                chart.data.datasets[i].data = [];
                const data = chart.data.datasets[i].data;
                for(let j = 0; j < nDatasets; j++){
                    if(!hidden[j]){
                        if(data.length > 0){
                            data.push(null);
                        }
                        if(i === j){
                            data.push(...unhiddenData.data[j])
                        }
                        else{
                            data.push(...unhiddenData.data[j].map(() => null));
                        }
                    }
                }
            }
        }
    }
    
    function toggleGroupWithLabels(...args){
        const {chart} = args[0],
            {datasetIndex} = args[1];
        const metaToggle = chart.getDatasetMeta(datasetIndex);
        metaToggle.hidden = !metaToggle.hidden;
    
    
        _updateData(chart);
        chart.update('none');
    }
    
    const chart = new Chart('myChart', config);
    initUpdates(chart)
    
    setTimeout(
        ()=>{
            addDataGroup(chart, 'Citrus', ['Oranges', 'Lemon', 'Pomelo', 'Tangerine'],
                Array.from({length: 4}, () => 100 + 200 * Math.random()), 'orange');
        }, 3000
    );
    
    setTimeout(
        ()=>{
            addDataGroup(chart, 'Cucumber', ['Straight', 'Curved', 'Long'],
                Array.from({length: 3}, () => 100 + 200 * Math.random()), 'green');
        }, 6000
    );
    
    setTimeout(
        ()=>{
            addDataGroup(chart, 'Exotic', ['Pomelo', 'Papaya', 'Pineapple', 'Banana'],
                Array.from({length: 4}, () => 100 + 200 * Math.random()), 'yellow');
        }, 9000
    )
    <div style="height: 300px">
        <canvas id="myChart">
        </canvas>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

    or as jsFiddle.