Files
flight-lookup/app.js
T

474 lines
15 KiB
JavaScript

/* ============ Flight Lookup App ============ */
const HISTORY_KEY = "flight-lookup-history";
const THEME_KEY = "flight-lookup-theme";
const LANG_KEY = "flight-lookup-lang";
const MAX_HISTORY = 8;
const DEBOUNCE_MS = 250;
// ---- DOM refs ----
const airlineInput = document.getElementById("airline");
const flightInput = document.getElementById("flight-number");
const form = document.getElementById("lookup-form");
const suggestionsBox = document.getElementById("airline-suggestions");
const resultBox = document.getElementById("result");
const historyList = document.getElementById("history-list");
const historySection = document.getElementById("history-section");
const langSelect = document.getElementById("lang");
const themeToggle = document.getElementById("theme-toggle");
let lastFlight = null;
let debounceTimer = null;
let activeSuggestion = -1;
let suggestionItems = [];
// ============ Theme ============
function initTheme() {
const saved = localStorage.getItem(THEME_KEY);
const prefersLight = window.matchMedia("(prefers-color-scheme: light)").matches;
const theme = saved || (prefersLight ? "light" : "dark");
document.documentElement.setAttribute("data-theme", theme);
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme") || "dark";
const next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem(THEME_KEY, next);
}
// ============ Language ============
function initLang() {
const saved = localStorage.getItem(LANG_KEY);
if (saved && SUPPORTED_LANGS.includes(saved)) {
setLang(saved);
} else {
setLang("en");
}
langSelect.value = currentLang;
}
function onLangChange() {
const lang = langSelect.value;
localStorage.setItem(LANG_KEY, lang);
setLang(lang);
applyTranslations();
if (lastFlight) {
renderResult(lastFlight);
}
}
// ============ History ============
function getHistory() {
try {
return JSON.parse(localStorage.getItem(HISTORY_KEY)) || [];
} catch {
return [];
}
}
function saveHistory(entry) {
let history = getHistory();
history = history.filter((h) => h.code !== entry.code);
history.unshift(entry);
history = history.slice(0, MAX_HISTORY);
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
renderHistory();
}
function renderHistory() {
const history = getHistory();
if (!history.length) {
historySection.classList.add("hidden");
return;
}
historySection.classList.remove("hidden");
historyList.innerHTML = "";
for (const entry of history) {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.className = "history-item";
btn.type = "button";
const route = entry.from && entry.to ? ` ${entry.from} \u2192 ${entry.to}` : "";
btn.innerHTML = `<span class="h-code">${entry.code}</span>${route}`;
btn.addEventListener("click", () => {
airlineInput.value = entry.airlineCode;
flightInput.value = entry.flightNumber;
doLookup(entry.airlineCode, entry.flightNumber);
});
li.appendChild(btn);
historyList.appendChild(li);
}
}
// ============ Airline suggestions ============
function showSuggestions(query) {
activeSuggestion = -1;
if (!query || query.length < 1) {
hideSuggestions();
return;
}
const matches = searchAirlines(query);
if (!matches.length) {
hideSuggestions();
return;
}
suggestionItems = matches;
suggestionsBox.innerHTML = "";
matches.forEach((airline, i) => {
const div = document.createElement("div");
div.className = "suggestion";
div.dataset.index = i;
div.innerHTML = `<span class="code">${airline.code}</span> ${airline.name}`;
div.addEventListener("mousedown", (e) => {
e.preventDefault();
selectSuggestion(airline);
});
suggestionsBox.appendChild(div);
});
suggestionsBox.classList.remove("hidden");
}
function hideSuggestions() {
suggestionsBox.classList.add("hidden");
suggestionItems = [];
activeSuggestion = -1;
}
function selectSuggestion(airline) {
airlineInput.value = airline.code;
hideSuggestions();
flightInput.focus();
}
function moveSuggestion(delta) {
if (!suggestionItems.length) return;
activeSuggestion = (activeSuggestion + delta + suggestionItems.length) % suggestionItems.length;
const items = suggestionsBox.querySelectorAll(".suggestion");
items.forEach((el, i) => el.classList.toggle("active", i === activeSuggestion));
if (items[activeSuggestion]) {
items[activeSuggestion].scrollIntoView({ block: "nearest" });
}
}
function commitSuggestion() {
if (activeSuggestion >= 0 && suggestionItems[activeSuggestion]) {
selectSuggestion(suggestionItems[activeSuggestion]);
}
}
// ============ Lookup ============
function doLookup(airlineCode, flightNumber) {
hideSuggestions();
showLoading();
lookupFlight(airlineCode, flightNumber)
.then((flight) => {
if (flight) {
lastFlight = flight;
renderResult(flight);
saveHistory({
code: `${flight.airlineCode}${flight.flightNumber}`,
airlineCode: flight.airlineCode,
flightNumber: flight.flightNumber,
from: flight.origin ? flight.origin.code : null,
to: flight.destination ? flight.destination.code : null,
});
} else {
showError(t("noFlightFound", { flight: `${airlineCode} ${flightNumber}` }));
}
})
.catch(() => {
showError(t("networkError"));
});
}
function showLoading() {
resultBox.className = "result loading";
resultBox.innerHTML = `<div class="spinner"></div><span>${t("searching")}</span>`;
}
function showError(message) {
lastFlight = null;
resultBox.className = "result error";
resultBox.textContent = message;
}
function renderResult(flight) {
const statusText = t(flight.status) || flight.status;
const shareUrl = `${location.origin}${location.pathname}?f=${flight.airlineCode}${flight.flightNumber}`;
const isLive = flight.live && flight.position;
let body;
if (isLive) {
body = renderLiveBody(flight);
} else {
body = renderMockBody(flight);
}
resultBox.className = "result" + (isLive ? " live" : "");
resultBox.innerHTML = `
<div class="flight-header">
<span class="flight-label">${flight.airlineCode} ${flight.flightNumber}</span>
<span class="status-badge ${flight.status}">${statusText}</span>
${isLive ? `<span class="live-pill">${t("live")}</span>` : `<span class="mock-pill">${t("mock")}</span>`}
</div>
${body}
<div class="map-wrap">${renderMap(flight)}</div>
<div class="result-meta">
<span class="airline-name">${flight.airline}</span>
<a class="share-link" href="${shareUrl}" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
${t("copyLink")}
</a>
</div>
`;
}
function renderMockBody(flight) {
const depTime = formatTime(new Date(flight.depTime));
const arrTime = formatTime(new Date(flight.arrTime));
const duration = formatDuration(Math.round((flight.arrTime - flight.depTime) / 60000));
return `
<div class="route">
<div class="airport">
<div class="code">${flight.origin.code}</div>
<div class="city">${flight.origin.city}</div>
<div class="time">${depTime}</div>
<div class="tz">${flight.origin.tz}</div>
</div>
<div class="route-mid">
<div class="route-line"></div>
<div class="route-duration">${duration}</div>
</div>
<div class="airport">
<div class="code">${flight.destination.code}</div>
<div class="city">${flight.destination.city}</div>
<div class="time">${arrTime}</div>
<div class="tz">${flight.destination.tz}</div>
</div>
</div>
`;
}
function renderLiveBody(flight) {
const p = flight.position;
const seenAgo = formatAgo(flight.lastSeen);
const rows = [
[t("altitude"), p.altitudeFt != null ? `${p.altitudeFt.toLocaleString()} ft` : "—"],
[t("speed"), p.velocityKt != null ? `${p.velocityKt} kt` : "—"],
[t("heading"), p.trackDeg != null ? `${p.trackDeg}°` : "—"],
[t("vertRate"), p.vertRateFpm != null ? `${p.vertRateFpm > 0 ? "+" : ""}${p.vertRateFpm} fpm` : "—"],
[t("position"), `${p.lat.toFixed(3)}, ${p.lon.toFixed(3)}`],
[t("lastSeen"), seenAgo],
];
return `
<div class="live-stats">
${rows
.map(
([k, v]) => `
<div class="stat">
<span class="stat-k">${k}</span>
<span class="stat-v">${v}</span>
</div>`
)
.join("")}
</div>
`;
}
// ============ Map rendering (equirectangular) ============
function renderMap(flight) {
const W = 600;
const H = 260;
const PAD = 40;
function project(lat, lon) {
const x = PAD + ((lon + 180) / 360) * (W - PAD * 2);
const y = PAD + ((90 - lat) / 180) * (H - PAD * 2);
return { x, y };
}
// Grid lines
let grid = "";
for (let lon = -180; lon <= 180; lon += 30) {
const p1 = project(-90, lon);
const p2 = project(90, lon);
grid += `<line class="map-grid-line" x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"/>`;
}
for (let lat = -90; lat <= 90; lat += 30) {
const p1 = project(lat, -180);
const p2 = project(lat, 180);
grid += `<line class="map-grid-line" x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"/>`;
}
const isLive = flight.live && flight.position;
let content;
let aria;
if (isLive) {
const p = flight.position;
const pos = project(p.lat, p.lon);
// Heading arrow: a short line from the position in the direction of travel
const rad = ((p.trackDeg || 0) - 90) * (Math.PI / 180);
const len = 26;
const hx = pos.x + Math.cos(rad) * len;
const hy = pos.y + Math.sin(rad) * len;
aria = `${flight.airlineCode}${flight.flightNumber} at ${p.lat.toFixed(2)}, ${p.lon.toFixed(2)}`;
content = `
<circle class="map-live-pulse" cx="${pos.x}" cy="${pos.y}" r="10"/>
<circle class="map-live-dot" cx="${pos.x}" cy="${pos.y}" r="6"/>
<line class="map-heading" x1="${pos.x}" y1="${pos.y}" x2="${hx}" y2="${hy}"/>
<text class="map-label" x="${pos.x}" y="${pos.y - 16}" text-anchor="middle">${flight.airlineCode}${flight.flightNumber}</text>
<text class="map-sublabel" x="${pos.x}" y="${pos.y + 24}" text-anchor="middle">${p.altitudeFt != null ? p.altitudeFt.toLocaleString() + " ft" : ""}</text>
`;
} else {
const from = project(flight.origin.lat, flight.origin.lon);
const to = project(flight.destination.lat, flight.destination.lon);
const midX = (from.x + to.x) / 2;
const midY = (from.y + to.y) / 2;
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const lift = Math.min(dist * 0.25, 60);
const ctrlX = midX - (dy / dist) * lift;
const ctrlY = midY + (dx / dist) * lift;
aria = `${flight.origin.code} to ${flight.destination.code}`;
content = `
<path class="map-route" d="M ${from.x} ${from.y} Q ${ctrlX} ${ctrlY} ${to.x} ${to.y}"/>
<circle class="map-point" cx="${from.x}" cy="${from.y}" r="5"/>
<circle class="map-point-arr" cx="${to.x}" cy="${to.y}" r="5"/>
<text class="map-label" x="${from.x}" y="${from.y - 12}" text-anchor="middle">${flight.origin.code}</text>
<text class="map-sublabel" x="${from.x}" y="${from.y + 20}" text-anchor="middle">${flight.origin.city}</text>
<text class="map-label" x="${to.x}" y="${to.y - 12}" text-anchor="middle">${flight.destination.code}</text>
<text class="map-sublabel" x="${to.x}" y="${to.y + 20}" text-anchor="middle">${flight.destination.city}</text>
`;
}
return `
<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="${aria}">
<defs>
<linearGradient id="routeGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="var(--accent)"/>
<stop offset="100%" stop-color="var(--accent-2)"/>
</linearGradient>
</defs>
${grid}
${content}
</svg>
`;
}
// ============ Formatting helpers ============
function formatTime(date) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function formatDuration(minutes) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m}m`;
return `${h}h ${m}m`;
}
function formatAgo(epochMs) {
if (!epochMs) return "—";
const secs = Math.max(0, Math.round((Date.now() - epochMs) / 1000));
if (secs < 60) return `${secs}s ago`;
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
return `${hrs}h ${mins % 60}m ago`;
}
// ============ URL params ============
function checkUrlParams() {
const params = new URLSearchParams(location.search);
const f = params.get("f");
if (f) {
const decoded = decodeFlight(f);
if (decoded) {
airlineInput.value = decoded.airline;
flightInput.value = decoded.number;
doLookup(decoded.airline, decoded.number);
}
}
}
// ============ Event wiring ============
function onAirlineInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
showSuggestions(airlineInput.value.trim());
}, DEBOUNCE_MS);
}
function onAirlineKeydown(e) {
if (suggestionsBox.classList.contains("hidden")) return;
if (e.key === "ArrowDown") {
e.preventDefault();
moveSuggestion(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
moveSuggestion(-1);
} else if (e.key === "Enter") {
e.preventDefault();
commitSuggestion();
} else if (e.key === "Escape") {
hideSuggestions();
}
}
function onFlightKeydown(e) {
if (e.key === "Enter") {
e.preventDefault();
submitForm();
}
}
function submitForm() {
const airline = airlineInput.value.trim().toUpperCase();
const flight = flightInput.value.trim();
if (!airline || !flight) return;
doLookup(airline, flight);
}
function onFormSubmit(e) {
e.preventDefault();
submitForm();
}
function onDocumentClick(e) {
if (!e.target.closest(".field")) {
hideSuggestions();
}
}
// ============ Service worker ============
function registerServiceWorker() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("sw.js").catch(() => {
// SW registration failed (e.g. file:// or unsupported) — non-fatal
});
}
}
// ============ Init ============
function init() {
initTheme();
initLang();
renderHistory();
checkUrlParams();
form.addEventListener("submit", onFormSubmit);
airlineInput.addEventListener("input", onAirlineInput);
airlineInput.addEventListener("keydown", onAirlineKeydown);
flightInput.addEventListener("keydown", onFlightKeydown);
document.addEventListener("click", onDocumentClick);
langSelect.addEventListener("change", onLangChange);
themeToggle.addEventListener("click", toggleTheme);
registerServiceWorker();
}
document.addEventListener("DOMContentLoaded", init);