轻规划鸿蒙开发实战12:人生平衡度雷达图与拖动平衡引擎,自研 Path 路径高精绘制与交互联动

背景介绍

《只管去做》理论指出,真正科学的人生管理必须是“平衡”的。如果你把 100% 的精力都投入在“工作事业”和“财务理财”上,而导致“健康身体”和“家庭生活”象限归零,这绝对是一个失败且不可持续的规划。

轻规划鸿蒙开发实战12:人生平衡度雷达图与拖动平衡引擎,自研 Path 路径高精绘制与交互联动.png

因此,“轻规划”(AeroPlan)在愿景看板下方提供了一个自研的 360° 人生平衡度雷达图(Radar Chart)

传统的图表库(如 ECharts)在移动端大多需要通过 WebView 桥接,无法进行细腻的、毫秒级流畅的拖拽交互。WebView 的通信桥接机制(JSBridge)在处理高频的手势操作时会引入不可避免的序列化与反序列化延迟,导致渲染帧率低下,且容易产生多端手势冲突。此外,在系统侧,WebView 的多线程调度也更容易引发内存泄漏和稳定性风险。

为了极致的交互反馈,我们设计了一个可以直接用手指拖动雷达图顶点来调整计划权重的交互引擎。当用户在大屏幕上用手指拖动“健康身体”象限的顶点往外拉时,雷达多边形实时变形,且下方的九宫格项目指标、精力分配权重会同步联动重算。

今天,我们将从极坐标几何计算、Path 路径渲染,到触摸碰撞逆投影算法,进行一次纯数学与渲染结合的硬核技术复盘。


1. 架构纵览:雷达图坐标计算与拖拽交互反馈管线

拖拽雷达图改变权重,本质上是一个**“屏幕像素坐标(直角坐标系)”到“雷达多边形顶点(极坐标系)”的逆投影换算与碰撞检测**过程。职责划分如下:

![[轻规划鸿蒙开发实战12:人生平衡度雷达图与拖动平衡引擎,自研 Path 路径高精绘制与交互联动-1.jpeg]]

交互数据管道流转机制

整个系统采用闭环流转架构,从用户物理触摸开始,到最终重绘画面,链路时延控制在 16ms 以内(即 60Hz 满帧刷新率要求)。

匹配成功, 锁定顶点索引

直角坐标转极坐标

更新状态并截流防御边界

触发 ArkUI Canvas 重绘

AppStorage 全局同步

用户触摸屏幕 TouchEvent

直角坐标提取 px, py

碰撞检测器 - 计算欧氏距离

动态拖拽投影器

计算新权重比 newValue

dimensions 状态变更

渲染引擎 drawRadar

外部业务组件联动

屏幕呈现最新雷达图

这套架构核心在于单向数据流与双向坐标投影的闭环联动。在初始化阶段,数据由模型层驱动渲染引擎生成多边形;在用户交互阶段,则通过逆向几何换算将屏幕点映射回模型数据,最后再次触发渲染,形成直觉式反馈循环。


2. 数学基石:360° 极坐标绘制公式

雷达图由 8 个轴向均分 360 度圆周组成,每个轴对应的弧度增量为:

θ i = i × 2 π 8 = i × π 4 \theta_i = i \times \frac{2\pi}{8} = i \times \frac{\pi}{4} θi=i×82π=i×4π

在屏幕直角坐标系中,设雷达图圆心为 ( c x , c y ) (cx, cy) (cx,cy),某象限权重大值为 R m a x R_{max} Rmax,当前实际权重比例为 v a l u e i value_i valuei 0.0 ∼ 1.0 0.0 \sim 1.0 0.01.0),则该象限顶点的物理像素坐标 ( x i , y i ) (x_i, y_i) (xi,yi) 换算公式为:

x i = c x + R m a x × v a l u e i × cos ⁡ ( θ i ) x_i = cx + R_{max} \times value_i \times \cos(\theta_i) xi=cx+Rmax×valuei×cos(θi)

y i = c y + R m a x × v a l u e i × sin ⁡ ( θ i ) y_i = cy + R_{max} \times value_i \times \sin(\theta_i) yi=cy+Rmax×valuei×sin(θi)

