在这里插入图片描述

每日一句正能量

“这世上所有的成功,闻起来都是死磕的味道。”
成功的表象是光鲜的,但本质是汗水和煎熬。所谓“死磕”,就是在所有人都觉得“差不多行了”的时候,还在打磨;在所有人都选择放弃的时候,还在坚持。这种味道或许不好闻,但它是成功唯一的入场券。


一、引言:多维数据的可视化挑战

在前两篇文章中,我们分别构建了柱状图(直角坐标系,侧重数值对比)和饼图/环形图(极坐标系,侧重占比分布)。然而,实际业务中还存在两类特殊的可视化需求:

雷达图(Radar Chart / Spider Chart) 用于展示同一实体在多个维度上的综合能力。例如游戏角色的六维属性、企业员工的KPI考核指标、产品的多维度评分——这些场景的共同特点是"维度数量固定、各维度量纲一致",需要在同一坐标系中直观对比优劣。

散点图(Scatter Plot) 用于揭示两个连续变量之间的相关性。例如广告投入与销售额的关系、用户活跃度与留存率的关联、温度与能耗的耦合——散点图的核心价值在于发现数据分布规律、识别异常点、甚至预测趋势。

本文将同时挑战这两个组件的实现。它们的技术难点截然不同:雷达图需要在极坐标系中构建闭合多边形并支持多系列叠加;散点图则需要实现坐标映射、回归线拟合、以及气泡大小对第三维度的映射。通过统一的分层架构,我们将证明:即使面对完全不同的图形类型,良好的抽象设计仍能保证代码的复用性与可维护性。


二、雷达图:从极坐标到多边形的绘制艺术

2.1 核心难点分析

雷达图的本质是将N个维度的数值映射为极坐标系中的N个顶点,再连接成闭合多边形。这与饼图的扇区绘制有相似之处(都使用极坐标),但存在关键差异:

  • 饼图:每个数据项对应一个扇区,扇区之间是"并列"关系,角度之和为360°;
  • 雷达图:每个维度对应一个轴线,数据值映射为轴线上的"距离",多边形面积反映综合能力。

在这里插入图片描述

核心坐标转换公式:

// N = 维度数量,index = 当前维度索引(0到N-1)
// 每个维度均匀分布在360°圆周上
const anglePerDimension = 360 / N;

// 从12点钟方向开始,顺时针分布
const angleDeg = index * anglePerDimension - 90;
const angleRad = (angleDeg * Math.PI) / 180;

// 数据值(0~1标准化)映射为距离原点的长度
const distance = value * maxRadius;

// 极坐标转直角坐标
const x = centerX + distance * Math.sin(angleRad);
const y = centerY + distance * Math.cos(angleRad);

关键设计决策

  • 角度计算使用 sin/cos 而非 cos/sin,是因为Canvas坐标系中Y轴向下为正,需要通过三角函数相位调整确保0°位于12点钟方向;
  • 数据值必须先进行Min-Max标准化(映射到0~1区间),否则不同量纲的维度无法在同一雷达图中比较。

2.2 数据模型层

雷达图的数据模型需要处理多系列、多维度、以及数据标准化。

// model/RadarDataModel.ets
export class RadarDimension {
  name: string = '';     // 维度名称(如"攻击力")
  maxValue: number = 100; // 该维度的最大值(用于标准化)
}

export class RadarSeries {
  name: string = '';     // 系列名称(如"角色A")
  color: ResourceColor = '#3498db';
  values: number[] = []; // 各维度原始值,长度必须与dimensions一致
}

export class RadarDataModel {
  dimensions: RadarDimension[] = [];
  seriesList: RadarSeries[] = [];

  constructor(dims: RadarDimension[], series: RadarSeries[]) {
    this.dimensions = dims;
    this.seriesList = series;
    this.validate();
  }

  private validate(): void {
    const dimCount = this.dimensions.length;
    this.seriesList.forEach(s => {
      if (s.values.length !== dimCount) {
        console.error(`系列 ${s.name} 的数据长度与维度数量不匹配`);
      }
    });
  }

  // Min-Max标准化:将原始值映射到0~1区间
  getNormalizedValues(seriesIndex: number): number[] {
    const series = this.seriesList[seriesIndex];
    return series.values.map((val, idx) => {
      const max = this.dimensions[idx].maxValue;
      return max > 0 ? Math.min(val / max, 1.0) : 0;
    });
  }

