Add animated orienteering course overlays and labels
This commit is contained in:
268
miniprogram/utils/orienteeringCourse.ts
Normal file
268
miniprogram/utils/orienteeringCourse.ts
Normal 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(/</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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user