Forráskód Böngészése

Add animated orienteering course overlays and labels

zhangyan 2 hete
szülő
commit
3b4b3ee3ec

+ 109 - 0
miniprogram/engine/layer/courseLayer.ts

@@ -0,0 +1,109 @@
+import { lonLatToWorldTile } from '../../utils/projection'
+import { worldToScreen, type CameraState } from '../camera/camera'
+import { type MapLayer, type LayerRenderContext } from './mapLayer'
+import { type MapScene } from '../renderer/mapRenderer'
+import { type ScreenPoint } from './trackLayer'
+import {
+  type OrienteeringCourseControl,
+  type OrienteeringCourseFinish,
+  type OrienteeringCourseLeg,
+  type OrienteeringCourseStart,
+} from '../../utils/orienteeringCourse'
+
+export interface ProjectedCourseLeg {
+  fromKind: OrienteeringCourseLeg['fromKind']
+  toKind: OrienteeringCourseLeg['toKind']
+  from: ScreenPoint
+  to: ScreenPoint
+}
+
+export interface ProjectedCourseStart {
+  label: string
+  point: ScreenPoint
+  headingDeg: number | null
+}
+
+export interface ProjectedCourseControl {
+  label: string
+  sequence: number
+  point: ScreenPoint
+}
+
+export interface ProjectedCourseFinish {
+  label: string
+  point: ScreenPoint
+}
+
+export interface ProjectedCourseLayers {
+  starts: ProjectedCourseStart[]
+  controls: ProjectedCourseControl[]
+  finishes: ProjectedCourseFinish[]
+  legs: ProjectedCourseLeg[]
+}
+
+function buildVectorCamera(scene: MapScene): CameraState {
+  return {
+    centerWorldX: scene.exactCenterWorldX,
+    centerWorldY: scene.exactCenterWorldY,
+    viewportWidth: scene.viewportWidth,
+    viewportHeight: scene.viewportHeight,
+    visibleColumns: scene.visibleColumns,
+    rotationRad: scene.rotationRad,
+  }
+}
+
+export class CourseLayer implements MapLayer {
+  projectPoint(point: { point: { lon: number; lat: number } }, scene: MapScene, camera: CameraState): ScreenPoint {
+    const worldPoint = lonLatToWorldTile(point.point, scene.zoom)
+    return worldToScreen(camera, worldPoint, false)
+  }
+
+  projectStarts(starts: OrienteeringCourseStart[], scene: MapScene, camera: CameraState): ProjectedCourseStart[] {
+    return starts.map((start) => ({
+      label: start.label,
+      point: this.projectPoint(start, scene, camera),
+      headingDeg: start.headingDeg,
+    }))
+  }
+
+  projectControls(controls: OrienteeringCourseControl[], scene: MapScene, camera: CameraState): ProjectedCourseControl[] {
+    return controls.map((control) => ({
+      label: control.label,
+      sequence: control.sequence,
+      point: this.projectPoint(control, scene, camera),
+    }))
+  }
+
+  projectFinishes(finishes: OrienteeringCourseFinish[], scene: MapScene, camera: CameraState): ProjectedCourseFinish[] {
+    return finishes.map((finish) => ({
+      label: finish.label,
+      point: this.projectPoint(finish, scene, camera),
+    }))
+  }
+
+  projectLegs(legs: OrienteeringCourseLeg[], scene: MapScene, camera: CameraState): ProjectedCourseLeg[] {
+    return legs.map((leg) => ({
+      fromKind: leg.fromKind,
+      toKind: leg.toKind,
+      from: worldToScreen(camera, lonLatToWorldTile(leg.fromPoint, scene.zoom), false),
+      to: worldToScreen(camera, lonLatToWorldTile(leg.toPoint, scene.zoom), false),
+    }))
+  }
+
+  projectCourse(scene: MapScene): ProjectedCourseLayers | null {
+    const course = scene.course
+    if (!course) {
+      return null
+    }
+
+    const camera = buildVectorCamera(scene)
+    return {
+      starts: this.projectStarts(course.layers.starts, scene, camera),
+      controls: this.projectControls(course.layers.controls, scene, camera),
+      finishes: this.projectFinishes(course.layers.finishes, scene, camera),
+      legs: this.projectLegs(course.layers.legs, scene, camera),
+    }
+  }
+
+  draw(_context: LayerRenderContext): void {}
+}

+ 20 - 3
miniprogram/engine/map/mapEngine.ts

@@ -4,6 +4,7 @@ import { LocationController } from '../sensor/locationController'
 import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
 import { type MapRendererStats } from '../renderer/mapRenderer'
 import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
+import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
 import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
 
 const RENDER_MODE = 'Single WebGL Pipeline'
