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',