/* global React, L */
const { useEffect: useEffectMap, useRef: useRefMap, useState: useStateMap } = React;
function TrajectoryMap({ trajectory, fromAirport, toAirport, t }) {
const tx = t || ((k) => k);
const mapEl = useRefMap(null);
const mapRef = useRefMap(null);
const layerRef = useRefMap(null);
const natLayerRef = useRefMap(null);
const [showNat, setShowNat] = useStateMap(false);
const [natTracks, setNatTracks] = useStateMap([]);
// init map once
useEffectMap(() => {
if (mapRef.current || !mapEl.current || typeof L === "undefined") return;
const map = L.map(mapEl.current, {
center: [30, 10],
zoom: 2,
zoomControl: true,
worldCopyJump: true,
attributionControl: true,
scrollWheelZoom: true,
minZoom: 2,
});
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
attribution: "© OpenStreetMap · CARTO",
subdomains: "abcd",
maxZoom: 18,
}).addTo(map);
mapRef.current = map;
setTimeout(() => map.invalidateSize(), 50);
const ro = new ResizeObserver(() => map.invalidateSize());
ro.observe(mapEl.current);
return () => ro.disconnect();
}, []);
// fetch NAT tracks once on mount
useEffectMap(() => {
fetch("/nat-tracks?ocean=atlantic")
.then((r) => r.ok ? r.json() : { tracks: [] })
.then((data) => {
const tracks = Array.isArray(data) ? data : (data.tracks || []);
if (tracks.length) setNatTracks(tracks);
})
.catch(() => {});
}, []);
// draw / hide NAT layer when showNat or natTracks changes
useEffectMap(() => {
const map = mapRef.current;
if (!map) return;
if (natLayerRef.current) {
map.removeLayer(natLayerRef.current);
natLayerRef.current = null;
}
if (!showNat || !natTracks.length) return;
const group = L.layerGroup();
natTracks.forEach((track) => {
if (!track.coords || track.coords.length < 2) return;
L.polyline(track.coords, {
color: "#1a6fbd",
weight: 1.5,
opacity: 0.55,
dashArray: "6 8",
}).bindTooltip(track.name || "NAT", { className: "penumbra-tip", sticky: true })
.addTo(group);
// direction arrow at midpoint
const mid = track.coords[Math.floor(track.coords.length / 2)];
if (mid) {
const icon = L.divIcon({
className: "",
html: `
${track.name || ""}
`,
iconAnchor: [20, 10],
});
L.marker(mid, { icon, interactive: false }).addTo(group);
}
});
group.addTo(map);
natLayerRef.current = group;
}, [showNat, natTracks]);
// redraw trajectory whenever it changes
useEffectMap(() => {
const map = mapRef.current;
if (!map) return;
if (layerRef.current) {
map.removeLayer(layerRef.current);
layerRef.current = null;
}
if (!trajectory) return;
const group = L.layerGroup();
// dashed path connecting samples
const latlngs = trajectory.samples.map(s => [s.lat, s.lon]);
L.polyline(latlngs, {
color: "#0a0a0a",
weight: 1.2,
opacity: 0.45,
dashArray: "3 5",
}).addTo(group);
// colored dots
trajectory.samples.forEach((s) => {
const icon = L.divIcon({
className: "",
html: ``,
iconSize: [12, 12],
iconAnchor: [6, 6],
});
const m = L.marker([s.lat, s.lon], { icon, interactive: true }).addTo(group);
const tip = `
${formatLatLon(s.lat, s.lon)}
${tx("tip_alt")}${Math.round(s.altFt).toLocaleString("fr-FR")} ft
${tx("tip_dose")}${s.doseRate.toFixed(2)} µSv/h
`;
m.bindTooltip(tip, {
className: "penumbra-tip",
direction: "top",
offset: [0, -6],
sticky: false,
});
});
// airport pins
if (fromAirport) {
const fromIcon = L.divIcon({
className: "",
html: `${fromAirport.iata}
`,
iconSize: [24, 24],
iconAnchor: [12, 12],
});
L.marker([fromAirport.lat, fromAirport.lon], { icon: fromIcon, zIndexOffset: 1000 })
.addTo(group)
.bindTooltip(`${fromAirport.city} · ${fromAirport.name}`, { className: "penumbra-tip", direction: "top" });
}
if (toAirport) {
const toIcon = L.divIcon({
className: "",
html: `${toAirport.iata}
`,
iconSize: [24, 24],
iconAnchor: [12, 12],
});
L.marker([toAirport.lat, toAirport.lon], { icon: toIcon, zIndexOffset: 1000 })
.addTo(group)
.bindTooltip(`${toAirport.city} · ${toAirport.name}`, { className: "penumbra-tip", direction: "top" });
}
group.addTo(map);
layerRef.current = group;
// fit bounds
const bounds = L.latLngBounds(latlngs);
map.fitBounds(bounds, { padding: [60, 60] });
}, [trajectory, fromAirport, toAirport]);
return (
<>
{!trajectory && (
)}
{natTracks.length > 0 && (
)}
{trajectory && (
{tx("legend_low")}
{tx("legend_mod")}
{tx("legend_high")}
)}
>
);
}
function formatLatLon(lat, lon) {
const ns = lat >= 0 ? "N" : "S";
const ew = lon >= 0 ? "E" : "O";
return `${Math.abs(lat).toFixed(2)}°${ns} · ${Math.abs(lon).toFixed(2)}°${ew}`;
}
window.TrajectoryMap = TrajectoryMap;