@@ -427,6 +428,8 @@ export class MapEngine {
   currentGpsPoint: LonLatPoint | null
   currentGpsTrack: LonLatPoint[]
   currentGpsAccuracyMeters: number | null
+  courseData: OrienteeringCourseData | null
+  cpRadiusMeters: number
   hasGpsCenteredOnce: boolean
 
   constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
@@ -477,6 +480,8 @@ export class MapEngine {
     this.currentGpsPoint = null
     this.currentGpsTrack = []
     this.currentGpsAccuracyMeters = null
+    this.courseData = null
+    this.cpRadiusMeters = 5
     this.hasGpsCenteredOnce = false
     this.state = {
       buildVersion: this.buildVersion,
@@ -649,8 +654,8 @@ export class MapEngine {
     )
   }
 
-  attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
-    this.renderer.attachCanvas(canvasNode, width, height, dpr)
+  attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
+    this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
     this.mounted = true
     this.state.mapReady = true
     this.state.mapReadyText = 'READY'
@@ -672,9 +677,11 @@ export class MapEngine {
     this.defaultCenterTileX = config.initialCenterTileX
     this.defaultCenterTileY = config.initialCenterTileY
     this.tileBoundsByZoom = config.tileBoundsByZoom
+    this.courseData = config.course
+    this.cpRadiusMeters = config.cpRadiusMeters
 
     const statePatch: Partial<MapEngineViewState> = {
-      configStatusText: '远程配置已载入',
+      configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
       projectionMode: config.projectionModeText,
       tileSource: config.tileSource,
       sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
@@ -1400,6 +1407,8 @@ export class MapEngine {
       gpsPoint: this.currentGpsPoint,
       gpsCalibration: GPS_MAP_CALIBRATION,
       gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
+      course: this.courseData,
+      cpRadiusMeters: this.cpRadiusMeters,
       osmReferenceEnabled: this.state.osmReferenceEnabled,
       overlayOpacity: MAP_OVERLAY_OPACITY,
     }
@@ -1792,6 +1801,14 @@ export class MapEngine {
 
 
 
+
+
+
+
+
+
+
+
 
 
 

+ 120 - 0
miniprogram/engine/renderer/courseLabelRenderer.ts

@@ -0,0 +1,120 @@
+import { type MapScene } from './mapRenderer'
+import { CourseLayer } from '../layer/courseLayer'
+
+const EARTH_CIRCUMFERENCE_METERS = 40075016.686
+const LABEL_FONT_SIZE_RATIO = 1.08
+const LABEL_OFFSET_X_RATIO = 1.18
+const LABEL_OFFSET_Y_RATIO = -0.68
+
+export class CourseLabelRenderer {
+  courseLayer: CourseLayer
+  canvas: any
+  ctx: any
+  dpr: number
+  width: number
+  height: number
+
+  constructor(courseLayer: CourseLayer) {
+    this.courseLayer = courseLayer
+    this.canvas = null
+    this.ctx = null
+    this.dpr = 1
+    this.width = 0
+    this.height = 0
+  }
+
+  attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
+    this.canvas = canvasNode
+    this.ctx = canvasNode.getContext('2d')
+    this.dpr = dpr || 1
+    this.width = width
+    this.height = height
+    canvasNode.width = Math.max(1, Math.floor(width * this.dpr))
+    canvasNode.height = Math.max(1, Math.floor(height * this.dpr))
+  }
+
+  destroy(): void {
+    this.ctx = null
+    this.canvas = null
+    this.width = 0
+    this.height = 0
+  }
+
+  render(scene: MapScene): void {
+    if (!this.ctx || !this.canvas) {
+      return
+    }
+
+    const course = this.courseLayer.projectCourse(scene)
+    const ctx = this.ctx
+    this.clearCanvas(ctx)
+
+    if (!course || !course.controls.length) {
+      return
+    }
+
+    const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
+    const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO)
+    const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
+    const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
+
+    this.applyPreviewTransform(ctx, scene)
+    ctx.save()
+    ctx.fillStyle = 'rgba(204, 0, 107, 0.98)'
+    ctx.textAlign = 'left'
+    ctx.textBaseline = 'middle'
+    ctx.font = `700 ${fontSizePx}px sans-serif`
+
+    for (const control of course.controls) {
+      ctx.save()
+      ctx.translate(control.point.x, control.point.y)
+      ctx.rotate(scene.rotationRad)
+      ctx.fillText(String(control.sequence), offsetX, offsetY)
+      ctx.restore()
+    }
+
+    ctx.restore()
+  }
+
+  clearCanvas(ctx: any): void {
+    ctx.setTransform(1, 0, 0, 1, 0, 0)
+    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
+  }
+
+  applyPreviewTransform(ctx: any, scene: MapScene): void {
+    const previewScale = scene.previewScale || 1
+    const previewOriginX = scene.previewOriginX || scene.viewportWidth / 2
+    const previewOriginY = scene.previewOriginY || scene.viewportHeight / 2
+    const translateX = (previewOriginX - previewOriginX * previewScale) * this.dpr
+    const translateY = (previewOriginY - previewOriginY * previewScale) * this.dpr
+
+    ctx.setTransform(
+      this.dpr * previewScale,
+      0,
+      0,
+      this.dpr * previewScale,
+      translateX,
+      translateY,
+    )
+  }
+
+  getMetric(scene: MapScene, meters: number): number {
+    return meters * this.getPixelsPerMeter(scene)
+  }
+
+  getPixelsPerMeter(scene: MapScene): number {
+    const tileSizePx = scene.viewportWidth / scene.visibleColumns
+    const centerLat = this.worldTileYToLat(scene.exactCenterWorldY, scene.zoom)
+    const metersPerTile = Math.cos(centerLat * Math.PI / 180) * EARTH_CIRCUMFERENCE_METERS / Math.pow(2, scene.zoom)
+    if (!tileSizePx || !metersPerTile) {
+      return 0
+    }
+    return tileSizePx / metersPerTile
+  }
+
+  worldTileYToLat(worldY: number, zoom: number): number {
+    const scale = Math.pow(2, zoom)
+    const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * worldY / scale)))
+    return latRad * 180 / Math.PI
+  }
+}