  // 计算多边形顶点坐标
  getPolygonPoints(seriesIndex: number, centerX: number, centerY: number, 
                   radius: number): Array<{ x: number; y: number }> {
    const normalized = this.getNormalizedValues(seriesIndex);
    const n = this.dimensions.length;
    const points: Array<{ x: number; y: number }> = [];

    for (let i = 0; i < n; i++) {
      const angleDeg = i * (360 / n) - 90;
      const angleRad = (angleDeg * Math.PI) / 180;
      const distance = normalized[i] * radius;
      points.push({
        x: centerX + distance * Math.sin(angleRad),
        y: centerY + distance * Math.cos(angleRad)
      });
    }

    return points;
  }
}

设计要点

  • 每个维度独立设置 maxValue,支持不同量纲的数据(如"攻击力"满值100,"防御力"满值200)在同一图中对比;
  • getPolygonPoints() 将数据计算与绘制分离,便于单元测试和复用。

2.3 雷达图绘制层

// painter/RadarChartPainter.ets
import { RadarDataModel, RadarSeries } from '../model/RadarDataModel';

export class RadarChartPainter {
  private ctx: CanvasRenderingContext2D;
  private width: number = 0;
  private height: number = 0;
  private centerX: number = 0;
  private centerY: number = 0;
  private radius: number = 0;

  // 配置
  private config = {
    gridLevels: 5,        // 网格层数
    axisColor: '#e0e0e0',
    gridColor: '#f0f0f0',
    labelColor: '#555',
    labelFont: '12px sans-serif'
  };

  constructor(context: CanvasRenderingContext2D) {
    this.ctx = context;
  }

  setSize(width: number, height: number): void {
    this.width = width;
    this.height = height;
    this.centerX = width / 2;
    this.centerY = height / 2;
    this.radius = Math.min(width, height) / 2 - 50; // 预留标签空间
  }

  draw(model: RadarDataModel, animationProgress: number = 1.0,
       selectedSeriesIndex: number = -1): void {
    this.ctx.clearRect(0, 0, this.width, this.height);

    this.drawGrid(model);
    this.drawAxes(model);
    this.drawLabels(model);

    // 绘制每个系列的多边形
    model.seriesList.forEach((series, index) => {
      const isSelected = index === selectedSeriesIndex;
      this.drawSeriesPolygon(model, index, animationProgress, isSelected);
    });
  }

  // 绘制网格(同心多边形)
  private drawGrid(model: RadarDataModel): void {
    const n = model.dimensions.length;
    const angles = Array.from({ length: n }, (_, i) => 
      (i * (360 / n) - 90) * Math.PI / 180
    );
    angles.push(angles[0]); // 闭合

    this.ctx.strokeStyle = this.config.gridColor;
    this.ctx.lineWidth = 1;

    for (let level = 1; level <= this.config.gridLevels; level++) {
      const r = (this.radius * level) / this.config.gridLevels;
      this.ctx.beginPath();
      angles.forEach((angle, idx) => {
        const x = this.centerX + r * Math.sin(angle);
        const y = this.centerY + r * Math.cos(angle);
        if (idx === 0) this.ctx.moveTo(x, y);
        else this.ctx.lineTo(x, y);
      });
      this.ctx.closePath();
      this.ctx.stroke();
    }
  }

  // 绘制轴线(从中心到各维度方向)
  private drawAxes(model: RadarDataModel): void {
    const n = model.dimensions.length;
    this.ctx.strokeStyle = this.config.axisColor;
    this.ctx.lineWidth = 1;

    for (let i = 0; i < n; i++) {
      const angleRad = (i * (360 / n) - 90) * Math.PI / 180;
      const x = this.centerX + this.radius * Math.sin(angleRad);
      const y = this.centerY + this.radius * Math.cos(angleRad);

      this.ctx.beginPath();
      this.ctx.moveTo(this.centerX, this.centerY);
      this.ctx.lineTo(x, y);
      this.ctx.stroke();
    }
  }

