feat: Mainview can choose Map, cluster or worked before
This commit is contained in:
+280
-187
@@ -31,6 +31,7 @@ import {
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
|
||||
GetAwardDefs,
|
||||
GetUIPref,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||
@@ -49,7 +50,7 @@ import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
import { MainMap } from '@/components/MainMap';
|
||||
import { WorldMap, LocatorMap } from '@/components/MainMap';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
@@ -659,6 +660,30 @@ export default function App() {
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// Main tab is two configurable panes; each side shows one of the great-circle
|
||||
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
||||
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
||||
// so it's loaded async on mount and re-read on profile:changed below.
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked';
|
||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||
const loadMainPanes = useCallback(async () => {
|
||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked';
|
||||
const [l, r] = await Promise.all([
|
||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||
GetUIPref('mainPaneRight').catch(() => ''),
|
||||
]);
|
||||
setMainPaneLeft(valid(l) ? l : 'map1');
|
||||
setMainPaneRight(valid(r) ? r : 'map2');
|
||||
}, []);
|
||||
useEffect(() => { loadMainPanes(); }, [loadMainPanes]);
|
||||
// Cluster filter sidebar visibility — shared by the Cluster tab and the
|
||||
// Main-view cluster pane (portable UI pref). Hiding it keeps the filters
|
||||
// active, it just reclaims the width.
|
||||
const [clusterShowFilters, setClusterShowFilters] = useState(() => localStorage.getItem('opslog.clusterShowFilters') !== '0');
|
||||
const toggleClusterFilters = useCallback(() => {
|
||||
setClusterShowFilters((v) => { const n = !v; writeUiPref('opslog.clusterShowFilters', n ? '1' : '0'); return n; });
|
||||
}, []);
|
||||
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
|
||||
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
|
||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||
@@ -1275,10 +1300,10 @@ export default function App() {
|
||||
// side reloads its managers; this keeps the React state in sync.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('profile:changed', () => {
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk();
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
||||
});
|
||||
return () => { off(); };
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk]);
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await reloadWk();
|
||||
@@ -2243,6 +2268,243 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Cluster spots after every active filter (band / mode / status / search /
|
||||
// hide-worked / group). Shared by the Cluster tab and the Main-view cluster
|
||||
// pane so both show exactly the same list.
|
||||
const clusterRenderedRows = useMemo(() => {
|
||||
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
|
||||
const search = clusterSearch.trim().toUpperCase();
|
||||
const list = spots.filter((s) => {
|
||||
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
|
||||
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
|
||||
if (search && !s.dx_call.includes(search)) return false;
|
||||
if (clusterLockMode) {
|
||||
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
if (spotMode && mode && spotMode !== mode) return false;
|
||||
}
|
||||
if (clusterModeFilter.size > 0) {
|
||||
const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
|
||||
if (!cat || !clusterModeFilter.has(cat)) return false;
|
||||
}
|
||||
if (clusterStatusFilter.size > 0) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const st = spotStatus[k]?.status || '';
|
||||
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
|
||||
}
|
||||
if (clusterHideWorked) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const e = spotStatus[k];
|
||||
if (!e) return false;
|
||||
if (e.worked_call || e.status === 'worked') return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
||||
if (clusterGroup) {
|
||||
const seen = new Map<string, ClusterSpot & { repeats: number }>();
|
||||
for (const s of list) {
|
||||
const e = seen.get(s.dx_call);
|
||||
if (e) { e.repeats++; }
|
||||
else seen.set(s.dx_call, { ...s, repeats: 1 });
|
||||
}
|
||||
rendered = Array.from(seen.values());
|
||||
}
|
||||
return rendered;
|
||||
}, [spots, clusterLockBand, band, clusterBands, clusterSearch, clusterFilterSource,
|
||||
clusterLockMode, mode, clusterModeFilter, clusterStatusFilter, spotStatus,
|
||||
clusterHideWorked, clusterGroup]);
|
||||
|
||||
// The Log4OM-style cluster filter sidebar (callsign search, hide-worked,
|
||||
// group, band/mode/status/source). Rendered both in the Cluster tab and the
|
||||
// Main-view cluster pane; toggled by clusterShowFilters.
|
||||
const renderClusterFilters = () => (
|
||||
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
|
||||
<div className="px-2.5 py-2 border-b border-border/60 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</span>
|
||||
<button type="button" onClick={toggleClusterFilters} title="Hide filters"
|
||||
className="text-muted-foreground hover:text-foreground">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
|
||||
{/* Callsign search */}
|
||||
<Input
|
||||
className="h-7 text-xs font-mono uppercase"
|
||||
placeholder="Search call…"
|
||||
value={clusterSearch}
|
||||
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
|
||||
/>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
|
||||
Hide worked
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
|
||||
Group duplicates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Band filter — multi-select listbox */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClusterLockBand((v) => !v)}
|
||||
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
|
||||
clusterLockBand ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||
title="Lock to the entry strip's current band"
|
||||
>
|
||||
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
|
||||
</button>
|
||||
{clusterBands.size > 0 && (
|
||||
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
|
||||
{bands.map((b) => {
|
||||
const on = clusterBands.has(b);
|
||||
return (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
onClick={() => setClusterBands((s) => { const n = new Set(s); if (n.has(b)) n.delete(b); else n.add(b); return n; })}
|
||||
className={cn('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
|
||||
on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode lock */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClusterLockMode((v) => !v)}
|
||||
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
|
||||
clusterLockMode ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||
title="Only show spots whose mode matches the entry strip"
|
||||
>
|
||||
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
|
||||
</button>
|
||||
|
||||
{/* Status filter */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
|
||||
]).map((s) => {
|
||||
const on = clusterStatusFilter.has(s.k);
|
||||
return (
|
||||
<button key={s.k} type="button"
|
||||
onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
|
||||
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode filter */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
|
||||
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
|
||||
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
|
||||
]).map((s) => {
|
||||
const on = clusterModeFilter.has(s.k);
|
||||
return (
|
||||
<button key={s.k} type="button"
|
||||
onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
|
||||
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
|
||||
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">All sources</SelectItem>
|
||||
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// A small "show filters" button shown when the sidebar is collapsed.
|
||||
const clusterFiltersToggleBtn = (
|
||||
<button type="button" onClick={toggleClusterFilters}
|
||||
title={clusterShowFilters ? 'Hide filters' : 'Show filters'}
|
||||
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium',
|
||||
clusterShowFilters ? 'bg-primary text-primary-foreground border-primary' : 'text-muted-foreground border-border hover:bg-muted')}>
|
||||
<SlidersHorizontal className="size-3" /> Filters
|
||||
</button>
|
||||
);
|
||||
|
||||
// Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from
|
||||
// the same four choices, configured per-profile in Settings → Main view.
|
||||
const renderMainPane = (kind: MainPaneKind) => {
|
||||
switch (kind) {
|
||||
case 'map1':
|
||||
return (
|
||||
<WorldMap
|
||||
fromGrid={station.my_grid}
|
||||
toGrid={grid}
|
||||
fromLabel={station.callsign}
|
||||
toLabel={callsign}
|
||||
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
||||
/>
|
||||
);
|
||||
case 'map2':
|
||||
return <LocatorMap toGrid={grid} toLabel={callsign} />;
|
||||
case 'cluster':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border/60 shrink-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Cluster</span>
|
||||
{clusterFiltersToggleBtn}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
<ClusterGrid rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
|
||||
</div>
|
||||
{clusterShowFilters && renderClusterFilters()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'worked':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
<ShutdownProgress />
|
||||
@@ -3022,60 +3284,15 @@ export default function App() {
|
||||
})}
|
||||
<div className="flex-1" />
|
||||
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
|
||||
{clusterFiltersToggleBtn}
|
||||
</div>
|
||||
|
||||
{/* Filters moved to the right-side panel (see below). */}
|
||||
|
||||
{(() => {
|
||||
// Apply every filter. `bandsActive` is the band set the
|
||||
// user clicked, OR the entry's locked band when Lock band
|
||||
// is on. Mode lock compares the spot's inferred mode to
|
||||
// the entry's current one.
|
||||
const bandsActive = clusterLockBand
|
||||
? new Set([band])
|
||||
: clusterBands;
|
||||
const search = clusterSearch.trim().toUpperCase();
|
||||
let list = spots.filter((s) => {
|
||||
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
|
||||
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
|
||||
if (search && !s.dx_call.includes(search)) return false;
|
||||
if (clusterLockMode) {
|
||||
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
if (spotMode && mode && spotMode !== mode) return false;
|
||||
}
|
||||
if (clusterModeFilter.size > 0) {
|
||||
const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
|
||||
if (!cat || !clusterModeFilter.has(cat)) return false;
|
||||
}
|
||||
if (clusterStatusFilter.size > 0) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const st = spotStatus[k]?.status || '';
|
||||
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
|
||||
}
|
||||
// Hide worked: drop spots whose exact call is already worked,
|
||||
// or whose entity+band+mode slot is already in the log. The
|
||||
// status is resolved asynchronously, so we also hide spots
|
||||
// whose status isn't known yet — otherwise a worked spot would
|
||||
// flash in (no status) then vanish once it resolves. A new
|
||||
// spot waits for its status, then appears only if not worked.
|
||||
if (clusterHideWorked) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const e = spotStatus[k];
|
||||
if (!e) return false;
|
||||
if (e.worked_call || e.status === 'worked') return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
||||
if (clusterGroup) {
|
||||
const seen = new Map<string, ClusterSpot & { repeats: number }>();
|
||||
for (const s of list) {
|
||||
const e = seen.get(s.dx_call);
|
||||
if (e) { e.repeats++; }
|
||||
else seen.set(s.dx_call, { ...s, repeats: 1 });
|
||||
}
|
||||
rendered = Array.from(seen.values());
|
||||
}
|
||||
// Filtered + grouped spots (shared with the Main-view cluster
|
||||
// pane). All the filter state lives in the right-side panel.
|
||||
const rendered = clusterRenderedRows;
|
||||
if (rendered.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||
@@ -3131,134 +3348,9 @@ export default function App() {
|
||||
</div>
|
||||
</div>{/* /left column */}
|
||||
|
||||
{/* Right-side filter panel (Log4OM style) */}
|
||||
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
|
||||
<div className="px-2.5 py-2 border-b border-border/60 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</div>
|
||||
<div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
|
||||
{/* Callsign search */}
|
||||
<Input
|
||||
className="h-7 text-xs font-mono uppercase"
|
||||
placeholder="Search call…"
|
||||
value={clusterSearch}
|
||||
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
|
||||
/>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
|
||||
Hide worked
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
|
||||
Group duplicates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Band filter — multi-select listbox */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClusterLockBand((v) => !v)}
|
||||
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
|
||||
clusterLockBand ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||
title="Lock to the entry strip's current band"
|
||||
>
|
||||
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
|
||||
</button>
|
||||
{clusterBands.size > 0 && (
|
||||
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
|
||||
{bands.map((b) => {
|
||||
const on = clusterBands.has(b);
|
||||
return (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
onClick={() => setClusterBands((s) => { const n = new Set(s); if (n.has(b)) n.delete(b); else n.add(b); return n; })}
|
||||
className={cn('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
|
||||
on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode lock */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClusterLockMode((v) => !v)}
|
||||
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
|
||||
clusterLockMode ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||
title="Only show spots whose mode matches the entry strip"
|
||||
>
|
||||
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
|
||||
</button>
|
||||
|
||||
{/* Status filter */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
|
||||
]).map((s) => {
|
||||
const on = clusterStatusFilter.has(s.k);
|
||||
return (
|
||||
<button key={s.k} type="button"
|
||||
onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
|
||||
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode filter */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
|
||||
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
|
||||
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
|
||||
]).map((s) => {
|
||||
const on = clusterModeFilter.has(s.k);
|
||||
return (
|
||||
<button key={s.k} type="button"
|
||||
onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
|
||||
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
|
||||
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">All sources</SelectItem>
|
||||
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right-side filter panel (Log4OM style) — shared with the
|
||||
Main-view cluster pane; toggle hides it in both places. */}
|
||||
{clusterShowFilters && renderClusterFilters()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
@@ -3277,13 +3369,13 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<TabsContent value="main" className="flex-1 min-h-0 p-0">
|
||||
<MainMap
|
||||
fromGrid={station.my_grid}
|
||||
toGrid={grid}
|
||||
fromLabel={station.callsign}
|
||||
toLabel={callsign}
|
||||
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
||||
/>
|
||||
{/* Two configurable panes (per-profile, Settings → Main view).
|
||||
Each side shows one of: great-circle map, locator map, cluster
|
||||
or worked-before. */}
|
||||
<div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2">
|
||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
|
||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
||||
@@ -3443,6 +3535,7 @@ export default function App() {
|
||||
initialSection={settingsSection}
|
||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+138
-129
@@ -14,22 +14,15 @@ function saveMapView(m: L.Map) {
|
||||
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
||||
}
|
||||
|
||||
// MainMap — Log4OM-style dual map for the Main tab:
|
||||
// • Left: a world map with the great-circle path drawn from the operator to
|
||||
// the contacted station, plus distance + short/long-path azimuth.
|
||||
// • Right: a street map zoomed onto the contacted station's grid locator.
|
||||
// The Main tab is built from two independent map panes that the operator can
|
||||
// place on either side (Settings → Main view):
|
||||
// • WorldMap ("map1"): a world map with the great-circle path from the
|
||||
// operator to the contacted station, distance, short/long-path azimuth and
|
||||
// the antenna beam lobe.
|
||||
// • LocatorMap ("map2"): a street map zoomed onto the contacted station's grid.
|
||||
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
|
||||
// circleMarkers / divIcons so we don't depend on Leaflet's image assets.
|
||||
|
||||
interface Props {
|
||||
fromGrid: string; // operator grid (active profile)
|
||||
toGrid: string; // contacted-station grid
|
||||
fromLabel?: string; // operator callsign
|
||||
toLabel?: string; // DX callsign
|
||||
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
|
||||
beamWidth?: number; // beamwidth (deg), default 30
|
||||
}
|
||||
|
||||
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
||||
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
||||
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
||||
@@ -62,14 +55,20 @@ function dot(color: string): L.DivIcon {
|
||||
});
|
||||
}
|
||||
|
||||
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) {
|
||||
interface WorldProps {
|
||||
fromGrid: string; // operator grid (active profile)
|
||||
toGrid: string; // contacted-station grid
|
||||
fromLabel?: string; // operator callsign
|
||||
toLabel?: string; // DX callsign
|
||||
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
|
||||
beamWidth?: number; // beamwidth (deg), default 30
|
||||
}
|
||||
|
||||
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: WorldProps) {
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const locatorRef = useRef<HTMLDivElement>(null);
|
||||
const worldMap = useRef<L.Map | null>(null);
|
||||
const locatorMap = useRef<L.Map | null>(null);
|
||||
// Layers we add/remove as the QSO changes (kept separate from the basemap).
|
||||
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
||||
const locatorOverlay = useRef<L.LayerGroup | null>(null);
|
||||
|
||||
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
|
||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||
@@ -86,86 +85,70 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
||||
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
|
||||
worldOverlay.current = L.layerGroup().addTo(m);
|
||||
worldMap.current = m;
|
||||
// Restore the saved free-pan view when not auto-zooming.
|
||||
const sv = loadMapView();
|
||||
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
||||
// Remember the view as the user pans/zooms (only meaningful when free).
|
||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||
}
|
||||
if (locatorRef.current && !locatorMap.current) {
|
||||
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
||||
.setView([20, 0], 2);
|
||||
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||
locatorMap.current = m;
|
||||
}
|
||||
// The Main tab may have just become visible — fix tile sizing.
|
||||
const t = window.setTimeout(() => {
|
||||
worldMap.current?.invalidateSize();
|
||||
locatorMap.current?.invalidateSize();
|
||||
}, 80);
|
||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||
return () => window.clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
// Redraw overlays whenever the operator/DX grids change.
|
||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||
useEffect(() => {
|
||||
const wm = worldMap.current, lm = locatorMap.current;
|
||||
const wo = worldOverlay.current, lo = locatorOverlay.current;
|
||||
if (!wm || !lm || !wo || !lo) return;
|
||||
const wm = worldMap.current, wo = worldOverlay.current;
|
||||
if (!wm || !wo) return;
|
||||
wo.clearLayers();
|
||||
lo.clearLayers();
|
||||
|
||||
const from = gridToLatLon(fromGrid);
|
||||
const to = gridToLatLon(toGrid);
|
||||
|
||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||
if (from && beamAzimuths && beamAzimuths.length) {
|
||||
const half = (beamWidth ?? 30) / 2;
|
||||
const D = 5500; // lobe length (km)
|
||||
// Great-circle radial out to distance D, stopping just short of the pole so
|
||||
// a poleward line doesn't snap across the top of the Mercator map.
|
||||
const radial = (b: number): [number, number][] => {
|
||||
const pts: [number, number][] = [];
|
||||
const N = 64;
|
||||
for (let i = 1; i <= N; i++) {
|
||||
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
|
||||
pts.push([d.lat, d.lon]);
|
||||
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
for (const az of beamAzimuths) {
|
||||
// Draw the lobe as a FAN of translucent great-circle radials, not a
|
||||
// filled polygon: a polygon breaks badly near the poles on Mercator (its
|
||||
// edges run off toward ±90° and the fill smears across the map), while
|
||||
// each radial LINE stays clean. The overlapping lines read as a lobe —
|
||||
// solid near the antenna, fanning out toward the front. Works for any
|
||||
// azimuth, north/south included.
|
||||
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
|
||||
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||
L.polyline(line as L.LatLngExpression[], { color: '#dc2626', weight: 6, opacity: 0.07 }).addTo(wo);
|
||||
}
|
||||
// Boresight (dashed centre line).
|
||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
|
||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Left: world + great-circle arc ──
|
||||
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
|
||||
if (to) {
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(toLabel || toGrid, { permanent: false, direction: 'top' }).addTo(wo);
|
||||
}
|
||||
if (from && to) {
|
||||
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||||
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
|
||||
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo);
|
||||
// Only re-frame the map when auto-zoom is on; otherwise keep the user's
|
||||
// chosen (remembered) view so the beam heading stays visible.
|
||||
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
|
||||
{ color: '#2563eb', weight: 2, opacity: 0.8 }).addTo(wo);
|
||||
|
||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||
if (beamAzimuths && beamAzimuths.length) {
|
||||
const half = (beamWidth ?? 30) / 2;
|
||||
const D = 5500; // lobe length (km)
|
||||
// Great-circle radial out to distance D, stopping just short of the pole
|
||||
// so a poleward line doesn't snap across the top of the Mercator map.
|
||||
const radial = (b: number): [number, number][] => {
|
||||
const out: [number, number][] = [];
|
||||
const N = 64;
|
||||
for (let i = 1; i <= N; i++) {
|
||||
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
|
||||
out.push([d.lat, d.lon]);
|
||||
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
|
||||
}
|
||||
return out;
|
||||
};
|
||||
for (const az of beamAzimuths) {
|
||||
// Draw the lobe as a FAN of translucent great-circle radials, not a
|
||||
// filled polygon: a polygon breaks badly near the poles on Mercator
|
||||
// (its edges run off toward ±90° and the fill smears across the map),
|
||||
// while each radial LINE stays clean. The overlapping lines read as a
|
||||
// lobe — solid near the antenna, fanning out toward the front. Works
|
||||
// for any azimuth, north/south included.
|
||||
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
|
||||
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||
L.polyline(line as L.LatLngExpression[], { color: '#dc2626', weight: 6, opacity: 0.07 }).addTo(wo);
|
||||
}
|
||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
|
||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||
}
|
||||
}
|
||||
|
||||
if (autoZoom) {
|
||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); // include the arc
|
||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
}
|
||||
} else if (autoZoom && to) {
|
||||
@@ -173,8 +156,73 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
||||
} else if (autoZoom && from) {
|
||||
wm.setView([from.lat, from.lon], 3);
|
||||
}
|
||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
|
||||
|
||||
// ── Right: street map on the DX locator ──
|
||||
const path = pathBetween(fromGrid, toGrid);
|
||||
|
||||
return (
|
||||
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||
<div ref={worldRef} className="absolute inset-0" />
|
||||
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
|
||||
(remembered across restarts), so the beam heading stays visible. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const v = !autoZoom;
|
||||
setAutoZoom(v);
|
||||
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
||||
const m = worldMap.current;
|
||||
if (!v && m) saveMapView(m);
|
||||
}}
|
||||
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
||||
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
Zoom DX
|
||||
</button>
|
||||
{path && (
|
||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
||||
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LocatorProps {
|
||||
toGrid: string; // contacted-station grid
|
||||
toLabel?: string; // DX callsign
|
||||
}
|
||||
|
||||
// LocatorMap — street map zoomed onto the DX grid, the "map2" pane.
|
||||
export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
||||
const locatorRef = useRef<HTMLDivElement>(null);
|
||||
const locatorMap = useRef<L.Map | null>(null);
|
||||
const locatorOverlay = useRef<L.LayerGroup | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (locatorRef.current && !locatorMap.current) {
|
||||
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
||||
.setView([20, 0], 2);
|
||||
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||
locatorMap.current = m;
|
||||
}
|
||||
const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80);
|
||||
return () => window.clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lm = locatorMap.current, lo = locatorOverlay.current;
|
||||
if (!lm || !lo) return;
|
||||
lo.clearLayers();
|
||||
const to = gridToLatLon(toGrid);
|
||||
if (to) {
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
@@ -184,57 +232,18 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
||||
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
|
||||
}
|
||||
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
|
||||
} else if (from) {
|
||||
lm.setView([from.lat, from.lon], 5);
|
||||
}
|
||||
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
|
||||
|
||||
const path = pathBetween(fromGrid, toGrid);
|
||||
setTimeout(() => { lm.invalidateSize(); }, 0);
|
||||
}, [toGrid, toLabel]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 gap-2 p-2">
|
||||
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
||||
<div className="relative isolate rounded-lg overflow-hidden border border-border">
|
||||
<div ref={worldRef} className="absolute inset-0" />
|
||||
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
|
||||
(remembered across restarts), so the beam heading stays visible. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const v = !autoZoom;
|
||||
setAutoZoom(v);
|
||||
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
||||
const m = worldMap.current;
|
||||
if (!v && m) saveMapView(m); // entering free mode → remember current view
|
||||
// (turning it on re-frames via the redraw effect, which depends on autoZoom)
|
||||
}}
|
||||
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
||||
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
Zoom DX
|
||||
</button>
|
||||
{path && (
|
||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
||||
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||
<div ref={locatorRef} className="absolute inset-0" />
|
||||
{!gridToLatLon(toGrid) && (
|
||||
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
|
||||
Enter a grid or look up the callsign to center the map.
|
||||
</div>
|
||||
<div className="relative isolate rounded-lg overflow-hidden border border-border">
|
||||
<div ref={locatorRef} className="absolute inset-0" />
|
||||
{!gridToLatLon(toGrid) && (
|
||||
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
|
||||
Enter a grid or look up the callsign to center the map.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -136,6 +137,7 @@ interface Props {
|
||||
initialSection?: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
|
||||
}
|
||||
|
||||
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
||||
@@ -445,6 +447,59 @@ function TelemetryToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||
// panes show, independently: the great-circle map, the locator street map, the
|
||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'map1', label: 'Map — great-circle + beam' },
|
||||
{ value: 'map2', label: 'Map — locator (street)' },
|
||||
{ value: 'cluster', label: 'Cluster spots' },
|
||||
{ value: 'worked', label: 'Worked before' },
|
||||
];
|
||||
function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', value: string) => void }) {
|
||||
const [left, setLeft] = useState('map1');
|
||||
const [right, setRight] = useState('map2');
|
||||
useEffect(() => {
|
||||
const valid = (v: string) => MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||
}, []);
|
||||
const pick = (side: 'left' | 'right', v: string) => {
|
||||
if (side === 'left') setLeft(v); else setRight(v);
|
||||
// Persist (per-profile) AND tell the parent the new value directly, so the
|
||||
// Main view updates from the chosen value — never a stale DB re-read.
|
||||
SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {});
|
||||
onChanged?.(side, v);
|
||||
};
|
||||
return (
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
|
||||
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
|
||||
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">Left pane</span>
|
||||
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">Right pane</span>
|
||||
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
|
||||
// (fills the IP/port). Self-contained so it can own its state (rendered inside
|
||||
// the hook-less CATPanel).
|
||||
@@ -495,7 +550,7 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged }: Props) {
|
||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -3292,6 +3347,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</label>
|
||||
<TelemetryToggle />
|
||||
|
||||
<MainViewPanes onChanged={onMainPaneChanged} />
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
||||
{secret.has_passphrase ? (
|
||||
|
||||
@@ -22,6 +22,7 @@ const PORTABLE_KEYS = [
|
||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||
];
|
||||
|
||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||
|
||||
Reference in New Issue
Block a user