+ 4 - 1
miniprogram/engine/renderer/mapRenderer.ts

@@ -2,6 +2,7 @@ import { type CameraState } from '../camera/camera'
 import { type TileStoreStats } from '../tile/tileStore'
 import { type LonLatPoint, type MapCalibration } from '../../utils/projection'
 import { type TileZoomBounds } from '../../utils/remoteMapConfig'
+import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
 
 export interface MapScene {
   tileSource: string
@@ -26,6 +27,8 @@ export interface MapScene {
   gpsPoint: LonLatPoint | null
   gpsCalibration: MapCalibration
   gpsCalibrationOrigin: LonLatPoint
+  course: OrienteeringCourseData | null
+  cpRadiusMeters: number
   osmReferenceEnabled: boolean
   overlayOpacity: number
 }
@@ -33,7 +36,7 @@ export interface MapScene {
 export type MapRendererStats = TileStoreStats
 
 export interface MapRenderer {
-  attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void
+  attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void
   updateScene(scene: MapScene): void
   setAnimationPaused(paused: boolean): void
   destroy(): void

+ 13 - 2
miniprogram/engine/renderer/webglMapRenderer.ts

@@ -1,3 +1,4 @@
+import { CourseLayer } from '../layer/courseLayer'
 import { TrackLayer } from '../layer/trackLayer'
 import { GpsLayer } from '../layer/gpsLayer'
 import { TileLayer } from '../layer/tileLayer'
@@ -5,6 +6,7 @@ import { TileStore, type TileStoreCallbacks } from '../tile/tileStore'
 import { type MapRenderer, type MapRendererStats, type MapScene } from './mapRenderer'
 import { WebGLTileRenderer } from './webglTileRenderer'
 import { WebGLVectorRenderer } from './webglVectorRenderer'
+import { CourseLabelRenderer } from './courseLabelRenderer'
 
 const RENDER_FRAME_MS = 16
 const ANIMATION_FRAME_MS = 33
@@ -14,10 +16,12 @@ export class WebGLMapRenderer implements MapRenderer {
   osmTileStore: TileStore
   tileLayer: TileLayer
   osmTileLayer: TileLayer
+  courseLayer: CourseLayer
   trackLayer: TrackLayer
   gpsLayer: GpsLayer
   tileRenderer: WebGLTileRenderer
   vectorRenderer: WebGLVectorRenderer
+  labelRenderer: CourseLabelRenderer
   scene: MapScene | null
   renderTimer: number
   animationTimer: number
@@ -52,10 +56,12 @@ export class WebGLMapRenderer implements MapRenderer {
     } satisfies TileStoreCallbacks)
     this.tileLayer = new TileLayer()
     this.osmTileLayer = new TileLayer()
+    this.courseLayer = new CourseLayer()
     this.trackLayer = new TrackLayer()
     this.gpsLayer = new GpsLayer()
     this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore, this.osmTileLayer, this.osmTileStore)
-    this.vectorRenderer = new WebGLVectorRenderer(this.trackLayer, this.gpsLayer)
+    this.vectorRenderer = new WebGLVectorRenderer(this.courseLayer, this.trackLayer, this.gpsLayer)
+    this.labelRenderer = new CourseLabelRenderer(this.courseLayer)
     this.scene = null
     this.renderTimer = 0
     this.animationTimer = 0
@@ -73,9 +79,12 @@ export class WebGLMapRenderer implements MapRenderer {
     }
   }
 
-  attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
+  attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
     this.tileRenderer.attachCanvas(canvasNode, width, height, dpr)
     this.vectorRenderer.attachContext(this.tileRenderer.gl, canvasNode)
+    if (labelCanvasNode) {
+      this.labelRenderer.attachCanvas(labelCanvasNode, width, height, dpr)
+    }
     this.startAnimation()
     this.scheduleRender()
   }
@@ -102,6 +111,7 @@ export class WebGLMapRenderer implements MapRenderer {
       clearTimeout(this.animationTimer)
       this.animationTimer = 0
     }
+    this.labelRenderer.destroy()
     this.vectorRenderer.destroy()
     this.tileRenderer.destroy()
     this.tileStore.destroy()
@@ -149,6 +159,7 @@ export class WebGLMapRenderer implements MapRenderer {
 
     this.tileRenderer.render(this.scene)
     this.vectorRenderer.render(this.scene, this.pulseFrame)
+    this.labelRenderer.render(this.scene)
     this.emitStats(this.tileStore.getStats(this.tileLayer.lastVisibleTileCount, this.tileLayer.lastReadyTileCount))
   }
 

+ 282 - 4
miniprogram/engine/renderer/webglVectorRenderer.ts

@@ -1,7 +1,28 @@
+import { getTileSizePx, type CameraState } from '../camera/camera'
+import { worldTileToLonLat } from '../../utils/projection'
 import { type MapScene } from './mapRenderer'