  // 绘制维度标签
  private drawLabels(model: RadarDataModel): void {
    const n = model.dimensions.length;
    this.ctx.font = this.config.labelFont;
    this.ctx.fillStyle = this.config.labelColor;
    this.ctx.textAlign = 'center';
    this.ctx.textBaseline = 'middle';

    for (let i = 0; i < n; i++) {
      const angleRad = (i * (360 / n) - 90) * Math.PI / 180;
      const labelRadius = this.radius + 25;
      const x = this.centerX + labelRadius * Math.sin(angleRad);
      const y = this.centerY + labelRadius * Math.cos(angleRad);
      this.ctx.fillText(model.dimensions[i].name, x, y);
    }
  }

  // 绘制单个系列的多边形
  private drawSeriesPolygon(model: RadarDataModel, seriesIndex: number,
                            progress: number, isSelected: boolean): void {
    const series = model.seriesList[seriesIndex];
    const points = model.getPolygonPoints(seriesIndex, this.centerX, this.centerY, 
                                          this.radius * progress);

    if (points.length === 0) return;

    const color = series.color as string;

    // 构建路径
    this.ctx.beginPath();
    points.forEach((p, idx) => {
      if (idx === 0) this.ctx.moveTo(p.x, p.y);
      else this.ctx.lineTo(p.x, p.y);
    });
    this.ctx.closePath();

    // 填充(选中时增加不透明度)
    this.ctx.fillStyle = color;
    this.ctx.globalAlpha = isSelected ? 0.35 : 0.2;
    this.ctx.fill();
    this.ctx.globalAlpha = 1.0;

    // 描边
    this.ctx.strokeStyle = color;
    this.ctx.lineWidth = isSelected ? 3 : 2;
    this.ctx.stroke();

    // 绘制顶点
    points.forEach(p => {
      this.ctx.beginPath();
      this.ctx.arc(p.x, p.y, isSelected ? 5 : 4, 0, Math.PI * 2);
      this.ctx.fillStyle = '#ffffff';
      this.ctx.fill();
      this.ctx.strokeStyle = color;
      this.ctx.lineWidth = 2;
      this.ctx.stroke();
    });
  }

  // 点击检测:判断触摸点距离哪个系列的多边形最近
  hitTest(touchX: number, touchY: number, model: RadarDataModel): number {
    let closestIndex = -1;
    let minDistance = Infinity;

    model.seriesList.forEach((_, index) => {
      const points = model.getPolygonPoints(index, this.centerX, this.centerY, this.radius);

      // 计算点到多边形各边的最短距离
      for (let i = 0; i < points.length; i++) {
        const p1 = points[i];
        const p2 = points[(i + 1) % points.length];
        const dist = this.pointToLineDistance(touchX, touchY, p1.x, p1.y, p2.x, p2.y);
        if (dist < minDistance) {
          minDistance = dist;
          closestIndex = index;
        }
      }

      // 同时检查是否接近某个顶点(更直观的交互)
      points.forEach(p => {
        const vertexDist = Math.sqrt(Math.pow(touchX - p.x, 2) + Math.pow(touchY - p.y, 2));
        if (vertexDist < 20 && vertexDist < minDistance) {
          minDistance = vertexDist;
          closestIndex = index;
        }
      });
    });

    return minDistance < 40 ? closestIndex : -1;
  }

  // 点到线段的最短距离
  private pointToLineDistance(px: number, py: number, x1: number, y1: number,
                               x2: number, y2: number): number {
    const A = px - x1;
    const B = py - y1;
    const C = x2 - x1;
    const D = y2 - y1;
    const dot = A * C + B * D;
    const lenSq = C * C + D * D;
    let param = -1;
    if (lenSq !== 0) param = dot / lenSq;

    let xx: number, yy: number;
    if (param < 0) { xx = x1; yy = y1; }
    else if (param > 1) { xx = x2; yy = y2; }
    else { xx = x1 + param * C; yy = y1 + param * D; }

    return Math.sqrt(Math.pow(px - xx, 2) + Math.pow(py - yy, 2));
  }
}

技术亮点

  • pointToLineDistance() 实现了点到线段的精确距离计算(而非无限长直线),这是判断用户点击哪个系列的关键算法;
  • 多边形顶点使用"白底彩边"的圆点样式,在重叠的多系列场景中仍能清晰辨识;
  • 选中系列通过增加描边宽度和填充不透明度实现视觉强调,无需复杂动画即可传达状态变化。

三、散点图:从坐标映射到回归分析

