0

I have a number of records in a database which have their status changes audited. I'm pulling this data out to display in a Chart.js horizontal bar chart to visualise the duration that each record is in each status.

I'm using the following code with dummy data to determine how I need to format the database data:

var myChart = new Chart(chartCtx, {
            type: 'bar',
            data: {
                labels: ['Record1', 'Record2', 'Record3', 'Record4', 'Record5'],
                datasets: [{
                    label: 'Open',
                    data: [
                        { x: '2024-02-01', y: 0, x2: '2024-02-04' },
                        { x: '2024-02-01', y: 1, x2: '2024-02-03' },
                        { x: '2024-02-01', y: 2, x2: '2024-02-02' },
                        { x: '2024-02-01', y: 3, x2: '2024-02-02' },
                        { x: '2024-02-04', y: 3, x2: '2024-02-10' },
                        { x: '2024-02-04', y: 4, x2: '2024-02-05' }
                    ],
                    backgroundColor: 'rgba(255, 99, 132, 0.2)',
                    borderColor: 'rgba(255, 99, 132, 1)',
                    borderWidth: 1
                },
                {
                    label: 'Under Review',
                    data: [
                        { x: '2024-02-04', y: 0, x2: '2024-02-07' },
                        { x: '2024-02-03', y: 1, x2: '2024-02-04' },
                        { x: '2024-02-02', y: 2, x2: '2024-02-08' },
                        { x: '2024-02-05', y: 4, x2: '2024-02-06' }
                    ],
                    backgroundColor: 'rgba(54, 162, 235, 0.2)',
                    borderColor: 'rgba(54, 162, 235, 1)',
                    borderWidth: 1
                },
                {
                    label: 'Closed',
                    data: [
                        { x: '2024-02-07', y: 0, x2: '2024-02-10' },
                        { x: '2024-02-04', y: 1, x2: '2024-02-10' },
                        { x: '2024-02-08', y: 2, x2: '2024-02-10' },
                        { x: '2024-02-02', y: 3, x2: '2024-02-07' },
                        { x: '2024-02-01', y: 4, x2: '2024-02-04' },
                        { x: '2024-02-06', y: 4, x2: '2024-02-10' }
                    ],
                    backgroundColor: 'rgba(75, 192, 192, 0.2)',
                    borderColor: 'rgba(75, 192, 192, 1)',
                    borderWidth: 1
                }]
            },
            options: {
                indexAxis: 'y',
                scales: {
                    x: {
                        stacked: true,
                        ticks: {
                            beginAtZero: true
                        },
                        type: 'time',
                        time: {
                            unit: 'day'
                        },
                        min: '2024-01-31',
                        max: '2024-02-10'
                    },
                    y: {
                        stacked: true
                    }
                }
            }
        });

For some reason, the chart isn't stacking correctly and the values also seem to start/end at the wrong points.

enter image description here enter image description here

I'm not sure if I'm using the correct format for the data to build the chart using a range. I think that the Y value is being used as the height of the data rather than indexing it.

Any help to figure out the correct data structure would be greatly appreciated.

Cheers,

Jamie

1
  • This code doesn't work
    – IT goldman
    Commented May 23 at 12:03

1 Answer 1

0

I'm not 100% sure what is the exact result you want to achieve, but I think what you want is to have "floating" bars, see the example in the docs. With that you fix both the base and the end of each bar. Otherwise, with floating bar, when you disable a dataset, the others will naturally "collapse" to the left.

For "floating" bars, a data point might look like:

{x: ['2024-02-06', '2024-02-10'], y: 0}

Here is your code with that change in data, stacked: true removed from the x axis, plus borderSkipped: false option to have border drawn on all sides.

