feat: add north-up and reset view functionality to flight tracker and status bar; enhance trail synthesis in useTrailHistory

This commit is contained in:
Kewonit
2026-02-14 20:27:40 +05:30
parent 0f8012361f
commit 2c60861407
5 changed files with 254 additions and 86 deletions

View File

@ -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 ( return (
<main className="relative h-screen w-screen overflow-hidden bg-black"> <main className="relative h-screen w-screen overflow-hidden bg-black">
<Map mapStyle={mapStyle.style} isDark={mapStyle.dark}> <Map mapStyle={mapStyle.style} isDark={mapStyle.dark}>
@ -182,6 +190,8 @@ function FlightTrackerInner() {
loading={loading} loading={loading}
rateLimited={rateLimited} rateLimited={rateLimited}
retryIn={retryIn} retryIn={retryIn}
onNorthUp={handleNorthUp}
onResetView={handleResetView}
/> />
</div> </div>

View File

@ -6,6 +6,9 @@ import { useSettings } from "@/hooks/use-settings";
import type { City } from "@/lib/cities"; import type { City } from "@/lib/cities";
const IDLE_TIMEOUT_MS = 5_000; 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 }) { export function CameraController({ city }: { city: City }) {
const { map, isLoaded } = useMap(); const { map, isLoaded } = useMap();
@ -22,14 +25,45 @@ export function CameraController({ city }: { city: City }) {
prevCityRef.current = city.id; prevCityRef.current = city.id;
map.flyTo({ map.flyTo({
center: city.coordinates, center: city.coordinates,
zoom: 9.2, zoom: DEFAULT_ZOOM,
pitch: 49, pitch: DEFAULT_PITCH,
bearing: 27.4, bearing: DEFAULT_BEARING,
duration: 2800, duration: 2800,
essential: true, essential: true,
}); });
}, [map, isLoaded, city]); }, [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(() => { useEffect(() => {
if (!map || !isLoaded || !city || !settings.autoOrbit) { if (!map || !isLoaded || !city || !settings.autoOrbit) {
if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current); if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current);

View File

@ -11,6 +11,28 @@ import type { PickingInfo } from "@deck.gl/core";
const ANIM_DURATION_MS = 30_000; const ANIM_DURATION_MS = 30_000;
const TELEPORT_THRESHOLD = 0.3; // degrees 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 }; type Snapshot = { lng: number; lat: number; alt: number; track: number };
@ -296,18 +318,49 @@ export function FlightLayers({
} }
if (showTrailsRef.current) { if (showTrailsRef.current) {
const trailMap = new Map(currentTrails.map((t) => [t.icao24, t]));
const handledIds = new Set<string>();
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( layers.push(
new PathLayer<TrailEntry>({ new PathLayer<TrailEntry>({
id: "flight-trails", id: "flight-trails",
data: currentTrails, data: trailData,
updateTriggers: { getPath: elapsed }, updateTriggers: { getPath: elapsed },
getPath: (d) => { getPath: (d) => {
const animFlight = interpolatedMap.get(d.icao24); const animFlight = interpolatedMap.get(d.icao24);
const alt = altitudeToElevation( const alt = altitudeToElevation(
animFlight?.baroAltitude ?? d.baroAltitude, animFlight?.baroAltitude ?? d.baroAltitude,
); );
const trailAlt = Math.max(0, alt - TRAIL_BELOW_AIRCRAFT_METERS);
const basePath = d.path.map( 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 ( if (
animFlight && animFlight &&
@ -318,20 +371,27 @@ export function FlightLayers({
const ax = animFlight.longitude; const ax = animFlight.longitude;
const ay = animFlight.latitude; const ay = animFlight.latitude;
const heading = ((animFlight.trueTrack ?? 0) * Math.PI) / 180; if (
const fdx = Math.sin(heading); animFlight.trueTrack != null &&
const fdy = Math.cos(heading); 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--) { for (let i = basePath.length - 1; i >= 0; i--) {
const vx = basePath[i][0] - ax; const vx = basePath[i][0] - ax;
const vy = basePath[i][1] - ay; const vy = basePath[i][1] - ay;
if (vx * fdx + vy * fdy > 0) { if (vx * fdx + vy * fdy > 0) {
basePath[i] = [ax, ay, alt]; basePath[i] = [ax, ay, trailAlt];
} else { } else {
break; break;
}
} }
} }
basePath[basePath.length - 1] = [ax, ay, alt];
basePath[basePath.length - 1] = [ax, ay, trailAlt];
} }
return basePath; return basePath;
}, },
@ -346,14 +406,15 @@ export function FlightLayers({
base[0], base[0],
base[1], base[1],
base[2], base[2],
Math.round(tVal * tVal * 100), Math.round(70 + tVal * 130),
]; ];
}) as [number, number, number, number][]; }) as [number, number, number, number][];
}, },
getWidth: 2, getWidth: 3,
widthUnits: "pixels", widthUnits: "pixels",
widthMinPixels: 1, widthMinPixels: 2,
widthMaxPixels: 4, widthMaxPixels: 6,
billboard: true,
capRounded: true, capRounded: true,
jointRounded: true, jointRounded: true,
}), }),

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { Plane, Radio, ShieldAlert } from "lucide-react"; import { Compass, Plane, Radio, ShieldAlert } from "lucide-react";
type StatusBarProps = { type StatusBarProps = {
flightCount: number; flightCount: number;
@ -9,6 +9,8 @@ type StatusBarProps = {
loading: boolean; loading: boolean;
rateLimited?: boolean; rateLimited?: boolean;
retryIn?: number; retryIn?: number;
onNorthUp?: () => void;
onResetView?: () => void;
}; };
export function StatusBar({ export function StatusBar({
@ -17,6 +19,8 @@ export function StatusBar({
loading, loading,
rateLimited = false, rateLimited = false,
retryIn = 0, retryIn = 0,
onNorthUp,
onResetView,
}: StatusBarProps) { }: StatusBarProps) {
return ( return (
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
@ -46,66 +50,104 @@ export function StatusBar({
)} )}
</AnimatePresence> </AnimatePresence>
<motion.div <div className="flex items-center gap-2">
initial={{ opacity: 0, y: 12 }} <motion.div
animate={{ opacity: 1, y: 0 }} initial={{ opacity: 0, y: 12 }}
transition={{ animate={{ opacity: 1, y: 0 }}
type: "spring", transition={{
stiffness: 300, type: "spring",
damping: 24, stiffness: 300,
delay: 0.4, damping: 24,
}} delay: 0.4,
className="flex items-center gap-3 rounded-xl border px-3.5 py-2 backdrop-blur-2xl" }}
style={{ className="flex items-center gap-3 rounded-xl border px-3.5 py-2 backdrop-blur-2xl"
borderColor: "rgb(var(--ui-fg) / 0.06)", style={{
backgroundColor: "rgb(var(--ui-bg) / 0.5)", borderColor: "rgb(var(--ui-fg) / 0.06)",
}} backgroundColor: "rgb(var(--ui-bg) / 0.5)",
aria-live="polite" }}
aria-atomic="true" aria-live="polite"
> aria-atomic="true"
<div className="flex items-center gap-2"> >
<div className="relative"> <div className="flex items-center gap-2">
<Radio <div className="relative">
className={`h-3 w-3 ${rateLimited ? "text-amber-400/80" : "text-emerald-400/80"}`} <Radio
/> className={`h-3 w-3 ${rateLimited ? "text-amber-400/80" : "text-emerald-400/80"}`}
/>
</div>
<span
className="text-[11px] font-medium tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
>
{rateLimited ? "Paused" : loading ? "Scanning..." : "Live"}
</span>
</div> </div>
<div
className="h-3 w-px"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.08)" }}
/>
<div className="flex items-center gap-1.5">
<Plane
className="h-3 w-3"
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
/>
<span
className="text-[11px] font-semibold tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.6)" }}
>
{flightCount}
</span>
</div>
<div
className="h-3 w-px"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.08)" }}
/>
<span <span
className="text-[11px] font-medium tracking-wide" className="text-[11px] font-medium tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }} style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
title={cityName}
> >
{rateLimited ? "Paused" : loading ? "Scanning..." : "Live"} {cityName}
</span> </span>
</div> </motion.div>
<div <motion.div
className="h-3 w-px" initial={{ opacity: 0, y: 12 }}
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.08)" }} animate={{ opacity: 1, y: 0 }}
/> transition={{
type: "spring",
<div className="flex items-center gap-1.5"> stiffness: 300,
<Plane damping: 24,
className="h-3 w-3" delay: 0.48,
style={{ color: "rgb(var(--ui-fg) / 0.3)" }} }}
/> className="flex items-center gap-1 rounded-xl border px-1.5 py-1.5 backdrop-blur-2xl"
<span style={{
className="text-[11px] font-semibold tracking-wide" borderColor: "rgb(var(--ui-fg) / 0.06)",
style={{ color: "rgb(var(--ui-fg) / 0.6)" }} backgroundColor: "rgb(var(--ui-bg) / 0.5)",
> }}
{flightCount}
</span>
</div>
<div
className="h-3 w-px"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.08)" }}
/>
<span
className="text-[11px] font-medium tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
> >
{cityName} <button
</span> type="button"
</motion.div> onClick={onNorthUp}
aria-label="North up"
title="North up"
className="rounded-lg px-2.5 py-1 text-[11px] font-medium tracking-wide transition-colors"
style={{ color: "rgb(var(--ui-fg) / 0.55)" }}
>
<Compass className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onResetView}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium tracking-wide transition-colors"
style={{ color: "rgb(var(--ui-fg) / 0.55)" }}
>
Reset
</button>
</motion.div>
</div>
</div> </div>
); );
} }

