1

In Apple Health there's an article called "Learn About Cardio Fitness" (found in the Browse tab (at the bottom) > Activity, then scroll down). And I stumble across the chart and I really want to emulate it in HTML.

The chart I'm talking about...

So here are my questions:

  • From the stacked bar chart in official docs, I found a "Randomize" button that can seamlessly switch between data. How can I implement Bootstrap tabs that allow switching between data like the Apple Health article above?
  • How do I add the number label in the bar? And turn off the hover feature?
  • Bonus: How do I rotate the "VO2 max" label to horizontal? I tried the code from here (https://stackoverflow.com/a/75432153/22078763), but unfortunately it doesn't work (probably because I'm using chart.js 4.3.0).

Here is my code and data so far (I forgot to add the Male/Female/All tab *facepalm*):

function createChart(id, data) {
  const ctx = document.getElementById(id).getContext('2d');
  new Chart(ctx, {
    type: 'bar',
    data: {
      labels: ['20-29', '30-39', '40-49', '50-59', '60+'],
      datasets: data
    },
    options: {
      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'
        }
      },
      plugins: {
        legend: {
          display: false
        }
      }
    }
  });
}

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>

1 Answers1

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.

kikon
  • 3,670
  • 3
  • 5
  • 20