From c8e35a140eb5e03aa0403146d59cd7ffb3d33d54 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:56:48 +0200 Subject: [PATCH] Add airport geocode endpoint --- app/pages/api-docs.vue | 42 ++ .../api/service/tools/airport-geocode.get.ts | 358 ++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 server/api/service/tools/airport-geocode.get.ts diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue index 0e56ea2..9795890 100644 --- a/app/pages/api-docs.vue +++ b/app/pages/api-docs.vue @@ -878,6 +878,48 @@ const endpointSections: EndpointSection[] = [ }`, notes: 'Returns null route when no taxiway network is available in the search area.', }, + { + method: 'GET', + path: '/api/service/tools/airport-geocode', + summary: 'Resolve named aerodrome features to coordinates for a specific airport.', + category: 'Tools & diagnostics', + auth: 'public', + query: [ + { name: 'airport', type: 'string', required: true, description: 'ICAO designator of the airport.' }, + { name: 'origin_name', type: 'string', description: 'Name or designator of the origin feature (e.g. stand, gate, runway).' }, + { name: 'origin_type', type: 'string', description: 'Optional hint for the origin feature type (runway, gate, stand, taxiway, holding).' }, + { name: 'dest_name', type: 'string', description: 'Name or designator of the destination feature.' }, + { name: 'dest_type', type: 'string', description: 'Optional hint for the destination feature type.' }, + ], + sampleRequest: `curl "https://opensquawk.de/api/service/tools/airport-geocode?airport=EDDF&origin_name=Stand%20V155&origin_type=stand&dest_name=RWY%2025C&dest_type=runway"`, + sampleResponse: `{ + "airport": "EDDF", + "feature_count": 284, + "origin": { + "query": "Stand V155", + "type_hint": "stand", + "result": { + "type": "stand", + "lat": 50.046321, + "lon": 8.576842, + "matched_alias": "V155", + "osm": { "type": "node", "id": 1234567890 } + } + }, + "dest": { + "query": "RWY 25C", + "type_hint": "runway", + "result": { + "type": "runway", + "lat": 50.043812, + "lon": 8.569231, + "matched_alias": "25C", + "osm": { "type": "way", "id": 987654321 } + } + } + }`, + notes: 'Returns null results when the feature cannot be matched within the selected aerodrome.', + }, ], }, { diff --git a/server/api/service/tools/airport-geocode.get.ts b/server/api/service/tools/airport-geocode.get.ts new file mode 100644 index 0000000..112c9aa --- /dev/null +++ b/server/api/service/tools/airport-geocode.get.ts @@ -0,0 +1,358 @@ +import { defineEventHandler, getQuery } from 'h3' +import https from 'node:https' + +type FeatureType = + | 'runway' + | 'gate' + | 'parking_position' + | 'taxiway' + | 'holding_position' + | 'stand' + | 'unknown' + +type OsmElement = + | ({ + type: 'node' + id: number + lat: number + lon: number + tags?: Record + }) + | ({ + type: 'way' + id: number + nodes: number[] + center?: { lat: number; lon: number } + tags?: Record + }) + +type Feature = { + osmType: 'node' | 'way' + osmId: number + type: FeatureType + lat: number + lon: number + tags: Record + aliases: string[] + normalizedAliases: Set +} + +type QueryType = 'runway' | 'gate' | 'stand' | 'taxiway' | 'holding' + +const OVERPASS_ENDPOINT = 'https://overpass-api.de/api/interpreter' + +function fetchOverpass(query: string) { + return new Promise((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() + 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): 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, featureType: FeatureType) { + const aliases = new Set() + + 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 Set() + for (const alias of aliases) { + const variants = normalizeWithPrefixes(alias) + for (const variant of variants) normalizedAliases.add(variant) + } + + if (normalizedAliases.size === 0) return null + + return { + osmType: element.type, + osmId: element.id, + type: featureType === 'parking_position' ? 'stand' : featureType, + lat, + lon, + tags, + aliases, + normalizedAliases + } +} + +function findMatch( + features: Feature[], + query: string, + typeHint?: QueryType +): { feature: Feature; matchedAlias: string } | null { + if (!query) return null + + const queryVariants = normalizeWithPrefixes(query) + if (queryVariants.size === 0) return null + + const typeFilters = typeHint + ? { + runway: ['runway'], + gate: ['gate'], + stand: ['stand'], + taxiway: ['taxiway'], + holding: ['holding_position'] + }[typeHint] + : undefined + + const candidates = typeFilters + ? features.filter((feature) => typeFilters.includes(feature.type)) + : features + + const fallbackCandidates = candidates.length > 0 ? candidates : features + + for (const feature of fallbackCandidates) { + for (const variant of queryVariants) { + if (feature.normalizedAliases.has(variant)) { + return { feature, matchedAlias: variant } + } + } + } + + // attempt loose matching (variant contains alias or vice versa) + for (const feature of fallbackCandidates) { + for (const variant of queryVariants) { + for (const alias of feature.normalizedAliases) { + if (variant.includes(alias) || alias.includes(variant)) { + return { feature, matchedAlias: alias } + } + } + } + } + + return null +} + +function mapTypeHint(value: string | undefined): QueryType | undefined { + if (!value) return undefined + const normalized = value.trim().toLowerCase() + if (!normalized) return undefined + if (['runway', 'rwy'].includes(normalized)) return 'runway' + if (['gate'].includes(normalized)) return 'gate' + if (['stand', 'parking', 'standposition'].includes(normalized)) return 'stand' + if (['taxiway', 'taxi'].includes(normalized)) return 'taxiway' + if (['holding', 'holdshort', 'holdingpoint'].includes(normalized)) return 'holding' + return undefined +} + +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 originType = mapTypeHint(typeof q.origin_type === 'string' ? q.origin_type : undefined) + const destType = mapTypeHint(typeof q.dest_type === 'string' ? q.dest_type : undefined) + + if (!airport) { + return { error: 'missing_airport' } + } + + if (!originName && !destName) { + 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 + try { + osm = await fetchOverpass(overpassQuery) + } 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, typeHint?: QueryType) => { + const match = findMatch(features, query, typeHint) + if (!match) { + return { + query, + type_hint: typeHint ?? null, + result: null + } + } + + const { feature, matchedAlias } = match + return { + query, + type_hint: typeHint ?? null, + result: { + type: feature.type, + lat: feature.lat, + lon: feature.lon, + matched_alias: matchedAlias, + osm: { + type: feature.osmType, + id: feature.osmId, + tags: feature.tags + } + } + } + } + + const response: Record = { + airport, + feature_count: features.length + } + + if (originName) response.origin = find(originName, originType) + if (destName) response.dest = find(destName, destType) + + return response +})