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:
2026-08-30 21:54:00 +02:00
parent a9fb428a0a
commit 755392e0fa
3 changed files with 186 additions and 2 deletions
+38
View File
@@ -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() {
</span>
</TabsTrigger>
)}
{ftmapTabOpen && (
<TabsTrigger value="ftmap" className="gap-1.5">
{t('ftmap.tab')}
<span
role="button"
aria-label="Close FT map"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeFtmapTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{stationTabOpen && (
<TabsTrigger value="station" className="gap-1.5">
{t('station.title')}
@@ -8393,6 +8422,15 @@ export default function App() {
</TabsContent>
)}
{ftmapTabOpen && (
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && (
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid} />
</div>
)}
</TabsContent>
)}
{stationTabOpen && (
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<StationControlPanel