路线服务与地图渲染

Canvas 地图渲染

采用 Canvas 动态绘制方案 (非 MapComponent/Shape/Polyline),解决以下问题:

  • MapComponent 需要 HMS 服务,模拟器不可用
  • Shape/Polyline 在状态变化时不重绘
  • Canvas + Image 方案存在 660x360 和 320vp 的尺寸不匹配问题

渲染架构

Canvas(this.mapCanvas)
  .width('100%')
  .height(320)
  .backgroundColor('#F5F2EB')
  .onReady(() => {
    this.currentMapRoute = RouteService.getInstance().getRouteByType(routeType);
    this.drawRouteMap();
  })

CanvasRenderingContext2D 为 plain private 属性 (非 @State):

private mapCanvas: CanvasRenderingContext2D =
  new CanvasRenderingContext2D(new RenderingContextSettings(true));

geoToPixel 坐标转换

const geoToPixel = (lat: number, lng: number): number[] => {
  const px = PAD + drawW * (lng - (viewCenterLng - viewLngSpan / 2)) / viewLngSpan;
  const py = PAD + drawH * ((viewCenterLat + viewLatSpan / 2) - lat) / viewLatSpan;
  return [px, py];
};

关键:geoToPixel 在 drawRouteMap() 内部定义,读取 canvasContext.width/height,而非缓存在 aboutToAppear 中。

视图中心与范围

  • 视图中心 = 路线起点 (route.points[0])
  • 视图范围 = maxDistFromStart * 2 * 1.35 (1.35 为边距系数)
  • drawW/drawH = canvasSize - PAD * 2 (PAD = 50)

