feat: added selection of map 4 choices

This commit is contained in:
2026-06-16 21:49:02 +02:00
parent 16dc864dbd
commit abdab22010
6 changed files with 113 additions and 10 deletions
+17
View File
@@ -2452,6 +2452,16 @@ func (a *App) invalidateAwardStats() {
a.awardSnapMu.Unlock() 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) // awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists // into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
// are large and not needed for matching; their names are filled afterwards. // 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: default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc)) 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) done(matched+added, total)
} }
+24 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; 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 { 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 { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -76,6 +76,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
const [showMissing, setShowMissing] = useState(false); const [showMissing, setShowMissing] = useState(false);
const [stats, setStats] = useState<AwardStats | null>(null); const [stats, setStats] = useState<AwardStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false); 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. // Lazily fetch the statistics matrix when the Stats view is shown.
useEffect(() => { useEffect(() => {
@@ -85,7 +88,24 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
.then((s) => setStats(s as any)) .then((s) => setStats(s as any))
.catch(() => setStats(null)) .catch(() => setStats(null))
.finally(() => setStatsLoading(false)); .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). // Compute one award (cached). force=true bypasses the cache (Rescan).
async function compute(code: string, force = false) { async function compute(code: string, force = false) {
@@ -192,8 +212,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards"> <Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
</Button> </Button>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected} <Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
title="Rescan all QSOs and recompute this award"> title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />} {loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
Rescan Rescan
</Button> </Button>
+65 -6
View File
@@ -46,6 +46,24 @@ const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const CARTO_ATTR = '&copy; OpenStreetMap &copy; CARTO'; const CARTO_ATTR = '&copy; OpenStreetMap &copy; CARTO';
const OSM_ATTR = '&copy; OpenStreetMap contributors'; const OSM_ATTR = '&copy; 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<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
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 &copy; 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 { function dot(color: string): L.DivIcon {
return L.divIcon({ return L.divIcon({
className: '', className: '',
@@ -70,6 +88,9 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const worldRef = useRef<HTMLDivElement>(null); const worldRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null); const worldMap = useRef<L.Map | null>(null);
const worldOverlay = useRef<L.LayerGroup | null>(null); const worldOverlay = useRef<L.LayerGroup | null>(null);
const baseLayer = useRef<L.TileLayer | null>(null);
const labelsLayer = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator // 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 // 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) { if (worldRef.current && !worldMap.current) {
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true }) const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
.setView([20, 0], 1); .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); worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m; worldMap.current = m;
const sv = loadMapView(); const sv = loadMapView();
@@ -94,6 +117,19 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
return () => window.clearTimeout(t); 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. // Redraw overlays whenever the operator/DX grids (or beam) change.
useEffect(() => { useEffect(() => {
const wm = worldMap.current, wo = worldOverlay.current; 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' }) L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) .bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo); .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[], 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) ── // ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (beamAzimuths && beamAzimuths.length) { if (beamAzimuths && beamAzimuths.length) {
@@ -139,10 +177,15 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
// for any azimuth, north/south included. // for any azimuth, north/south included.
for (let b = az - half; b <= az + half + 0.001; b += 1.5) { for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]); 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)]); 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); .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]); bpts.push([d.lat, d.lon]);
if (Math.abs(d.lat) > 86) break; 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); .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 ( return (
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border"> <div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div ref={worldRef} className="absolute inset-0" /> <div ref={worldRef} className="absolute inset-0" />
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<button
key={k}
type="button"
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
title={`Basemap: ${BASEMAPS[k].label}`}
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
}`}
>
{BASEMAPS[k].label}
</button>
))}
</div>
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom {/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
(remembered across restarts), so the beam heading stays visible. */} (remembered across restarts), so the beam heading stays visible. */}
<button <button
+1
View File
@@ -23,6 +23,7 @@ const PORTABLE_KEYS = [
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/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.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite)
]; ];
// syncPortablePrefs reconciles the DB with the local cache at startup: // syncPortablePrefs reconciles the DB with the local cache at startup:
+2
View File
@@ -313,6 +313,8 @@ export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>; export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
export function RescanAwards():Promise<void>;
export function ResetAwardDefs():Promise<Array<award.Def>>; export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>; export function ResetDatabaseToDefault():Promise<void>;
+4
View File
@@ -598,6 +598,10 @@ export function ReplaceAwardReferences(arg1, arg2) {
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2); return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
} }
export function RescanAwards() {
return window['go']['main']['App']['RescanAwards']();
}
export function ResetAwardDefs() { export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs'](); return window['go']['main']['App']['ResetAwardDefs']();
} }