474 lines
15 KiB
JavaScript
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);
|