鸿蒙PC用Electron框架实现——柱状图技术实现详解
欢迎加入开源鸿蒙PC社区:
https://harmonypc.csdn.net/
atomgit仓库地址: https://atomgit.com/m0_66062719/fl_char_tableshow

1. 概述
1.1 柱状图的特点与应用场景
柱状图是数据可视化中最直观、最易于理解的图表类型之一。它通过垂直或水平的矩形条块来展示不同类别之间的数据差异,每个条块的高度或长度与它所代表的数值成正比。与折线图相比,柱状图更适合展示离散的、分类的数据,而不是连续变化的数据。
柱状图在日常工作中有着广泛的应用。在商业领域,它可以用来展示季度销售额对比、各地区销量排名、产品类目分布等;在教育领域,可以展示学生成绩分布、课程选修人数统计等;在科研领域,可以展示实验数据对比、样本分析结果等。可以说,只要是涉及分类数据对比的场景,柱状图都是首选的可视化工具。
在鸿蒙PC平台上实现柱状图,我们需要考虑几个关键问题。首先是柱状的布局算法,如何在有限的宽度内合理安排所有柱状的位置和间距;其次是视觉效果的实现,包括渐变填充、圆角设计、阴影效果等;最后是交互功能的实现,如悬停高亮、点击选中、数据编辑等。本文将详细介绍如何用纯Canvas 2D API实现一个功能完善的柱状图组件。
1.2 柱状图类型分类
柱状图有多种变体,每种都有其特定的应用场景和展示效果:
基本柱状图:最简单直接的柱状图形式,每个类别对应一个柱状,柱状之间有适当间距。这种图表适合展示少量分类(一般不超过10个)的数据对比。
分组柱状图:将多个数据系列并排放置,每个分组内的柱状代表不同的数据系列。这种图表适合比较多个系列在相同样本下的表现差异。
堆叠柱状图:将多个数据系列垂直堆叠在一起,每个柱状显示各系列的累计值。这种图表适合展示整体与部分的关系。
百分比堆叠柱状图:与堆叠柱状图类似,但展示的是各系列占总体的百分比,而不是绝对值。这种图表适合比较不同类别的构成比例。
本文将重点介绍基本柱状图和分组柱状图的实现,这两个是最常用的类型。堆叠柱状图可以看作是基本柱状图的扩展,理解了基本柱状图之后,堆叠版本就很容易实现了。
2. 架构设计
2.1 组件架构概览
柱状图组件采用与折线图类似但有所调整的架构设计。核心区别在于柱状图的绘制逻辑与折线图完全不同——柱状图使用矩形绘制而非线条连接,数据定位也主要在Y轴方向。
class BarChart {
constructor(canvasId, data, options = {}) {
// 核心属性
this.canvas = null;
this.ctx = null;
this.data = null;
this.options = {};
// 尺寸相关
this.width = 0;
this.height = 0;
this.dpr = 1;
// 布局配置
this.padding = { top: 30, right: 30, bottom: 50, left: 60 };
this.chartWidth = 0;
this.chartHeight = 0;
// 柱状图特定配置
this.barSpacing = 0.2; // 柱状间距比例(0-1之间)
this.barRadius = 6; // 柱状圆角半径
// 状态
this.hoveredBar = null;
this.selectedBar = null;
// 初始化
this.init(canvasId, data, options);
}
init(canvasId, data, options) {
// 获取Canvas元素
this.canvas = document.getElementById(canvasId);
if (!this.canvas) {
throw new Error(`Canvas element with id "${canvasId}" not found`);
}
// 获取绑定上下文
this.ctx = this.canvas.getContext('2d');
// 合并配置
this.options = { ...this.getDefaultOptions(), ...options };
// 设置数据
this.setData(data);
// 初始化尺寸
this.resize();
// 绑定窗口大小变化事件
window.addEventListener('resize', () => this.debouncedResize());
// 绑定交互事件
this.bindEvents();
// 渲染图表
this.render();
}
getDefaultOptions() {
return {
barSpacing: 0.2,
barRadius: 6,
showValues: true,
showGrid: true,
animated: true,
animationDuration: 800,
gradientStart: 1.0,
gradientEnd: 0.6
};
}
}
2.2 模块划分
柱状图组件的功能模块划分如下:
布局计算模块:这是柱状图特有的核心模块,负责计算每个柱状的宽度、位置和间距。需要考虑容器宽度、柱状数量、分组数量等因素,确保所有柱状都能正确显示且保持合适的间距。
矩形绘制模块:负责绘制柱状的矩形形状。与简单的fillRect不同,我们需要实现带圆角的矩形绘制,还要支持渐变填充效果。
坐标系统模块:与折线图类似,负责建立数据值和屏幕像素之间的映射关系。柱状图主要关注Y轴映射(值→高度),X轴主要用于确定柱状的位置。
交互处理模块:负责处理鼠标悬停、点击等交互事件。需要实现柱状的高亮效果和选中状态。
动画控制模块:负责实现柱状的入场动画和更新动画。柱状图动画的难点在于柱状高度的渐变过渡。
3. 核心代码实现
3.1 布局计算算法
布局计算是柱状图的核心。给定容器宽度、柱状数量,我们需要计算每个柱状的宽度和位置。这个计算要考虑间距、边距等多种因素。
class BarChart {
// 计算布局参数
calculateLayout() {
// 获取配置参数
const barCount = this.data.labels.length;
const seriesCount = this.data.datasets.length;
const isGrouped = seriesCount > 1;
// 计算每个分类的宽度
const categoryWidth = this.chartWidth / barCount;
// 计算柱状数量(分组时每组有多个柱状)
const barsPerCategory = isGrouped ? seriesCount : 1;
// 计算单个柱状的宽度
// 柱状宽度 = 分类宽度 * (1 - 间距比例)
const barWidth = categoryWidth * (1 - this.options.barSpacing);
// 分组时,需要将柱状宽度进一步细分
const actualBarWidth = isGrouped ? barWidth / barsPerCategory : barWidth;
// 计算柱状之间的间距
const barGap = categoryWidth * this.options.barSpacing;
// 计算分组内柱状的起始偏移
const groupOffset = isGrouped ? (seriesCount - 1) * actualBarWidth / 2 : 0;
// 保存布局结果
this.layout = {
categoryWidth,
barWidth: actualBarWidth,
barGap,
groupOffset,
barCount,
seriesCount,
isGrouped
};
}
// 计算单个柱状的绘制参数
getBarParams(categoryIndex, seriesIndex = 0) {
const { categoryWidth, barWidth, groupOffset } = this.layout;
// 计算分类的起始X坐标
const categoryStartX = this.padding.left + categoryIndex * categoryWidth;
// 计算柱状的X坐标
let barX;
if (this.layout.isGrouped) {
// 分组柱状:根据系列索引偏移
barX = categoryStartX + groupOffset + seriesIndex * barWidth;
} else {
// 单系列柱状:居中放置
barX = categoryStartX + (categoryWidth - barWidth) / 2;
}
return {
x: barX,
width: barWidth,
categoryStartX
};
}
// 计算柱状的Y坐标和高度
getBarHeight(value, minValue, maxValue) {
// 归一化数值
const normalized = (value - minValue) / (maxValue - minValue);
// 计算高度
const height = normalized * this.chartHeight;
// 计算Y坐标(从底部算起)
const y = this.padding.top + this.chartHeight - height;
return { y, height };
}
}
布局计算的设计考虑了多种场景。单系列柱状图时,柱状居中放置在每个分类下方;多系列分组柱状图时,柱状均匀分布在该分类的区域内。间距通过barSpacing参数控制,值为0.2表示柱状占宽度的80%,剩余20%作为间距。
3.2 带圆角矩形绘制
Canvas没有直接提供圆角矩形的API,我们需要自行实现这个功能。圆角矩形的绘制需要用到arcTo或者手动绘制圆弧加直线。
class BarChart {
// 绘制带圆角的矩形
drawRoundedRect(x, y, width, height, radius) {
const ctx = this.ctx;
// 确保圆角半径不会超过矩形尺寸
radius = Math.min(radius, width / 2, height / 2);
ctx.beginPath();
// 从左上角开始,顺时针绘制
ctx.moveTo(x + radius, y);
// 上边
ctx.lineTo(x + width - radius, y);
// 右上角圆弧
ctx.arcTo(x + width, y, x + width, y + radius, radius);
// 右边
ctx.lineTo(x + width, y + height - radius);
// 右下角圆弧
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
// 下边
ctx.lineTo(x + radius, y + height);
// 左下角圆弧
ctx.arcTo(x, y + height, x, y + height - radius, radius);
// 左边
ctx.lineTo(x, y + radius);
// 左上角圆弧
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
}
// 绘制渐变柱状
drawGradientBar(x, y, width, height, color, direction = 'vertical') {
const ctx = this.ctx;
// 创建渐变
let gradient;
if (direction === 'vertical') {
// 垂直渐变:从上到下
gradient = ctx.createLinearGradient(x, y, x, y + height);
} else {
// 水平渐变:从左到右
gradient = ctx.createLinearGradient(x, y, x + width, y);
}
// 设置渐变色
// 顶部使用较亮的颜色,底部使用较暗的颜色
gradient.addColorStop(0, color);
gradient.addColorStop(1, this.adjustColorBrightness(color, this.options.gradientEnd));
// 绘制柱状
this.drawRoundedRect(x, y, width, height, this.options.barRadius);
ctx.fillStyle = gradient;
ctx.fill();
}
// 调整颜色亮度
adjustColorBrightness(hexColor, factor) {
// 解析十六进制颜色
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
// 调整亮度
const newR = Math.round(r * factor);
const newG = Math.round(g * factor);
const newB = Math.round(b * factor);
// 转换回十六进制
return `rgb(${newR}, ${newG}, ${newB})`;
}
}
圆角矩形的实现使用了arcTo方法。arcTo需要两个控制点和一个半径,它会在当前位置和第一个控制点之间画一条直线,然后从这个交点画圆弧到第二个控制点。这种方式比手动计算圆弧坐标要简洁得多。
渐变效果通过createLinearGradient实现。垂直渐变从上到下,颜色逐渐变暗,产生立体感;也可以选择水平渐变,产生不同的视觉效果。
3.3 完整柱状绘制
现在我们可以实现完整的柱状绘制逻辑,将布局计算、矩形绘制、数据映射整合在一起。
class BarChart {
// 绘制柱状图
drawBars(animated = false) {
// 计算数据范围
const { minValue, maxValue } = this.calculateDataRange();
// 重新计算布局
this.calculateLayout();
// 绘制每个分类的柱状
this.data.labels.forEach((label, categoryIndex) => {
this.data.datasets.forEach((dataset, seriesIndex) => {
const value = dataset.data[categoryIndex];
// 获取位置参数
const { x, width } = this.getBarParams(categoryIndex, seriesIndex);
// 获取高度参数
const { y, height } = this.getBarHeight(value, minValue, maxValue);
// 绘制柱状
if (animated && this.options.animated) {
this.drawAnimatedBar(x, y, width, 0, height, dataset.color, seriesIndex);
} else {
this.drawGradientBar(x, y, width, height, dataset.color);
this.drawValueLabel(x, y, width, value);
}
});
// 绘制分类标签
this.drawCategoryLabel(categoryIndex);
});
}
// 绘制带动画的柱状
drawAnimatedBar(x, y, width, fromHeight, toHeight, color, delay = 0) {
const ctx = this.ctx;
const startTime = Date.now();
const duration = this.options.animationDuration;
// 计算延迟(分组柱状需要依次动画)
const actualDelay = delay * 100;
const animate = () => {
const elapsed = Date.now() - startTime - actualDelay;
if (elapsed < 0) {
requestAnimationFrame(animate);
return;
}
const progress = Math.min(elapsed / duration, 1);
const easedProgress = this.easeOutCubic(progress);
// 计算当前高度
const currentHeight = fromHeight + (toHeight - fromHeight) * easedProgress;
// 清除该区域
ctx.clearRect(x - 2, this.padding.top - 2, width + 4, this.chartHeight + 4);
// 重新绘制整个图表
this.drawBackground();
this.drawGrid();
// 绘制动画中的柱状
const currentY = this.padding.top + this.chartHeight - currentHeight;
this.drawGradientBar(x, currentY, width, currentHeight, color);
// 绘制值标签
if (progress > 0.8) {
const valueProgress = (progress - 0.8) / 0.2;
ctx.globalAlpha = valueProgress;
this.drawValueLabel(x, currentY, width, this.getCurrentValue());
ctx.globalAlpha = 1;
}
// 继续动画或结束
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
// 计算数据范围
calculateDataRange() {
const allValues = this.data.datasets.flatMap(d => d.data);
let minValue = Math.min(...allValues);
let maxValue = Math.max(...allValues);
// 如果包含负数,需要调整最小值
if (minValue < 0) {
minValue = minValue * 1.1;
} else {
minValue = 0; // 柱状图通常从0开始
}
// 最大值留出空间
maxValue = maxValue * 1.1;
return { minValue, maxValue };
}
// 绘制分类标签
drawCategoryLabel(categoryIndex) {
const ctx = this.ctx;
const { x, width } = this.getBarParams(categoryIndex);
const centerX = x + width / 2;
const label = this.data.labels[categoryIndex];
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// 旋转标签以适应空间
if (this.layout.categoryWidth < 60) {
ctx.save();
ctx.translate(centerX, this.height - 20);
ctx.rotate(-Math.PI / 4);
ctx.fillText(label, 0, 0);
ctx.restore();
} else {
ctx.fillText(label, centerX, this.height - 35);
}
}
// 绘制数值标签
drawValueLabel(x, y, width, value) {
if (!this.options.showValues) return;
const ctx = this.ctx;
const centerX = x + width / 2;
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(value.toString(), centerX, y - 5);
}
}
柱状图的动画与折线图有所不同。折线图是点数从左到右逐渐出现,柱状图则是高度从下往上逐渐增长。动画过程中,每一帧都需要重新绘制整个图表(因为柱状高度变化会影响后面的内容),然后在当前位置绘制正在动画的柱状。
分类标签的处理也需要考虑空间限制。当分类较多、每个分类的宽度较小时,水平放置的标签会相互重叠。这时可以旋转标签角度,让它们斜向排列,充分利用对角线方向的空间。
3.4 分组柱状图实现
分组柱状图是多个数据系列并排放置的柱状图形式。实现的关键在于正确计算每个系列的位置偏移。
class BarChart {
// 绘制分组柱状图
drawGroupedBars(animated = false) {
const { minValue, maxValue } = this.calculateDataRangeForGrouped();
this.calculateLayout();
// 绘制每个分类
this.data.labels.forEach((label, categoryIndex) => {
// 获取该分类下的所有数据
const categoryValues = this.data.datasets.map(d => d.data[categoryIndex]);
const maxInCategory = Math.max(...categoryValues);
// 绘制该分类的所有柱状
this.data.datasets.forEach((dataset, seriesIndex) => {
const value = dataset.data[categoryIndex];
const { x, width } = this.getBarParams(categoryIndex, seriesIndex);
// 分组柱状图的高度应该相对于该分类的最大值
const normalized = value / maxInCategory;
const height = normalized * this.chartHeight;
const y = this.padding.top + this.chartHeight - height;
if (animated) {
this.drawAnimatedBar(x, y, width, 0, height, dataset.color, seriesIndex);
} else {
this.drawGradientBar(x, y, width, height, dataset.color);
}
});
// 绘制分类标签
this.drawCategoryLabel(categoryIndex);
});
// 绘制图例
this.drawLegend();
}
// 分组柱状图的数据范围计算
calculateDataRangeForGrouped() {
// 分组柱状图不需要整体归一化
// 每个分类内的柱状高度相对于该分类的最大值
let maxValue = 0;
this.data.labels.forEach((label, categoryIndex) => {
const categoryMax = Math.max(...this.data.datasets.map(d => d.data[categoryIndex]));
maxValue = Math.max(maxValue, categoryMax);
});
return { minValue: 0, maxValue: maxValue * 1.1 };
}
// 绘制图例
drawLegend() {
const ctx = this.ctx;
const legendWidth = this.data.datasets.length * 100;
const startX = (this.width - legendWidth) / 2;
const y = 12;
this.data.datasets.forEach((dataset, index) => {
const x = startX + index * 100;
// 绘制颜色方块
this.drawRoundedRect(x, y - 6, 12, 12, 2);
ctx.fillStyle = dataset.color;
ctx.fill();
// 绘制标签
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(dataset.label, x + 18, y);
});
}
}
分组柱状图与普通柱状图的关键区别在于高度的计算方式。普通柱状图所有柱状共用同一个Y轴刻度,便于直接比较不同分类的绝对值;分组柱状图每个分类内的柱状高度相对于该分类的最大值,便于比较同一分类内不同系列的比例关系。
图例是分组柱状图的重要组成部分,帮助用户理解每个颜色对应的数据系列。图例的位置通常在图表顶部居中,与柱状图的X轴标签互不干扰。
3.5 网格与坐标轴绘制
柱状图的网格和坐标轴与折线图类似,但由于柱状图通常从0开始,Y轴的处理略有不同。
class BarChart {
// 绘制背景和网格
drawBackground() {
const ctx = this.ctx;
// 清空画布
ctx.clearRect(0, 0, this.width, this.height);
// 绘制背景(可选)
// ctx.fillStyle = '#1a1a2e';
// ctx.fillRect(0, 0, this.width, this.height);
}
// 绘制网格线
drawGrid() {
if (!this.options.showGrid) return;
const ctx = this.ctx;
const { minValue, maxValue } = this.calculateDataRange();
// 网格线数量
const gridLines = 5;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
for (let i = 0; i <= gridLines; i++) {
// 计算网格线位置
const yRatio = i / gridLines;
const y = this.padding.top + yRatio * this.chartHeight;
// 绘制水平线
ctx.beginPath();
ctx.moveTo(this.padding.left, y);
ctx.lineTo(this.width - this.padding.right, y);
ctx.stroke();
// 计算对应的值
const value = maxValue - yRatio * (maxValue - minValue);
// 绘制Y轴刻度
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(value).toString(), this.padding.left - 8, y);
}
// 绘制基准线(如果最小值是0)
if (minValue === 0) {
const baselineY = this.padding.top + this.chartHeight;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(this.padding.left, baselineY);
ctx.lineTo(this.width - this.padding.right, baselineY);
ctx.stroke();
}
}
// 完整的渲染流程
render() {
this.drawBackground();
this.drawGrid();
this.drawBars(this.options.animated);
}
}
柱状图的Y轴通常从0开始,这是柱状图与折线图的重要区别之一。从0开始的Y轴能准确反映数据之间的比例关系——如果从非0值开始,视觉上可能会产生误导。
基准线使用比普通网格线更深的颜色和更粗的线宽,强调这是柱状图的起点。数值标签在基准线上方(柱状内部),而不是下方,因为柱状的高度从这里开始计算。
4. 交互功能实现
4.1 鼠标悬停效果
柱状图的交互主要通过鼠标悬停来实现。悬停时,柱状应该高亮显示,并出现提示框显示详细信息。
class BarChart {
// 绑定交互事件
bindEvents() {
// 创建提示框
this.tooltip = document.createElement('div');
this.tooltip.className = 'bar-chart-tooltip';
this.applyTooltipStyle();
this.canvas.parentElement.style.position = 'relative';
this.canvas.parentElement.appendChild(this.tooltip);
// 绑定鼠标事件
this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
this.canvas.addEventListener('mouseleave', () => this.handleMouseLeave());
this.canvas.addEventListener('click', (e) => this.handleClick(e));
}
// 应用提示框样式
applyTooltipStyle() {
this.tooltip.style.cssText = `
position: absolute;
background: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
`;
}
// 处理鼠标移动
handleMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 查找悬停的柱状
const barInfo = this.findBarAtPosition(x, y);
if (barInfo) {
// 高亮柱状
if (this.hoveredBar !== barInfo) {
this.hoveredBar = barInfo;
this.redrawWithHighlight(barInfo);
}
// 显示提示框
this.showTooltip(e.clientX, e.clientY, barInfo);
} else {
this.handleMouseLeave();
}
}
// 查找指定位置的柱状
findBarAtPosition(x, y) {
const { minValue, maxValue } = this.calculateDataRange();
for (let categoryIndex = 0; categoryIndex < this.data.labels.length; categoryIndex++) {
for (let seriesIndex = 0; seriesIndex < this.data.datasets.length; seriesIndex++) {
const value = this.data.datasets[seriesIndex].data[categoryIndex];
const { x: barX, width } = this.getBarParams(categoryIndex, seriesIndex);
const { y: barY, height } = this.getBarHeight(value, minValue, maxValue);
// 检查是否在柱状范围内
if (x >= barX && x <= barX + width && y >= barY && y <= barY + height) {
return {
categoryIndex,
seriesIndex,
category: this.data.labels[categoryIndex],
series: this.data.datasets[seriesIndex].label,
value: value,
color: this.data.datasets[seriesIndex].color,
x: barX,
y: barY,
width: width,
height: height
};
}
}
}
return null;
}
// 高亮重绘
redrawWithHighlight(highlightInfo) {
const ctx = this.ctx;
// 清空并重绘
ctx.clearRect(0, 0, this.width, this.height);
this.drawBackground();
this.drawGrid();
// 绘制所有柱状
this.data.labels.forEach((label, categoryIndex) => {
this.data.datasets.forEach((dataset, seriesIndex) => {
const value = dataset.data[categoryIndex];
const { x, width } = this.getBarParams(categoryIndex, seriesIndex);
const { y, height } = this.getBarHeight(value, 0, Math.max(...this.data.datasets.flatMap(d => d.data)) * 1.1);
// 如果是悬停的柱状,使用高亮效果
if (categoryIndex === highlightInfo.categoryIndex &&
seriesIndex === highlightInfo.seriesIndex) {
// 绘制高亮柱状(添加发光效果)
ctx.shadowColor = dataset.color;
ctx.shadowBlur = 15;
this.drawGradientBar(x, y, width, height, dataset.color);
ctx.shadowBlur = 0;
} else {
// 正常柱状
this.drawGradientBar(x, y, width, height, dataset.color);
}
});
this.drawCategoryLabel(categoryIndex);
});
this.drawLegend();
}
// 显示提示框
showTooltip(mouseX, mouseY, barInfo) {
let html = `<strong>${barInfo.category}</strong><br>`;
html += `<span style="color: ${barInfo.color}">●</span> ${barInfo.series}: <strong>${barInfo.value}</strong>`;
this.tooltip.innerHTML = html;
this.tooltip.style.opacity = '1';
// 调整位置避免超出边界
const tooltipRect = this.tooltip.getBoundingClientRect();
let left = mouseX + 15;
let top = mouseY - 10;
if (left + tooltipRect.width > window.innerWidth) {
left = mouseX - tooltipRect.width - 15;
}
if (top + tooltipRect.height > window.innerHeight) {
top = mouseY - tooltipRect.height - 10;
}
this.tooltip.style.left = left + 'px';
this.tooltip.style.top = top + 'px';
}
// 处理鼠标离开
handleMouseLeave() {
if (this.hoveredBar) {
this.hoveredBar = null;
this.tooltip.style.opacity = '0';
this.render();
}
}
// 处理点击
handleClick(e) {
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const barInfo = this.findBarAtPosition(x, y);
if (barInfo) {
this.selectedBar = barInfo;
// 触发回调
if (this.options.onBarClick) {
this.options.onBarClick(barInfo);
}
// 可以添加选中效果或执行其他操作
console.log('Clicked bar:', barInfo);
}
}
}
交互实现的核心是findBarAtPosition方法,它遍历所有柱状,检查鼠标坐标是否落在某个柱状的范围内。这个方法需要考虑分组柱状图的情况,因为同一个分类下可能有多个柱状。
高亮效果通过Canvas的阴影属性实现。shadowColor和shadowBlur可以在绑制的形状周围添加发光效果,让被悬停的柱状更加突出。
提示框的定位需要考虑边界情况。当鼠标靠近屏幕边缘时,提示框可能会超出可视范围,所以需要调整位置使其始终可见。
5. 高级功能
5.1 动态数据更新
柱状图的数据可能会随时间更新,组件需要支持平滑的数据过渡动画。
class BarChart {
// 更新数据
updateData(newData, animated = true) {
const oldData = this.data;
this.setData(newData);
if (animated && this.options.animated) {
this.animateDataChange(oldData, newData);
} else {
this.render();
}
}
// 数据变化动画
animateDataChange(oldData, newData) {
const startTime = Date.now();
const duration = this.options.animationDuration;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = this.easeOutCubic(progress);
// 清空画布
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawBackground();
this.drawGrid();
// 绘制动画中的柱状
this.data.labels.forEach((label, categoryIndex) => {
this.data.datasets.forEach((dataset, seriesIndex) => {
const oldValue = oldData.datasets[seriesIndex].data[categoryIndex] || 0;
const newValue = dataset.data[categoryIndex];
// 插值计算当前值
const currentValue = oldValue + (newValue - oldValue) * easedProgress;
const { x, width } = this.getBarParams(categoryIndex, seriesIndex);
const { y, height } = this.getBarHeight(currentValue, 0, this.getMaxValue() * 1.1);
this.drawGradientBar(x, y, width, height, dataset.color);
});
this.drawCategoryLabel(categoryIndex);
});
this.drawLegend();
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
// 获取最大值
getMaxValue() {
return Math.max(...this.data.datasets.flatMap(d => d.data));
}
// 缓动函数
easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
}
数据更新动画的核心是插值计算。在每一帧中,根据动画进度计算旧值和新值之间的中间值,然后绘制这个中间值对应的柱状。通过这种方式,柱状的高度会平滑地从旧值过渡到新值。
5.2 响应式设计
柱状图需要能够响应容器尺寸的变化。
class BarChart {
// 防抖处理resize
debouncedResize() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
this.resize();
}, 150);
}
// 调整尺寸
resize() {
// 获取新尺寸
const rect = this.canvas.parentElement.getBoundingClientRect();
// 检查尺寸是否有效
if (rect.width <= 0 || rect.height <= 0) return;
// 检查是否真的变了
if (Math.abs(rect.width - this.width) < 2) return;
// 更新尺寸
const dpr = window.devicePixelRatio || 1;
this.width = rect.width;
this.height = 250;
this.canvas.width = this.width * dpr;
this.canvas.height = this.height * dpr;
this.canvas.style.width = this.width + 'px';
this.canvas.style.height = this.height + 'px';
this.ctx.scale(dpr, dpr);
// 重新计算布局
this.calculateChartArea();
this.calculateLayout();
// 重绘
this.render();
}
calculateChartArea() {
this.chartWidth = this.width - this.padding.left - this.padding.right;
this.chartHeight = this.height - this.padding.top - this.padding.bottom;
}
}
响应式设计的关键是正确处理Canvas尺寸变化。当容器尺寸改变时,需要重新计算布局参数并重绘图表。防抖处理可以避免在调整窗口大小过程中频繁重绘。
6. 实际应用示例
6.1 季度销售对比
const salesData = {
labels: ['Q1', 'Q2', 'Q3', 'Q4'],
datasets: [
{
label: '2023年',
data: [1200, 1580, 1820, 2150],
color: '#3b82f6'
},
{
label: '2024年',
data: [1450, 1720, 1980, 2350],
color: '#22c55e'
}
]
};
const barChart = new BarChart('sales-chart', salesData, {
animated: true,
animationDuration: 1000,
showValues: true,
onBarClick: (barInfo) => {
console.log(`查看${barInfo.category}${barInfo.series}的详细数据`);
}
});
6.2 分类数据展示
const categoryData = {
labels: ['电子产品', '服装', '食品', '日用品', '家居'],
datasets: [{
label: '本月销量',
data: [450, 320, 280, 180, 220],
color: '#a855f7'
}]
};
const categoryChart = new BarChart('category-chart', categoryData, {
barRadius: 8,
showValues: true
});
7. 总结
本文详细介绍了柱状图组件的完整实现过程,涵盖以下核心知识点:
-
布局计算算法:计算柱状的宽度、位置和间距,支持单系列和分组柱状图。
-
圆角矩形绘制:使用
arcTo方法实现带圆角的矩形,支持可配置的圆角半径。 -
渐变填充效果:通过
createLinearGradient实现垂直或水平渐变,增强视觉效果。 -
交互功能实现:鼠标悬停高亮、提示框显示、点击选中等交互功能。
-
动画效果:柱状高度的变化动画、数据更新过渡动画。
-
响应式设计:窗口大小变化时的自动调整和重绘。
柱状图虽然是最简单的图表类型之一,但实现一个完善的组件仍然需要考虑诸多细节。从布局计算到视觉效果,从交互处理到动画过渡,每个环节都有其技术难点和设计考量。希望读者通过本文的学习,能够掌握柱状图绑定的核心原理,并将其应用到实际项目中。
更多推荐
所有评论(0)