325 lines
16 KiB
TypeScript
325 lines
16 KiB
TypeScript
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';
|
|
|
|
// 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: '',
|
|
html: `<span style="display:block;width:12px;height:12px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.4)"></span>`,
|
|
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[]; // radiating heading(s) (deg) → draw a beam lobe each
|
|
beamWidth?: number; // beamwidth (deg), default 30
|
|
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
|
}
|
|
|
|
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
|
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
|
|
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
|
|
// 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);
|
|
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();
|
|
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);
|
|
}, []);
|
|
|
|
// 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;
|
|
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, 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.85, smoothFactor: 0 }).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: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo);
|
|
}
|
|
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Mechanical boom (rotor) direction — thin grey dashed line. Drawn when the
|
|
// Ultrabeam radiates elsewhere (reverse/bi) so the boom heading stays visible
|
|
// next to the red radiating lobe(s).
|
|
if (boomAzimuth != null) {
|
|
const bpts: [number, number][] = [[from.lat, from.lon]];
|
|
const N = 64, D = 5500;
|
|
for (let i = 1; i <= N; i++) {
|
|
const d = destinationPoint(from.lat, from.lon, boomAzimuth, (D * i) / N);
|
|
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', smoothFactor: 0 })
|
|
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { 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, boomAzimuth, autoZoom]);
|
|
|
|
const path = pathBetween(fromGrid, toGrid);
|
|
|
|
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
|
|
type="button"
|
|
onClick={() => {
|
|
const v = !autoZoom;
|
|
setAutoZoom(v);
|
|
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
|
const m = worldMap.current;
|
|
if (!v && m) saveMapView(m);
|
|
}}
|
|
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
|
|
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
|
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
|
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement>(null);
|
|
const locatorMap = useRef<L.Map | null>(null);
|
|
const locatorOverlay = useRef<L.LayerGroup | null>(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 (
|
|
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
|
<div ref={locatorRef} className="absolute inset-0" />
|
|
{!gridToLatLon(toGrid) && (
|
|
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
|
|
Enter a grid or look up the callsign to center the map.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|