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
+35 -28
View File
@@ -6459,6 +6459,37 @@ export default function App() {
</div>
);
// ONE CLICK TAKES THE STATION, TWO ANSWER IT — and the FT map means the same
// by them as the list does. They were written inline on the list, so the map
// had no way to offer the same gestures without a second copy of them.
function selectDecode(d: any) {
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
onCallsignInput(d.call, { force: true });
}
function callDecode(d: any) {
// The station being answered is also the one worth analysing: the PSK
// Reporter panel follows the click rather than asking for a second one.
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
// Auto-call adopts the station: the click chooses WHO, the watchdogs still
// decide how long it is called for.
TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
onCallsignInput(d.call, { force: true });
// With two slices on two bands, the Reply reaches the right INSTANCE but the
// radio still transmits on whichever slice holds the TX flag. Move it to the
// decode's band first, or an 80 m answer goes out on 20 m. A no-op on any
// backend without slices.
FlexTXOnBand(d.band ?? '').catch(() => { /* not a Flex, or no such slice */ });
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
// The RAW mode marker, not the resolved name: the receiving application
// matches the Reply against its decode list field for field, and JTDX
// drops one that says "FT8" where it decoded "~".
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
).catch((e: any) => setError(String(e?.message ?? e)));
}
const renderDecodesList = () => (
<DecodesPanel
decodes={decodes}
@@ -6483,33 +6514,8 @@ export default function App() {
// One click: take the station, transmit nothing. The entry is filled and
// the panels follow it, exactly as clicking a cluster spot does — so a
// row can be inspected, looked up and read about without keying up.
onSelect={(d) => {
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
onCallsignInput(d.call, { force: true });
}}
onCall={(d) => {
// The station being answered is also the one worth analysing: the PSK
// Reporter panel follows the click rather than asking for a second one.
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
// Auto-call adopts the station: the click chooses WHO, the watchdogs
// still decide how long it is called for.
TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
onCallsignInput(d.call, { force: true });
// With two slices on two bands, the Reply reaches the right INSTANCE but
// the radio still transmits on whichever slice holds the TX flag. Move
// it to the decode's band first, or an 80 m answer goes out on 20 m.
// A no-op on any backend without slices.
FlexTXOnBand(d.band ?? '').catch(() => { /* not a Flex, or no such slice */ });
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
// The RAW mode marker, not the resolved name: the receiving
// application matches the Reply against its decode list field for
// field, and JTDX drops one that says "FT8" where it decoded "~".
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
).catch((e: any) => setError(String(e?.message ?? e)));
}}
onSelect={selectDecode}
onCall={callDecode}
// Wipes the live view only — nothing here is stored, and the staging
// buffer goes too or the next flush would put back what was just cleared.
onClear={(instance) => {
@@ -8733,7 +8739,8 @@ export default function App() {
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && (
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid} />
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid}
onSelect={selectDecode} onCall={callDecode} />
</div>
)}
</TabsContent>
+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]);