绘制层次 (从底到顶)

  1. 坐标网格: 0.005 度间隔,#E0DDD5 线条
  2. 坐标标签: 经纬度文本标注
  3. 地标: 9 个北京地标 (自动判断是否在视图内)
  4. 路线折线: 路线颜色 (#4CAF50/#2196F3/#FF9800) + 白色中心线
  5. 起点/终点标记: 绿色 S / 红色 E 圆形标记 (7px)
  6. 途经点: 3px 彩色圆点

Canvas 时序修复

问题: aboutToAppear 在 Canvas onReady 之前执行,此时 canvasContext.width/height 为 0。

解决: 不在 aboutToAppear 中缓存像素坐标,而是在 onReady 回调中调用 drawRouteMap(),drawRouteMap() 内部读取 canvasContext.width/height。

aboutToAppear()    →  canvasContext.width = 0  (未就绪)
Canvas.onReady()   →  canvasContext.width = 实际值  (就绪)
  └── drawRouteMap()  →  使用实际 width/height 绘制

标准跑路线

标准跑路线

地标数据

9 个北京核心地标,在地图视图内自动显示:

地标纬度经度
天安门39.9087116.3975
故宫39.9163116.3972
景山39.9210116.3935
北海39.9260116.3830
天坛39.8820116.3930
王府井39.9200116.4000
前门39.9050116.3970
鼓楼39.9280116.3860
什刹海39.9250116.3850

RouteService 服务架构

单例模式

RouteService 采用单例模式,确保全局只有一个路线数据实例:

class RouteService {
  private static instance: RouteService | null = null;
  private routes: RouteInfo[] = [];

  static getInstance(): RouteService {
    if (!RouteService.instance) {
      RouteService.instance = new RouteService();
    }
    return RouteService.instance;
  }
}

单例模式的优势在于路线数据只需初始化一次,避免重复创建带来的内存开销和数据不一致问题。所有页面通过 RouteService.getInstance() 获取同一个实例,保证了数据源的统一性。

数据模型

路线数据由 RouteInfo 和 Waypoint 两个模型组成:

class Waypoint {
  name: string;
  lat: number;
  lng: number;

  constructor(name: string, lat: number, lng: number) {
    this.name = name;
    this.lat = lat;
    this.lng = lng;
  }
}

class RouteInfo {
  type: string;
  name: string;
  distance: number;
  duration: string;
  difficulty: string;
  color: string;
  points: Waypoint[];

  getStartPoint(): Waypoint {
    return this.points[0];
  }

  getEndPoint(): Waypoint {
    return this.points[this.points.length - 1];
  }
}

Waypoint 模型包含地标名称和经纬度坐标。RouteInfo 模型包含路线的基本属性(类型、名称、距离、时长、难度、颜色)和途经点列表。getStartPoint 和 getEndPoint 方法分别返回起点和终点,用于地图渲染时绘制标记。

路线查询接口

getRouteByType(type: string): RouteInfo | undefined {
  return this.routes.find((r: RouteInfo) => r.type === type);
}

getAllRoutes(): RouteInfo[] {
  return this.routes;
}

getRouteByType 根据路线类型字符串(‘easy’/‘standard’/‘endurance’)查找对应路线,用于地图详情页渲染指定路线。getAllRoutes 返回全部路线列表,用于路线列表页展示。

路线初始化

三条路线在 RouteService 构造函数中硬编码初始化,每条路线包含详细的途经点坐标:

private constructor() {
  this.routes = [
    this.createEasyRoute(),
    this.createStandardRoute(),
    this.createEnduranceRoute()
  ];
}

路线坐标基于北京城区真实地理坐标,经由地图工具精确标定。每个途经点对应一个可识别的地标或路口,方便跑步者定位导航。

地图渲染详细流程

drawRouteMap() 完整流程

drawRouteMap() 是地图渲染的核心方法,按照以下步骤执行:

1. 获取 Canvas 上下文和尺寸
   ├── ctx = this.mapCanvas
   ├── canvasW = ctx.width
   └── canvasH = ctx.height

2. 计算视图范围
   ├── 遍历所有途经点,计算 maxDistFromStart
   ├── viewLatSpan = maxDist * 2 * 1.35
   ├── viewLngSpan = maxDist * 2 * 1.35
   ├── viewCenterLat = route.points[0].lat
   └── viewCenterLng = route.points[0].lng

3. 计算绘图区域
   ├── drawW = canvasW - PAD * 2 (PAD = 50)
   └── drawH = canvasH - PAD * 2

4. 定义 geoToPixel 转换函数

5. 分层绘制
   ├── 清除画布
   ├── 绘制坐标网格
   ├── 绘制坐标标签
   ├── 绘制地标标注
   ├── 绘制路线折线
   ├── 绘制起点/终点标记
   └── 绘制途经点标记

视图范围计算详解

视图范围的计算目标是确保所有途经点都能在 Canvas 中显示,同时留有适当边距:

let maxDist: number = 0;
const startLat: number = route.points[0].lat;
const startLng: number = route.points[0].lng;

for (const point of route.points) {
  const dLat: number = Math.abs(point.lat - startLat);
  const dLng: number = Math.abs(point.lng - startLng);
  if (dLat > maxDist) maxDist = dLat;
  if (dLng > maxDist) maxDist = dLng;
}

const viewLatSpan: number = maxDist * 2 * 1.35;
const viewLngSpan: number = maxDist * 2 * 1.35;

以起点为中心,maxDist 为所有途经点距起点的最大经纬度差值。乘以 2 得到覆盖全部途经点所需的最小跨度,再乘以 1.35 增加约 35% 的边距,确保路线不会紧贴画布边缘。三个路线使用统一的 1.35 系数,在不同路线跨度下均能获得良好的视觉效果。

起点居中策略

const viewCenterLat: number = startLat;
const viewCenterLng: number = startLng;

视图中心固定为路线起点,而非所有途经点的几何中心。这一设计决策基于以下考量:

  1. 跑步者从起点出发,起点是用户最关注的参考位置
  2. 耐力跑路线为环线(终点回到起点),起点居中可以同时展示起终点
  3. 几何中心可能导致起点偏移到画布角落,不够直观

坐标网格绘制

const gridInterval: number = 0.005;
const latStart: number = viewCenterLat - viewLatSpan / 2;
const latEnd: number = viewCenterLat + viewLatSpan / 2;

for (let lat: number = Math.ceil(latStart / gridInterval) * gridInterval;
     lat <= latEnd; lat += gridInterval) {
  const [px1, py1] = geoToPixel(lat, viewCenterLng - viewLngSpan / 2);
  const [px2, py2] = geoToPixel(lat, viewCenterLng + viewLngSpan / 2);
  ctx.strokeStyle = '#E0DDD5';
  ctx.lineWidth = 0.5;
  ctx.beginPath();
  ctx.moveTo(px1, py1);
  ctx.lineTo(px2, py2);
  ctx.stroke();

  ctx.fillStyle = '#999999';
  ctx.font = '10px sans-serif';
  ctx.fillText(lat.toFixed(3), px1 + 2, py1 - 2);
}

0.005 度间隔约对应 550 米距离,在北京城区范围内可以提供足够的网格密度。网格线使用低饱和度颜色 #E0DDD5,不干扰路线和地标信息的阅读。坐标标签使用 10px 字体,显示在网格线起始处。

地标过滤与绘制

9 个北京地标在绘制时需要判断是否落在当前视图范围内:

for (const lm of landmarks) {
  if (lm.lat >= latStart && lm.lat <= latEnd &&
      lm.lng >= lngStart && lm.lng <= lngEnd) {
    const [px, py] = geoToPixel(lm.lat, lm.lng);
    ctx.fillStyle = '#666666';
    ctx.font = '12px sans-serif';
    ctx.fillText(lm.name, px + 8, py - 4);
    ctx.beginPath();
    ctx.arc(px, py, 3, 0, 2 * Math.PI);
    ctx.fillStyle = '#999999';
    ctx.fill();
  }
}

地标过滤避免了视图范围外的地标占据绘制计算资源,同时防止标注出现在画布可视区域之外。每条路线由于视图范围不同,显示的地标数量也不同:轻松跑显示约 5-6 个地标,耐力跑可能显示全部 9 个。

路线折线绘制

路线折线采用双层绘制方案:外层为路线颜色粗线,内层为白色细线,形成道路效果:

ctx.strokeStyle = route.color;
ctx.lineWidth = 4;
ctx.beginPath();
const [sx, sy] = geoToPixel(route.points[0].lat, route.points[0].lng);
ctx.moveTo(sx, sy);
for (let i: number = 1; i < route.points.length; i++) {
  const [px, py] = geoToPixel(route.points[i].lat, route.points[i].lng);
  ctx.lineTo(px, py);
}
ctx.stroke();

ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx, sy);
for (let i: number = 1; i < route.points.length; i++) {
  const [px, py] = geoToPixel(route.points[i].lat, route.points[i].lng);
  ctx.lineTo(px, py);
}
ctx.stroke();

