feat: added selection of map 4 choices
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -46,6 +46,24 @@ const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|||||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||||
const OSM_ATTR = '© OpenStreetMap contributors';
|
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<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 © 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Vendored
+2
@@ -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>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user