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() })); } // The Main tab is built from two independent map panes that the operator can // place on either side (Settings → Main view): // • WorldMap ("map1"): a world map with the great-circle path from the // operator to the contacted station, distance, short/long-path azimuth and // the antenna beam lobe. // • LocatorMap ("map2"): a street map zoomed onto the contacted station's grid. // Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use // circleMarkers / divIcons so we don't depend on Leaflet's image assets. // unwrapLon makes a lat/lon ring continuous in longitude (each point within // 180° of the previous) so a polygon crossing the antimeridian doesn't snap // across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine. function unwrapLon(ring: [number, number][]): [number, number][] { const out: [number, number][] = []; let prev = NaN; for (const [la, lo] of ring) { let lon = lo; if (!Number.isNaN(prev)) { while (lon - prev > 180) lon -= 360; while (lon - prev < -180) lon += 360; } prev = lon; out.push([la, lon]); } return out; } const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; const CARTO_ATTR = '© OpenStreetMap © CARTO'; const OSM_ATTR = '© OpenStreetMap contributors'; function dot(color: string): L.DivIcon { return L.divIcon({ className: '', html: ``, iconSize: [12, 12], iconAnchor: [6, 6], }); } interface WorldProps { fromGrid: string; // operator grid (active profile) toGrid: string; // contacted-station grid fromLabel?: string; // operator callsign toLabel?: string; // DX callsign beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each beamWidth?: number; // beamwidth (deg), default 30 } // WorldMap — great-circle path + beam lobe(s), the "map1" pane. export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: WorldProps) { const worldRef = useRef(null); const worldMap = useRef(null); const worldOverlay = useRef(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) { 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); worldOverlay.current = L.layerGroup().addTo(m); worldMap.current = m; const sv = loadMapView(); if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom); m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); }); } const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80); return () => window.clearTimeout(t); }, []); // Redraw overlays whenever the operator/DX grids (or beam) change. useEffect(() => { const wm = worldMap.current, wo = worldOverlay.current; if (!wm || !wo) return; wo.clearLayers(); const from = gridToLatLon(fromGrid); const to = gridToLatLon(toGrid); if (from && to) { L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' }) .bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) .addTo(wo); 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); L.polyline(unwrapLon(pts) as L.LatLngExpression[], { color: '#2563eb', weight: 2, opacity: 0.8 }).addTo(wo); // ── Antenna beam lobe(s) (drawn first, under the arc/markers) ── if (beamAzimuths && beamAzimuths.length) { const half = (beamWidth ?? 30) / 2; const D = 5500; // lobe length (km) // Great-circle radial out to distance D, stopping just short of the pole // so a poleward line doesn't snap across the top of the Mercator map. const radial = (b: number): [number, number][] => { const out: [number, number][] = []; const N = 64; for (let i = 1; i <= N; i++) { const d = destinationPoint(from.lat, from.lon, b, (D * i) / N); out.push([d.lat, d.lon]); if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap } return out; }; for (const az of beamAzimuths) { // Draw the lobe as a FAN of translucent great-circle radials, not a // filled polygon: a polygon breaks badly near the poles on Mercator // (its edges run off toward ±90° and the fill smears across the map), // while each radial LINE stays clean. The overlapping lines read as a // lobe — solid near the antenna, fanning out toward the front. Works // 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); } 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' }) .bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo); } } if (autoZoom) { const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 }); } } else if (autoZoom && to) { wm.setView([to.lat, to.lon], 3); } else if (autoZoom && from) { wm.setView([from.lat, from.lon], 3); } setTimeout(() => { wm.invalidateSize(); }, 0); // eslint-disable-next-line react-hooks/exhaustive-deps }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]); const path = pathBetween(fromGrid, toGrid); return (
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom (remembered across restarts), so the beam heading stays visible. */} {path && (
Dist {Math.round(path.distanceShort).toLocaleString()} km · LP {Math.round(path.distanceLong).toLocaleString()} km
Az SP {Math.round(path.bearingShort)}° · LP {Math.round(path.bearingLong)}°
)}
); } interface LocatorProps { toGrid: string; // contacted-station grid toLabel?: string; // DX callsign } // LocatorMap — street map zoomed onto the DX grid, the "map2" pane. export function LocatorMap({ toGrid, toLabel }: LocatorProps) { const locatorRef = useRef(null); const locatorMap = useRef(null); const locatorOverlay = useRef(null); useEffect(() => { if (locatorRef.current && !locatorMap.current) { const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true }) .setView([20, 0], 2); L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m); locatorOverlay.current = L.layerGroup().addTo(m); locatorMap.current = m; } const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80); return () => window.clearTimeout(t); }, []); useEffect(() => { const lm = locatorMap.current, lo = locatorOverlay.current; if (!lm || !lo) return; lo.clearLayers(); const to = gridToLatLon(toGrid); if (to) { L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' }) .bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) .addTo(lo); const b = gridSquareBounds(toGrid); if (b) { L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo); } lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7); } setTimeout(() => { lm.invalidateSize(); }, 0); }, [toGrid, toLabel]); return (
{!gridToLatLon(toGrid) && (
Enter a grid or look up the callsign to center the map.
)}
); }