ADS-B map: altitude-colored aircraft icons + click-to-show flight trail
Icons were 16px accent-colored glyphs, indistinguishable from OSM's own
airport symbols. Now a 30px outlined airliner silhouette filled on
tar1090/ADS-B Exchange's altitude hue ramp, with a callsign/altitude
hover tooltip; the selected aircraft grows and gets a white outline.
Clicking an aircraft draws the path heard so far, segment-colored by
altitude. c2-core writes one point per position change to
aircraft/{icao}/positions (deduped in-process, writes now concurrent);
points carry expire_at and a TTL fieldOverride deletes them after ~24h.
Trail reads are gated on the parent aircraft doc's org via get(), so the
query needs no org filter or composite index. The latest stretch without
a 20-min gap counts as the current flight.
Verified: c2-core pytest 479 passed; frontend tsc --noEmit clean (node:20
container on radio-box).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9b83f0ec6d
commit
bff69a1d04
@@ -9,13 +9,15 @@ import {
|
||||
Polyline,
|
||||
Popup,
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
useMap,
|
||||
} from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
|
||||
import type { AircraftTrack, CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
|
||||
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { useAircraft } from "@/lib/useAircraft";
|
||||
import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
||||
import { useVessels } from "@/lib/useVessels";
|
||||
|
||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||
@@ -92,36 +94,113 @@ function nodeIcon(status: NodeStatus): L.DivIcon {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Aircraft icon — node-26#9 second-SDR ADS-B overlay ────────────────────────
|
||||
function aircraftIcon(trackDeg: number | null): L.DivIcon {
|
||||
const size = 16;
|
||||
const rotation = trackDeg ?? 0;
|
||||
// ── Aircraft — node-26#9 second-SDR ADS-B overlay ─────────────────────────────
|
||||
// Styled after ADS-B Exchange / tar1090: a sized airliner silhouette with a
|
||||
// dark outline, filled by altitude on tar1090's hue ramp, so height reads at a
|
||||
// glance and the icon stands out from OSM's own (purple) airport symbols.
|
||||
const ALT_HUE_STOPS: [number, number][] = [
|
||||
[0, 20], [2000, 32.5], [4000, 43], [6000, 54], [8000, 72], [9000, 85], [11000, 140], [40000, 300],
|
||||
];
|
||||
|
||||
function altitudeColor(altFt: number | null): string {
|
||||
if (altFt == null) return "hsl(0, 0%, 55%)";
|
||||
if (altFt <= 0) return "hsl(0, 0%, 45%)"; // on the ground
|
||||
let hue = ALT_HUE_STOPS[ALT_HUE_STOPS.length - 1][1];
|
||||
for (let i = 1; i < ALT_HUE_STOPS.length; i++) {
|
||||
const [a1, h1] = ALT_HUE_STOPS[i];
|
||||
if (altFt <= a1) {
|
||||
const [a0, h0] = ALT_HUE_STOPS[i - 1];
|
||||
hue = h0 + ((h1 - h0) * (altFt - a0)) / (a1 - a0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return `hsl(${hue.toFixed(0)}, 88%, 48%)`;
|
||||
}
|
||||
|
||||
const AIRLINER_PATH =
|
||||
"M32 2 C34.2 2 35.2 5 35.2 8 L35.2 23 L61 37.5 L61 42 L35.2 35 L34.2 51 L42.5 57.5 L42.5 61 L32 58.5 " +
|
||||
"L21.5 61 L21.5 57.5 L29.8 51 L28.8 35 L3 42 L3 37.5 L28.8 23 L28.8 8 C28.8 5 29.8 2 32 2 Z";
|
||||
|
||||
function aircraftIcon(trackDeg: number | null, altFt: number | null, selected: boolean): L.DivIcon {
|
||||
const size = selected ? 36 : 30;
|
||||
const outline = selected ? "#ffffff" : "#000000";
|
||||
const shadow = selected ? "drop-shadow(0 0 3px #000)" : "drop-shadow(0 1px 1px rgba(0,0,0,.45))";
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L15 11 L22 15 L15 15.5 L14 21 L17 22.5 L12 21.5 L7 22.5 L10 21 L9 15.5 L2 15 L9 11 Z"/></svg></div>`,
|
||||
html:
|
||||
`<div style="width:${size}px;height:${size}px;transform:rotate(${trackDeg ?? 0}deg);filter:${shadow}">` +
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 64 64"><path d="${AIRLINER_PATH}" ` +
|
||||
`fill="${altitudeColor(altFt)}" stroke="${outline}" stroke-width="${selected ? 3 : 2}" stroke-linejoin="round"/></svg></div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function AircraftLayer() {
|
||||
const { aircraft } = useAircraft();
|
||||
function AircraftTrail({ icao, current }: { icao: string; current: AircraftTrack }) {
|
||||
const trail = useAircraftTrail(icao);
|
||||
// Extend to the live position so the path always meets the icon.
|
||||
const points = [...trail];
|
||||
if (current.lat != null && current.lon != null) {
|
||||
points.push({ lat: current.lat, lon: current.lon, altitude_ft: current.altitude_ft, t: current.last_seen });
|
||||
}
|
||||
// One segment per leg, colored by altitude like tar1090's track.
|
||||
return (
|
||||
<>
|
||||
{aircraft
|
||||
.filter((a) => a.lat != null && a.lon != null)
|
||||
.map((a) => (
|
||||
<Marker key={a.icao} position={[a.lat as number, a.lon as number]} icon={aircraftIcon(a.track_deg)}>
|
||||
<Popup minWidth={160}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{a.callsign || a.icao}</div>
|
||||
<div className="text-xs text-ink-muted">ICAO {a.icao}</div>
|
||||
{a.altitude_ft != null && <div className="text-xs">Altitude: {Math.round(a.altitude_ft)} ft</div>}
|
||||
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
{points.slice(1).map((p, i) => (
|
||||
<Polyline
|
||||
key={`${icao}-${i}`}
|
||||
positions={[[points[i].lat, points[i].lon], [p.lat, p.lon]]}
|
||||
pathOptions={{ color: altitudeColor(p.altitude_ft), weight: 3, opacity: 0.9, lineCap: "round" }}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AircraftLayer() {
|
||||
const { aircraft } = useAircraft();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const positioned = aircraft.filter((a) => a.lat != null && a.lon != null);
|
||||
const selectedTrack = positioned.find((a) => a.icao === selected);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedTrack && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />}
|
||||
{positioned.map((a) => (
|
||||
<Marker
|
||||
key={a.icao}
|
||||
position={[a.lat as number, a.lon as number]}
|
||||
icon={aircraftIcon(a.track_deg, a.altitude_ft, a.icao === selected)}
|
||||
zIndexOffset={a.icao === selected ? 1000 : 0}
|
||||
eventHandlers={{
|
||||
click: () => setSelected(a.icao),
|
||||
popupclose: () => setSelected((cur) => (cur === a.icao ? null : cur)),
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -14]}>
|
||||
{a.callsign || a.icao}
|
||||
{a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`}
|
||||
</Tooltip>
|
||||
<Popup minWidth={160}>
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">{a.callsign || a.icao}</div>
|
||||
<div className="text-xs text-ink-muted">ICAO {a.icao}</div>
|
||||
{a.altitude_ft != null && (
|
||||
<div className="text-xs">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full mr-1 align-middle"
|
||||
style={{ background: altitudeColor(a.altitude_ft) }}
|
||||
/>
|
||||
Altitude: {Math.round(a.altitude_ft).toLocaleString()} ft
|
||||
</div>
|
||||
)}
|
||||
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
|
||||
{a.track_deg != null && <div className="text-xs">Heading: {Math.round(a.track_deg)}°</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user