269 lines
6.7 KiB
TypeScript
269 lines
6.7 KiB
TypeScript
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),
|
|
}
|
|
}
|