This commit is contained in:
2026-06-07 21:44:49 +02:00
parent 3dd9620cca
commit 6542504a4b
14 changed files with 585 additions and 139 deletions
+55 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween } from '@/lib/maidenhead';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } 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
@@ -15,6 +15,26 @@ interface Props {
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
}
// 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';
@@ -31,7 +51,7 @@ function dot(color: string): L.DivIcon {
});
}
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) {
const worldRef = useRef<HTMLDivElement>(null);
const locatorRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null);
@@ -75,6 +95,37 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid);
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (from && beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2;
const D = 9000; // lobe length (km)
const radial = (b: number): [number, number][] =>
Array.from({ length: 14 }, (_, i) => {
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
return [d.lat, d.lon] as [number, number];
});
for (const az of beamAzimuths) {
const arc: [number, number][] = [];
for (let b = az - half; b <= az + half + 0.001; b += 2) {
const d = destinationPoint(from.lat, from.lon, b, D);
arc.push([d.lat, d.lon]);
}
const ring = unwrapLon([
[from.lat, from.lon],
...radial(az - half),
...arc,
...radial(az + half).reverse(),
]);
L.polygon(ring as L.LatLngExpression[], {
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
}).addTo(wo);
// Boresight (dashed centre line).
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);
}
}
// ── Left: world + great-circle arc ──
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
if (to) {
@@ -108,7 +159,8 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
lm.setView([from.lat, from.lon], 5);
}
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
}, [fromGrid, toGrid, fromLabel, toLabel]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth]);
const path = pathBetween(fromGrid, toGrid);