Add animated orienteering course overlays and labels
This commit is contained in:
109
miniprogram/engine/layer/courseLayer.ts
Normal file
109
miniprogram/engine/layer/courseLayer.ts
Normal file
@@ -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 {}
|
||||
}
|
||||
@@ -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
miniprogram/engine/renderer/courseLabelRenderer.ts
Normal file
120
miniprogram/engine/renderer/courseLabelRenderer.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
miniprogram/utils/orienteeringCourse.ts
Normal file
268
miniprogram/utils/orienteeringCourse.ts
Normal file
@@ -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(/<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),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user