Files
OpsLog/frontend/src/components/MainMap.tsx
T
rouggy f82631690a fix: grey line cut off at the edge of the world copy
The map repeats the world sideways, and zoomed out several copies are on screen
at once. The rings span −180…+180 once, so the shading ended in a hard vertical
edge wherever the next copy began: it read as a rectangle laid over the planet
rather than as night.

Each ring is now drawn again shifted a full turn either way. Three copies cover
every zoom this map reaches and cost nothing — three more polygons on a canvas
layer.

Zooming the map to fit one world would have hidden it rather than fixed it, and
would have taken away the free pan and zoom the operator has.
2026-07-30 19:35:26 +02:00

456 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
// 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 = '&copy; OpenStreetMap &copy; CARTO';
const OSM_ATTR = '&copy; 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 &copy; 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';
}
// 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.
function addBasemap(
m: L.Map,
key: BasemapKey,
base: React.MutableRefObject<L.TileLayer | null>,
labels: React.MutableRefObject<L.TileLayer | null>,
) {
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 opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).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). */}
<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>
{/* 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> {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>
);
}