diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue index 9795890..85d2a5a 100644 --- a/app/pages/api-docs.vue +++ b/app/pages/api-docs.vue @@ -887,33 +887,31 @@ const endpointSections: EndpointSection[] = [ 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.' }, + { name: 'dest_name', type: 'string', description: 'Name or designator of the destination feature.' } ], - 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"`, + sampleRequest: `curl "https://opensquawk.de/api/service/tools/airport-geocode?airport=EDDF&origin_name=Stand%20V155&dest_name=RWY%2025C"`, 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", + "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 } } }, "dest": { "query": "RWY 25C", - "type_hint": "runway", "result": { "type": "runway", "lat": 50.043812, "lon": 8.569231, "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 } } } diff --git a/server/api/service/tools/airport-geocode.get.ts b/server/api/service/tools/airport-geocode.get.ts index 2e8aad5..2df7909 100644 --- a/server/api/service/tools/airport-geocode.get.ts +++ b/server/api/service/tools/airport-geocode.get.ts @@ -34,11 +34,9 @@ type Feature = { lon: number tags: Record aliases: string[] - normalizedAliases: Set + normalizedAliases: Map } -type QueryType = 'runway' | 'gate' | 'stand' | 'taxiway' | 'holding' - const OVERPASS_ENDPOINT = 'https://overpass-api.de/api/interpreter' function fetchOverpass(query: string) { @@ -179,10 +177,14 @@ function createFeature(element: OsmElement): Feature | null { const aliases = buildAliases(tags, featureType) if (aliases.length === 0) return null - const normalizedAliases = new Set() + const normalizedAliases = new Map() for (const alias of aliases) { const variants = normalizeWithPrefixes(alias) - for (const variant of variants) normalizedAliases.add(variant) + for (const variant of variants) { + if (!normalizedAliases.has(variant)) { + normalizedAliases.set(variant, alias) + } + } } if (normalizedAliases.size === 0) return null @@ -199,99 +201,112 @@ function createFeature(element: OsmElement): Feature | null { } } -function inferTypeFromQuery(query: string) { - let sanitized = query - let inferred: QueryType | undefined - - const patterns: Array<{ hint: QueryType; detect: RegExp; remove: RegExp }> = [ - { hint: 'runway', detect: /\b(runway|rwy)\b/i, remove: /\b(runway|rwy)\b/gi }, - { hint: 'gate', detect: /\b(gate)\b/i, remove: /\b(gate)\b/gi }, - { - hint: 'stand', - detect: /\b(stand|parking|standposition)\b/i, - remove: /\b(stand|parking|standposition)\b/gi - }, - { hint: 'taxiway', detect: /\b(taxiway|taxi)\b/i, remove: /\b(taxiway|taxi)\b/gi }, - { - hint: 'holding', - detect: /\b(holding|holdshort|holdingpoint)\b/i, - remove: /\b(holding|holdshort|holdingpoint)\b/gi +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 { hint, detect, remove } of patterns) { - if (detect.test(sanitized)) { - inferred = inferred ?? hint - sanitized = sanitized.replace(remove, ' ') - } + 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 { - sanitizedQuery: sanitized || query.trim(), - inferredType: inferred + trimmed, + sanitizedQuery: sanitized, + runwayBias } } -function findMatch( - features: Feature[], - query: string, - typeHint?: QueryType -): { feature: Feature; matchedAlias: string } | null { +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 queryVariants = normalizeWithPrefixes(query) + 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 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 } + const variantList = Array.from(queryVariants) + let best: + | { + feature: Feature + matchedAlias: string + score: number } - } - } + | null = null - // 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 } + 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 + } } } } } - return null -} + if (!best) 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 + return { + feature: best.feature, + matchedAlias: best.matchedAlias + } } export default defineEventHandler(async (event) => { @@ -300,8 +315,6 @@ export default defineEventHandler(async (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' } @@ -353,14 +366,11 @@ out center tags; return { error: 'no_features', airport, origin: originName || null, dest: destName || null } } - const find = (query: string, typeHint?: QueryType) => { - const { sanitizedQuery, inferredType } = inferTypeFromQuery(query) - const effectiveType = typeHint ?? inferredType - const match = findMatch(features, sanitizedQuery, effectiveType) + const find = (query: string) => { + const match = findMatch(features, query) if (!match) { return { query, - type_hint: effectiveType ?? null, result: null } } @@ -368,12 +378,12 @@ out center tags; const { feature, matchedAlias } = match return { query, - type_hint: effectiveType ?? null, result: { type: feature.type, lat: feature.lat, lon: feature.lon, matched_alias: matchedAlias, + map_url: buildMapUrl(feature), osm: { type: feature.osmType, id: feature.osmId, @@ -388,8 +398,8 @@ out center tags; feature_count: features.length } - if (originName) response.origin = find(originName, originType) - if (destName) response.dest = find(destName, destType) + if (originName) response.origin = find(originName) + if (destName) response.dest = find(destName) return response })