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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user