javascripthtmlchart.js

How to Create Dual Bars with Hollow Max and Solid Current Values in Chart.js?


I'm trying to create a grouped bar chart using Chart.js, where each group has two bars (e.g., green and red). For each bar:

  1. The solid part represents the current value.
  2. The hollow outline represents the maximum value.

The goal is to overlay the max value as a hollow bar around the current value. Each group should only display two bars (one for each metric, e.g., green and red). However, I keep ending up with four bars per group instead of two.

What I’ve Tried:

  1. Adjusting the dataset structure.
  2. Using custom plugins to overlay hollow bars for max values.
  3. Referenced Chart.js documentation and examples.
  4. Tried implementing solutions with ChatGPT, but they didn’t work as expected.

Desired Output:

Each group should show two bars:

expected output

Issue:

issue

Any guidance, working examples, or corrections would be greatly appreciated!

Here’s my code:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Chart.js Grouped Bar Chart</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <style>
    canvas {
        max-width: 50%;
    }
  </style>
</head>
<body>
  <main>
    <canvas id="barChart"></canvas>
  </main>

  <script>
    const data = {
      labels: ['2020', '2021', '2022', '2023', '2024'],
      datasets: [
        {
          label: 'Current Value (Green)',
          data: [50, 150, 20, 40, 150],
          backgroundColor: 'rgba(0, 255, 0, 0.5)',
          borderColor: 'rgba(0, 255, 0, 1)',
          borderWidth: 0,
          barThickness: 20,
        },
        {
          label: 'Max Value (Green)',
          data: [100, 200, 30, 50, 200],
          backgroundColor: 'rgba(0, 0, 0, 0)', // Transparent fill for hollow effect
          borderColor: 'rgba(0, 255, 0, 1)',
          borderWidth: 2,
          barThickness: 20,
        },
        {
          label: 'Current Value (Red)',
          data: [20, 100, 160, 25, 85],
          backgroundColor: 'rgba(255, 0, 0, 0.5)',
          borderColor: 'rgba(255, 0, 0, 1)',
          borderWidth: 0,
          barThickness: 20,
        },
        {
          label: 'Max Value (Red)',
          data: [50, 150, 180, 30, 100],
          backgroundColor: 'rgba(0, 0, 0, 0)', // Transparent fill for hollow effect
          borderColor: 'rgba(255, 0, 0, 1)',
          borderWidth: 2,
          barThickness: 20,
        },
      ],
    };

    const options = {
      responsive: true,
      maintainAspectRatio: false,
      plugins: {
        legend: {
          display: true,
        },
      },
      scales: {
        x: {
          stacked: false,
          grid: {
            display: false,
          },
        },
        y: {
          beginAtZero: true,
          grid: {
            drawBorder: false,
          },
        },
      },
    };

    const ctx = document.getElementById('barChart').getContext('2d');
    new Chart(ctx, {
      type: 'bar',
      data: data,
      options: options,
    });
  </script>
</body>
</html>

