路线服务与地图渲染

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.9087 116.3975
故宫 39.9163 116.3972
景山 39.9210 116.3935
北海 39.9260 116.3830
天坛 39.8820 116.3930
王府井 39.9200 116.4000
前门 39.9050 116.3970
鼓楼 39.9280 116.3860
什刹海 39.9250 116.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/km 7:00 min/km
中等(标准跑) 5:30-7:00 min/km 6:00 min/km
挑战(耐力跑) 5:00-6:30 min/km 5:30 min/km

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

使用场景

场景一:初跑者选择路线

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

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

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

场景三:游客跑步游览

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

Logo

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

更多推荐