Files
aeris/src/hooks/use-trail-history.ts

203 lines
5.9 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
import type { FlightState } from "@/lib/opensky";
type Position = [lng: number, lat: number];
type TrailPoint = {
position: Position;
baroAltitude: number | null;
};
export type TrailEntry = {
icao24: string;
path: Position[];
altitudes: Array<number | null>;
baroAltitude: number | null;
};
const MAX_POINTS = 40;
const JUMP_THRESHOLD_DEG = 0.3;
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;
type AltitudeState = {
filtered: number | null;
recent: number[];
outlierStreak: number;
};
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[] {
if (f.longitude == null || f.latitude == null) return [];
const lng = f.longitude;
const lat = f.latitude;
const heading = ((f.trueTrack ?? 0) * Math.PI) / 180;
const speed = f.velocity ?? 200;
const degPerSecond = speed / 111_320;
const polls: Position[] = [];
for (let i = HISTORICAL_BOOTSTRAP_POLLS; i >= 1; i--) {
const tSec = HISTORICAL_BOOTSTRAP_STEP_SEC * i;
const decay = 1 - (HISTORICAL_BOOTSTRAP_POLLS - i) * 0.08;
const distanceDeg = Math.min(degPerSecond * tSec * decay, 0.06);
polls.push([
lng - Math.sin(heading) * distanceDeg,
lat - Math.cos(heading) * distanceDeg,
]);
}
return polls;
}
class TrailStore {
private trails = new Map<string, TrailPoint[]>();
private altitudeStates = new Map<string, AltitudeState>();
private seen = new Set<string>();
private bootstrapUpdatesRemaining = BOOTSTRAP_UPDATES;
private filterAltitude(id: string, rawAltitude: number | null): number | null {
if (rawAltitude == null) return null;
const state =
this.altitudeStates.get(id) ??
({ filtered: null, recent: [], outlierStreak: 0 } as AltitudeState);
if (state.filtered == null) {
state.filtered = rawAltitude;
state.recent.push(rawAltitude);
this.altitudeStates.set(id, state);
return rawAltitude;
}
const med = median(state.recent);
const absoluteDeviations = state.recent.map((x) => Math.abs(x - med));
const mad = median(absoluteDeviations);
const outlierThreshold =
ALTITUDE_OUTLIER_BASE_METERS + ALTITUDE_OUTLIER_SCALE * Math.max(120, mad);
const isOutlier = Math.abs(rawAltitude - med) > outlierThreshold;
state.outlierStreak = isOutlier ? state.outlierStreak + 1 : 0;
const trustedTarget = !isOutlier || state.outlierStreak >= 2;
const maxStep = trustedTarget
? ALTITUDE_HARD_STEP_METERS
: ALTITUDE_SOFT_STEP_METERS;
const alpha = trustedTarget
? ALTITUDE_SMOOTHING_ALPHA_TRUSTED
: ALTITUDE_SMOOTHING_ALPHA_GUARDED;
const delta = rawAltitude - state.filtered;
const clampedDelta = Math.max(
-maxStep,
Math.min(maxStep, delta),
);
const filtered = state.filtered + clampedDelta * alpha;
state.filtered = filtered;
state.recent.push(filtered);
if (state.recent.length > ALTITUDE_RECENT_WINDOW) {
state.recent.splice(0, state.recent.length - ALTITUDE_RECENT_WINDOW);
}
this.altitudeStates.set(id, state);
return filtered;
}
update(flights: FlightState[]): TrailEntry[] {
const current = new Set<string>();
let processedFlightCount = 0;
for (const f of flights) {
if (f.longitude == null || f.latitude == null) continue;
processedFlightCount += 1;
const id = f.icao24;
current.add(id);
const filteredAltitude = this.filterAltitude(id, f.baroAltitude);
const pos: TrailPoint = {
position: [f.longitude, f.latitude],
baroAltitude: filteredAltitude,
};
let trail = this.trails.get(id);
if (!trail) {
trail =
this.bootstrapUpdatesRemaining > 0
? synthesizeHistoricalPolls(f).map((position) => ({
position,
baroAltitude: filteredAltitude,
}))
: [];
this.trails.set(id, trail);
}
if (trail.length === 0) {
trail.push(pos);
continue;
}
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;
}
trail.push(pos);
if (trail.length > MAX_POINTS) {
trail.splice(0, trail.length - MAX_POINTS);
}
}
for (const id of this.seen) {
if (!current.has(id)) {
this.trails.delete(id);
this.altitudeStates.delete(id);
}
}
this.seen = current;
if (this.bootstrapUpdatesRemaining > 0 && processedFlightCount > 0) {
this.bootstrapUpdatesRemaining -= 1;
}
const result: TrailEntry[] = [];
for (const f of flights) {
const trail = this.trails.get(f.icao24);
if (trail && trail.length >= 2) {
const path = trail.map((p) => p.position);
const altitudes = trail.map((p) => p.baroAltitude);
result.push({
icao24: f.icao24,
path: [...path],
altitudes,
baroAltitude: altitudes[altitudes.length - 1] ?? null,
});
}
}
return result;
}
}
export function useTrailHistory(flights: FlightState[]): TrailEntry[] {
const [store] = useState(() => new TrailStore());
return useMemo(() => store.update(flights), [flights, store]);
}