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:
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.
Each group should show two bars:
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>
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>