feat(ftmap): the live decode feed as a world map — feature branch
A new FT Map tab (Tools menu, beside FT decodes): every station decoded in the last half hour drawn as a great-circle arc from the operator's own square, coloured by band the way PSK Reporter taught everyone to read it, fading with age, one arc per call (its freshest sighting), a tooltip with call/grid/SNR, the MainMap basemap picker and a legend of only the bands on screen. Performance was the open question and it is answered structurally: the panel mounts only while its tab is ACTIVE (zero cost otherwise), the map renders canvas (one element, not one node per arc), arcs are capped at 300, and redraws follow the decode list — every period, not every frame.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead';
|
||||
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// 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.
|
||||
|
||||
export type FTMapDecode = {
|
||||
call: string;
|
||||
grid?: string;
|
||||
band?: string;
|
||||
snr: number;
|
||||
at: string;
|
||||
};
|
||||
|
||||
// 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 }: { decodes: FTMapDecode[]; myGrid: string }) {
|
||||
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;
|
||||
const m = L.map(divRef.current, {
|
||||
zoomControl: true, attributionControl: true,
|
||||
worldCopyJump: true, preferCanvas: true,
|
||||
center: [25, 0], zoom: 2, minZoom: 2,
|
||||
});
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
return () => { 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: false };
|
||||
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);
|
||||
const pts = 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);
|
||||
L.circleMarker([to.lat, to.lon], {
|
||||
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
||||
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
|
||||
.addTo(layer);
|
||||
}
|
||||
}, [decodes, myGrid]);
|
||||
|
||||
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||
return (
|
||||
<div className="relative 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user