feat(ft map): click a station to take it, double-click to answer it

The map draws a dot per decoded station and they did nothing. They now
answer the same two gestures as the decodes list, because the two are
views of the same list and a click has to mean the same thing on both:
one takes the callsign into the entry (and points the PSK Reporter panel
at it), two answer it.

The handlers were written inline on the list, so the map could not offer
them without a second copy — they are named once now and given to both.
Leaflet leaves the telling apart of click and dblclick to the handler, an
invisible marker three times the dot's radius takes the clicks (a
three-pixel dot is a fine mark and a poor target), and the double click
is stopped rather than merely unpropagated: the map zooms on one, and
answering a station is not a request to zoom in on it.
This commit is contained in:
2026-09-06 14:47:22 +02:00
parent ed66e9394b
commit ab1b45da07
3 changed files with 80 additions and 32 deletions
+41 -2
View File
@@ -19,12 +19,16 @@ import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
// 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.
@@ -39,7 +43,22 @@ const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9c
const MAX_ARCS = 300;
const MAX_AGE_MS = 30 * 60_000;
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
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 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.
@@ -145,10 +164,30 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
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], {
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 twice the size takes the clicks.
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);
const hit = L.circleMarker([to.lat, to.lon], {
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
}).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]);