feat: add trail thickness and distance settings; implement sliders in control panel and update settings context

This commit is contained in:
Kewonit
2026-02-15 00:07:04 +05:30
parent 293a54b332
commit fdbc604919
5 changed files with 440 additions and 118 deletions

View File

@ -191,6 +191,8 @@ function FlightTrackerInner() {
onHover={handleHover} onHover={handleHover}
onClick={handleClick} onClick={handleClick}
showTrails={settings.showTrails} showTrails={settings.showTrails}
trailThickness={settings.trailThickness}
trailDistance={settings.trailDistance}
showShadows={settings.showShadows} showShadows={settings.showShadows}
showAltitudeColors={settings.showAltitudeColors} showAltitudeColors={settings.showAltitudeColors}
/> />

View File

@ -9,12 +9,15 @@ import type { FlightState } from "@/lib/opensky";
import { type TrailEntry } from "@/hooks/use-trail-history"; import { type TrailEntry } from "@/hooks/use-trail-history";
import type { PickingInfo } from "@deck.gl/core"; import type { PickingInfo } from "@deck.gl/core";
const ANIM_DURATION_MS = 30_000; const DEFAULT_ANIM_DURATION_MS = 30_000;
const TELEPORT_THRESHOLD = 0.3; // degrees 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 TRAIL_BELOW_AIRCRAFT_METERS = 20;
const STARTUP_TRAIL_POLLS = 3; const STARTUP_TRAIL_POLLS = 3;
const STARTUP_TRAIL_STEP_SEC = 12; const STARTUP_TRAIL_STEP_SEC = 12;
const TRACK_DAMPING = 0.18; const TRACK_DAMPING = 0.18;
const TRAIL_SMOOTHING_ITERATIONS = 3;
function buildStartupFallbackTrail(f: FlightState): [number, number][] { function buildStartupFallbackTrail(f: FlightState): [number, number][] {
if (f.longitude == null || f.latitude == null) return []; 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; 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 { function smoothStep(t: number): number {
return t * t * (3 - 2 * t); 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 { function createAircraftAtlas(): HTMLCanvasElement {
const size = 128; const size = 128;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
@ -94,6 +205,8 @@ type FlightLayerProps = {
onHover: (info: PickingInfo<FlightState> | null) => void; onHover: (info: PickingInfo<FlightState> | null) => void;
onClick: (info: PickingInfo<FlightState> | null) => void; onClick: (info: PickingInfo<FlightState> | null) => void;
showTrails: boolean; showTrails: boolean;
trailThickness: number;
trailDistance: number;
showShadows: boolean; showShadows: boolean;
showAltitudeColors: boolean; showAltitudeColors: boolean;
}; };
@ -104,6 +217,8 @@ export function FlightLayers({
onHover, onHover,
onClick, onClick,
showTrails, showTrails,
trailThickness,
trailDistance,
showShadows, showShadows,
showAltitudeColors, showAltitudeColors,
}: FlightLayerProps) { }: FlightLayerProps) {
@ -114,11 +229,14 @@ export function FlightLayers({
const prevSnapshotsRef = useRef<Map<string, Snapshot>>(new Map()); const prevSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
const currSnapshotsRef = useRef<Map<string, Snapshot>>(new Map()); const currSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
const dataTimestampRef = useRef(0); const dataTimestampRef = useRef(0);
const animDurationRef = useRef(DEFAULT_ANIM_DURATION_MS);
const animFrameRef = useRef(0); const animFrameRef = useRef(0);
const flightsRef = useRef(flights); const flightsRef = useRef(flights);
const trailsRef = useRef(trails); const trailsRef = useRef(trails);
const showTrailsRef = useRef(showTrails); const showTrailsRef = useRef(showTrails);
const trailThicknessRef = useRef(trailThickness);
const trailDistanceRef = useRef(trailDistance);
const showShadowsRef = useRef(showShadows); const showShadowsRef = useRef(showShadows);
const showAltColorsRef = useRef(showAltitudeColors); const showAltColorsRef = useRef(showAltitudeColors);
@ -126,14 +244,15 @@ export function FlightLayers({
flightsRef.current = flights; flightsRef.current = flights;
trailsRef.current = trails; trailsRef.current = trails;
showTrailsRef.current = showTrails; showTrailsRef.current = showTrails;
trailThicknessRef.current = trailThickness;
trailDistanceRef.current = trailDistance;
showShadowsRef.current = showShadows; showShadowsRef.current = showShadows;
showAltColorsRef.current = showAltitudeColors; showAltColorsRef.current = showAltitudeColors;
}); });
// Capture current animated position as new "prev" on each data update
useEffect(() => { useEffect(() => {
const elapsed = performance.now() - dataTimestampRef.current; 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 oldAngleT = smoothStep(oldLinearT);
const newPrev = new Map<string, Snapshot>(); const newPrev = new Map<string, Snapshot>();
@ -179,7 +298,15 @@ export function FlightLayers({
} }
} }
currSnapshotsRef.current = next; 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]); }, [flights]);
const handleHover = useCallback( const handleHover = useCallback(
@ -232,7 +359,7 @@ export function FlightLayers({
try { try {
const elapsed = performance.now() - dataTimestampRef.current; const elapsed = performance.now() - dataTimestampRef.current;
const rawT = elapsed / ANIM_DURATION_MS; const rawT = elapsed / animDurationRef.current;
const tPos = Math.min(rawT, 1); const tPos = Math.min(rawT, 1);
const tAngle = smoothStep(smoothStep(smoothStep(tPos))); const tAngle = smoothStep(smoothStep(smoothStep(tPos)));
@ -249,13 +376,12 @@ export function FlightLayers({
const curr = currSnapshotsRef.current.get(f.icao24); const curr = currSnapshotsRef.current.get(f.icao24);
if (!curr) return f; if (!curr) return f;
// Synthesize a virtual "prev" for new flights so they slide in
let prev = prevSnapshotsRef.current.get(f.icao24); let prev = prevSnapshotsRef.current.get(f.icao24);
if (!prev) { if (!prev) {
const rad = (curr.track * Math.PI) / 180; const rad = (curr.track * Math.PI) / 180;
const spd = f.velocity ?? 200; const spd = f.velocity ?? 200;
const step = Math.min( const step = Math.min(
(spd * (ANIM_DURATION_MS / 1000)) / 111_320, (spd * (animDurationRef.current / 1000)) / 111_320,
0.015, 0.015,
); );
prev = { prev = {
@ -269,31 +395,32 @@ export function FlightLayers({
const dx = curr.lng - prev.lng; const dx = curr.lng - prev.lng;
const dy = curr.lat - prev.lat; const dy = curr.lat - prev.lat;
if (dx * dx + dy * dy > TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) { if (dx * dx + dy * dy > TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) {
return f; // teleport — skip interpolation return f;
} }
if (rawT <= 1) { if (rawT <= 1) {
const blendedTrack = lerpAngle(prev.track, curr.track, tAngle);
return { return {
...f, ...f,
longitude: prev.lng + dx * tPos, longitude: prev.lng + dx * tPos,
latitude: prev.lat + dy * tPos, latitude: prev.lat + dy * tPos,
baroAltitude: prev.alt + (curr.alt - prev.alt) * 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 heading = (curr.track * Math.PI) / 180;
const speed = f.velocity ?? 200; 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 extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
const moveDx = Math.sin(heading) * extraDeg;
const moveDy = Math.cos(heading) * extraDeg;
return { return {
...f, ...f,
longitude: curr.lng + Math.sin(heading) * extraDeg, longitude: curr.lng + moveDx,
latitude: curr.lat + Math.cos(heading) * extraDeg, latitude: curr.lat + moveDy,
baroAltitude: curr.alt, baroAltitude: curr.alt,
trueTrack: curr.track, trueTrack: trackFromDelta(moveDx, moveDy, curr.track),
}; };
}); });
@ -344,6 +471,9 @@ export function FlightLayers({
trailData.push({ trailData.push({
icao24: f.icao24, icao24: f.icao24,
path: startupPath, path: startupPath,
altitudes: startupPath.map(
() => existing?.baroAltitude ?? f.baroAltitude,
),
baroAltitude: existing?.baroAltitude ?? f.baroAltitude, baroAltitude: existing?.baroAltitude ?? f.baroAltitude,
}); });
} }
@ -360,14 +490,35 @@ export function FlightLayers({
data: trailData, data: trailData,
updateTriggers: { getPath: elapsed }, updateTriggers: { getPath: elapsed },
getPath: (d) => { 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 animFlight = interpolatedMap.get(d.icao24);
const alt = altitudeToElevation( const basePath = smoothPathSlice.map((p, i) => {
animFlight?.baroAltitude ?? d.baroAltitude, const pointAlt =
); altitudeMeters[i] ?? altitudeToElevation(d.baroAltitude);
const trailAlt = Math.max(0, alt - TRAIL_BELOW_AIRCRAFT_METERS); const trailAlt = Math.max(
const basePath = d.path.map( 0,
(p) => [p[0], p[1], trailAlt] as [number, number, number], pointAlt - TRAIL_BELOW_AIRCRAFT_METERS,
); );
return [p[0], p[1], trailAlt] as [number, number, number];
});
if ( if (
animFlight && animFlight &&
animFlight.longitude != null && animFlight.longitude != null &&
@ -376,33 +527,33 @@ export function FlightLayers({
) { ) {
const ax = animFlight.longitude; const ax = animFlight.longitude;
const ay = animFlight.latitude; const ay = animFlight.latitude;
const currentAlt = Math.max(
0,
altitudeToElevation(animFlight.baroAltitude) -
TRAIL_BELOW_AIRCRAFT_METERS,
);
if ( const clipped = trimPathAheadOfAircraft(basePath, [
animFlight.trueTrack != null && ax,
basePath.length > STARTUP_TRAIL_POLLS + 3 && ay,
(animFlight.velocity ?? 0) > 40 currentAlt,
) { ]);
const heading = (animFlight.trueTrack * Math.PI) / 180; if (clipped.length < 4) return clipped;
const fdx = Math.sin(heading); return smoothElevatedPath(clipped);
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;
} }
} if (basePath.length < 4) return basePath;
} return smoothElevatedPath(basePath);
basePath[basePath.length - 1] = [ax, ay, trailAlt];
}
return basePath;
}, },
getColor: (d) => { 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 const base = altColors
? altitudeToColor(d.baroAltitude) ? altitudeToColor(d.baroAltitude)
: defaultColor; : defaultColor;
@ -417,10 +568,10 @@ export function FlightLayers({
]; ];
}) as [number, number, number, number][]; }) as [number, number, number, number][];
}, },
getWidth: 3, getWidth: trailThicknessRef.current,
widthUnits: "pixels", widthUnits: "pixels",
widthMinPixels: 2, widthMinPixels: Math.max(1, trailThicknessRef.current * 0.6),
widthMaxPixels: 6, widthMaxPixels: Math.max(2, trailThicknessRef.current * 1.8),
billboard: true, billboard: true,
capRounded: true, capRounded: true,
jointRounded: true, jointRounded: true,

View File

@ -638,6 +638,10 @@ const ORBIT_SPEED_PRESETS = [
const ORBIT_SPEED_MIN = 0.02; const ORBIT_SPEED_MIN = 0.02;
const ORBIT_SPEED_MAX = 0.5; const ORBIT_SPEED_MAX = 0.5;
const ORBIT_SNAP_THRESHOLD = 0.025; 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 }[] = [ const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
{ label: "Clockwise", value: "clockwise" }, { label: "Clockwise", value: "clockwise" },
@ -645,7 +649,7 @@ const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
]; ];
function SettingsContent() { function SettingsContent() {
const { settings, update } = useSettings(); const { settings, update, reset } = useSettings();
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
@ -683,6 +687,18 @@ function SettingsContent() {
checked={settings.showTrails} checked={settings.showTrails}
onChange={(v) => update("showTrails", v)} 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 <SettingRow
icon={<Layers className="h-4 w-4" />} icon={<Layers className="h-4 w-4" />}
title="Ground shadows" title="Ground shadows"
@ -697,6 +713,16 @@ function SettingsContent() {
checked={settings.showAltitudeColors} checked={settings.showAltitudeColors}
onChange={(v) => update("showAltitudeColors", v)} 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> </div>
</ScrollArea> </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({ function SettingRow({
icon, icon,
title, title,

View File

@ -18,15 +18,43 @@ export type Settings = {
orbitSpeed: number; orbitSpeed: number;
orbitDirection: OrbitDirection; orbitDirection: OrbitDirection;
showTrails: boolean; showTrails: boolean;
trailThickness: number;
trailDistance: number;
showShadows: boolean; showShadows: boolean;
showAltitudeColors: 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 = { const DEFAULT_SETTINGS: Settings = {
autoOrbit: true, autoOrbit: true,
orbitSpeed: 0.06, orbitSpeed: 0.06,
orbitDirection: "clockwise", orbitDirection: "clockwise",
showTrails: true, showTrails: true,
trailThickness: 2,
trailDistance: 40,
showShadows: true, showShadows: true,
showAltitudeColors: true, showAltitudeColors: true,
}; };
@ -50,6 +78,14 @@ function isValidSettings(obj: unknown): obj is Settings {
(s.orbitDirection === "clockwise" || (s.orbitDirection === "clockwise" ||
s.orbitDirection === "counter-clockwise") && s.orbitDirection === "counter-clockwise") &&
typeof s.showTrails === "boolean" && 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.showShadows === "boolean" &&
typeof s.showAltitudeColors === "boolean" typeof s.showAltitudeColors === "boolean"
); );
@ -62,7 +98,6 @@ function loadSettings(): Settings {
if (!raw) return DEFAULT_SETTINGS; if (!raw) return DEFAULT_SETTINGS;
const envelope: StorageEnvelope = JSON.parse(raw); const envelope: StorageEnvelope = JSON.parse(raw);
if (envelope.v !== STORAGE_VERSION || !isValidSettings(envelope.data)) { if (envelope.v !== STORAGE_VERSION || !isValidSettings(envelope.data)) {
// Merge salvageable keys with defaults
const merged = { ...DEFAULT_SETTINGS }; const merged = { ...DEFAULT_SETTINGS };
if (typeof envelope.data === "object" && envelope.data !== null) { if (typeof envelope.data === "object" && envelope.data !== null) {
const d = envelope.data as Record<string, unknown>; 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 { } catch {
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;
} }
@ -93,6 +128,7 @@ function saveSettings(settings: Settings): void {
type SettingsContextValue = { type SettingsContextValue = {
settings: Settings; settings: Settings;
update: <K extends keyof Settings>(key: K, value: Settings[K]) => void; update: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
reset: () => void;
}; };
const SettingsContext = createContext<SettingsContextValue | null>(null); 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]) => { <K extends keyof Settings>(key: K, value: Settings[K]) => {
setOverride((prev) => { setOverride((prev) => {
const base = prev ?? getSettingsSnapshot(); const base = prev ?? getSettingsSnapshot();
return { ...base, [key]: value }; return normalizeSettings({ ...base, [key]: value });
}); });
}, },
[], [],
); );
const reset = useCallback(() => {
setOverride({ ...DEFAULT_SETTINGS });
}, []);
return ( return (
<SettingsContext.Provider value={{ settings, update }}> <SettingsContext.Provider value={{ settings, update, reset }}>
{children} {children}
</SettingsContext.Provider> </SettingsContext.Provider>
); );

View File

@ -5,73 +5,44 @@ import type { FlightState } from "@/lib/opensky";
type Position = [lng: number, lat: number]; type Position = [lng: number, lat: number];
type TrailPoint = {
position: Position;
baroAltitude: number | null;
};
export type TrailEntry = { export type TrailEntry = {
icao24: string; icao24: string;
path: Position[]; path: Position[];
altitudes: Array<number | null>;
baroAltitude: number | null; baroAltitude: number | null;
}; };
const MAX_POINTS = 40; const MAX_POINTS = 40;
const JUMP_THRESHOLD_DEG = 0.3; const JUMP_THRESHOLD_DEG = 0.3;
export const SAMPLES_PER_SEGMENT = 16;
const HISTORICAL_BOOTSTRAP_POLLS = 3; const HISTORICAL_BOOTSTRAP_POLLS = 3;
const HISTORICAL_BOOTSTRAP_STEP_SEC = 12; const HISTORICAL_BOOTSTRAP_STEP_SEC = 12;
const BOOTSTRAP_UPDATES = 3; 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). type AltitudeState = {
// Produces smooth C1 curves that pass through every control point. filtered: number | null;
function catmullRomSmooth( recent: number[];
points: Position[], outlierStreak: number;
samplesPerSegment: number = SAMPLES_PER_SEGMENT, };
): Position[] {
if (points.length < 3) return [...points];
const result: Position[] = [points[0]]; function median(values: number[]): number {
if (values.length === 0) return 0;
for (let i = 0; i < points.length - 1; i++) { const sorted = [...values].sort((a, b) => a - b);
const p0 = points[Math.max(0, i - 1)]; const middle = Math.floor(sorted.length / 2);
const p1 = points[i]; return sorted.length % 2 === 0
const p2 = points[i + 1]; ? (sorted[middle - 1] + sorted[middle]) / 2
const p3 = points[Math.min(points.length - 1, i + 2)]; : sorted[middle];
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 synthesizeHistoricalPolls(f: FlightState): Position[] { function synthesizeHistoricalPolls(f: FlightState): Position[] {
@ -96,10 +67,58 @@ function synthesizeHistoricalPolls(f: FlightState): Position[] {
} }
class TrailStore { 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 seen = new Set<string>();
private bootstrapUpdatesRemaining = BOOTSTRAP_UPDATES; 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[] { update(flights: FlightState[]): TrailEntry[] {
const current = new Set<string>(); const current = new Set<string>();
let processedFlightCount = 0; let processedFlightCount = 0;
@ -109,13 +128,22 @@ class TrailStore {
processedFlightCount += 1; processedFlightCount += 1;
const id = f.icao24; const id = f.icao24;
current.add(id); 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); let trail = this.trails.get(id);
if (!trail) { if (!trail) {
trail = trail =
this.bootstrapUpdatesRemaining > 0 ? synthesizeHistoricalPolls(f) : []; this.bootstrapUpdatesRemaining > 0
? synthesizeHistoricalPolls(f).map((position) => ({
position,
baroAltitude: filteredAltitude,
}))
: [];
this.trails.set(id, trail); this.trails.set(id, trail);
} }
@ -124,9 +152,9 @@ class TrailStore {
continue; continue;
} }
const last = trail[trail.length - 1]; const last = trail[trail.length - 1].position;
const dx = pos[0] - last[0]; const dx = pos.position[0] - last[0];
const dy = pos[1] - last[1]; const dy = pos.position[1] - last[1];
if (dx * dx + dy * dy > JUMP_THRESHOLD_DEG * JUMP_THRESHOLD_DEG) { if (dx * dx + dy * dy > JUMP_THRESHOLD_DEG * JUMP_THRESHOLD_DEG) {
trail.length = 0; trail.length = 0;
} }
@ -138,7 +166,10 @@ class TrailStore {
} }
for (const id of this.seen) { 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; this.seen = current;
@ -150,10 +181,14 @@ class TrailStore {
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) {
const path = trail.map((p) => p.position);
const altitudes = trail.map((p) => p.baroAltitude);
result.push({ result.push({
icao24: f.icao24, icao24: f.icao24,
path: trail.length >= 5 ? catmullRomSmooth(trail) : [...trail], path: [...path],
baroAltitude: f.baroAltitude, altitudes,
baroAltitude: altitudes[altitudes.length - 1] ?? null,
}); });
} }
} }