3.1 核心难点分析

散点图看似简单——在直角坐标系中绘制一系列点——但生产级实现涉及多个技术挑战:

  1. 坐标映射:数据值域到像素坐标的线性变换,需处理负值、小数、以及自动计算合适的坐标轴范围;
  2. 回归线拟合:使用最小二乘法(Least Squares Method)计算趋势线,揭示变量间的线性关系;
  3. 气泡映射:将第三维度数据(如销售额、权重)映射为点的半径大小;
  4. 四象限分析:以均值点为原点划分四个象限,辅助业务决策。

在这里插入图片描述


3.2 数据模型层

// model/ScatterDataModel.ets
export class ScatterPoint {
  x: number = 0;           // X轴数值
  y: number = 0;           // Y轴数值
  size: number = 5;        // 气泡大小(第三维度)
  color: ResourceColor = '#3498db';  // 颜色(可映射第四维度分类)
  label: string = '';      // 数据标签
}

export class ScatterDataModel {
  points: ScatterPoint[] = [];

  // 自动计算的坐标轴范围
  xMin: number = 0;
  xMax: number = 0;
  yMin: number = 0;
  yMax: number = 0;

  constructor(points: ScatterPoint[]) {
    this.points = points;
    this.calculateRanges();
  }

  private calculateRanges(): void {
    if (this.points.length === 0) return;

    const xValues = this.points.map(p => p.x);
    const yValues = this.points.map(p => p.y);

    this.xMin = Math.min(...xValues);
    this.xMax = Math.max(...xValues);
    this.yMin = Math.min(...yValues);
    this.yMax = Math.max(...yValues);

    // 增加10%边距,避免点贴边
    const xPadding = (this.xMax - this.xMin) * 0.1;
    const yPadding = (this.yMax - this.yMin) * 0.1;
    this.xMin -= xPadding;
    this.xMax += xPadding;
    this.yMin -= yPadding;
    this.yMax += yPadding;

    // 确保最小范围不为0(避免除零)
    if (this.xMax === this.xMin) { this.xMin -= 1; this.xMax += 1; }
    if (this.yMax === this.yMin) { this.yMin -= 1; this.yMax += 1; }
  }

  // 最小二乘法线性回归:y = slope * x + intercept
  getRegressionLine(): { slope: number; intercept: number; r2: number } | null {
    if (this.points.length < 2) return null;

    const n = this.points.length;
    const sumX = this.points.reduce((s, p) => s + p.x, 0);
    const sumY = this.points.reduce((s, p) => s + p.y, 0);
    const sumXY = this.points.reduce((s, p) => s + p.x * p.y, 0);
    const sumX2 = this.points.reduce((s, p) => s + p.x * p.x, 0);
    const sumY2 = this.points.reduce((s, p) => s + p.y * p.y, 0);

    const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
    const intercept = (sumY - slope * sumX) / n;

    // 计算R²(决定系数)
    const yMean = sumY / n;
    const ssTotal = this.points.reduce((s, p) => s + Math.pow(p.y - yMean, 2), 0);
    const ssResidual = this.points.reduce((s, p) => {
      const predicted = slope * p.x + intercept;
      return s + Math.pow(p.y - predicted, 2);
    }, 0);
    const r2 = 1 - (ssResidual / ssTotal);

    return { slope, intercept, r2 };
  }

  // 数据值 → 像素坐标的映射
  mapToPixel(value: number, min: number, max: number, pixelMin: number, 
             pixelMax: number): number {
    return pixelMin + ((value - min) / (max - min)) * (pixelMax - pixelMin);
  }
}

设计要点

  • calculateRanges() 自动计算坐标轴范围并增加10%边距,避免数据点紧贴边界;
  • getRegressionLine() 实现了完整的最小二乘法计算,包括斜率、截距和决定系数R²,R²越接近1表示线性相关性越强;
  • 所有数据映射通过统一的 mapToPixel() 方法处理,确保X轴和Y轴的变换逻辑一致。

3.3 散点图绘制层

// painter/ScatterChartPainter.ets
import { ScatterDataModel, ScatterPoint } from '../model/ScatterDataModel';

export class ScatterChartPainter {
  private ctx: CanvasRenderingContext2D;
  private width: number = 0;
  private height: number = 0;

