diff --git a/html/airlines.js b/html/airlines.js new file mode 100644 index 00000000..c63e9653 --- /dev/null +++ b/html/airlines.js @@ -0,0 +1,100 @@ +// airlines.js +// Loads the IATA -> ICAO callsign prefix table from OpenTravelData at +// runtime and caches it in localStorage, so it's fetched at most once +// per AIRLINE_CACHE_MAX_AGE_MS instead of on every page load. +"use strict"; + +const AIRLINE_CSV_URL = "https://raw.githubusercontent.com/opentraveldata/opentraveldata/master/opentraveldata/optd_airline_best_known_so_far.csv"; +const AIRLINE_CACHE_KEY = "tar1090_iata_to_icao_v1"; +const AIRLINE_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 1 week + +let iata_to_icao = {}; + +function parseAirlineCsv(text) { + // Columns: pk^env_id^validity_from^validity_to^3char_code^2char_code^...^type^... + // Only currently valid (validity_to empty) entries are kept. Passenger + // entries are preferred over cargo ones when both exist for the same + // IATA code (see the Lufthansa/Lufthansa Cargo special case in + // iataToIcao() in planeObject.js), but a cargo-only entry is still + // used when it's the only mapping available for that IATA code. + const map = {}; + const cargoOnly = {}; + const lines = text.split('\n'); + for (let i = 1; i < lines.length; i++) { + const f = lines[i].split('^'); + if (f.length < 12) { + continue; + } + const validityTo = (f[3] || '').trim(); + const icao3 = (f[4] || '').trim(); + const iata2 = (f[5] || '').trim(); + const type = (f[11] || '').trim(); + if (!/^[A-Z0-9]{2}$/.test(iata2) || !/^[A-Z]{3}$/.test(icao3) || validityTo) { + continue; + } + if (type === 'C') { + cargoOnly[iata2] = icao3; + } else { + map[iata2] = icao3; + } + } + for (const iata2 in cargoOnly) { + if (!(iata2 in map)) { + map[iata2] = cargoOnly[iata2]; + } + } + return map; +} + +// Re-run setFlight() on already-tracked aircraft so their callsigns pick +// up the table once it's loaded (planes seen before that point were left +// with their raw, unconverted callsign). +function refreshTrackedCallsigns() { + if (typeof g === 'undefined' || !g.planes) { + return; + } + for (const hex in g.planes) { + const plane = g.planes[hex]; + if (plane.flight) { + const oldTs = plane.flightTs; + plane.setFlight(plane.flight); + plane.flightTs = oldTs; + } + } +} + +function loadAirlineTable() { + try { + const cached = JSON.parse(localStorage.getItem(AIRLINE_CACHE_KEY)); + if (cached && typeof cached.ts === 'number' && cached.data && typeof cached.data === 'object' + && Date.now() - cached.ts < AIRLINE_CACHE_MAX_AGE_MS) { + iata_to_icao = cached.data; + refreshTrackedCallsigns(); + return; + } + } catch (e) { + // ignore missing/corrupt cache, fall through to fetch + } + + fetch(AIRLINE_CSV_URL) + .then(res => { + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}`); + } + return res.text(); + }) + .then(text => { + iata_to_icao = parseAirlineCsv(text); + refreshTrackedCallsigns(); + try { + localStorage.setItem(AIRLINE_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: iata_to_icao })); + } catch (e) { + // localStorage full or unavailable, not fatal + } + }) + .catch(err => { + console.error("airlines.js: failed to load IATA->ICAO table", err); + }); +} + +loadAirlineTable(); diff --git a/html/index.html b/html/index.html index 6c8be558..f209bc47 100644 --- a/html/index.html +++ b/html/index.html @@ -1103,6 +1103,7 @@ + diff --git a/html/planeObject.js b/html/planeObject.js index c7dfc3d8..e82e468a 100644 --- a/html/planeObject.js +++ b/html/planeObject.js @@ -2881,18 +2881,69 @@ PlaneObject.prototype.setProjection = function(arg) { } } +// Converts an IATA airline code + flight number to the ICAO code. +// Returns null if there's no known mapping. +function iataToIcao(iataCode, numPart) { + // LH is the IATA code for both Lufthansa passenger (ICAO DLH) and + // Lufthansa Cargo (ICAO GEC). Cargo flights use 4-digit numbers 8000-8999. + if (iataCode === 'LH') { + if (/^[0-9]{4}$/.test(numPart)) { + const n = parseInt(numPart, 10); + if (n >= 8000 && n <= 8999) { + return 'GEC'; + } + } + return 'DLH'; + } + + if (typeof iata_to_icao !== 'undefined' && iata_to_icao && iata_to_icao[iataCode]) { + return iata_to_icao[iataCode]; + } + + return null; +} + function normalized_callsign(flight) { - const re = /^([A-Z]*)([0-9]*)([A-Z]*)$/; - const match = flight.match(re); - if (!match) { - return flight; - } - let alpha = match[1]; - let num = match[2]; - let alpha2 = match[3]; - while(num[0] == '0' && num.length > 1) { + // Distinguish ICAO (3 letters) from IATA (2 alphanumeric, or digit+letter) + // prefixes so IATA callsigns can be converted to ICAO below. + const prefixRe = /^(?:([A-Z]{3})|([A-Z][A-Z0-9])|([0-9][A-Z]))([0-9]+)([A-Z]*)$/; + let match = flight.match(prefixRe); + + let alpha, num, alpha2, prefixType; + if (match) { + if (match[1]) { + alpha = match[1]; + prefixType = 'ICAO'; + } else { + alpha = match[2] || match[3]; + prefixType = 'IATA'; + } + num = match[4]; + alpha2 = match[5]; + } else { + // Fallback for callsigns that don't fit the pattern above (e.g. registrations). + const re = /^([A-Z]*)([0-9]*)([A-Z]*)$/; + match = flight.match(re); + if (!match) { + return flight; + } + alpha = match[1]; + num = match[2]; + alpha2 = match[3]; + prefixType = 'unknown'; + } + + while (num[0] == '0' && num.length > 1) { num = num.slice(1); } + + if (prefixType === 'IATA') { + const icao = iataToIcao(alpha, num); + if (icao) { + return icao + num + alpha2; + } + } + return alpha + num + alpha2; } @@ -3095,8 +3146,14 @@ PlaneObject.prototype.setFlight = function(flight) { this.flight = null; this.name ='no callsign'; } else { - this.flight = `${flight}`; - this.name = this.flight.trim() || 'empty callsign'; + let trimmed = `${flight}`.trim(); + if (trimmed) { + // Normalize here so the table, map label, and route lookup + // (routeCheck() uses this.name) all show the same callsign. + trimmed = normalized_callsign(trimmed); + } + this.flight = trimmed; + this.name = trimmed || 'empty callsign'; this.flightTs = now; } }