From 3b4b3ee3ec362b1c265393997b31c728b2e2fad1 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Mon, 23 Mar 2026 16:57:40 +0800 Subject: [PATCH] Add animated orienteering course overlays and labels --- miniprogram/engine/layer/courseLayer.ts | 109 +++++++ miniprogram/engine/map/mapEngine.ts | 23 +- .../engine/renderer/courseLabelRenderer.ts | 120 ++++++++ miniprogram/engine/renderer/mapRenderer.ts | 5 +- .../engine/renderer/webglMapRenderer.ts | 15 +- .../engine/renderer/webglVectorRenderer.ts | 286 +++++++++++++++++- miniprogram/pages/map/map.ts | 12 +- miniprogram/pages/map/map.wxml | 8 + miniprogram/pages/map/map.wxss | 6 + miniprogram/utils/orienteeringCourse.ts | 268 ++++++++++++++++ miniprogram/utils/remoteMapConfig.ts | 63 +++- 11 files changed, 902 insertions(+), 13 deletions(-) create mode 100644 miniprogram/engine/layer/courseLayer.ts create mode 100644 miniprogram/engine/renderer/courseLabelRenderer.ts create mode 100644 miniprogram/utils/orienteeringCourse.ts diff --git a/miniprogram/engine/layer/courseLayer.ts b/miniprogram/engine/layer/courseLayer.ts new file mode 100644 index 0000000..5e5574b --- /dev/null +++ b/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 {} +} diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 79b5d2e..214c974 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/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 = { - 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 { + + + + + + + + diff --git a/miniprogram/engine/renderer/courseLabelRenderer.ts b/miniprogram/engine/renderer/courseLabelRenderer.ts new file mode 100644 index 0000000..0ad2f8e --- /dev/null +++ b/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 + } +} diff --git a/miniprogram/engine/renderer/mapRenderer.ts b/miniprogram/engine/renderer/mapRenderer.ts index 8bec3e3..2151c9c 100644 --- a/miniprogram/engine/renderer/mapRenderer.ts +++ b/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 diff --git a/miniprogram/engine/renderer/webglMapRenderer.ts b/miniprogram/engine/renderer/webglMapRenderer.ts index 1bc6d4b..51d28c0 100644 --- a/miniprogram/engine/renderer/webglMapRenderer.ts +++ b/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)) } diff --git a/miniprogram/engine/renderer/webglVectorRenderer.ts b/miniprogram/engine/renderer/webglVectorRenderer.ts index 0d408f5..83d46ec 100644 --- a/miniprogram/engine/renderer/webglVectorRenderer.ts +++ b/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 { } } } + + diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index a16fa5b..b2adc98 100644 --- a/miniprogram/pages/map/map.ts +++ b/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({ + + diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 4d79fef..8498a19 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -13,6 +13,12 @@ canvas-id="mapCanvas" class="map-canvas map-canvas--base" > + @@ -283,3 +289,5 @@ + + diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index e0c505d..8beab05 100644 --- a/miniprogram/pages/map/map.wxss +++ b/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 @@ + diff --git a/miniprogram/utils/orienteeringCourse.ts b/miniprogram/utils/orienteeringCourse.ts new file mode 100644 index 0000000..81c3578 --- /dev/null +++ b/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(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/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(/([\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(//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), + } +} diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index 4c9814a..4f0d75c 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/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 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 { + const parsed: Record = {} + 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 try { parsed = JSON.parse(text) } catch { - throw new Error('game.json 解析失败') + parsed = parseLooseJsonObject(text) } const normalized: Record = {} @@ -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