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