diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index 79b9610..61c96e1 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -138,6 +138,14 @@ function FlightTrackerInner() { } }, []); + const handleNorthUp = useCallback(() => { + window.dispatchEvent(new CustomEvent("aeris:north-up")); + }, []); + + const handleResetView = useCallback(() => { + window.dispatchEvent(new CustomEvent("aeris:reset-view")); + }, []); + return (
@@ -182,6 +190,8 @@ function FlightTrackerInner() { loading={loading} rateLimited={rateLimited} retryIn={retryIn} + onNorthUp={handleNorthUp} + onResetView={handleResetView} /> diff --git a/src/components/map/camera-controller.tsx b/src/components/map/camera-controller.tsx index 2a5936e..bd65465 100644 --- a/src/components/map/camera-controller.tsx +++ b/src/components/map/camera-controller.tsx @@ -6,6 +6,9 @@ import { useSettings } from "@/hooks/use-settings"; import type { City } from "@/lib/cities"; const IDLE_TIMEOUT_MS = 5_000; +const DEFAULT_ZOOM = 9.2; +const DEFAULT_PITCH = 49; +const DEFAULT_BEARING = 27.4; export function CameraController({ city }: { city: City }) { const { map, isLoaded } = useMap(); @@ -22,14 +25,45 @@ export function CameraController({ city }: { city: City }) { prevCityRef.current = city.id; map.flyTo({ center: city.coordinates, - zoom: 9.2, - pitch: 49, - bearing: 27.4, + zoom: DEFAULT_ZOOM, + pitch: DEFAULT_PITCH, + bearing: DEFAULT_BEARING, duration: 2800, essential: true, }); }, [map, isLoaded, city]); + useEffect(() => { + if (!map || !isLoaded || !city) return; + + const onNorthUp = () => { + map.easeTo({ + bearing: 0, + duration: 650, + essential: true, + }); + }; + + const onResetView = () => { + map.flyTo({ + center: city.coordinates, + zoom: DEFAULT_ZOOM, + pitch: DEFAULT_PITCH, + bearing: DEFAULT_BEARING, + duration: 1200, + essential: true, + }); + }; + + window.addEventListener("aeris:north-up", onNorthUp); + window.addEventListener("aeris:reset-view", onResetView); + + return () => { + window.removeEventListener("aeris:north-up", onNorthUp); + window.removeEventListener("aeris:reset-view", onResetView); + }; + }, [map, isLoaded, city]); + useEffect(() => { if (!map || !isLoaded || !city || !settings.autoOrbit) { if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current); diff --git a/src/components/map/flight-layers.tsx b/src/components/map/flight-layers.tsx index 856ad7e..1ae3ed0 100644 --- a/src/components/map/flight-layers.tsx +++ b/src/components/map/flight-layers.tsx @@ -11,6 +11,28 @@ import type { PickingInfo } from "@deck.gl/core"; const ANIM_DURATION_MS = 30_000; const TELEPORT_THRESHOLD = 0.3; // degrees +const TRAIL_BELOW_AIRCRAFT_METERS = 20; +const STARTUP_TRAIL_POLLS = 3; +const STARTUP_TRAIL_STEP_SEC = 12; + +function buildStartupFallbackTrail(f: FlightState): [number, number][] { + if (f.longitude == null || f.latitude == null) return []; + + const heading = ((f.trueTrack ?? 0) * Math.PI) / 180; + const speed = f.velocity ?? 200; + const degPerSecond = speed / 111_320; + + const path: [number, number][] = []; + for (let i = STARTUP_TRAIL_POLLS; i >= 1; i--) { + const distDeg = Math.min(degPerSecond * STARTUP_TRAIL_STEP_SEC * i, 0.08); + path.push([ + f.longitude - Math.sin(heading) * distDeg, + f.latitude - Math.cos(heading) * distDeg, + ]); + } + path.push([f.longitude, f.latitude]); + return path; +} type Snapshot = { lng: number; lat: number; alt: number; track: number }; @@ -296,18 +318,49 @@ export function FlightLayers({ } if (showTrailsRef.current) { + const trailMap = new Map(currentTrails.map((t) => [t.icao24, t])); + const handledIds = new Set(); + const trailData: TrailEntry[] = []; + + for (const f of interpolated) { + if (f.longitude == null || f.latitude == null) continue; + + const existing = trailMap.get(f.icao24); + handledIds.add(f.icao24); + + if (existing && existing.path.length >= 2) { + trailData.push(existing); + continue; + } + + const startupPath = buildStartupFallbackTrail(f); + + trailData.push({ + icao24: f.icao24, + path: startupPath, + baroAltitude: existing?.baroAltitude ?? f.baroAltitude, + }); + } + + for (const d of currentTrails) { + if (!handledIds.has(d.icao24)) { + trailData.push(d); + } + } + layers.push( new PathLayer({ id: "flight-trails", - data: currentTrails, + data: trailData, updateTriggers: { getPath: elapsed }, getPath: (d) => { const animFlight = interpolatedMap.get(d.icao24); const alt = altitudeToElevation( animFlight?.baroAltitude ?? d.baroAltitude, ); + const trailAlt = Math.max(0, alt - TRAIL_BELOW_AIRCRAFT_METERS); const basePath = d.path.map( - (p) => [p[0], p[1], alt] as [number, number, number], + (p) => [p[0], p[1], trailAlt] as [number, number, number], ); if ( animFlight && @@ -318,20 +371,27 @@ export function FlightLayers({ const ax = animFlight.longitude; const ay = animFlight.latitude; - const heading = ((animFlight.trueTrack ?? 0) * Math.PI) / 180; - const fdx = Math.sin(heading); - const fdy = Math.cos(heading); + if ( + animFlight.trueTrack != null && + basePath.length > STARTUP_TRAIL_POLLS + 3 && + (animFlight.velocity ?? 0) > 40 + ) { + const heading = (animFlight.trueTrack * Math.PI) / 180; + const fdx = Math.sin(heading); + const fdy = Math.cos(heading); - for (let i = basePath.length - 1; i >= 0; i--) { - const vx = basePath[i][0] - ax; - const vy = basePath[i][1] - ay; - if (vx * fdx + vy * fdy > 0) { - basePath[i] = [ax, ay, alt]; - } else { - break; + for (let i = basePath.length - 1; i >= 0; i--) { + const vx = basePath[i][0] - ax; + const vy = basePath[i][1] - ay; + if (vx * fdx + vy * fdy > 0) { + basePath[i] = [ax, ay, trailAlt]; + } else { + break; + } } } - basePath[basePath.length - 1] = [ax, ay, alt]; + + basePath[basePath.length - 1] = [ax, ay, trailAlt]; } return basePath; }, @@ -346,14 +406,15 @@ export function FlightLayers({ base[0], base[1], base[2], - Math.round(tVal * tVal * 100), + Math.round(70 + tVal * 130), ]; }) as [number, number, number, number][]; }, - getWidth: 2, + getWidth: 3, widthUnits: "pixels", - widthMinPixels: 1, - widthMaxPixels: 4, + widthMinPixels: 2, + widthMaxPixels: 6, + billboard: true, capRounded: true, jointRounded: true, }), diff --git a/src/components/ui/status-bar.tsx b/src/components/ui/status-bar.tsx index b9d2719..e2d616d 100644 --- a/src/components/ui/status-bar.tsx +++ b/src/components/ui/status-bar.tsx @@ -1,7 +1,7 @@ "use client"; import { motion, AnimatePresence } from "motion/react"; -import { Plane, Radio, ShieldAlert } from "lucide-react"; +import { Compass, Plane, Radio, ShieldAlert } from "lucide-react"; type StatusBarProps = { flightCount: number; @@ -9,6 +9,8 @@ type StatusBarProps = { loading: boolean; rateLimited?: boolean; retryIn?: number; + onNorthUp?: () => void; + onResetView?: () => void; }; export function StatusBar({ @@ -17,6 +19,8 @@ export function StatusBar({ loading, rateLimited = false, retryIn = 0, + onNorthUp, + onResetView, }: StatusBarProps) { return (
@@ -46,66 +50,104 @@ export function StatusBar({ )} - -
-
- +
+ +
+
+ +
+ + {rateLimited ? "Paused" : loading ? "Scanning..." : "Live"} +
+ +
+ +
+ + + {flightCount} + +
+ +
- {rateLimited ? "Paused" : loading ? "Scanning..." : "Live"} + {cityName} -
+ -
- -
- - - {flightCount} - -
- -
- - {cityName} - - + + + +
); } diff --git a/src/hooks/use-trail-history.ts b/src/hooks/use-trail-history.ts index 9fc1ef3..8b9f432 100644 --- a/src/hooks/use-trail-history.ts +++ b/src/hooks/use-trail-history.ts @@ -12,9 +12,11 @@ export type TrailEntry = { }; const MAX_POINTS = 40; -const SYNTHETIC_COUNT = 12; const JUMP_THRESHOLD_DEG = 0.3; export const SAMPLES_PER_SEGMENT = 8; +const HISTORICAL_BOOTSTRAP_POLLS = 3; +const HISTORICAL_BOOTSTRAP_STEP_SEC = 12; +const BOOTSTRAP_UPDATES = 3; // Centripetal Catmull-Rom spline (Barry-Goldman algorithm, α = 0.5). // Produces smooth C1 curves that pass through every control point. @@ -72,30 +74,39 @@ function catmullRomSmooth( return result; } -function synthesizeTail(f: FlightState): Position[] { - const lng = f.longitude!; - const lat = f.latitude!; +function synthesizeHistoricalPolls(f: FlightState): Position[] { + if (f.longitude == null || f.latitude == null) return []; + const lng = f.longitude; + const lat = f.latitude; const heading = ((f.trueTrack ?? 0) * Math.PI) / 180; const speed = f.velocity ?? 200; - const step = Math.min((speed * 10) / 111_320, 0.02); + const degPerSecond = speed / 111_320; - const pts: Position[] = []; - for (let i = SYNTHETIC_COUNT; i >= 1; i--) { - const d = step * i; - pts.push([lng - Math.sin(heading) * d, lat - Math.cos(heading) * d]); + const polls: Position[] = []; + for (let i = HISTORICAL_BOOTSTRAP_POLLS; i >= 1; i--) { + const tSec = HISTORICAL_BOOTSTRAP_STEP_SEC * i; + const decay = 1 - (HISTORICAL_BOOTSTRAP_POLLS - i) * 0.08; + const distanceDeg = Math.min(degPerSecond * tSec * decay, 0.06); + polls.push([ + lng - Math.sin(heading) * distanceDeg, + lat - Math.cos(heading) * distanceDeg, + ]); } - return pts; + return polls; } class TrailStore { private trails = new Map(); private seen = new Set(); + private bootstrapUpdatesRemaining = BOOTSTRAP_UPDATES; update(flights: FlightState[]): TrailEntry[] { const current = new Set(); + let processedFlightCount = 0; for (const f of flights) { - if (f.longitude === null || f.latitude === null) continue; + if (f.longitude == null || f.latitude == null) continue; + processedFlightCount += 1; const id = f.icao24; current.add(id); @@ -103,10 +114,16 @@ class TrailStore { let trail = this.trails.get(id); if (!trail) { - trail = synthesizeTail(f); + trail = + this.bootstrapUpdatesRemaining > 0 ? synthesizeHistoricalPolls(f) : []; this.trails.set(id, trail); } + if (trail.length === 0) { + trail.push(pos); + continue; + } + const last = trail[trail.length - 1]; const dx = pos[0] - last[0]; const dy = pos[1] - last[1]; @@ -125,13 +142,17 @@ class TrailStore { } this.seen = current; + if (this.bootstrapUpdatesRemaining > 0 && processedFlightCount > 0) { + this.bootstrapUpdatesRemaining -= 1; + } + const result: TrailEntry[] = []; for (const f of flights) { const trail = this.trails.get(f.icao24); if (trail && trail.length >= 2) { result.push({ icao24: f.icao24, - path: trail.length >= 3 ? catmullRomSmooth(trail) : [...trail], + path: trail.length >= 5 ? catmullRomSmooth(trail) : [...trail], baroAltitude: f.baroAltitude, }); }