From 755392e0faef5b6ee8e8f5f3bd9b383e16eb6b99 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 30 Aug 2026 21:54:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(ftmap):=20the=20live=20decode=20feed=20as?= =?UTF-8?q?=20a=20world=20map=20=E2=80=94=20feature=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 38 +++++++ frontend/src/components/FTMapPanel.tsx | 146 +++++++++++++++++++++++++ frontend/src/lib/i18n.tsx | 4 +- 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/FTMapPanel.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b721428..ea149af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -75,6 +75,7 @@ import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; import { AutoEQSL } from '@/components/qsl/AutoEQSL'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; +import { FTMapPanel } from '@/components/FTMapPanel'; import { FirstRunModal } from '@/components/FirstRunModal'; import { QSOEditModal } from '@/components/QSOEditModal'; import { BandMap } from '@/components/BandMap'; @@ -1313,6 +1314,17 @@ export default function App() { writeUiPref('opslog.gridsTab', '0'); setActiveTab((t) => (t === 'grids' ? 'recent' : t)); } + const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1'); + function openFtmapTab() { + setFtmapTabOpen(true); + writeUiPref('opslog.ftmapTab', '1'); + setActiveTab('ftmap'); + } + function closeFtmapTab() { + setFtmapTabOpen(false); + writeUiPref('opslog.ftmapTab', '0'); + setActiveTab((t) => (t === 'ftmap' ? 'recent' : t)); + } function openDecodesTab() { setDecodesTabOpen(true); writeUiPref('opslog.decodesTab', '1'); @@ -5110,6 +5122,7 @@ export default function App() { { type: 'item', label: t('stats.tab'), action: 'tools.stats' }, { type: 'item', label: t('station.title'), action: 'tools.station' }, { type: 'item', label: t('dec.tab'), action: 'tools.decodes' }, + { type: 'item', label: t('ftmap.tab'), action: 'tools.ftmap' }, { type: 'item', label: t('gsm.title'), action: 'tools.grids' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'separator' }, @@ -5164,6 +5177,7 @@ export default function App() { case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break; case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break; case 'tools.decodes': openDecodesTab(); break; + case 'tools.ftmap': openFtmapTab(); break; case 'tools.grids': openGridsTab(); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; @@ -7795,6 +7809,21 @@ export default function App() { )} + {ftmapTabOpen && ( + + {t('ftmap.tab')} + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); closeFtmapTab(); }} + > + + + + )} {stationTabOpen && ( {t('station.title')} @@ -8393,6 +8422,15 @@ export default function App() { )} + {ftmapTabOpen && ( + + {activeTab === 'ftmap' && ( +
+ +
+ )} +
+ )} {stationTabOpen && ( , 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 = { + '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(null); + const mapRef = useRef(null); + const layerRef = useRef(null); + const baseRef = useRef(null); + const labelsRef = useRef(null); + const [basemap, setBasemap] = useState(() => + (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(); + 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 ( +
+
+ {/* Basemap picker, MainMap's own vocabulary. */} +
+ {(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => ( + + ))} +
+ {/* Band legend — only the bands actually on screen. */} + {bands.length > 0 && ( +
+ {bands.map((b) => ( + + + {b.toUpperCase()} + + ))} +
+ )} + {!gridToLatLon(myGrid) && ( +
+ + {t('ftmap.noGrid')} + +
+ )} +
+ ); +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 08c8af1..fbea394 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -162,7 +162,7 @@ const en: Dict = { 'mx.tipThisCall': 'already worked with this callsign', 'mx.tipThisCallConf': 'already confirmed with this callsign', // FTx decodes panel (Tools -> FT decodes) - 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only', + 'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only', 'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents', 'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message', 'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX', @@ -682,7 +682,7 @@ const fr: Dict = { 'mx.tipThisCall': 'déjà contacté avec cet indicatif', 'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif', // Panneau des decodes FTx (Outils -> Decodes FT) - 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement', + 'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement', 'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents', 'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message', 'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX',