refactor(tools): forward the airport endpoints to the backend

/api/service/tools/* was a second implementation of the OSM geocoding and taxi
routing the Python backend already owns — its own Overpass client, alias
matching and scoring. It drifted: it still asked Overpass for `out center tags`,
so a runway resolved to the middle of the strip rather than a threshold, which
is the runway-endpoint bug the backend fixed. Teaching the copy about runway
endpoints would mean maintaining the geometry twice, so the copy goes instead.

Forwarding exposes origin_runway_point / dest_runway_point and
include_connectors, and inherits the backend's Overpass cache and its radius
fallback for aerodromes with no generated OSM area (EDDM), which the copy
answered with an empty feature list. Failures now carry an HTTP status as well
as the error code in the body; the old copy answered every error with 200.

The frequencies call sites needed real types: airportGeocode.ts sat under
server/api with no default export, so Nitro registered it as a route whose
return type collapsed to any and poisoned the whole API type map. The typecheck
was green because of it, not despite it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-27 11:30:52 +02:00
parent d9ec4f40a0
commit d70fffdd68
7 changed files with 290 additions and 904 deletions

View File

@@ -1,100 +1,17 @@
import { defineEventHandler, getQuery } from 'h3'
import {
fetchAirportFeatures,
resolveFeature,
toGeocodePayload,
type GeocodeQuery
} from './airportGeocode'
import { forwardToRadioBackend } from '../../../utils/radioBackend'
function parseCoordinate(value: unknown) {
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function hasCoordinatePair(query: GeocodeQuery) {
return typeof query.lat === 'number' && typeof query.lon === 'number'
}
function normalizeQuery(query: GeocodeQuery) {
return {
name: query.name?.trim() || null,
lat: hasCoordinatePair(query) ? (query.lat as number) : null,
lon: hasCoordinatePair(query) ? (query.lon as number) : null
}
}
export default defineEventHandler(async (event) => {
const q = getQuery(event)
const airport = typeof q.airport === 'string' ? q.airport.trim().toUpperCase() : ''
const originQuery: GeocodeQuery = {
name: typeof q.origin_name === 'string' ? q.origin_name : undefined,
lat: parseCoordinate(q.origin_lat),
lon: parseCoordinate(q.origin_lng ?? q.origin_lon)
}
const destQuery: GeocodeQuery = {
name: typeof q.dest_name === 'string' ? q.dest_name : undefined,
lat: parseCoordinate(q.dest_lat),
lon: parseCoordinate(q.dest_lng ?? q.dest_lon)
}
const normalizedOrigin = normalizeQuery(originQuery)
const normalizedDest = normalizeQuery(destQuery)
const hasOriginQuery = Boolean(normalizedOrigin.name) || hasCoordinatePair(originQuery)
const hasDestQuery = Boolean(normalizedDest.name) || hasCoordinatePair(destQuery)
if (!airport) {
return { error: 'missing_airport' }
}
if (!hasOriginQuery && !hasDestQuery) {
return { error: 'missing_query', airport }
}
let features
try {
features = await fetchAirportFeatures(airport)
} catch (error) {
return { error: 'overpass_error', airport, details: (error as Error).message }
}
if (!features.length) {
return {
error: 'no_features',
airport,
origin: hasOriginQuery ? { query: normalizedOrigin, result: null } : undefined,
dest: hasDestQuery ? { query: normalizedDest, result: null } : undefined
}
}
const resolve = (query: GeocodeQuery) => {
const match = resolveFeature(features, query)
if (!match) return null
return toGeocodePayload(match)
}
const response: Record<string, unknown> = {
airport,
feature_count: features.length
}
if (hasOriginQuery) {
response.origin = {
query: normalizedOrigin,
result: resolve(originQuery)
}
}
if (hasDestQuery) {
response.dest = {
query: normalizedDest,
result: resolve(destQuery)
}
}
return response
})
/**
* Resolve named aerodrome features to coordinates, or coordinates to the
* nearest named feature, for one airport.
*
* Forwarded to the Python backend, which owns the OSM feature dataset and the
* alias matching. Beyond ending the duplication, this inherits two things the
* Nuxt copy never had: the Overpass response cache, and the radius fallback
* for aerodromes whose OSM relation has no generated area (EDDM), which the
* copy answered with an empty feature list.
*/
export default defineEventHandler(async (event) =>
forwardToRadioBackend('/api/service/tools/airport-geocode', getQuery(event))
)

