Spaces:
Running
Running
| /* 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; | |