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(/([\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(//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), } }