Solution

  • To put hollow green bars on top of full green bars and the same for the red ones, you have to set two stack groups, that is set stack value for the datasets that are one over the other to the same value; for instance set stack: "green", for the first two datasets and stack: "red" for the other two. You may check the example in the documentation.

    Snippet with that change applied to your code:

    const data = {
       labels: ['2020', '2021', '2022', '2023', '2024'],
       datasets: [
          {
             label: 'Current Value (Green)',
             data: [50, 150, 20, 40, 150],
             backgroundColor: 'rgba(0, 255, 0, 0.5)',
             borderColor: 'rgba(0, 255, 0, 1)',
             borderWidth: 2,
             barPercentage: 0.9,
             categoryPercentage: 0.9,
             stack: "green"
          },
          {
             label: 'Max Value (Green)',
             data: [100, 200, 30, 50, 200],
             backgroundColor: 'rgba(0, 0, 0, 0)', // Transparent fill for hollow effect
             borderColor: 'rgba(0, 255, 0, 1)',
             borderWidth: 2,
             barPercentage: 0.9, 
             categoryPercentage: 0.9,
             stack: "green"
          },
          {
             label: 'Current Value (Red)',
             data: [20, 100, 160, 25, 85],
             backgroundColor: 'rgba(255, 0, 0, 0.5)',
             borderColor: 'rgba(255, 0, 0, 1)',
             borderWidth: 2,
             barPercentage: 0.9, 
             categoryPercentage: 0.9,
             stack: "red"
          },
          {
             label: 'Max Value (Red)',
             data: [50, 150, 180, 30, 100],
             backgroundColor: 'rgba(0, 0, 0, 0)', // Transparent fill for hollow effect
             borderColor: 'rgba(255, 0, 0, 1)',
             borderWidth: 2,
             barPercentage: 0.9, 
             categoryPercentage: 0.9,
             stack: "red"
          },
       ],
    };
    
    const options = {
       responsive: true,
       maintainAspectRatio: false,
       plugins: {
          legend: {
             display: true,
          },
       },
       scales: {
          x: {
             stacked: true,
             grid: {
                display: false,
             },
          },
          y: {
             beginAtZero: true,
             grid: {
                drawBorder: false,
             },
          },
       },
    };
    
    new Chart('barChart', {
       type: 'bar',
       data: data,
       options: options,
    });
    <canvas id="barChart"></canvas>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

    I used barPercentage and categoryPercentage instead of barWidth, but you could of course use barWidth instead of the percentage properties, if that makes sense for your application.

    Looking at other variants you have tried, a custom plugin could also be implemented to draw the hollow part of a bar, but this stack-based solution is far simpler and more natural for the structure of your data, i.e., (four) separate datasets, (four) separate legend entries, separate tooltip entries.


    As requested, to be able to order the bars (which is at the base and which is on top) depending on their value (larger value on top), here's a modified solution based on "floating bars" (see example in the docs).

    If in the dataset data we provide two values for each bar, then the first value will set the base of the bar and the second the top (actually the order of the values is not important, one value is one end and the other is the other end; but we will provide the smaller value first). So, we will put the values for the base bar starting at 0 and its top at its nominal value, while the top bar will have its base at the other bar's value and the top at the sum of the two:

    for(const stack of ["green", "red"]){
       const [dts0, dts1] = data.datasets.filter(dataset => dataset.stack === stack);
       for(let i = 0; i < dts0.data.length; i++){
          if(dts0.data[i] < dts1.data[i]){
             [dts0.data[i], dts1.data[i]] = [[0, dts0.data[i]], [dts0.data[i], dts0.data[i] + dts1.data[i]]];
          }
          else{
             [dts1.data[i], dts0.data[i]] = [[0, dts1.data[i]], [dts1.data[i], dts0.data[i] + dts1.data[i]]];
          }
       }
    }
    

    Here's a snippet with that change; it also includes a change in the tooltip label callback for it to display just one value as before, not the two it currently holds.

    const data = {
       labels: ['2020', '2021', '2022', '2023', '2024'],
       datasets: [
          {
             label: 'Current Value (Green)',
             data: [50, 150, 20, 40, 150],
             backgroundColor: 'rgba(0, 255, 0, 0.5)',
             borderColor: 'rgba(0, 255, 0, 1)',
             borderWidth: 2,
             barPercentage: 0.9,
             categoryPercentage: 0.9,
             stack: "green"
          },
          {
             label: 'Max Value (Green)',
             data: [100, 90, 30, 50, 200],
             backgroundColor: 'rgba(0, 0, 0, 0)', // Transparent fill for hollow effect
             borderColor: 'rgba(0, 255, 0, 1)',
             borderWidth: 2,
             barPercentage: 0.9,
             categoryPercentage: 0.9,
             stack: "green"
          },
          {
             label: 'Current Value (Red)',
             data: [20, 100, 160, 25, 85],
             backgroundColor: 'rgba(255, 0, 0, 0.5)',
             borderColor: 'rgba(255, 0, 0, 1)',
             borderWidth: 2,
             barPercentage: 0.9,
             categoryPercentage: 0.9,
             stack: "red"
          },
          {
             label: 'Max Value (Red)',
             data: [50, 150, 80, 30, 100],
             backgroundColor: 'rgba(0, 0, 0, 0)', // Transparent fill for hollow effect
             borderColor: 'rgba(255, 0, 0, 1)',
             borderWidth: 2,
             barPercentage: 0.9,
             categoryPercentage: 0.9,
             stack: "red"
          },
       ],
    };
    
    for(const stack of ["green", "red"]){
       const [dts0, dts1] = data.datasets.filter(dataset => dataset.stack === stack);
       for(let i = 0; i < dts0.data.length; i++){
          if(dts0.data[i] < dts1.data[i]){
             [dts0.data[i], dts1.data[i]] = [[0, dts0.data[i]], [dts0.data[i], dts0.data[i] + dts1.data[i]]];
          }
          else{
             [dts1.data[i], dts0.data[i]] = [[0, dts1.data[i]], [dts1.data[i], dts0.data[i] + dts1.data[i]]];
          }
       }
    }
    
    
    const options = {
       responsive: true,
       maintainAspectRatio: false,
       plugins: {
          legend: {
             display: true,
          },
          tooltip: {
             callbacks: {
                label: function({dataset: {label}, raw: [v1, v2]}){
                   return label + ': ' + (v2 - v1).toFixed(0) // if values are integer
                }
             }
          }
       },
       scales: {
          x: {
             stacked: true,
             grid: {
                display: false,
             },
          },
          y: {
             stacked: false,
             beginAtZero: true,
             grid: {
                drawBorder: false,
             },
          },
       },
    };
    
    new Chart('barChart', {
       type: 'bar',
       data: data,
       options: options,
    });
    <main>
       <canvas id="barChart"></canvas>
    </main>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>