在实际绘制中,因为 Canvas 的 Y 轴向下,而传统的极坐标 Y 轴向上,所以我们需要在弧度计算和坐标投影时,对顺时针/逆时针方向有精确的把控。

坐标系空间映射对比
坐标系属性 直角坐标系 (Canvas 物理空间) 极坐标系 (业务权重空间)
基础参数 横坐标 X X X,纵坐标 Y Y Y 半径 r r r (权重值),角度 θ \theta θ (象限弧度)
原点位置 组件左上角 ( 0 , 0 ) (0, 0) (0,0) 雷达图中心点 ( c x , c y ) (cx, cy) (cx,cy)
方向规则 X X X 轴向右递增, Y Y Y 轴向下递增 顺时针旋转,由首个象限轴开始均分
应用场景 触摸点捕获、屏幕像素绘制描边 权重比例变更、数值持久化保存

我们在 ArkUI Canvas 绘制模块中将其转化为 Path 路径:

// 定义雷达图的维度接口
export interface RadarDimension {
  name: string;   // 维度名称(如健康、工作等)
  value: number;  // 维度对应的权重值,范围限定在 0.0 到 1.0 之间
}

// 极坐标与直角坐标互转辅助工具类
export class RadarMathHelper {
  /**
   * 将极坐标(半径和角度)转换为 Canvas 直角坐标系中的物理坐标
   * @param cx 圆心 X 坐标
   * @param cy 圆心 Y 坐标
   * @param radius 雷达图最大外接圆半径
   * @param angleRad 当前维度轴对应的弧度值
   * @param value 维度的权重比例(0.0 ~ 1.0)
   * @returns 物理坐标对象 { x, y }
   */
  public static getPointCoordinate(
    cx: number, 
    cy: number, 
    radius: number, 
    angleRad: number, 
    value: number
  ): { x: number, y: number } {
    return {
      // x = cx + R * cos(theta),计算在 X 轴上的投影坐标
      x: cx + radius * value * Math.cos(angleRad),
      // y = cy + R * sin(theta),计算在 Y 轴上的投影坐标
      y: cy + radius * value * Math.sin(angleRad)
    };
  }
}

3. 碰撞检测与逆投影拖拽引擎实现

为了实现手指拖动,我们必须在 onTouch 回调中,高频检测触点与哪个象限轴的实际顶点在欧氏距离上最接近。

碰撞判定与逆投影数学原理

当手指触摸在 ( p x , p y ) (px, py) (px,py) 点时,由于雷达图有多个顶点,我们需要逐一计算触点与各象限当前顶点之间的欧氏距离 D i D_i Di

D i = ( p t i . x − p x ) 2 + ( p t i . y − p y ) 2 D_i = \sqrt{(pt_i.x - px)^2 + (pt_i.y - py)^2} Di=(pti.xpx)2+(pti.ypy)2

D i D_i Di 小于我们设定的碰撞死区半径 m i n D i s t a n c e minDistance minDistance 时,判定成功,并锁定该维度索引 a c t i v e D i m e n s i o n I n d e x = i activeDimensionIndex = i activeDimensionIndex=i

在拖拽移动过程中(TouchType.Move),我们要根据手指当前位置计算新的权重。将手指到圆心的距离映射为权重比:

r t o u c h = ( p x − c x ) 2 + ( p y − c y ) 2 r_{touch} = \sqrt{(px - cx)^2 + (py - cy)^2} rtouch=(pxcx)2+(pycy)2

n e w V a l u e = r t o u c h R m a x newValue = \frac{r_{touch}}{R_{max}} newValue=Rmaxrtouch

为了防止用户把多边形缩减到绝对零点,导致 8 个顶点重合在圆心而无法再次被拖开,我们必须引入下限容错截断机制,将其钳位在 [ 0.1 , 1.0 ] [0.1, 1.0] [0.1,1.0] 区间内,这也是系统界面稳定性的重要保障。

拖拽雷达图核心代码
@Component
export struct DynamicRadarChart {
  // 创建渲染上下文设置对象,开启反锯齿以保证高精度的 Path 线条边缘平滑
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  // 初始化 Canvas 2D 绘图上下文,用于底层的渲染管线操作
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  
  // 使用 @State 装饰器监听雷达图各维度的数据变更,数据改变时会自动触发 UI 更新
  @State dimensions: RadarDimension[] = [
    { name: "健康", value: 0.8 },
    { name: "工作", value: 0.7 },
    { name: "理财", value: 0.5 },
    { name: "人际", value: 0.6 },
    { name: "家庭", value: 0.7 },
    { name: "学习", value: 0.8 },
    { name: "突破", value: 0.4 },
    { name: "心灵", value: 0.9 }
  ];

