Add animated orienteering course overlays and labels
This commit is contained in:
@@ -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