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