You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用Chart.js与Bootstrap标签页复刻Apple Health心肺适能堆叠柱状图

复刻Apple Health中的堆叠柱状图需求及技术问题

我想在HTML中复刻Apple Health里《Learn About Cardio Fitness》文章里的堆叠柱状图(该文章可通过底部Browse标签页>Activity,向下滚动找到)。

我的具体技术问题如下:

  1. 参考Chart.js堆叠柱状图示例里的"Randomize"按钮无缝切换数据的效果,如何实现类似Apple Health文章中的Bootstrap标签页来切换图表数据?
  2. 如何在柱状图的柱子内添加数值标签,并关闭图表的悬停交互功能?
  3. 如何将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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.18 10:37:06