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}
|
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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
basePath[basePath.length - 1] = [ax, ay, trailAlt];
|
|
||||||
}
|
}
|
||||||
return basePath;
|
if (basePath.length < 4) return basePath;
|
||||||
|
return smoothElevatedPath(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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user