map
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||
|
||||
@@ -32,6 +32,8 @@ interface Props {
|
||||
currentFreqHz: number;
|
||||
onSpotClick: (s: Spot) => void;
|
||||
onClose?: () => void;
|
||||
side?: 'left' | 'right';
|
||||
onToggleSide?: () => void;
|
||||
}
|
||||
|
||||
const BAND_RANGES: Record<string, [number, number]> = {
|
||||
@@ -144,7 +146,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// last; ties broken by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) {
|
||||
const range = BAND_RANGES[band];
|
||||
const segments = SEGMENT_COLORS[band] ?? [];
|
||||
const [zoomIdx, setZoomIdx] = useState(0);
|
||||
@@ -365,6 +367,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
title="Scroll to current rig frequency">
|
||||
<Crosshair className="size-3" />
|
||||
</button>
|
||||
{onToggleSide && (
|
||||
<button type="button" onClick={onToggleSide}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title={side === 'right' ? 'Move band map to the left' : 'Move band map to the right'}>
|
||||
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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: `<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],
|
||||
});
|
||||
}
|
||||
|
||||
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const locatorRef = useRef<HTMLDivElement>(null);
|
||||
const worldMap = useRef<L.Map | null>(null);
|
||||
const locatorMap = useRef<L.Map | null>(null);
|
||||
// Layers we add/remove as the QSO changes (kept separate from the basemap).
|
||||
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
||||
const locatorOverlay = useRef<L.LayerGroup | null>(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 (
|
||||
<div className="flex flex-col h-full min-h-0 gap-2 p-2">
|
||||
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
||||
<div className="relative rounded-lg overflow-hidden border border-border">
|
||||
<div ref={worldRef} className="absolute inset-0" />
|
||||
{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>
|
||||
<div className="relative 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user