欢迎加入开源鸿蒙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的阴影属性实现。shadowColorshadowBlur可以在绑制的形状周围添加发光效果,让被悬停的柱状更加突出。

提示框的定位需要考虑边界情况。当鼠标靠近屏幕边缘时,提示框可能会超出可视范围,所以需要调整位置使其始终可见。

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. 总结

本文详细介绍了柱状图组件的完整实现过程,涵盖以下核心知识点:

  1. 布局计算算法:计算柱状的宽度、位置和间距,支持单系列和分组柱状图。

  2. 圆角矩形绘制:使用arcTo方法实现带圆角的矩形,支持可配置的圆角半径。

  3. 渐变填充效果:通过createLinearGradient实现垂直或水平渐变,增强视觉效果。

  4. 交互功能实现:鼠标悬停高亮、提示框显示、点击选中等交互功能。

  5. 动画效果:柱状高度的变化动画、数据更新过渡动画。

  6. 响应式设计:窗口大小变化时的自动调整和重绘。

柱状图虽然是最简单的图表类型之一,但实现一个完善的组件仍然需要考虑诸多细节。从布局计算到视觉效果,从交互处理到动画过渡,每个环节都有其技术难点和设计考量。希望读者通过本文的学习,能够掌握柱状图绑定的核心原理,并将其应用到实际项目中。

Logo

赋能鸿蒙PC开发者,共建全场景原生生态,共享一次开发多端部署创新价值。

更多推荐