Add animated orienteering course overlays and labels

This commit is contained in:
2026-03-23 16:57:40 +08:00
parent cb190f3c66
commit 3b4b3ee3ec
11 changed files with 902 additions and 13 deletions

View File

@@ -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,
}
}