View File

@ -12,9 +12,11 @@ export type TrailEntry = {
}; };
const MAX_POINTS = 40; const MAX_POINTS = 40;
const SYNTHETIC_COUNT = 12;
const JUMP_THRESHOLD_DEG = 0.3; const JUMP_THRESHOLD_DEG = 0.3;
export const SAMPLES_PER_SEGMENT = 8; 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). // Centripetal Catmull-Rom spline (Barry-Goldman algorithm, α = 0.5).
// Produces smooth C1 curves that pass through every control point. // Produces smooth C1 curves that pass through every control point.
@ -72,30 +74,39 @@ function catmullRomSmooth(
return result; return result;
} }
function synthesizeTail(f: FlightState): Position[] { function synthesizeHistoricalPolls(f: FlightState): Position[] {
const lng = f.longitude!; if (f.longitude == null || f.latitude == null) return [];
const lat = f.latitude!; const lng = f.longitude;
const lat = f.latitude;
const heading = ((f.trueTrack ?? 0) * Math.PI) / 180; const heading = ((f.trueTrack ?? 0) * Math.PI) / 180;
const speed = f.velocity ?? 200; const speed = f.velocity ?? 200;
const step = Math.min((speed * 10) / 111_320, 0.02); const degPerSecond = speed / 111_320;
const pts: Position[] = []; const polls: Position[] = [];
for (let i = SYNTHETIC_COUNT; i >= 1; i--) { for (let i = HISTORICAL_BOOTSTRAP_POLLS; i >= 1; i--) {
const d = step * i; const tSec = HISTORICAL_BOOTSTRAP_STEP_SEC * i;
pts.push([lng - Math.sin(heading) * d, lat - Math.cos(heading) * d]); 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 { class TrailStore {
private trails = new Map<string, Position[]>(); private trails = new Map<string, Position[]>();
private seen = new Set<string>(); private seen = new Set<string>();
private bootstrapUpdatesRemaining = BOOTSTRAP_UPDATES;
update(flights: FlightState[]): TrailEntry[] { update(flights: FlightState[]): TrailEntry[] {
const current = new Set<string>(); const current = new Set<string>();
let processedFlightCount = 0;
for (const f of flights) { 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; const id = f.icao24;
current.add(id); current.add(id);
@ -103,10 +114,16 @@ class TrailStore {
let trail = this.trails.get(id); let trail = this.trails.get(id);
if (!trail) { if (!trail) {
trail = synthesizeTail(f); trail =
this.bootstrapUpdatesRemaining > 0 ? synthesizeHistoricalPolls(f) : [];
this.trails.set(id, trail); this.trails.set(id, trail);
} }
if (trail.length === 0) {
trail.push(pos);
continue;
}
const last = trail[trail.length - 1]; const last = trail[trail.length - 1];
const dx = pos[0] - last[0]; const dx = pos[0] - last[0];
const dy = pos[1] - last[1]; const dy = pos[1] - last[1];
@ -125,13 +142,17 @@ class TrailStore {
} }
this.seen = current; this.seen = current;
if (this.bootstrapUpdatesRemaining > 0 && processedFlightCount > 0) {
this.bootstrapUpdatesRemaining -= 1;
}
const result: TrailEntry[] = []; const result: TrailEntry[] = [];
for (const f of flights) { for (const f of flights) {
const trail = this.trails.get(f.icao24); const trail = this.trails.get(f.icao24);
if (trail && trail.length >= 2) { if (trail && trail.length >= 2) {
result.push({ result.push({
icao24: f.icao24, icao24: f.icao24,
path: trail.length >= 3 ? catmullRomSmooth(trail) : [...trail], path: trail.length >= 5 ? catmullRomSmooth(trail) : [...trail],
baroAltitude: f.baroAltitude, baroAltitude: f.baroAltitude,
}); });
} }