Chartjs-How do I emulate the stacked bar chart from Apple Health's Cardio Fitness article using chart.js and Bootstrap tabs?

0👍

To start with, you’d have to set the axes limits to be the same,
for your data, I set the max of y scale to be 70. You
can compute this value from the data.

Also, disable animation options.animation = false.

The box shown on hover is the tooltip. You disable that by
setting options.plugins.tooltip.enable = false.

I suppose you want to display the value statically. This
is achieved by the chart-plugin-datalabels, see
this example.

With these, you get something like:

function createChart(id, data) {
    new Chart(document.getElementById(id), {
        type: 'bar',
        data: {
            labels: ['20-29', '30-39', '40-49', '50-59', '60+'],
            datasets: data
        },
        plugins: [
            ChartDataLabels,
            {
                afterDraw: chart => {
                    var ctx = chart.ctx;
                    ctx.save();
                    ctx.font = "bold 14px Arial";
                    ctx.fillStyle = "gray";
                    console.log(chart.ctx.height)
                    var y = 50;

                    ctx.textAlign = 'left';
                    ctx.fillText('CO2', 5, y);
                    ctx.fillText('°C', 46, y);

                    ctx.textAlign = 'right';
                    ctx.fillText('%', chart.width - 10, y);
                    ctx.restore();
                }
            }
        ],
        options: {
            animation: false,
            scales: {
                x: {
                    stacked: true,
                    title: {
                        display: true,
                        text: 'Age Ranges',
                        align: 'start'
                    }
                },
                y: {
                    stacked: true,
                    title: {
                        display: true,
                        text: 'VO2 max',
                        align: 'end'
                    },
                    position: 'right',
                    max: 70
                }
            },
            plugins: {
                legend: {
                    display: false
                },
                tooltip:{
                    enabled: false
                },
                datalabels: {
                    color: 'black',
                    display: function(context) {
                        // don't show for small values
                        const interval = context.dataset.data[context.chart.config.data.labels[context.dataIndex]];
                        return interval[1] - interval[0] > 1;
                    },
                    font: {
                        size: '14pt',
                        weight: 'bold'
                    },
                    formatter(_, context){
                        const interval = context.dataset.data[
                            context.chart.config.data.labels[context.dataIndex]];
                        return interval[1] - interval[0]; // or interval[0] + ' - ' + interval[1]
                    }
                }
            }
        }
    });
}

const lowData = [{
    label: 'Low',
    data: {
        "20-29": [29, 38],
        "30-39": [27, 34],
        "40-49": [24, 31],
        "50-59": [21, 26],
        "60+": [17, 18]
    },
}];

const belowAverageData = [{
    label: 'Below Average',
    data: {
        "20-29": [38, 48],
        "30-39": [34, 43],
        "40-49": [31, 38],
        "50-59": [26, 33],
        "60+": [18, 28]
    },
}];

const aboveAverageData = [{
    label: 'Above Average',
    data: {
        "20-29": [48, 57],
        "30-39": [43, 52],
        "40-49": [38, 47],
        "50-59": [33, 41],
        "60+": [28, 36]
    },
}];

const highData = [{
    label: 'High',
    data: {
        "20-29": [57, 66],
        "30-39": [52, 60],
        "40-49": [47, 56],
        "50-59": [41, 51],
        "60+": [36, 42]
    },
}];

createChart('lowChart', lowData);
createChart('belowAverageChart', belowAverageData);
createChart('aboveAverageChart', aboveAverageData);
createChart('highChart', highData);

const triggerTabList = document.querySelectorAll('#chartTabs button')
triggerTabList.forEach(triggerEl => {
    const tabTrigger = new bootstrap.Tab(triggerEl)

    triggerEl.addEventListener('click', event => {
        event.preventDefault()
        tabTrigger.show()
    })
})
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<ul class="nav nav-pills" id="chartTabs" role="tablist">
    <li class="nav-item" role="presentation">
        <button class="nav-link active" id="low-tab" data-bs-toggle="tab" data-bs-target="#low" type="button" role="tab" aria-controls="low" aria-selected="true">Low</button>
    </li>
    <li class="nav-item" role="presentation">
        <button class="nav-link" id="below-average-tab" data-bs-toggle="tab" data-bs-target="#below-average" type="button" role="tab" aria-controls="below-average" aria-selected="false">Below Average</button>
    </li>
    <li class="nav-item" role="presentation">
        <button class="nav-link" id="above-average-tab" data-bs-toggle="tab" data-bs-target="#above-average" type="button" role="tab" aria-controls="above-average" aria-selected="false">Above Average</button>
    </li>
    <li class="nav-item" role="presentation">
        <button class="nav-link" id="high-tab" data-bs-toggle="tab" data-bs-target="#high" type="button" role="tab" aria-controls="high" aria-selected="false">High</button>
    </li>
