diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 03f1af5..61b8b6a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('map1'); + const [mainPaneRight, setMainPaneRight] = useState('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() { ); + // 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(); + 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 = () => ( +
+
+ Filters + +
+
+ {/* Callsign search */} + setClusterSearch(e.target.value.toUpperCase())} + /> + + {/* Toggles */} +
+ + +
+ + {/* Band filter — multi-select listbox */} +
+
+ Bands +
+ + {clusterBands.size > 0 && ( + + )} +
+
+
+ {bands.map((b) => { + const on = clusterBands.has(b); + return ( + + ); + })} +
+
+ + {/* Mode lock */} + + + {/* Status filter */} +
+
Status
+
+ {([ + { 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 ( + + ); + })} +
+
+ + {/* Mode filter */} +
+
Mode
+
+ {([ + { 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 ( + + ); + })} +
+
+ + {/* Source */} +
+
Source
+ +
+
+
+ ); + + // A small "show filters" button shown when the sidebar is collapsed. + const clusterFiltersToggleBtn = ( + + ); + + // 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 ( + + ); + case 'map2': + return ; + case 'cluster': + return ( +
+
+ Cluster + {clusterFiltersToggleBtn} +
+
+
+ +
+ {clusterShowFilters && renderClusterFilters()} +
+
+ ); + case 'worked': + return ( +
+ openEdit(q.id as number)} + onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} + onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} /> +
+ ); + } + }; + return (
@@ -3022,60 +3284,15 @@ export default function App() { })}
{spots.length} live + {clusterFiltersToggleBtn}
{/* 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(); - 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 (
@@ -3131,134 +3348,9 @@ export default function App() {
{/* /left column */} - {/* Right-side filter panel (Log4OM style) */} -
-
Filters
-
- {/* Callsign search */} - setClusterSearch(e.target.value.toUpperCase())} - /> - - {/* Toggles */} -
- - -
- - {/* Band filter — multi-select listbox */} -
-
- Bands -
- - {clusterBands.size > 0 && ( - - )} -
-
-
- {bands.map((b) => { - const on = clusterBands.has(b); - return ( - - ); - })} -
-
- - {/* Mode lock */} - - - {/* Status filter */} -
-
Status
-
- {([ - { 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 ( - - ); - })} -
-
- - {/* Mode filter */} -
-
Mode
-
- {([ - { 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 ( - - ); - })} -
-
- - {/* Source */} -
-
Source
- -
-
-
+ {/* Right-side filter panel (Log4OM style) — shared with the + Main-view cluster pane; toggle hides it in both places. */} + {clusterShowFilters && renderClusterFilters()} @@ -3277,13 +3369,13 @@ export default function App() { )} - + {/* Two configurable panes (per-profile, Settings → Main view). + Each side shows one of: great-circle map, locator map, cluster + or worked-before. */} +
+
{renderMainPane(mainPaneLeft)}
+
{renderMainPane(mainPaneRight)}
+
@@ -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); }} /> )} diff --git a/frontend/src/components/MainMap.tsx b/frontend/src/components/MainMap.tsx index 4b1d30e..1f9535a 100644 --- a/frontend/src/components/MainMap.tsx +++ b/frontend/src/components/MainMap.tsx @@ -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(null); - const locatorRef = useRef(null); const worldMap = useRef(null); - const locatorMap = useRef(null); - // Layers we add/remove as the QSO changes (kept separate from the basemap). const worldOverlay = useRef(null); - const locatorOverlay = useRef(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 ( +
+
+ {/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom + (remembered across restarts), so the beam heading stays visible. */} + + {path && ( +
+
Dist {Math.round(path.distanceShort).toLocaleString()} km + · LP {Math.round(path.distanceLong).toLocaleString()} km
+
Az SP {Math.round(path.bearingShort)}° + · LP {Math.round(path.bearingLong)}°
+
+ )} +
+ ); +} + +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(null); + const locatorMap = useRef(null); + const locatorOverlay = useRef(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 ( -
-
-
-
- {/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom - (remembered across restarts), so the beam heading stays visible. */} - - {path && ( -
-
Dist {Math.round(path.distanceShort).toLocaleString()} km - · LP {Math.round(path.distanceLong).toLocaleString()} km
-
Az SP {Math.round(path.bearingShort)}° - · LP {Math.round(path.bearingLong)}°
-
- )} +
+
+ {!gridToLatLon(toGrid) && ( +
+ Enter a grid or look up the callsign to center the map.
-
-
- {!gridToLatLon(toGrid) && ( -
- Enter a grid or look up the callsign to center the map. -
- )} -
-
+ )}
); } diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 9e15d49..8b34752 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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 ( +
+

Main view

+

Choose what the Main tab shows on each side (per profile).

+
+ + +
+
+ ); +} + // 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((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) { + +

Password encryption

{secret.has_passphrase ? ( diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts index dbdf502..b95e27b 100644 --- a/frontend/src/lib/uiPref.ts +++ b/frontend/src/lib/uiPref.ts @@ -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: