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:
+4
-2
@@ -17,7 +17,8 @@
|
|||||||
"The world map now waits for the station’s square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
|
"The world map now waits for the station’s square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
|
||||||
"Auto-call: the rest between two series is counted in the station’s own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
|
"Auto-call: the rest between two series is counted in the station’s own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
|
||||||
"Auto-call: a period the station was decoded in is never counted as a miss. A period is judged more than once — the decodes arrive in a burst and stragglers follow — and a later judgement holds a partial view of it, not evidence of absence: a station answering in that very period showed “1/3 missed” against it.",
|
"Auto-call: a period the station was decoded in is never counted as a miss. A period is judged more than once — the decodes arrive in a burst and stragglers follow — and a later judgement holds a partial view of it, not evidence of absence: a station answering in that very period showed “1/3 missed” against it.",
|
||||||
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period."
|
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period.",
|
||||||
|
"FT map: the station dots answer the same two gestures as the decodes list — one click takes the callsign into the entry, two answer it. The hit area is wider than the dot, and a double click no longer zooms the map on its way through."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"L’auto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusqu’au début de la suivante, et un arrivant tardif est pesé face à l’ensemble.",
|
"L’auto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusqu’au début de la suivante, et un arrivant tardif est pesé face à l’ensemble.",
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
|
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
|
||||||
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, c’est quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
|
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, c’est quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
|
||||||
"Auto-call : une période où la station a été décodée n’est plus comptée comme un raté. Une période est jugée plusieurs fois — les décodages arrivent en rafale puis les retardataires — et un jugement tardif n’en donne qu’une vue partielle, pas la preuve d’une absence : une station qui répondait dans cette période exacte se voyait compter « 1/3 raté ».",
|
"Auto-call : une période où la station a été décodée n’est plus comptée comme un raté. Une période est jugée plusieurs fois — les décodages arrivent en rafale puis les retardataires — et un jugement tardif n’en donne qu’une vue partielle, pas la preuve d’une absence : une station qui répondait dans cette période exacte se voyait compter « 1/3 raté ».",
|
||||||
"L’affichage de l’auto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue s’affiche avec un sablier. Le bouton changeait de largeur à chaque période."
|
"L’affichage de l’auto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue s’affiche avec un sablier. Le bouton changeait de largeur à chaque période.",
|
||||||
|
"Carte FTx : les points des stations répondent aux mêmes deux gestes que la liste des décodages — un clic met l’indicatif dans la saisie, deux l’appellent. La zone cliquable est plus large que le point, et un double clic ne zoome plus la carte au passage."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+35
-28
@@ -6459,6 +6459,37 @@ export default function App() {
|
|||||||
</div>
|
</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 = () => (
|
const renderDecodesList = () => (
|
||||||
<DecodesPanel
|
<DecodesPanel
|
||||||
decodes={decodes}
|
decodes={decodes}
|
||||||
@@ -6483,33 +6514,8 @@ export default function App() {
|
|||||||
// One click: take the station, transmit nothing. The entry is filled and
|
// 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
|
// 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.
|
// row can be inspected, looked up and read about without keying up.
|
||||||
onSelect={(d) => {
|
onSelect={selectDecode}
|
||||||
setPskTarget((d.call ?? '').toUpperCase());
|
onCall={callDecode}
|
||||||
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)));
|
|
||||||
}}
|
|
||||||
// Wipes the live view only — nothing here is stored, and the staging
|
// 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.
|
// buffer goes too or the next flush would put back what was just cleared.
|
||||||
onClear={(instance) => {
|
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">
|
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
{activeTab === 'ftmap' && (
|
{activeTab === 'ftmap' && (
|
||||||
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -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,
|
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
|
||||||
// not per frame.
|
// 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 = {
|
export type FTMapDecode = {
|
||||||
call: string;
|
call: string;
|
||||||
grid?: string;
|
grid?: string;
|
||||||
band?: string;
|
band?: string;
|
||||||
snr: number;
|
snr: number;
|
||||||
at: string;
|
at: string;
|
||||||
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The band palette every PSK Reporter user already knows, near enough.
|
// 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_ARCS = 300;
|
||||||
const MAX_AGE_MS = 30 * 60_000;
|
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
|
// 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
|
// 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.
|
// 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[][], {
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||||
}).addTo(layer);
|
}).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,
|
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' })
|
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
|
||||||
.addTo(layer);
|
.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]);
|
}, [decodes, myGrid]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user