</ul>

<div class="tab-content" id="chartTabsContent">
    <div class="tab-pane show active" id="low" role="tabpanel" aria-labelledby="low-tab">
        <div>
            <canvas id="lowChart"></canvas>
        </div>
    </div>
    <div class="tab-pane" id="below-average" role="tabpanel" aria-labelledby="below-average-tab">
        <div>
            <canvas id="belowAverageChart"></canvas>
        </div>
    </div>
    <div class="tab-pane" id="above-average" role="tabpanel" aria-labelledby="above-average-tab">
        <div>
            <canvas id="aboveAverageChart"></canvas>
        </div>
    </div>
    <div class="tab-pane" id="high" role="tabpanel" aria-labelledby="high-tab">
        <div>
            <canvas id="highChart"></canvas>
        </div>
    </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-datalabels/2.2.0/chartjs-plugin-datalabels.min.js" integrity="sha512-JPcRR8yFa8mmCsfrw4TNte1ZvF1e3+1SdGMslZvmrzDYxS69J7J49vkFL8u6u8PlPJK+H3voElBtUCzaXj+6ig==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

I find this still shows a flicker when you first activate
a tab, as opposed to the subsequent activations; that’s
because chart.js knows that the canvas is hidden and
only renders the chart when the tab becomes first visible.

You can eliminate the flicker by using an
OffscreenCanvas
or the three hidden charts. You’ll get something like:

function createChart(id, data, id2) {
    // id2 - use a memory canvas the same size as id2
    const canvas = document.getElementById(id);
    let targetCanvas = canvas;
    if(id2){
        const canvas2 = document.getElementById(id2);
        const memCanvas = new OffscreenCanvas(canvas2.offsetWidth, canvas2.offsetHeight);
        canvas.height = canvas2.offsetHeight;
        canvas.width = canvas2.offsetWidth;
        targetCanvas = memCanvas;
    }
    new Chart(targetCanvas, {
        type: 'bar',
        data: {
            labels: ['20-29', '30-39', '40-49', '50-59', '60+'],
            datasets: data
        },
        plugins: [
            ChartDataLabels,
            {
                afterDraw: chart => {
                    var ctx = chart.ctx;
                    ctx.save();
                    ctx.font = "bold 14px Arial";
                    ctx.fillStyle = "gray";
                    var y = 50;
                    
                    ctx.textAlign = 'left';
                    ctx.fillText('CO2', 5, y);
                    ctx.fillText('°C', 46, y);
                    
                    ctx.textAlign = 'right';
                    ctx.fillText('%', chart.width - 10, y);
                    ctx.restore();
                }
            }
        ],
        options: {
            animation: false,
            scales: {
                x: {
                    stacked: true,
                    title: {
                        display: true,
                        text: 'Age Ranges',
                        align: 'start'
                    }
                },
                y: {
                    stacked: true,
                    title: {
                        display: true,
                        text: 'VO2 max',
                        align: 'end'
                    },
                    position: 'right',
                    max: 70
                }
            },
            plugins: {
                legend: {
                    display: false
                },
                tooltip: {
                    enabled: false
                },
                datalabels: {
                    color: 'black',
                    display: function(context){
                        // don't show for small values
                        const interval = context.dataset.data[context.chart.config.data.labels[context.dataIndex]];
                        return interval[1] - interval[0] > 1;
                    },
                    font: {
                        size: '14pt',
                        weight: 'bold'
                    },
                    formatter(_, context){
                        const interval = context.dataset.data[
                            context.chart.config.data.labels[context.dataIndex]];
                        return interval[1] - interval[0]; // or interval[0] + ' - ' + interval[1]
                    }
                }
            }
        }
    });
    if(id2){
        canvas.getContext('2d').drawImage(targetCanvas, 0, 0);
    }
}