  // 物理坐标参数,定义画布中心点、雷达图最大外接半径以及拖拽死区判定阈值
  private cx = 150;
  private cy = 150;
  private maxRadius = 100;
  private activeDimensionIndex = -1; // -1 表示当前未选中任何轴的顶点

  build() {
    Canvas(this.ctx)
      .width(300)
      .height(300)
      .onReady(() => {
        // 画布初始化就绪后,执行首次的雷达图渲染绘制
        this.drawRadar();
      })
      .onTouch((event: TouchEvent) => {
        // 绑定触摸事件监听器,接管 Down、Move、Up 交互手势
        this.handleTouch(event);
      })
  }

  /**
   * 处理 Canvas 组件上的所有触摸交互事件
   * 通过多态状态机设计,分别处理按下、拖动及释放三种触控状态
   */
  private handleTouch(event: TouchEvent) {
    // 安全获取当前的触摸触点
    if (!event.touches || event.touches.length === 0) {
      return;
    }
    const touch = event.touches[0];
    const px = touch.x;
    const py = touch.y;

    if (event.type === TouchType.Down) {
      // 1. 碰撞检测阶段:寻找距离用户触点最近的雷达图顶点
      this.activeDimensionIndex = -1;
      let minDistance = 25.0; // 交互判定死区半径,在 25px 像素范围内的触摸点才有效

      for (let i = 0; i < this.dimensions.length; i++) {
        // 计算每个维度在八边形中对应的均分弧度值
        const angle = i * Math.PI / 4;
        // 计算出当前维度顶点在 Canvas 画布上的物理直角坐标
        const pt = RadarMathHelper.getPointCoordinate(this.cx, this.cy, this.maxRadius, angle, this.dimensions[i].value);
        
        // 计算当前触点与顶点的几何欧氏距离
        const dx = pt.x - px;
        const dy = pt.y - py;
        const dist = Math.sqrt(dx * dx + dy * dy);

        // 如果距离小于当前的最小有效距离,则锁定当前象限轴顶点
        if (dist < minDistance) {
          minDistance = dist;
          this.activeDimensionIndex = i; // 缓存当前被选中的顶点索引,供 Move 事件消费
        }
      }
    } else if (event.type === TouchType.Move && this.activeDimensionIndex !== -1) {
      // 2. 交互联动阶段:根据手指的拖拽位置实时重算维度权重值
      const dx = px - this.cx;
      const dy = py - this.cy;
      // 计算手指触点到雷达图原点(中心点)的欧氏距离
      const distFromCenter = Math.sqrt(dx * dx + dy * dy);

      // 将该物理距离与雷达图最大外接圆半径做比值,换算为 0.0 ~ 1.0 的相对比率
      let newValue = distFromCenter / this.maxRadius;
      // 容错钳位机制:设置 0.1 下限和 1.0 上限,防止雷达多边形彻底塌缩无法再进行物理选中
      newValue = Math.max(0.1, Math.min(1.0, newValue));

      // 局部更新对应维度的数据模型,自动触发重绘及外部数据流向更新
      this.dimensions[this.activeDimensionIndex].value = newValue;
      this.drawRadar();
      
      // 同步数据至全局应用存储 AppStorage 中,驱动下层精力规划及目标管理组件进行 UI 同步联动
      AppStorage.setOrCreate('radar_dimensions_sync', this.dimensions.slice());
      
    } else if (event.type === TouchType.Up) {
      // 3. 释放锁定阶段:当用户手指抬起时,清空当前激活的顶点索引,防止后续指针漂移干扰
      this.activeDimensionIndex = -1; 
    }
  }

