This commit is contained in:
2026-06-10 20:27:44 +02:00
parent 42b5c6247d
commit 6150498a9e
9 changed files with 223 additions and 120 deletions
+53 -8
View File
@@ -1,7 +1,18 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { writeUiPref } from '@/lib/uiPref';
// Persisted free-pan view of the world map (when auto-zoom is off).
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
catch { return null; }
}
function saveMapView(m: L.Map) {
const c = m.getCenter();
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
@@ -60,6 +71,13 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
const worldOverlay = useRef<L.LayerGroup | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(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
// across restarts. Default on.
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
const autoZoomRef = useRef(autoZoom);
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
// One-time map creation.
useEffect(() => {
if (worldRef.current && !worldMap.current) {
@@ -68,6 +86,11 @@ 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 })
@@ -135,13 +158,16 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
if (from && to) {
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);
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
// Include the arc so high-latitude curves aren't clipped.
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} else if (to) {
// Only re-frame the map when auto-zoom is on; otherwise keep the user's
// chosen (remembered) view so the beam heading stays visible.
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
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
} else if (autoZoom && to) {
wm.setView([to.lat, to.lon], 3);
} else if (from) {
} else if (autoZoom && from) {
wm.setView([from.lat, from.lon], 3);
}
@@ -160,7 +186,7 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
}
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]);
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
const path = pathBetween(fromGrid, toGrid);
@@ -169,6 +195,25 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
<div className="relative isolate rounded-lg overflow-hidden border border-border">
<div ref={worldRef} className="absolute inset-0" />
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
(remembered across restarts), so the beam heading stays visible. */}
<button
type="button"
onClick={() => {
const v = !autoZoom;
setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current;
if (!v && m) saveMapView(m); // entering free mode → remember current view
// (turning it on re-frames via the redraw effect, which depends on autoZoom)
}}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
}`}
>
Zoom DX
</button>
{path && (
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km