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:
kew
2026-02-22 18:40:52 +05:30
committed by GitHub
parent a08f1c7250
commit 3a10da0486
14 changed files with 1123 additions and 76 deletions

View File

@ -26,6 +26,7 @@ import { SettingsProvider, useSettings } from "@/hooks/use-settings";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useFlights } from "@/hooks/use-flights";
import { useTrailHistory } from "@/hooks/use-trail-history";
import { useFlightTrack } from "@/hooks/use-flight-track";
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
import { CITIES, type City } from "@/lib/cities";
import { AIRPORTS, findByIata, airportToCity } from "@/lib/airports";
@ -34,6 +35,7 @@ import {
fetchFlightByCallsign,
type FlightState,
} from "@/lib/opensky";
import { snapLngToReference, unwrapLngPath } from "@/lib/geo";
import { formatCallsign } from "@/lib/flight-utils";
import type { PickingInfo } from "@deck.gl/core";
import { Github, Star, Keyboard } from "lucide-react";
@ -270,6 +272,267 @@ function FlightTrackerInner() {
const displayFlights = flights;
const displayTrails = useTrailHistory(displayFlights);
// Fetch /tracks only for explicit click-selection (never FPV).
const selectedFlightForTrack = useMemo(() => {
if (!selectedIcao24) return null;
return displayFlights.find((f) => f.icao24 === selectedIcao24) ?? null;
}, [selectedIcao24, displayFlights]);
const shouldFetchSelectedTrack =
!!selectedIcao24 &&
!fpvIcao24 &&
!(selectedFlightForTrack?.onGround ?? false);
const { track: selectedTrack, fetchedAtMs: selectedTrackFetchedAtMs } =
useFlightTrack(selectedIcao24, {
enabled: shouldFetchSelectedTrack,
});
const mergedTrails = useMemo(() => {
if (!selectedIcao24 || !selectedTrack) return displayTrails;
const flight =
displayFlights.find((f) => f.icao24 === selectedIcao24) ?? null;
const livePos: [number, number] | null =
flight && flight.longitude != null && flight.latitude != null
? [flight.longitude, flight.latitude]
: null;
const trackPositions: [number, number][] = [];
const trackAltitudes: Array<number | null> = [];
for (const p of selectedTrack.path) {
if (p.longitude == null || p.latitude == null) continue;
trackPositions.push([p.longitude, p.latitude]);
trackAltitudes.push(p.baroAltitude ?? null);
}
// Unwrap longitudes to avoid dateline/world-wrap glitches.
if (trackPositions.length >= 2) {
const unwrapped = unwrapLngPath(trackPositions);
trackPositions.splice(0, trackPositions.length, ...unwrapped);
}
const livePosAdjusted: [number, number] | null =
livePos && trackPositions.length > 0
? [
snapLngToReference(
livePos[0],
trackPositions[trackPositions.length - 1][0],
),
livePos[1],
]
: livePos;
const lastWaypointTime =
selectedTrack.path[selectedTrack.path.length - 1]?.time;
const nowSec =
selectedTrackFetchedAtMs > 0
? Math.floor(selectedTrackFetchedAtMs / 1000)
: 0;
const lastWaypointAgeSec =
typeof lastWaypointTime === "number" && Number.isFinite(lastWaypointTime)
? Math.max(0, nowSec - lastWaypointTime)
: 0;
const speedMps =
flight && Number.isFinite(flight.velocity) && flight.velocity! > 30
? Math.max(0, flight.velocity!)
: 140;
const expectedDeg = (speedMps * lastWaypointAgeSec) / 111_320;
// Guard against wrong tracks (tolerate sparse/laggy waypoints).
if (livePosAdjusted && trackPositions.length >= 2) {
const searchWindow = 70;
const start = Math.max(0, trackPositions.length - searchWindow);
let bestDistSq = Number.POSITIVE_INFINITY;
for (let i = start; i < trackPositions.length; i++) {
const p = trackPositions[i];
const dx = p[0] - livePosAdjusted[0];
const dy = p[1] - livePosAdjusted[1];
const d2 = dx * dx + dy * dy;
if (d2 < bestDistSq) bestDistSq = d2;
}
// Tracks can be sparse; scale tolerance by speed and waypoint age.
const lowAltitude =
flight && Number.isFinite(flight.baroAltitude)
? flight.baroAltitude! < 6_000
: false;
const maxAllowedDeg = Math.min(
lowAltitude ? 2.8 : 6,
Math.max(lowAltitude ? 0.75 : 0.9, expectedDeg * 1.35 + 0.22),
);
if (bestDistSq > maxAllowedDeg * maxAllowedDeg) {
return displayTrails;
}
}
// Merge the high-frequency live tail for recent turns.
const existingTrail =
displayTrails.find((t) => t.icao24 === selectedIcao24) ?? null;
if (existingTrail && existingTrail.path.length >= 2) {
const tailCount = 18;
const start = Math.max(0, existingTrail.path.length - tailCount);
const rawTailPath = existingTrail.path.slice(start);
const tailAlt = existingTrail.altitudes.slice(start);
// Unwrap tail points to be continuous with the historical track.
const tailPath: [number, number][] = [];
let refLng =
trackPositions.length > 0
? trackPositions[trackPositions.length - 1][0]
: rawTailPath[0][0];
for (const [lng, lat] of rawTailPath) {
const nextLng = snapLngToReference(lng, refLng);
tailPath.push([nextLng, lat]);
refLng = nextLng;
}
// Merge where the two data sources overlap near the end.
const MERGE_SNAP_DEG = 0.06;
const CONNECT_BRIDGE_DEG = 0.07;
const MAX_CONNECT_GAP_DEG =
flight &&
Number.isFinite(flight.baroAltitude) &&
flight.baroAltitude! < 6_000
? 1.25
: 3.5;
const firstTail = tailPath[0];
const searchWindow = 70;
const searchStart = Math.max(0, trackPositions.length - searchWindow);
let bestIndex = -1;
let bestDistSq = Number.POSITIVE_INFINITY;
for (let i = searchStart; i < trackPositions.length; i++) {
const p = trackPositions[i];
const dx = p[0] - firstTail[0];
const dy = p[1] - firstTail[1];
const d2 = dx * dx + dy * dy;
if (d2 < bestDistSq) {
bestDistSq = d2;
bestIndex = i;
}
}
if (bestIndex >= 0 && bestDistSq <= MERGE_SNAP_DEG * MERGE_SNAP_DEG) {
// Snap to overlap, then append the live tail.
trackPositions.splice(bestIndex + 1);
trackAltitudes.splice(bestIndex + 1);
const join = trackPositions[trackPositions.length - 1];
if (join) {
tailPath[0] = join;
const joinAlt = trackAltitudes[trackAltitudes.length - 1] ?? null;
tailAlt[0] = joinAlt ?? tailAlt[0] ?? null;
}
} else {
// No overlap: disconnect stale history or insert a short bridge when close.
const last = trackPositions[trackPositions.length - 1];
const lastAlt = trackAltitudes[trackAltitudes.length - 1] ?? null;
if (last) {
const dx = last[0] - firstTail[0];
const dy = last[1] - firstTail[1];
const gap = Math.sqrt(dx * dx + dy * dy);
const shouldDisconnect =
gap > 0.25 ||
(lastWaypointAgeSec > 900 && gap > 0.06) ||
(lastWaypointAgeSec > 300 && gap > 0.1);
if (shouldDisconnect) {
trackPositions.splice(0, trackPositions.length, ...tailPath);
trackAltitudes.splice(0, trackAltitudes.length, ...tailAlt);
tailPath.length = 0;
tailAlt.length = 0;
} else {
if (gap > MAX_CONNECT_GAP_DEG) {
tailPath.length = 0;
} else if (gap > CONNECT_BRIDGE_DEG) {
const steps = Math.max(6, Math.min(24, Math.ceil(gap / 0.15)));
const firstTailAlt = tailAlt[0] ?? null;
for (let s = 1; s < steps; s++) {
const t = s / steps;
trackPositions.push([
last[0] + (firstTail[0] - last[0]) * t,
last[1] + (firstTail[1] - last[1]) * t,
]);
if (lastAlt == null && firstTailAlt == null) {
trackAltitudes.push(null);
} else {
const a0 = lastAlt ?? firstTailAlt ?? 0;
const a1 = firstTailAlt ?? lastAlt ?? a0;
trackAltitudes.push(a0 + (a1 - a0) * t);
}
}
} else {
tailPath[0] = last;
tailAlt[0] = lastAlt ?? tailAlt[0] ?? null;
}
}
}
}
// Append tail points, skipping consecutive duplicates.
for (let i = 0; i < tailPath.length; i++) {
const pos = tailPath[i];
const alt = tailAlt[i] ?? null;
const last = trackPositions[trackPositions.length - 1];
if (last && last[0] === pos[0] && last[1] === pos[1]) continue;
trackPositions.push(pos);
trackAltitudes.push(alt);
}
}
// Ensure the trail reaches the aircraft.
if (livePosAdjusted) {
const last = trackPositions[trackPositions.length - 1];
if (
!last ||
last[0] !== livePosAdjusted[0] ||
last[1] !== livePosAdjusted[1]
) {
trackPositions.push(livePosAdjusted);
trackAltitudes.push(flight?.baroAltitude ?? null);
}
}
if (trackPositions.length < 2) return displayTrails;
const out = displayTrails.map((t) => {
if (t.icao24 !== selectedIcao24) return t;
const baroAltitude =
trackAltitudes[trackAltitudes.length - 1] ?? t.baroAltitude ?? null;
return {
...t,
path: trackPositions,
altitudes: trackAltitudes,
baroAltitude,
fullHistory: true,
};
});
// If the selected aircraft didn't have an in-memory trail yet, add one.
if (!out.some((t) => t.icao24 === selectedIcao24)) {
out.push({
icao24: selectedIcao24,
path: trackPositions,
altitudes: trackAltitudes,
baroAltitude: trackAltitudes[trackAltitudes.length - 1] ?? null,
fullHistory: true,
});
}
return out;
}, [
selectedIcao24,
selectedTrack,
selectedTrackFetchedAtMs,
displayTrails,
displayFlights,
]);
const selectedFlight = useMemo(() => {
if (!selectedIcao24) return null;
return (
@ -428,7 +691,7 @@ function FlightTrackerInner() {
missingSinceRef.current = now;
return;
}
if (now - missingSinceRef.current >= 30_000) {
if (now - missingSinceRef.current >= 60_000) {
const timer = setTimeout(() => setSelectedIcao24(null), 0);
missingSinceRef.current = null;
return () => clearTimeout(timer);
@ -636,7 +899,7 @@ function FlightTrackerInner() {
/>
<FlightLayers
flights={displayFlights}
trails={displayTrails}
trails={mergedTrails}
onClick={handleClick}
selectedIcao24={fpvIcao24 ?? selectedIcao24}
showTrails={settings.showTrails}

View File

@ -8,6 +8,7 @@ import { ScenegraphLayer } from "@deck.gl/mesh-layers";
import { useMap } from "./map";
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
import type { FlightState } from "@/lib/opensky";
import { snapLngToReference, unwrapLngPath } from "@/lib/geo";
import { type TrailEntry } from "@/hooks/use-trail-history";
import type { PickingInfo } from "@deck.gl/core";
@ -241,6 +242,37 @@ function horizontalDistanceMeters(a: Snapshot, b: Snapshot): number {
return horizontalDistanceFromLngLat(a.lng, a.lat, b.lng, b.lat);
}
function trimAfterLargeJump(
path: [number, number][],
altitudes: Array<number | null>,
maxJumpDeg: number,
): { path: [number, number][]; altitudes: Array<number | null> } {
if (path.length < 2) return { path, altitudes };
const maxJumpSq = maxJumpDeg * maxJumpDeg;
let start = 0;
for (let i = path.length - 2; i >= 0; i--) {
const a = path[i];
const b = path[i + 1];
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (dx * dx + dy * dy > maxJumpSq) {
start = i + 1;
break;
}
}
if (start > 0) {
start = Math.min(start, path.length - 2);
return {
path: path.slice(start),
altitudes: altitudes.slice(start),
};
}
return { path, altitudes };
}
type ElevatedPoint = [number, number, number];
function smoothElevatedPath(
@ -686,19 +718,19 @@ export function FlightLayers({
const curr = currSnapshotsRef.current.get(f.icao24);
if (!curr) return f;
let prev = prevSnapshotsRef.current.get(f.icao24);
const prev = prevSnapshotsRef.current.get(f.icao24);
// For newly-loaded aircraft we may not have a real previous snapshot yet.
// Avoid synthesizing a fake motion vector from heading/velocity because it
// can briefly animate aircraft in the wrong direction until the next poll.
if (!prev) {
const rad = (curr.track * Math.PI) / 180;
const spd = Number.isFinite(f.velocity) ? f.velocity! : 200;
const step = Math.min(
(spd * (animDurationRef.current / 1000)) / 111_320,
0.015,
);
prev = {
lng: curr.lng - Math.sin(rad) * step,
lat: curr.lat - Math.cos(rad) * step,
alt: curr.alt,
track: curr.track,
return {
...f,
longitude: curr.lng,
latitude: curr.lat,
baroAltitude: curr.alt,
trueTrack: Number.isFinite(f.trueTrack)
? f.trueTrack!
: curr.track,
};
}
@ -861,19 +893,69 @@ export function FlightLayers({
trail: TrailEntry,
animFlight: FlightState | undefined,
): ElevatedPoint[] => {
const historyPoints = Math.max(
2,
Math.round(trailDistanceRef.current),
const isFullHistory = trail.fullHistory === true;
const historyPoints = isFullHistory
? trail.path.length
: Math.max(2, Math.round(trailDistanceRef.current));
let pathSlice =
isFullHistory || trail.path.length <= historyPoints
? trail.path
: trail.path.slice(trail.path.length - historyPoints);
let altitudeSlice =
isFullHistory || trail.altitudes.length <= historyPoints
? trail.altitudes
: trail.altitudes.slice(trail.altitudes.length - historyPoints);
// Keep full-history rendering performant by limiting point count.
if (isFullHistory) {
const MAX_FULL_HISTORY_POINTS = 1200;
if (pathSlice.length > MAX_FULL_HISTORY_POINTS) {
const stride = pathSlice.length / MAX_FULL_HISTORY_POINTS;
const nextPath: [number, number][] = [];
const nextAlt: Array<number | null> = [];
for (let i = 0; i < MAX_FULL_HISTORY_POINTS - 1; i++) {
const idx = Math.floor(i * stride);
nextPath.push(pathSlice[idx]);
nextAlt.push(altitudeSlice[idx] ?? null);
}
nextPath.push(pathSlice[pathSlice.length - 1]);
nextAlt.push(altitudeSlice[altitudeSlice.length - 1] ?? null);
pathSlice = nextPath;
altitudeSlice = nextAlt;
}
}
if (altitudeSlice.length !== pathSlice.length) {
const last = altitudeSlice[altitudeSlice.length - 1] ?? null;
if (altitudeSlice.length < pathSlice.length) {
altitudeSlice = [...altitudeSlice];
while (altitudeSlice.length < pathSlice.length) {
altitudeSlice.push(last);
}
} else {
altitudeSlice = altitudeSlice.slice(
altitudeSlice.length - pathSlice.length,
);
}
}
const unwrappedPath = unwrapLngPath(pathSlice);
const maxJumpDeg = isFullHistory ? 3.0 : TELEPORT_THRESHOLD;
const trimmed = trimAfterLargeJump(
unwrappedPath,
altitudeSlice,
maxJumpDeg,
);
const pathSlice =
trail.path.length > historyPoints
? trail.path.slice(trail.path.length - historyPoints)
: trail.path;
const altitudeSlice =
trail.altitudes.length > historyPoints
? trail.altitudes.slice(trail.altitudes.length - historyPoints)
: trail.altitudes;
const smoothPathSlice = smoothPlanarPath(pathSlice);
pathSlice = trimmed.path;
altitudeSlice = trimmed.altitudes;
// The OpenSky track endpoint can be extremely sparse (waypoints ~ every 15min).
// Applying planar smoothing to sparse points can create visible kinks/loops.
// For full-history tracks, keep the raw geometry.
const smoothPathSlice = isFullHistory
? pathSlice
: smoothPlanarPath(pathSlice);
const altitudeMeters = smoothNumericSeries(
altitudeSlice.map(
@ -888,7 +970,7 @@ export function FlightLayers({
]) as ElevatedPoint[];
const denseBasePath = densifyElevatedPath(
basePath,
denseSubdivisions,
isFullHistory ? 1 : denseSubdivisions,
);
if (
@ -897,8 +979,13 @@ export function FlightLayers({
animFlight.latitude != null &&
denseBasePath.length > 1
) {
const clipped = trimPathAheadOfAircraft(denseBasePath, [
const refLng = denseBasePath[denseBasePath.length - 1][0];
const snappedLng = snapLngToReference(
animFlight.longitude,
refLng,
);
const clipped = trimPathAheadOfAircraft(denseBasePath, [
snappedLng,
animFlight.latitude,
Math.max(0, animFlight.baroAltitude ?? 0),
]);
@ -906,7 +993,10 @@ export function FlightLayers({
const smoothed =
clipped.length < 4
? clipped
: smoothElevatedPath(clipped, smoothingIterations);
: smoothElevatedPath(
clipped,
isFullHistory ? 0 : smoothingIterations,
);
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
}
@ -914,7 +1004,10 @@ export function FlightLayers({
const smoothed =
denseBasePath.length < 4
? denseBasePath
: smoothElevatedPath(denseBasePath, smoothingIterations);
: smoothElevatedPath(
denseBasePath,
isFullHistory ? 0 : smoothingIterations,
);
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
};

View File

@ -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

View File

@ -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>

View File

@ -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">

View File

@ -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

View File

@ -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"