feat: implement full flight history tracking and enhance trail rendering (#11)
* feat: implement full flight history tracking and enhance trail rendering * feat: enhance flight tracking logic and improve path handling * feat: implement airline logo caching and error handling in flight components * feat: enhance flight tracking logic to improve waypoint handling and connection logic * feat: refactor longitude handling and improve flight tracking logic * feat: improve longitude handling and enhance airline logo failure tracking
This commit is contained in:
@ -32,12 +32,18 @@ import { formatCallsign } from "@/lib/flight-utils";
|
||||
|
||||
type TabId = "search" | "style" | "settings";
|
||||
|
||||
const TABS: { id: TabId; icon: typeof Search; label: string }[] = [
|
||||
const MAIN_TABS: {
|
||||
id: TabId;
|
||||
icon: typeof Search;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ id: "search", icon: Search, label: "Search" },
|
||||
{ id: "style", icon: MapIcon, label: "Map Style" },
|
||||
{ id: "settings", icon: Settings, label: "Settings" },
|
||||
];
|
||||
|
||||
const PANEL_TABS = MAIN_TABS;
|
||||
|
||||
type ControlPanelProps = {
|
||||
activeCity: City;
|
||||
onSelectCity: (city: City) => void;
|
||||
@ -73,7 +79,7 @@ export function ControlPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{TABS.map(({ id, icon: Icon, label }) => (
|
||||
{MAIN_TABS.map(({ id, icon: Icon, label }) => (
|
||||
<motion.button
|
||||
key={id}
|
||||
onClick={() => open(id)}
|
||||
@ -218,7 +224,7 @@ function PanelDialog({
|
||||
Controls
|
||||
</p>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{TABS.map(({ id, icon: Icon, label }) => {
|
||||
{PANEL_TABS.map(({ id, icon: Icon, label }) => {
|
||||
const active = id === activeTab;
|
||||
return (
|
||||
<button
|
||||
@ -279,7 +285,7 @@ function PanelDialog({
|
||||
id="panel-dialog-title"
|
||||
className="text-[14px] font-semibold tracking-tight text-white/90"
|
||||
>
|
||||
{TABS.find((t) => t.id === activeTab)?.label}
|
||||
{PANEL_TABS.find((t) => t.id === activeTab)?.label}
|
||||
</h2>
|
||||
</div>
|
||||
{/* Desktop header */}
|
||||
@ -288,7 +294,7 @@ function PanelDialog({
|
||||
id="panel-dialog-title"
|
||||
className="text-[15px] font-semibold tracking-tight text-white/90"
|
||||
>
|
||||
{TABS.find((t) => t.id === activeTab)?.label}
|
||||
{PANEL_TABS.find((t) => t.id === activeTab)?.label}
|
||||
</h2>
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
@ -338,7 +344,7 @@ function PanelDialog({
|
||||
{/* Mobile tab bar */}
|
||||
<div className="flex sm:hidden items-center gap-1 border-t border-white/6 px-3 pt-2 pb-3">
|
||||
<nav className="flex flex-1 gap-1">
|
||||
{TABS.map(({ id, icon: Icon, label }) => {
|
||||
{PANEL_TABS.map(({ id, icon: Icon, label }) => {
|
||||
const active = id === activeTab;
|
||||
return (
|
||||
<button
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import {
|
||||
Plane,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Gauge,
|
||||
@ -25,6 +24,11 @@ import {
|
||||
import { lookupAirline, parseFlightNumber } from "@/lib/airlines";
|
||||
import { aircraftTypeHint } from "@/lib/aircraft";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
import {
|
||||
loadedAirlineLogoUrls,
|
||||
markAirlineLogoFailed,
|
||||
wasAirlineLogoRecentlyFailed,
|
||||
} from "@/lib/logo-cache";
|
||||
|
||||
type FlightCardProps = {
|
||||
flight: FlightState | null;
|
||||
@ -33,8 +37,6 @@ type FlightCardProps = {
|
||||
isFpvActive?: boolean;
|
||||
};
|
||||
|
||||
const loadedLogoUrls = new Set<string>();
|
||||
|
||||
export function FlightCard({
|
||||
flight,
|
||||
onClose,
|
||||
@ -60,14 +62,27 @@ export function FlightCard({
|
||||
const [logoLoadedByKey, setLogoLoadedByKey] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [genericLogoFailed, setGenericLogoFailed] = useState(false);
|
||||
const airlineKey = airline ?? "__none__";
|
||||
const logoIndex = logoIndexByAirline[airlineKey] ?? 0;
|
||||
const logoLoadKey = `${airlineKey}:${logoIndex}`;
|
||||
const logoUrl = logoCandidates[logoIndex] ?? null;
|
||||
const baseLogoIndex = logoIndexByAirline[airlineKey] ?? 0;
|
||||
const resolvedLogoIndex = useMemo(() => {
|
||||
let idx = baseLogoIndex;
|
||||
while (
|
||||
idx < logoCandidates.length &&
|
||||
wasAirlineLogoRecentlyFailed(logoCandidates[idx] ?? "")
|
||||
) {
|
||||
idx += 1;
|
||||
}
|
||||
return idx;
|
||||
}, [baseLogoIndex, logoCandidates]);
|
||||
|
||||
const logoLoadKey = `${airlineKey}:${resolvedLogoIndex}`;
|
||||
const logoUrl = logoCandidates[resolvedLogoIndex] ?? null;
|
||||
const logoLoaded =
|
||||
(logoUrl ? loadedLogoUrls.has(logoUrl) : false) ||
|
||||
(logoUrl ? loadedAirlineLogoUrls.has(logoUrl) : false) ||
|
||||
(logoLoadedByKey[logoLoadKey] ?? false);
|
||||
const showLogo = Boolean(logoUrl);
|
||||
const genericLogoUrl = "/airline-logos/envoy-air.png";
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
@ -110,17 +125,19 @@ export function FlightCard({
|
||||
}`}
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
if (logoUrl) loadedLogoUrls.add(logoUrl);
|
||||
if (logoUrl) loadedAirlineLogoUrls.add(logoUrl);
|
||||
setLogoLoadedByKey((current) => ({
|
||||
...current,
|
||||
[logoLoadKey]: true,
|
||||
}));
|
||||
}}
|
||||
onError={() => {
|
||||
if (logoIndex + 1 < logoCandidates.length) {
|
||||
if (logoUrl) markAirlineLogoFailed(logoUrl);
|
||||
|
||||
if (resolvedLogoIndex + 1 < logoCandidates.length) {
|
||||
setLogoIndexByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: logoIndex + 1,
|
||||
[airlineKey]: resolvedLogoIndex + 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@ -132,7 +149,23 @@ export function FlightCard({
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<Plane className="h-10 w-10 text-sky-400/85" />
|
||||
<span className="relative flex h-18 w-18 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-white/95 p-3.5 shadow-sm">
|
||||
{genericLogoFailed ? (
|
||||
<span className="text-[22px] font-semibold text-black/25">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={genericLogoUrl}
|
||||
alt="Generic airline logo"
|
||||
width={68}
|
||||
height={68}
|
||||
className="h-13 w-13 object-contain grayscale opacity-80"
|
||||
unoptimized
|
||||
onError={() => setGenericLogoFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -3,11 +3,16 @@
|
||||
import { useRef, useEffect, useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "motion/react";
|
||||
import { X, Eye, ArrowUp, ArrowDown, Minus, Gauge } from "lucide-react";
|
||||
import { X, ArrowUp, ArrowDown, Minus, Gauge } from "lucide-react";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import { formatCallsign, headingToCardinal } from "@/lib/flight-utils";
|
||||
import { lookupAirline } from "@/lib/airlines";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
import {
|
||||
loadedAirlineLogoUrls,
|
||||
markAirlineLogoFailed,
|
||||
wasAirlineLogoRecentlyFailed,
|
||||
} from "@/lib/logo-cache";
|
||||
|
||||
type FpvHudProps = {
|
||||
flight: FlightState;
|
||||
@ -139,11 +144,39 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
|
||||
() => lookupAirline(flight.callsign),
|
||||
[flight.callsign],
|
||||
);
|
||||
const logoUrl = useMemo(() => {
|
||||
return airlineLogoCandidates(airline)[0] ?? null;
|
||||
}, [airline]);
|
||||
const [logoErrorUrl, setLogoErrorUrl] = useState<string | null>(null);
|
||||
const logoError = logoUrl !== null && logoUrl === logoErrorUrl;
|
||||
const logoCandidates = useMemo(
|
||||
() => airlineLogoCandidates(airline),
|
||||
[airline],
|
||||
);
|
||||
const airlineKey = airline ?? "__none__";
|
||||
const [logoIndexByAirline, setLogoIndexByAirline] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [logoLoadedByKey, setLogoLoadedByKey] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [genericFailedByAirline, setGenericFailedByAirline] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const baseLogoIndex = logoIndexByAirline[airlineKey] ?? 0;
|
||||
const resolvedLogoIndex = useMemo(() => {
|
||||
let idx = baseLogoIndex;
|
||||
while (
|
||||
idx < logoCandidates.length &&
|
||||
wasAirlineLogoRecentlyFailed(logoCandidates[idx] ?? "")
|
||||
) {
|
||||
idx += 1;
|
||||
}
|
||||
return idx;
|
||||
}, [baseLogoIndex, logoCandidates]);
|
||||
const logoUrl = logoCandidates[resolvedLogoIndex] ?? null;
|
||||
const logoLoadKey = `${airlineKey}:${resolvedLogoIndex}`;
|
||||
const logoLoaded =
|
||||
logoUrl !== null &&
|
||||
(loadedAirlineLogoUrls.has(logoUrl) ||
|
||||
(logoLoadedByKey[logoLoadKey] ?? false));
|
||||
const genericLogoFailed = genericFailedByAirline[airlineKey] ?? false;
|
||||
const genericLogoUrl = "/airline-logos/envoy-air.png";
|
||||
const vsIcon =
|
||||
vs !== null && Number.isFinite(vs) ? (
|
||||
vs > 0.5 ? (
|
||||
@ -183,21 +216,68 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
|
||||
|
||||
<div className="flex w-full items-stretch">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 border-r border-white/6 px-2 py-1.5 sm:px-3 sm:py-2">
|
||||
{logoUrl && !logoError ? (
|
||||
{logoUrl ? (
|
||||
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/95 shadow-sm ring-1 ring-white/20">
|
||||
{!logoLoaded && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/85 via-neutral-200/65 to-white/80"
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={airline ? `${airline} logo` : "Airline logo"}
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain p-1"
|
||||
className="relative object-contain p-1"
|
||||
unoptimized
|
||||
onError={() => setLogoErrorUrl(logoUrl)}
|
||||
onLoad={() => {
|
||||
if (logoUrl) loadedAirlineLogoUrls.add(logoUrl);
|
||||
setLogoLoadedByKey((current) => ({
|
||||
...current,
|
||||
[logoLoadKey]: true,
|
||||
}));
|
||||
}}
|
||||
onError={() => {
|
||||
if (logoUrl) markAirlineLogoFailed(logoUrl);
|
||||
if (resolvedLogoIndex + 1 < logoCandidates.length) {
|
||||
setLogoIndexByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: resolvedLogoIndex + 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setLogoIndexByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: logoCandidates.length,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 ring-1 ring-white/10">
|
||||
<Eye className="h-3.5 w-3.5 text-emerald-400/70 animate-pulse" />
|
||||
<span className="relative flex h-7 w-7 items-center justify-center overflow-hidden rounded-full bg-white/95 ring-1 ring-white/15">
|
||||
{genericLogoFailed ? (
|
||||
<span className="text-[12px] font-semibold text-black/25">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={genericLogoUrl}
|
||||
alt="Generic airline logo"
|
||||
fill
|
||||
sizes="28px"
|
||||
className="object-contain p-1 grayscale opacity-80"
|
||||
unoptimized
|
||||
onError={() =>
|
||||
setGenericFailedByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: true,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
|
||||
@ -18,8 +18,17 @@ export function MapAttribution({ styleId }: MapAttributionProps) {
|
||||
|
||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||
|
||||
// Expand by default on larger screens (after mount to avoid hydration mismatch)
|
||||
useEffect(() => {
|
||||
setExpanded(window.innerWidth >= SM_BREAKPOINT);
|
||||
const mq = window.matchMedia(`(min-width: ${SM_BREAKPOINT}px)`);
|
||||
const sync = () => setExpanded(mq.matches);
|
||||
const raf = window.requestAnimationFrame(sync);
|
||||
|
||||
mq.addEventListener("change", sync);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
mq.removeEventListener("change", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close on outside click for small screens
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Compass, Dices, Plane, Radio, ShieldAlert } from "lucide-react";
|
||||
import { Dices, Plane, Radio, ShieldAlert } from "lucide-react";
|
||||
|
||||
type StatusBarProps = {
|
||||
flightCount: number;
|
||||
@ -138,7 +138,14 @@ export function StatusBar({
|
||||
className="text-[11px] font-medium tracking-wide transition-colors"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.55)" }}
|
||||
>
|
||||
<Compass className="h-3 w-3" />
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 3L4 21l8-4 8 4L12 3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="h-3 w-px"
|
||||
|
||||
Reference in New Issue
Block a user