  // 绘制区域(扣除边距)
  private plotArea = { left: 50, right: 30, top: 30, bottom: 50 };
  private plotWidth: number = 0;
  private plotHeight: number = 0;

  constructor(context: CanvasRenderingContext2D) {
    this.ctx = context;
  }

  setSize(width: number, height: number): void {
    this.width = width;
    this.height = height;
    this.plotWidth = width - this.plotArea.left - this.plotArea.right;
    this.plotHeight = height - this.plotArea.top - this.plotArea.bottom;
  }

  draw(model: ScatterDataModel, animationProgress: number = 1.0,
       showRegression: boolean = true, showQuadrant: boolean = true,
       selectedIndex: number = -1): void {
    this.ctx.clearRect(0, 0, this.width, this.height);

    this.drawAxes(model);

    if (showQuadrant) {
      this.drawQuadrantLines(model);
    }

    if (showRegression) {
      this.drawRegressionLine(model);
    }

    this.drawPoints(model, animationProgress, selectedIndex);
    this.drawAxisLabels(model);
  }

  // 绘制坐标轴
  private drawAxes(model: ScatterDataModel): void {
    this.ctx.strokeStyle = '#ccc';
    this.ctx.lineWidth = 1.5;

    // X轴
    this.ctx.beginPath();
    this.ctx.moveTo(this.plotArea.left, this.plotArea.top + this.plotHeight);
    this.ctx.lineTo(this.plotArea.left + this.plotWidth, this.plotArea.top + this.plotHeight);
    this.ctx.stroke();

    // Y轴
    this.ctx.beginPath();
    this.ctx.moveTo(this.plotArea.left, this.plotArea.top);
    this.ctx.lineTo(this.plotArea.left, this.plotArea.top + this.plotHeight);
    this.ctx.stroke();
  }

  // 绘制四象限分割线(以均值点为界)
  private drawQuadrantLines(model: ScatterDataModel): void {
    const xMean = model.points.reduce((s, p) => s + p.x, 0) / model.points.length;
    const yMean = model.points.reduce((s, p) => s + p.y, 0) / model.points.length;

    const xPixel = model.mapToPixel(xMean, model.xMin, model.xMax, 
                                     this.plotArea.left, 
                                     this.plotArea.left + this.plotWidth);
    const yPixel = model.mapToPixel(yMean, model.yMin, model.yMax,
                                     this.plotArea.top + this.plotHeight,
                                     this.plotArea.top);

    this.ctx.strokeStyle = '#ddd';
    this.ctx.lineWidth = 1;
    this.ctx.setLineDash([5, 5]);

    // 垂直分割线
    this.ctx.beginPath();
    this.ctx.moveTo(xPixel, this.plotArea.top);
    this.ctx.lineTo(xPixel, this.plotArea.top + this.plotHeight);
    this.ctx.stroke();

    // 水平分割线
    this.ctx.beginPath();
    this.ctx.moveTo(this.plotArea.left, yPixel);
    this.ctx.lineTo(this.plotArea.left + this.plotWidth, yPixel);
    this.ctx.stroke();

    this.ctx.setLineDash([]);
  }

  // 绘制回归线
  private drawRegressionLine(model: ScatterDataModel): void {
    const reg = model.getRegressionLine();
    if (!reg) return;

    const x1 = model.xMin;
    const y1 = reg.slope * x1 + reg.intercept;
    const x2 = model.xMax;
    const y2 = reg.slope * x2 + reg.intercept;

    const px1 = model.mapToPixel(x1, model.xMin, model.xMax, 
                                  this.plotArea.left, 
                                  this.plotArea.left + this.plotWidth);
    const py1 = model.mapToPixel(y1, model.yMin, model.yMax,
                                  this.plotArea.top + this.plotHeight,
                                  this.plotArea.top);
    const px2 = model.mapToPixel(x2, model.xMin, model.xMax,
                                  this.plotArea.left,
                                  this.plotArea.left + this.plotWidth);
    const py2 = model.mapToPixel(y2, model.yMin, model.yMax,
                                  this.plotArea.top + this.plotHeight,
                                  this.plotArea.top);

    this.ctx.strokeStyle = '#9b59b6';
    this.ctx.lineWidth = 2.5;
    this.ctx.setLineDash([8, 4]);
    this.ctx.beginPath();
    this.ctx.moveTo(px1, py1);
    this.ctx.lineTo(px2, py2);
    this.ctx.stroke();
    this.ctx.setLineDash([]);

    // 标注回归方程
    this.ctx.fillStyle = '#9b59b6';
    this.ctx.font = 'bold 11px sans-serif';
    this.ctx.fillText(`y=${reg.slope.toFixed(2)}x+${reg.intercept.toFixed(1)} R²=${reg.r2.toFixed(3)}`,
                      this.plotArea.left + 10, this.plotArea.top + 15);
  }