+import { CourseLayer, type ProjectedCourseLayers, type ProjectedCourseLeg } from '../layer/courseLayer'
 import { TrackLayer } from '../layer/trackLayer'
 import { GpsLayer } from '../layer/gpsLayer'
 
+const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96]
+const EARTH_CIRCUMFERENCE_METERS = 40075016.686
+const CONTROL_RING_WIDTH_RATIO = 0.2
+const FINISH_INNER_RADIUS_RATIO = 0.6
+const FINISH_RING_WIDTH_RATIO = 0.2
+const START_RING_WIDTH_RATIO = 0.2
+const LEG_WIDTH_RATIO = 0.2
+const LEG_TRIM_TO_RING_CENTER_RATIO = 1 - CONTROL_RING_WIDTH_RATIO / 2
+
+type RgbaColor = [number, number, number, number]
+
+const GUIDE_FLOW_COUNT = 6
+const GUIDE_FLOW_SPEED = 0.022
+const GUIDE_FLOW_MIN_RADIUS_RATIO = 0.14
+const GUIDE_FLOW_MAX_RADIUS_RATIO = 0.34
+const GUIDE_FLOW_OUTER_SCALE = 1.45
+const GUIDE_FLOW_INNER_SCALE = 0.56
+
 function createShader(gl: any, type: number, source: string): any {
   const shader = gl.createShader(type)
   if (!shader) {
@@ -46,6 +67,7 @@ export class WebGLVectorRenderer {
   canvas: any
   gl: any
   dpr: number
+  courseLayer: CourseLayer
   trackLayer: TrackLayer
   gpsLayer: GpsLayer
   program: any
@@ -54,10 +76,11 @@ export class WebGLVectorRenderer {
   positionLocation: number
   colorLocation: number
 
-  constructor(trackLayer: TrackLayer, gpsLayer: GpsLayer) {
+  constructor(courseLayer: CourseLayer, trackLayer: TrackLayer, gpsLayer: GpsLayer) {
     this.canvas = null
     this.gl = null
     this.dpr = 1
+    this.courseLayer = courseLayer
     this.trackLayer = trackLayer
     this.gpsLayer = gpsLayer
     this.program = null
@@ -123,11 +146,16 @@ export class WebGLVectorRenderer {
     }
 
     const gl = this.gl
+    const course = this.courseLayer.projectCourse(scene)
     const trackPoints = this.trackLayer.projectPoints(scene)
     const gpsPoint = this.gpsLayer.projectPoint(scene)
     const positions: number[] = []
     const colors: number[] = []
 
+    if (course) {
+      this.pushCourse(positions, colors, course, scene, pulseFrame)
+    }
+
     for (let index = 1; index < trackPoints.length; index += 1) {
       this.pushSegment(positions, colors, trackPoints[index - 1], trackPoints[index], 6, [0.09, 0.43, 0.36, 0.96], scene)
     }
@@ -163,13 +191,261 @@ export class WebGLVectorRenderer {
     gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2)
   }
 
+  getPixelsPerMeter(scene: MapScene): number {
+    const camera: CameraState = {
+      centerWorldX: scene.exactCenterWorldX,
+      centerWorldY: scene.exactCenterWorldY,
+      viewportWidth: scene.viewportWidth,
+      viewportHeight: scene.viewportHeight,
+      visibleColumns: scene.visibleColumns,
+    }
+    const tileSizePx = getTileSizePx(camera)
+    const centerLonLat = worldTileToLonLat({ x: scene.exactCenterWorldX, y: scene.exactCenterWorldY }, scene.zoom)
+    const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * EARTH_CIRCUMFERENCE_METERS / Math.pow(2, scene.zoom)
+    if (!tileSizePx || !metersPerTile) {
+      return 0
+    }
+    return tileSizePx / metersPerTile
+  }
+
+  getMetric(scene: MapScene, meters: number): number {
+    return meters * this.getPixelsPerMeter(scene)
+  }
+
+  getControlRadiusMeters(scene: MapScene): number {
+    return scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
+  }
+
+  pushCourse(
+    positions: number[],
+    colors: number[],
+    course: ProjectedCourseLayers,
+    scene: MapScene,
+    pulseFrame: number,
+  ): void {
+    const controlRadiusMeters = this.getControlRadiusMeters(scene)
+
+    for (const leg of course.legs) {
+      this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, scene)
+    }
+
+    const guideLeg = this.getGuideLeg(course)
+    if (guideLeg) {
+      this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
+    }
+
+    for (const start of course.starts) {
+      this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene)
+    }
+
+    for (const control of course.controls) {
+      this.pushRing(
+        positions,
+        colors,
+        control.point.x,
+        control.point.y,
+        this.getMetric(scene, controlRadiusMeters),
+        this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)),
+        COURSE_COLOR,
+        scene,
+      )
+    }
+
+    for (const finish of course.finishes) {
+      this.pushRing(
+        positions,
+        colors,
+        finish.point.x,
+        finish.point.y,
+        this.getMetric(scene, controlRadiusMeters),
+        this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
+        COURSE_COLOR,
+        scene,
+      )
+      this.pushRing(
+        positions,
+        colors,
+        finish.point.x,
+        finish.point.y,
+        this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO),
+        this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
+        COURSE_COLOR,
+        scene,
+      )
+    }
+  }
+
+  getGuideLeg(course: ProjectedCourseLayers): ProjectedCourseLeg | null {
+    return course.legs.length ? course.legs[0] : null
+  }
+
+  pushCourseLeg(
+    positions: number[],
+    colors: number[],
+    leg: ProjectedCourseLeg,
+    controlRadiusMeters: number,
+    scene: MapScene,
+  ): void {
+    const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
+    if (!trimmed) {
+      return
+    }
+
+    this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), COURSE_COLOR, scene)
+  }
+
+  pushGuidanceFlow(
+    positions: number[],
+    colors: number[],
+    leg: ProjectedCourseLeg,
+    controlRadiusMeters: number,
+    scene: MapScene,
+    pulseFrame: number,
+  ): void {
+    const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
+    if (!trimmed) {
+      return
+    }
+
+    const dx = trimmed.to.x - trimmed.from.x
+    const dy = trimmed.to.y - trimmed.from.y
+    const length = Math.sqrt(dx * dx + dy * dy)
+    if (!length) {
+      return
+    }
+
+    for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) {
+      const progress = (pulseFrame * GUIDE_FLOW_SPEED + index / GUIDE_FLOW_COUNT) % 1
+      const eased = progress * progress
+      const x = trimmed.from.x + dx * progress
+      const y = trimmed.from.y + dy * progress
+      const radius = this.getMetric(
+        scene,
+        controlRadiusMeters * (GUIDE_FLOW_MIN_RADIUS_RATIO + (GUIDE_FLOW_MAX_RADIUS_RATIO - GUIDE_FLOW_MIN_RADIUS_RATIO) * eased),
+      )
+      const outerColor = this.getGuideFlowOuterColor(eased)
+      const innerColor = this.getGuideFlowInnerColor(eased)
+
+      this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_OUTER_SCALE, outerColor, scene)
+      this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_INNER_SCALE, innerColor, scene)
+    }
+  }
+
+  getTrimmedCourseLeg(
+    leg: ProjectedCourseLeg,
+    controlRadiusMeters: number,
+    scene: MapScene,
+  ): { from: { x: number; y: number }; to: { x: number; y: number } } | null {
+    return this.trimSegment(
+      leg.from,
+      leg.to,
+      this.getLegTrim(leg.fromKind, controlRadiusMeters, scene),
+      this.getLegTrim(leg.toKind, controlRadiusMeters, scene),
+    )
+  }
+
+  getGuideFlowOuterColor(progress: number): RgbaColor {
+    return [1, 0.18, 0.6, 0.16 + progress * 0.34]
+  }
+
+  getGuideFlowInnerColor(progress: number): RgbaColor {
+    return [1, 0.95, 0.98, 0.3 + progress * 0.54]
+  }
+
+  getLegTrim(kind: ProjectedCourseLeg['fromKind'], controlRadiusMeters: number, scene: MapScene): number {
+    if (kind === 'start') {
+      return this.getMetric(scene, controlRadiusMeters * (1 - START_RING_WIDTH_RATIO / 2))
+    }
+
+    if (kind === 'finish') {
+      return this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO / 2))
+    }
+
+    return this.getMetric(scene, controlRadiusMeters * LEG_TRIM_TO_RING_CENTER_RATIO)
+  }
+
+  trimSegment(
+    from: { x: number; y: number },
+    to: { x: number; y: number },
+    fromTrim: number,
+    toTrim: number,
+  ): { from: { x: number; y: number }; to: { x: number; y: number } } | null {
+    const dx = to.x - from.x
+    const dy = to.y - from.y
+    const length = Math.sqrt(dx * dx + dy * dy)
+    if (!length || length <= fromTrim + toTrim) {
+      return null
+    }
+
+    const ux = dx / length
+    const uy = dy / length
+    return {
+      from: {
+        x: from.x + ux * fromTrim,
+        y: from.y + uy * fromTrim,
+      },
+      to: {
+        x: to.x - ux * toTrim,
+        y: to.y - uy * toTrim,
+      },
+    }
+  }
+
+  pushStartTriangle(
+    positions: number[],
+    colors: number[],
+    centerX: number,
+    centerY: number,
+    headingDeg: number | null,
+    controlRadiusMeters: number,
+    scene: MapScene,
+  ): void {
+    const startRadius = this.getMetric(scene, controlRadiusMeters)
+    const startRingWidth = this.getMetric(scene, controlRadiusMeters * START_RING_WIDTH_RATIO)
+    const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180
+    const vertices = [0, 1, 2].map((index) => {
+      const angle = headingRad + index * (Math.PI * 2 / 3)
+      return {
+        x: centerX + Math.cos(angle) * startRadius,
+        y: centerY + Math.sin(angle) * startRadius,
+      }
+    })
+
+    this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, COURSE_COLOR, scene)
+    this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, COURSE_COLOR, scene)
+    this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, COURSE_COLOR, scene)
+  }
+
+  pushRing(
+    positions: number[],
+    colors: number[],
+    centerX: number,
+    centerY: number,
+    outerRadius: number,
+    innerRadius: number,
+    color: RgbaColor,
+    scene: MapScene,
+  ): void {
+    const segments = 36
+    for (let index = 0; index < segments; index += 1) {
+      const startAngle = index / segments * Math.PI * 2
+      const endAngle = (index + 1) / segments * Math.PI * 2
+      const outerStart = this.toClip(centerX + Math.cos(startAngle) * outerRadius, centerY + Math.sin(startAngle) * outerRadius, scene)
+      const outerEnd = this.toClip(centerX + Math.cos(endAngle) * outerRadius, centerY + Math.sin(endAngle) * outerRadius, scene)
+      const innerStart = this.toClip(centerX + Math.cos(startAngle) * innerRadius, centerY + Math.sin(startAngle) * innerRadius, scene)
+      const innerEnd = this.toClip(centerX + Math.cos(endAngle) * innerRadius, centerY + Math.sin(endAngle) * innerRadius, scene)
+      this.pushTriangle(positions, colors, outerStart, outerEnd, innerStart, color)
+      this.pushTriangle(positions, colors, innerStart, outerEnd, innerEnd, color)
+    }
+  }
+
   pushSegment(
     positions: number[],
     colors: number[],
     start: { x: number; y: number },
     end: { x: number; y: number },
     width: number,
-    color: [number, number, number, number],
+    color: RgbaColor,
     scene: MapScene,
   ): void {
     const deltaX = end.x - start.x
@@ -196,7 +472,7 @@ export class WebGLVectorRenderer {
     centerX: number,
     centerY: number,
     radius: number,
-    color: [number, number, number, number],
+    color: RgbaColor,
     scene: MapScene,
   ): void {
     const segments = 20
@@ -216,7 +492,7 @@ export class WebGLVectorRenderer {
     first: { x: number; y: number },
     second: { x: number; y: number },
     third: { x: number; y: number },
-    color: [number, number, number, number],
+    color: RgbaColor,
   ): void {
     positions.push(first.x, first.y, second.x, second.y, third.x, third.y)
     for (let index = 0; index < 3; index += 1) {
@@ -237,3 +513,5 @@ export class WebGLVectorRenderer {
     }
   }
 }
+
+

+ 11 - 1
miniprogram/pages/map/map.ts

@@ -191,8 +191,10 @@ Page({
 
       const canvasQuery = wx.createSelectorQuery().in(page)
       canvasQuery.select('#mapCanvas').fields({ node: true, size: true })
+      canvasQuery.select('#routeLabelCanvas').fields({ node: true, size: true })
       canvasQuery.exec((canvasRes) => {
         const canvasRef = canvasRes[0] as any
+        const labelCanvasRef = canvasRes[1] as any
         if (!canvasRef || !canvasRef.node) {
           page.setData({
             statusText: `WebGL 寮曟搸鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`,
@@ -202,7 +204,13 @@ Page({
 
         const dpr = wx.getSystemInfoSync().pixelRatio || 1
         try {
-          currentEngine.attachCanvas(canvasRef.node, rect.width, rect.height, dpr)
+          currentEngine.attachCanvas(
+            canvasRef.node,
+            rect.width,
+            rect.height,
+            dpr,
+            labelCanvasRef && labelCanvasRef.node ? labelCanvasRef.node : undefined,
+          )
         } catch (error) {
           page.setData({
             statusText: `WebGL 鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`,
@@ -369,6 +377,8 @@ Page({
 
 
 
+
+
 
 
 

+ 8 - 0
miniprogram/pages/map/map.wxml

@@ -13,6 +13,12 @@
         canvas-id="mapCanvas"
         class="map-canvas map-canvas--base"
       ></canvas>
+      <canvas
+        id="routeLabelCanvas"
+        type="2d"
+        canvas-id="routeLabelCanvas"
+        class="map-canvas map-canvas--labels"
+      ></canvas>
     </view>
 
     <view class="map-stage__crosshair"></view>
@@ -283,3 +289,5 @@
 
 
 
+
+

+ 6 - 0
miniprogram/pages/map/map.wxss

@@ -30,6 +30,11 @@
   z-index: 1;
 }
 
+.map-canvas--labels {
+  z-index: 2;
+  pointer-events: none;
+}
+
 .map-stage__crosshair {
   position: absolute;
   left: 50%;
@@ -974,5 +979,6 @@
 
 
 
+
 
 

+ 268 - 0
miniprogram/utils/orienteeringCourse.ts

@@ -0,0 +1,268 @@
+import { type LonLatPoint } from './projection'
+
+export type OrienteeringCourseNodeKind = 'start' | 'control' | 'finish'
+
+export interface OrienteeringCourseStart {
+  label: string
+  point: LonLatPoint
+  headingDeg: number | null
+}
+
+export interface OrienteeringCourseControl {
+  label: string
+  point: LonLatPoint
+  sequence: number
+}
+
+export interface OrienteeringCourseFinish {
+  label: string
+  point: LonLatPoint
+}
+
+export interface OrienteeringCourseLeg {
+  fromKind: OrienteeringCourseNodeKind
+  toKind: OrienteeringCourseNodeKind
+  fromPoint: LonLatPoint
+  toPoint: LonLatPoint
+}
+
+export interface OrienteeringCourseLayers {
+  starts: OrienteeringCourseStart[]
+  controls: OrienteeringCourseControl[]
+  finishes: OrienteeringCourseFinish[]
+  legs: OrienteeringCourseLeg[]
+}
+
+export interface OrienteeringCourseData {
+  title: string
+  layers: OrienteeringCourseLayers
+}
+
+interface ParsedPlacemarkPoint {
+  label: string
+  point: LonLatPoint
+  explicitKind: OrienteeringCourseNodeKind | null
+}
+
+interface OrderedCourseNode {
+  label: string
+  point: LonLatPoint
+  kind: OrienteeringCourseNodeKind
+}
+
+function decodeXmlEntities(text: string): string {
+  return text
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"')
+    .replace(/&#39;/g, "'")
+    .replace(/&apos;/g, "'")
+    .replace(/&amp;/g, '&')
+}
+
+function stripXml(text: string): string {
+  return decodeXmlEntities(text.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim()
+}
+
+function extractTagText(block: string, tagName: string): string {
+  const match = block.match(new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'))
+  return match ? stripXml(match[1]) : ''
+}
+
+function parseCoordinateTuple(rawValue: string): LonLatPoint | null {
+  const parts = rawValue.trim().split(',')
+  if (parts.length < 2) {
+    return null
+  }
+
+  const lon = Number(parts[0])
+  const lat = Number(parts[1])
+  if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
+    return null
+  }
+
+  return { lon, lat }
+}
+
+function extractPointCoordinates(block: string): LonLatPoint | null {
+  const pointMatch = block.match(/<Point\b[\s\S]*?<coordinates>([\s\S]*?)<\/coordinates>[\s\S]*?<\/Point>/i)
+  if (!pointMatch) {
+    return null
+  }
+
+  const coordinateMatch = pointMatch[1].trim().match(/-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?)?/) 
+  return coordinateMatch ? parseCoordinateTuple(coordinateMatch[0]) : null
+}
+
+function normalizeCourseLabel(label: string): string {
+  return label.trim().replace(/\s+/g, ' ')
+}
+
+function inferExplicitKind(label: string, placemarkBlock: string): OrienteeringCourseNodeKind | null {
+  const normalized = label.toUpperCase().replace(/[^A-Z0-9]/g, '')
+  const styleHint = placemarkBlock.toUpperCase()
+
+  if (
+    normalized === 'S'
+    || normalized.startsWith('START')
+    || /^S\d+$/.test(normalized)
+    || styleHint.includes('START')
+    || styleHint.includes('TRIANGLE')
+  ) {
+    return 'start'
+  }
+
+  if (
+    normalized === 'F'
+    || normalized === 'M'
+    || normalized.startsWith('FINISH')
+    || normalized.startsWith('GOAL')
+    || /^F\d+$/.test(normalized)
+    || styleHint.includes('FINISH')
+    || styleHint.includes('GOAL')
+  ) {
+    return 'finish'
+  }
+
+  return null
+}
+
+function extractPlacemarkPoints(kmlText: string): ParsedPlacemarkPoint[] {
+  const placemarkBlocks = kmlText.match(/<Placemark\b[\s\S]*?<\/Placemark>/gi) || []
+  const points: ParsedPlacemarkPoint[] = []
+
+  for (const placemarkBlock of placemarkBlocks) {
+    const point = extractPointCoordinates(placemarkBlock)
+    if (!point) {
+      continue
+    }
+
+    const label = normalizeCourseLabel(extractTagText(placemarkBlock, 'name'))
+    points.push({
+      label,
+      point,
+      explicitKind: inferExplicitKind(label, placemarkBlock),
+    })
+  }
+
+  return points
+}
+
+function classifyOrderedNodes(points: ParsedPlacemarkPoint[]): OrderedCourseNode[] {
+  if (!points.length) {
+    return []
+  }
+
+  const startIndex = points.findIndex((point) => point.explicitKind === 'start')
+  let finishIndex = -1
+  for (let index = points.length - 1; index >= 0; index -= 1) {
+    if (points[index].explicitKind === 'finish') {
+      finishIndex = index
+      break
+    }
+  }
+
+  return points.map((point, index) => {
+    let kind = point.explicitKind
+    if (!kind) {
+      if (startIndex === -1 && index === 0) {
+        kind = 'start'
+      } else if (finishIndex === -1 && points.length > 1 && index === points.length - 1) {
+        kind = 'finish'
+      } else {
+        kind = 'control'
+      }
+    }
+
+    return {
+      label: point.label,
+      point: point.point,
+      kind,
+    }
+  })
+}
+
+function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
+  const fromLatRad = from.lat * Math.PI / 180
+  const toLatRad = to.lat * Math.PI / 180
+  const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
+  const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
+  const x = Math.cos(fromLatRad) * Math.sin(toLatRad)
+    - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
+  const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
+  return (bearingDeg + 360) % 360
+}
+
+function buildCourseLayers(nodes: OrderedCourseNode[]): OrienteeringCourseLayers {
+  const starts: OrienteeringCourseStart[] = []
+  const controls: OrienteeringCourseControl[] = []
+  const finishes: OrienteeringCourseFinish[] = []
+  const legs: OrienteeringCourseLeg[] = []
+  let controlSequence = 1
+
+  nodes.forEach((node, index) => {
+    const nextNode = index < nodes.length - 1 ? nodes[index + 1] : null
+    const label = node.label || (
+      node.kind === 'start'
+        ? 'Start'
+        : node.kind === 'finish'
+          ? 'Finish'
+          : String(controlSequence)
+    )
+
+    if (node.kind === 'start') {
+      starts.push({
+        label,
+        point: node.point,
+        headingDeg: nextNode ? getInitialBearingDeg(node.point, nextNode.point) : null,
+      })
+      return
+    }
+
+    if (node.kind === 'finish') {
+      finishes.push({
+        label,
+        point: node.point,
+      })
+      return
+    }
+
+    controls.push({
+      label,
+      point: node.point,
+      sequence: controlSequence,
+    })
+    controlSequence += 1
+  })
+
+  for (let index = 1; index < nodes.length; index += 1) {
+    legs.push({
+      fromKind: nodes[index - 1].kind,
+      toKind: nodes[index].kind,
+      fromPoint: nodes[index - 1].point,
+      toPoint: nodes[index].point,
+    })
+  }
+
+  return {
+    starts,
+    controls,
+    finishes,
+    legs,
+  }
+}
+
+export function parseOrienteeringCourseKml(kmlText: string): OrienteeringCourseData {
+  const points = extractPlacemarkPoints(kmlText)
+  if (!points.length) {
+    throw new Error('KML 中没有可用的 Point 控制点')
+  }
+
+  const documentTitle = extractTagText(kmlText, 'name')
+  const nodes = classifyOrderedNodes(points)
+
+  return {
+    title: documentTitle || 'Orienteering Course',
+    layers: buildCourseLayers(nodes),
+  }
+}

+ 61 - 2
miniprogram/utils/remoteMapConfig.ts

@@ -1,4 +1,5 @@
 import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
+import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
 
 export interface TileZoomBounds {
   minX: number
@@ -24,11 +25,17 @@ export interface RemoteMapConfig {
   tileBoundsByZoom: Record<number, TileZoomBounds>
   mapMetaUrl: string
   mapRootUrl: string
+  courseUrl: string | null
+  course: OrienteeringCourseData | null
+  courseStatusText: string
+  cpRadiusMeters: number
 }
 
 interface ParsedGameConfig {
   mapRoot: string
   mapMeta: string
+  course: string | null
+  cpRadiusMeters: number
   declinationDeg: number
 }
 
@@ -146,12 +153,43 @@ function parseDeclinationValue(rawValue: unknown): number {
   return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91
 }
 
+function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
+  const numericValue = Number(rawValue)
+  return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
+}
+
+function parseLooseJsonObject(text: string): Record<string, unknown> {
+  const parsed: Record<string, unknown> = {}
+  const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
+  let match: RegExpExecArray | null
+
+  while ((match = pairPattern.exec(text))) {
+    const rawValue = match[2]
+    let value: unknown = rawValue
+
+    if (rawValue === 'true' || rawValue === 'false') {
+      value = rawValue === 'true'
+    } else if (rawValue === 'null') {
+      value = null
+    } else if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
+      value = match[3] || ''
+    } else {
+      const numericValue = Number(rawValue)
+      value = Number.isFinite(numericValue) ? numericValue : rawValue
+    }
+
+    parsed[match[1]] = value
+  }
+
+  return parsed
+}
+
 function parseGameConfigFromJson(text: string): ParsedGameConfig {
   let parsed: Record<string, unknown>
   try {
     parsed = JSON.parse(text)
   } catch {
-    throw new Error('game.json 解析失败')
+    parsed = parseLooseJsonObject(text)
   }
 
   const normalized: Record<string, unknown> = {}
@@ -169,6 +207,8 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
   return {
     mapRoot,
     mapMeta,
+    course: typeof normalized.course === 'string' ? normalized.course : null,
+    cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5),
     declinationDeg: parseDeclinationValue(normalized.declination),
   }
 }
@@ -200,6 +240,8 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig {
   return {
     mapRoot,
     mapMeta,
+    course: typeof config.course === 'string' ? config.course : null,
+    cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
     declinationDeg: parseDeclinationValue(config.declination),
   }
 }
@@ -373,9 +415,23 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
   const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl)
   const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta)
   const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot)
+  const courseUrl = gameConfig.course ? resolveUrl(gameConfigUrl, gameConfig.course) : null
   const mapMetaText = await requestText(mapMetaUrl)
   const mapMeta = parseMapMeta(mapMetaText)
 
+  let course: OrienteeringCourseData | null = null
+  let courseStatusText = courseUrl ? '路线待加载' : '未配置路线'
+  if (courseUrl) {
+    try {
+      const courseText = await requestText(courseUrl)
+      course = parseOrienteeringCourseKml(courseText)
+      courseStatusText = `路线已载入 (${course.layers.controls.length} controls)`
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '未知错误'
+      courseStatusText = `路线加载失败: ${message}`
+    }
+  }
+
   const defaultZoom = clamp(17, mapMeta.minZoom, mapMeta.maxZoom)
   const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
   const centerWorldTile = boundsCorners
@@ -399,6 +455,9 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
     tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
     mapMetaUrl,
     mapRootUrl,
+    courseUrl,
+    course,
+    courseStatusText,
+    cpRadiusMeters: gameConfig.cpRadiusMeters,
   }
 }
-