var myChart = new Chart('myChart', {
    type: 'bar',
    data: {
        labels: ['Record1', 'Record2', 'Record3', 'Record4', 'Record5'],
        datasets: [{
            label: 'Open',
            data: [
                {x: '2024-02-01', y: 0, x2: '2024-02-04'},
                {x: '2024-02-01', y: 1, x2: '2024-02-03'},
                {x: '2024-02-01', y: 2, x2: '2024-02-02'},
                {x: '2024-02-01', y: 3, x2: '2024-02-02'},
                {x: '2024-02-04', y: 3, x2: '2024-02-10'},
                {x: '2024-02-04', y: 4, x2: '2024-02-05'}
            ].map(({x, x2, y}) => ({x: [x, x2], y})),
            backgroundColor: 'rgba(255, 99, 132, 0.2)',
            borderColor: 'rgba(255, 99, 132, 1)',
            borderWidth: 1,
            borderSkipped: false
        },
            {
                label: 'Under Review',
                data: [
                    {x: '2024-02-04', y: 0, x2: '2024-02-07'},
                    {x: '2024-02-03', y: 1, x2: '2024-02-04'},
                    {x: '2024-02-02', y: 2, x2: '2024-02-08'},
                    {x: '2024-02-05', y: 4, x2: '2024-02-06'}
                ].map(({x, x2, y}) => ({x: [x, x2], y})),
                backgroundColor: 'rgba(54, 162, 235, 0.2)',
                borderColor: 'rgba(54, 162, 235, 1)',
                borderWidth: 1,
                borderSkipped: false
            },
            {
                label: 'Closed',
                data: [
                    {x: '2024-02-07', y: 0, x2: '2024-02-10'},
                    {x: '2024-02-04', y: 1, x2: '2024-02-10'},
                    {x: '2024-02-08', y: 2, x2: '2024-02-10'},
                    {x: '2024-02-02', y: 3, x2: '2024-02-07'},
                    {x: '2024-02-01', y: 4, x2: '2024-02-04'},
                    {x: '2024-02-06', y: 4, x2: '2024-02-10'}
                ].map(({x, x2, y}) => ({x: [x, x2], y})),
                backgroundColor: 'rgba(75, 212, 0, 0.2)',
                borderColor: 'rgba(75, 212, 0, 1)',
                borderWidth: 1,
                borderSkipped: false
            }]
    },
    options: {
        indexAxis: 'y',
        scales: {
            x: {
                //stacked: true,
                ticks: {
                    beginAtZero: true
                },
                type: 'time',
                time: {
                    unit: 'day'
                },
                min: '2024-01-31',
                max: '2024-02-10'
            },
            y: {
                //stacked: true
            }
        }
    }
});
<div style="min-height: 60vh">
    <canvas id="myChart">
    </canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>

Of course, you may factor options that are common to all datasets to the options section.


Update

Looking at "record 5" in the chart, I realized that the value of y is ignored, the y category is given by the index in the data array, and if there are more items than the labels, all the supplemental data points are added to the last category.

To overcome that issue, I made a simple plugin that rearranges the bars according to their y value, but still keeping the structure of a bar chart, with all values of a dataset in the same category at the same at vertical position position within the category (if y axis is not stacked):

const config =  {
    type: 'bar',
    data: {
        labels: ['Record1', 'Record2', 'Record3', 'Record4', 'Record5'],
        datasets: [{
            label: 'Open',
            data: [
                {x: '2024-02-01', y: 0, x2: '2024-02-04'},
                {x: '2024-02-01', y: 1, x2: '2024-02-03'},
                {x: '2024-02-01', y: 2, x2: '2024-02-02'},
                {x: '2024-02-01', y: 3, x2: '2024-02-02'},
                {x: '2024-02-04', y: 3, x2: '2024-02-10'},
                {x: '2024-02-04', y: 4, x2: '2024-02-05'}
            ].map(({x, x2, y}) => ({x: [x, x2], y})),
            backgroundColor: 'rgba(255, 99, 132, 0.2)',
            borderColor: 'rgba(255, 99, 132, 1)',
            borderWidth: 1,
            borderSkipped: false
        },
            {
                label: 'Under Review',
                data: [
                    {x: '2024-02-04', y: 0, x2: '2024-02-07'},
                    {x: '2024-02-03', y: 1, x2: '2024-02-04'},
                    {x: '2024-02-02', y: 2, x2: '2024-02-08'},
                    {x: '2024-02-05', y: 4, x2: '2024-02-06'}
                ].map(({x, x2, y}) => ({x: [x, x2], y})),
                backgroundColor: 'rgba(54, 162, 235, 0.2)',
                borderColor: 'rgba(54, 162, 235, 1)',
                borderWidth: 1,
                borderSkipped: false
            },
            {
                label: 'Closed',
                data: [
                    {x: '2024-02-07', y: 0, x2: '2024-02-10'},
                    {x: '2024-02-04', y: 1, x2: '2024-02-10'},
                    {x: '2024-02-08', y: 2, x2: '2024-02-10'},
                    {x: '2024-02-02', y: 3, x2: '2024-02-07'},
                    {x: '2024-02-01', y: 4, x2: '2024-02-04'},
                    {x: '2024-02-06', y: 4, x2: '2024-02-10'}
                ].map(({x, x2, y}) => ({x: [x, x2], y})),
                backgroundColor: 'rgba(75, 212, 0, 0.2)',
                borderColor: 'rgba(75, 212, 128, 1)',
                borderWidth: 1,
                borderSkipped: false
            }]
    },
    options: {
        indexAxis: 'y',
        scales: {
            x: {
                //stacked: true,
                ticks: {
                    beginAtZero: true
                },
                type: 'time',
                time: {
                    unit: 'day'
                },
                min: '2024-01-31',
                max: '2024-02-10'
            },
            y: {
                //stacked: true
            }
        }
    },
    plugins:[{
        beforeDraw(chart){
            const categoryPercentage = chart.options.categoryPercentage;
            const barYPx = (category, dataset, nCategories, nDatasets) => chart.scales.y.getPixelForDecimal(
                1/nCategories * category + 1/nCategories * ((1 - categoryPercentage/2) +
                    categoryPercentage*(dataset+1)/nDatasets-categoryPercentage/nDatasets/2 - 0.5)
            );
            const nDatasets = chart.data.datasets.length,
                nCategories = chart.data.labels.length
            for(let iDataset = 0; iDataset < 3; iDataset++){
                const bars = chart.getDatasetMeta(iDataset)?.data ?? [];
                const data = chart.data.datasets[iDataset].data;
                bars.forEach(
                    (bar, i) => {
                        const actualY = bar.y,
                            yFromDataY = barYPx(data[i].y, iDataset, nCategories, nDatasets);
                        if(Math.abs(actualY - yFromDataY) > 1e-5){
                            bar.y = yFromDataY;
                        }
                    }
                )
            }
        }
    }]
};
new Chart('myChart', config);
<div style="min-height: 60vh">
    <canvas id="myChart">
    </canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>

