import { useEffect, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead'; import { BASEMAPS, type BasemapKey } from '@/components/MainMap'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; // FT Map — the live decode feed as geography: every station decoded in the // last half hour, an arc from the operator's own square to theirs, coloured by // band the way PSK Reporter taught everyone to read it. Wholly display: the // decode list is the same one the FT decodes tab shows, and a station with no // grid (never sent one in a CQ) simply cannot be placed and is not drawn. // // Performance is a design constraint, not an afterthought: the panel only // exists while its tab is active (the parent unmounts it otherwise), the map // renders with canvas (one , not one DOM node per arc), the arcs are // capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8, // not per frame. export type FTMapDecode = { call: string; grid?: string; band?: string; snr: number; at: string; }; // The band palette every PSK Reporter user already knows, near enough. const BAND_COLOURS: Record = { '160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff', '30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166', '12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044', '2m': '#ff1493', '70cm': '#999900', }; const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af'; const MAX_ARCS = 300; const MAX_AGE_MS = 30 * 60_000; export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) { const { t } = useI18n(); const divRef = useRef(null); const mapRef = useRef(null); const layerRef = useRef(null); const baseRef = useRef(null); const labelsRef = useRef(null); const [basemap, setBasemap] = useState(() => (localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite'); // The map itself, once. useEffect(() => { if (!divRef.current || mapRef.current) return; // ONE world: noWrap tiles inside hard bounds, no side-by-side copies — // and the space beyond the edge is the theme's own surface (style.css). const m = L.map(divRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: false, preferCanvas: true, center: [25, 0], zoom: 2, minZoom: 2, maxBounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)), maxBoundsViscosity: 1, }); mapRef.current = m; layerRef.current = L.layerGroup().addTo(m); return () => { m.remove(); mapRef.current = null; layerRef.current = null; }; }, []); // Basemap follows the picker. useEffect(() => { const m = mapRef.current; if (!m) return; baseRef.current?.remove(); labelsRef.current?.remove(); const bm = BASEMAPS[basemap]; const opts: L.TileLayerOptions = { maxNativeZoom: bm.maxNativeZoom, noWrap: true, bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)), }; baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m); if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m); localStorage.setItem('opslog.ftmapBase', basemap); }, [basemap]); // The arcs, redrawn when the decode list changes. Newest last so they paint // on top; opacity falls with age so the map reads as "now" with a memory. useEffect(() => { const layer = layerRef.current; if (!layer) return; layer.clearLayers(); const from = gridToLatLon(myGrid); if (!from) return; const now = Date.now(); const placed = decodes .filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS) .slice(-MAX_ARCS); // One line per CALL (its freshest sighting): the same CQer decoded thirty // times in ten minutes is one path on the air, not thirty strokes of it. const byCall = new Map(); for (const d of placed) byCall.set(d.call.toUpperCase(), d); L.circleMarker([from.lat, from.lon], { radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1, }).addTo(layer); for (const d of byCall.values()) { const to = gridToLatLon(d.grid!); if (!to) continue; const age = now - Date.parse(d.at); const fade = Math.max(0.15, 1 - age / MAX_AGE_MS); const colour = bandColour(d.band); const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48); L.polyline(pts as L.LatLngExpression[], { color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0, }).addTo(layer); L.circleMarker([to.lat, to.lon], { radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade, }).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' }) .addTo(layer); } }, [decodes, myGrid]); const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))]; return (
{/* Basemap picker, MainMap's own vocabulary. */}
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => ( ))}
{/* Band legend — only the bands actually on screen. */} {bands.length > 0 && (
{bands.map((b) => ( {b.toUpperCase()} ))}
)} {!gridToLatLon(myGrid) && (
{t('ftmap.noGrid')}
)}
); }