这种双层绘制在地图可视化中常见,粗色线提供视觉辨识度,白色中心线增加道路质感。线宽 4px 和 2px 的比例在各种屏幕密度下均可清晰辨识。

起终点标记绘制

起点和终点使用带字母的圆形标记,半径 7px:

const [startPx, startPy] = geoToPixel(route.points[0].lat, route.points[0].lng);
ctx.beginPath();
ctx.arc(startPx, startPy, 7, 0, 2 * Math.PI);
ctx.fillStyle = '#4CAF50';
ctx.fill();
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 10px sans-serif';
ctx.fillText('S', startPx - 3, startPy + 3);

const lastIdx: number = route.points.length - 1;
const [endPx, endPy] = geoToPixel(
  route.points[lastIdx].lat, route.points[lastIdx].lng);
ctx.beginPath();
ctx.arc(endPx, endPy, 7, 0, 2 * Math.PI);
ctx.fillStyle = '#F44336';
ctx.fill();
ctx.fillStyle = '#FFFFFF';
ctx.fillText('E', endPx - 3, endPy + 3);

绿色 S 标记起点(Start),红色 E 标记终点(End),颜色选择遵循通用地图标记惯例。7px 半径在 320px 高度的画布中大小适中,不会遮挡路线细节。

Canvas 方案设计决策

方案对比

方案优势劣势结论
MapComponent原生地图、交互丰富依赖 HMS 服务、模拟器不可用排除
Shape + Polyline声明式 UI、代码简洁状态变化时不重绘、坐标绑定困难排除
Canvas + Image利用离屏渲染660x360 像素与 320vp 不匹配排除
Canvas 直接绘制完全控制、模拟器可用无交互手势、需手动计算坐标采用