A more Gantt-like chart can be obtained by a plugin that rearranges the bars inside each category, sorting them according to their start date. This complicates things a lot because (among other things) such a category doesn't have a fixed height - its height is given by the number of bars inside it and the flex algorithm has to be employed, like for instance in this answer. Here's a version of this kind of plugin.

const pluginVariableSortedHorizontalBars = {
    // for https://stackoverflow.com/a/78523992/16466946
    // floating horizontal bars are redistributed to y axis categories, depending on
    // their `y` value; this may result in variable sized categories,
    // see https://stackoverflow.com/a/78273931/16466946 for details
    // the bars in a category are then vertically-sorted according to their first x values
    // to which `Data.parse` is applied, creating a Gantt-like sequence, if data matches
    _computeFlexBarCoordinates(categoryWidths, start = 0){
        this.$idxGridLinesValues = categoryWidths.reduce((a, w)=>[...a, a[a.length-1]+w], [0]);
        this.$idxAxisMax = this.$idxGridLinesValues[this.$idxGridLinesValues.length-1];
        this.$idxGridLinesValues = this.$idxGridLinesValues.map(v=>this.$idxAxisMax-v);
        // computes the coordinates of the labels of the bar to achieve categoryWidths spacing of categories
        this.$idxBarPositions = categoryWidths.reduce(
            (ax, wi, i) => {
                let next = 0;
                if(i === categoryWidths.length - 1){
                    next = ax[i] + categoryWidths[i];
                }
                else{
                    next = ax[i] + 2 * categoryWidths[i + 1];
                    if(next < ax[i]){
                        next = ax[i] - 2 * categoryWidths[i + 1];
                    }
                }
                return [...ax, next];
            },
            [start + categoryWidths[0] / 2, start + 3 * categoryWidths[0] / 2]);

        this.$labelPositions = categoryWidths.reduce(
            (ax, wi, i) => [...ax, ax[i] + (wi + categoryWidths[i + 1]) / 2],
            [start + categoryWidths[0] / 2]
        );
        this.$labelPositions.pop();
        this.$idxBarPositions = this.$idxBarPositions.map(pos => this.$idxAxisMax - pos);
        this.$labelPositions = this.$labelPositions.map(pos => this.$idxAxisMax - pos);
    },

    beforeInit(chart){
        const allData = chart.data;
        const yIndexedData = {};
        for(let datasetIndex = 0; datasetIndex < allData.datasets.length; datasetIndex++){
            const dataset = allData.datasets[datasetIndex];
            const data = dataset.data;
            for(let dataIndex = 0; dataIndex < data.length; dataIndex++){
                const dataPoint = data[dataIndex];
                yIndexedData[dataPoint.y] = (yIndexedData[dataPoint.y] ?? []).concat([{...dataPoint, dataIndex, datasetIndex}]);
            }
        }
        for(const yDataPoints of Object.values(yIndexedData)){
            yDataPoints.sort(({x: [x1]}, {x: [x2]}) => Date.parse(x1) - Date.parse(x2));
        }
        let groupBase = 0;
        for(const [yIndex, yDataPoints] of Object.entries(yIndexedData)){
            const groupEnd = groupBase + yDataPoints.length;
            yDataPoints.forEach(yDataPoint => {
                yDataPoint.yCategory = [groupBase, groupEnd]
                yDataPoint.yCategoryIndex = parseFloat(yIndex);
            });
            groupBase = groupEnd;
        }
        const categoryWidths = Array.from({length: Object.keys(yIndexedData).length}, (_, i)=>yIndexedData[i].length)
        this._computeFlexBarCoordinates(categoryWidths)

        this.$originalLabels = allData.labels;
        allData.labels = this.$idxBarPositions;
        allData.datasets.forEach(
            dataset => {
                dataset.data.push({x: null, y: null});
                dataset.barThickness = "flex";
            }
        );

        chart.options.scales.y.offset = false;
        chart.options.scales.y.type = "linear";
        chart.options.scales.y.min = 0;
        chart.options.scales.y.max = this.$idxAxisMax;

        let overAllIndex = 0;
        for(const yBars of Object.values(yIndexedData)){
            for(const yBar of yBars){
                const barData = allData.datasets[yBar.datasetIndex].data[yBar.dataIndex];
                barData.yCategory = yBar.yCategory.map(y=>this.$idxAxisMax - y);
                barData.yCategoryIndex = yBar.yCategoryIndex;
                barData.y = this.$idxAxisMax - ++overAllIndex;
            }
        }
        const plugin = this;
        chart.options.plugins.tooltip.callbacks.title = function(tooltipItems){
            return [...new Set(tooltipItems.map(item=>plugin.$originalLabels[item.raw.yCategoryIndex]))].join(' - ');
        };
        chart.options.plugins.tooltip.callbacks.label = function(tooltipItems){
            tooltipItems.formattedValue = tooltipItems.raw.x.join(' - ');
        }
    },
    beforeUpdate(chart){
        const plugin = this;
        const yAxis = chart.scales.y;
        yAxis.afterTickToLabelConversion = function(){
            this.ticks = Array.from({length: plugin.$labelPositions.length}, (_, i) => ({
                value: plugin.$labelPositions[i], label: plugin.$originalLabels[i] || ''
            }));
        }
        if(!this.$gridLineComputerModified){
            this.$gridLineComputerModified = true;
            let original_computeGridLineItems = yAxis._computeGridLineItems.bind(yAxis);
            yAxis._computeGridLineItems = function(...args){
                const gridLines = original_computeGridLineItems(...args);
                gridLines.forEach((gridLine, idx) => {
                    gridLine.y1 = gridLine.y2 = gridLine.ty1 = gridLine.ty2 =
                        yAxis.getPixelForValue(plugin.$idxGridLinesValues[idx])
                });
                return gridLines;
            }
        }
    },
    _setBarElementPxProp(bar, prop, value){
        bar[prop] = value;
        if(bar.$animations?.[prop]){
            bar.$animations[prop]._from = value;
            bar.$animations[prop]._to = value;
        }
    },
    _setBarElementYPx(bar, scale, categoryPercentage, barPercentage){
        const {y, yCategory} = bar.$context.raw;
        if(y === null){
            return 0;
        }
        const maxPxCat = scale.getPixelForValue(yCategory[1]),
            minPxCat = scale.getPixelForValue(yCategory[0]),
            pxCat =  maxPxCat - minPxCat,
            nCat = yCategory[0] - yCategory[1],
            hPxRaw = pxCat / nCat * categoryPercentage;
        const h = hPxRaw *barPercentage,
            orderInCat = y - yCategory[1],
            yPx = maxPxCat - pxCat * (1 - categoryPercentage) / 2 - hPxRaw / 2 - orderInCat * hPxRaw;

        this._setBarElementPxProp(bar, 'height', h);
        this._setBarElementPxProp(bar, 'y', yPx);
    },
    afterUpdate(chart){
        const categoryPercentage = chart.options.categoryPercentage,
            barPercentage = chart.options.barPercentage;

        for(let iDataset = 0; iDataset < chart.getVisibleDatasetCount(); iDataset++){
            const bars = chart.getDatasetMeta(iDataset)?.data ?? [];
            bars.forEach(
                bar => {
                    this._setBarElementYPx(bar, chart.scales.y, categoryPercentage, barPercentage);
                }
            )
        }
    }
};

