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

View File

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

View File

@ -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<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(
new PathLayer<TrailEntry>({
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,
}),

View File

@ -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 (
<div className="flex flex-col items-start gap-2">
@ -46,66 +50,104 @@ export function StatusBar({
)}
</AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 24,
delay: 0.4,
}}
className="flex items-center gap-3 rounded-xl border px-3.5 py-2 backdrop-blur-2xl"
style={{
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
}}
aria-live="polite"
aria-atomic="true"
>
<div className="flex items-center gap-2">
<div className="relative">
<Radio
className={`h-3 w-3 ${rateLimited ? "text-amber-400/80" : "text-emerald-400/80"}`}
/>
<div className="flex items-center gap-2">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 24,
delay: 0.4,
}}
className="flex items-center gap-3 rounded-xl border px-3.5 py-2 backdrop-blur-2xl"
style={{
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
}}
aria-live="polite"
aria-atomic="true"
>
<div className="flex items-center gap-2">
<div className="relative">
<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
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
className="text-[11px] font-medium tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
title={cityName}
>
{rateLimited ? "Paused" : loading ? "Scanning..." : "Live"}
{cityName}
</span>
</div>
</motion.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
className="text-[11px] font-medium tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 24,
delay: 0.48,
}}
className="flex items-center gap-1 rounded-xl border px-1.5 py-1.5 backdrop-blur-2xl"
style={{
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
}}
>
{cityName}
</span>
</motion.div>
<button
type="button"
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>
);
}

View File

@ -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<string, Position[]>();
private seen = new Set<string>();
private bootstrapUpdatesRemaining = BOOTSTRAP_UPDATES;
update(flights: FlightState[]): TrailEntry[] {
const current = new Set<string>();
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,
});
}