Files

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