const data = {
    labels: ['Record1', 'Record2', 'Record3', 'Record4', 'Record5'],
    datasets: [{
        label: 'Open',
        data: [
            {x: '2024-02-01', y: 0, x2: '2024-02-04'},
            {x: '2024-02-01', y: 1, x2: '2024-02-03'},
            {x: '2024-02-01', y: 2, x2: '2024-02-02'},
            {x: '2024-02-01', y: 3, x2: '2024-02-02'},
            {x: '2024-02-04', y: 3, x2: '2024-02-10'},
            {x: '2024-02-04', y: 4, x2: '2024-02-05'}
        ].map(({x, x2, y}) => ({x: [x, x2], y})),
        backgroundColor: 'rgba(255, 99, 132, 0.2)',
        borderColor: 'rgba(255, 99, 132, 1)',
        borderWidth: 1,
        borderSkipped: false
    },
    {
        label: 'Under Review',
        data: [
            {x: '2024-02-04', y: 0, x2: '2024-02-07'},
            {x: '2024-02-03', y: 1, x2: '2024-02-04'},
            {x: '2024-02-02', y: 2, x2: '2024-02-08'},
            {x: '2024-02-05', y: 4, x2: '2024-02-06'}
        ].map(({x, x2, y}) => ({x: [x, x2], y})),
        backgroundColor: 'rgba(54, 162, 235, 0.2)',
        borderColor: 'rgba(54, 162, 235, 1)',
        borderWidth: 1,
        borderSkipped: false
    },
    {
        label: 'Closed',
        data: [
            {x: '2024-02-07', y: 0, x2: '2024-02-10'},
            {x: '2024-02-04', y: 1, x2: '2024-02-10'},
            {x: '2024-02-08', y: 2, x2: '2024-02-10'},
            {x: '2024-02-02', y: 3, x2: '2024-02-07'},
            {x: '2024-02-01', y: 4, x2: '2024-02-04'},
            {x: '2024-02-06', y: 4, x2: '2024-02-10'}
        ].map(({x, x2, y}) => ({x: [x, x2], y})),
        backgroundColor: 'rgba(75, 212, 0, 0.2)',
        borderColor: 'rgba(75, 212, 128, 1)',
        borderWidth: 1,
        borderSkipped: false
    }]
};

