feat: added selection of map 4 choices
This commit is contained in:
@@ -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<AwardStats | null>(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 }
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected}
|
||||
title="Rescan all QSOs and recompute this award">
|
||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||||
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" />}
|
||||
Rescan
|
||||
</Button>
|
||||
|
||||
@@ -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<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 {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
@@ -70,6 +88,9 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const worldMap = useRef<L.Map | 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
|
||||
// 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 (
|
||||
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||
<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
|
||||
(remembered across restarts), so the beam heading stays visible. */}
|
||||
<button
|
||||
|
||||
@@ -23,6 +23,7 @@ const PORTABLE_KEYS = [
|
||||
'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)
|
||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||
];
|
||||
|
||||
// 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 RescanAwards():Promise<void>;
|
||||
|
||||
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
export function ResetDatabaseToDefault():Promise<void>;
|
||||
|
||||
@@ -598,6 +598,10 @@ export function ReplaceAwardReferences(arg1, arg2) {
|
||||
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RescanAwards() {
|
||||
return window['go']['main']['App']['RescanAwards']();
|
||||
}
|
||||
|
||||
export function ResetAwardDefs() {
|
||||
return window['go']['main']['App']['ResetAwardDefs']();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user