mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-08 10:41:49 +08:00
Add airport geocode endpoint
This commit is contained in:
@@ -878,6 +878,48 @@ const endpointSections: EndpointSection[] = [
|
|||||||
}`,
|
}`,
|
||||||
notes: 'Returns null route when no taxiway network is available in the search area.',
|
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.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
358
server/api/service/tools/airport-geocode.get.ts
Normal file
358
server/api/service/tools/airport-geocode.get.ts
Normal file
@@ -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<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: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryType = 'runway' | 'gate' | 'stand' | 'taxiway' | 'holding'
|
||||||
|
|
||||||
|
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 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 Set<string>()
|
||||||
|
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<string, unknown> = {
|
||||||
|
airport,
|
||||||
|
feature_count: features.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originName) response.origin = find(originName, originType)
|
||||||
|
if (destName) response.dest = find(destName, destType)
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user