The invisible circle that catches the clicks sits ON TOP of the dot, so it takes the hover as well — and the tooltip was bound only to the dot underneath. From the moment the stations became clickable, pointing at one said nothing: the callsign, square and report an operator reads off the map had simply gone.
238 lines
11 KiB
TypeScript
238 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import L from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
|
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
|
|
|
// FT Map — the live decode feed as geography: every station decoded in the
|
|
// last half hour, an arc from the operator's own square to theirs, coloured by
|
|
// band the way PSK Reporter taught everyone to read it. Wholly display: the
|
|
// decode list is the same one the FT decodes tab shows, and a station with no
|
|
// grid (never sent one in a CQ) simply cannot be placed and is not drawn.
|
|
//
|
|
// Performance is a design constraint, not an afterthought: the panel only
|
|
// exists while its tab is active (the parent unmounts it otherwise), the map
|
|
// renders with canvas (one <canvas>, not one DOM node per arc), the arcs are
|
|
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
|
|
// not per frame.
|
|
|
|
// The map is handed the SAME decode objects the list works from — it only reads
|
|
// a few of the fields. The rest travel with them so a click here can answer the
|
|
// station exactly as a double-click in the list does.
|
|
export type FTMapDecode = {
|
|
call: string;
|
|
grid?: string;
|
|
band?: string;
|
|
snr: number;
|
|
at: string;
|
|
[k: string]: unknown;
|
|
};
|
|
|
|
// The band palette every PSK Reporter user already knows, near enough.
|
|
const BAND_COLOURS: Record<string, string> = {
|
|
'160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff',
|
|
'30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166',
|
|
'12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044',
|
|
'2m': '#ff1493', '70cm': '#999900',
|
|
};
|
|
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
|
|
|
const MAX_ARCS = 300;
|
|
const MAX_AGE_MS = 30 * 60_000;
|
|
|
|
export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|
decodes: FTMapDecode[];
|
|
myGrid: string;
|
|
// One click takes the station, two answer it — the list's own gestures.
|
|
onSelect?: (d: FTMapDecode) => void;
|
|
onCall?: (d: FTMapDecode) => void;
|
|
}) {
|
|
// Held in refs so the redraw below does not have to list them as dependencies
|
|
// and rebuild every arc whenever the parent re-renders.
|
|
const selectRef = useRef(onSelect);
|
|
const callRef = useRef(onCall);
|
|
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
|
|
// Distinguishing the two gestures is ours to do: Leaflet fires click before
|
|
// dblclick and leaves the telling apart to the handler.
|
|
const clickTimer = useRef<number | undefined>(undefined);
|
|
useEffect(() => () => window.clearTimeout(clickTimer.current), []);
|
|
// Where this map was left. Panning and zooming it is the operator saying which
|
|
// part of the world they are working; throwing that away on every tab switch
|
|
// made it something to set up again rather than something to glance at.
|
|
const saved = useRef(loadMapView(MAP_VIEW_FT));
|
|
const { t } = useI18n();
|
|
const divRef = useRef<HTMLDivElement>(null);
|
|
const mapRef = useRef<L.Map | null>(null);
|
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
|
const baseRef = useRef<L.TileLayer | null>(null);
|
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
|
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
|
(localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite');
|
|
|
|
// The map itself, once.
|
|
useEffect(() => {
|
|
if (!divRef.current || mapRef.current) return;
|
|
// ONE world: noWrap tiles inside hard bounds, no side-by-side copies —
|
|
// and the space beyond the edge is the theme's own surface (style.css).
|
|
const m = L.map(divRef.current, {
|
|
zoomControl: true, attributionControl: true,
|
|
// No maxBounds: with the world smaller than the window the clamp
|
|
// dragged every zoom into a corner. noWrap tiles alone keep one world.
|
|
worldCopyJump: false, preferCanvas: true,
|
|
center: saved.current ? [saved.current.lat, saved.current.lon] : [25, 0],
|
|
zoom: saved.current ? saved.current.zoom : 2,
|
|
minZoom: 2,
|
|
});
|
|
// Every move, not just the deliberate ones: a zoom is as much a choice as a
|
|
// pan, and there is no moment afterwards at which to ask.
|
|
m.on('moveend', () => {
|
|
const c = m.getCenter();
|
|
saveMapView(MAP_VIEW_FT, c.lat, c.lng, m.getZoom());
|
|
});
|
|
mapRef.current = m;
|
|
layerRef.current = L.layerGroup().addTo(m);
|
|
// Leaflet measures its container ONCE, when the map is created, and then
|
|
// draws tiles for that size for ever. This panel is mounted the moment its
|
|
// tab is selected — before the flex layout has settled — and the window can
|
|
// be resized under it, so the stale measurement showed as a strip of dead
|
|
// space along the bottom where tiles were never asked for. The observer
|
|
// hands it the real size whenever the box changes.
|
|
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
|
ro.observe(divRef.current);
|
|
// Once more after the first paint: the first observation can arrive while
|
|
// the panel is still zero-height, and no further resize follows a layout
|
|
// that settles by itself.
|
|
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
|
|
return () => {
|
|
window.clearTimeout(settle);
|
|
ro.disconnect();
|
|
m.remove();
|
|
mapRef.current = null;
|
|
layerRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
// Basemap follows the picker.
|
|
useEffect(() => {
|
|
const m = mapRef.current;
|
|
if (!m) return;
|
|
baseRef.current?.remove(); labelsRef.current?.remove();
|
|
const bm = BASEMAPS[basemap];
|
|
const opts: L.TileLayerOptions = {
|
|
maxNativeZoom: bm.maxNativeZoom,
|
|
noWrap: true,
|
|
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
|
|
};
|
|
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
|
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
|
localStorage.setItem('opslog.ftmapBase', basemap);
|
|
}, [basemap]);
|
|
|
|
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
|
// on top; opacity falls with age so the map reads as "now" with a memory.
|
|
useEffect(() => {
|
|
const layer = layerRef.current;
|
|
if (!layer) return;
|
|
layer.clearLayers();
|
|
const from = gridToLatLon(myGrid);
|
|
if (!from) return;
|
|
const now = Date.now();
|
|
const placed = decodes
|
|
.filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS)
|
|
.slice(-MAX_ARCS);
|
|
// One line per CALL (its freshest sighting): the same CQer decoded thirty
|
|
// times in ten minutes is one path on the air, not thirty strokes of it.
|
|
const byCall = new Map<string, FTMapDecode>();
|
|
for (const d of placed) byCall.set(d.call.toUpperCase(), d);
|
|
L.circleMarker([from.lat, from.lon], {
|
|
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
|
|
}).addTo(layer);
|
|
for (const d of byCall.values()) {
|
|
const to = gridToLatLon(d.grid!);
|
|
if (!to) continue;
|
|
const age = now - Date.parse(d.at);
|
|
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
|
const colour = bandColour(d.band);
|
|
// Cut at the antimeridian: this map shows ONE world, so a path running
|
|
// past ±180 has to leave one edge and come back at the other. Without it
|
|
// every arc out of VK or ZL was drawn into the blank space off the side
|
|
// of the map, its far end sitting alone on the opposite coast.
|
|
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
|
L.polyline(pts as L.LatLngExpression[][], {
|
|
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
|
}).addTo(layer);
|
|
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
|
|
const mk = L.circleMarker([to.lat, to.lon], {
|
|
// A three-pixel dot is a fine mark and a poor target, so the visible
|
|
// radius stays and an invisible one three times the size takes the
|
|
// clicks.
|
|
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
|
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
|
// The tooltip goes on the HIT circle too, and it is the one that matters:
|
|
// being on top, it takes the hover as well as the click, and binding it
|
|
// only to the dot underneath left the map silent from the moment the dots
|
|
// became clickable — the callsign and report an operator reads by pointing
|
|
// at a station had simply gone.
|
|
const hit = L.circleMarker([to.lat, to.lon], {
|
|
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
|
|
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
|
for (const target of [mk, hit]) {
|
|
target.on('click', (e) => {
|
|
// Not to the map: a click on a station is not a click on the water.
|
|
L.DomEvent.stopPropagation(e as unknown as Event);
|
|
window.clearTimeout(clickTimer.current);
|
|
clickTimer.current = window.setTimeout(() => selectRef.current?.(d), 250);
|
|
});
|
|
target.on('dblclick', (e) => {
|
|
// stop(), not stopPropagation(): the map zooms on a double click, and
|
|
// answering a station is not a request to zoom in on it.
|
|
L.DomEvent.stop(e as unknown as Event);
|
|
window.clearTimeout(clickTimer.current);
|
|
callRef.current?.(d);
|
|
});
|
|
}
|
|
}
|
|
}, [decodes, myGrid]);
|
|
|
|
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
|
return (
|
|
// isolate: Leaflet stacks its panes and controls up to z-index 1000, which
|
|
// beat the app menus and the Settings dialog. A stacking context of our own
|
|
// keeps all of it inside this panel.
|
|
<div className="relative isolate z-0 h-full w-full min-h-0">
|
|
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
|
{/* Basemap picker, MainMap's own vocabulary. */}
|
|
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
|
<button key={k} type="button" onClick={() => setBasemap(k)}
|
|
className={cn('px-2 py-0.5 rounded text-[11px]',
|
|
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
|
|
{BASEMAPS[k].label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/* Band legend — only the bands actually on screen. */}
|
|
{bands.length > 0 && (
|
|
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
|
{bands.map((b) => (
|
|
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
|
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
|
{b.toUpperCase()}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!gridToLatLon(myGrid) && (
|
|
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
|
|
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
|
|
{t('ftmap.noGrid')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|