up
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user