  // 绘制散点(支持气泡大小映射)
  private drawPoints(model: ScatterDataModel, progress: number, 
                     selectedIndex: number): void {
    model.points.forEach((point, index) => {
      const x = model.mapToPixel(point.x, model.xMin, model.xMax,
                                  this.plotArea.left,
                                  this.plotArea.left + this.plotWidth);
      const y = model.mapToPixel(point.y, model.yMin, model.yMax,
                                  this.plotArea.top + this.plotHeight,
                                  this.plotArea.top);

      // 动画:从中心点向外扩散出现
      const centerX = this.plotArea.left + this.plotWidth / 2;
      const centerY = this.plotArea.top + this.plotHeight / 2;
      const animX = centerX + (x - centerX) * progress;
      const animY = centerY + (y - centerY) * progress;

      const isSelected = index === selectedIndex;
      const radius = point.size * (isSelected ? 1.3 : 1.0) * progress;

      // 绘制气泡
      this.ctx.beginPath();
      this.ctx.arc(animX, animY, radius, 0, Math.PI * 2);
      this.ctx.fillStyle = point.color as string;
      this.ctx.globalAlpha = 0.7;
      this.ctx.fill();
      this.ctx.globalAlpha = 1.0;

      // 描边
      this.ctx.strokeStyle = isSelected ? '#1a1a2e' : '#ffffff';
      this.ctx.lineWidth = isSelected ? 3 : 1.5;
      this.ctx.stroke();

      // 选中时显示标签
      if (isSelected && point.label) {
        this.ctx.fillStyle = '#1a1a2e';
        this.ctx.font = 'bold 11px sans-serif';
        this.ctx.textAlign = 'center';
        this.ctx.fillText(point.label, animX, animY - radius - 8);
      }
    });
  }

  // 绘制坐标轴刻度标签
  private drawAxisLabels(model: ScatterDataModel): void {
    this.ctx.fillStyle = '#888';
    this.ctx.font = '10px sans-serif';
    this.ctx.textAlign = 'center';
    this.ctx.textBaseline = 'top';

    // X轴刻度(5档)
    for (let i = 0; i <= 4; i++) {
      const value = model.xMin + (model.xMax - model.xMin) * (i / 4);
      const x = model.mapToPixel(value, model.xMin, model.xMax,
                                  this.plotArea.left,
                                  this.plotArea.left + this.plotWidth);
      this.ctx.fillText(value.toFixed(0), x, this.plotArea.top + this.plotHeight + 8);
    }

    // Y轴刻度
    this.ctx.textAlign = 'right';
    this.ctx.textBaseline = 'middle';
    for (let i = 0; i <= 4; i++) {
      const value = model.yMin + (model.yMax - model.yMin) * (i / 4);
      const y = model.mapToPixel(value, model.yMin, model.yMax,
                                  this.plotArea.top + this.plotHeight,
                                  this.plotArea.top);
      this.ctx.fillText(value.toFixed(0), this.plotArea.left - 8, y);
    }
  }

  // 点击检测:找到距离触摸点最近的散点
  hitTest(touchX: number, touchY: number, model: ScatterDataModel): number {
    let closestIndex = -1;
    let minDistance = Infinity;

    model.points.forEach((point, index) => {
      const x = model.mapToPixel(point.x, model.xMin, model.xMax,
                                  this.plotArea.left,
                                  this.plotArea.left + this.plotWidth);
      const y = model.mapToPixel(point.y, model.yMin, model.yMax,
                                  this.plotArea.top + this.plotHeight,
                                  this.plotArea.top);
      const dist = Math.sqrt(Math.pow(touchX - x, 2) + Math.pow(touchY - y, 2));

      if (dist < minDistance && dist < point.size + 10) {
        minDistance = dist;
        closestIndex = index;
      }
    });

    return closestIndex;
  }
}