  /**
   * 执行高精度 Canvas Path 路径绘制渲染的核心引擎
   * 包括底层背景网格绘制、实际权重多边形色块渲染以及大头针手柄渲染
   */
  private drawRadar() {
    // 每次重绘前清理画布的指定物理区域,防止历史残影残留
    this.ctx.clearRect(0, 0, 300, 300);

    // 1. 绘制雷达背景网格:通过 3 层同心嵌套八边形网格来辅助感知刻度
    for (let r = 1; r <= 3; r++) {
      const currentR = this.maxRadius * (r / 3); // 计算每一层背景网格的半径大小
      this.ctx.beginPath(); // 开启渲染上下文新路径描述
      for (let i = 0; i < 8; i++) {
        const angle = i * Math.PI / 4;
        // 获取当前层半径所对应的八边形顶点坐标
        const pt = RadarMathHelper.getPointCoordinate(this.cx, this.cy, currentR, angle, 1.0);
        if (i === 0) {
          this.ctx.moveTo(pt.x, pt.y); // 将画笔定位到首个顶点
        } else {
          this.ctx.lineTo(pt.x, pt.y); // 顺时针依次连线各个顶点
        }
      }
      this.ctx.closePath(); // 闭合路径,连接起止点
      this.ctx.strokeStyle = '#E2E2E2'; // 设置背景网格线条颜色
      this.ctx.lineWidth = 1; // 设置细线宽
      this.ctx.stroke(); // 触发底层的线条像素绘制
    }

    // 2. 绘制实际填充的多边形:表现用户各个生命象限的均衡分布情况
    this.ctx.beginPath(); // 开启新路径
    for (let i = 0; i < 8; i++) {
      const angle = i * Math.PI / 4;
      // 根据用户当前的维度权重值,动态投影对应的顶点直角坐标
      const pt = RadarMathHelper.getPointCoordinate(this.cx, this.cy, this.maxRadius, angle, this.dimensions[i].value);
      if (i === 0) {
        this.ctx.moveTo(pt.x, pt.y);
      } else {
        this.ctx.lineTo(pt.x, pt.y);
      }
    }
    this.ctx.closePath(); // 闭合权重折线区域
    
    // 填充高颜值半透明橙金色区域
    this.ctx.fillStyle = 'rgba(255, 165, 0, 0.25)'; // 设置 25% 半透明橙黄色
    this.ctx.fill(); // 填充多边形内部
    this.ctx.strokeStyle = '#FFA500'; // 设置边界描边颜色
    this.ctx.lineWidth = 2; // 加粗线条
    this.ctx.stroke(); // 边界描边

    // 3. 绘制 8 个顶点的实体大头针手柄,为用户提供清晰的手势拖动指引
    for (let i = 0; i < 8; i++) {
      const angle = i * Math.PI / 4;
      const pt = RadarMathHelper.getPointCoordinate(this.cx, this.cy, this.maxRadius, angle, this.dimensions[i].value);
      
      this.ctx.beginPath(); // 开启顶点圆形绘制路径
      // 绘制半径为 5px 的实体辅助圈圆
      this.ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
      this.ctx.fillStyle = '#FFFFFF'; // 白色圆心,提供视觉聚焦点
      this.ctx.fill();
      this.ctx.strokeStyle = '#FFA500'; // 橙色外圈边线
      this.ctx.lineWidth = 2;
      this.ctx.stroke();
    }
  }
}

运行效果如下:

轻规划鸿蒙开发实战12:人生平衡度雷达图与拖动平衡引擎,自研 Path 路径高精绘制与交互联动-1.png


4. 极客避坑:自适应容器缩放与 Touch 事件坐标偏移

在开发一多架构(一次开发多端部署)时,雷达图组件在大屏幕折叠屏或平板上会被拉大。如果我们在 onTouch 回调中直接读取 event.touches[0].x 这种绝对物理坐标,而没有对 Canvas 的渲染分辨率做等比缩放换算,拖拽手势就会发生严重的“位置漂移”。

避坑指南:视口比例因子换算

我们必须在 Canvas 触发 onAreaChangeonReady 时,实时计算出 Canvas 实际布局宽度(CSS 逻辑像素)与画布绘图属性(width/height)的比例因子(Scale Factor)。

下面的公式展示了当画布属性大小与容器大小不同时的缩放因子计算:

S c a l e X = C a n v a s W i d t h P r o p e r t y C o m p o n e n t W i d t h A c t u a l Scale_X = \frac{CanvasWidth_{Property}}{ComponentWidth_{Actual}} ScaleX=ComponentWidthActualCanvasWidthProperty

