Everything is computed in kilometres and converted once at display time, in lib/units — storing miles anywhere would give the same number two sources of truth and a rounding error that grows with every hop. The grids capture the unit inside their column definitions, header and formatter both, so a change is published to them the way the date format already is; without that a toggle would only appear after a language change or a restart. The preference is portable, like the other display ones.
523 lines
26 KiB
TypeScript
523 lines
26 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import L from 'leaflet';
|
||
import 'leaflet/dist/leaflet.css';
|
||
import { nightPolygon } from '../lib/greyline';
|
||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||
import { writeUiPref } from '@/lib/uiPref';
|
||
import { formatDistance } from '@/lib/units';
|
||
|
||
// 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;
|
||
}
|
||
|
||
// CARTO IS GONE, and it went the same way OpenStreetMap did.
|
||
//
|
||
// Their key-free basemaps now serve tiles stamped "API KEY REQUIRED" across
|
||
// the middle of the map — reported by an operator whose Light and Voyager views
|
||
// came up watermarked. They still answer 200 with a real image, which is why
|
||
// nothing here could detect it: the map looks like it is working and simply
|
||
// says otherwise in large grey letters.
|
||
//
|
||
// So both are replaced by Esri layers, the same provider already behind Street
|
||
// and Satellite. That is deliberate: a second key-free provider is a second
|
||
// chance to be cut off, and this one has already been serving the other two
|
||
// views for as long as OpsLog has had a map.
|
||
|
||
// NOT tile.openstreetmap.org. Those are OpenStreetMap's VOLUNTEER-run servers,
|
||
// and their usage policy does not cover a desktop application handed to an
|
||
// unbounded number of operators — they blocked OpsLog for it, and every user's
|
||
// map filled with "Access blocked / 403" tiles at once.
|
||
//
|
||
// The replacements are tile services whose terms do cover redistributed apps,
|
||
// with no API key: Carto (whose tiles are OSM-derived, hence the attribution
|
||
// still credits OpenStreetMap) and Esri. Anything added here later must be
|
||
// checked the same way — a free tile URL is not the same thing as a tile URL we
|
||
// are allowed to ship.
|
||
const ESRI = 'https://server.arcgisonline.com/ArcGIS/rest/services';
|
||
const ESRI_STREET = ESRI + '/World_Street_Map/MapServer/tile/{z}/{y}/{x}';
|
||
const ESRI_STREET_ATTR = 'Tiles © Esri — Source: Esri, HERE, Garmin, © OpenStreetMap contributors';
|
||
|
||
// The plain background the old Carto "Light" was: a pale canvas that lets the
|
||
// paths and the spots carry the colour. Its names come on a separate
|
||
// transparent layer, the same arrangement the satellite view already uses.
|
||
const ESRI_LIGHT = ESRI + '/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}';
|
||
const ESRI_LIGHT_LABELS = ESRI + '/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}';
|
||
const ESRI_LIGHT_ATTR = 'Tiles © Esri — Esri, DeLorme, NAVTEQ';
|
||
|
||
// 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).
|
||
export type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||
// maxNativeZoom is where a layer RUNS OUT of tiles. Leaflet then upscales the
|
||
// last real one instead of asking for a level that does not exist, which is the
|
||
// difference between a slightly soft map and a blank grey square: the light
|
||
// canvas stops at 16 where the imagery and the topo go on to 19.
|
||
export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string; maxNativeZoom?: number }> = {
|
||
light: { label: 'Light', url: ESRI_LIGHT, attr: ESRI_LIGHT_ATTR, labelsUrl: ESRI_LIGHT_LABELS, maxNativeZoom: 16 },
|
||
// The key is still "voyager" so that everyone who chose it keeps their view
|
||
// rather than being silently moved to another one. What it draws is Esri's
|
||
// topographic map: terrain under the labels, which is what made Voyager worth
|
||
// choosing over the plain canvas.
|
||
voyager: { label: 'Topo', url: ESRI + '/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
|
||
attr: 'Tiles © Esri — Esri, HERE, Garmin, USGS, NGA, © OpenStreetMap contributors' },
|
||
street: { label: 'Street', url: ESRI_STREET, attr: ESRI_STREET_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}' },
|
||
};
|
||
export function loadBasemap(): BasemapKey {
|
||
const v = localStorage.getItem('opslog.mapBasemap');
|
||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||
}
|
||
|
||
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||
// layers WebView2 has to hold — is double every other basemap's.
|
||
export function addBasemap(
|
||
m: L.Map,
|
||
key: BasemapKey,
|
||
base: React.MutableRefObject<L.TileLayer | null>,
|
||
labels: React.MutableRefObject<L.TileLayer | null>,
|
||
// noWrap draws the world ONCE instead of repeating it east and west.
|
||
//
|
||
// The great-circle map wants the repeats: a path from France to Japan reads
|
||
// better crossing a second copy of Asia than wrapping round the back. A map of
|
||
// where you have been heard does not — three North Americas is three times the
|
||
// same answer, and it makes the picture harder to read, not wider.
|
||
// bounds stops Leaflet asking for tiles outside the world at all: Esri answers
|
||
// those with a grey "Map data not yet available" image, and zoomed out on a
|
||
// wide window that is most of the screen.
|
||
opts?: { noWrap?: boolean; bounds?: L.LatLngBoundsExpression },
|
||
) {
|
||
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
||
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||
const bm = BASEMAPS[key];
|
||
const tileOpts: L.TileLayerOptions = {
|
||
maxZoom: 19, maxNativeZoom: bm.maxNativeZoom ?? 19,
|
||
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||
noWrap: !!opts?.noWrap,
|
||
...(opts?.bounds ? { bounds: opts.bounds } : {}),
|
||
};
|
||
base.current = L.tileLayer(bm.url, { ...tileOpts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, tileOpts).addTo(m);
|
||
}
|
||
|
||
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
|
||
zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished)
|
||
}
|
||
|
||
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: 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');
|
||
|
||
// Grey line — the day/night terminator with its twilight band. Off by
|
||
// default: it is a working tool for the operator who wants it, not decoration
|
||
// for the one who does not.
|
||
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
|
||
const greylineLayer = useRef<L.LayerGroup | null>(null);
|
||
const autoZoomRef = useRef(autoZoom);
|
||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||
|
||
// One-time map creation.
|
||
useEffect(() => {
|
||
if (worldRef.current && !worldMap.current) {
|
||
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
||
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
||
// enough to blow WebView2's raster budget and leave the window painting in
|
||
// patches. On canvas it is a single layer.
|
||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
||
.setView([20, 0], 1);
|
||
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||
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);
|
||
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
||
// every overlay redraw (as before) forced a full repaint of the map each time
|
||
// the rotor moved a degree.
|
||
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||
if (worldRef.current) ro.observe(worldRef.current);
|
||
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||
}, []);
|
||
|
||
// 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) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||
}, [basemap]);
|
||
|
||
// Draw the grey line, and keep it moving.
|
||
//
|
||
// Redrawn every minute: the terminator travels a quarter of a degree in that
|
||
// time, which is invisible, but a map left on all day would otherwise be
|
||
// silently wrong by evening — and the whole point of the display is knowing
|
||
// where the line is NOW.
|
||
//
|
||
// It lives in its own pane below the overlay pane so the path and beam are
|
||
// never hidden behind the shading, and is non-interactive so it can never
|
||
// swallow a click meant for the map.
|
||
useEffect(() => {
|
||
const m = worldMap.current;
|
||
if (!m) return;
|
||
|
||
if (!greyline) {
|
||
greylineLayer.current?.remove();
|
||
greylineLayer.current = null;
|
||
return;
|
||
}
|
||
|
||
if (!m.getPane('greyline')) {
|
||
const pane = m.createPane('greyline');
|
||
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
|
||
pane.style.pointerEvents = 'none';
|
||
}
|
||
|
||
const draw = () => {
|
||
const now = new Date();
|
||
greylineLayer.current?.remove();
|
||
const g = L.layerGroup([], { pane: 'greyline' });
|
||
|
||
// The rings span −180…+180 once, but this map repeats the world sideways
|
||
// (worldCopyJump, and zoomed out several copies are on screen at once). A
|
||
// single ring therefore ends in a hard vertical edge wherever a copy
|
||
// begins — the shading looked like a rectangle laid over the planet.
|
||
//
|
||
// So each ring is drawn again shifted a full turn either way. Three copies
|
||
// cover every zoom level this map reaches; they cost nothing, being three
|
||
// polygons on a canvas layer.
|
||
const COPIES = [-360, 0, 360];
|
||
const shifted = (ring: [number, number][], by: number): [number, number][] =>
|
||
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
|
||
|
||
// Two bands, drawn dark-to-light so they read as one gradient into night:
|
||
// full darkness (Sun below the horizon) and civil twilight (down to −6°),
|
||
// which is the band operators actually mean by "grey line".
|
||
const nightRing = nightPolygon(now, 0);
|
||
const twilightRing = nightPolygon(now, -6);
|
||
// The terminator itself, so the line is readable at a glance rather than
|
||
// being inferred from where the shading fades. Dropping the last two
|
||
// points leaves the terminator alone, without the segment that closes the
|
||
// polygon along the dark pole.
|
||
const lineRing = nightRing.slice(0, -2);
|
||
|
||
for (const by of COPIES) {
|
||
L.polygon(shifted(twilightRing, by), {
|
||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
|
||
}).addTo(g);
|
||
L.polygon(shifted(nightRing, by), {
|
||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
|
||
}).addTo(g);
|
||
L.polyline(shifted(lineRing, by), {
|
||
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
|
||
}).addTo(g);
|
||
}
|
||
g.addTo(m);
|
||
greylineLayer.current = g;
|
||
};
|
||
|
||
draw();
|
||
const id = window.setInterval(draw, 60_000);
|
||
return () => { window.clearInterval(id); };
|
||
}, [greyline]);
|
||
|
||
// 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);
|
||
|
||
// Station marker + antenna beam/boom are drawn whenever the station grid is
|
||
// known — independent of any DX. The antenna is always pointed somewhere, so
|
||
// the beam heading should show even before a callsign is entered.
|
||
if (from) {
|
||
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||
.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 DENSE fan of translucent great-circle radials, not
|
||
// a filled polygon: a polygon smears badly near the poles on Mercator
|
||
// (a poleward beam degenerates into a giant triangle), while each radial
|
||
// LINE stays clean at any azimuth. A small angular step makes the lines
|
||
// overlap into a solid-looking lobe (no separate strokes), darker near
|
||
// the antenna and fanning out toward the front.
|
||
for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
|
||
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, 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. Same dashArray so the casing tracks each dash.
|
||
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);
|
||
}
|
||
}
|
||
|
||
// DX marker + great-circle arc — only when a DX grid is known (callsign entered).
|
||
let arcPts: [number, number][] | null = null;
|
||
if (from && to) {
|
||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||
.addTo(wo);
|
||
arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line).
|
||
L.polyline(unwrapLon(arcPts) as L.LatLngExpression[],
|
||
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||
}
|
||
|
||
if (autoZoom) {
|
||
if (from && to && arcPts) {
|
||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||
arcPts.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);
|
||
}
|
||
}
|
||
// No invalidateSize() here — a ResizeObserver handles the only case that needs
|
||
// it. Forcing a full map repaint on every redraw is what made a moving rotor
|
||
// thrash the compositor.
|
||
// Headings are rounded in the deps: a rotor reports a jittering float, and a
|
||
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
|
||
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
|
||
|
||
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). */}
|
||
{/* Clear of the zoom buttons, not merely next to them: at left-12 the
|
||
first basemap sat a few pixels from the − button and operators kept
|
||
hitting Light when they meant to zoom out. */}
|
||
<div className="absolute top-1 left-[4.5rem] 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>
|
||
{/* Grey line toggle, under the zoom button. */}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const v = !greyline;
|
||
setGreyline(v);
|
||
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
|
||
}}
|
||
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
|
||
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||
}`}
|
||
>
|
||
Grey line
|
||
</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> {formatDistance(path.distanceShort)}
|
||
<span className="text-muted-foreground"> · LP</span> {formatDistance(path.distanceLong)}</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);
|
||
// Same restraint as the world map: fetch on idle, not mid-pan, and keep a
|
||
// single ring of buffer tiles. Requesting tiles as fast as the pointer
|
||
// moves is what gets an app blocked, and the next provider is under no
|
||
// more obligation to tolerate it than the last one was.
|
||
L.tileLayer(ESRI_LIGHT, {
|
||
attribution: ESRI_LIGHT_ATTR, maxZoom: 19, maxNativeZoom: 16,
|
||
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||
}).addTo(m);
|
||
L.tileLayer(ESRI_LIGHT_LABELS, {
|
||
maxZoom: 19, maxNativeZoom: 16,
|
||
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||
}).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>
|
||
);
|
||
}
|