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