Files
OpsLog/frontend/src/components/FTMapPanel.tsx
T
rouggy b2e93e9164 feat(ftmap): the who-hears-me marks are filled and take their own colour
Filled, with a hairline white edge — the same trick the home marker
uses. A ring is an outline drawn over whatever is beneath it, and eight
pixels of one over the satellite imagery was barely visible, which is
the whole reason the layer exists to be looked at.

Its own colour, in its own key, beside the switch that turns the layer
on. Separate from the decode colour deliberately: that one exists
because the band palette disappears over some basemaps, and cyan over a
pale sea has exactly the same problem for exactly the same reason. One
control for both would have forced the two layers into a single colour,
which is the distinction it took a shape to draw in the first place.

The picker only appears while the layer is on, since it configures that
layer and nothing else, and it is a portable UI pref like the other map
colours, so it travels with data/.
2026-09-10 14:49:52 +02:00

430 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <canvas>, 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<string, string> = {
'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<Heard[]>([]);
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<number | undefined>(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<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(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<L.LayerGroup | null>(null);
const baseRef = useRef<L.TileLayer | null>(null);
const labelsRef = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(() =>
(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<string, FTMapDecode>();
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: `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center">`
+ `<div style="width:9px;height:9px;transform:rotate(45deg);background:${heardInk};`
+ `box-shadow:0 0 0 1px rgba(255,255,255,.75);opacity:${fade.toFixed(2)}"></div></div>`,
});
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.
<div className="relative isolate z-0 h-full w-full min-h-0">
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
{/* 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. */}
<div className="absolute top-2 left-16 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<button key={k} type="button" onClick={() => setBasemap(k)}
className={cn('px-2 py-0.5 rounded text-[11px]',
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
{BASEMAPS[k].label}
</button>
))}
</div>
{/* 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. */}
<div className="absolute top-2 right-2 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
{/* 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. */}
<input type="color" title={t('ftmap.colour')}
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
value={colour || '#e11d48'}
onChange={(e) => { const v = asHex(e.target.value); setColour(v); writeUiPref(COL_KEY, v); }} />
{!!colour && (
<button type="button" title={t('ftmap.colourPerBand')}
onClick={() => { setColour(''); writeUiPref(COL_KEY, ''); }}
className="px-1 text-[11px] text-muted-foreground hover:text-foreground"></button>
)}
<span className="mx-0.5 w-px self-stretch bg-border" />
{/* The reverse layer. A switch, not a filter: it starts a subscription
at the broker, so it is off until asked for. */}
<button type="button" onClick={toggleHearMe} disabled={heardBusy}
title={t('ftmap.hearMeTip')}
className={cn('flex items-center gap-1 px-1.5 h-6 rounded text-[11px] disabled:opacity-50',
hearMe ? 'font-semibold' : 'text-muted-foreground hover:bg-muted')}
style={hearMe ? { color: heardInk } : undefined}>
<Ear className="size-3" />
{t('ftmap.hearMe')}
{hearMe && <span className="tabular-nums opacity-80">{heard.length}</span>}
</button>
{hearMe && (
<input type="color" title={t('ftmap.heardColour')}
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
value={heardInk}
onChange={(e) => { const v = asHex(e.target.value); setHeardColour(v); writeUiPref(HEARD_COL_KEY, v); }} />
)}
{hearMe && !!heardColour && (
<button type="button" title={t('ftmap.heardColourReset')}
onClick={() => { setHeardColour(''); writeUiPref(HEARD_COL_KEY, ''); }}
className="px-1 text-[11px] text-muted-foreground hover:text-foreground"></button>
)}
</div>
{/* 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 && (
<div className="absolute bottom-2 right-2 z-[1000] flex items-center gap-1.5 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border text-[11px]">
<span className="inline-block size-2 rotate-45" style={{ background: heardInk }} />
{t('ftmap.hearMeLegend', { n: heard.length })}
</div>
)}
{/* 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 && (
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
{bands.map((b) => (
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
{!colour && <span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />}
{b.toUpperCase()}
</span>
))}
</div>
)}
{!gridToLatLon(myGrid) && (
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
{t('ftmap.noGrid')}
</span>
</div>
)}
</div>
);
}