const lowData = [{
    label: 'Low',
    data: {
        "20-29": [29, 38],
        "30-39": [27, 34],
        "40-49": [24, 31],
        "50-59": [21, 26],
        "60+": [17, 18]
    },
}];

const belowAverageData = [{
    label: 'Below Average',
    data: {
        "20-29": [38, 48],
        "30-39": [34, 43],
        "40-49": [31, 38],
        "50-59": [26, 33],
        "60+": [18, 28]
    },
}];

const aboveAverageData = [{
    label: 'Above Average',
    data: {
        "20-29": [48, 57],
        "30-39": [43, 52],
        "40-49": [38, 47],
        "50-59": [33, 41],
        "60+": [28, 36]
    },
}];

const highData = [{
    label: 'High',
    data: {
        "20-29": [57, 66],
        "30-39": [52, 60],
        "40-49": [47, 56],
        "50-59": [41, 51],
        "60+": [36, 42]
    },
}];

createChart('lowChart', lowData);
createChart('belowAverageChart', belowAverageData, 'lowChart');
createChart('aboveAverageChart', aboveAverageData, 'lowChart');
createChart('highChart', highData, 'lowChart');

const triggerTabList = document.querySelectorAll('#chartTabs button')
triggerTabList.forEach(triggerEl => {
    const tabTrigger = new bootstrap.Tab(triggerEl)

    triggerEl.addEventListener('click', event => {
        event.preventDefault()
        tabTrigger.show()
    })
})
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<ul class="nav nav-pills" id="chartTabs" role="tablist">
    <li class="nav-item" role="presentation">
        <button class="nav-link active" id="low-tab" data-bs-toggle="tab" data-bs-target="#low" type="button" role="tab" aria-controls="low" aria-selected="true">Low</button>
    </li>
    <li class="nav-item" role="presentation">
        <button class="nav-link" id="below-average-tab" data-bs-toggle="tab" data-bs-target="#below-average" type="button" role="tab" aria-controls="below-average" aria-selected="false">Below Average</button>
    </li>
    <li class="nav-item" role="presentation">
        <button class="nav-link" id="above-average-tab" data-bs-toggle="tab" data-bs-target="#above-average" type="button" role="tab" aria-controls="above-average" aria-selected="false">Above Average</button>
    </li>
    <li class="nav-item" role="presentation">
        <button class="nav-link" id="high-tab" data-bs-toggle="tab" data-bs-target="#high" type="button" role="tab" aria-controls="high" aria-selected="false">High</button>
    </li>
</ul>

<div class="tab-content" id="chartTabsContent">
    <div class="tab-pane show active" id="low" role="tabpanel" aria-labelledby="low-tab">
        <div>
            <canvas id="lowChart"></canvas>
        </div>
    </div>
    <div class="tab-pane" id="below-average" role="tabpanel" aria-labelledby="below-average-tab">
        <div>
            <canvas id="belowAverageChart"></canvas>
        </div>
    </div>
    <div class="tab-pane" id="above-average" role="tabpanel" aria-labelledby="above-average-tab">
        <div>
            <canvas id="aboveAverageChart"></canvas>
        </div>
    </div>
    <div class="tab-pane" id="high" role="tabpanel" aria-labelledby="high-tab">
        <div>
            <canvas id="highChart"></canvas>
        </div>
    </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-datalabels/2.2.0/chartjs-plugin-datalabels.min.js" integrity="sha512-JPcRR8yFa8mmCsfrw4TNte1ZvF1e3+1SdGMslZvmrzDYxS69J7J49vkFL8u6u8PlPJK+H3voElBtUCzaXj+6ig==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

Still, I don’t think this approach is perfectly safe;
I wouldn’t be 100% certain that the contents of the tabs will
overlap perfectly, like it’s the case with mobile apps screen
views. You already have a problem when the window is resized.
That can be addressed with more code.
But I’d suggest instead you rethink it as one chart, with
buttons. Besides being safer, this approach offers more
possibilities, like smooth animation of the transition.

Leave a comment