470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useCallback } from "react";
|
|
import { MapboxOverlay } from "@deck.gl/mapbox";
|
|
import { IconLayer, PathLayer } from "@deck.gl/layers";
|
|
import { useMap } from "./map";
|
|
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
|
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 TRAIL_BELOW_AIRCRAFT_METERS = 20;
|
|
const STARTUP_TRAIL_POLLS = 3;
|
|
const STARTUP_TRAIL_STEP_SEC = 12;
|
|
const TRACK_DAMPING = 0.18;
|
|
|
|
function buildStartupFallbackTrail(f: FlightState): [number, number][] {
|
|
if (f.longitude == null || f.latitude == null) return [];
|
|
|
|
const heading = ((f.trueTrack ?? 0) * Math.PI) / 180;
|
|
const speed = f.velocity ?? 200;
|
|
const degPerSecond = speed / 111_320;
|
|
|
|
const path: [number, number][] = [];
|
|
for (let i = STARTUP_TRAIL_POLLS; i >= 1; i--) {
|
|
const distDeg = Math.min(degPerSecond * STARTUP_TRAIL_STEP_SEC * i, 0.08);
|
|
path.push([
|
|
f.longitude - Math.sin(heading) * distDeg,
|
|
f.latitude - Math.cos(heading) * distDeg,
|
|
]);
|
|
}
|
|
path.push([f.longitude, f.latitude]);
|
|
return path;
|
|
}
|
|
|
|
type Snapshot = { lng: number; lat: number; alt: number; track: number };
|
|
|
|
function lerpAngle(a: number, b: number, t: number): number {
|
|
const delta = ((b - a + 540) % 360) - 180;
|
|
return a + delta * t;
|
|
}
|
|
|
|
function smoothStep(t: number): number {
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
function createAircraftAtlas(): HTMLCanvasElement {
|
|
const size = 128;
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const ctx = canvas.getContext("2d")!;
|
|
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.beginPath();
|
|
ctx.moveTo(64, 12);
|
|
ctx.lineTo(72, 48);
|
|
ctx.lineTo(108, 72);
|
|
ctx.lineTo(104, 78);
|
|
ctx.lineTo(72, 66);
|
|
ctx.lineTo(70, 96);
|
|
ctx.lineTo(88, 108);
|
|
ctx.lineTo(86, 114);
|
|
ctx.lineTo(64, 104);
|
|
ctx.lineTo(42, 114);
|
|
ctx.lineTo(40, 108);
|
|
ctx.lineTo(58, 96);
|
|
ctx.lineTo(56, 66);
|
|
ctx.lineTo(24, 78);
|
|
ctx.lineTo(20, 72);
|
|
ctx.lineTo(56, 48);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
return canvas;
|
|
}
|
|
|
|
const AIRCRAFT_ICON_MAPPING = {
|
|
aircraft: { x: 0, y: 0, width: 128, height: 128, mask: true },
|
|
};
|
|
|
|
let _atlasCache: string | undefined;
|
|
function getAircraftAtlasUrl(): string {
|
|
if (typeof document === "undefined") return "";
|
|
if (!_atlasCache) _atlasCache = createAircraftAtlas().toDataURL();
|
|
return _atlasCache;
|
|
}
|
|
|
|
type FlightLayerProps = {
|
|
flights: FlightState[];
|
|
trails: TrailEntry[];
|
|
onHover: (info: PickingInfo<FlightState> | null) => void;
|
|
onClick: (info: PickingInfo<FlightState> | null) => void;
|
|
showTrails: boolean;
|
|
showShadows: boolean;
|
|
showAltitudeColors: boolean;
|
|
};
|
|
|
|
export function FlightLayers({
|
|
flights,
|
|
trails,
|
|
onHover,
|
|
onClick,
|
|
showTrails,
|
|
showShadows,
|
|
showAltitudeColors,
|
|
}: FlightLayerProps) {
|
|
const { map, isLoaded } = useMap();
|
|
const overlayRef = useRef<MapboxOverlay | null>(null);
|
|
const atlasUrl = getAircraftAtlasUrl();
|
|
|
|
const prevSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
|
|
const currSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
|
|
const dataTimestampRef = useRef(0);
|
|
const animFrameRef = useRef(0);
|
|
|
|
const flightsRef = useRef(flights);
|
|
const trailsRef = useRef(trails);
|
|
const showTrailsRef = useRef(showTrails);
|
|
const showShadowsRef = useRef(showShadows);
|
|
const showAltColorsRef = useRef(showAltitudeColors);
|
|
|
|
useEffect(() => {
|
|
flightsRef.current = flights;
|
|
trailsRef.current = trails;
|
|
showTrailsRef.current = showTrails;
|
|
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 oldAngleT = smoothStep(oldLinearT);
|
|
|
|
const newPrev = new Map<string, Snapshot>();
|
|
for (const f of flights) {
|
|
if (f.longitude == null || f.latitude == null) continue;
|
|
const id = f.icao24;
|
|
const oldPrev = prevSnapshotsRef.current.get(id);
|
|
const oldCurr = currSnapshotsRef.current.get(id);
|
|
|
|
if (oldPrev && oldCurr) {
|
|
const dx = oldCurr.lng - oldPrev.lng;
|
|
const dy = oldCurr.lat - oldPrev.lat;
|
|
if (dx * dx + dy * dy <= TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) {
|
|
newPrev.set(id, {
|
|
lng: oldPrev.lng + dx * oldLinearT,
|
|
lat: oldPrev.lat + dy * oldLinearT,
|
|
alt: oldPrev.alt + (oldCurr.alt - oldPrev.alt) * oldLinearT,
|
|
track: lerpAngle(oldPrev.track, oldCurr.track, oldAngleT),
|
|
});
|
|
} else {
|
|
newPrev.set(id, oldCurr);
|
|
}
|
|
} else if (oldCurr) {
|
|
newPrev.set(id, oldCurr);
|
|
}
|
|
}
|
|
prevSnapshotsRef.current = newPrev;
|
|
|
|
const next = new Map<string, Snapshot>();
|
|
for (const f of flights) {
|
|
if (f.longitude != null && f.latitude != null) {
|
|
const prev = newPrev.get(f.icao24);
|
|
const rawTrack = f.trueTrack ?? 0;
|
|
next.set(f.icao24, {
|
|
lng: f.longitude,
|
|
lat: f.latitude,
|
|
alt: f.baroAltitude ?? 0,
|
|
track:
|
|
prev != null
|
|
? lerpAngle(prev.track, rawTrack, TRACK_DAMPING)
|
|
: rawTrack,
|
|
});
|
|
}
|
|
}
|
|
currSnapshotsRef.current = next;
|
|
dataTimestampRef.current = performance.now();
|
|
}, [flights]);
|
|
|
|
const handleHover = useCallback(
|
|
(info: PickingInfo<FlightState>) => {
|
|
onHover(info.object ? info : null);
|
|
},
|
|
[onHover],
|
|
);
|
|
|
|
const handleClick = useCallback(
|
|
(info: PickingInfo<FlightState>) => {
|
|
if (info.object) onClick(info);
|
|
},
|
|
[onClick],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!map || !isLoaded) return;
|
|
|
|
if (!overlayRef.current) {
|
|
overlayRef.current = new MapboxOverlay({
|
|
interleaved: false,
|
|
layers: [],
|
|
});
|
|
map.addControl(overlayRef.current as unknown as maplibregl.IControl);
|
|
}
|
|
|
|
return () => {
|
|
if (overlayRef.current) {
|
|
try {
|
|
map.removeControl(
|
|
overlayRef.current as unknown as maplibregl.IControl,
|
|
);
|
|
} catch {
|
|
/* unmounted */
|
|
}
|
|
overlayRef.current = null;
|
|
}
|
|
};
|
|
}, [map, isLoaded]);
|
|
|
|
useEffect(() => {
|
|
if (!atlasUrl) return;
|
|
|
|
function buildAndPushLayers() {
|
|
animFrameRef.current = requestAnimationFrame(buildAndPushLayers);
|
|
|
|
const overlay = overlayRef.current;
|
|
if (!overlay) return;
|
|
|
|
try {
|
|
const elapsed = performance.now() - dataTimestampRef.current;
|
|
const rawT = elapsed / ANIM_DURATION_MS;
|
|
const tPos = Math.min(rawT, 1);
|
|
const tAngle = smoothStep(smoothStep(smoothStep(tPos)));
|
|
|
|
const currentFlights = flightsRef.current;
|
|
const currentTrails = trailsRef.current;
|
|
const altColors = showAltColorsRef.current;
|
|
const defaultColor: [number, number, number, number] = [
|
|
180, 220, 255, 200,
|
|
];
|
|
|
|
const interpolated: FlightState[] = currentFlights.map((f) => {
|
|
if (f.longitude == null || f.latitude == null) return f;
|
|
|
|
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,
|
|
0.015,
|
|
);
|
|
prev = {
|
|
lng: curr.lng - Math.sin(rad) * step,
|
|
lat: curr.lat - Math.cos(rad) * step,
|
|
alt: curr.alt,
|
|
track: curr.track,
|
|
};
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if (rawT <= 1) {
|
|
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),
|
|
};
|
|
}
|
|
|
|
// 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 extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
|
|
return {
|
|
...f,
|
|
longitude: curr.lng + Math.sin(heading) * extraDeg,
|
|
latitude: curr.lat + Math.cos(heading) * extraDeg,
|
|
baroAltitude: curr.alt,
|
|
trueTrack: curr.track,
|
|
};
|
|
});
|
|
|
|
const interpolatedMap = new Map<string, FlightState>();
|
|
for (const f of interpolated) {
|
|
interpolatedMap.set(f.icao24, f);
|
|
}
|
|
|
|
const layers = [];
|
|
|
|
if (showShadowsRef.current) {
|
|
layers.push(
|
|
new IconLayer<FlightState>({
|
|
id: "flight-shadows",
|
|
data: interpolated,
|
|
getPosition: (d) => [d.longitude!, d.latitude!, 0],
|
|
getIcon: () => "aircraft",
|
|
getSize: 18,
|
|
getColor: [0, 0, 0, 60],
|
|
getAngle: (d) => 360 - (d.trueTrack ?? 0),
|
|
iconAtlas: atlasUrl,
|
|
iconMapping: AIRCRAFT_ICON_MAPPING,
|
|
billboard: false,
|
|
sizeUnits: "pixels",
|
|
sizeScale: 1,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (showTrailsRef.current) {
|
|
const trailMap = new Map(currentTrails.map((t) => [t.icao24, t]));
|
|
const handledIds = new Set<string>();
|
|
const trailData: TrailEntry[] = [];
|
|
|
|
for (const f of interpolated) {
|
|
if (f.longitude == null || f.latitude == null) continue;
|
|
|
|
const existing = trailMap.get(f.icao24);
|
|
handledIds.add(f.icao24);
|
|
|
|
if (existing && existing.path.length >= 2) {
|
|
trailData.push(existing);
|
|
continue;
|
|
}
|
|
|
|
const startupPath = buildStartupFallbackTrail(f);
|
|
|
|
trailData.push({
|
|
icao24: f.icao24,
|
|
path: startupPath,
|
|
baroAltitude: existing?.baroAltitude ?? f.baroAltitude,
|
|
});
|
|
}
|
|
|
|
for (const d of currentTrails) {
|
|
if (!handledIds.has(d.icao24)) {
|
|
trailData.push(d);
|
|
}
|
|
}
|
|
|
|
layers.push(
|
|
new PathLayer<TrailEntry>({
|
|
id: "flight-trails",
|
|
data: trailData,
|
|
updateTriggers: { getPath: elapsed },
|
|
getPath: (d) => {
|
|
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],
|
|
);
|
|
if (
|
|
animFlight &&
|
|
animFlight.longitude != null &&
|
|
animFlight.latitude != null &&
|
|
basePath.length > 1
|
|
) {
|
|
const ax = animFlight.longitude;
|
|
const ay = animFlight.latitude;
|
|
|
|
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];
|
|
}
|
|
return basePath;
|
|
},
|
|
getColor: (d) => {
|
|
const len = d.path.length;
|
|
const base = altColors
|
|
? altitudeToColor(d.baroAltitude)
|
|
: defaultColor;
|
|
return Array.from({ length: len }, (_, i) => {
|
|
const tVal = len > 1 ? i / (len - 1) : 1;
|
|
const fade = Math.pow(tVal, 2.4);
|
|
return [
|
|
Math.min(255, base[0] + 22),
|
|
Math.min(255, base[1] + 22),
|
|
Math.min(255, base[2] + 22),
|
|
Math.round(20 + fade * 200),
|
|
];
|
|
}) as [number, number, number, number][];
|
|
},
|
|
getWidth: 3,
|
|
widthUnits: "pixels",
|
|
widthMinPixels: 2,
|
|
widthMaxPixels: 6,
|
|
billboard: true,
|
|
capRounded: true,
|
|
jointRounded: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
layers.push(
|
|
new IconLayer<FlightState>({
|
|
id: "flight-aircraft",
|
|
data: interpolated,
|
|
getPosition: (d) => [
|
|
d.longitude!,
|
|
d.latitude!,
|
|
altitudeToElevation(d.baroAltitude),
|
|
],
|
|
getIcon: () => "aircraft",
|
|
getSize: 22,
|
|
getColor: (d) =>
|
|
altColors ? altitudeToColor(d.baroAltitude) : defaultColor,
|
|
getAngle: (d) => 360 - (d.trueTrack ?? 0),
|
|
iconAtlas: atlasUrl,
|
|
iconMapping: AIRCRAFT_ICON_MAPPING,
|
|
billboard: false,
|
|
sizeUnits: "pixels",
|
|
sizeScale: 1,
|
|
pickable: true,
|
|
onHover: handleHover,
|
|
onClick: handleClick,
|
|
autoHighlight: true,
|
|
highlightColor: [255, 255, 255, 80],
|
|
}),
|
|
);
|
|
|
|
overlay.setProps({ layers });
|
|
} catch (err) {
|
|
console.error("[aeris] FlightLayers render error:", err);
|
|
}
|
|
}
|
|
|
|
buildAndPushLayers();
|
|
return () => cancelAnimationFrame(animFrameRef.current);
|
|
}, [atlasUrl, handleHover, handleClick]);
|
|
|
|
return null;
|
|
}
|