feat: add north-up and reset view functionality to flight tracker and status bar; enhance trail synthesis in useTrailHistory
This commit is contained in:
@ -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>
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user