如何用Chart.js与Bootstrap标签页复刻Apple Health心肺适能堆叠柱状图
复刻Apple Health中的堆叠柱状图需求及技术问题
我想在HTML中复刻Apple Health里《Learn About Cardio Fitness》文章里的堆叠柱状图(该文章可通过底部Browse标签页>Activity,向下滚动找到)。
我的具体技术问题如下:
- 参考Chart.js堆叠柱状图示例里的"Randomize"按钮无缝切换数据的效果,如何实现类似Apple Health文章中的Bootstrap标签页来切换图表数据?
- 如何在柱状图的柱子内添加数值标签,并关闭图表的悬停交互功能?
- 如何将Y轴的“VO2 max”标签旋转为水平显示?我试过旧版本的解决方案,但在Chart.js 4.3.0里不生效,求可行方案。
以下是我目前的代码(还没添加Male/Female/All标签页):
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. Bootstrap标签页无缝切换单图表数据
放弃为每个标签页创建独立图表的方案,维护单个图表实例,点击标签时直接更新数据集,实现无缝切换效果,同时补充Male/Female/All分组切换逻辑。
2. 添加柱子内数值标签并关闭悬停交互
- 关闭悬停:设置
options.interaction.mode为'none',同时禁用tooltip - 添加柱子标签:使用
chartjs-plugin-datalabels插件,配置标签显示在柱子中心,适配堆叠柱状图的数值范围展示。
3. Y轴标题水平显示(适配Chart.js 4.3.0)
在Y轴标题配置中设置rotation: 0,通过调整padding让标题水平显示在轴的顶部,避免与轴线重叠。
修改后的完整代码
HTML部分
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.3.0/dist/chart.umd.js"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/dist/chartjs-plugin-datalabels.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <!-- 性别分组标签 --> <ul class="nav nav-pills mb-3" id="genderTabs" role="tablist"> <li class="nav-item" role="presentation"> <button class="nav-link active" id="all-tab" data-bs-toggle="tab" data-bs-target="#all" type="button" role="tab" aria-controls="all" aria-selected="true">All</button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="male-tab" data-bs-toggle="tab" data-bs-target="#male" type="button" role="tab" aria-controls="male" aria-selected="false">Male</button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="female-tab" data-bs-toggle="tab" data-bs-target="#female" type="button" role="tab" aria-controls="female" aria-selected="false">Female</button> </li> </ul> <!-- 水平分类标签 --> <ul class="nav nav-pills mb-3" id="levelTabs" 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="chartContainer"> <canvas id="cardioChart"></canvas> </div>
JavaScript部分
// 注册数据标签插件 Chart.register(ChartDataLabels); // 整理完整数据结构:按性别分组,每个分组下对应不同水平的数据集(堆叠差值) const chartData = { all: { low: [29, 27, 24, 21, 17], belowAverage: [9,7,7,5,1], aboveAverage: [10,9,7,7,10], high: [18,17,18,18,14] }, male: { low: [38,34,31,26,18], belowAverage: [10,9,7,7,10], aboveAverage: [9,9,9,8,8], high: [9,8,9,10,6] }, female: { low: [29,27,24,21,17], belowAverage: [9,7,7,5,1], aboveAverage: [10,9,7,7,10], high: [9,9,9,8,8] } }; // 初始化图表实例 const ctx = document.getElementById('cardioChart').getContext('2d'); let currentChart = new Chart(ctx, { type: 'bar', data: { labels: ['20-29', '30-39', '40-49', '50-59', '60+'], datasets: getDatasets('all', 'low') }, options: { scales: { x: { stacked: true, title: { display: true, text: 'Age Ranges', align: 'start' } }, y: { stacked: true, title: { display: true, text: 'VO2 max', align: 'center', rotation: 0, // 水平显示标题 padding: { top: -30, bottom: 0 } // 调整位置避免重叠 }, position: 'right' } }, plugins: { legend: { display: false }, tooltip: { enabled: false // 关闭悬停提示 }, datalabels: { align: 'center', anchor: 'center', color: '#fff', font: { weight: 'bold' }, formatter: function(value, context) { // 计算并显示实际数值范围 const level = context.dataset.label.toLowerCase().replace(' ', ''); const gender = document.querySelector('#genderTabs .nav-link.active').id.split('-')[0]; const index = context.dataIndex; let start = 0; if (level === 'belowaverage') start = chartData[gender].low[index]; if (level === 'aboveaverage') start = chartData[gender].low[index] + chartData[gender].belowAverage[index]; if (level === 'high') start = chartData[gender].low[index] + chartData[gender].belowAverage[index] + chartData[gender].aboveAverage[index]; return `${start}-${start + value}`; } } }, interaction: { mode: 'none' // 关闭所有悬停交互 } } }); // 根据选中的性别和水平获取数据集 function getDatasets(gender, level) { const dataKey = level.toLowerCase().replace(' ', ''); return [{ label: level, data: chartData[gender][dataKey], backgroundColor: getColor(level), borderColor: getColor(level), borderWidth: 1 }]; } // 对应水平的配色(模拟Apple Health风格) function getColor(level) { switch(level) { case 'Low': return '#ff3b30'; case 'Below Average': return '#ff9500'; case 'Above Average': return '#ffcc00'; case 'High': return '#34c759'; default: return '#000'; } } // 监听标签切换事件,更新图表 document.querySelectorAll('#genderTabs button, #levelTabs button').forEach(btn => { btn.addEventListener('shown.bs.tab', () => { const activeGender = document.querySelector('#genderTabs .nav-link.active').id.split('-')[0]; const activeLevel = document.querySelector('#levelTabs .nav-link.active').text; currentChart.data.datasets = getDatasets(activeGender, activeLevel); currentChart.update(); }); });
内容的提问来源于stack exchange,提问作者SomebodyOnceToldMe
相关产品推荐
相关产品推荐