const config = {
    type: 'bar',
    data,
    options: {
        indexAxis: 'y',
        maintainAspectRatio: false,
        scales: {
            x: {
                //stacked: true,
                grid:{
                    color: 'rgba(0,0,0,0.15)',
                },
                type: 'time',
                time: {
                    unit: 'day'
                },
                min: '2024-01-31',
                max: '2024-02-10'
            },
            y: {
                //stacked: true
                grid:{
                    color: 'rgba(0,0,0,0.2)',
                },
            }
        }
    },
    plugins: [pluginVariableSortedHorizontalBars]
};

const chart = new Chart('myChart', config);
<div style="min-height: 350px">
    <canvas id="myChart">
    </canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>

2
  • Hi @kikon, thanks for the reply. Since asking the question I had tried something similar but still encounter problems with overlapping when a record has status changes that go back to an earlier defined one. i.e Open->Under Review->Open->Closed->Open. I think this is because the parser sees each series object as a record. If there are more objects than labels defined, it stacks them on top of each other. When running the code you posted, you can see this happening with dummy record 5. I might have to look into using a Gantt chart plugin and seeing if I can get the data to stack nicely.
    – bowfinger
    Commented May 24 at 19:45
  • @bowfinger Some of the overlapping can be removed by removing stacked: true for the y axis. That will not remove overlapping for items from the same dataset with the same y value, as is the case with record 5. That can be probably achieved by some other mapping of the data, but you have to make clear how you want the chart to appear, most clearly by making an image of the desired result.
    – kikon
    Commented May 25 at 11:35

Not the answer you're looking for? Browse other questions tagged or ask your own question.