pin_drop当前位置:知识文库 ❯ 图文
数据可视化:柱状图实现 - 从入门到实践详解
教程简介
柱状图是最常用、最直观的数据可视化图表类型之一,通过矩形柱子的高度(或宽度)来表示数据的大小,非常适合比较不同类别之间的数值差异。本教程将全面讲解柱状图的各类变体实现,包括基础柱状图、分组柱状图、堆叠柱状图、水平柱状图,以及动画效果、交互功能和响应式适配,帮助你掌握柱状图的完整实现能力。
核心概念
柱状图的类型
基础柱状图:每个类别对应一根柱子,柱高表示数值大小
分组柱状图:每个类别包含多根柱子,用于比较不同组在同一类别下的表现
堆叠柱状图:每根柱子由多个子段堆叠而成,展示总量和各部分占比
水平柱状图(条形图):柱子水平排列,适合类别名称较长或类别较多的场景
百分比堆叠柱状图:堆叠柱状图的变体,每根柱子高度相同,展示各部分占比
柱状图的设计要素
柱宽与间距:柱宽通常占类别间距的60%-80%,过窄难以比较,过宽显得拥挤
坐标轴:X轴显示类别,Y轴显示数值,需要合理的刻度
网格线:水平网格线辅助数值读取,应尽量淡化
数据标签:直接在柱子上方标注数值,减少读取误差
图例:分组和堆叠柱状图需要图例区分子系列
颜色:不同系列使用不同颜色,同系列保持一致
柱状图的适用场景
比较不同类别的数值大小
展示排名或排序
展示数据随时间的变化(时间作为类别)
展示分组数据的对比
语法与用法
柱状图绘制核心逻辑
代码示例
// 基础柱状图绘制步骤
function drawBarChart(ctx, data, config) {
const { margin, plotWidth, plotHeight } = config;
const maxVal = Math.max(...data.map(d => d.value));
const barWidth = plotWidth / data.length * 0.7;
const gap = plotWidth / data.length;
ctx.save();
ctx.translate(margin.left, margin.top);
// 1. 绘制网格线和Y轴刻度
drawGridAndYAxis(ctx, maxVal, plotWidth, plotHeight);
// 2. 绘制柱子
data.forEach((d, i) => {
const barHeight = (d.value / maxVal) * plotHeight;
const x = i * gap + (gap - barWidth) / 2;
const y = plotHeight - barHeight;
ctx.fillStyle = d.color || config.defaultColor;
ctx.fillRect(x, y, barWidth, barHeight);
// 3. 绘制X轴标签
ctx.fillStyle = '#888';
ctx.textAlign = 'center';
ctx.fillText(d.label, x + barWidth / 2, plotHeight + 15);
});
ctx.restore();
}代码示例
示例1:分组柱状图与堆叠柱状图
以下示例实现了分组柱状图和堆叠柱状图两种常见变体,支持动画和交互。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分组柱状图与堆叠柱状图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
padding: 20px;
}
h1 { text-align: center; color: #2c3e50; margin-bottom: 20px; }
.tabs {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
}
.tab-btn {
padding: 10px 24px;
border: 2px solid #4e79a7;
border-radius: 8px;
background: #fff;
color: #4e79a7;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn.active {
background: #4e79a7;
color: #fff;
}
.chart-container {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
max-width: 900px;
margin: 0 auto;
}
canvas { width: 100%; cursor: pointer; }
.legend {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 15px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
}
.legend-color {
width: 14px;
height: 14px;
border-radius: 3px;
}
</style>
</head>
<body>
<h1>柱状图变体演示</h1>
<div class="tabs">
<button class="tab-btn active" onclick="switchChart('grouped')">分组柱状图</button>
<button class="tab-btn" onclick="switchChart('stacked')">堆叠柱状图</button>
<button class="tab-btn" onclick="switchChart('percent')">百分比堆叠</button>
</div>
<div class="chart-container">
<canvas id="barChart"></canvas>
<div class="legend" id="legend"></div>
</div>
<script>
const salesData = {
categories: ['Q1', 'Q2', 'Q3', 'Q4'],
series: [
{ name: '华东区', color: '#4e79a7', data: [320, 450, 380, 520] },
{ name: '华南区', color: '#f28e2b', data: [280, 350, 420, 480] },
{ name: '华北区', color: '#e15759', data: [200, 280, 350, 400] },
{ name: '西部区', color: '#59a14f', data: [150, 220, 280, 350] }
]
};
let currentType = 'grouped';
let animProgress = 0;
let hoveredSegment = null;
const canvas = document.getElementById('barChart');
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.parentElement.clientWidth - 60;
const displayH = Math.min(450, displayW * 0.55);
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
canvas.style.width = displayW + 'px';
canvas.style.height = displayH + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const margin = { top: 30, right: 30, bottom: 50, left: 60 };
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
// 存储柱子区域用于交互
let barSegments = [];
function renderLegend() {
const legend = document.getElementById('legend');
legend.innerHTML = salesData.series.map(s =>
`<div class="legend-item"><div class="legend-color" style="background:${s.color}"></div>${s.name}</div>`
).join('');
}
function getMaxValue() {
if (currentType === 'grouped') {
return Math.max(...salesData.series.flatMap(s => s.data));
} else if (currentType === 'stacked') {
return Math.max(...salesData.categories.map((_, ci) =>
salesData.series.reduce((sum, s) => sum + s.data[ci], 0)
));
} else {
return 100; // 百分比
}
}
function niceTicks(maxVal, count) {
const step = Math.ceil(maxVal / count / 10) * 10;
const ticks = [];
for (let v = 0; v <= maxVal + step * 0.5; v += step) {
ticks.push(Math.round(v));
}
return { max: ticks[ticks.length - 1], ticks };
}
function drawChart() {
ctx.clearRect(0, 0, displayW, displayH);
ctx.save();
ctx.translate(margin.left, margin.top);
const rawMax = getMaxValue();
const { max: maxVal, ticks } = niceTicks(rawMax, 6);
const catCount = salesData.categories.length;
const seriesCount = salesData.series.length;
// 网格线
ticks.forEach(v => {
const y = ph - (v / maxVal) * ph;
ctx.strokeStyle = '#f0f0f0';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
ctx.fillStyle = '#999';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
const label = currentType === 'percent' ? v + '%' : v;
ctx.fillText(label, -8, y);
});
barSegments = [];
const catGap = pw / catCount;
salesData.categories.forEach((cat, ci) => {
const catX = ci * catGap;
if (currentType === 'grouped') {
// 分组柱状图
const groupWidth = catGap * 0.75;
const barWidth = groupWidth / seriesCount;
const startX = catX + (catGap - groupWidth) / 2;
salesData.series.forEach((s, si) => {
const val = s.data[ci];
const barH = (val / maxVal) * ph * animProgress;
const x = startX + si * barWidth;
const y = ph - barH;
const isHovered = hoveredSegment &&
hoveredSegment.ci === ci && hoveredSegment.si === si;
ctx.fillStyle = isHovered ? s.color + 'dd' : s.color;
ctx.beginPath();
if (barH > 4) {
ctx.moveTo(x + 2, y);
ctx.lineTo(x + barWidth - 2, y);
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + 2);
ctx.lineTo(x + barWidth, ph);
ctx.lineTo(x, ph);
ctx.lineTo(x, y + 2);
ctx.quadraticCurveTo(x, y, x + 2, y);
} else {
ctx.rect(x, y, barWidth, barH);
}
ctx.fill();
barSegments.push({ x, y, w: barWidth, h: barH, ci, si, val, name: s.name, color: s.color });
});
} else {
// 堆叠柱状图
const barWidth = catGap * 0.5;
const startX = catX + (catGap - barWidth) / 2;
let cumY = ph;
// 计算总量
const total = salesData.series.reduce((sum, s) => sum + s.data[ci], 0);
salesData.series.forEach((s, si) => {
const val = currentType === 'percent'
? (s.data[ci] / total) * 100
: s.data[ci];
const barH = (val / maxVal) * ph * animProgress;
const y = cumY - barH;
const isHovered = hoveredSegment &&
hoveredSegment.ci === ci && hoveredSegment.si === si;
ctx.fillStyle = isHovered ? s.color + 'dd' : s.color;
ctx.fillRect(startX, y, barWidth, barH);
// 堆叠段边框
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(startX, y, barWidth, barH);
barSegments.push({ x: startX, y, w: barWidth, h: barH, ci, si, val, name: s.name, color: s.color, total });
cumY = y;
});
}
// X轴标签
ctx.fillStyle = '#888';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(cat, catX + catGap / 2, ph + 10);
});
// Tooltip
if (hoveredSegment) {
const seg = hoveredSegment;
const series = salesData.series[seg.si];
const text = currentType === 'percent'
? `${series.name} ${salesData.categories[seg.ci]}: ${seg.val.toFixed(1)}%`
: `${series.name} ${salesData.categories[seg.ci]}: ${seg.val}`;
ctx.font = 'bold 12px sans-serif';
const tw = ctx.measureText(text).width + 16;
const tx = seg.x + seg.w / 2;
const ty = seg.y - 10;
ctx.fillStyle = 'rgba(44,62,80,0.9)';
ctx.beginPath();
ctx.roundRect(tx - tw / 2, ty - 24, tw, 24, 4);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, tx, ty - 12);
}
// 坐标轴
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();
ctx.restore();
}
function animate() {
const start = performance.now();
const duration = 800;
function step(now) {
animProgress = Math.min(1, (now - start) / duration);
animProgress = 1 - Math.pow(1 - animProgress, 3);
drawChart();
if (animProgress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function switchChart(type) {
currentType = type;
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
hoveredSegment = null;
animate();
}
// 交互
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (displayW / rect.width) - margin.left;
const my = (e.clientY - rect.top) * (displayH / rect.height) - margin.top;
let found = null;
for (const seg of barSegments) {
if (mx >= seg.x && mx <= seg.x + seg.w && my >= seg.y && my <= seg.y + seg.h) {
found = seg; break;
}
}
if (found !== hoveredSegment) {
hoveredSegment = found;
drawChart();
}
});
canvas.addEventListener('mouseleave', () => {
hoveredSegment = null;
drawChart();
});
renderLegend();
animate();
</script>
</body>
</html>示例2:水平柱状图(条形图)
以下示例实现水平柱状图,支持排名动画和悬停交互。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>水平柱状图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
display: flex;
justify-content: center;
padding: 30px 20px;
}
.chart-container {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 800px;
}
.chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 20px; }
canvas { width: 100%; cursor: pointer; }
.sort-btn {
display: block;
margin: 15px auto 0;
padding: 8px 20px;
border: 2px solid #4e79a7;
border-radius: 8px;
background: #fff;
color: #4e79a7;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.sort-btn:hover { background: #4e79a7; color: #fff; }
</style>
</head>
<body>
<div class="chart-container">
<h2 class="chart-title">编程语言流行度排名</h2>
<canvas id="hBarChart"></canvas>
<button class="sort-btn" onclick="toggleSort()">切换排序</button>
</div>
<script>
let langData = [
{ name: 'Python', value: 28.11, color: '#4e79a7' },
{ name: 'JavaScript', value: 21.52, color: '#f28e2b' },
{ name: 'Java', value: 15.68, color: '#e15759' },
{ name: 'C/C++', value: 12.34, color: '#76b7b2' },
{ name: 'C#', value: 7.85, color: '#59a14f' },
{ name: 'TypeScript', value: 5.92, color: '#edc948' },
{ name: 'Go', value: 4.21, color: '#b07aa1' },
{ name: 'Rust', value: 2.87, color: '#ff9da7' },
{ name: 'Swift', value: 2.15, color: '#9c755f' },
{ name: 'Kotlin', value: 1.89, color: '#bab0ac' }
];
let sorted = true;
let animProgress = 0;
let targetPositions = [];
let currentPositions = [];
let hoveredIndex = -1;
const canvas = document.getElementById('hBarChart');
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.parentElement.clientWidth - 60;
const displayH = 500;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
canvas.style.width = displayW + 'px';
canvas.style.height = displayH + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const margin = { top: 20, right: 80, bottom: 30, left: 100 };
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
function calculatePositions() {
const barH = ph / langData.length * 0.7;
const gap = ph / langData.length;
const maxVal = Math.max(...langData.map(d => d.value));
targetPositions = langData.map((d, i) => ({
y: margin.top + i * gap + (gap - barH) / 2,
barW: (d.value / maxVal) * pw,
barH,
data: d,
index: i
}));
}
function drawChart() {
ctx.clearRect(0, 0, displayW, displayH);
const maxVal = Math.max(...langData.map(d => d.value));
// 网格线
const gridSteps = 5;
for (let i = 0; i <= gridSteps; i++) {
const x = margin.left + (pw * i / gridSteps);
ctx.strokeStyle = '#f0f0f0';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, margin.top); ctx.lineTo(x, margin.top + ph); ctx.stroke();
ctx.fillStyle = '#999';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText((maxVal * i / gridSteps).toFixed(1) + '%', x, margin.top + ph + 18);
}
// 柱子
currentPositions.forEach((pos, i) => {
const isHovered = i === hoveredIndex;
const barW = pos.barW * animProgress;
// 柱子渐变
const grad = ctx.createLinearGradient(margin.left, 0, margin.left + barW, 0);
grad.addColorStop(0, pos.data.color);
grad.addColorStop(1, pos.data.color + (isHovered ? 'ff' : 'cc'));
ctx.fillStyle = grad;
ctx.beginPath();
const r = Math.min(4, barW / 2);
if (barW > r * 2) {
ctx.moveTo(margin.left, pos.y);
ctx.lineTo(margin.left + barW - r, pos.y);
ctx.quadraticCurveTo(margin.left + barW, pos.y, margin.left + barW, pos.y + r);
ctx.lineTo(margin.left + barW, pos.y + pos.barH - r);
ctx.quadraticCurveTo(margin.left + barW, pos.y + pos.barH, margin.left + barW - r, pos.y + pos.barH);
ctx.lineTo(margin.left, pos.y + pos.barH);
} else {
ctx.rect(margin.left, pos.y, barW, pos.barH);
}
ctx.fill();
// 高亮效果
if (isHovered) {
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
}
// 名称标签
ctx.fillStyle = isHovered ? '#333' : '#666';
ctx.font = (isHovered ? 'bold ' : '') + '13px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(pos.data.name, margin.left - 10, pos.y + pos.barH / 2);
// 数值标签
ctx.fillStyle = isHovered ? pos.data.color : '#999';
ctx.font = (isHovered ? 'bold ' : '') + '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(pos.data.value.toFixed(2) + '%', margin.left + barW + 8, pos.y + pos.barH / 2);
});
// 坐标轴
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, margin.top + ph);
ctx.lineTo(margin.left + pw, margin.top + ph);
ctx.stroke();
}
function animateToTarget() {
const startPositions = currentPositions.map(p => ({ ...p }));
const startTime = performance.now();
const duration = 600;
function step(now) {
const t = Math.min(1, (now - startTime) / duration);
const ease = 1 - Math.pow(1 - t, 3);
currentPositions = startPositions.map((sp, i) => ({
...targetPositions[i],
y: sp.y + (targetPositions[i].y - sp.y) * ease,
barW: sp.barW + (targetPositions[i].barW - sp.barW) * ease
}));
animProgress = 1;
drawChart();
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function toggleSort() {
sorted = !sorted;
if (sorted) {
langData.sort((a, b) => b.value - a.value);
} else {
langData.sort((a, b) => a.name.localeCompare(b.name));
}
calculatePositions();
animateToTarget();
}
// 交互
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const my = (e.clientY - rect.top) * (displayH / rect.height);
let found = -1;
currentPositions.forEach((pos, i) => {
if (my >= pos.y && my <= pos.y + pos.barH) found = i;
});
if (found !== hoveredIndex) {
hoveredIndex = found;
drawChart();
}
});
canvas.addEventListener('mouseleave', () => {
hoveredIndex = -1;
drawChart();
});
// 初始化
langData.sort((a, b) => b.value - a.value);
calculatePositions();
currentPositions = targetPositions.map(p => ({ ...p, barW: 0 }));
// 入场动画
const startTime = performance.now();
function entrance(now) {
animProgress = Math.min(1, (now - startTime) / 800);
animProgress = 1 - Math.pow(1 - animProgress, 3);
currentPositions.forEach(p => p.barW = targetPositions[currentPositions.indexOf(p)].barW * animProgress);
drawChart();
if (animProgress < 1) requestAnimationFrame(entrance);
}
requestAnimationFrame(entrance);
</script>
</body>
</html>示例3:带动画与交互的完整柱状图
以下示例实现一个功能完整的柱状图,包含入场动画、悬停提示、点击筛选和响应式适配。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>完整柱状图示例</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
padding: 20px;
}
.dashboard {
max-width: 1000px;
margin: 0 auto;
}
h1 { text-align: center; color: #2c3e50; margin-bottom: 20px; }
.chart-card {
background: #fff;
border-radius: 16px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.chart-title { color: #2c3e50; font-size: 18px; }
.filter-btns { display: flex; gap: 8px; flex-wrap: wrap; }
.filter-btn {
padding: 6px 16px;
border: 1px solid #ddd;
border-radius: 20px;
background: #fff;
color: #666;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn.active {
background: #4e79a7;
color: #fff;
border-color: #4e79a7;
}
canvas { width: 100%; }
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 18px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
text-align: center;
}
.stat-value { font-size: 28px; font-weight: 700; color: #2c3e50; }
.stat-label { font-size: 13px; color: #999; margin-top: 4px; }
.stat-change { font-size: 12px; margin-top: 4px; }
.stat-up { color: #27ae60; }
.stat-down { color: #e74c3c; }
</style>
</head>
<body>
<div class="dashboard">
<h1>销售数据分析仪表盘</h1>
<div class="stats-row" id="statsRow"></div>
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">月度销售趋势</span>
<div class="filter-btns">
<button class="filter-btn active" onclick="filterData('all')">全部</button>
<button class="filter-btn" onclick="filterData('online')">线上</button>
<button class="filter-btn" onclick="filterData('offline')">线下</button>
</div>
</div>
<canvas id="mainChart"></canvas>
</div>
</div>
<script>
const monthlyData = [
{ month: '1月', online: 120, offline: 80 },
{ month: '2月', online: 95, offline: 70 },
{ month: '3月', online: 150, offline: 90 },
{ month: '4月', online: 180, offline: 110 },
{ month: '5月', online: 210, offline: 130 },
{ month: '6月', online: 195, offline: 145 },
{ month: '7月', online: 230, offline: 160 },
{ month: '8月', online: 250, offline: 155 },
{ month: '9月', online: 220, offline: 140 },
{ month: '10月', online: 260, offline: 170 },
{ month: '11月', online: 310, offline: 200 },
{ month: '12月', online: 280, offline: 190 }
];
let currentFilter = 'all';
let chartData = [];
let animProgress = 0;
let hoveredBar = null;
let selectedIndex = -1;
const canvas = document.getElementById('mainChart');
const dpr = window.devicePixelRatio || 1;
let displayW, displayH, ctx;
function initCanvas() {
displayW = canvas.parentElement.clientWidth - 50;
displayH = Math.min(400, displayW * 0.5);
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
canvas.style.width = displayW + 'px';
canvas.style.height = displayH + 'px';
ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
}
const margin = { top: 20, right: 20, bottom: 50, left: 55 };
function getFilteredData() {
return monthlyData.map(d => {
let value;
if (currentFilter === 'online') value = d.online;
else if (currentFilter === 'offline') value = d.offline;
else value = d.online + d.offline;
return { label: d.month, value, online: d.online, offline: d.offline };
});
}
function updateStats() {
const data = getFilteredData();
const total = data.reduce((s, d) => s + d.value, 0);
const avg = total / data.length;
const max = Math.max(...data.map(d => d.value));
const maxMonth = data.find(d => d.value === max).label;
const onlineTotal = data.reduce((s, d) => s + d.online, 0);
const offlineTotal = data.reduce((s, d) => s + d.offline, 0);
document.getElementById('statsRow').innerHTML = `
<div class="stat-card">
<div class="stat-value">${total}</div>
<div class="stat-label">总销售额(万元)</div>
</div>
<div class="stat-card">
<div class="stat-value">${avg.toFixed(1)}</div>
<div class="stat-label">月均销售额</div>
</div>
<div class="stat-card">
<div class="stat-value">${max}</div>
<div class="stat-label">最高月份 (${maxMonth})</div>
</div>
<div class="stat-card">
<div class="stat-value">${(onlineTotal / (onlineTotal + offlineTotal) * 100).toFixed(1)}%</div>
<div class="stat-label">线上占比</div>
<div class="stat-change stat-up">较去年 +5.2%</div>
</div>
`;
}
function drawChart() {
ctx.clearRect(0, 0, displayW, displayH);
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
const maxVal = Math.ceil(Math.max(...chartData.map(d => d.value)) / 50) * 50;
ctx.save();
ctx.translate(margin.left, margin.top);
// 网格
for (let i = 0; i <= 5; i++) {
const v = maxVal * i / 5;
const y = ph - (v / maxVal) * ph;
ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
ctx.fillStyle = '#999'; ctx.font = '11px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(v, -8, y);
}
// 柱子
const barWidth = pw / chartData.length * 0.6;
const gap = pw / chartData.length;
const colors = currentFilter === 'online' ? '#4e79a7' :
currentFilter === 'offline' ? '#f28e2b' : '#59a14f';
chartData.forEach((d, i) => {
const barH = (d.value / maxVal) * ph * animProgress;
const x = i * gap + (gap - barWidth) / 2;
const y = ph - barH;
const isHovered = hoveredBar === i;
const isSelected = selectedIndex === i;
// 渐变
const grad = ctx.createLinearGradient(x, y, x, ph);
if (typeof colors === 'string') {
grad.addColorStop(0, colors);
grad.addColorStop(1, colors + '88');
}
ctx.fillStyle = grad;
// 圆角
ctx.beginPath();
if (barH > 4) {
ctx.moveTo(x + 3, y);
ctx.lineTo(x + barWidth - 3, y);
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + 3);
ctx.lineTo(x + barWidth, ph);
ctx.lineTo(x, ph);
ctx.lineTo(x, y + 3);
ctx.quadraticCurveTo(x, y, x + 3, y);
} else {
ctx.rect(x, y, barWidth, barH);
}
ctx.fill();
if (isHovered) {
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.fill();
}
if (isSelected) {
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.stroke();
}
// 数据标签
if (isHovered || isSelected) {
ctx.fillStyle = '#333';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(d.value, x + barWidth / 2, y - 8);
}
// X轴标签
ctx.fillStyle = (isHovered || isSelected) ? '#333' : '#888';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(d.label, x + barWidth / 2, ph + 15);
});
// Tooltip
if (hoveredBar !== null) {
const d = chartData[hoveredBar];
const lines = [
d.label,
`总计: ${d.value}万元`,
`线上: ${d.online}万元`,
`线下: ${d.offline}万元`
];
ctx.font = '12px sans-serif';
const maxW = Math.max(...lines.map(l => ctx.measureText(l).width));
const tw = maxW + 20;
const th = lines.length * 20 + 12;
const barH = (d.value / maxVal) * ph * animProgress;
const barX = hoveredBar * gap + (gap - barWidth) / 2;
let tx = barX + barWidth / 2 - tw / 2;
tx = Math.max(0, Math.min(pw - tw, tx));
const ty = ph - barH - th - 10;
ctx.fillStyle = 'rgba(44,62,80,0.92)';
ctx.beginPath();
ctx.roundRect(tx, ty, tw, th, 6);
ctx.fill();
lines.forEach((line, li) => {
ctx.fillStyle = li === 0 ? '#fff' : '#ccc';
ctx.font = li === 0 ? 'bold 12px sans-serif' : '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(line, tx + 10, ty + 14 + li * 20);
});
}
// 坐标轴
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();
ctx.restore();
}
function filterData(type) {
currentFilter = type;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
chartData = getFilteredData();
selectedIndex = -1;
updateStats();
animateChart();
}
function animateChart() {
const start = performance.now();
function step(now) {
animProgress = Math.min(1, (now - start) / 600);
animProgress = 1 - Math.pow(1 - animProgress, 3);
drawChart();
if (animProgress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// 交互
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (displayW / rect.width) - margin.left;
const my = (e.clientY - rect.top) * (displayH / rect.height) - margin.top;
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
const gap = pw / chartData.length;
const barWidth = gap * 0.6;
const maxVal = Math.ceil(Math.max(...chartData.map(d => d.value)) / 50) * 50;
let found = null;
chartData.forEach((d, i) => {
const x = i * gap + (gap - barWidth) / 2;
const barH = (d.value / maxVal) * ph;
const y = ph - barH;
if (mx >= x && mx <= x + barWidth && my >= y && my <= ph) found = i;
});
if (found !== hoveredBar) {
hoveredBar = found;
drawChart();
}
});
canvas.addEventListener('click', () => {
if (hoveredBar !== null) {
selectedIndex = selectedIndex === hoveredBar ? -1 : hoveredBar;
drawChart();
}
});
canvas.addEventListener('mouseleave', () => {
hoveredBar = null;
drawChart();
});
// 响应式
window.addEventListener('resize', () => {
initCanvas();
drawChart();
});
// 初始化
initCanvas();
chartData = getFilteredData();
updateStats();
animateChart();
</script>
</body>
</html>浏览器兼容性
注意事项与最佳实践
柱宽比例:柱宽占类别间距的60%-80%为宜,避免过窄或过宽。
Y轴从零开始:柱状图的Y轴必须从零开始,否则会夸大差异造成误导。
颜色使用:分组柱状图使用分类色阶区分系列,基础柱状图可用同一颜色或渐变。
堆叠顺序:堆叠柱状图中,将最重要的系列放在底部,便于比较。
数据标签:当柱子数量较少时,直接在柱子上方标注数值提升可读性。
动画适度:入场动画时长控制在500-1000ms,避免过长影响体验。
交互反馈:悬停时提供视觉反馈(高亮、tooltip),点击可选中/筛选。
响应式:监听窗口resize事件,重新计算Canvas尺寸和重绘图表。
代码规范示例
代码示例
// 好的做法:柱状图配置对象
const barChartConfig = {
margin: { top: 30, right: 20, bottom: 50, left: 60 },
barWidthRatio: 0.7, // 柱宽占类别间距比例
borderRadius: 4, // 柱子圆角
animation: {
duration: 800,
easing: 'easeOutCubic'
},
colors: {
grid: '#f0f0f0',
axis: '#ddd',
label: '#888',
tooltip: 'rgba(44,62,80,0.9)'
},
yAxis: {
startFromZero: true, // Y轴从零开始
tickCount: 6
}
};
// 好的做法:数据验证
function validateBarData(data) {
if (!Array.isArray(data) || data.length === 0) {
throw new Error('柱状图数据必须是非空数组');
}
data.forEach(item => {
if (typeof item.value !== 'number' || item.value < 0) {
throw new Error('柱状图数值必须是非负数');
}
});
}
// 不好的做法:Y轴不从零开始
// const yMin = Math.min(...data.map(d => d.value)); // 可能造成误导
// 好的做法:Y轴从零开始
// const yMin = 0;常见问题与解决方案
问题1:分组柱状图柱子太窄
原因:类别数和系列数过多,导致每根柱子宽度不足。
解决方案:
减少同时显示的系列数(提供筛选功能)
增加图表宽度
使用堆叠柱状图替代分组柱状图
使用水平柱状图获得更多水平空间
问题2:堆叠柱状图难以比较中间层
原因:堆叠柱状图中只有底层可以直接比较,中间层因基线不同难以比较。
解决方案:
对重要系列使用分组柱状图
提供切换分组/堆叠的选项
使用百分比堆叠展示占比
问题3:柱状图动画卡顿
原因:每帧都重新计算和绘制所有元素。
解决方案:
使用离屏Canvas缓存静态元素
只重绘变化的部分
减少不必要的save/restore调用
使用will-change: transform提示浏览器优化
问题4:负值柱状图显示异常
原因:负值柱子应该向下延伸,但代码未处理负值情况。
解决方案:
代码示例
// 处理正负值
data.forEach(d => {
const barH = Math.abs(d.value) / maxVal * ph;
const y = d.value >= 0 ? ph - barH : ph;
ctx.fillRect(x, y, barWidth, barH);
});
// 绘制零线
const zeroY = ph - (0 - yMin) / (yMax - yMin) * ph;
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, zeroY); ctx.lineTo(pw, zeroY); ctx.stroke();总结
本教程全面讲解了柱状图的各类实现:
基础柱状图:单系列柱状图的核心绘制逻辑
分组柱状图:多系列并排展示,便于组间对比
堆叠柱状图:多系列堆叠展示总量和构成
百分比堆叠:展示各部分占比,消除总量差异
水平柱状图:适合长标签和排名展示
动画效果:入场动画、排序过渡动画
交互功能:悬停提示、点击筛选、数据高亮
响应式适配:窗口resize时自动调整
柱状图是最基础也最实用的图表类型,掌握其各类变体的实现是数据可视化学习的重要基础。
常见问题
分组柱状图柱子太窄
- 减少同时显示的系列数(提供筛选功能) - 增加图表宽度 - 使用堆叠柱状图替代分组柱状图 - 使用水平柱状图获得更多水平空间
堆叠柱状图难以比较中间层
- 对重要系列使用分组柱状图 - 提供切换分组/堆叠的选项 - 使用百分比堆叠展示占比
柱状图动画卡顿
- 使用离屏Canvas缓存静态元素 - 只重绘变化的部分 - 减少不必要的save/restore调用 - 使用will-change: transform提示浏览器优化
负值柱状图显示异常
负值柱子应该向下延伸,但代码未处理负值情况。
本文由小确幸生活整理发布,转载请注明出处
本文涉及AI创作
内容由AI创作,请仔细甄别