feat: add trail thickness and distance settings; implement sliders in control panel and update settings context
This commit is contained in:
@ -191,6 +191,8 @@ function FlightTrackerInner() {
|
||||
onHover={handleHover}
|
||||
onClick={handleClick}
|
||||
showTrails={settings.showTrails}
|
||||
trailThickness={settings.trailThickness}
|
||||
trailDistance={settings.trailDistance}
|
||||
showShadows={settings.showShadows}
|
||||
showAltitudeColors={settings.showAltitudeColors}
|
||||
/>
|
||||
|
||||
@ -9,12 +9,15 @@ import type { FlightState } from "@/lib/opensky";
|
||||
import { type TrailEntry } from "@/hooks/use-trail-history";
|
||||
import type { PickingInfo } from "@deck.gl/core";
|
||||
|
||||
const ANIM_DURATION_MS = 30_000;
|
||||
const TELEPORT_THRESHOLD = 0.3; // degrees
|
||||
const DEFAULT_ANIM_DURATION_MS = 30_000;
|
||||
const MIN_ANIM_DURATION_MS = 8_000;
|
||||
const MAX_ANIM_DURATION_MS = 45_000;
|
||||
const TELEPORT_THRESHOLD = 0.3;
|
||||
const TRAIL_BELOW_AIRCRAFT_METERS = 20;
|
||||
const STARTUP_TRAIL_POLLS = 3;
|
||||
const STARTUP_TRAIL_STEP_SEC = 12;
|
||||
const TRACK_DAMPING = 0.18;
|
||||
const TRAIL_SMOOTHING_ITERATIONS = 3;
|
||||
|
||||
function buildStartupFallbackTrail(f: FlightState): [number, number][] {
|
||||
if (f.longitude == null || f.latitude == null) return [];
|
||||
@ -42,10 +45,118 @@ function lerpAngle(a: number, b: number, t: number): number {
|
||||
return a + delta * t;
|
||||
}
|
||||
|
||||
function trackFromDelta(dx: number, dy: number, fallback: number): number {
|
||||
if (dx * dx + dy * dy < 1e-10) return fallback;
|
||||
return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360;
|
||||
}
|
||||
|
||||
function smoothStep(t: number): number {
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
type ElevatedPoint = [number, number, number];
|
||||
|
||||
function smoothElevatedPath(
|
||||
points: ElevatedPoint[],
|
||||
iterations: number = TRAIL_SMOOTHING_ITERATIONS,
|
||||
): ElevatedPoint[] {
|
||||
if (points.length < 3 || iterations <= 0) return points;
|
||||
|
||||
let current = points;
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
if (current.length < 3) break;
|
||||
|
||||
const next: ElevatedPoint[] = [current[0]];
|
||||
for (let i = 0; i < current.length - 1; i++) {
|
||||
const a = current[i];
|
||||
const b = current[i + 1];
|
||||
next.push([
|
||||
a[0] * 0.75 + b[0] * 0.25,
|
||||
a[1] * 0.75 + b[1] * 0.25,
|
||||
a[2] * 0.75 + b[2] * 0.25,
|
||||
]);
|
||||
next.push([
|
||||
a[0] * 0.25 + b[0] * 0.75,
|
||||
a[1] * 0.25 + b[1] * 0.75,
|
||||
a[2] * 0.25 + b[2] * 0.75,
|
||||
]);
|
||||
}
|
||||
next.push(current[current.length - 1]);
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function smoothNumericSeries(values: number[]): number[] {
|
||||
if (values.length < 3) return values;
|
||||
const out = [...values];
|
||||
for (let i = 1; i < values.length - 1; i++) {
|
||||
out[i] = values[i - 1] * 0.2 + values[i] * 0.6 + values[i + 1] * 0.2;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function smoothPlanarPath(points: [number, number][]): [number, number][] {
|
||||
if (points.length < 3) return points;
|
||||
|
||||
let current = points;
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const next = [...current];
|
||||
for (let i = 1; i < current.length - 1; i++) {
|
||||
next[i] = [
|
||||
current[i - 1][0] * 0.2 + current[i][0] * 0.6 + current[i + 1][0] * 0.2,
|
||||
current[i - 1][1] * 0.2 + current[i][1] * 0.6 + current[i + 1][1] * 0.2,
|
||||
];
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function trimPathAheadOfAircraft(
|
||||
points: ElevatedPoint[],
|
||||
aircraft: ElevatedPoint,
|
||||
): ElevatedPoint[] {
|
||||
if (points.length < 2) return [aircraft];
|
||||
|
||||
const px = aircraft[0];
|
||||
const py = aircraft[1];
|
||||
|
||||
let bestIndex = points.length - 2;
|
||||
let bestDistanceSq = Number.POSITIVE_INFINITY;
|
||||
const searchStart = Math.max(0, points.length - 10);
|
||||
|
||||
for (let i = searchStart; i < points.length - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const denom = dx * dx + dy * dy;
|
||||
const t =
|
||||
denom > 1e-12
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(1, ((px - a[0]) * dx + (py - a[1]) * dy) / denom),
|
||||
)
|
||||
: 0;
|
||||
const qx = a[0] + dx * t;
|
||||
const qy = a[1] + dy * t;
|
||||
const distSq = (px - qx) * (px - qx) + (py - qy) * (py - qy);
|
||||
|
||||
if (distSq < bestDistanceSq) {
|
||||
bestDistanceSq = distSq;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = points.slice(0, bestIndex + 1);
|
||||
trimmed.push([px, py, aircraft[2]]);
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function createAircraftAtlas(): HTMLCanvasElement {
|
||||
const size = 128;
|
||||
const canvas = document.createElement("canvas");
|
||||
@ -94,6 +205,8 @@ type FlightLayerProps = {
|
||||
onHover: (info: PickingInfo<FlightState> | null) => void;
|
||||
onClick: (info: PickingInfo<FlightState> | null) => void;
|
||||
showTrails: boolean;
|
||||
trailThickness: number;
|
||||
trailDistance: number;
|
||||
showShadows: boolean;
|
||||
showAltitudeColors: boolean;
|
||||
};
|
||||
@ -104,6 +217,8 @@ export function FlightLayers({
|
||||
onHover,
|
||||
onClick,
|
||||
showTrails,
|
||||
trailThickness,
|
||||
trailDistance,
|
||||
showShadows,
|
||||
showAltitudeColors,
|
||||
}: FlightLayerProps) {
|
||||
@ -114,11 +229,14 @@ export function FlightLayers({
|
||||
const prevSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
|
||||
const currSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
|
||||
const dataTimestampRef = useRef(0);
|
||||
const animDurationRef = useRef(DEFAULT_ANIM_DURATION_MS);
|
||||
const animFrameRef = useRef(0);
|
||||
|
||||
const flightsRef = useRef(flights);
|
||||
const trailsRef = useRef(trails);
|
||||
const showTrailsRef = useRef(showTrails);
|
||||
const trailThicknessRef = useRef(trailThickness);
|
||||
const trailDistanceRef = useRef(trailDistance);
|
||||
const showShadowsRef = useRef(showShadows);
|
||||
const showAltColorsRef = useRef(showAltitudeColors);
|
||||
|
||||
@ -126,14 +244,15 @@ export function FlightLayers({
|
||||
flightsRef.current = flights;
|
||||
trailsRef.current = trails;
|
||||
showTrailsRef.current = showTrails;
|
||||
trailThicknessRef.current = trailThickness;
|
||||
trailDistanceRef.current = trailDistance;
|
||||
showShadowsRef.current = showShadows;
|
||||
showAltColorsRef.current = showAltitudeColors;
|
||||
});
|
||||
|
||||
// Capture current animated position as new "prev" on each data update
|
||||
useEffect(() => {
|
||||
const elapsed = performance.now() - dataTimestampRef.current;
|
||||
const oldLinearT = Math.min(elapsed / ANIM_DURATION_MS, 1);
|
||||
const oldLinearT = Math.min(elapsed / animDurationRef.current, 1);
|
||||
const oldAngleT = smoothStep(oldLinearT);
|
||||
|
||||
const newPrev = new Map<string, Snapshot>();
|
||||
@ -179,7 +298,15 @@ export function FlightLayers({
|
||||
}
|
||||
}
|
||||
currSnapshotsRef.current = next;
|
||||
dataTimestampRef.current = performance.now();
|
||||
const now = performance.now();
|
||||
if (dataTimestampRef.current > 0) {
|
||||
const observedInterval = now - dataTimestampRef.current;
|
||||
animDurationRef.current = Math.max(
|
||||
MIN_ANIM_DURATION_MS,
|
||||
Math.min(MAX_ANIM_DURATION_MS, observedInterval * 0.94),
|
||||
);
|
||||
}
|
||||
dataTimestampRef.current = now;
|
||||
}, [flights]);
|
||||
|
||||
const handleHover = useCallback(
|
||||
@ -232,7 +359,7 @@ export function FlightLayers({
|
||||
|
||||
try {
|
||||
const elapsed = performance.now() - dataTimestampRef.current;
|
||||
const rawT = elapsed / ANIM_DURATION_MS;
|
||||
const rawT = elapsed / animDurationRef.current;
|
||||
const tPos = Math.min(rawT, 1);
|
||||
const tAngle = smoothStep(smoothStep(smoothStep(tPos)));
|
||||
|
||||
@ -249,13 +376,12 @@ export function FlightLayers({
|
||||
const curr = currSnapshotsRef.current.get(f.icao24);
|
||||
if (!curr) return f;
|
||||
|
||||
// Synthesize a virtual "prev" for new flights so they slide in
|
||||
let prev = prevSnapshotsRef.current.get(f.icao24);
|
||||
if (!prev) {
|
||||
const rad = (curr.track * Math.PI) / 180;
|
||||
const spd = f.velocity ?? 200;
|
||||
const step = Math.min(
|
||||
(spd * (ANIM_DURATION_MS / 1000)) / 111_320,
|
||||
(spd * (animDurationRef.current / 1000)) / 111_320,
|
||||
0.015,
|
||||
);
|
||||
prev = {
|
||||
@ -269,31 +395,32 @@ export function FlightLayers({
|
||||
const dx = curr.lng - prev.lng;
|
||||
const dy = curr.lat - prev.lat;
|
||||
if (dx * dx + dy * dy > TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) {
|
||||
return f; // teleport — skip interpolation
|
||||
return f;
|
||||
}
|
||||
|
||||
if (rawT <= 1) {
|
||||
const blendedTrack = lerpAngle(prev.track, curr.track, tAngle);
|
||||
return {
|
||||
...f,
|
||||
longitude: prev.lng + dx * tPos,
|
||||
latitude: prev.lat + dy * tPos,
|
||||
baroAltitude: prev.alt + (curr.alt - prev.alt) * tPos,
|
||||
trueTrack: lerpAngle(prev.track, curr.track, tAngle),
|
||||
trueTrack: trackFromDelta(dx, dy, blendedTrack),
|
||||
};
|
||||
}
|
||||
|
||||
// Extrapolate when the next poll is delayed (velocity-continuous
|
||||
// with the linear interpolation above)
|
||||
const heading = (curr.track * Math.PI) / 180;
|
||||
const speed = f.velocity ?? 200;
|
||||
const extraSec = ((rawT - 1) * ANIM_DURATION_MS) / 1000;
|
||||
const extraSec = ((rawT - 1) * animDurationRef.current) / 1000;
|
||||
const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
|
||||
const moveDx = Math.sin(heading) * extraDeg;
|
||||
const moveDy = Math.cos(heading) * extraDeg;
|
||||
return {
|
||||
...f,
|
||||
longitude: curr.lng + Math.sin(heading) * extraDeg,
|
||||
latitude: curr.lat + Math.cos(heading) * extraDeg,
|
||||
longitude: curr.lng + moveDx,
|
||||
latitude: curr.lat + moveDy,
|
||||
baroAltitude: curr.alt,
|
||||
trueTrack: curr.track,
|
||||
trueTrack: trackFromDelta(moveDx, moveDy, curr.track),
|
||||
};
|
||||
});
|
||||
|
||||
@ -344,6 +471,9 @@ export function FlightLayers({
|
||||
trailData.push({
|
||||
icao24: f.icao24,
|
||||
path: startupPath,
|
||||
altitudes: startupPath.map(
|
||||
() => existing?.baroAltitude ?? f.baroAltitude,
|
||||
),
|
||||
baroAltitude: existing?.baroAltitude ?? f.baroAltitude,
|
||||
});
|
||||
}
|
||||
@ -360,14 +490,35 @@ export function FlightLayers({
|
||||
data: trailData,
|
||||
updateTriggers: { getPath: elapsed },
|
||||
getPath: (d) => {
|
||||
const historyPoints = Math.max(
|
||||
2,
|
||||
Math.round(trailDistanceRef.current),
|
||||
);
|
||||
const pathSlice =
|
||||
d.path.length > historyPoints
|
||||
? d.path.slice(d.path.length - historyPoints)
|
||||
: d.path;
|
||||
const altitudeSlice =
|
||||
d.altitudes.length > historyPoints
|
||||
? d.altitudes.slice(d.altitudes.length - historyPoints)
|
||||
: d.altitudes;
|
||||
const smoothPathSlice = smoothPlanarPath(pathSlice);
|
||||
const altitudeMeters = smoothNumericSeries(
|
||||
altitudeSlice.map((a) =>
|
||||
altitudeToElevation(a ?? d.baroAltitude),
|
||||
),
|
||||
);
|
||||
|
||||
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], trailAlt] as [number, number, number],
|
||||
const basePath = smoothPathSlice.map((p, i) => {
|
||||
const pointAlt =
|
||||
altitudeMeters[i] ?? altitudeToElevation(d.baroAltitude);
|
||||
const trailAlt = Math.max(
|
||||
0,
|
||||
pointAlt - TRAIL_BELOW_AIRCRAFT_METERS,
|
||||
);
|
||||
return [p[0], p[1], trailAlt] as [number, number, number];
|
||||
});
|
||||
if (
|
||||
animFlight &&
|
||||
animFlight.longitude != null &&
|
||||
@ -376,33 +527,33 @@ export function FlightLayers({
|
||||
) {
|
||||
const ax = animFlight.longitude;
|
||||
const ay = animFlight.latitude;
|
||||
const currentAlt = Math.max(
|
||||
0,
|
||||
altitudeToElevation(animFlight.baroAltitude) -
|
||||
TRAIL_BELOW_AIRCRAFT_METERS,
|
||||
);
|
||||
|
||||
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, trailAlt];
|
||||
} else {
|
||||
break;
|
||||
const clipped = trimPathAheadOfAircraft(basePath, [
|
||||
ax,
|
||||
ay,
|
||||
currentAlt,
|
||||
]);
|
||||
if (clipped.length < 4) return clipped;
|
||||
return smoothElevatedPath(clipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
basePath[basePath.length - 1] = [ax, ay, trailAlt];
|
||||
}
|
||||
return basePath;
|
||||
if (basePath.length < 4) return basePath;
|
||||
return smoothElevatedPath(basePath);
|
||||
},
|
||||
getColor: (d) => {
|
||||
const len = d.path.length;
|
||||
const historyPoints = Math.max(
|
||||
2,
|
||||
Math.round(trailDistanceRef.current),
|
||||
);
|
||||
const visibleLen = Math.min(d.path.length, historyPoints);
|
||||
const len =
|
||||
visibleLen < 4
|
||||
? visibleLen
|
||||
: visibleLen * 2 ** TRAIL_SMOOTHING_ITERATIONS;
|
||||
const base = altColors
|
||||
? altitudeToColor(d.baroAltitude)
|
||||
: defaultColor;
|
||||
@ -417,10 +568,10 @@ export function FlightLayers({
|
||||
];
|
||||
}) as [number, number, number, number][];
|
||||
},
|
||||
getWidth: 3,
|
||||
getWidth: trailThicknessRef.current,
|
||||
widthUnits: "pixels",
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 6,
|
||||
widthMinPixels: Math.max(1, trailThicknessRef.current * 0.6),
|
||||
widthMaxPixels: Math.max(2, trailThicknessRef.current * 1.8),
|
||||
billboard: true,
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
|
||||
@ -638,6 +638,10 @@ const ORBIT_SPEED_PRESETS = [
|
||||
const ORBIT_SPEED_MIN = 0.02;
|
||||
const ORBIT_SPEED_MAX = 0.5;
|
||||
const ORBIT_SNAP_THRESHOLD = 0.025;
|
||||
const TRAIL_THICKNESS_MIN = 1;
|
||||
const TRAIL_THICKNESS_MAX = 8;
|
||||
const TRAIL_DISTANCE_MIN = 12;
|
||||
const TRAIL_DISTANCE_MAX = 100;
|
||||
|
||||
const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
|
||||
{ label: "Clockwise", value: "clockwise" },
|
||||
@ -645,7 +649,7 @@ const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
|
||||
];
|
||||
|
||||
function SettingsContent() {
|
||||
const { settings, update } = useSettings();
|
||||
const { settings, update, reset } = useSettings();
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
@ -683,6 +687,18 @@ function SettingsContent() {
|
||||
checked={settings.showTrails}
|
||||
onChange={(v) => update("showTrails", v)}
|
||||
/>
|
||||
{settings.showTrails && (
|
||||
<>
|
||||
<TrailThicknessSlider
|
||||
value={settings.trailThickness}
|
||||
onChange={(v) => update("trailThickness", v)}
|
||||
/>
|
||||
<TrailDistanceSlider
|
||||
value={settings.trailDistance}
|
||||
onChange={(v) => update("trailDistance", v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SettingRow
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
title="Ground shadows"
|
||||
@ -697,6 +713,16 @@ function SettingsContent() {
|
||||
checked={settings.showAltitudeColors}
|
||||
onChange={(v) => update("showAltitudeColors", v)}
|
||||
/>
|
||||
|
||||
<div className="px-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="inline-flex h-8 items-center justify-center rounded-lg px-3 text-[12px] font-medium text-white/65 ring-1 ring-white/10 transition-colors hover:bg-white/5 hover:text-white/85"
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
@ -771,6 +797,74 @@ function OrbitSpeedSlider({
|
||||
);
|
||||
}
|
||||
|
||||
function TrailThicknessSlider({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
|
||||
<Layers className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-medium text-white/80">
|
||||
Trail thickness
|
||||
</p>
|
||||
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
|
||||
{value.toFixed(1)} px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={TRAIL_THICKNESS_MIN}
|
||||
max={TRAIL_THICKNESS_MAX}
|
||||
step={0.1}
|
||||
value={[value]}
|
||||
onValueChange={(vals) => onChange(vals[0])}
|
||||
aria-label="Trail thickness"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrailDistanceSlider({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
|
||||
<Route className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-medium text-white/80">
|
||||
Trail distance
|
||||
</p>
|
||||
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
|
||||
{value} pts
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={TRAIL_DISTANCE_MIN}
|
||||
max={TRAIL_DISTANCE_MAX}
|
||||
step={1}
|
||||
value={[value]}
|
||||
onValueChange={(vals) => onChange(vals[0])}
|
||||
aria-label="Trail distance"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
icon,
|
||||
title,
|
||||
|
||||
@ -18,15 +18,43 @@ export type Settings = {
|
||||
orbitSpeed: number;
|
||||
orbitDirection: OrbitDirection;
|
||||
showTrails: boolean;
|
||||
trailThickness: number;
|
||||
trailDistance: number;
|
||||
showShadows: boolean;
|
||||
showAltitudeColors: boolean;
|
||||
};
|
||||
|
||||
const TRAIL_THICKNESS_MIN = 1;
|
||||
const TRAIL_THICKNESS_MAX = 8;
|
||||
const TRAIL_DISTANCE_MIN = 12;
|
||||
const TRAIL_DISTANCE_MAX = 100;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function normalizeSettings(input: Settings): Settings {
|
||||
return {
|
||||
...input,
|
||||
orbitSpeed: clamp(input.orbitSpeed, 0.02, 0.5),
|
||||
trailThickness: clamp(
|
||||
input.trailThickness,
|
||||
TRAIL_THICKNESS_MIN,
|
||||
TRAIL_THICKNESS_MAX,
|
||||
),
|
||||
trailDistance: Math.round(
|
||||
clamp(input.trailDistance, TRAIL_DISTANCE_MIN, TRAIL_DISTANCE_MAX),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
autoOrbit: true,
|
||||
orbitSpeed: 0.06,
|
||||
orbitDirection: "clockwise",
|
||||
showTrails: true,
|
||||
trailThickness: 2,
|
||||
trailDistance: 40,
|
||||
showShadows: true,
|
||||
showAltitudeColors: true,
|
||||
};
|
||||
@ -50,6 +78,14 @@ function isValidSettings(obj: unknown): obj is Settings {
|
||||
(s.orbitDirection === "clockwise" ||
|
||||
s.orbitDirection === "counter-clockwise") &&
|
||||
typeof s.showTrails === "boolean" &&
|
||||
typeof s.trailThickness === "number" &&
|
||||
Number.isFinite(s.trailThickness) &&
|
||||
s.trailThickness >= TRAIL_THICKNESS_MIN &&
|
||||
s.trailThickness <= TRAIL_THICKNESS_MAX &&
|
||||
typeof s.trailDistance === "number" &&
|
||||
Number.isFinite(s.trailDistance) &&
|
||||
s.trailDistance >= TRAIL_DISTANCE_MIN &&
|
||||
s.trailDistance <= TRAIL_DISTANCE_MAX &&
|
||||
typeof s.showShadows === "boolean" &&
|
||||
typeof s.showAltitudeColors === "boolean"
|
||||
);
|
||||
@ -62,7 +98,6 @@ function loadSettings(): Settings {
|
||||
if (!raw) return DEFAULT_SETTINGS;
|
||||
const envelope: StorageEnvelope = JSON.parse(raw);
|
||||
if (envelope.v !== STORAGE_VERSION || !isValidSettings(envelope.data)) {
|
||||
// Merge salvageable keys with defaults
|
||||
const merged = { ...DEFAULT_SETTINGS };
|
||||
if (typeof envelope.data === "object" && envelope.data !== null) {
|
||||
const d = envelope.data as Record<string, unknown>;
|
||||
@ -72,9 +107,9 @@ function loadSettings(): Settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
return normalizeSettings(merged);
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...envelope.data };
|
||||
return normalizeSettings({ ...DEFAULT_SETTINGS, ...envelope.data });
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
@ -93,6 +128,7 @@ function saveSettings(settings: Settings): void {
|
||||
type SettingsContextValue = {
|
||||
settings: Settings;
|
||||
update: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const SettingsContext = createContext<SettingsContextValue | null>(null);
|
||||
@ -138,14 +174,18 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
<K extends keyof Settings>(key: K, value: Settings[K]) => {
|
||||
setOverride((prev) => {
|
||||
const base = prev ?? getSettingsSnapshot();
|
||||
return { ...base, [key]: value };
|
||||
return normalizeSettings({ ...base, [key]: value });
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setOverride({ ...DEFAULT_SETTINGS });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, update }}>
|
||||
<SettingsContext.Provider value={{ settings, update, reset }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
@ -5,73 +5,44 @@ import type { FlightState } from "@/lib/opensky";
|
||||
|
||||
type Position = [lng: number, lat: number];
|
||||
|
||||
type TrailPoint = {
|
||||
position: Position;
|
||||
baroAltitude: number | null;
|
||||
};
|
||||
|
||||
export type TrailEntry = {
|
||||
icao24: string;
|
||||
path: Position[];
|
||||
altitudes: Array<number | null>;
|
||||
baroAltitude: number | null;
|
||||
};
|
||||
|
||||
const MAX_POINTS = 40;
|
||||
const JUMP_THRESHOLD_DEG = 0.3;
|
||||
export const SAMPLES_PER_SEGMENT = 16;
|
||||
const HISTORICAL_BOOTSTRAP_POLLS = 3;
|
||||
const HISTORICAL_BOOTSTRAP_STEP_SEC = 12;
|
||||
const BOOTSTRAP_UPDATES = 3;
|
||||
const ALTITUDE_RECENT_WINDOW = 6;
|
||||
const ALTITUDE_SOFT_STEP_METERS = 500;
|
||||
const ALTITUDE_HARD_STEP_METERS = 12_000;
|
||||
const ALTITUDE_OUTLIER_BASE_METERS = 1_200;
|
||||
const ALTITUDE_OUTLIER_SCALE = 3;
|
||||
const ALTITUDE_SMOOTHING_ALPHA_TRUSTED = 0.9;
|
||||
const ALTITUDE_SMOOTHING_ALPHA_GUARDED = 0.5;
|
||||
|
||||
// Centripetal Catmull-Rom spline (Barry-Goldman algorithm, α = 0.5).
|
||||
// Produces smooth C1 curves that pass through every control point.
|
||||
function catmullRomSmooth(
|
||||
points: Position[],
|
||||
samplesPerSegment: number = SAMPLES_PER_SEGMENT,
|
||||
): Position[] {
|
||||
if (points.length < 3) return [...points];
|
||||
type AltitudeState = {
|
||||
filtered: number | null;
|
||||
recent: number[];
|
||||
outlierStreak: number;
|
||||
};
|
||||
|
||||
const result: Position[] = [points[0]];
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[Math.max(0, i - 1)];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const p3 = points[Math.min(points.length - 1, i + 2)];
|
||||
|
||||
const d01 = Math.pow(Math.hypot(p1[0] - p0[0], p1[1] - p0[1]), 0.5) || 1e-6;
|
||||
const d12 = Math.pow(Math.hypot(p2[0] - p1[0], p2[1] - p1[1]), 0.5) || 1e-6;
|
||||
const d23 = Math.pow(Math.hypot(p3[0] - p2[0], p3[1] - p2[1]), 0.5) || 1e-6;
|
||||
|
||||
const t0 = 0;
|
||||
const t1 = d01;
|
||||
const t2 = t1 + d12;
|
||||
const t3 = t2 + d23;
|
||||
|
||||
for (let s = 1; s <= samplesPerSegment; s++) {
|
||||
const t = t1 + (t2 - t1) * (s / samplesPerSegment);
|
||||
|
||||
const a1x =
|
||||
((t1 - t) / (t1 - t0)) * p0[0] + ((t - t0) / (t1 - t0)) * p1[0];
|
||||
const a1y =
|
||||
((t1 - t) / (t1 - t0)) * p0[1] + ((t - t0) / (t1 - t0)) * p1[1];
|
||||
const a2x =
|
||||
((t2 - t) / (t2 - t1)) * p1[0] + ((t - t1) / (t2 - t1)) * p2[0];
|
||||
const a2y =
|
||||
((t2 - t) / (t2 - t1)) * p1[1] + ((t - t1) / (t2 - t1)) * p2[1];
|
||||
const a3x =
|
||||
((t3 - t) / (t3 - t2)) * p2[0] + ((t - t2) / (t3 - t2)) * p3[0];
|
||||
const a3y =
|
||||
((t3 - t) / (t3 - t2)) * p2[1] + ((t - t2) / (t3 - t2)) * p3[1];
|
||||
|
||||
const b1x = ((t2 - t) / (t2 - t0)) * a1x + ((t - t0) / (t2 - t0)) * a2x;
|
||||
const b1y = ((t2 - t) / (t2 - t0)) * a1y + ((t - t0) / (t2 - t0)) * a2y;
|
||||
const b2x = ((t3 - t) / (t3 - t1)) * a2x + ((t - t1) / (t3 - t1)) * a3x;
|
||||
const b2y = ((t3 - t) / (t3 - t1)) * a2y + ((t - t1) / (t3 - t1)) * a3y;
|
||||
|
||||
const cx = ((t2 - t) / (t2 - t1)) * b1x + ((t - t1) / (t2 - t1)) * b2x;
|
||||
const cy = ((t2 - t) / (t2 - t1)) * b1y + ((t - t1) / (t2 - t1)) * b2y;
|
||||
|
||||
result.push([cx, cy]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[middle];
|
||||
}
|
||||
|
||||
function synthesizeHistoricalPolls(f: FlightState): Position[] {
|
||||
@ -96,10 +67,58 @@ function synthesizeHistoricalPolls(f: FlightState): Position[] {
|
||||
}
|
||||
|
||||
class TrailStore {
|
||||
private trails = new Map<string, Position[]>();
|
||||
private trails = new Map<string, TrailPoint[]>();
|
||||
private altitudeStates = new Map<string, AltitudeState>();
|
||||
private seen = new Set<string>();
|
||||
private bootstrapUpdatesRemaining = BOOTSTRAP_UPDATES;
|
||||
|
||||
private filterAltitude(id: string, rawAltitude: number | null): number | null {
|
||||
if (rawAltitude == null) return null;
|
||||
|
||||
const state =
|
||||
this.altitudeStates.get(id) ??
|
||||
({ filtered: null, recent: [], outlierStreak: 0 } as AltitudeState);
|
||||
|
||||
if (state.filtered == null) {
|
||||
state.filtered = rawAltitude;
|
||||
state.recent.push(rawAltitude);
|
||||
this.altitudeStates.set(id, state);
|
||||
return rawAltitude;
|
||||
}
|
||||
|
||||
const med = median(state.recent);
|
||||
const absoluteDeviations = state.recent.map((x) => Math.abs(x - med));
|
||||
const mad = median(absoluteDeviations);
|
||||
const outlierThreshold =
|
||||
ALTITUDE_OUTLIER_BASE_METERS + ALTITUDE_OUTLIER_SCALE * Math.max(120, mad);
|
||||
|
||||
const isOutlier = Math.abs(rawAltitude - med) > outlierThreshold;
|
||||
state.outlierStreak = isOutlier ? state.outlierStreak + 1 : 0;
|
||||
const trustedTarget = !isOutlier || state.outlierStreak >= 2;
|
||||
const maxStep = trustedTarget
|
||||
? ALTITUDE_HARD_STEP_METERS
|
||||
: ALTITUDE_SOFT_STEP_METERS;
|
||||
const alpha = trustedTarget
|
||||
? ALTITUDE_SMOOTHING_ALPHA_TRUSTED
|
||||
: ALTITUDE_SMOOTHING_ALPHA_GUARDED;
|
||||
|
||||
const delta = rawAltitude - state.filtered;
|
||||
const clampedDelta = Math.max(
|
||||
-maxStep,
|
||||
Math.min(maxStep, delta),
|
||||
);
|
||||
|
||||
const filtered = state.filtered + clampedDelta * alpha;
|
||||
state.filtered = filtered;
|
||||
state.recent.push(filtered);
|
||||
if (state.recent.length > ALTITUDE_RECENT_WINDOW) {
|
||||
state.recent.splice(0, state.recent.length - ALTITUDE_RECENT_WINDOW);
|
||||
}
|
||||
|
||||
this.altitudeStates.set(id, state);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
update(flights: FlightState[]): TrailEntry[] {
|
||||
const current = new Set<string>();
|
||||
let processedFlightCount = 0;
|
||||
@ -109,13 +128,22 @@ class TrailStore {
|
||||
processedFlightCount += 1;
|
||||
const id = f.icao24;
|
||||
current.add(id);
|
||||
const filteredAltitude = this.filterAltitude(id, f.baroAltitude);
|
||||
|
||||
const pos: Position = [f.longitude, f.latitude];
|
||||
const pos: TrailPoint = {
|
||||
position: [f.longitude, f.latitude],
|
||||
baroAltitude: filteredAltitude,
|
||||
};
|
||||
let trail = this.trails.get(id);
|
||||
|
||||
if (!trail) {
|
||||
trail =
|
||||
this.bootstrapUpdatesRemaining > 0 ? synthesizeHistoricalPolls(f) : [];
|
||||
this.bootstrapUpdatesRemaining > 0
|
||||
? synthesizeHistoricalPolls(f).map((position) => ({
|
||||
position,
|
||||
baroAltitude: filteredAltitude,
|
||||
}))
|
||||
: [];
|
||||
this.trails.set(id, trail);
|
||||
}
|
||||
|
||||
@ -124,9 +152,9 @@ class TrailStore {
|
||||
continue;
|
||||
}
|
||||
|
||||
const last = trail[trail.length - 1];
|
||||
const dx = pos[0] - last[0];
|
||||
const dy = pos[1] - last[1];
|
||||
const last = trail[trail.length - 1].position;
|
||||
const dx = pos.position[0] - last[0];
|
||||
const dy = pos.position[1] - last[1];
|
||||
if (dx * dx + dy * dy > JUMP_THRESHOLD_DEG * JUMP_THRESHOLD_DEG) {
|
||||
trail.length = 0;
|
||||
}
|
||||
@ -138,7 +166,10 @@ class TrailStore {
|
||||
}
|
||||
|
||||
for (const id of this.seen) {
|
||||
if (!current.has(id)) this.trails.delete(id);
|
||||
if (!current.has(id)) {
|
||||
this.trails.delete(id);
|
||||
this.altitudeStates.delete(id);
|
||||
}
|
||||
}
|
||||
this.seen = current;
|
||||
|
||||
@ -150,10 +181,14 @@ class TrailStore {
|
||||
for (const f of flights) {
|
||||
const trail = this.trails.get(f.icao24);
|
||||
if (trail && trail.length >= 2) {
|
||||
const path = trail.map((p) => p.position);
|
||||
const altitudes = trail.map((p) => p.baroAltitude);
|
||||
|
||||
result.push({
|
||||
icao24: f.icao24,
|
||||
path: trail.length >= 5 ? catmullRomSmooth(trail) : [...trail],
|
||||
baroAltitude: f.baroAltitude,
|
||||
path: [...path],
|
||||
altitudes,
|
||||
baroAltitude: altitudes[altitudes.length - 1] ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user