The world map and the grid-square map shared a single key, so picking satellite imagery to look at grids repainted the main map too, and there was no way to have terrain on one and streets on the other. They are different maps answering different questions, and the imagery that suits one is not the imagery that suits the next. Four keys now, one per map, in lib/mapBase beside the remembered views — named in one place so a rename cannot silently orphan somebody's choice — and portable, so a copied data folder brings them along. The grid map inherits whatever was set under the old shared key rather than being reset to the default: an operator who chose imagery there keeps it.
239 lines
12 KiB
TypeScript
239 lines
12 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';
|
|
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
|
|
|
// 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);
|
|
saveMapBase(MAP_BASE_FT, 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>
|
|
);
|
|
}
|