技术亮点

  • 散点入场动画采用"从中心向外扩散"的效果,而非简单的淡入,增强视觉层次感;
  • 气泡大小通过 point.size 直接映射为圆半径,支持第三维度数据的可视化;
  • 回归线使用虚线样式,与数据点形成视觉区分,同时标注R²值帮助用户判断拟合优度。

四、动画与交互设计

4.1 雷达图多边形展开动画

雷达图的入场动画是**多边形从中心向外"生长"**的过程——所有顶点同时从原点沿各自轴线延伸到目标位置。

// 在RadarChart组件中集成动画
startEntranceAnimation(): void {
  this.animator = animator.create({
    duration: 1000,
    easing: 'ease-out-cubic',
    fill: 'forwards',
    begin: 0,
    end: 100
  });

  this.animator.onFrame = (value: number) => {
    this.animationProgress = value / 100;
    // 使用cubic缓动:progress = 1 - (1-t)^3
    const eased = 1 - Math.pow(1 - this.animationProgress, 3);
    this.painter.draw(this.dataModel, eased, this.selectedSeriesIndex);
  };

  this.animator.play();
}

4.2 散点图渐入动画

散点图的动画策略与雷达图不同:由于散点之间无连接关系,采用从画布中心向外扩散的入场方式,营造"数据涌现"的视觉效果。

// 在ScatterChart组件中
startScatterAnimation(): void {
  this.animator = animator.create({
    duration: 1200,
    easing: 'ease-out',
    fill: 'forwards',
    begin: 0,
    end: 100
  });

  this.animator.onFrame = (value: number) => {
    this.animationProgress = value / 100;
    // 散点图使用线性缓动即可,因为每个点的运动轨迹独立
    this.painter.draw(this.dataModel, this.animationProgress, 
                      this.showRegression, this.showQuadrant, 
                      this.selectedIndex);
  };

  this.animator.play();
}

五、性能优化策略

5.1 雷达图优化

  • 网格缓存:同心多边形网格在数据不变时完全静态,可预渲染到离屏Canvas;
  • 顶点LOD:当维度数量超过12时,隐藏顶点圆点,仅保留多边形轮廓,避免视觉混乱;
  • 系列裁剪:未选中的系列降低不透明度至0.1,而非完全隐藏,保持上下文感知。

5.2 散点图优化

  • 大数据量降级:当点数超过500时,关闭气泡大小映射(统一使用固定半径),并将回归线计算移至Web Worker异步执行;
  • 碰撞检测优化:使用空间哈希(Spatial Hash)或四叉树(QuadTree)加速点击检测,将O(n)复杂度降至O(log n);
  • 局部重绘:选中某个散点时,仅重绘该点及其周围区域,而非整个画布。

六、完整使用示例

// pages/ChartDemo.ets
import { RadarChart } from '../components/chart/RadarChart';
import { ScatterChart } from '../components/chart/ScatterChart';
import { RadarDataModel, RadarDimension, RadarSeries } from '../components/chart/model/RadarDataModel';
import { ScatterDataModel, ScatterPoint } from '../components/chart/model/ScatterDataModel';

@Entry
@Component
struct ChartDemo {
  // 雷达图数据:游戏角色能力对比
  private radarData: RadarDataModel = new RadarDataModel(
    [
      { name: '攻击', maxValue: 100 },
      { name: '防御', maxValue: 100 },
      { name: '速度', maxValue: 100 },
      { name: '技巧', maxValue: 100 },
      { name: '耐力', maxValue: 100 },
      { name: '暴击', maxValue: 100 }
    ],
    [
      { name: '战士', color: '#FF6B6B', values: [90, 50, 80, 70, 40, 85] },
      { name: '坦克', color: '#45B7D1', values: [40, 90, 30, 50, 95, 20] },
      { name: '刺客', color: '#2ecc71', values: [60, 40, 95, 90, 30, 70] }
    ]
  );

