feat(ftmap): a layer for who hears me

The map drew one direction of every path on it: what this receiver
decoded. The reverse — which stations are reporting our own
transmissions — is the half an operator cannot see from their own
radio, and on FT8 it is the half that decides whether calling is worth
the cycle.

It is the narrowest slice of the PSK Reporter feed there is. The v2
topic is

	pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/…

so putting the operator's callsign in the TRANSMIT level makes the
broker send nothing else. For scale, from internal/pskr's own measured
numbers: four bands unfiltered is 83 messages a second, filtered on the
receiver's square 0.2 to 1.2 a second — one callsign in the transmit
level is a handful per FT8 cycle however open the band is. Both grids
are in the payload, so the arc is arithmetic and there is no lookup.

internal/pskrme, with its own connection, for the same reason
internal/pskrtgt has its own: the three want slices of the feed that
cannot be filtered out of one another. It also means this keeps working
with the band-opening watch off — hanging it off that feed's lifecycle
would have made it fail silently for anyone not chasing openings.

Nothing is persisted. One entry per STATION inside a fifteen-minute
window, carrying its freshest report: PSK Reporter's uploaders batch,
many every five minutes, so a tighter window would show a fraction of
who actually heard the last few calls. Stop clears the window, or
switching the layer back on would redraw who heard us before it was on.

On the map the layer is dashed and single-coloured. Solid is what we
decoded, dashed is somebody decoding us; colour alone could not carry
that distinction next to fourteen band colours. The receivers are rings
rather than filled dots for the same reason. Per profile, since the
callsign IS the subscription — a switch resubscribes rather than going
on reporting who hears the previous station.
This commit is contained in:
2026-09-10 14:01:39 +02:00
parent f090e845ff
commit d25edd114c
10 changed files with 649 additions and 5 deletions
+106 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
@@ -8,6 +8,8 @@ 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
@@ -56,6 +58,18 @@ const COL_KEY = 'opslog.ftMapColour';
// 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 DASHED, and in one colour whatever the band.
//
// The two layers answer opposite questions and must not be readable as one
// palette: solid is what this receiver heard, dashed is what someone else
// heard of us. Colour alone would not do it — the decode arcs already use
// fourteen of them, and a fifteenth would just look like another band.
const HEARD_COLOUR = '#22d3ee';
const HEARD_DASH = '5 4';
const MAX_ARCS = 300;
const MAX_AGE_MS = 30 * 60_000;
@@ -69,6 +83,11 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
// 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) ?? ''));
// 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]);
@@ -84,6 +103,10 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
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>(() =>
@@ -111,6 +134,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
});
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
@@ -129,6 +153,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
m.remove();
mapRef.current = null;
layerRef.current = null;
heardLayerRef.current = null;
};
}, []);
@@ -148,6 +173,34 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
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(() => {
@@ -214,6 +267,37 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
}
}, [decodes, myGrid, colour]);
// The reverse layer. Drawn from OUR square outwards to each station that
// reported us, dashed, so the direction of the claim is legible: a solid arc
// is something this receiver decoded, a dashed one is somebody else
// decoding us.
useEffect(() => {
const layer = heardLayerRef.current;
if (!layer) return;
layer.clearLayers();
const from = gridToLatLon(myGrid);
if (!from || !hearMe) return;
const now = Date.now();
for (const h of heard) {
const to = gridToLatLon(h.grid);
if (!to) continue;
const ageMin = Math.max(0, Math.round((now - Date.parse(h.at)) / 60_000));
const label = `${h.call} · ${h.grid} · ${h.snr > 0 ? '+' : ''}${h.snr} dB · ${h.band}${ageMin > 0 ? ` · ${ageMin}'` : ''}`;
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
L.polyline(pts as L.LatLngExpression[][], {
color: HEARD_COLOUR, weight: 1.2, opacity: 0.7, dashArray: HEARD_DASH, smoothFactor: 0,
}).addTo(layer);
// A ring, not a disc: the decode dots are filled, and a receiver of ours
// is a different kind of thing in the same place on the map.
L.circleMarker([to.lat, to.lon], {
radius: 4, color: HEARD_COLOUR, weight: 1.6, fillOpacity: 0,
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
L.circleMarker([to.lat, to.lon], {
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
}
}, [heard, hearMe, myGrid]);
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
@@ -246,7 +330,28 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
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: HEARD_COLOUR } : undefined}>
<Ear className="size-3" />
{t('ftmap.hearMe')}
{hearMe && <span className="tabular-nums opacity-80">{heard.length}</span>}
</button>
</div>
{/* What the dashed arcs are. In the legend rather than a tooltip because
it is the only thing on the map that is not a decode of ours, and an
unexplained second style is worse than none. */}
{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 w-4 border-t-2 border-dashed" style={{ borderColor: HEARD_COLOUR }} />
{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