排除 MapComponent 的原因

MapComponent 是 HarmonyOS 提供的地图组件,但在 RunAdvisor 的开发环境中存在关键限制:

  1. MapComponent 需要 HMS (Huawei Mobile Services) 地图服务支持
  2. 模拟器未安装 HMS 服务框架,MapComponent 初始化即崩溃
  3. 开发阶段无法在模拟器上验证地图功能
  4. 地图服务的 API Key 配置需要开发者账号认证

排除 Shape + Polyline 的原因

ArkUI 的 Shape 容器和 Polyline 子组件理论上可以绘制折线,但实际测试发现:

  1. Polyline 的 points 属性在 @State 变化时不触发重绘
  2. 嵌套在 Scroll/Tabs 中的 Shape 组件布局异常
  3. 无法实现分层绘制(网格、路线、标记需要不同样式)

Canvas 直接绘制的权衡

Canvas 方案的最大优势是完全的绘制控制权,可以在 onReady 回调中按需重绘。缺点是放弃了声明式 UI 的便利性,所有视觉元素需要手动计算坐标和样式。在路线地图场景下,由于地图内容在页面打开后不再变化,Canvas 的"一次绘制"模式恰好适合。

路线列表页实现

页面结构

路线列表页作为 Route Tab 的主页面,展示三条路线的卡片列表:

ForEach(RouteService.getInstance().getAllRoutes(), (route: RouteInfo) => {
  Column() {
    Row() {
      Circle({ width: 12, height: 12 }).fill(route.color)
      Text(route.name).fontSize(16).fontWeight(FontWeight.Bold)
    }
    Row() {
      Text(route.distance + 'km')
      Text(route.duration)
      Text(route.difficulty)
    }
    Button('查看路线')
      .onClick(() => {
        this.navStack.pushPath({ name: 'MapRoute', param: route.type });
      })
  }
})

每张路线卡片包含路线颜色标识、名称、关键指标(距离/时长/难度)和查看按钮。点击"查看路线"通过 NavPathStack 导航到地图详情页,传递路线类型字符串作为参数。

地图详情页导航

地图详情页通过 NavDestination 实现,从 NavDestBuilder 接收路线类型参数:

@Builder
NavDestBuilder(name: string, param: Object) {
  if (name === 'MapRoute') {
    this.MapRoutePageNav(param as string)
  }
}

MapRoutePageNav Builder 根据路线类型加载对应数据并渲染 Canvas 地图。路线类型字符串(‘easy’/‘standard’/‘endurance’)作为唯一标识贯穿路由跳转和数据查询。

路线详情页截图

路线详情

耐力跑路线详情

距离与时长计算说明

距离计算

路线距离基于途经点之间的 Haversine 公式近似计算:

d = 2R * arcsin(sqrt(sin2(dLat/2) + cos(lat1)*cos(lat2)*sin2(dLng/2)))

其中 R 为地球半径 6371km。由于城市跑步路线的途经点间距较短(通常小于 1km),Haversine 公式的精度足以满足展示需求。

时长估算

跑步时长根据平均配速估算:

难度配速范围平均配速
入门(轻松跑)6:00-8:00 min/km7:00 min/km
中等(标准跑)5:30-7:00 min/km6:00 min/km
挑战(耐力跑)5:00-6:30 min/km5:30 min/km

时长 = 距离 x 平均配速,取整为 5 分钟的倍数。

使用场景

场景一:初跑者选择路线

初跑者打开应用,进入路线 Tab,浏览三条路线卡片。通过距离和难度信息判断轻松跑路线适合自己,点击"查看路线"进入地图详情页,查看途经地标和路线走向,确认路线可行后开始跑步。

场景二:有经验跑者规划长距离

有经验的跑者查看耐力跑路线地图,确认天坛、前门等地标的位置关系,评估路线距离是否匹配当日训练计划。地图上的坐标网格帮助判断各段路程的实际距离。

场景三:游客跑步游览

来北京旅游的用户选择标准跑路线,地图上标注的故宫、雍和宫、王府井等地标既是途经点也是旅游景点,跑步同时完成城市观光。

Logo

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

更多推荐