Add bidirectional airport geocode and name-aware taxi routing

This commit is contained in:
Remi
2025-10-19 19:17:18 +02:00
parent cc7e4927e4
commit 3dee67b3a5
4 changed files with 742 additions and 399 deletions

View File

@@ -1,311 +1,26 @@
import { defineEventHandler, getQuery } from 'h3'
import https from 'node:https'
type FeatureType =
| 'runway'
| 'gate'
| 'parking_position'
| 'taxiway'
| 'holding_position'
| 'stand'
| 'unknown'
import {
fetchAirportFeatures,
resolveFeature,
toGeocodePayload,
type GeocodeQuery
} from './airportGeocode'
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>
})
type Feature = {
osmType: 'node' | 'way'
osmId: number
type: FeatureType
lat: number
lon: number
tags: Record<string, string>
aliases: string[]
normalizedAliases: Map<string, string>
function parseCoordinate(value: unknown) {
const num = Number(value)
return Number.isFinite(num) ? num : null
}
const OVERPASS_ENDPOINT = 'https://overpass-api.de/api/interpreter'
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 hasCoordinatePair(query: GeocodeQuery) {
return typeof query.lat === 'number' && typeof query.lon === 'number'
}
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.length === 0) 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>()
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 addAlias = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return
aliases.add(trimmed)
if (featureType === 'runway') {
const normalized = trimmed.replace(/^RWY\s*/i, '')
if (normalized !== trimmed) aliases.add(normalized)
}
}
for (const candidate of candidates) {
if (!candidate) continue
if (featureType === 'runway') {
const parts = candidate.split(/[\/;,]/)
if (parts.length > 1) {
for (const part of parts) addAlias(part)
}
}
addAlias(candidate)
}
if (featureType === 'parking_position') {
// parking positions are effectively stands
const refStand = tags['ref:stand']
if (refStand) addAlias(refStand)
}
return Array.from(aliases)
}
function createFeature(element: OsmElement): Feature | 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 aliases = buildAliases(tags, featureType)
if (aliases.length === 0) return null
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
function normalizeQuery(query: GeocodeQuery) {
return {
osmType: element.type,
osmId: element.id,
type: featureType === 'parking_position' ? 'stand' : featureType,
lat,
lon,
tags,
aliases,
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
}
}
function buildMapUrl(feature: Feature) {
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}`
}
function findMatch(features: Feature[], 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: Feature
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
name: query.name?.trim() || null,
lat: hasCoordinatePair(query) ? (query.lat as number) : null,
lon: hasCoordinatePair(query) ? (query.lon as number) : null
}
}
@@ -313,93 +28,73 @@ 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() : ''
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 (!originName && !destName) {
if (!hasOriginQuery && !hasDestQuery) {
return { error: 'missing_query', airport }
}
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;
`
let osm: any
let features
try {
osm = await fetchOverpass(overpassQuery)
features = await fetchAirportFeatures(airport)
} catch (error) {
return { error: 'overpass_error', airport, details: (error as Error).message }
}
const features: Feature[] = []
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)
}
}
if (features.length === 0) {
return { error: 'no_features', airport, origin: originName || null, dest: destName || null }
}
const find = (query: string) => {
const match = findMatch(features, query)
if (!match) {
return {
query,
result: null
}
}
const { feature, matchedAlias } = match
if (!features.length) {
return {
query,
result: {
type: feature.type,
lat: feature.lat,
lon: feature.lon,
matched_alias: matchedAlias,
map_url: buildMapUrl(feature),
osm: {
type: feature.osmType,
id: feature.osmId,
tags: feature.tags
}
}
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 (originName) response.origin = find(originName)
if (destName) response.dest = find(destName)
if (hasOriginQuery) {
response.origin = {
query: normalizedOrigin,
result: resolve(originQuery)
}
}
if (hasDestQuery) {
response.dest = {
query: normalizedDest,
result: resolve(destQuery)
}
}
return response
})

View File

@@ -0,0 +1,465 @@
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 { aliases, primaryAlias } = buildAliases(tags, featureType)
if (aliases.length === 0 || !primaryAlias) return null
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,18 +1,143 @@
import { defineEventHandler, getQuery } from 'h3'
import https from 'node:https'
import { fetchAirportFeatures, resolveFeature, toGeocodePayload } from './airportGeocode'
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 oLat = Number(q.origin_lat), oLon = Number(q.origin_lng)
const dLat = Number(q.dest_lat), dLon = Number(q.dest_lng)
const radius = Number(q.radius ?? 5000)
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},${oLat},${oLon});
way["aeroway"="taxiway"](around:${radius},${dLat},${dLon});
way["aeroway"="taxiway"](around:${radius},${oLatNum},${oLonNum});
way["aeroway"="taxiway"](around:${radius},${dLatNum},${dLonNum});
);
(._;>;);
out body;
@@ -91,11 +216,16 @@ out body;
return { node_id: bestId, lat: nn.lat, lon: nn.lon, distance_m: bestD }
}
const startAttach = nearestNode(oLat,oLon)
const endAttach = nearestNode(dLat,dLon)
const startAttach = nearestNode(oLatNum,oLonNum)
const endAttach = nearestNode(dLatNum,dLonNum)
if (!startAttach || !endAttach) {
return { error: 'no_nodes_in_area', origin:{lat:oLat,lon:oLon}, dest:{lat:dLat,lon:dLon} }
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) ---
@@ -148,8 +278,9 @@ out body;
const sp = dijkstra(startAttach.node_id, endAttach.node_id)
if (!sp) {
return {
origin: { lat:oLat, lon:oLon },
dest: { lat:dLat, lon:dLon },
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,
@@ -193,8 +324,9 @@ out body;
}
return {
origin: { lat:oLat, lon:oLon },
dest: { lat:dLat, lon:dLon },
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: {