"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; 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(); 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; 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]); }