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

@@ -20,6 +20,19 @@ export type AirportFrequencyEntry = {
lastUpdated?: string
}
/**
* Shape of `GET /api/airports/:icao/frequencies`.
*
* Stated explicitly because `api.get()` is generic over the response and
* cannot infer it from a template-literal path.
*/
type AirportFrequenciesResponse = {
icao: string
airportName?: string
sources: { vatsim: boolean; openaip: boolean }
frequencies: AirportFrequencyEntry[]
}
export type DisplayAirportFrequencyEntry = AirportFrequencyEntry & {
displayKey: string
sourceList: Array<'vatsim' | 'openaip'>
@@ -429,7 +442,7 @@ export function useFrequencyPresets(
if (!destinationIcao.value) return
if (destinationIcao.value === activeAirportIcao.value?.trim().toUpperCase()) return
try {
const response = await api.get(`/api/airports/${encodeURIComponent(destinationIcao.value)}/frequencies`)
const response = await api.get<AirportFrequenciesResponse>(`/api/airports/${encodeURIComponent(destinationIcao.value)}/frequencies`)
destinationFrequencies.value = Array.isArray(response?.frequencies)
? response.frequencies as AirportFrequencyEntry[]
: []
@@ -457,7 +470,7 @@ export function useFrequencyPresets(
// broadcast text onto the frequency entries, so a partial load would show
// an ATIS station with no information letter.
const [response, report] = await Promise.all([
api.get(`/api/airports/${encodeURIComponent(icao)}/frequencies`),
api.get<AirportFrequenciesResponse>(`/api/airports/${encodeURIComponent(icao)}/frequencies`),
api.get(`/api/airports/${encodeURIComponent(icao)}/atis`).catch((err: unknown) => {
console.error('Failed to load ATIS report:', err)
return null

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))
)

View File

@@ -0,0 +1,59 @@
import { useRuntimeConfig } from '#imports'
/**
* Forwarding for the public `/api/service/tools/*` endpoints.
*
* The OSM airport geocoder and the taxi router live in the Python backend,
* which owns the Overpass client and its cache, the airport dataset, and the
* runway-threshold logic. Nuxt used to carry a second copy of all of it; the
* copy drifted and started routing to runway centres. These helpers keep the
* documented public paths while leaving exactly one implementation.
*/
/** Build the upstream URL, forwarding every supplied query parameter. */
export function backendToolUrl(
baseUrl: string,
path: string,
query: Record<string, unknown>
): string {
const params = new URLSearchParams()
for (const [key, raw] of Object.entries(query)) {
// h3 yields an array when a parameter is repeated; the backend takes one.
const value = Array.isArray(raw) ? raw[0] : raw
if (value === undefined || value === null) continue
const text = String(value).trim()
if (!text) continue
params.set(key, text)
}
const base = baseUrl.replace(/\/+$/, '')
const search = params.toString()
return search ? `${base}${path}?${search}` : `${base}${path}`
}
export function radioBackendBaseUrl(): string {
const config = useRuntimeConfig() as any
return config?.public?.radioBackendUrl || 'http://127.0.0.1:8000'
}
/**
* Forward a tools request and hand back what the backend answered.
*
* The backend reports failures with real status codes and keeps the old error
* code in the body (`{"error": "origin_not_found", …}`). Both are passed
* through: rewriting a 404 back into the legacy HTTP 200 would hide a failed
* lookup from every caller that checks the status.
*/
export async function forwardToRadioBackend(
path: string,
query: Record<string, unknown>
): Promise<Record<string, any>> {
const url = backendToolUrl(radioBackendBaseUrl(), path, query)
const fetcher = (globalThis as any).$fetch as (
target: string,
options?: Record<string, unknown>
) => Promise<Record<string, any>>
return await fetcher(url, { method: 'GET' })
}

View File

@@ -0,0 +1,184 @@
/**
* `/api/service/tools/*` forwards to the Python backend.
*
* These endpoints used to be a second, older implementation of the OSM
* geocoding and taxi routing that the backend already owns — with its own
* Overpass client, alias matching and scoring. The copy 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 bug the backend fixed with
* `origin_runway_point` / `dest_runway_point`.
*
* Forwarding is what keeps the two from drifting again, so what is pinned here
* is that every parameter reaches the backend untouched — including the ones
* the Nuxt copy never knew about — and that an upstream failure is reported as
* a failure rather than smoothed over.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { backendToolUrl, radioBackendBaseUrl } from '~~/server/utils/radioBackend'
/** An h3 event carrying just the query string the handler reads. */
function createEvent(search: string) {
return { path: `/api/service/tools/x?${search}`, node: { req: { url: `/x?${search}` } } } as any
}
/** Capture the URL the handler asks for, and answer with `response`. */
async function callHandler(modulePath: string, search: string, response: unknown = { ok: true }) {
;(globalThis as any).defineEventHandler = (handler: any) => handler
const calls: string[] = []
const original = (globalThis as any).$fetch
;(globalThis as any).$fetch = async (target: string) => {
calls.push(target)
if (response instanceof Error) throw response
return response
}
try {
const mod = await import(modulePath)
const result = await mod.default(createEvent(search))
return { calls, result }
} finally {
;(globalThis as any).$fetch = original
}
}
describe('backendToolUrl', () => {
const build = (query: Record<string, any>, base = 'http://127.0.0.1:8000') =>
new URL(backendToolUrl(base, '/api/service/tools/taxiroute', query))
it('targets the backend path', () => {
const url = build({ airport: 'EDDF' })
assert.equal(url.origin, 'http://127.0.0.1:8000')
assert.equal(url.pathname, '/api/service/tools/taxiroute')
})
it('forwards the documented parameters', () => {
const url = build({
airport: 'EDDF',
origin_name: 'Gate A5',
dest_name: 'RWY 25C',
radius: '2500',
})
assert.equal(url.searchParams.get('airport'), 'EDDF')
assert.equal(url.searchParams.get('origin_name'), 'Gate A5')
assert.equal(url.searchParams.get('dest_name'), 'RWY 25C')
assert.equal(url.searchParams.get('radius'), '2500')
})
it('forwards the runway endpoint parameters', () => {
// The whole point of forwarding: the Nuxt copy could not express these.
const url = build({
airport: 'EDDF',
origin_name: 'Gate A5',
dest_name: 'RWY 25C',
dest_runway_point: 'end',
origin_runway_point: 'center',
})
assert.equal(url.searchParams.get('dest_runway_point'), 'end')
assert.equal(url.searchParams.get('origin_runway_point'), 'center')
})
it('forwards parameters the proxy has never heard of', () => {
// A whitelist would silently swallow anything the backend gains later.
assert.equal(build({ include_connectors: 'true' }).searchParams.get('include_connectors'), 'true')
})
it('drops empty and absent values instead of sending blanks', () => {
const url = build({ airport: 'EDDF', origin_name: '', dest_name: undefined, radius: null })
assert.equal(url.searchParams.has('origin_name'), false)
assert.equal(url.searchParams.has('dest_name'), false)
assert.equal(url.searchParams.has('radius'), false)
})
it('takes the first value of a repeated parameter', () => {
assert.equal(build({ airport: ['EDDF', 'EDDM'] }).searchParams.get('airport'), 'EDDF')
})
it('accepts a base URL with a trailing slash', () => {
const url = build({ airport: 'EDDF' }, 'https://backend.example.com/')
assert.equal(url.origin, 'https://backend.example.com')
assert.equal(url.pathname, '/api/service/tools/taxiroute')
})
it('omits the query string entirely when there is nothing to send', () => {
assert.equal(
backendToolUrl('http://127.0.0.1:8000', '/api/service/tools/taxiroute', {}),
'http://127.0.0.1:8000/api/service/tools/taxiroute'
)
})
})
describe('radioBackendBaseUrl', () => {
it('reads the configured backend rather than assuming localhost', () => {
// Silently falling back to 127.0.0.1 in production would take the public
// endpoint down with a connection error and no hint why.
const previous = process.env.NUXT_PUBLIC_RADIO_BACKEND_URL
process.env.NUXT_PUBLIC_RADIO_BACKEND_URL = 'https://backend.example.com'
try {
assert.equal(radioBackendBaseUrl(), 'https://backend.example.com')
} finally {
if (previous === undefined) delete process.env.NUXT_PUBLIC_RADIO_BACKEND_URL
else process.env.NUXT_PUBLIC_RADIO_BACKEND_URL = previous
}
})
it('falls back to the dev backend when nothing is configured', () => {
const previous = process.env.NUXT_PUBLIC_RADIO_BACKEND_URL
delete process.env.NUXT_PUBLIC_RADIO_BACKEND_URL
try {
assert.equal(radioBackendBaseUrl(), 'http://127.0.0.1:8000')
} finally {
if (previous !== undefined) process.env.NUXT_PUBLIC_RADIO_BACKEND_URL = previous
}
})
})
describe('/api/service/tools/taxiroute handler', () => {
it('forwards the request to the backend taxiroute endpoint', async () => {
const { calls } = await callHandler(
'~~/server/api/service/tools/taxiroute.get',
'airport=EDDF&origin_name=Gate+A5&dest_name=RWY+25C&dest_runway_point=end'
)
assert.equal(calls.length, 1)
const url = new URL(calls[0]!)
assert.equal(url.pathname, '/api/service/tools/taxiroute')
assert.equal(url.searchParams.get('airport'), 'EDDF')
assert.equal(url.searchParams.get('dest_name'), 'RWY 25C')
assert.equal(url.searchParams.get('dest_runway_point'), 'end')
})
it('returns the backend response unchanged', async () => {
const payload = { airport: 'EDDF', names_collapsed: ['L7', 'N'] }
const { result } = await callHandler(
'~~/server/api/service/tools/taxiroute.get',
'airport=EDDF',
payload
)
assert.deepEqual(result, payload)
})
it('reports an upstream failure as a failure', async () => {
// The old Nuxt copy answered every error with HTTP 200 and an error body.
// Swallowing a 404 here would hide a failed lookup from the caller.
const upstream = Object.assign(new Error('Not Found'), { statusCode: 404 })
await assert.rejects(
() =>
callHandler('~~/server/api/service/tools/taxiroute.get', 'airport=EDDF', upstream),
(error: any) => error?.statusCode === 404
)
})
})
describe('/api/service/tools/airport-geocode handler', () => {
it('forwards the request to the backend airport-geocode endpoint', async () => {
const { calls } = await callHandler(
'~~/server/api/service/tools/airport-geocode.get',
'airport=EDDF&origin_name=Stand+V155&origin_runway_point=end'
)
assert.equal(calls.length, 1)
const url = new URL(calls[0]!)
assert.equal(url.pathname, '/api/service/tools/airport-geocode')
assert.equal(url.searchParams.get('origin_name'), 'Stand V155')
assert.equal(url.searchParams.get('origin_runway_point'), 'end')
})
})

View File

@@ -15,5 +15,8 @@ export function useRuntimeConfig() {
useSpeaches: process.env.USE_SPEACHES ?? false,
speachesBaseUrl: process.env.SPEACHES_BASE_URL || '',
speechModelId: process.env.SPEECH_MODEL_ID || 'speaches-ai/piper-en_US-ryan-low',
public: {
radioBackendUrl: process.env.NUXT_PUBLIC_RADIO_BACKEND_URL || 'http://127.0.0.1:8000',
},
}
}