diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index 0827402..6a83c72 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -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} /> diff --git a/src/components/map/flight-layers.tsx b/src/components/map/flight-layers.tsx index d12d7be..2503a52 100644 --- a/src/components/map/flight-layers.tsx +++ b/src/components/map/flight-layers.tsx @@ -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 | null) => void; onClick: (info: PickingInfo | 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>(new Map()); const currSnapshotsRef = useRef>(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(); @@ -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; - } - } - } - - basePath[basePath.length - 1] = [ax, ay, trailAlt]; + const clipped = trimPathAheadOfAircraft(basePath, [ + ax, + ay, + currentAlt, + ]); + if (clipped.length < 4) return clipped; + return smoothElevatedPath(clipped); } - 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, diff --git a/src/components/ui/control-panel.tsx b/src/components/ui/control-panel.tsx index 3d58a6d..50805d1 100644 --- a/src/components/ui/control-panel.tsx +++ b/src/components/ui/control-panel.tsx @@ -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 ( @@ -683,6 +687,18 @@ function SettingsContent() { checked={settings.showTrails} onChange={(v) => update("showTrails", v)} /> + {settings.showTrails && ( + <> + update("trailThickness", v)} + /> + update("trailDistance", v)} + /> + + )} } title="Ground shadows" @@ -697,6 +713,16 @@ function SettingsContent() { checked={settings.showAltitudeColors} onChange={(v) => update("showAltitudeColors", v)} /> + +
+ +
); @@ -771,6 +797,74 @@ function OrbitSpeedSlider({ ); } +function TrailThicknessSlider({ + value, + onChange, +}: { + value: number; + onChange: (v: number) => void; +}) { + return ( +
+
+ +
+
+
+

+ Trail thickness +

+ + {value.toFixed(1)} px + +
+ onChange(vals[0])} + aria-label="Trail thickness" + /> +
+
+ ); +} + +function TrailDistanceSlider({ + value, + onChange, +}: { + value: number; + onChange: (v: number) => void; +}) { + return ( +
+
+ +
+
+
+

+ Trail distance +

+ + {value} pts + +
+ onChange(vals[0])} + aria-label="Trail distance" + /> +
+
+ ); +} + function SettingRow({ icon, title, diff --git a/src/hooks/use-settings.tsx b/src/hooks/use-settings.tsx index 3f24d50..53a532b 100644 --- a/src/hooks/use-settings.tsx +++ b/src/hooks/use-settings.tsx @@ -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; @@ -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: (key: K, value: Settings[K]) => void; + reset: () => void; }; const SettingsContext = createContext(null); @@ -138,14 +174,18 @@ export function SettingsProvider({ children }: { children: ReactNode }) { (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 ( - + {children} ); diff --git a/src/hooks/use-trail-history.ts b/src/hooks/use-trail-history.ts index 49f60fa..e424198 100644 --- a/src/hooks/use-trail-history.ts +++ b/src/hooks/use-trail-history.ts @@ -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; 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(); + private trails = new Map(); + private altitudeStates = new Map(); private seen = new Set(); 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(); 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, }); } }