View File

@@ -1,466 +0,0 @@
import https from 'node:https'
export type FeatureType =
| 'runway'
| 'gate'
| 'parking_position'
| 'taxiway'
| 'holding_position'
| 'stand'
| 'unknown'
export type OsmElement =
| ({
type: 'node'
id: number
lat: number
lon: number
tags?: Record<string, string>
})
| ({
type: 'way'
id: number
nodes: number[]
center?: { lat: number; lon: number }
tags?: Record<string, string>
})
export type AirportFeature = {
osmType: 'node' | 'way'
osmId: number
type: FeatureType
lat: number
lon: number
tags: Record<string, string>
aliases: string[]
primaryAlias: string
normalizedAliases: Map<string, string>
}
export type GeocodeQuery = {
name?: string | null
lat?: number | null
lon?: number | null
}
export type GeocodeMatch = {
feature: AirportFeature
matchedAlias: string | null
distanceMeters?: number
source: 'name' | 'coordinate'
}
const OVERPASS_ENDPOINT = 'https://overpass-api.de/api/interpreter'
const EARTH_RADIUS = 6371000
function toRadians(value: number) {
return (value * Math.PI) / 180
}
function haversineDistance(
a: { lat: number; lon: number },
b: { lat: number; lon: number }
) {
const dLat = toRadians(b.lat - a.lat)
const dLon = toRadians(b.lon - a.lon)
const lat1 = toRadians(a.lat)
const lat2 = toRadians(b.lat)
const sinLat = Math.sin(dLat / 2)
const sinLon = Math.sin(dLon / 2)
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h))
}
function fetchOverpass(query: string) {
return new Promise<any>((resolve, reject) => {
const req = https.request(
OVERPASS_ENDPOINT,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
},
(res) => {
let data = ''
res.on('data', (d) => (data += d))
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`))
}
try {
resolve(JSON.parse(data))
} catch (err) {
reject(err)
}
})
}
)
req.on('error', reject)
req.write('data=' + encodeURIComponent(query))
req.end()
})
}
function normalize(value: string) {
return value.toUpperCase().replace(/[^A-Z0-9]/g, '')
}
function normalizeWithPrefixes(value: string) {
const variants = new Set<string>()
const trimmed = value.trim().toUpperCase()
if (!trimmed) return variants
const base = normalize(trimmed)
if (base) variants.add(base)
const withoutRunway = trimmed.replace(/^(RUNWAY|RWY)\s+/, '')
if (withoutRunway !== trimmed) {
const normalized = normalize(withoutRunway)
if (normalized) variants.add(normalized)
}
const withoutGate = trimmed.replace(/^(GATE|STAND)\s+/, '')
if (withoutGate !== trimmed) {
const normalized = normalize(withoutGate)
if (normalized) variants.add(normalized)
}
const tokens = trimmed.split(/\s+/)
if (tokens.length > 1) {
const lastToken = normalize(tokens[tokens.length - 1])
if (lastToken) variants.add(lastToken)
}
return variants
}
function deriveFeatureType(tags: Record<string, string>): FeatureType {
const aeroway = tags.aeroway as FeatureType | undefined
if (!aeroway) return 'unknown'
if (aeroway === 'parking_position') return 'parking_position'
if (aeroway === 'gate') return 'gate'
if (aeroway === 'runway') return 'runway'
if (aeroway === 'taxiway') return 'taxiway'
if (aeroway === 'holding_position') return 'holding_position'
return 'unknown'
}
function buildAliases(tags: Record<string, string>, featureType: FeatureType) {
const aliases = new Set<string>()
let primaryAlias: string | null = null
const addAlias = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return
if (!aliases.has(trimmed)) {
aliases.add(trimmed)
if (!primaryAlias) primaryAlias = trimmed
}
}
const candidates: string[] = []
const ref = tags.ref
const name = tags.name
const designation = tags.designation
const icao = tags['ref:icao']
if (ref) candidates.push(ref)
if (designation && designation !== ref) candidates.push(designation)
if (name && name !== ref) candidates.push(name)
if (icao && icao !== ref) candidates.push(icao)
if (featureType === 'runway') {
const runway = tags['ref:runway'] || tags['ref:icao:runway']
if (runway) candidates.push(runway)
}
const addCandidate = (candidate: string) => {
if (!candidate) return
if (featureType === 'runway') {
const parts = candidate.split(/[\/;,]/)
if (parts.length > 1) {
for (const part of parts) addAlias(part)
}
}
addAlias(candidate)
}
for (const candidate of candidates) {
addCandidate(candidate)
}
if (featureType === 'parking_position') {
const refStand = tags['ref:stand']
if (refStand) addAlias(refStand)
}
const aliasList = Array.from(aliases)
return {
aliases: aliasList,
primaryAlias: primaryAlias ?? aliasList[0] ?? null
}
}
function createFeature(element: OsmElement): AirportFeature | null {
const tags = element.tags ?? {}
const featureType = deriveFeatureType(tags)
const lat = element.type === 'node' ? element.lat : element.center?.lat
const lon = element.type === 'node' ? element.lon : element.center?.lon
if (lat === undefined || lon === undefined) return null
const aliasResult = buildAliases(tags, featureType)
if (aliasResult.aliases.length === 0 || !aliasResult.primaryAlias) return null
const { aliases, primaryAlias } = aliasResult as { aliases: string[]; primaryAlias: string }
const normalizedAliases = new Map<string, string>()
for (const alias of aliases) {
const variants = normalizeWithPrefixes(alias)
for (const variant of variants) {
if (!normalizedAliases.has(variant)) {
normalizedAliases.set(variant, alias)
}
}
}
if (normalizedAliases.size === 0) return null
return {
osmType: element.type,
osmId: element.id,
type: featureType === 'parking_position' ? 'stand' : featureType,
lat,
lon,
tags,
aliases,
primaryAlias,
normalizedAliases
}
}
function analyzeQuery(query: string) {
const trimmed = query.trim()
if (!trimmed) {
return {
trimmed,
sanitizedQuery: '',
runwayBias: false
}
}
let sanitized = trimmed
const patterns: RegExp[] = [
/\b(runway|rwy)\b/gi,
/\b(gate)\b/gi,
/\b(stand|parking|standposition)\b/gi,
/\b(taxiway|taxi)\b/gi,
/\b(holding|holdshort|holdingpoint)\b/gi
]
for (const pattern of patterns) {
sanitized = sanitized.replace(pattern, ' ')
}
sanitized = sanitized.replace(/\s+/g, ' ').trim()
const runwayBias =
/^\s*\d/.test(trimmed) ||
/\b(runway|rwy)\b/i.test(query) ||
/\d{1,2}[LRC]?\b/i.test(trimmed) ||
/\d{2}\s*\/\s*\d{2}/.test(trimmed)
return {
trimmed,
sanitizedQuery: sanitized,
runwayBias
}
}
export function buildMapUrl(feature: AirportFeature) {
const zoom = feature.type === 'runway' ? 17 : 19
const lat = feature.lat.toFixed(6)
const lon = feature.lon.toFixed(6)
return `https://www.openstreetmap.org/${feature.osmType}/${feature.osmId}?mlat=${lat}&mlon=${lon}#map=${zoom}/${lat}/${lon}`
}
export async function fetchAirportFeatures(airport: string) {
const overpassQuery = `
[out:json][timeout:60];
(
area["aeroway"="aerodrome"]["ref:icao"="${airport}"];
area["aeroway"="aerodrome"]["icao"="${airport}"];
area["aeroway"="aerodrome"]["ref"="${airport}"];
)->.airport;
(
node(area.airport)["aeroway"="parking_position"];
way(area.airport)["aeroway"="parking_position"];
node(area.airport)["aeroway"="gate"];
way(area.airport)["aeroway"="gate"];
node(area.airport)["aeroway"="runway"];
node(area.airport)["aeroway"="taxiway"];
node(area.airport)["aeroway"="holding_position"];
way(area.airport)["aeroway"="runway"];
way(area.airport)["aeroway"="taxiway"];
way(area.airport)["aeroway"="holding_position"];
);
out center tags;
`
const osm = await fetchOverpass(overpassQuery)
const features: AirportFeature[] = []
if (Array.isArray(osm?.elements)) {
for (const element of osm.elements as OsmElement[]) {
if (element.type !== 'node' && element.type !== 'way') continue
const feature = createFeature(element)
if (feature) features.push(feature)
}
}
return features
}
export function matchFeatureByName(features: AirportFeature[], query: string) {
if (!query) return null
const { trimmed, sanitizedQuery, runwayBias } = analyzeQuery(query)
const baseQuery = sanitizedQuery || trimmed
if (!baseQuery) return null
const queryVariants = normalizeWithPrefixes(baseQuery)
if (queryVariants.size === 0) return null
const variantList = Array.from(queryVariants)
let best:
| {
feature: AirportFeature
matchedAlias: string
score: number
}
| null = null
for (const feature of features) {
for (const [aliasVariant, originalAlias] of feature.normalizedAliases) {
for (const variant of variantList) {
let score = 0
if (aliasVariant === variant) {
score = 100
} else if (aliasVariant.includes(variant) || variant.includes(aliasVariant)) {
score = 70
} else {
continue
}
if (runwayBias) {
if (feature.type === 'runway') score += 20
else score -= 10
}
if (feature.type === 'runway' && /^\d/.test(variant)) {
score += 10
}
if (feature.type === 'gate' || feature.type === 'stand') {
score += 2
}
if (!best || score > best.score) {
best = {
feature,
matchedAlias: originalAlias,
score
}
}
}
}
}
if (!best) return null
return {
feature: best.feature,
matchedAlias: best.matchedAlias
}
}
export function matchFeatureByCoordinate(
features: AirportFeature[],
lat: number,
lon: number
) {
let best:
| {
feature: AirportFeature
distanceMeters: number
}
| null = null
for (const feature of features) {
const distanceMeters = haversineDistance({ lat, lon }, { lat: feature.lat, lon: feature.lon })
if (!best || distanceMeters < best.distanceMeters) {
best = { feature, distanceMeters }
}
}
if (!best) return null
return best
}
export function resolveFeature(features: AirportFeature[], query: GeocodeQuery): GeocodeMatch | null {
const name = query.name?.trim()
const lat = typeof query.lat === 'number' ? query.lat : null
const lon = typeof query.lon === 'number' ? query.lon : null
if (name) {
const match = matchFeatureByName(features, name)
if (match) {
return {
feature: match.feature,
matchedAlias: match.matchedAlias,
source: 'name'
}
}
}
if (lat !== null && lon !== null) {
const match = matchFeatureByCoordinate(features, lat, lon)
if (match) {
return {
feature: match.feature,
matchedAlias: match.feature.primaryAlias,
distanceMeters: match.distanceMeters,
source: 'coordinate'
}
}
}
return null
}
export function toGeocodePayload(match: GeocodeMatch) {
const feature = match.feature
const resolvedName = match.matchedAlias ?? feature.primaryAlias
return {
type: feature.type,
name: resolvedName,
lat: feature.lat,
lon: feature.lon,
matched_alias: match.matchedAlias,
primary_alias: feature.primaryAlias,
map_url: buildMapUrl(feature),
osm: {
type: feature.osmType,
id: feature.osmId,
tags: feature.tags
},
source: match.source,
distance_m: match.distanceMeters ?? null
}
}

View File

@@ -1,342 +1,18 @@
import { defineEventHandler, getQuery } from 'h3'
import https from 'node:https'
import { fetchAirportFeatures, resolveFeature, toGeocodePayload } from './airportGeocode'
import { forwardToRadioBackend } from '../../../utils/radioBackend'
// TODO @najajan-de: für die taxiroute soll gelten:
// wenn es um wegbeschreibungen mit dem name eines runways (also er muss erst über ein query rausfinden dass der source oder dest name vom typ eines runways ist) soll man angeben können ob man den node vom anfang oder ende des runways nutzen möchte weil aktuell nutz osm die kurve des runway das ja aus viele nodes besteht und nimmt davon die mitte was ja quatsch ist. also wenn man ein runway name angibt dann soll man sagen können ob man von diesen nodes aus denen der runway path besteht start (default) oder ende nutzen will). das gitl nur wenn er erkannt hat dass das was man da per name referenziert hat ein runway ist
function parseCoordinate(value: unknown) {
if (value === undefined || value === null) return null
if (typeof value === 'string' && value.trim() === '') return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
export default defineEventHandler(async (event) => {
const q = getQuery(event)
const airport = typeof q.airport === 'string' ? q.airport.trim().toUpperCase() : ''
const originName = typeof q.origin_name === 'string' ? q.origin_name.trim() : ''
const destName = typeof q.dest_name === 'string' ? q.dest_name.trim() : ''
let oLat: number | null = parseCoordinate(q.origin_lat)
let oLon: number | null = parseCoordinate(q.origin_lng ?? q.origin_lon)
let dLat: number | null = parseCoordinate(q.dest_lat)
let dLon: number | null = parseCoordinate(q.dest_lng ?? q.dest_lon)
const radius = parseCoordinate(q.radius) ?? 5000
const originCoordsProvided = oLat !== null && oLon !== null
const destCoordsProvided = dLat !== null && dLon !== null
if (!originCoordsProvided && !originName) {
return { error: 'missing_origin', details: 'provide origin coordinates or name', airport: airport || null }
}
if (!destCoordsProvided && !destName) {
return { error: 'missing_destination', details: 'provide destination coordinates or name', airport: airport || null }
}
const requiresGeocode = (!originCoordsProvided && !!originName) || (!destCoordsProvided && !!destName)
let features: Awaited<ReturnType<typeof fetchAirportFeatures>> | null = null
const ensureFeatures = async () => {
if (!features) {
if (!airport) {
throw Object.assign(new Error('missing airport for geocode'), { code: 'missing_airport' })
}
features = await fetchAirportFeatures(airport)
}
return features
}
let originMatch: ReturnType<typeof resolveFeature> = null
let destMatch: ReturnType<typeof resolveFeature> = null
try {
if (requiresGeocode) {
await ensureFeatures()
}
} catch (error) {
const err = error as Error & { code?: string }
if (err.code === 'missing_airport') {
return { error: 'missing_airport', details: 'airport is required when using feature names' }
}
return { error: 'overpass_error', airport, details: err.message }
}
if (!originCoordsProvided && originName) {
const match = resolveFeature(features!, { name: originName })
if (!match) {
return { error: 'origin_not_found', airport, origin: { name: originName } }
}
originMatch = match
oLat = match.feature.lat
oLon = match.feature.lon
}
if (!destCoordsProvided && destName) {
const match = resolveFeature(features!, { name: destName })
if (!match) {
return { error: 'dest_not_found', airport, dest: { name: destName } }
}
destMatch = match
dLat = match.feature.lat
dLon = match.feature.lon
}
if (originCoordsProvided && originName && airport) {
try {
const match = resolveFeature(features ?? (await ensureFeatures()), { name: originName })
if (match) originMatch = match
} catch {
// ignore lookup failures when coordinates are already provided
}
}
if (destCoordsProvided && destName && airport) {
try {
const match = resolveFeature(features ?? (await ensureFeatures()), { name: destName })
if (match) destMatch = match
} catch {
// ignore lookup failures when coordinates are already provided
}
}
if (oLat === null || oLon === null || dLat === null || dLon === null) {
return {
error: 'missing_coordinates',
airport: airport || null,
origin: { lat: oLat, lon: oLon, name: originName || null },
dest: { lat: dLat, lon: dLon, name: destName || null }
}
}
const oLatNum = oLat as number
const oLonNum = oLon as number
const dLatNum = dLat as number
const dLonNum = dLon as number
const originQuerySummary = {
name: originName || null,
lat: originCoordsProvided ? oLatNum : null,
lon: originCoordsProvided ? oLonNum : null
}
const destQuerySummary = {
name: destName || null,
lat: destCoordsProvided ? dLatNum : null,
lon: destCoordsProvided ? dLonNum : null
}
const originFeaturePayload = originMatch ? toGeocodePayload(originMatch) : null
const destFeaturePayload = destMatch ? toGeocodePayload(destMatch) : null
const endpoint = 'https://overpass-api.de/api/interpreter'
const overpassQ = `
[out:json][timeout:90];
(
way["aeroway"="taxiway"](around:${radius},${oLatNum},${oLonNum});
way["aeroway"="taxiway"](around:${radius},${dLatNum},${dLonNum});
);
(._;>;);
out body;
`
// --- utils ---
const toRad = (d:number)=>d*Math.PI/180
function haversine(a:{lat:number,lon:number}, b:{lat:number,lon:number}){
const R=6371000
const dLat=toRad(b.lat-a.lat), dLon=toRad(b.lon-a.lon)
const la1=toRad(a.lat), la2=toRad(b.lat)
const s=Math.sin(dLat/2)**2 + Math.cos(la1)*Math.cos(la2)*Math.sin(dLon/2)**2
return 2*R*Math.asin(Math.sqrt(s))
}
function fetchOverpass(query: string) {
return new Promise<any>((resolve, reject) => {
const req = https.request(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}, res => {
let data = ''
res.on('data', d => (data += d))
res.on('end', () => {
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0,200)}`))
resolve(JSON.parse(data))
})
})
req.on('error', reject)
req.write('data=' + encodeURIComponent(query))
req.end()
})
}
// --- build graph from OSM ---
const osm = await fetchOverpass(overpassQ)
const nodes = new Map<number, {id:number,lat:number,lon:number}>()
const ways: Array<any> = []
for (const el of osm.elements) {
if (el.type === 'node') nodes.set(el.id, { id: el.id, lat: el.lat, lon: el.lon })
else if (el.type === 'way') ways.push(el)
}
type Edge = { u:number, v:number, w:number, way_id:number, name:string|null }
const edges: Edge[] = []
for (const w of ways) {
const name: string | null = w.tags?.name || w.tags?.ref || null
for (let i=0;i<w.nodes.length-1;i++){
const a = nodes.get(w.nodes[i])
const b = nodes.get(w.nodes[i+1])
if (!a || !b) continue
const dist = haversine(a,b)
edges.push({ u:a.id, v:b.id, w:dist, way_id:w.id, name })
edges.push({ u:b.id, v:a.id, w:dist, way_id:w.id, name })
}
}
// --- adjacency + quick edge lookup ---
const adj = new Map<number, Array<{to:number, w:number}>>()
const edgeName = new Map<string, {name:string|null, way_id:number}>()
for (const e of edges){
if (!adj.has(e.u)) adj.set(e.u, [])
adj.get(e.u)!.push({ to:e.v, w:e.w })
edgeName.set(`${e.u}->${e.v}`, { name:e.name, way_id:e.way_id })
}
// --- nearest node to a coordinate ---
function nearestNode(lat:number, lon:number){
let bestId:number|undefined, bestD=Infinity
for (const n of nodes.values()){
const d = haversine({lat,lon},{lat:n.lat,lon:n.lon})
if (d<bestD){ bestD=d; bestId=n.id }
}
if (bestId===undefined) return null
const nn = nodes.get(bestId)!
return { node_id: bestId, lat: nn.lat, lon: nn.lon, distance_m: bestD }
}
const startAttach = nearestNode(oLatNum,oLonNum)
const endAttach = nearestNode(dLatNum,dLonNum)
if (!startAttach || !endAttach) {
return {
error: 'no_nodes_in_area',
airport: airport || null,
origin: { lat:oLatNum, lon:oLonNum, query: originQuerySummary, feature: originFeaturePayload },
dest: { lat:dLatNum, lon:dLonNum, query: destQuerySummary, feature: destFeaturePayload }
}
}
// --- dijkstra shortest path (meters) ---
function dijkstra(src:number, dst:number){
const dist = new Map<number, number>()
const prev = new Map<number, number>()
const visited = new Set<number>()
const pq: Array<{id:number, d:number}> = []
const push = (id:number, d:number)=>{
pq.push({id,d})
// simple binary heap-free: insertion sort-ish
pq.sort((a,b)=>a.d-b.d)
}
for (const id of nodes.keys()) dist.set(id, Infinity)
dist.set(src, 0); push(src,0)
while (pq.length){
const {id:u} = pq.shift()!
if (visited.has(u)) continue
visited.add(u)
if (u===dst) break
const lst = adj.get(u); if (!lst) continue
for (const {to:v, w} of lst){
const alt = dist.get(u)! + w
if (alt < dist.get(v)!){
dist.set(v, alt)
prev.set(v, u)
push(v, alt)
}
}
}
if (!prev.has(dst) && src!==dst) return null
const path:number[] = []
let u = dst
path.push(u)
while (u !== src){
const p = prev.get(u)
if (p===undefined){ break }
u = p
path.push(u)
}
path.reverse()
const total_m = dist.get(dst)!
return { path, total_m }
}
const sp = dijkstra(startAttach.node_id, endAttach.node_id)
if (!sp) {
return {
airport: airport || null,
origin: { lat:oLatNum, lon:oLonNum, query: originQuerySummary, feature: originFeaturePayload },
dest: { lat:dLatNum, lon:dLonNum, query: destQuerySummary, feature: destFeaturePayload },
start_attach: startAttach,
end_attach: endAttach,
route: null,
names: [],
names_collapsed: []
}
}
// names along path (drop null/unnamed); keep both raw sequence and collapsed variant
const namesRaw: string[] = []
const hasDigits = (value: string) => /\d/.test(value)
for (let i=0;i<sp.path.length-1;i++){
const u = sp.path[i], v = sp.path[i+1]
const meta = edgeName.get(`${u}->${v}`) || edgeName.get(`${v}->${u}`)
const nm = meta?.name?.trim()
if (!nm) continue
if (namesRaw.length === 0 || namesRaw[namesRaw.length-1] !== nm) namesRaw.push(nm)
}
const namesCollapsed: string[] = []
for (const nm of namesRaw) {
const last = namesCollapsed[namesCollapsed.length - 1]
if (last === nm) continue
const lastNormalized = last?.replace(/\s+/g, '').match(/^([A-Za-z]+)/)?.[1]?.toUpperCase()
const currentNormalized = nm.replace(/\s+/g, '').match(/^([A-Za-z]+)/)?.[1]?.toUpperCase()
const lastHasDigits = last ? hasDigits(last) : false
if (
lastNormalized &&
currentNormalized &&
lastNormalized === currentNormalized &&
lastHasDigits &&
hasDigits(nm)
) {
continue
}
namesCollapsed.push(nm)
}
return {
airport: airport || null,
origin: { lat:oLatNum, lon:oLonNum, query: originQuerySummary, feature: originFeaturePayload },
dest: { lat:dLatNum, lon:dLonNum, query: destQuerySummary, feature: destFeaturePayload },
start_attach: startAttach,
end_attach: endAttach,
route: {
node_ids: sp.path,
total_distance_m: sp.total_m
},
names: namesRaw,
names_collapsed: namesCollapsed
}
})
/**
* Compute a taxi route between two points or named aerodrome features.
*
* The routing itself lives in the Python backend, which owns the Overpass
* client and its cache, the airport dataset, and the runway-threshold logic.
* This route used to be a second implementation of all of it and drifted:
* it asked Overpass for `out center tags`, so a runway resolved to the centre
* of the strip instead of a threshold — the runway-endpoint bug the backend
* fixed with `origin_runway_point` / `dest_runway_point` (start|end|center,
* default start), which forwarding now exposes here too.
*/
export default defineEventHandler(async (event) =>
forwardToRadioBackend('/api/service/tools/taxiroute', getQuery(event))
)