From 3a10da048654cbad330e0890ab09da383bce0471 Mon Sep 17 00:00:00 2001 From: kew <108450560+kewonit@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:40:52 +0530 Subject: [PATCH] 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 --- src/components/flight-tracker.tsx | 267 ++++++++++++++++++++++++- src/components/map/flight-layers.tsx | 149 +++++++++++--- src/components/ui/control-panel.tsx | 18 +- src/components/ui/flight-card.tsx | 57 ++++-- src/components/ui/fpv-hud.tsx | 100 +++++++++- src/components/ui/map-attribution.tsx | 11 +- src/components/ui/status-bar.tsx | 11 +- src/hooks/use-flight-track.ts | 209 +++++++++++++++++++ src/hooks/use-flights.ts | 11 +- src/hooks/use-trail-history.ts | 1 + src/lib/cities.ts | 24 +-- src/lib/geo.ts | 23 +++ src/lib/logo-cache.ts | 41 ++++ src/lib/opensky.ts | 277 +++++++++++++++++++++++++- 14 files changed, 1123 insertions(+), 76 deletions(-) create mode 100644 src/hooks/use-flight-track.ts create mode 100644 src/lib/geo.ts create mode 100644 src/lib/logo-cache.ts diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index ea85181..0f7001e 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -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 = []; + + 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() { /> , + maxJumpDeg: number, +): { path: [number, number][]; altitudes: Array } { + 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 = []; + 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])]); }; diff --git a/src/components/ui/control-panel.tsx b/src/components/ui/control-panel.tsx index 494f094..623a3ac 100644 --- a/src/components/ui/control-panel.tsx +++ b/src/components/ui/control-panel.tsx @@ -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 }) => ( open(id)} @@ -218,7 +224,7 @@ function PanelDialog({ Controls