S c a l e Y = C a n v a s H e i g h t P r o p e r t y C o m p o n e n t H e i g h t A c t u a l Scale_Y = \frac{CanvasHeight_{Property}}{ComponentHeight_{Actual}} ScaleY=ComponentHeightActualCanvasHeightProperty

利用该比例因子,我们将触控事件中的坐标映射回画布渲染空间中,从而保证了多端自适应状态下的绝对精确对齐:

// 声明组件实例变量,用于缓存 Canvas 组件在屏幕上的实际像素尺寸
private actualWidth: number = 300;
private actualHeight: number = 300;

// 在 Canvas 的 onAreaChange 事件回调中获取其实时物理尺寸
// 该监听不仅在组件初次渲染时触发,当折叠屏展开折叠或屏幕方向切换时也会即时响应
.onAreaChange((oldValue: Area, newValue: Area) => {
  this.actualWidth = newValue.width as number;
  this.actualHeight = newValue.height as number;
})

// 在 handleTouch(event: TouchEvent) 中对触点物理坐标进行逆向映射换算
const touch = event.touches[0];

// 核心换算公式:将事件反馈的触点坐标,乘以 (画布设计大小 / 画布在界面的实际渲染大小)
const canvasScaleX = 300 / this.actualWidth;
const canvasScaleY = 300 / this.actualHeight;

// 经过该映射因子转换后的 px 与 py 坐标,将与画笔 ctx 调用的渲染空间高度重合,完美解决漂移问题
const px = touch.x * canvasScaleX;
const py = touch.y * canvasScaleY;

5. 性能深度调优:防抖截流与离屏 Canvas

由于 onTouch 事件在拖动时会以每秒高达 120 次的频率触发,每次触发都调用 drawRadar() 进行整图重绘会带来不必要的 CPU 与 GPU 负担,容易造成中低端机型的帧率抖动。我们可以通过以下手段优化性能:

  1. 数值变更阈值拦截:在更新维度权重值时,只有当数值的改变量超过 0.005 0.005 0.005 时,才触发画布重绘(drawRadar())。这样可以有效过滤手指微小颤动引起的无效重绘。
  2. 离屏渲染(Offscreen Canvas):雷达图底部的 3层嵌套八边形网格背景是静态不变的,无需每次交互都重新计算与绘制。我们可以利用离屏 Canvas 机制将背景八边形网格预先绘制在一张静态的离屏位图(ImageBitmap)上。在 drawRadar 时,只需通过 drawImage 接口将离屏背景直接贴到主画布上,即可节省大量的数学三角函数计算与路径连线操作。
  3. 数据同步截流(Throttle):使用 AppStorage 同步数据给外部组件时,利用 setTimeout 或事件队列机制进行截流限制,确保业务联动更新不会频繁抢占渲染主线程的计算资源。

6. 稳定性与不合规数据拦截

在接收拖动更新时,程序必须防御可能导致多边形不合规的异常边界情况,例如负值或异常浮点数。这有助于避免图表因异常数据发生形状失真、界面崩溃等稳定性风险。

在处理多维联动逻辑(例如修改其中一个象限导致其他象限发生补偿变化)时,由于采用了类似物理质点受力的算法,需要特别注意浮点数溢出及除零稳定性风险。我们必须通过严格的数值合法性检查和保护机制(如对分母加极小值 ϵ = 1 e − 6 \epsilon = 1e-6 ϵ=1e6)来确保计算稳定性。

同时,确保从 AppStorage 中读取的外部数据不涉及任何非法越权访问或越界注入,保障运行时状态一致性。


7. 总结与下期预告

通过极坐标投影几何变换与视口比例因子换算,我们自研了一套高保真、支持顶点拖拽碰撞感应的人生平衡雷达图,为目标管理的平衡调节提供了顶级的直觉交互。

雷达图展示了宏观平衡度,而要展示用户习惯沉淀的微观深度,我们需要一个仿 GitHub 的高颜值贡献热力图矩阵。

在下一篇文章中,我们将踏入海量小网格的渲染优化:自研 HabitHeatmapView 习惯热力图,高性能自定义绘制与离屏 Canvas 渲染调优! 敬请期待。在这里插入图片描述

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