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;
}
}