319 lines
12 KiB
JavaScript
319 lines
12 KiB
JavaScript
// Mock flight data source.
|
|
// In production this would be replaced by an API call (e.g. FlightAware AeroAPI).
|
|
|
|
const AIRLINES = {
|
|
AA: { name: "American Airlines", iata: "AA" },
|
|
DL: { name: "Delta Air Lines", iata: "DL" },
|
|
UA: { name: "United Airlines", iata: "UA" },
|
|
BA: { name: "British Airways", iata: "BA" },
|
|
LH: { name: "Lufthansa", iata: "LH" },
|
|
AF: { name: "Air France", iata: "AF" },
|
|
JL: { name: "Japan Airlines", iata: "JL" },
|
|
QF: { name: "Qantas", iata: "QF" },
|
|
};
|
|
|
|
const AIRPORTS = {
|
|
JFK: { city: "New York", country: "USA", lat: 40.6413, lon: -73.7781, tz: "America/New_York" },
|
|
LAX: { city: "Los Angeles", country: "USA", lat: 33.9416, lon: -118.4085, tz: "America/Los_Angeles" },
|
|
ORD: { city: "Chicago", country: "USA", lat: 41.9742, lon: -87.9073, tz: "America/Chicago" },
|
|
SFO: { city: "San Francisco", country: "USA", lat: 37.6213, lon: -122.379, tz: "America/Los_Angeles" },
|
|
ATL: { city: "Atlanta", country: "USA", lat: 33.6407, lon: -84.4277, tz: "America/New_York" },
|
|
LHR: { city: "London", country: "UK", lat: 51.47, lon: -0.4543, tz: "Europe/London" },
|
|
CDG: { city: "Paris", country: "France", lat: 49.0097, lon: 2.5479, tz: "Europe/Paris" },
|
|
FRA: { city: "Frankfurt", country: "Germany", lat: 50.0333, lon: 8.5706, tz: "Europe/Berlin" },
|
|
NRT: { city: "Tokyo", country: "Japan", lat: 35.772, lon: 140.3929, tz: "Asia/Tokyo" },
|
|
SYD: { city: "Sydney", country: "Australia", lat: -33.9399, lon: 151.1753, tz: "Australia/Sydney" },
|
|
};
|
|
|
|
// key: `${airlineCode}-${flightNumber}`
|
|
const FLIGHTS = {
|
|
"AA-100": { origin: "JFK", destination: "LAX", status: "on-time", depOffset: 45, durationMin: 330 },
|
|
"AA-200": { origin: "LAX", destination: "JFK", status: "delayed", depOffset: 120, durationMin: 345, delayMin: 40 },
|
|
"AA-300": { origin: "ORD", destination: "ATL", status: "on-time", depOffset: 90, durationMin: 110 },
|
|
"DL-123": { origin: "ATL", destination: "SFO", status: "on-time", depOffset: 30, durationMin: 240 },
|
|
"DL-456": { origin: "SFO", destination: "ATL", status: "cancelled", depOffset: 60, durationMin: 250 },
|
|
"UA-789": { origin: "ORD", destination: "LHR", status: "delayed", depOffset: 180, durationMin: 420, delayMin: 25 },
|
|
"UA-101": { origin: "LHR", destination: "ORD", status: "on-time", depOffset: 200, durationMin: 430 },
|
|
"BA-117": { origin: "LHR", destination: "JFK", status: "on-time", depOffset: 15, durationMin: 420 },
|
|
"BA-118": { origin: "JFK", destination: "LHR", status: "on-time", depOffset: 75, durationMin: 435 },
|
|
"LH-400": { origin: "FRA", destination: "JFK", status: "delayed", depOffset: 100, durationMin: 440, delayMin: 55 },
|
|
"LH-401": { origin: "JFK", destination: "FRA", status: "on-time", depOffset: 140, durationMin: 450 },
|
|
"LH-700": { origin: "FRA", destination: "NRT", status: "on-time", depOffset: 220, durationMin: 720 },
|
|
"AF-001": { origin: "CDG", destination: "JFK", status: "on-time", depOffset: 55, durationMin: 430 },
|
|
"JL-003": { origin: "NRT", destination: "LAX", status: "delayed", depOffset: 260, durationMin: 480, delayMin: 35 },
|
|
"QF-010": { origin: "SYD", destination: "LAX", status: "on-time", depOffset: 300, durationMin: 470 },
|
|
};
|
|
|
|
// --- i18n ---
|
|
|
|
const I18N = {
|
|
en: {
|
|
title: "Flight Lookup",
|
|
subtitle: "Enter an airline and flight number to see its route.",
|
|
airline: "Airline",
|
|
airlinePlaceholder: "e.g. American, BA, Lufthansa…",
|
|
flightNumber: "Flight number",
|
|
flightNumberPlaceholder: "e.g. 100",
|
|
lookup: "Look up flight",
|
|
onTime: "On time",
|
|
delayed: "Delayed",
|
|
cancelled: "Cancelled",
|
|
departs: "Departs",
|
|
arrives: "Arrives",
|
|
copyLink: "Copy link",
|
|
copied: "Copied!",
|
|
recentLookups: "Recent lookups",
|
|
noFlightFound: "No flight found for {flight}. Try e.g. AA 100 or BA 117.",
|
|
multipleAirlines: 'Multiple airlines match "{query}". Please select one from the list.',
|
|
noAirlineFound: 'No airline found matching "{query}".',
|
|
enterFlightNumber: "Please enter a flight number.",
|
|
searching: "Searching…",
|
|
networkError: "Network error. Please try again.",
|
|
themeToggle: "Toggle theme",
|
|
language: "Language",
|
|
live: "LIVE",
|
|
mock: "SAMPLE",
|
|
airborne: "Airborne",
|
|
onGround: "On ground",
|
|
altitude: "Altitude",
|
|
speed: "Speed",
|
|
heading: "Heading",
|
|
vertRate: "Vert. rate",
|
|
position: "Position",
|
|
lastSeen: "Last seen",
|
|
},
|
|
fr: {
|
|
title: "Recherche de vol",
|
|
subtitle: "Saisissez une compagnie et un numéro de vol pour voir son itinéraire.",
|
|
airline: "Compagnie",
|
|
airlinePlaceholder: "ex. American, BA, Lufthansa…",
|
|
flightNumber: "Numéro de vol",
|
|
flightNumberPlaceholder: "ex. 100",
|
|
lookup: "Rechercher le vol",
|
|
onTime: "À l'heure",
|
|
delayed: "Retardé",
|
|
cancelled: "Annulé",
|
|
departs: "Départ",
|
|
arrives: "Arrivée",
|
|
copyLink: "Copier le lien",
|
|
copied: "Copié !",
|
|
recentLookups: "Recherches récentes",
|
|
noFlightFound: "Aucun vol trouvé pour {flight}. Essayez p. ex. AA 100 ou BA 117.",
|
|
multipleAirlines: 'Plusieurs compagnies correspondent à « {query} ». Veuillez en sélectionner une.',
|
|
noAirlineFound: 'Aucune compagnie trouvée pour « {query} ».',
|
|
enterFlightNumber: "Veuillez saisir un numéro de vol.",
|
|
searching: "Recherche…",
|
|
networkError: "Erreur réseau. Veuillez réessayer.",
|
|
themeToggle: "Changer de thème",
|
|
language: "Langue",
|
|
live: "EN DIRECT",
|
|
mock: "EXEMPLE",
|
|
airborne: "En vol",
|
|
onGround: "Au sol",
|
|
altitude: "Altitude",
|
|
speed: "Vitesse",
|
|
heading: "Cap",
|
|
vertRate: "Vitesse vert.",
|
|
position: "Position",
|
|
lastSeen: "Vu il y a",
|
|
},
|
|
de: {
|
|
title: "Flugsuche",
|
|
subtitle: "Geben Sie eine Airline und eine Flugnummer ein, um die Route zu sehen.",
|
|
airline: "Airline",
|
|
airlinePlaceholder: "z. B. American, BA, Lufthansa…",
|
|
flightNumber: "Flugnummer",
|
|
flightNumberPlaceholder: "z. B. 100",
|
|
lookup: "Flug suchen",
|
|
onTime: "Pünktlich",
|
|
delayed: "Verspätet",
|
|
cancelled: "Storniert",
|
|
departs: "Abflug",
|
|
arrives: "Ankunft",
|
|
copyLink: "Link kopieren",
|
|
copied: "Kopiert!",
|
|
recentLookups: "Letzte Suchen",
|
|
noFlightFound: "Kein Flug für {flight} gefunden. Z. B. AA 100 oder BA 117.",
|
|
multipleAirlines: 'Mehrere Airlines passen auf „{query}". Bitte eine auswählen.',
|
|
noAirlineFound: 'Keine Airline gefunden für „{query}"',
|
|
enterFlightNumber: "Bitte geben Sie eine Flugnummer ein.",
|
|
searching: "Suche…",
|
|
networkError: "Netzwerkfehler. Bitte erneut versuchen.",
|
|
themeToggle: "Theme wechseln",
|
|
language: "Sprache",
|
|
live: "LIVE",
|
|
mock: "BEISPIEL",
|
|
airborne: "In der Luft",
|
|
onGround: "Am Boden",
|
|
altitude: "Höhe",
|
|
speed: "Geschwindigkeit",
|
|
heading: "Richtung",
|
|
vertRate: "Vertikalrate",
|
|
position: "Position",
|
|
lastSeen: "Zuletzt gesehen",
|
|
},
|
|
ja: {
|
|
title: "フライト検索",
|
|
subtitle: "航空会社とフライト番号を入力してルートを確認します。",
|
|
airline: "航空会社",
|
|
airlinePlaceholder: "例: American, BA, Lufthansa…",
|
|
flightNumber: "フライト番号",
|
|
flightNumberPlaceholder: "例: 100",
|
|
lookup: "フライトを検索",
|
|
onTime: "定時",
|
|
delayed: "遅延",
|
|
cancelled: "欠航",
|
|
departs: "出発",
|
|
arrives: "到着",
|
|
copyLink: "リンクをコピー",
|
|
copied: "コピーしました!",
|
|
recentLookups: "最近の検索",
|
|
noFlightFound: "{flight} のフライトが見つかりません。例: AA 100 や BA 117。",
|
|
multipleAirlines: '「{query}」に複数の航空会社があります。一覧から選択してください。',
|
|
noAirlineFound: '「{query}」に一致する航空会社が見つかりません。',
|
|
enterFlightNumber: "フライト番号を入力してください。",
|
|
searching: "検索中…",
|
|
networkError: "ネットワークエラー。もう一度お試しください。",
|
|
themeToggle: "テーマ切替",
|
|
language: "言語",
|
|
live: "ライブ",
|
|
mock: "サンプル",
|
|
airborne: "飛行中",
|
|
onGround: "地上",
|
|
altitude: "高度",
|
|
speed: "速度",
|
|
heading: "針路",
|
|
vertRate: "垂直速度",
|
|
position: "位置",
|
|
lastSeen: "最終確認",
|
|
},
|
|
};
|
|
|
|
const SUPPORTED_LANGS = ["en", "fr", "de", "ja"];
|
|
|
|
let currentLang = "en";
|
|
|
|
function t(key, params) {
|
|
const dict = I18N[currentLang] || I18N.en;
|
|
let str = dict[key] || I18N.en[key] || key;
|
|
if (params) {
|
|
for (const [k, v] of Object.entries(params)) {
|
|
str = str.replace(`{${k}}`, v);
|
|
}
|
|
}
|
|
return str;
|
|
}
|
|
|
|
function setLang(lang) {
|
|
if (!SUPPORTED_LANGS.includes(lang)) lang = "en";
|
|
currentLang = lang;
|
|
document.documentElement.lang = lang;
|
|
applyTranslations();
|
|
}
|
|
|
|
function applyTranslations() {
|
|
document.title = t("title");
|
|
document.querySelector("h1").textContent = t("title");
|
|
document.querySelector(".subtitle").textContent = t("subtitle");
|
|
document.querySelector('[for="airline"]').textContent = t("airline");
|
|
airlineInput.placeholder = t("airlinePlaceholder");
|
|
document.querySelector('[for="flight-number"]').textContent = t("flightNumber");
|
|
flightInput.placeholder = t("flightNumberPlaceholder");
|
|
document.querySelector('button[type="submit"]').textContent = t("lookup");
|
|
const histHeading = document.querySelector("#history-section h2");
|
|
if (histHeading) histHeading.textContent = t("recentLookups");
|
|
const themeBtn = document.querySelector("#theme-toggle");
|
|
if (themeBtn) themeBtn.title = t("themeToggle");
|
|
const langLabel = document.querySelector('[for="lang"]');
|
|
if (langLabel) langLabel.textContent = t("language");
|
|
// Re-render result if visible
|
|
if (lastFlight && !resultBox.classList.contains("hidden")) {
|
|
renderResult(lastFlight);
|
|
}
|
|
}
|
|
|
|
// --- Lookup (async to simulate network) ---
|
|
|
|
// Look up a flight. Tries the live OpenSky proxy first; if the flight has
|
|
// no live position (or the proxy is unavailable), falls back to the mock
|
|
// dataset so the UI always has something to show.
|
|
async function lookupFlight(airlineCode, flightNumber) {
|
|
// 1) Try live data via the Node proxy
|
|
try {
|
|
const r = await fetch(
|
|
`/api/lookup?code=${encodeURIComponent(airlineCode)}&num=${encodeURIComponent(flightNumber)}`
|
|
);
|
|
if (r.ok) {
|
|
const live = await r.json();
|
|
if (live.live) {
|
|
// Enrich with airline name + airport info when we can
|
|
const airlineName = AIRLINES[airlineCode] ? AIRLINES[airlineCode].name : airlineCode;
|
|
return { ...live, airline: airlineName, live: true };
|
|
}
|
|
// live === false → not airborne or OpenSky error. Fall through to mock.
|
|
}
|
|
} catch (e) {
|
|
// Proxy unreachable (e.g. opened via file://) — fall through to mock.
|
|
}
|
|
|
|
// 2) Fallback: mock dataset
|
|
const key = `${airlineCode}-${flightNumber}`;
|
|
const flight = FLIGHTS[key];
|
|
if (!flight) return null;
|
|
const now = Date.now();
|
|
const depEpoch = now + flight.depOffset * 60000;
|
|
const arrEpoch = depEpoch + flight.durationMin * 60000;
|
|
return {
|
|
airline: AIRLINES[airlineCode].name,
|
|
airlineCode,
|
|
flightNumber,
|
|
status: flight.status,
|
|
delayMin: flight.delayMin || 0,
|
|
origin: { code: flight.origin, ...AIRPORTS[flight.origin] },
|
|
destination: { code: flight.destination, ...AIRPORTS[flight.destination] },
|
|
depTime: depEpoch,
|
|
arrTime: arrEpoch,
|
|
live: false,
|
|
};
|
|
}
|
|
|
|
// Fuzzy airline search
|
|
function searchAirlines(query) {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return Object.entries(AIRLINES).map(([code, a]) => ({ code, name: a.name }));
|
|
|
|
const scored = Object.entries(AIRLINES).map(([code, a]) => {
|
|
const name = a.name.toLowerCase();
|
|
let score = 0;
|
|
if (code.toLowerCase() === q) score = 100;
|
|
else if (code.toLowerCase().startsWith(q)) score = 80;
|
|
else if (name.startsWith(q)) score = 70;
|
|
else if (name.includes(q)) score = 50;
|
|
else {
|
|
let idx = 0;
|
|
for (const ch of name) {
|
|
if (ch === q[idx]) idx++;
|
|
if (idx === q.length) break;
|
|
}
|
|
if (idx === q.length) score = 30;
|
|
}
|
|
return { code, name: a.name, score };
|
|
}).filter((a) => a.score > 0);
|
|
|
|
scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
return scored.map(({ code, name }) => ({ code, name }));
|
|
}
|
|
|
|
function encodeFlight(airlineCode, flightNumber) {
|
|
return `?f=${encodeURIComponent(airlineCode)}${encodeURIComponent(flightNumber)}`;
|
|
}
|
|
|
|
function decodeFlight(url) {
|
|
const params = new URLSearchParams(url.search);
|
|
const f = params.get("f");
|
|
if (!f) return null;
|
|
const match = f.match(/^([A-Z]{2,3})(\d+)$/i);
|
|
if (!match) return null;
|
|
return { airline: match[1].toUpperCase(), number: match[2] };
|
|
}
|