+
+ {showLogo ? (
+
+ {!logoLoaded && (
+
+ )}
+ {
+ if (logoUrl) loadedLogoUrls.add(logoUrl);
+ setLogoLoadedByKey((current) => ({
+ ...current,
+ [logoLoadKey]: true,
+ }));
+ }}
+ onError={() => {
+ if (logoIndex + 1 < logoCandidates.length) {
+ setLogoIndexByAirline((current) => ({
+ ...current,
+ [airlineKey]: logoIndex + 1,
+ }));
+ return;
+ }
+ setLogoIndexByAirline((current) => ({
+ ...current,
+ [airlineKey]: logoCandidates.length,
+ }));
+ }}
+ />
+
+ ) : (
+
+ )}
@@ -79,11 +143,14 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
- {airline && (
+ {company && (
- {airline}
+ {company}
+ {model ? (
+ · {model}
+ ) : null}
)}
diff --git a/src/lib/aircraft.ts b/src/lib/aircraft.ts
new file mode 100644
index 0000000..e094bf7
--- /dev/null
+++ b/src/lib/aircraft.ts
@@ -0,0 +1,36 @@
+const CATEGORY_LABELS: Record
= {
+ 2: "Light aircraft",
+ 3: "Small aircraft",
+ 4: "Large aircraft",
+ 5: "High vortex large",
+ 6: "Heavy aircraft",
+ 7: "High-performance aircraft",
+ 8: "Rotorcraft",
+ 9: "Glider / sailplane",
+ 10: "Lighter-than-air",
+ 11: "Parachutist / skydiver",
+ 12: "Ultralight / hang-glider",
+ 13: "Reserved",
+ 14: "Unmanned aerial vehicle",
+ 15: "Space / trans-atmospheric",
+ 16: "Surface emergency vehicle",
+ 17: "Surface service vehicle",
+ 18: "Point obstacle",
+ 19: "Cluster obstacle",
+ 20: "Line obstacle",
+};
+
+export function categoryToAircraftLabel(category: number | null): string | null {
+ if (category === null) return null;
+ return CATEGORY_LABELS[category] ?? null;
+}
+
+export function aircraftModelHint(category: number | null): string | null {
+ const label = categoryToAircraftLabel(category);
+ if (!label) return null;
+ return `${label} class`;
+}
+
+export function aircraftTypeHint(category: number | null): string | null {
+ return aircraftModelHint(category);
+}
diff --git a/src/lib/airline-logos.ts b/src/lib/airline-logos.ts
new file mode 100644
index 0000000..4f284b6
--- /dev/null
+++ b/src/lib/airline-logos.ts
@@ -0,0 +1,77 @@
+function normalizeAirlineText(value: string): string {
+ return value
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .trim();
+}
+
+function toAirlineLogoSlug(airlineName: string): string {
+ return normalizeAirlineText(airlineName)
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+function toAirlineAliasKey(airlineName: string): string {
+ return normalizeAirlineText(airlineName).replace(/[^a-z0-9]+/g, "");
+}
+
+const LOGO_SLUG_ALIASES: Record = {
+ allnipponairways: "all-nippon-airways",
+ ana: "all-nippon-airways",
+ jal: "japan-airlines",
+ elal: "el-al",
+ itaairways: "ita-airways",
+ latam: "latam-airlines",
+ latamairlines: "latam-airlines",
+ norwegian: "norwegian-air-shuttle",
+ swiss: "swiss",
+ tapairportugal: "tap-air-portugal",
+ vietjetair: "vietjet-air",
+ xiamenair: "xiamenair",
+ pakistaninternationalairlines: "pakistan-international-airlines",
+ pakistanintlairlines: "pakistan-int-l-airlines",
+ indigo: "indigo",
+ indigoairlines: "indigo",
+ goindigo: "indigo",
+};
+
+function buildSlugVariants(baseSlug: string): string[] {
+ if (!baseSlug) return [];
+
+ const variants = new Set([baseSlug]);
+ variants.add(baseSlug.replace(/-airlines$/, ""));
+ variants.add(baseSlug.replace(/-airline$/, ""));
+ variants.add(baseSlug.replace(/-airways$/, ""));
+ variants.add(baseSlug.replace(/-air$/, ""));
+ variants.add(baseSlug.replace(/-international$/, ""));
+ variants.add(baseSlug.replace(/-int-l$/, ""));
+ variants.add(baseSlug.replace(/-intl$/, ""));
+
+ return Array.from(variants).filter(Boolean);
+}
+
+export function airlineLogoCandidates(airlineName: string | null): string[] {
+ if (!airlineName) return [];
+
+ const slug = toAirlineLogoSlug(airlineName);
+ const aliasKey = toAirlineAliasKey(airlineName);
+ const aliasSlug = LOGO_SLUG_ALIASES[aliasKey] ?? null;
+
+ const orderedSlugs = Array.from(
+ new Set([
+ ...buildSlugVariants(slug),
+ ...(aliasSlug ? buildSlugVariants(aliasSlug) : []),
+ ]),
+ );
+
+ if (orderedSlugs.length === 0) return [];
+
+ const candidates: string[] = [];
+ for (const s of orderedSlugs) {
+ candidates.push(`/airline-logos/${s}.svg`);
+ candidates.push(`/airline-logos/${s}.png`);
+ }
+
+ return candidates;
+}
diff --git a/src/lib/airlines.ts b/src/lib/airlines.ts
index eb70723..80f396c 100644
--- a/src/lib/airlines.ts
+++ b/src/lib/airlines.ts
@@ -5,10 +5,17 @@ type AirlineInfo = {
const ICAO_AIRLINES: Record = {
AAL: { name: "American Airlines" },
AAR: { name: "Asiana Airlines" },
+ AAY: { name: "Allegiant Air" },
+ ABY: { name: "Air Arabia" },
ACA: { name: "Air Canada" },
+ AEA: { name: "Air Europa" },
AEE: { name: "Aegean Airlines" },
+ AFL: { name: "Aeroflot" },
AFR: { name: "Air France" },
AIC: { name: "Air India" },
+ AIQ: { name: "AirAsia" },
+ ASL: { name: "Air Serbia" },
+ ANE: { name: "Air Nostrum" },
AIJ: { name: "Interjet" },
AJT: { name: "Amerijet" },
ALK: { name: "SriLankan Airlines" },
@@ -18,23 +25,40 @@ const ICAO_AIRLINES: Record = {
ASA: { name: "Alaska Airlines" },
AUA: { name: "Austrian Airlines" },
AVA: { name: "Avianca" },
+ ARG: { name: "Aerolíneas Argentinas" },
AWE: { name: "US Airways" },
+ AXM: { name: "AirAsia" },
+ AXB: { name: "Air India Express" },
AZA: { name: "Alitalia / ITA Airways" },
+ AZU: { name: "Azul" },
BAW: { name: "British Airways" },
BEL: { name: "Brussels Airlines" },
BER: { name: "Air Berlin" },
+ BTI: { name: "Air Baltic" },
CAL: { name: "China Airlines" },
CCA: { name: "Air China" },
+ CEB: { name: "Cebu Pacific" },
CES: { name: "China Eastern" },
+ CHH: { name: "Hainan Airlines" },
+ CFG: { name: "Condor" },
CLH: { name: "Lufthansa CityLine" },
CMP: { name: "Copa Airlines" },
CPA: { name: "Cathay Pacific" },
+ CRK: { name: "Hong Kong Airlines" },
+ CQH: { name: "Spring Airlines" },
+ CSC: { name: "Sichuan Airlines" },
CSN: { name: "China Southern" },
+ CSZ: { name: "Shenzhen Airlines" },
CTN: { name: "Croatia Airlines" },
CXA: { name: "Xiamen Airlines" },
+ DAH: { name: "Air Algerie" },
DAL: { name: "Delta Air Lines" },
+ DKH: { name: "Juneyao Airlines" },
+ DAT: { name: "Brussels Airlines" },
+ DLA: { name: "Air Dolomiti" },
DLH: { name: "Lufthansa" },
EIN: { name: "Aer Lingus" },
+ ENY: { name: "Envoy Air" },
EJU: { name: "easyJet Europe" },
ELY: { name: "El Al" },
ETD: { name: "Etihad Airways" },
@@ -42,41 +66,68 @@ const ICAO_AIRLINES: Record = {
EVA: { name: "EVA Air" },
EWG: { name: "Eurowings" },
EZY: { name: "easyJet" },
+ EXS: { name: "Jet2" },
+ FFT: { name: "Frontier Airlines" },
FDX: { name: "FedEx Express" },
+ FDB: { name: "flydubai" },
FIN: { name: "Finnair" },
FJI: { name: "Fiji Airways" },
GAF: { name: "German Air Force" },
+ GFA: { name: "Gulf Air" },
GIA: { name: "Garuda Indonesia" },
+ GLO: { name: "GOL" },
GTI: { name: "Atlas Air" },
HAL: { name: "Hawaiian Airlines" },
+ HKE: { name: "Hong Kong Express" },
HVN: { name: "Vietnam Airlines" },
+ IGO: { name: "IndiGo" },
IBE: { name: "Iberia" },
IBK: { name: "Norwegian Air Int'l" },
+ IBB: { name: "Binter Canarias" },
+ IBU: { name: "IndiGo" },
ICE: { name: "Icelandair" },
+ IBS: { name: "Iberia Express" },
JAL: { name: "Japan Airlines" },
JBU: { name: "JetBlue" },
+ JJA: { name: "Jeju Air" },
+ JJP: { name: "Jetstar" },
JST: { name: "Jetstar" },
+ JZA: { name: "Air Canada Jazz" },
KAL: { name: "Korean Air" },
KLM: { name: "KLM" },
+ KZR: { name: "Air Astana" },
LAN: { name: "LATAM Airlines" },
+ LGL: { name: "Luxair" },
+ LPE: { name: "LATAM Perú" },
LOT: { name: "LOT Polish Airlines" },
MAU: { name: "Air Mauritius" },
MAS: { name: "Malaysia Airlines" },
MSR: { name: "EgyptAir" },
NAX: { name: "Norwegian Air Shuttle" },
NKS: { name: "Spirit Airlines" },
+ OMA: { name: "Oman Air" },
+ OZW: { name: "SkyWest Airlines" },
PAL: { name: "Philippine Airlines" },
PIA: { name: "Pakistan Int'l Airlines" },
+ PGT: { name: "Pegasus Airlines" },
+ POE: { name: "Porter Airlines" },
QFA: { name: "Qantas" },
QTR: { name: "Qatar Airways" },
RAM: { name: "Royal Air Maroc" },
RJA: { name: "Royal Jordanian" },
+ RPA: { name: "Republic Airways" },
ROT: { name: "TAROM" },
RYR: { name: "Ryanair" },
SAS: { name: "Scandinavian Airlines" },
+ SCO: { name: "Scoot" },
+ SDM: { name: "Rossiya" },
+ SCX: { name: "Sun Country Airlines" },
+ SEJ: { name: "SpiceJet" },
+ SEH: { name: "Sky Express" },
SAA: { name: "South African Airways" },
SIA: { name: "Singapore Airlines" },
SKW: { name: "SkyWest Airlines" },
+ SKY: { name: "Skymark Airlines" },
SVA: { name: "Saudia" },
SWA: { name: "Southwest Airlines" },
SWR: { name: "Swiss Int'l Air Lines" },
@@ -84,16 +135,26 @@ const ICAO_AIRLINES: Record = {
TAP: { name: "TAP Air Portugal" },
THA: { name: "Thai Airways" },
THY: { name: "Turkish Airlines" },
+ TOM: { name: "TUI Airways" },
+ TRA: { name: "Transavia" },
+ TSC: { name: "Air Transat" },
+ TWB: { name: "Tway Airlines" },
TUI: { name: "TUI Airways" },
TVF: { name: "Transavia France" },
UAE: { name: "Emirates" },
UAL: { name: "United Airlines" },
+ USA: { name: "US Airways" },
UPS: { name: "UPS Airlines" },
+ VJC: { name: "VietJet Air" },
VIR: { name: "Virgin Atlantic" },
+ VOE: { name: "Volotea" },
+ VOI: { name: "Volaris" },
VOZ: { name: "Virgin Australia" },
VLG: { name: "Vueling" },
WJA: { name: "WestJet" },
+ WIF: { name: "Widerøe" },
WZZ: { name: "Wizz Air" },
+ XAX: { name: "AirAsia X" },
};
export function lookupAirline(callsign: string | null): string | null {
diff --git a/src/lib/airports.ts b/src/lib/airports.ts
index acd23fb..4e4d396 100644
--- a/src/lib/airports.ts
+++ b/src/lib/airports.ts
@@ -72510,43 +72510,91 @@ export const AIRPORTS: Airport[] = [
},
];
+type SearchAirportEntry = {
+ airport: Airport;
+ iata: string;
+ city: string;
+ name: string;
+ country: string;
+};
+
+const AIRPORT_SEARCH_INDEX: SearchAirportEntry[] = AIRPORTS.map((airport) => ({
+ airport,
+ iata: airport.iata.toLowerCase(),
+ city: airport.city.toLowerCase(),
+ name: airport.name.toLowerCase(),
+ country: airport.country.toLowerCase(),
+}));
+
+const IATA_LOOKUP = new Map(AIRPORTS.map((airport) => [airport.iata, airport]));
+
+const SEARCH_CACHE_LIMIT = 80;
+const SEARCH_CACHE = new Map();
+
+function getCachedAirportSearch(query: string): Airport[] | undefined {
+ const cached = SEARCH_CACHE.get(query);
+ if (!cached) return undefined;
+
+ SEARCH_CACHE.delete(query);
+ SEARCH_CACHE.set(query, cached);
+ return cached;
+}
+
+function setCachedAirportSearch(query: string, airports: Airport[]) {
+ if (SEARCH_CACHE.has(query)) SEARCH_CACHE.delete(query);
+ SEARCH_CACHE.set(query, airports);
+
+ if (SEARCH_CACHE.size > SEARCH_CACHE_LIMIT) {
+ const oldest = SEARCH_CACHE.keys().next().value;
+ if (oldest) SEARCH_CACHE.delete(oldest);
+ }
+}
+
export function searchAirports(query: string, limit = 20): Airport[] {
const q = query.toLowerCase().trim();
if (!q) return [];
+ const cached = getCachedAirportSearch(q);
+ if (cached) return cached.slice(0, limit);
+
const exact: Airport[] = [];
const iataPrefix: Airport[] = [];
const cityStart: Airport[] = [];
const nameStart: Airport[] = [];
const contains: Airport[] = [];
- for (const a of AIRPORTS) {
- const iata = a.iata.toLowerCase();
- const city = a.city.toLowerCase();
- const name = a.name.toLowerCase();
- const country = a.country.toLowerCase();
+ for (const entry of AIRPORT_SEARCH_INDEX) {
+ const { airport, iata, city, name, country } = entry;
- if (iata === q) exact.push(a);
- else if (iata.startsWith(q)) iataPrefix.push(a);
- else if (city.startsWith(q)) cityStart.push(a);
- else if (name.startsWith(q)) nameStart.push(a);
+ if (iata === q) {
+ if (exact.length < limit) exact.push(airport);
+ } else if (iata.startsWith(q)) {
+ if (iataPrefix.length < limit) iataPrefix.push(airport);
+ } else if (city.startsWith(q)) {
+ if (cityStart.length < limit) cityStart.push(airport);
+ } else if (name.startsWith(q)) {
+ if (nameStart.length < limit) nameStart.push(airport);
+ }
else if (city.includes(q) || name.includes(q) || country.startsWith(q))
- contains.push(a);
+ if (contains.length < limit) contains.push(airport);
}
- return [
+ const results = [
...exact,
...iataPrefix,
...cityStart,
...nameStart,
...contains,
- ].slice(0, limit);
+ ];
+
+ setCachedAirportSearch(q, results);
+ return results.slice(0, limit);
}
export function findByIata(iata: string): Airport | undefined {
const code = iata.toUpperCase();
- return AIRPORTS.find((a) => a.iata === code);
+ return IATA_LOOKUP.get(code);
}
export function airportToCity(airport: Airport): City {
diff --git a/src/lib/opensky.ts b/src/lib/opensky.ts
index 9b26cb0..f9f21b2 100644
--- a/src/lib/opensky.ts
+++ b/src/lib/opensky.ts
@@ -18,6 +18,7 @@ export type FlightState = {
squawk: string | null;
spiFlag: boolean;
positionSource: number;
+ category: number | null;
};
type OpenSkyResponse = {
@@ -44,6 +45,7 @@ function parseStates(raw: OpenSkyResponse): FlightState[] {
squawk: s[14] as string | null,
spiFlag: s[15] as boolean,
positionSource: s[16] as number,
+ category: (s[17] as number | null) ?? null,
}))
.filter(
(f) =>
@@ -75,7 +77,7 @@ export async function fetchFlightsByBbox(
const lo0 = clamp(lomin, -180, 180);
const lo1 = clamp(lomax, -180, 180);
- const url = `${OPENSKY_API}/states/all?lamin=${la0}&lamax=${la1}&lomin=${lo0}&lomax=${lo1}`;
+ const url = `${OPENSKY_API}/states/all?lamin=${la0}&lamax=${la1}&lomin=${lo0}&lomax=${lo1}&extended=1`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);