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

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

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