Penumbra / frontend /map.jsx
Louis Fichet
Initial deploy
c528423
/* 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: `<div class="nat-label">${track.name || ""}</div>`,
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: `<div class="trajectory-dot ${s.risk}"></div>`,
iconSize: [12, 12],
iconAnchor: [6, 6],
});
const m = L.marker([s.lat, s.lon], { icon, interactive: true }).addTo(group);
const tip = `
<div class="tip-head">${formatLatLon(s.lat, s.lon)}</div>
<div class="tip-row"><span class="k">${tx("tip_alt")}</span><span class="v">${Math.round(s.altFt).toLocaleString("fr-FR")} ft</span></div>
<div class="tip-row"><span class="k">${tx("tip_dose")}</span><span class="v">${s.doseRate.toFixed(2)} µSv/h</span></div>
`;
m.bindTooltip(tip, {
className: "penumbra-tip",
direction: "top",
offset: [0, -6],
sticky: false,
});
});
// airport pins
if (fromAirport) {
const fromIcon = L.divIcon({
className: "",
html: `<div class="airport-pin">${fromAirport.iata}</div>`,
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: `<div class="airport-pin">${toAirport.iata}</div>`,
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 (
<>
<div ref={mapEl} id="map"></div>
{!trajectory && (
<div className="map-empty">
<div className="icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1L15 22v-1.5L13 19v-5.5l8 2.5z"/>
</svg>
</div>
<div className="text">{tx("map_empty")}</div>
</div>
)}
<div className="map-controls">
{natTracks.length > 0 && (
<button
className={"nat-toggle" + (showNat ? " active" : "")}
onClick={() => setShowNat((v) => !v)}
title={showNat ? tx("nat_toggle_hide") : tx("nat_toggle_show")}
>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12h18M3 6l9-3 9 3M3 18l9 3 9-3"/>
</svg>
{tx("nat_label")}
</button>
)}
{trajectory && (
<div className="map-legend">
<span className="item"><span className="swatch low"></span>{tx("legend_low")}</span>
<span className="item"><span className="swatch mod"></span>{tx("legend_mod")}</span>
<span className="item"><span className="swatch high"></span>{tx("legend_high")}</span>
</div>
)}
</div>
</>
);
}
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;