import { useCallback, useEffect, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead'; import { BASEMAPS, type BasemapKey } from '@/components/MainMap'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView'; import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase'; import { writeUiPref } from '@/lib/uiPref'; import { GetHearMe, SetHearMe, GetWhoHearsMe } from '../../wailsjs/go/main/App'; import { Ear } from 'lucide-react'; // 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. // The map is handed the SAME decode objects the list works from — it only reads // a few of the fields. The rest travel with them so a click here can answer the // station exactly as a double-click in the list does. export type FTMapDecode = { call: string; grid?: string; band?: string; snr: number; at: string; [k: string]: unknown; }; // 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'; // One colour for every arc, overriding the palette. Empty means per band, // which stays the default. // // The palette is only useful to somebody watching several bands at once, and // it is calibrated against a plain map: 60m navy and 70cm olive all but // disappear over the satellite imagery, and 20m yellow over the deserts. An // operator on one band has nothing to lose by painting the whole map in a // colour that shows up against the ground he chose. const COL_KEY = 'opslog.ftMapColour'; // The reverse layer's own colour. Empty means the default below. // // Separate from COL_KEY on purpose: the decode colour exists because the // band palette vanishes over some basemaps, and this one has exactly the // same problem for exactly the same reason — cyan over a pale sea reads no // better than 60m navy does. One control for both would have forced the // two layers into one colour, which is the distinction it took a shape to // make in the first place. const HEARD_COL_KEY = 'opslog.ftMapHeardColour'; // A colour input only accepts #rrggbb, so anything else stored here is // treated as no choice at all rather than driving the swatch to black. const asHex = (v: string) => (/^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : ''); // One station reporting our own transmissions, from PSK Reporter. type Heard = { call: string; grid: string; band: string; mode: string; snr: number; at: string }; // The reverse layer is marks only, in one colour whatever the band, and the // mark is a DIAMOND. // // It started with an arc per station, like the decodes, and that was wrong: // with a few dozen receivers reporting, the map became a fan of lines out of // one square that buried the very arcs it sat beside. Nothing was gained by // them either — an arc's job on the decode layer is to say WHICH of many // stations a path belongs to, and here every path starts at the same place. // // Shape, then, rather than colour, carries the distinction: the decode dots // are small filled circles, and the arcs already use fourteen colours, so a // fifteenth would read as another band. A diamond is unmistakably not one of // them at a glance. // // Filled, with a hairline white edge — the same trick the home marker uses. // It was a ring, and a ring is an outline drawn over whatever is beneath it: // eight pixels of it over the satellite imagery was barely there. const HEARD_COLOUR = '#22d3ee'; // the default, when nothing is chosen const MAX_ARCS = 300; const MAX_AGE_MS = 30 * 60_000; export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: { decodes: FTMapDecode[]; myGrid: string; // One click takes the station, two answer it — the list's own gestures. onSelect?: (d: FTMapDecode) => void; onCall?: (d: FTMapDecode) => void; }) { // Held in refs so the redraw below does not have to list them as dependencies // and rebuild every arc whenever the parent re-renders. const [colour, setColour] = useState(() => asHex(localStorage.getItem(COL_KEY) ?? '')); const [heardColour, setHeardColour] = useState(() => asHex(localStorage.getItem(HEARD_COL_KEY) ?? '')); const heardInk = heardColour || HEARD_COLOUR; // Whether the reverse feed is wanted lives in the DB, not here: it is what // starts an MQTT subscription, so the backend has to be the one that knows. const [hearMe, setHearMe] = useState(false); const [heard, setHeard] = useState([]); const [heardBusy, setHeardBusy] = useState(false); const selectRef = useRef(onSelect); const callRef = useRef(onCall); useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]); // Distinguishing the two gestures is ours to do: Leaflet fires click before // dblclick and leaves the telling apart to the handler. const clickTimer = useRef(undefined); useEffect(() => () => window.clearTimeout(clickTimer.current), []); // Where this map was left. Panning and zooming it is the operator saying which // part of the world they are working; throwing that away on every tab switch // made it something to set up again rather than something to glance at. const saved = useRef(loadMapView(MAP_VIEW_FT)); const { t } = useI18n(); const divRef = useRef(null); const mapRef = useRef(null); const layerRef = useRef(null); // Its own group: the reverse layer refreshes on its own clock, and clearing // the decode arcs to redraw it would throw away three hundred polylines // every twenty seconds for nothing. const heardLayerRef = 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, // No maxBounds: with the world smaller than the window the clamp // dragged every zoom into a corner. noWrap tiles alone keep one world. worldCopyJump: false, preferCanvas: true, center: saved.current ? [saved.current.lat, saved.current.lon] : [25, 0], zoom: saved.current ? saved.current.zoom : 2, minZoom: 2, }); // Every move, not just the deliberate ones: a zoom is as much a choice as a // pan, and there is no moment afterwards at which to ask. m.on('moveend', () => { const c = m.getCenter(); saveMapView(MAP_VIEW_FT, c.lat, c.lng, m.getZoom()); }); mapRef.current = m; layerRef.current = L.layerGroup().addTo(m); heardLayerRef.current = L.layerGroup().addTo(m); // Leaflet measures its container ONCE, when the map is created, and then // draws tiles for that size for ever. This panel is mounted the moment its // tab is selected — before the flex layout has settled — and the window can // be resized under it, so the stale measurement showed as a strip of dead // space along the bottom where tiles were never asked for. The observer // hands it the real size whenever the box changes. const ro = new ResizeObserver(() => m.invalidateSize({ animate: false })); ro.observe(divRef.current); // Once more after the first paint: the first observation can arrive while // the panel is still zero-height, and no further resize follows a layout // that settles by itself. const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100); return () => { window.clearTimeout(settle); ro.disconnect(); m.remove(); mapRef.current = null; layerRef.current = null; heardLayerRef.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); saveMapBase(MAP_BASE_FT, basemap); }, [basemap]); useEffect(() => { GetHearMe().then((v) => setHearMe(!!v)).catch(() => {}); }, []); // Polled rather than pushed: the reports arrive from the broker in batches // whenever an uploader gets round to it, and a fifteen-minute window redrawn // every twenty seconds is as live as the data underneath it actually is. const loadHeard = useCallback(() => { GetWhoHearsMe().then((r: any) => setHeard((Array.isArray(r) ? r : []) as Heard[])).catch(() => {}); }, []); useEffect(() => { if (!hearMe) { setHeard([]); return; } loadHeard(); const id = window.setInterval(loadHeard, 20_000); return () => window.clearInterval(id); }, [hearMe, loadHeard]); const toggleHearMe = async () => { setHeardBusy(true); const next = !hearMe; try { await SetHearMe(next); setHearMe(next); } catch { // The usual cause is no station callsign, which is what it subscribes // to. Read the state back rather than assuming either way. try { setHearMe(!!(await GetHearMe())); } catch { /* leave it */ } } finally { setHeardBusy(false); } }; // 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 stroke = colour || bandColour(d.band); // Cut at the antimeridian: this map shows ONE world, so a path running // past ±180 has to leave one edge and come back at the other. Without it // every arc out of VK or ZL was drawn into the blank space off the side // of the map, its far end sitting alone on the opposite coast. const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48)); L.polyline(pts as L.LatLngExpression[][], { color: stroke, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0, }).addTo(layer); const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`; const mk = L.circleMarker([to.lat, to.lon], { // A three-pixel dot is a fine mark and a poor target, so the visible // radius stays and an invisible one three times the size takes the // clicks. radius: 3, color: stroke, weight: 1, fillColor: stroke, fillOpacity: 0.9 * fade, }).bindTooltip(label, { direction: 'top' }).addTo(layer); // The tooltip goes on the HIT circle too, and it is the one that matters: // being on top, it takes the hover as well as the click, and binding it // only to the dot underneath left the map silent from the moment the dots // became clickable — the callsign and report an operator reads by pointing // at a station had simply gone. const hit = L.circleMarker([to.lat, to.lon], { radius: 9, opacity: 0, fillOpacity: 0, interactive: true, }).bindTooltip(label, { direction: 'top' }).addTo(layer); for (const target of [mk, hit]) { target.on('click', (e) => { // Not to the map: a click on a station is not a click on the water. L.DomEvent.stopPropagation(e as unknown as Event); window.clearTimeout(clickTimer.current); clickTimer.current = window.setTimeout(() => selectRef.current?.(d), 250); }); target.on('dblclick', (e) => { // stop(), not stopPropagation(): the map zooms on a double click, and // answering a station is not a request to zoom in on it. L.DomEvent.stop(e as unknown as Event); window.clearTimeout(clickTimer.current); callRef.current?.(d); }); } } }, [decodes, myGrid, colour]); // The reverse layer: one mark where each station that reported us sits, and // no line to it. // // A divIcon rather than a canvas circle, because canvas draws circles and // nothing else, and the whole point is a shape that is not a circle. The // cost is DOM nodes, which is affordable HERE and would not be on the decode // layer: this is a few dozen receivers against three hundred arcs. useEffect(() => { const layer = heardLayerRef.current; if (!layer) return; layer.clearLayers(); if (!hearMe) return; const now = Date.now(); for (const h of heard) { const to = gridToLatLon(h.grid); if (!to) continue; const ageMs = Math.max(0, now - Date.parse(h.at)); const ageMin = Math.round(ageMs / 60_000); // Faded with age over the window, as the decode arcs are: the freshest // report is the one that says a path is open NOW. const fade = Math.max(0.3, 1 - ageMs / (15 * 60_000)); const label = `${h.call} · ${h.grid} · ${h.snr > 0 ? '+' : ''}${h.snr} dB · ${h.band}${ageMin > 0 ? ` · ${ageMin}'` : ''}`; // The box is bigger than the diamond so there is something to point at: // a nine-pixel mark is a fine sight and a poor target. const icon = L.divIcon({ className: '', iconSize: [16, 16], iconAnchor: [8, 8], html: `
` + `
`, }); L.marker([to.lat, to.lon], { icon, interactive: true, keyboard: false }) .bindTooltip(label, { direction: 'top' }) .addTo(layer); } }, [heard, hearMe, heardInk]); const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))]; return ( // isolate: Leaflet stacks its panes and controls up to z-index 1000, which // beat the app menus and the Settings dialog. A stacking context of our own // keeps all of it inside this panel.
{/* Basemap picker, MainMap's own vocabulary. left-16, not left-12: Leaflet's zoom control is 30 px of buttons plus its 10 px margin and a border, and at 48 px this row started on top of it — the − button took the click that was meant for Street. */}
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => ( ))}
{/* The two things that are not the basemap, on the OTHER side. They sat in the same row, which grew until it reached the middle of the map — and a control bar spanning half the width of a world map is covering the Atlantic to save a corner that was empty the whole time. Leaflet puts nothing top-right but the attribution, which is at the bottom. */}
{/* Opens on the crimson the home marker already uses — chosen to hold up on every basemap, which is a better first suggestion than whichever band colour happens to be first in the palette. */} { const v = asHex(e.target.value); setColour(v); writeUiPref(COL_KEY, v); }} /> {!!colour && ( )} {/* The reverse layer. A switch, not a filter: it starts a subscription at the broker, so it is off until asked for. */} {hearMe && ( { const v = asHex(e.target.value); setHeardColour(v); writeUiPref(HEARD_COL_KEY, v); }} /> )} {hearMe && !!heardColour && ( )}
{/* What the diamonds are. In the legend rather than a tooltip because they are the only thing on the map that is not a decode of ours, and an unexplained second mark is worse than none. Bottom-right, where the band legend is not: the two would otherwise stack into one block and read as one key. */} {hearMe && heard.length > 0 && (
{t('ftmap.hearMeLegend', { n: heard.length })}
)} {/* Band legend — only the bands actually on screen. With one colour forced it keeps the band NAMES and drops the swatches: which bands are up is still worth knowing, a colour key that no longer maps to anything is not. */} {bands.length > 0 && (
{bands.map((b) => ( {!colour && } {b.toUpperCase()} ))}
)} {!gridToLatLon(myGrid) && (
{t('ftmap.noGrid')}
)}
); }