mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
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:
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user