From abdab22010c41409a957ae886e969553b8f1ef8c Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 16 Jun 2026 21:49:02 +0200 Subject: [PATCH] feat: added selection of map 4 choices --- app.go | 17 ++++++ frontend/src/components/AwardsPanel.tsx | 28 ++++++++-- frontend/src/components/MainMap.tsx | 71 ++++++++++++++++++++++--- frontend/src/lib/uiPref.ts | 1 + frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 ++ 6 files changed, 113 insertions(+), 10 deletions(-) diff --git a/app.go b/app.go index a8ca6f4..aa93760 100644 --- a/app.go +++ b/app.go @@ -2452,6 +2452,16 @@ func (a *App) invalidateAwardStats() { a.awardSnapMu.Unlock() } +// RescanAwards forces the next award computation to re-pull the logbook from the +// database, bypassing the in-memory snapshot. Bound to the Awards panel's +// "Rescan" button so the operator can refresh after an external change the +// revision check can't see (e.g. a LoTW/QRZ confirmation download that only +// flips qsl_rcvd flags on existing rows). +func (a *App) RescanAwards() error { + a.invalidateAwardStats() + return nil +} + // awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false) // into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists // are large and not needed for matching; their names are filled afterwards. @@ -5416,6 +5426,13 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe default: emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc)) } + // Confirmations flip lotw_rcvd/qsl_rcvd on EXISTING rows, which doesn't move + // the logbook revision (count:maxID) — so the cached award snapshot would + // stay stale. Drop it whenever anything was matched or added so the next + // Awards view reflects the new confirmations. + if matched > 0 || added > 0 { + a.invalidateAwardStats() + } done(matched+added, total) } diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx index 893d97c..85a6a3f 100644 --- a/frontend/src/components/AwardsPanel.tsx +++ b/frontend/src/components/AwardsPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react'; -import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs } from '../../wailsjs/go/main/App'; +import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -76,6 +76,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } const [showMissing, setShowMissing] = useState(false); const [stats, setStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); + // Bumped by Rescan to force the stats matrix to re-fetch (the selected award + // didn't change, but the backend snapshot did). + const [rescanTick, setRescanTick] = useState(0); // Lazily fetch the statistics matrix when the Stats view is shown. useEffect(() => { @@ -85,7 +88,24 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } .then((s) => setStats(s as any)) .catch(() => setStats(null)) .finally(() => setStatsLoading(false)); - }, [view, selected]); + }, [view, selected, rescanTick]); + + // Rescan: drop the backend snapshot (so confirmations from a fresh LoTW/QRZ + // download are picked up) and the cached results, then recompute everything. + async function rescan() { + if (!selected) return; + setLoading(true); + try { + await RescanAwards(); + setByCode({}); + setRescanTick((t) => t + 1); + await compute(selected, true); + } catch (e: any) { + setErr(String(e?.message ?? e)); + } finally { + setLoading(false); + } + } // Compute one award (cached). force=true bypasses the cache (Rescan). async function compute(code: string, force = false) { @@ -192,8 +212,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } - diff --git a/frontend/src/components/MainMap.tsx b/frontend/src/components/MainMap.tsx index 916906b..7086b83 100644 --- a/frontend/src/components/MainMap.tsx +++ b/frontend/src/components/MainMap.tsx @@ -46,6 +46,24 @@ const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; const CARTO_ATTR = '© OpenStreetMap © CARTO'; const OSM_ATTR = '© OpenStreetMap contributors'; +// Selectable basemaps for the world (great-circle) map. All key-free and all +// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name +// overlay on top of an imagery basemap (so satellite keeps its names too). +type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite'; +const BASEMAPS: Record = { + light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' }, + voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + attr: CARTO_ATTR, subdomains: 'abcd' }, + street: { label: 'Street', url: OSM, attr: OSM_ATTR }, + satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics', + labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' }, +}; +function loadBasemap(): BasemapKey { + const v = localStorage.getItem('opslog.mapBasemap'); + return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light'; +} + function dot(color: string): L.DivIcon { return L.divIcon({ className: '', @@ -70,6 +88,9 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b const worldRef = useRef(null); const worldMap = useRef(null); const worldOverlay = useRef(null); + const baseLayer = useRef(null); + const labelsLayer = useRef(null); + const [basemap, setBasemap] = useState(loadBasemap); // 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 @@ -83,7 +104,9 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b if (worldRef.current && !worldMap.current) { const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true }) .setView([20, 0], 1); - L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m); + const bm = BASEMAPS[basemap]; + baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m); + if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m); worldOverlay.current = L.layerGroup().addTo(m); worldMap.current = m; const sv = loadMapView(); @@ -94,6 +117,19 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b return () => window.clearTimeout(t); }, []); + // Swap the basemap (and its optional place-name overlay) when the operator + // picks a different one. Vector overlays (path/beam) live in Leaflet's + // overlayPane, always above any tile layer, so nothing to re-stack there. + useEffect(() => { + const m = worldMap.current; + if (!m) return; + if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; } + if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; } + const bm = BASEMAPS[basemap]; + baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m); + if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m); + }, [basemap]); + // Redraw overlays whenever the operator/DX grids (or beam) change. useEffect(() => { const wm = worldMap.current, wo = worldOverlay.current; @@ -110,9 +146,11 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b 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); + const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128); + // smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the + // line, which makes a smooth arc look angular/bumpy). L.polyline(unwrapLon(pts) as L.LatLngExpression[], - { color: '#2563eb', weight: 2, opacity: 0.8 }).addTo(wo); + { color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo); // ── Antenna beam lobe(s) (drawn first, under the arc/markers) ── if (beamAzimuths && beamAzimuths.length) { @@ -139,10 +177,15 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b // 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); + L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).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' }) + // Dark casing under the boresight so the bright dashed line stays + // readable on any basemap (esp. dark satellite imagery). Same dashArray + // as the red line so the casing tracks each dash — otherwise the wide + // casing peeks through the gaps and the line looks bumpy. + L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo); + L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 }) .bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo); } } @@ -158,7 +201,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b bpts.push([d.lat, d.lon]); if (Math.abs(d.lat) > 86) break; } - L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4' }) + L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 }) .bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo); } @@ -181,6 +224,22 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b return (
+ {/* Basemap picker — Light / Street / Satellite (key-free tiles). */} +
+ {(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => ( + + ))} +
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom (remembered across restarts), so the beam heading stays visible. */}