  // 散点图数据:广告投入 vs 销售额(气泡大小=利润)
  private scatterData: ScatterDataModel = new ScatterDataModel([
    { x: 20, y: 35, size: 8, color: '#FF6B6B', label: 'Q1' },
    { x: 35, y: 55, size: 12, color: '#4ECDC4', label: 'Q2' },
    { x: 50, y: 48, size: 10, color: '#45B7D1', label: 'Q3' },
    { x: 65, y: 72, size: 15, color: '#96CEB4', label: 'Q4' },
    { x: 80, y: 68, size: 14, color: '#FFEAA7', label: 'Q5' },
    { x: 45, y: 60, size: 11, color: '#FF6B6B', label: 'Q6' },
    { x: 30, y: 42, size: 9, color: '#4ECDC4', label: 'Q7' },
    { x: 70, y: 80, size: 16, color: '#45B7D1', label: 'Q8' }
  ]);

  build() {
    Scroll() {
      Column({ space: 24 }) {
        Text('HarmonyOS 图表组件实战')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1a1a2e')
          .margin({ top: 24 })

        // 雷达图区域
        Column() {
          Text('角色能力雷达对比')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333')
            .margin({ bottom: 8 })

          RadarChart({ dataModel: this.radarData })
            .height(320)
        }
        .width('100%')
        .padding(16)
        .backgroundColor('#ffffff')
        .borderRadius(12)
        .margin({ left: 16, right: 16 })

        // 散点图区域
        Column() {
          Text('广告投入与销售额关联分析')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333')
            .margin({ bottom: 8 })

          ScatterChart({ 
            dataModel: this.scatterData,
            showRegression: true,
            showQuadrant: true 
          })
            .height(320)
        }
        .width('100%')
        .padding(16)
        .backgroundColor('#ffffff')
        .borderRadius(12)
        .margin({ left: 16, right: 16 })

        Text('点击图表元素查看交互效果')
          .fontSize(12)
          .fontColor('#999')
          .margin({ bottom: 30 })
      }
      .width('100%')
    }
    .backgroundColor('#f5f6fa')
  }
}

七、运行效果预览

在这里插入图片描述

上图展示了两个组件的最终效果:

  • 左侧雷达图:三个游戏角色的六维能力对比,多边形面积直观反映综合强度差异,顶点圆点便于精确定位;
  • 右侧散点图:随机分布的数据点带有气泡大小差异,虚线回归线揭示X/Y变量的正相关趋势,四象限分割线辅助业务分析。

八、三种图表的技术对比总结

维度 柱状图(第120篇) 饼图/环形图(第121篇) 雷达图/散点图(本篇)
坐标系 直角坐标系 极坐标系(扇区) 雷达:极坐标;散点:直角坐标
核心图形 矩形柱体 圆弧扇区 雷达:闭合多边形;散点:圆点
数学难点 高度映射 角度分配、极坐标转换 雷达:多边形顶点计算;散点:回归线拟合
动画类型 高度增长 角度展开+半径扩展 雷达:多边形生长;散点:中心扩散
交互检测 X轴区间 极坐标(距离+角度) 雷达:点到线段距离;散点:圆点碰撞
适用数据 分类数值对比 占比分布 雷达:多维能力评估;散点:双变量相关性
扩展维度 分组/堆叠 多层环形 雷达:多系列叠加;散点:气泡大小映射

九、总结与扩展

在这里插入图片描述

本文同时攻克了雷达图和散点图两个高复杂度组件,形成了完整的HarmonyOS图表组件 trilogy(柱状图→饼图→雷达/散点)。核心技术收获:

  1. 雷达图:掌握极坐标系下多边形顶点计算、同心网格绘制、以及点到线段的精确距离检测;
  2. 散点图:实现自动坐标范围计算、最小二乘法回归线拟合、四象限分析、以及气泡大小对第三维度的映射;
  3. 统一架构:三种图表共享相同的五层架构(数据/绘制/动画/交互/样式),证明了良好抽象设计的复用价值。

后续扩展方向

  • 折线图/面积图:基于直角坐标系,复用散点图的坐标映射逻辑,增加路径连接和填充;
  • K线图(蜡烛图):金融数据专用,需要支持开盘价/收盘价/最高价/最低价的四元数据绘制;
  • 热力图:二维矩阵数据的颜色映射,适用于用户行为分析、地理数据可视化;
  • 图表联动:多个图表共享同一数据集,实现"点击柱状图月份→饼图更新该月品类占比→散点图显示该月广告数据"的联动分析。

转载自:https://blog.csdn.net/u014727709/article/details/163449756
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