mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-05-14 19:25:37 +08:00
Add bidirectional airport geocode and name-aware taxi routing
This commit is contained in:
@@ -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
|
||||
})
|
||||
|
||||
465
server/api/service/tools/airportGeocode.ts
Normal file
465
server/api/service/tools/airportGeocode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user