diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue index 85d2a5a..1725cc3 100644 --- a/app/pages/api-docs.vue +++ b/app/pages/api-docs.vue @@ -856,67 +856,118 @@ const endpointSections: EndpointSection[] = [ { method: 'GET', path: '/api/service/tools/taxiroute', - summary: 'Compute a taxi route between two coordinates using OpenStreetMap data.', + summary: 'Compute a taxi route between coordinates or named aerodrome features using OpenStreetMap data.', category: 'Tools & diagnostics', auth: 'public', query: [ - { name: 'origin_lat', type: 'number', required: true, description: 'Origin latitude in decimal degrees.' }, - { name: 'origin_lng', type: 'number', required: true, description: 'Origin longitude in decimal degrees.' }, - { name: 'dest_lat', type: 'number', required: true, description: 'Destination latitude in decimal degrees.' }, - { name: 'dest_lng', type: 'number', required: true, description: 'Destination longitude in decimal degrees.' }, - { name: 'radius', type: 'number', description: 'Search radius in metres (default 2000).' }, + { name: 'airport', type: 'string', description: 'ICAO designator of the airport. Required when using feature names.' }, + { name: 'origin_lat', type: 'number', description: 'Origin latitude in decimal degrees.' }, + { name: 'origin_lng', type: 'number', description: 'Origin longitude in decimal degrees.' }, + { name: 'origin_name', type: 'string', description: 'Name or designator of the origin feature (e.g. gate, stand, runway).' }, + { name: 'dest_lat', type: 'number', description: 'Destination latitude in decimal degrees.' }, + { name: 'dest_lng', type: 'number', description: 'Destination longitude in decimal degrees.' }, + { name: 'dest_name', type: 'string', description: 'Name or designator of the destination feature.' }, + { name: 'radius', type: 'number', description: 'Search radius in metres (default 5000).' }, ], - sampleRequest: `curl "https://opensquawk.de/api/service/tools/taxiroute?origin_lat=50.0506&origin_lng=8.5708&dest_lat=50.0473&dest_lng=8.5610&radius=2500"`, + sampleRequest: `curl "https://opensquawk.de/api/service/tools/taxiroute?airport=EDDF&origin_name=Gate%20A5&dest_name=RWY%2025C&radius=2500"`, sampleResponse: `{ - "origin": { "lat": 50.0506, "lon": 8.5708 }, - "dest": { "lat": 50.0473, "lon": 8.561 }, + "airport": "EDDF", + "origin": { + "lat": 50.0506, + "lon": 8.5708, + "query": { "name": "Gate A5", "lat": null, "lon": null }, + "feature": { + "type": "gate", + "name": "A5", + "lat": 50.05061, + "lon": 8.57079, + "matched_alias": "A5", + "primary_alias": "A5", + "map_url": "https://www.openstreetmap.org/node/1234567890?mlat=50.050610&mlon=8.570790#map=19/50.050610/8.570790", + "osm": { "type": "node", "id": 1234567890 }, + "source": "name", + "distance_m": null + } + }, + "dest": { + "lat": 50.0473, + "lon": 8.561, + "query": { "name": "RWY 25C", "lat": null, "lon": null }, + "feature": { + "type": "runway", + "name": "25C", + "lat": 50.04726, + "lon": 8.561, + "matched_alias": "25C", + "primary_alias": "25C", + "map_url": "https://www.openstreetmap.org/way/987654321?mlat=50.047260&mlon=8.561000#map=17/50.047260/8.561000", + "osm": { "type": "way", "id": 987654321 }, + "source": "name", + "distance_m": null + } + }, + "start_attach": { "node_id": 111, "lat": 50.0507, "lon": 8.5709, "distance_m": 8.3 }, + "end_attach": { "node_id": 222, "lat": 50.0472, "lon": 8.5611, "distance_m": 5.7 }, "route": { "node_ids": [1234567890, 1234567990, 1234568021], "total_distance_m": 1580.3 }, - "names": ["L7", "L", "N"] + "names": ["L7", "L", "N"], + "names_collapsed": ["L7", "N"] }`, - notes: 'Returns null route when no taxiway network is available in the search area.', + notes: 'Provide coordinates, feature names, or a mix of both. The service will resolve missing coordinates via the airport geocode lookup before computing the taxi route.', }, { method: 'GET', path: '/api/service/tools/airport-geocode', - summary: 'Resolve named aerodrome features to coordinates for a specific airport.', + summary: 'Resolve aerodrome features between names and 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: 'dest_name', type: 'string', description: 'Name or designator of the destination feature.' } + { name: 'origin_lat', type: 'number', description: 'Origin latitude in decimal degrees.' }, + { name: 'origin_lng', type: 'number', description: 'Origin longitude in decimal degrees.' }, + { name: 'dest_name', type: 'string', description: 'Name or designator of the destination feature.' }, + { name: 'dest_lat', type: 'number', description: 'Destination latitude in decimal degrees.' }, + { name: 'dest_lng', type: 'number', description: 'Destination longitude in decimal degrees.' } ], - sampleRequest: `curl "https://opensquawk.de/api/service/tools/airport-geocode?airport=EDDF&origin_name=Stand%20V155&dest_name=RWY%2025C"`, + sampleRequest: `curl "https://opensquawk.de/api/service/tools/airport-geocode?airport=EDDF&origin_name=Stand%20V155&dest_lat=50.0474&dest_lng=8.5612"`, sampleResponse: `{ "airport": "EDDF", "feature_count": 284, "origin": { - "query": "Stand V155", + "query": { "name": "Stand V155", "lat": null, "lon": null }, "result": { "type": "stand", + "name": "V155", "lat": 50.046321, "lon": 8.576842, "matched_alias": "V155", + "primary_alias": "V155", "map_url": "https://www.openstreetmap.org/node/1234567890?mlat=50.046321&mlon=8.576842#map=19/50.046321/8.576842", - "osm": { "type": "node", "id": 1234567890 } + "osm": { "type": "node", "id": 1234567890 }, + "source": "name", + "distance_m": null } }, "dest": { - "query": "RWY 25C", + "query": { "name": null, "lat": 50.0474, "lon": 8.5612 }, "result": { "type": "runway", - "lat": 50.043812, - "lon": 8.569231, + "name": "25C", + "lat": 50.04726, + "lon": 8.5611, "matched_alias": "25C", - "map_url": "https://www.openstreetmap.org/way/987654321?mlat=50.043812&mlon=8.569231#map=17/50.043812/8.569231", - "osm": { "type": "way", "id": 987654321 } + "primary_alias": "25C", + "map_url": "https://www.openstreetmap.org/way/987654321?mlat=50.047260&mlon=8.561100#map=17/50.047260/8.561100", + "osm": { "type": "way", "id": 987654321 }, + "source": "coordinate", + "distance_m": 9.2 } } }`, - notes: 'Returns null results when the feature cannot be matched within the selected aerodrome.', + notes: 'You can provide names, coordinates, or both for origin and destination. The response always returns the matched feature metadata, coordinates, and OpenStreetMap links.', }, ], }, diff --git a/server/api/service/tools/airport-geocode.get.ts b/server/api/service/tools/airport-geocode.get.ts index 2df7909..63b7683 100644 --- a/server/api/service/tools/airport-geocode.get.ts +++ b/server/api/service/tools/airport-geocode.get.ts @@ -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 - }) - | ({ - 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: Map +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((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() - 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 Map() - 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 = { 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 }) diff --git a/server/api/service/tools/airportGeocode.ts b/server/api/service/tools/airportGeocode.ts new file mode 100644 index 0000000..f2bd69a --- /dev/null +++ b/server/api/service/tools/airportGeocode.ts @@ -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 + }) + | ({ + type: 'way' + id: number + nodes: number[] + center?: { lat: number; lon: number } + tags?: Record + }) + +export type AirportFeature = { + osmType: 'node' | 'way' + osmId: number + type: FeatureType + lat: number + lon: number + tags: Record + aliases: string[] + primaryAlias: string + normalizedAliases: Map +} + +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((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) 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() + 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() + 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 + } +} diff --git a/server/api/service/tools/taxiroute.get.ts b/server/api/service/tools/taxiroute.get.ts index 4bf4e05..7276d77 100644 --- a/server/api/service/tools/taxiroute.get.ts +++ b/server/api/service/tools/taxiroute.get.ts @@ -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> | 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 = null + let destMatch: ReturnType = 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: {