import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween } from '@/lib/maidenhead'; // MainMap — Log4OM-style dual map for the Main tab: // • Left: a world map with the great-circle path drawn from the operator to // the contacted station, plus distance + short/long-path azimuth. // • Right: a street map zoomed onto the contacted station's grid locator. // Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use // circleMarkers / divIcons so we don't depend on Leaflet's image assets. interface Props { fromGrid: string; // operator grid (active profile) toGrid: string; // contacted-station grid fromLabel?: string; // operator callsign toLabel?: string; // DX callsign } 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], }); } export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) { const worldRef = useRef(null); const locatorRef = useRef(null); const worldMap = useRef(null); const locatorMap = useRef(null); // Layers we add/remove as the QSO changes (kept separate from the basemap). const worldOverlay = useRef(null); const locatorOverlay = useRef(null); // 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; } 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; } // The Main tab may have just become visible — fix tile sizing. const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); locatorMap.current?.invalidateSize(); }, 80); return () => window.clearTimeout(t); }, []); // Redraw overlays whenever the operator/DX grids change. useEffect(() => { const wm = worldMap.current, lm = locatorMap.current; const wo = worldOverlay.current, lo = locatorOverlay.current; if (!wm || !lm || !wo || !lo) return; wo.clearLayers(); lo.clearLayers(); const from = gridToLatLon(fromGrid); const to = gridToLatLon(toGrid); // ── Left: world + great-circle arc ── if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo); if (to) { L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' }) .bindTooltip(toLabel || toGrid, { permanent: false, direction: 'top' }).addTo(wo); } 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) { wm.setView([to.lat, to.lon], 3); } else if (from) { wm.setView([from.lat, from.lon], 3); } // ── Right: street map on the DX locator ── 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); } else if (from) { lm.setView([from.lat, from.lon], 5); } setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0); }, [fromGrid, toGrid, fromLabel, toLabel]); const path = pathBetween(fromGrid, toGrid); return (
{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)}°
)}
{!gridToLatLon(toGrid) && (
Enter a grid or look up the callsign to center the map.
)}
); }