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),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
|
||||
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
|
||||
|
||||
export interface TileZoomBounds {
|
||||
minX: number
|
||||
@@ -24,11 +25,17 @@ export interface RemoteMapConfig {
|
||||
tileBoundsByZoom: Record<number, TileZoomBounds>
|
||||
mapMetaUrl: string
|
||||
mapRootUrl: string
|
||||
courseUrl: string | null
|
||||
course: OrienteeringCourseData | null
|
||||
courseStatusText: string
|
||||
cpRadiusMeters: number
|
||||
}
|
||||
|
||||
interface ParsedGameConfig {
|
||||
mapRoot: string
|
||||
mapMeta: string
|
||||
course: string | null
|
||||
cpRadiusMeters: number
|
||||
declinationDeg: number
|
||||
}
|
||||
|
||||
@@ -146,12 +153,43 @@ function parseDeclinationValue(rawValue: unknown): number {
|
||||
return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91
|
||||
}
|
||||
|
||||
function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
|
||||
const numericValue = Number(rawValue)
|
||||
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
|
||||
}
|
||||
|
||||
function parseLooseJsonObject(text: string): Record<string, unknown> {
|
||||
const parsed: Record<string, unknown> = {}
|
||||
const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pairPattern.exec(text))) {
|
||||
const rawValue = match[2]
|
||||
let value: unknown = rawValue
|
||||
|
||||
if (rawValue === 'true' || rawValue === 'false') {
|
||||
value = rawValue === 'true'
|
||||
} else if (rawValue === 'null') {
|
||||
value = null
|
||||
} else if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
|
||||
value = match[3] || ''
|
||||
} else {
|
||||
const numericValue = Number(rawValue)
|
||||
value = Number.isFinite(numericValue) ? numericValue : rawValue
|
||||
}
|
||||
|
||||
parsed[match[1]] = value
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseGameConfigFromJson(text: string): ParsedGameConfig {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('game.json 解析失败')
|
||||
parsed = parseLooseJsonObject(text)
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {}
|
||||
@@ -169,6 +207,8 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
|
||||
return {
|
||||
mapRoot,
|
||||
mapMeta,
|
||||
course: typeof normalized.course === 'string' ? normalized.course : null,
|
||||
cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5),
|
||||
declinationDeg: parseDeclinationValue(normalized.declination),
|
||||
}
|
||||
}
|
||||
@@ -200,6 +240,8 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig {
|
||||
return {
|
||||
mapRoot,
|
||||
mapMeta,
|
||||
course: typeof config.course === 'string' ? config.course : null,
|
||||
cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
|
||||
declinationDeg: parseDeclinationValue(config.declination),
|
||||
}
|
||||
}
|
||||
@@ -373,9 +415,23 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
||||
const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl)
|
||||
const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta)
|
||||
const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot)
|
||||
const courseUrl = gameConfig.course ? resolveUrl(gameConfigUrl, gameConfig.course) : null
|
||||
const mapMetaText = await requestText(mapMetaUrl)
|
||||
const mapMeta = parseMapMeta(mapMetaText)
|
||||
|
||||
let course: OrienteeringCourseData | null = null
|
||||
let courseStatusText = courseUrl ? '路线待加载' : '未配置路线'
|
||||
if (courseUrl) {
|
||||
try {
|
||||
const courseText = await requestText(courseUrl)
|
||||
course = parseOrienteeringCourseKml(courseText)
|
||||
courseStatusText = `路线已载入 (${course.layers.controls.length} controls)`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知错误'
|
||||
courseStatusText = `路线加载失败: ${message}`
|
||||
}
|
||||
}
|
||||
|
||||
const defaultZoom = clamp(17, mapMeta.minZoom, mapMeta.maxZoom)
|
||||
const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
|
||||
const centerWorldTile = boundsCorners
|
||||
@@ -399,6 +455,9 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
||||
tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
|
||||
mapMetaUrl,
|
||||
mapRootUrl,
|
||||
courseUrl,
|
||||
course,
|
||||
courseStatusText,
|
||||
cpRadiusMeters: gameConfig.cpRadiusMeters,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user