feat(cluster): single click fills the call, double click works the spot

Reading the cluster means running down dozens of lines, and a single
click did the whole job — QSY, mode, callsign — so every line looked at
dragged the radio with it. One stray click while reading took the
operator off the station they were working.

Looking and going are two different intentions, so they are two gestures
now. The band map is unchanged: clicking a spot there is already a
deliberate act, not a way of reading a list.
This commit is contained in:
2026-08-24 19:08:33 +02:00
parent 0848c275d2
commit 4e9b1eebe9
3 changed files with 32 additions and 5 deletions
+4 -2
View File
@@ -10,7 +10,8 @@
"Yaesu console: the power slider goes to 200 W on the models that make it (FTDX101MP and the like), and follows the radio if it ever reports more than expected. The slider, not the rig, was the 100 W limit.", "Yaesu console: the power slider goes to 200 W on the models that make it (FTDX101MP and the like), and follows the radio if it ever reports more than expected. The slider, not the rig, was the 100 W limit.",
"Yaesu console: the NAR button (narrow IF filter) is shown only when the radio answers the command, and says what it is. A button that did nothing when pressed read as a fault in the rig.", "Yaesu console: the NAR button (narrow IF filter) is shown only when the radio answers the command, and says what it is. A button that did nothing when pressed read as a fault in the rig.",
"The frequency readout now steps the Hz digits under the mouse wheel too, not just the kHz ones — 100, 10 and 1 Hz. Zero-beating a CW signal is a few tens of Hz, and it was the one move the display would not make.", "The frequency readout now steps the Hz digits under the mouse wheel too, not just the kHz ones — 100, 10 and 1 Hz. Zero-beating a CW signal is a few tens of Hz, and it was the one move the display would not make.",
"The split-pile-up chaser only appears in CW: the marker comes from a skimmer decoding a report, so on SSB or FT8 there is nothing for it to chase." "The split-pile-up chaser only appears in CW: the marker comes from a skimmer decoding a report, so on SSB or FT8 there is nothing for it to chase.",
"Cluster: a single click now only fills the callsign, and a DOUBLE click works the spot — QSY, mode and the rest. Running down the list used to drag the radio along with every line looked at."
], ],
"fr": [ "fr": [
"Quand TQSL refuse un envoi, le journal contient désormais l'enregistrement ADIF exact qui lui a été remis et l'emplacement de station demandé pour la signature. « No QSOs processed » recouvre plusieurs causes sans rapport et le fichier temporaire est supprimé dès que TQSL rend la main : la seule pièce à conviction utile était justement invisible.", "Quand TQSL refuse un envoi, le journal contient désormais l'enregistrement ADIF exact qui lui a été remis et l'emplacement de station demandé pour la signature. « No QSOs processed » recouvre plusieurs causes sans rapport et le fichier temporaire est supprimé dès que TQSL rend la main : la seule pièce à conviction utile était justement invisible.",
@@ -20,7 +21,8 @@
"Console Yaesu : le curseur de puissance monte à 200 W sur les modèles qui la sortent (FTDX101MP et consorts), et suit la radio si elle annonce davantage. C'était le curseur, pas la radio, qui plafonnait à 100 W.", "Console Yaesu : le curseur de puissance monte à 200 W sur les modèles qui la sortent (FTDX101MP et consorts), et suit la radio si elle annonce davantage. C'était le curseur, pas la radio, qui plafonnait à 100 W.",
"Console Yaesu : le bouton NAR (filtre FI étroit) n'apparaît que si la radio répond à la commande, et indique ce qu'il fait. Un bouton sans effet passait pour une panne de la radio.", "Console Yaesu : le bouton NAR (filtre FI étroit) n'apparaît que si la radio répond à la commande, et indique ce qu'il fait. Un bouton sans effet passait pour une panne de la radio.",
"L'affichage de fréquence fait maintenant défiler aussi les chiffres des Hz à la molette, pas seulement ceux des kHz — 100, 10 et 1 Hz. Se caler au zéro-beat sur un signal CW se joue à quelques dizaines de Hz, et c'était le seul geste que l'affichage refusait.", "L'affichage de fréquence fait maintenant défiler aussi les chiffres des Hz à la molette, pas seulement ceux des kHz — 100, 10 et 1 Hz. Se caler au zéro-beat sur un signal CW se joue à quelques dizaines de Hz, et c'était le seul geste que l'affichage refusait.",
"Le chasseur de pile-up en split n'apparaît qu'en CW : le marqueur vient d'un skimmer qui décode un report, donc en SSB ou en FT8 il n'a rien à chasser." "Le chasseur de pile-up en split n'apparaît qu'en CW : le marqueur vient d'un skimmer qui décode un report, donc en SSB ou en FT8 il n'a rien à chasser.",
"Cluster : un clic simple ne remplit plus que l'indicatif, et le DOUBLE clic travaille le spot — QSY, mode et le reste. Parcourir la liste entraînait la radio à chaque ligne regardée."
] ]
}, },
{ {
+15 -1
View File
@@ -3159,6 +3159,19 @@ export default function App() {
// Clicking a spot (cluster grid or any band map): tune the rig, set the mode, // Clicking a spot (cluster grid or any band map): tune the rig, set the mode,
// fill the call, pre-fill POTA, (re)start the recording. Shared so every spot // fill the call, pre-fill POTA, (re)start the recording. Shared so every spot
// source behaves identically. // source behaves identically.
// Single click on a cluster line: fill the callsign, nothing else.
//
// Reading the cluster means running down dozens of lines, and while a single
// click WORKED the spot every pass dragged the radio with it — one stray click
// while looking took the operator off the station they were working. Looking
// and going are two different intentions, so they are now two gestures: click
// to look the call up, double-click to go there.
function handleSpotSelect(s: any) {
if (!s?.dx_call?.trim()) return;
onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref);
}
function handleSpotClick(s: any) { function handleSpotClick(s: any) {
const m = inferSpotMode(s.comment ?? '', s.freq_hz); const m = inferSpotMode(s.comment ?? '', s.freq_hz);
// Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic), // Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
@@ -6074,7 +6087,7 @@ export default function App() {
</div> </div>
<div className="flex-1 min-h-0 flex"> <div className="flex-1 min-h-0 flex">
<div className="flex-1 min-w-0 flex flex-col min-h-0"> <div className="flex-1 min-w-0 flex flex-col min-h-0">
<ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} /> <ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} onSpotSelect={handleSpotSelect} />
</div> </div>
{clusterShowFilters && renderClusterFilters()} {clusterShowFilters && renderClusterFilters()}
</div> </div>
@@ -7752,6 +7765,7 @@ export default function App() {
rows={rendered as any} rows={rendered as any}
spotStatus={spotStatus} spotStatus={spotStatus}
onSpotClick={handleSpotClick} onSpotClick={handleSpotClick}
onSpotSelect={handleSpotSelect}
/> />
); );
})()} })()}
+13 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, AllCommunityModule, ModuleRegistry,
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme'; import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
@@ -75,7 +75,13 @@ export type SpotStatusEntry = {
type Props = { type Props = {
rows: ClusterSpot[]; rows: ClusterSpot[];
spotStatus: Record<string, SpotStatusEntry>; spotStatus: Record<string, SpotStatusEntry>;
// A DOUBLE click works the spot: QSY, mode, callsign, the lot.
onSpotClick?: (s: ClusterSpot) => void; onSpotClick?: (s: ClusterSpot) => void;
// A SINGLE click only fills the callsign. Reading the cluster means passing
// over dozens of lines, and every pass used to drag the radio with it — one
// stray click while looking took the operator off the station they were
// working. Looking and going are now two different gestures.
onSpotSelect?: (s: ClusterSpot) => void;
}; };
const COL_STATE_KEY = 'hamlog.clusterColState.v1'; const COL_STATE_KEY = 'hamlog.clusterColState.v1';
@@ -500,7 +506,7 @@ const GROUP_ORDER = ['Spot', 'Geo'];
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' }; const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g); const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) { export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@@ -583,6 +589,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
}, []); }, []);
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) { function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
if (e.data && onSpotSelect) onSpotSelect(e.data);
}
function handleRowDoubleClicked(e: RowDoubleClickedEvent<ClusterSpot>) {
if (e.data && onSpotClick) onSpotClick(e.data); if (e.data && onSpotClick) onSpotClick(e.data);
} }
@@ -647,6 +657,7 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
onColumnVisible={saveColumnState} onColumnVisible={saveColumnState}
onSortChanged={saveColumnState} onSortChanged={saveColumnState}
onRowClicked={handleRowClicked} onRowClicked={handleRowClicked}
onRowDoubleClicked={handleRowDoubleClicked}
animateRows={false} animateRows={false}
suppressCellFocus suppressCellFocus
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`} getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}