skip to Main Content

I have a bar chart with multiple datasets which include empty values. I use skipNull to hide the empty values. Currently each group (month) has the same width, and the width of the bars is inversely proportional to the number of bars in a group. I’d like all bars in the chart to have the same width, and the groups to have variable widths.

This is my current code:

const data = {
  labels: ['January', 'February', 'March'],
  datasets: [
    {
      label: 'Dataset 1',
      data: [3, null, null],
      skipNull: true,
    },
    {
      label: 'Dataset 2',
      data: [1, 2, null],
      skipNull: true,
    },
    {
      label: 'Dataset 3',
      data: [4, 1, 3],
      skipNull: true,
    },
  ],
};

new Chart(
  document.querySelector('#chart'),
  {
    type: 'bar',
    data: data,
  }
)
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
<canvas width="500" height="100" id="chart"></canvas>

This is more or less how I’d like the chart to look (excuse my poor Photoshop skills):

expected result

I tried barPercentage and barThickness, but they don’t affect the group width.

2

Answers


  1. Use maxBarThickness property:

    const data = {
      labels: ['January', 'February', 'March'],
      datasets: [
        {
          label: 'Dataset 1',
          data: [3, null, null],
          skipNull: true,
          maxBarThickness: 50,
        },
        {
          label: 'Dataset 2',
          data: [1, 2, null],
          skipNull: true,
          maxBarThickness: 50,
        },
        {
          label: 'Dataset 3',
          data: [4, 1, 3],
          skipNull: true,
          maxBarThickness: 50,
        },
      ],
    };
    
    new Chart(
      document.querySelector('#chart'),
      {
        type: 'bar',
        data: data,
      }
    )
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
    <canvas width="500" height="100" id="chart"></canvas>
    Login or Signup to reply.
  2. Bar charts with variable category or bar widths can be obtained in chart.js using
    barThicknes: "flex",
    however, it is not simple to set the widths according to predefined numeric data.
    I gave a simple example of such an application in a comment,
    but that post was abandoned and I haven’t had the chance to refine the code.

    This question poses a supplemental challenge – obviously, the category width has to be proportional to
    the number of non-null data points in that category; however, the width of each bar is modified
    by categoryPercent option; this will create a different spacing of a three-bar category from a one-bar category
    and results in visually defective charts.
    The only solution I found was to recenter the bars and the axis labels with a plugin, in the afterUpdate
    callback.

    This is a rather lengthy piece of code, and explaining it in detail makes little sense. The central part
    is changing the x axis to a linear type and positioning the categories to exact values computed by
    "reverse engineering" the function computeFlexCategoryTraits
    of chart.js source. This also involves adding an invisible category by pushing a null to each of the
    datasets data; that is because the number of parameters required to position n categories
    with computeFlexCategoryTraits is n+1.

    Here’s a snippet using this contraption for the original example data:

    const data = {
        labels: ['January', 'February', 'March'],
        datasets: [
            {
                label: 'Dataset 1',
                data: [3, null,  null],
                skipNull: true,
                barThickness: 'flex',
            },
            {
                label: 'Dataset 2',
                //data: [1, 2, null],
                data: [1, null,  2],
                skipNull: true,
                barThickness: 'flex',
            },
            {
                label: 'Dataset 3',
                data: [4, 6, 5],
                skipNull: true,
                barThickness: 'flex',
            },
        ],
    };
    
    function flexBarCoordinates(categoryWidths, start = 0){
        // computes the coordinates of the labels of the bar 
        // to achieve categoryWidths spacing of categories
        const x = 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]);
    
        const xBarCenters = categoryWidths.reduce(
            (ax, wi, i)=>[...ax, ax[i]+(wi+categoryWidths[i+1])/2],
            [start + categoryWidths[0]/2]
        );
        xBarCenters.pop();
        const end = start + categoryWidths.reduce((s, w)=>s+w);
        return {xBarPositions: x, xLabelPositions: xBarCenters, xAxisMax: end};
    }
    
    const barWidths = data.datasets.reduce(
        // count non-null items for each category
        (bw, {data}) => Array.from(
            {length: Math.max(bw.length, data.length)},
            (_, j) => (bw[j]??0) + (data[j] === null ? 0 : 1)
        ),
        []
    );
    // barWidths is [3, 2, 1] for this data
    
    const {xBarPositions, xLabelPositions, xAxisMax} = flexBarCoordinates(barWidths);
    
    data.labels1 = data.labels;
    data.labels = xBarPositions;
    data.datasets.forEach(
        dataset => dataset.data.push(null) // add an invisible category
    )
    
    new Chart(
        document.querySelector('#chart'),
        {
            type: 'bar',
            data: data,
            options:{
                //barPercentage: 1,
                //categoryPercentage: 1,
                scales:{
                    x:{
                        type: 'linear',
                        min: 0,
                        max: xAxisMax,
                        afterBuildTicks(scale){
                            scale.ticks = Array.from({length: xLabelPositions.length}, (_, i)=>({
                                value: xLabelPositions[i], label: ''+xLabelPositions[i], label1: scale.chart.data.labels1[i]
                            }));
                        },
                        afterTickToLabelConversion(scale){
                            scale.ticks = scale.ticks.map(({value, label1}) => ({value, label: label1}))
                        },
                        ticks: {
                            //includeBounds: false,
                            //stepSize: 0.1,
                            //minRotation: 90
                        },
                        grid:{
                            display: false
                            //color: 'red'
                        },
                        offset: false,
                    }
                },
    
                plugins: {
                    tooltip: {
                        callbacks: {
                            title([ctx]){
                                return ctx.chart.data.labels1[ctx.dataIndex];
                            }
                        }
                    }
                }
            },
            plugins:[
                {
                    // re-center the bars and axis labels
                    afterUpdate(chart, {mode}){
                        const xAxis = chart.scales.x;
                        if(!chart.$catMoveX){
                            // get the space to the left and right of each category, average those spaces; 
                            // compute the displacement for each category in real units, such that the spaces get even 
                            const catLeft = [], catRight = [];
                            for(let iDataset = 0; iDataset < data.datasets.length; iDataset++){
                                const bars = chart.getDatasetMeta(iDataset).data.map(bar => bar.$context.raw == null ? null : bar);
                                bars.pop();
                                const barPercentages = bars.map(bar => bar && (chart.options.barPercentage ??
                                    bar.$context.dataset.barPercentage));
                                const left = bars.map((bar, i) => bar && xAxis.getValueForPixel(bar.x - bar.width / barPercentages[i] / 2)),
                                    right = bars.map((bar, i) => bar && xAxis.getValueForPixel(bar.x + bar.width / barPercentages[i] / 2));
                                left.forEach((l, i) => {if(l!==null){catLeft[i] = Math.min(l, catLeft[i] ?? l)}});
                                right.forEach((r, i) => {if(r!==null){catRight[i] = Math.max(r, catRight[i] ?? r)}});
                            }
                            const catSpaces = catLeft.flatMap((min, i) => i === 0 ? [min] :
                                [(min - catRight[i - 1]) / 2, (min - catRight[i - 1]) / 2])
                                .concat([xAxis.max - catRight[catRight.length - 1]]);
                            const avg = catSpaces.reduce((s, x) => s + x) / catSpaces.length;
                            const deltaUniformSpaces = catSpaces.map(sp => avg - sp);
                            chart.$catMoveX = catLeft.map((_, i) => deltaUniformSpaces.slice(0, 2 * i + 1).reduce((s, x) => s + x));
                        }
    
                        if(!chart.$catMoved || mode === 'resize'){
                            chart.$catMoved = true;
                            const catLeft = [], catRight = [];
                            for(let iDataset = 0; iDataset < data.datasets.length; iDataset++){
                                const bars = chart.getDatasetMeta(iDataset).data;
                                bars.pop();
                                bars.forEach((bar, category) => {
                                    const newX = xAxis.getValueForPixel(bar.x) + chart.$catMoveX[category];
                                    catLeft[category] = Math.min(catLeft[category] ?? newX, newX);
                                    catRight[category] = Math.max(catRight[category] ?? newX, newX);
                                    bar.x = xAxis.getPixelForValue(newX);
                                });
                            }
                            const newTickValues = catLeft.map((left, i)=>(left + catRight[i])/2);
                            xAxis.ticks.forEach((tick, i) => {tick.value = newTickValues[i]});
                        }
                    }
                }
            ]
        }
    )
    <div style="height:300px">
        <canvas id="chart"></canvas>
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search