From 1c8aadb339cdb2cb6755d41af09d34eda5ddbca3 Mon Sep 17 00:00:00 2001
From: gadgethd <111318106+gadgethd@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:17:28 +0100
Subject: [PATCH] Expand owner, packet, and map experiences
---
frontend/package-lock.json | 80 +++++
frontend/package.json | 4 +
frontend/src/App.tsx | 168 ++++++++--
.../components/FilterPanel/FilterPanel.tsx | 2 +
frontend/src/components/LazyChartMount.tsx | 33 ++
frontend/src/components/LoadingIndicator.tsx | 3 +
frontend/src/components/Map/DeckGLOverlay.tsx | 165 +++++++++-
.../components/Map/LinkQualitySparkline.tsx | 35 +++
.../components/Map/LiveOverlayController.tsx | 35 ++-
frontend/src/components/Map/MapLibreMap.tsx | 295 ++++++++++++------
.../src/components/Map/NodePopupContent.tsx | 28 +-
frontend/src/components/Map/NodeSearch.tsx | 116 +++++--
.../src/components/Map/geojsonBuilders.ts | 32 ++
frontend/src/components/Map/mapConfig.ts | 3 +-
frontend/src/components/Map/types.ts | 1 +
.../components/ObserverRegistrationForm.tsx | 41 +++
frontend/src/components/PacketDetailPanel.tsx | 2 +
frontend/src/components/PacketFeed.tsx | 121 ++++---
frontend/src/components/app/AppTopBar.tsx | 40 +++
.../src/components/app/MobileControls.tsx | 28 +-
.../src/components/app/WatchlistPanel.tsx | 17 +-
.../components/owner/OwnerPortalSections.tsx | 79 +++++
frontend/src/config/mapModes.ts | 4 +
frontend/src/hooks/useAppMessageHandler.ts | 9 +-
frontend/src/hooks/useDashboardStats.ts | 1 +
frontend/src/hooks/useNodes.ts | 26 +-
frontend/src/hooks/usePacketDetailData.ts | 152 +++++++++
frontend/src/hooks/useWebSocket.ts | 6 +-
frontend/src/main.tsx | 24 ++
frontend/src/pages/OwnerPortalPage.tsx | 96 +++---
frontend/src/pages/SpamTransparencyPage.tsx | 62 +++-
frontend/src/pages/StatsPage.tsx | 26 +-
frontend/src/pages/StatusPage.tsx | 33 +-
frontend/src/pages/TopologyPage.tsx | 68 +++-
frontend/src/pages/network-intelligence.css | 7 +
frontend/src/pages/owner-portal.css | 31 ++
frontend/src/pages/shared/SiteLayout.tsx | 15 +
frontend/src/pages/site-content.css | 44 +++
frontend/src/pages/site-shell.css | 12 +
frontend/src/pages/spam-page.css | 10 +
frontend/src/pages/stats-page.css | 21 ++
.../src/pages/ukmesh/PacketDetailPanel.tsx | 149 +--------
frontend/src/pages/ukmesh/UKCompanionPage.tsx | 8 +
frontend/src/pages/ukmesh/UKFeedPage.tsx | 66 ++--
frontend/src/pages/ukmesh/UKHomePage.tsx | 47 ++-
frontend/src/pages/ukmesh/UKInstallPage.tsx | 39 ++-
frontend/src/pages/ukmesh/UKLayout.tsx | 1 +
.../src/pages/ukmesh/UKRepeaterSearchPage.tsx | 50 ++-
frontend/src/pages/ukmesh/feed-page.css | 9 +
frontend/src/store/overlayStore.ts | 8 +-
frontend/src/styles/globals.css | 43 +++
frontend/src/styles/map-app.css | 89 +++++-
frontend/src/styles/tokens.css | 71 +++++
frontend/src/utils/mapUrlState.ts | 2 +
54 files changed, 2074 insertions(+), 483 deletions(-)
create mode 100644 frontend/src/components/LazyChartMount.tsx
create mode 100644 frontend/src/components/Map/LinkQualitySparkline.tsx
create mode 100644 frontend/src/components/ObserverRegistrationForm.tsx
create mode 100644 frontend/src/components/PacketDetailPanel.tsx
create mode 100644 frontend/src/components/owner/OwnerPortalSections.tsx
create mode 100644 frontend/src/hooks/usePacketDetailData.ts
create mode 100644 frontend/src/styles/tokens.css
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 2a09a7b..b710906 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,10 +8,13 @@
"name": "meshcore-analytics-frontend",
"version": "1.0.0",
"dependencies": {
+ "@deck.gl/aggregation-layers": "^9.3.7",
"@deck.gl/core": "^9.1.0",
"@deck.gl/extensions": "^9.1.0",
"@deck.gl/layers": "^9.1.0",
"@deck.gl/mapbox": "^9.1.0",
+ "d3-force": "^3.0.0",
+ "fuse.js": "^7.5.0",
"maplibre-gl": "^4.7.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1",
@@ -23,6 +26,7 @@
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@playwright/test": "^1.61.1",
+ "@types/d3-force": "^3.0.10",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
@@ -314,6 +318,24 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@deck.gl/aggregation-layers": {
+ "version": "9.3.7",
+ "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.3.7.tgz",
+ "integrity": "sha512-eRzddMlHuBBFeSfhBig5V/32psav5JLvRYjuMqHgcwWXKfanAcxsKBfDSA4tzwWpnDU4id9sfdGW1RDzbVgY5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@luma.gl/shadertools": "^9.3.3",
+ "@math.gl/core": "^4.1.0",
+ "@math.gl/web-mercator": "^4.1.0",
+ "d3-hexbin": "^0.2.1"
+ },
+ "peerDependencies": {
+ "@deck.gl/core": "~9.3.0",
+ "@deck.gl/layers": "~9.3.0",
+ "@luma.gl/core": "~9.3.3",
+ "@luma.gl/engine": "~9.3.3"
+ }
+ },
"node_modules/@deck.gl/core": {
"version": "9.3.6",
"resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.3.6.tgz",
@@ -1510,6 +1532,13 @@
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -1900,6 +1929,15 @@
"node": ">=12"
}
},
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -1908,6 +1946,20 @@
"node": ">=12"
}
},
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -1916,6 +1968,12 @@
"node": ">=12"
}
},
+ "node_modules/d3-hexbin": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz",
+ "integrity": "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -1935,6 +1993,15 @@
"node": ">=12"
}
},
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -2148,6 +2215,19 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/fuse.js": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.5.0.tgz",
+ "integrity": "sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/krisk"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 0711c97..28c74fe 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,10 +12,13 @@
"test:mobile": "playwright test mobile.spec.ts --project=mobile-regression"
},
"dependencies": {
+ "@deck.gl/aggregation-layers": "^9.3.7",
"@deck.gl/core": "^9.1.0",
"@deck.gl/extensions": "^9.1.0",
"@deck.gl/layers": "^9.1.0",
"@deck.gl/mapbox": "^9.1.0",
+ "d3-force": "^3.0.0",
+ "fuse.js": "^7.5.0",
"maplibre-gl": "^4.7.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1",
@@ -29,6 +32,7 @@
"@playwright/test": "^1.61.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "@types/d3-force": "^3.0.10",
"@vitejs/plugin-react": "^4.3.3",
"tsx": "^4.23.1",
"typescript": "^5.6.3",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e44f7d3..7ba5de6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -38,6 +38,7 @@ const DEFAULT_FILTERS: Filters = {
terrain: false,
clientNodes: false,
packetHistory: false,
+ heatmap: false,
betaPaths: false,
betaPathThreshold: 0.45,
hexClashes: false,
@@ -46,6 +47,10 @@ const DEFAULT_FILTERS: Filters = {
const DISCLAIMER_KEY = 'meshcore-disclaimer-dismissed';
const FILTERS_KEY = 'meshcore-app-filters-v3';
+const LEGACY_FILTERS_KEY = 'meshcore-app-filters-v2';
+const FILTERS_VERSION = 3;
+const MAP_FETCH_TIMEOUT_MS = 4_000;
+const OTHER_FETCH_TIMEOUT_MS = 15_000;
const ignoreCoverageUpdate = () => {};
const TimelineControl = React.lazy(() => import('./components/app/TimelineControl.js').then((module) => ({ default: module.TimelineControl })));
const PlannerComparison = React.lazy(() => import('./components/app/PlannerComparison.js').then((module) => ({ default: module.PlannerComparison })));
@@ -55,8 +60,23 @@ export const App: React.FC = () => {
const [filters, setFilters] = useState(() => {
let stored = DEFAULT_FILTERS;
try {
- const raw = localStorage.getItem(FILTERS_KEY);
- if (raw) stored = { ...DEFAULT_FILTERS, ...JSON.parse(raw) as Partial, betaPathThreshold: 0.45 };
+ const raw = localStorage.getItem(FILTERS_KEY) ?? localStorage.getItem(LEGACY_FILTERS_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw) as { version?: number; filters?: Partial } & Partial;
+ const version = typeof parsed.version === 'number'
+ ? parsed.version
+ : (localStorage.getItem(FILTERS_KEY) ? FILTERS_VERSION : 2);
+ const migrated = version === FILTERS_VERSION
+ ? (parsed.filters ?? parsed)
+ : version === 2
+ ? (parsed.filters ?? parsed)
+ : null;
+ if (migrated) {
+ stored = { ...DEFAULT_FILTERS, ...migrated, betaPathThreshold: 0.45 };
+ localStorage.setItem(FILTERS_KEY, JSON.stringify({ version: FILTERS_VERSION, filters: stored }));
+ localStorage.removeItem(LEGACY_FILTERS_KEY);
+ }
+ }
} catch {
stored = DEFAULT_FILTERS;
}
@@ -72,8 +92,11 @@ export const App: React.FC = () => {
});
const [initialMapView] = useState(() => initialMapViewFromUrl());
const [shareLabel, setShareLabel] = useState('Copy view link');
+ const [annotation, setAnnotation] = useState(() => new URLSearchParams(window.location.search).get('note') ?? '');
const [selectedNodeId, setSelectedNodeId] = useState(() => new URLSearchParams(window.location.search).get('node'));
const [filtersCollapsed, setFiltersCollapsed] = useState(() => !!new URLSearchParams(window.location.search).get('node'));
+ const [fullScreenMap, setFullScreenMap] = useState(false);
+ const [highContrast, setHighContrast] = useState(() => localStorage.getItem('meshcore-contrast') === 'high');
// MapLibre map instance — used by MobileControls/NodeSearch for flyTo
const [mlMap, setMlMap] = useState(null);
const [showDisclaimer, setShowDisclaimer] = useState(() => !localStorage.getItem(DISCLAIMER_KEY));
@@ -84,22 +107,27 @@ export const App: React.FC = () => {
const [initialStateLoaded, setInitialStateLoaded] = useState(false);
const [initialPollLoaded, setInitialPollLoaded] = useState(false);
const [pollRefreshing, setPollRefreshing] = useState(false);
+ const [mapLight, setMapLight] = useState(() => localStorage.getItem('map-theme') === 'light');
+ const [showShortcutGuide, setShowShortcutGuide] = useState(false);
const [isPageVisible, setIsPageVisible] = useState(
() => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'),
);
const clashRestoreRef = useRef<{ clientNodes: boolean } | null>(null);
const prevHexClashesRef = useRef(DEFAULT_FILTERS.hexClashes);
+ const wsConnectedRef = useRef(false);
- const networkFilter = site.networkFilter;
+ const requestedNetwork = new URLSearchParams(window.location.search).get('network');
+ const networkFilter = requestedNetwork === 'teesside' || requestedNetwork === 'ukmesh'
+ ? requestedNetwork
+ : site.networkFilter;
const observerFilter = site.observerId;
const stats = useDashboardStats(fetchedStats);
useEffect(() => {
- if ('serviceWorker' in navigator) {
- navigator.serviceWorker.register('/sw.js').catch(() => {});
- }
- }, []);
+ document.documentElement.dataset.contrast = highContrast ? 'high' : 'standard';
+ localStorage.setItem('meshcore-contrast', highContrast ? 'high' : 'standard');
+ }, [highContrast]);
useEffect(() => {
if (typeof document === 'undefined') return undefined;
@@ -109,7 +137,7 @@ export const App: React.FC = () => {
}, []);
useEffect(() => {
- localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
+ localStorage.setItem(FILTERS_KEY, JSON.stringify({ version: FILTERS_VERSION, filters }));
writeFiltersToUrl(filters, activeMode);
}, [activeMode, filters]);
@@ -162,6 +190,31 @@ export const App: React.FC = () => {
window.setTimeout(() => setShareLabel('Copy view link'), 1800);
}, [mlMap]);
+ const handleEditAnnotation = useCallback(() => {
+ const next = window.prompt('Add a short annotation to this shared map URL:', annotation);
+ if (next === null) return;
+ const clean = next.trim().slice(0, 240);
+ setAnnotation(clean);
+ const url = new URL(window.location.href);
+ if (clean) url.searchParams.set('note', clean);
+ else url.searchParams.delete('note');
+ window.history.replaceState(null, '', url);
+ }, [annotation]);
+
+ const handleMapThemeToggle = useCallback(() => {
+ setMapLight((current) => {
+ const next = !current;
+ localStorage.setItem('map-theme', next ? 'light' : 'dark');
+ return next;
+ });
+ }, []);
+
+ const handleNetworkChange = useCallback((network: string) => {
+ const url = new URL(window.location.href);
+ url.searchParams.set('network', network);
+ window.location.assign(url);
+ }, []);
+
// Keep the fast poll to live data that changes independently of the socket.
// Expensive inferred/path overlays use their own, slower conditional polls.
useEffect(() => {
@@ -171,7 +224,10 @@ export const App: React.FC = () => {
const scheduleNext = () => {
if (cancelled || !isPageVisible) return;
- timer = window.setTimeout(() => { void syncLiveData(); }, 10_000);
+ timer = window.setTimeout(
+ () => { void syncLiveData(); },
+ wsConnectedRef.current ? 60_000 : 10_000,
+ );
};
const syncLiveData = async () => {
@@ -182,10 +238,12 @@ export const App: React.FC = () => {
try {
const [packetsRes, statsRes] = await Promise.allSettled([
fetch(uncachedEndpoint(withScopeParams('/api/packets/recent?limit=12', { network: networkFilter, observer: observerFilter })), {
- cache: 'no-store', signal: controller.signal,
+ cache: 'no-store',
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(MAP_FETCH_TIMEOUT_MS)]),
}),
fetch(uncachedEndpoint(withScopeParams('/api/stats', { network: networkFilter, observer: observerFilter })), {
- cache: 'no-store', signal: controller.signal,
+ cache: 'no-store',
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(MAP_FETCH_TIMEOUT_MS)]),
}),
]);
@@ -241,7 +299,10 @@ export const App: React.FC = () => {
try {
const response = await fetch(
uncachedEndpoint(withScopeParams('/api/inferred-nodes', { network: networkFilter, observer: observerFilter })),
- { cache: 'no-store', signal: controller.signal },
+ {
+ cache: 'no-store',
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(OTHER_FETCH_TIMEOUT_MS)]),
+ },
);
if (!response.ok || cancelled) return;
const payload = await response.json() as {
@@ -270,7 +331,7 @@ export const App: React.FC = () => {
}, [isPageVisible, networkFilter, observerFilter]);
useEffect(() => {
- if (!filters.packetHistory) {
+ if (!filters.packetHistory && !filters.heatmap) {
setPacketHistorySegments([]);
return undefined;
}
@@ -290,7 +351,10 @@ export const App: React.FC = () => {
try {
const response = await fetch(
uncachedEndpoint(withScopeParams('/api/path-beta/multibyte-paths', { network: networkFilter, observer: observerFilter })),
- { cache: 'no-store', signal: controller.signal },
+ {
+ cache: 'no-store',
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(OTHER_FETCH_TIMEOUT_MS)]),
+ },
);
if (!response.ok || cancelled) return;
const payload = await response.json() as { segments?: PacketHistorySegment[] };
@@ -311,7 +375,7 @@ export const App: React.FC = () => {
controller?.abort();
if (timer) window.clearTimeout(timer);
};
- }, [filters.packetHistory, isPageVisible, networkFilter, observerFilter]);
+ }, [filters.heatmap, filters.packetHistory, isPageVisible, networkFilter, observerFilter]);
useEffect(() => {
const wasHexClashes = prevHexClashesRef.current;
@@ -335,6 +399,7 @@ export const App: React.FC = () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind, message, stack, page: window.location.href, userAgent: navigator.userAgent }),
+ signal: AbortSignal.timeout(OTHER_FETCH_TIMEOUT_MS),
}).catch(() => {});
};
@@ -383,14 +448,50 @@ export const App: React.FC = () => {
});
const wsState = useWebSocket(handleMessage, { network: networkFilter, observer: observerFilter });
+ wsConnectedRef.current = wsState === 'connected';
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ const target = event.target as HTMLElement | null;
+ const isEditing = target?.matches('input, textarea, select, [contenteditable="true"]');
+ if (event.key === '?' && !isEditing) {
+ event.preventDefault();
+ setShowShortcutGuide((current) => !current);
+ } else if (event.key === 'Escape') {
+ setShowShortcutGuide(false);
+ setSelectedNodeId(null);
+ } else if (!isEditing && event.key.toLowerCase() === 'f') {
+ event.preventDefault();
+ window.dispatchEvent(new Event('meshcore:focus-search'));
+ } else if (!isEditing && event.key.toLowerCase() === 's') {
+ event.preventDefault();
+ void handleShare();
+ } else if (!isEditing && event.key.toLowerCase() === 't') {
+ setFilters((current) => ({ ...current, terrain: !current.terrain }));
+ } else if (!isEditing && event.key.toLowerCase() === 'l') {
+ setFilters((current) => ({ ...current, betaPaths: !current.betaPaths }));
+ }
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [handleShare]);
return (
-
+
setShowDisclaimer(true)}
stats={stats}
+ mapLight={mapLight}
+ onToggleMapTheme={handleMapThemeToggle}
+ network={networkFilter ?? 'ukmesh'}
+ onNetworkChange={handleNetworkChange}
+ annotation={annotation}
+ onEditAnnotation={handleEditAnnotation}
+ onShowShortcuts={() => setShowShortcutGuide(true)}
+ highContrast={highContrast}
+ onToggleContrast={() => setHighContrast((value) => !value)}
/>
{
onShare={handleShare}
shareLabel={shareLabel}
onNodeSelect={setSelectedNodeId}
+ fullScreenMap={fullScreenMap}
+ onToggleFullScreenMap={() => setFullScreenMap((value) => !value)}
/>
@@ -419,6 +522,7 @@ export const App: React.FC = () => {
selectedNodeId={selectedNodeId}
onNodeSelect={setSelectedNodeId}
onMapReady={setMlMap}
+ mapLight={mapLight}
/>
{
observer={observerFilter}
packetHistorySegments={packetHistorySegments}
/>
- {(!initialStateLoaded || !initialPollLoaded) && (
+ {(!initialStateLoaded && !initialPollLoaded) && (
)}
@@ -440,7 +544,7 @@ export const App: React.FC = () => {
)}
- {
collapsed={filtersCollapsed}
onToggleCollapse={() => setFiltersCollapsed((value) => !value)}
nodeOpen={selectedNodeId != null}
- />
+ />}
{VIEWSHED_ENABLED && }
- {filters.livePackets && }
+ {filters.livePackets && !fullScreenMap && }
+
+ {annotation && (
+
+ {annotation}
+
+ )}
+
+ {showShortcutGuide && (
+ setShowShortcutGuide(false)}>
+
event.stopPropagation()}>
+
Keyboard shortcuts setShowShortcutGuide(false)} aria-label="Close">×
+
+
Esc Close panels or cancel a map tool
+
F Focus node search
+
S Copy the current map link
+
T Toggle 3D terrain
+
L Toggle Live Path
+
? Show or hide this guide
+
+
+
+ )}
{showDisclaimer && }
diff --git a/frontend/src/components/FilterPanel/FilterPanel.tsx b/frontend/src/components/FilterPanel/FilterPanel.tsx
index 626d76b..312eaae 100644
--- a/frontend/src/components/FilterPanel/FilterPanel.tsx
+++ b/frontend/src/components/FilterPanel/FilterPanel.tsx
@@ -10,6 +10,7 @@ export interface Filters {
terrain: boolean;
clientNodes: boolean;
packetHistory: boolean;
+ heatmap: boolean;
betaPaths: boolean;
betaPathThreshold: number; // 0–1
hexClashes: boolean;
@@ -40,6 +41,7 @@ export const FILTER_ROWS: Array<{ key: keyof Filters; label: string; color: stri
{ key: 'livePackets', label: 'Live Feed', color: '#00c4ff' },
{ key: 'terrain', label: '3D Terrain', color: '#60a5fa' },
{ key: 'packetHistory', label: 'Paths', color: '#00c4ff', hollow: true },
+ { key: 'heatmap', label: 'Packet Heatmap', color: '#ef4444' },
{ key: 'betaPaths', label: 'Live Path', color: '#a855f7', hollow: true },
{ key: 'hexClashes', label: 'Hex Clashes', color: '#f97316' },
{ key: 'clientNodes', label: 'Companion / Room', color: '#ff9800' },
diff --git a/frontend/src/components/LazyChartMount.tsx b/frontend/src/components/LazyChartMount.tsx
new file mode 100644
index 0000000..c63a1cd
--- /dev/null
+++ b/frontend/src/components/LazyChartMount.tsx
@@ -0,0 +1,33 @@
+import React, { memo, useEffect, useRef, useState } from 'react';
+
+const LazyChartMount: React.FC<{ children: React.ReactNode; minHeight?: number }> = ({
+ children,
+ minHeight = 220,
+}) => {
+ const rootRef = useRef
(null);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const root = rootRef.current;
+ if (!root || visible) return;
+ if (!('IntersectionObserver' in window)) {
+ setVisible(true);
+ return;
+ }
+ const observer = new IntersectionObserver(([entry]) => {
+ if (!entry?.isIntersecting) return;
+ setVisible(true);
+ observer.disconnect();
+ }, { rootMargin: '300px 0px' });
+ observer.observe(root);
+ return () => observer.disconnect();
+ }, [visible]);
+
+ return (
+
+ {visible ? children :
}
+
+ );
+};
+
+export default memo(LazyChartMount);
diff --git a/frontend/src/components/LoadingIndicator.tsx b/frontend/src/components/LoadingIndicator.tsx
index 1eb43cf..85a8278 100644
--- a/frontend/src/components/LoadingIndicator.tsx
+++ b/frontend/src/components/LoadingIndicator.tsx
@@ -6,13 +6,16 @@ type LoadingIndicatorProps = {
label?: string;
variant?: LoadingIndicatorVariant;
className?: string;
+ visible?: boolean;
};
export const LoadingIndicator: React.FC = ({
label = 'Loading...',
variant = 'block',
className = '',
+ visible = true,
}) => {
+ if (!visible) return null;
const cls = ['loading-indicator', `loading-indicator--${variant}`, className]
.filter(Boolean)
.join(' ');
diff --git a/frontend/src/components/Map/DeckGLOverlay.tsx b/frontend/src/components/Map/DeckGLOverlay.tsx
index c40ac65..d298dd0 100644
--- a/frontend/src/components/Map/DeckGLOverlay.tsx
+++ b/frontend/src/components/Map/DeckGLOverlay.tsx
@@ -7,11 +7,12 @@
*/
import React, { useEffect, useRef, useMemo } from 'react';
import { MapboxOverlay } from '@deck.gl/mapbox';
-import { ArcLayer, LineLayer, PathLayer, ScatterplotLayer } from '@deck.gl/layers';
+import { ArcLayer, LineLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
+import { HeatmapLayer } from '@deck.gl/aggregation-layers';
import { PathStyleExtension } from '@deck.gl/extensions';
import type { PathStyleExtensionProps } from '@deck.gl/extensions';
import type { Layer } from '@deck.gl/core';
-import type maplibregl from 'maplibre-gl';
+import maplibregl from 'maplibre-gl';
import type { PacketArc } from '../../hooks/useNodes.js';
import type { HiddenMaskGeometry } from '../../utils/pathing.js';
import { maskPoint } from '../../utils/pathing.js';
@@ -31,6 +32,14 @@ type HistorySegmentWithColor = HistorySegment & {
width: number;
};
+type ConsistencySegment = {
+ positions: [[number, number], [number, number]];
+ midpoint: [number, number];
+ observerCount: number;
+};
+
+type HeatPoint = { position: [number, number]; weight: number };
+
interface Props {
map: maplibregl.Map | null;
@@ -41,6 +50,7 @@ interface Props {
// Packet path history (link-segment heat map)
packetHistorySegments: HistorySegment[];
showPacketHistory: boolean;
+ showHeatmap: boolean;
// Beta path overlays
betaPaths: [number, number][][];
@@ -49,6 +59,10 @@ interface Props {
clashPathLines: ClashPathLine[];
showBetaPaths: boolean;
pathFadingOut: boolean;
+ betaConfidence: number | null;
+ pathObserverCount: number;
+ pathAlternatives: number;
+ pathSummary: string | null;
hiddenCoordMask: Map;
positionElevations: Map;
@@ -108,6 +122,9 @@ function buildLayers(
losProfiles: LosProfile[] | null,
customLosSegments: CustomLosSegment[],
customLosStart: CustomLosPoint | null,
+ consistencySegments: ConsistencySegment[],
+ onPathSegmentClick: (segment: ConsistencySegment) => void,
+ showHeatmap: boolean,
): Layer[] {
const now = Date.now();
const layers: Layer[] = [];
@@ -173,6 +190,30 @@ function buildLayers(
);
}
+ if (showHeatmap && packetHistorySegments.length > 0) {
+ const heatPoints: HeatPoint[] = packetHistorySegments.flatMap((segment) => [
+ { position: [segment.positions[0][1], segment.positions[0][0]], weight: segment.count / 2 },
+ { position: [segment.positions[1][1], segment.positions[1][0]], weight: segment.count / 2 },
+ ]);
+ layers.push(new HeatmapLayer({
+ id: 'packet-heatmap',
+ data: heatPoints,
+ getPosition: (point) => point.position,
+ getWeight: (point) => point.weight,
+ radiusPixels: 45,
+ intensity: 1.2,
+ threshold: 0.03,
+ colorRange: [
+ [0, 196, 255, 0],
+ [0, 196, 255, 130],
+ [251, 191, 36, 190],
+ [249, 115, 22, 220],
+ [239, 68, 68, 245],
+ [255, 255, 255, 255],
+ ],
+ }));
+ }
+
// ── Beta path overlays ─────────────────────────────────────────────────────
if (showBetaPaths) {
const targetOpacity = pathFadingOut ? 0 : 1;
@@ -234,6 +275,54 @@ function buildLayers(
}),
);
}
+
+ if (consistencySegments.length > 0) {
+ const badgeColor = (count: number): [number, number, number, number] => {
+ const ratio = count / Math.max(1, Math.max(...consistencySegments.map((segment) => segment.observerCount)));
+ if (ratio >= 0.75 && count > 1) return [34, 197, 94, 245];
+ if (ratio >= 0.4 && count > 1) return [251, 191, 36, 245];
+ return [239, 68, 68, 245];
+ };
+ layers.push(
+ new LineLayer({
+ id: 'path-consistency-hit-targets',
+ data: consistencySegments,
+ getSourcePosition: (segment) => toXYZ(segment.positions[0], hiddenCoordMask, positionElevations, useTerrainElevation),
+ getTargetPosition: (segment) => toXYZ(segment.positions[1], hiddenCoordMask, positionElevations, useTerrainElevation),
+ getColor: [0, 0, 0, 1],
+ getWidth: 14,
+ widthUnits: 'pixels',
+ pickable: true,
+ onClick: ({ object }) => { if (object) onPathSegmentClick(object); },
+ }),
+ new ScatterplotLayer({
+ id: 'path-consistency-badges',
+ data: consistencySegments,
+ getPosition: (segment) => toXYZ(segment.midpoint, hiddenCoordMask, positionElevations, useTerrainElevation),
+ getFillColor: (segment) => badgeColor(segment.observerCount),
+ getLineColor: [255, 255, 255, 230],
+ getRadius: 9,
+ radiusUnits: 'pixels',
+ stroked: true,
+ lineWidthUnits: 'pixels',
+ getLineWidth: 1.5,
+ pickable: true,
+ onClick: ({ object }) => { if (object) onPathSegmentClick(object); },
+ }),
+ new TextLayer({
+ id: 'path-consistency-labels',
+ data: consistencySegments,
+ getPosition: (segment) => toXYZ(segment.midpoint, hiddenCoordMask, positionElevations, useTerrainElevation),
+ getText: (segment) => String(segment.observerCount),
+ getColor: [255, 255, 255, 255],
+ getSize: 10,
+ sizeUnits: 'pixels',
+ getTextAnchor: 'middle',
+ getAlignmentBaseline: 'center',
+ pickable: false,
+ }),
+ );
+ }
}
// ── Hex clash paths ───────────────────────────────────────────────────────
@@ -351,8 +440,13 @@ export const DeckGLOverlay: React.FC = ({
map,
arcs, showArcs,
packetHistorySegments, showPacketHistory,
+ showHeatmap,
betaPaths, betaLowSegments, betaCompletionPaths, clashPathLines,
showBetaPaths, pathFadingOut,
+ betaConfidence,
+ pathObserverCount,
+ pathAlternatives,
+ pathSummary,
hiddenCoordMask,
positionElevations,
useTerrainElevation,
@@ -361,6 +455,67 @@ export const DeckGLOverlay: React.FC = ({
customLosStart,
}) => {
const overlayRef = useRef(null);
+ const explanationPopupRef = useRef(null);
+
+ const consistencySegments = useMemo(() => {
+ const counts = new Map();
+ const coordKey = (point: [number, number]) => `${point[0].toFixed(5)},${point[1].toFixed(5)}`;
+ for (const path of betaPaths) {
+ const seenForObserver = new Set();
+ for (let index = 0; index < path.length - 1; index += 1) {
+ const a = path[index]!;
+ const b = path[index + 1]!;
+ const aKey = coordKey(a);
+ const bKey = coordKey(b);
+ const key = aKey < bKey ? `${aKey}|${bKey}` : `${bKey}|${aKey}`;
+ if (seenForObserver.has(key)) continue;
+ seenForObserver.add(key);
+ const existing = counts.get(key);
+ if (existing) existing.observerCount += 1;
+ else counts.set(key, {
+ positions: [a, b],
+ midpoint: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
+ observerCount: 1,
+ });
+ }
+ }
+ return Array.from(counts.values());
+ }, [betaPaths]);
+
+ const onPathSegmentClick = useMemo(() => (segment: ConsistencySegment) => {
+ if (!map) return;
+ explanationPopupRef.current?.remove();
+ const container = document.createElement('div');
+ container.className = 'path-explanation-popover';
+ const title = document.createElement('strong');
+ title.textContent = 'Path segment evidence';
+ const details = document.createElement('dl');
+ const rows: Array<[string, string]> = [
+ ['Confidence', betaConfidence == null ? 'N/A' : `${Math.round(betaConfidence * 100)}%`],
+ ['Observers', `${segment.observerCount} confirming${pathObserverCount ? ` of ${pathObserverCount}` : ''}`],
+ ['Evidence', `${segment.observerCount} independent path${segment.observerCount === 1 ? '' : 's'}`],
+ ['Top alternatives', String(pathAlternatives)],
+ ];
+ for (const [label, value] of rows) {
+ const row = document.createElement('div');
+ const term = document.createElement('dt');
+ const description = document.createElement('dd');
+ term.textContent = label;
+ description.textContent = value;
+ row.append(term, description);
+ details.append(row);
+ }
+ container.append(title, details);
+ if (pathSummary) {
+ const summary = document.createElement('p');
+ summary.textContent = pathSummary;
+ container.append(summary);
+ }
+ explanationPopupRef.current = new maplibregl.Popup({ closeButton: true, maxWidth: '290px' })
+ .setLngLat([segment.midpoint[1], segment.midpoint[0]])
+ .setDOMContent(container)
+ .addTo(map);
+ }, [betaConfidence, map, pathAlternatives, pathObserverCount, pathSummary]);
// Create/destroy the MapboxOverlay when the map instance changes
useEffect(() => {
@@ -372,6 +527,7 @@ export const DeckGLOverlay: React.FC = ({
overlayRef.current = overlay;
return () => {
+ explanationPopupRef.current?.remove();
map.removeControl(overlay as unknown as maplibregl.IControl);
overlayRef.current = null;
};
@@ -389,11 +545,14 @@ export const DeckGLOverlay: React.FC = ({
losProfiles,
customLosSegments,
customLosStart,
+ consistencySegments,
+ onPathSegmentClick,
+ showHeatmap,
),
[arcs, showArcs, packetHistorySegments, showPacketHistory,
betaPaths, betaLowSegments, betaCompletionPaths, clashPathLines,
showBetaPaths, pathFadingOut, hiddenCoordMask, positionElevations, useTerrainElevation, losProfiles,
- customLosSegments, customLosStart],
+ customLosSegments, customLosStart, consistencySegments, onPathSegmentClick, showHeatmap],
);
// Push updated layers to the overlay imperatively
diff --git a/frontend/src/components/Map/LinkQualitySparkline.tsx b/frontend/src/components/Map/LinkQualitySparkline.tsx
new file mode 100644
index 0000000..33ec147
--- /dev/null
+++ b/frontend/src/components/Map/LinkQualitySparkline.tsx
@@ -0,0 +1,35 @@
+import { memo, useEffect, useState } from 'react';
+import { Line, LineChart, ResponsiveContainer, Tooltip, YAxis } from 'recharts';
+
+type Point = { time: string; snr: number | null; path_loss: number | null };
+
+export const LinkQualitySparkline = memo(function LinkQualitySparkline({
+ source,
+ target,
+}: {
+ source: string;
+ target: string;
+}) {
+ const [points, setPoints] = useState([]);
+ useEffect(() => {
+ const controller = new AbortController();
+ fetch(`/api/links/${encodeURIComponent(`${source}:${target}`)}/history?hours=72`, { signal: controller.signal })
+ .then((response) => response.ok ? response.json() as Promise<{ points?: Point[] }> : { points: [] })
+ .then((value) => setPoints(value.points ?? []))
+ .catch(() => {});
+ return () => controller.abort();
+ }, [source, target]);
+ if (points.length === 0) return No 72h radio samples ;
+ return (
+
+
+
+
+ new Date(String(value)).toLocaleString()} />
+
+
+
+
+
+ );
+});
diff --git a/frontend/src/components/Map/LiveOverlayController.tsx b/frontend/src/components/Map/LiveOverlayController.tsx
index 3e489e5..e34f67b 100644
--- a/frontend/src/components/Map/LiveOverlayController.tsx
+++ b/frontend/src/components/Map/LiveOverlayController.tsx
@@ -35,12 +35,31 @@ export const LiveOverlayController: React.FC = ({
const customLosSegments = useOverlayStore((state) => state.customLosSegments);
const customLosStart = useOverlayStore((state) => state.customLosStart);
const clashPathLines = useOverlayStore((state) => state.clashPathLines);
+ const losProfilesKey = useMemo(() => Object.entries(losProfilesByNodeId)
+ .map(([nodeId, profiles]) => `${nodeId}:${profiles.length}:${profiles.map((profile) => `${profile.peer_id}:${profile.profile.length}:${profile.itm_viable ? 1 : 0}`).join(',')}`)
+ .sort()
+ .join('|'), [losProfilesByNodeId]);
const losProfiles = useMemo(
() => Object.values(losProfilesByNodeId).flat(),
- [losProfilesByNodeId],
+ // The key avoids flattening again when Zustand produces an equivalent
+ // record while unrelated overlay state changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [losProfilesKey],
);
const nodes = useNodeMap();
- const hiddenCoordMask = useMemo(() => buildHiddenCoordMask(nodes.values()), [nodes]);
+ const nodeCoordinateKey = useMemo(() => Array.from(nodes.values())
+ .map((node) => `${node.node_id}:${node.lat ?? ''}:${node.lon ?? ''}:${node.name?.includes('🚫') ? 1 : 0}`)
+ .sort()
+ .join('|'), [nodes]);
+ const nodeElevationKey = useMemo(() => Array.from(nodes.values())
+ .map((node) => `${node.node_id}:${node.lat ?? ''}:${node.lon ?? ''}:${node.elevation_m ?? 0}`)
+ .sort()
+ .join('|'), [nodes]);
+ const hiddenCoordMask = useMemo(
+ () => buildHiddenCoordMask(nodes.values()),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [nodeCoordinateKey],
+ );
const positionElevations = useMemo(() => {
const elevations = new Map();
for (const node of nodes.values()) {
@@ -51,9 +70,11 @@ export const LiveOverlayController: React.FC = ({
elevations.set(positionKey(maskedLat, maskedLon), elevation);
}
return elevations;
- }, [hiddenCoordMask, nodes]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hiddenCoordMask, nodeElevationKey]);
const setPathNodeIds = useOverlayStore((state) => state.setPathNodeIds);
const setBetaMetrics = useOverlayStore((state) => state.setBetaMetrics);
+ const pathExplanation = useOverlayStore((state) => state.pathExplanation);
const {
packetPaths,
@@ -88,7 +109,8 @@ export const LiveOverlayController: React.FC = ({
else index.set(key, new Set([node.node_id.toLowerCase()]));
}
return index;
- }, [nodes]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [nodeCoordinateKey]);
const pathNodeIdsPrevRef = React.useRef | null>(null);
const pathNodeIds = useMemo | null>(() => {
@@ -159,11 +181,16 @@ export const LiveOverlayController: React.FC = ({
showArcs={filters.livePackets}
packetHistorySegments={packetHistorySegments}
showPacketHistory={filters.packetHistory}
+ showHeatmap={filters.heatmap}
betaPaths={renderedPaths}
betaLowSegments={betaLowConfidenceSegments}
betaCompletionPaths={betaCompletionPaths}
clashPathLines={clashPathLines}
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
+ betaConfidence={betaPathConfidence}
+ pathObserverCount={activePacketSnapshot?.observerIds.length ?? 0}
+ pathAlternatives={pathExplanation?.alternativesConsidered ?? betaPermutationCount ?? 0}
+ pathSummary={pathExplanation?.summary ?? null}
pathFadingOut={pathFadingOut}
hiddenCoordMask={hiddenCoordMask}
positionElevations={positionElevations}
diff --git a/frontend/src/components/Map/MapLibreMap.tsx b/frontend/src/components/Map/MapLibreMap.tsx
index f6a2f19..2bf5848 100644
--- a/frontend/src/components/Map/MapLibreMap.tsx
+++ b/frontend/src/components/Map/MapLibreMap.tsx
@@ -10,7 +10,6 @@
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import maplibregl from 'maplibre-gl';
-import 'maplibre-gl/dist/maplibre-gl.css';
import type { MeshNode } from '../../hooks/useNodes.js';
import { nodeStore } from '../../hooks/useNodes.js';
import { coverageStore, type NodeCoverage } from '../../hooks/useCoverage.js';
@@ -27,6 +26,7 @@ import {
DEFAULT_CENTER,
DEFAULT_ZOOM,
EMPTY_FC,
+ MAP_ARC_REFRESH_INTERVAL_MS,
MAP_REFRESH_INTERVAL_MS,
MAP_STYLE,
MAP_STYLE_LIGHT,
@@ -44,13 +44,17 @@ import {
buildPlannedPinGeoJSON,
buildPrivacyRingsGeoJSON,
computeClashData,
+ ALL_MAP_SOURCE_DIRTY_FLAGS,
+ mergeMapSourceDirtyFlags,
} from './geojsonBuilders.js';
+import type { MapSourceDirtyFlags } from './geojsonBuilders.js';
import { NodePopupContent } from './NodePopupContent.js';
import { NodeLegend } from './NodeLegend.js';
import { ActivitySparkline } from './ActivitySparkline.js';
import { PlannedRepeaterPopup } from './PlannedRepeaterPopup.js';
import { useWatchlist } from '../../hooks/useWatchlist.js';
import type {
+ ClashComputation,
CustomLosPoint,
LosProfile,
MapLibreMapProps,
@@ -62,6 +66,34 @@ import type {
import { sampleElevationAt } from '../../utils/terrainSampler.js';
import { computeCustomLos } from '../../utils/customLos.js';
+const NODE_LINK_CACHE_TTL_MS = 5 * 60_000;
+const nodeLinksCache = new Map }>();
+
+function fetchNodeLinks(nodeId: string): Promise {
+ const cached = nodeLinksCache.get(nodeId);
+ if (cached?.rows && Date.now() - (cached.fetchedAt ?? 0) < NODE_LINK_CACHE_TTL_MS) {
+ return Promise.resolve(cached.rows);
+ }
+ if (cached?.pending) return cached.pending;
+
+ const pending = fetch(`/api/nodes/${encodeURIComponent(nodeId)}/links`, {
+ signal: AbortSignal.timeout(15_000),
+ })
+ .then(async (response) => {
+ if (!response.ok) throw new Error(`links request failed: ${response.status}`);
+ const payload = await response.json() as NodeLink[];
+ const rows = Array.isArray(payload) ? payload : [];
+ nodeLinksCache.set(nodeId, { rows, fetchedAt: Date.now() });
+ return rows;
+ })
+ .catch((error) => {
+ nodeLinksCache.delete(nodeId);
+ throw error;
+ });
+ nodeLinksCache.set(nodeId, { ...cached, pending });
+ return pending;
+}
+
// ── Main Component ────────────────────────────────────────────────────────────
export function MapLibreMap({
@@ -77,6 +109,7 @@ export function MapLibreMap({
selectedNodeId = null,
onNodeSelect,
onMapReady,
+ mapLight,
}: MapLibreMapProps) {
const containerRef = useRef(null);
const mapRef = useRef(null);
@@ -98,6 +131,14 @@ export function MapLibreMap({
const setClashPathLines = useOverlayStore((state) => state.setClashPathLines);
const hiddenCoordMaskRef = useRef>(new Map());
const refreshTimerRef = useRef(null);
+ const lastArcActivityRef = useRef(0);
+ const dirtyFlagsRef = useRef({ ...ALL_MAP_SOURCE_DIRTY_FLAGS });
+ const clashRef = useRef({
+ clashOffenderNodeIds: new Set(),
+ clashRelayIds: new Set(),
+ clashPathLines: [],
+ clashModeActive: false,
+ });
const selectedNodeIdRef = useRef(selectedNodeId);
const onNodeSelectRef = useRef(onNodeSelect);
onNodeSelectRef.current = onNodeSelect;
@@ -134,18 +175,20 @@ export function MapLibreMap({
const watchlist = useWatchlist();
const [copyLinkLabel, setCopyLinkLabel] = useState('Copy link');
- // -- Map theme (light/dark) -------------------------------------------------
- const [mapLight, setMapLight] = useState(() => localStorage.getItem('map-theme') === 'light');
+ // MapLibre's stylesheet is loaded only when the map component mounts, keeping
+ // map-specific CSS out of non-map entry points.
+ useEffect(() => {
+ void import('maplibre-gl/dist/maplibre-gl.css');
+ }, []);
- const toggleMapTheme = useCallback(() => {
- setMapLight((prev) => {
- const next = !prev;
- localStorage.setItem('map-theme', next ? 'light' : 'dark');
+ // -- Map theme (light/dark) -------------------------------------------------
+ useEffect(() => {
const map = mapRef.current;
if (map && mapLoadedRef.current) {
- const oldId = next ? 'carto-dark' : 'carto-light';
- const newId = next ? 'carto-light' : 'carto-dark';
- const variant = next ? 'light_all' : 'dark_all';
+ const oldId = mapLight ? 'carto-dark' : 'carto-light';
+ const newId = mapLight ? 'carto-light' : 'carto-dark';
+ const variant = mapLight ? 'light_all' : 'dark_all';
+ if (map.getSource(newId)) return;
if (map.getLayer('background')) map.removeLayer('background');
if (map.getLayer('bg-fill')) map.removeLayer('bg-fill');
if (map.getSource(oldId)) map.removeSource(oldId);
@@ -162,7 +205,7 @@ export function MapLibreMap({
// Insert bg-fill + basemap at the very bottom
const firstLayer = map.getStyle().layers[0]?.id;
map.addLayer(
- { id: 'bg-fill', type: 'background', paint: { 'background-color': next ? '#e8e8e8' : '#080d14' } },
+ { id: 'bg-fill', type: 'background', paint: { 'background-color': mapLight ? '#e8e8e8' : '#080d14' } },
firstLayer,
);
map.addLayer(
@@ -170,9 +213,7 @@ export function MapLibreMap({
map.getStyle().layers[1]?.id, // after bg-fill, before everything else
);
}
- return next;
- });
- }, []);
+ }, [mapLight]);
// -- LOS profiles (client-side, multi-node, auto-expire) -------------------
@@ -216,8 +257,7 @@ export function MapLibreMap({
const ANTENNA_H = 10;
const EXAG = TERRAIN_CONFIG.exaggeration;
try {
- const links = await fetch(`/api/nodes/${nodeId}/links`)
- .then((r) => r.json()) as NodeLink[];
+ const links = await fetchNodeLinks(nodeId);
const sourceNode = nodesRef.current.get(nodeId);
if (!sourceNode || !hasCoords(sourceNode)) {
setLosProfilesForNode(nodeId, []);
@@ -524,71 +564,104 @@ export function MapLibreMap({
const refreshMapSources = useCallback(() => {
if (!mapLoadedRef.current || !mapRef.current) return;
+ const dirty = dirtyFlagsRef.current;
+ dirtyFlagsRef.current = {
+ nodes: false,
+ privacy: false,
+ links: false,
+ coverage: false,
+ clash: false,
+ plannedLinks: false,
+ };
const nodes = nodesRef.current;
const coverage = viewshedEnabled ? coverageRef.current : [];
const viablePairsArr = viablePairsRef.current;
const linkMetrics = linkMetricsRef.current;
const currentPathNodeIds = pathNodeIdsRef.current;
- const currentHiddenCoordMask = buildHiddenMask(nodes);
- hiddenCoordMaskRef.current = currentHiddenCoordMask;
+ const maskDirty = dirty.nodes || dirty.privacy || dirty.links || dirty.clash || dirty.plannedLinks;
+ const currentHiddenCoordMask = maskDirty ? buildHiddenMask(nodes) : hiddenCoordMaskRef.current;
+ if (maskDirty) hiddenCoordMaskRef.current = currentHiddenCoordMask;
- const clash = computeClashData(
- nodes,
- coverage,
- viablePairsArr,
- linkMetrics,
- showHexClashesRef.current,
- maxHexClashHopsRef.current,
- focusedNodeId,
- focusedPrefixNodeIds,
- );
+ const clashDirty = dirty.clash || dirty.nodes || dirty.coverage || dirty.links;
+ const clash = clashDirty
+ ? computeClashData(
+ nodes,
+ coverage,
+ viablePairsArr,
+ linkMetrics,
+ showHexClashesRef.current,
+ maxHexClashHopsRef.current,
+ focusedNodeId,
+ focusedPrefixNodeIds,
+ )
+ : clashRef.current;
+ if (clashDirty) clashRef.current = clash;
- const nodeGeoJSON = buildNodeGeoJSON(
- nodes,
- currentHiddenCoordMask,
- showClientNodesRef.current,
- showLinksRef.current,
- new Set(viablePairsArr.flatMap(([aId, bId]) => [aId.toLowerCase(), bId.toLowerCase()])),
- clash.clashOffenderNodeIds,
- clash.clashRelayIds,
- clash.clashModeActive,
- clash.clashModeActive ? null : currentPathNodeIds,
- replayNodeIdsRef.current,
- );
- (mapRef.current.getSource('nodes') as maplibregl.GeoJSONSource | undefined)?.setData(nodeGeoJSON);
+ if (dirty.nodes || clashDirty) {
+ const nodeGeoJSON = buildNodeGeoJSON(
+ nodes,
+ currentHiddenCoordMask,
+ showClientNodesRef.current,
+ showLinksRef.current,
+ new Set(viablePairsArr.flatMap(([aId, bId]) => [aId.toLowerCase(), bId.toLowerCase()])),
+ clash.clashOffenderNodeIds,
+ clash.clashRelayIds,
+ clash.clashModeActive,
+ clash.clashModeActive ? null : currentPathNodeIds,
+ replayNodeIdsRef.current,
+ );
+ (mapRef.current.getSource('nodes') as maplibregl.GeoJSONSource | undefined)?.setData(nodeGeoJSON);
+ }
- const privacyGeoJSON = buildPrivacyRingsGeoJSON(nodes, currentHiddenCoordMask);
- (mapRef.current.getSource('privacy-rings') as maplibregl.GeoJSONSource | undefined)?.setData(privacyGeoJSON);
+ if (dirty.privacy || dirty.nodes) {
+ const privacyGeoJSON = buildPrivacyRingsGeoJSON(nodes, currentHiddenCoordMask);
+ (mapRef.current.getSource('privacy-rings') as maplibregl.GeoJSONSource | undefined)?.setData(privacyGeoJSON);
+ }
- const linksGeoJSON = showLinksRef.current
- ? buildLinksGeoJSON(nodes, viablePairsArr, linkMetrics, currentHiddenCoordMask)
- : EMPTY_FC;
- (mapRef.current.getSource('viable-links') as maplibregl.GeoJSONSource | undefined)?.setData(linksGeoJSON);
- mapRef.current.setLayoutProperty('viable-links-layer', 'visibility', showLinksRef.current ? 'visible' : 'none');
+ if (dirty.links || dirty.nodes) {
+ const linksGeoJSON = showLinksRef.current
+ ? buildLinksGeoJSON(nodes, viablePairsArr, linkMetrics, currentHiddenCoordMask)
+ : EMPTY_FC;
+ (mapRef.current.getSource('viable-links') as maplibregl.GeoJSONSource | undefined)?.setData(linksGeoJSON);
+ mapRef.current.setLayoutProperty('viable-links-layer', 'visibility', showLinksRef.current ? 'visible' : 'none');
+ }
- const coverageGeoJSON = viewshedEnabled && selectedCoverageRef.current && !clash.clashModeActive
- ? buildCoverageGeoJSON([selectedCoverageRef.current])
- : EMPTY_FC;
- (mapRef.current.getSource('coverage') as maplibregl.GeoJSONSource | undefined)?.setData(coverageGeoJSON);
- mapRef.current.setLayoutProperty('coverage-fill', 'visibility',
- viewshedEnabled && selectedCoverageRef.current && !clash.clashModeActive ? 'visible' : 'none');
+ if (dirty.coverage || clashDirty) {
+ const coverageGeoJSON = viewshedEnabled && selectedCoverageRef.current && !clash.clashModeActive
+ ? buildCoverageGeoJSON([selectedCoverageRef.current])
+ : EMPTY_FC;
+ (mapRef.current.getSource('coverage') as maplibregl.GeoJSONSource | undefined)?.setData(coverageGeoJSON);
+ mapRef.current.setLayoutProperty('coverage-fill', 'visibility',
+ viewshedEnabled && selectedCoverageRef.current && !clash.clashModeActive ? 'visible' : 'none');
+ }
- setClashPathLines(clash.clashModeActive ? clash.clashPathLines : []);
- (mapRef.current.getSource('clash-lines') as maplibregl.GeoJSONSource | undefined)?.setData(EMPTY_FC);
- mapRef.current.setLayoutProperty('clash-lines-layer', 'visibility', 'none');
+ if (clashDirty) {
+ setClashPathLines(clash.clashModeActive ? clash.clashPathLines : []);
+ (mapRef.current.getSource('clash-lines') as maplibregl.GeoJSONSource | undefined)?.setData(EMPTY_FC);
+ mapRef.current.setLayoutProperty('clash-lines-layer', 'visibility', 'none');
+ }
- // Rebuild planned-link lines against the freshly-updated node positions/mask.
- updatePlannedLinks();
+ if (dirty.plannedLinks || dirty.nodes || dirty.links) updatePlannedLinks();
}, [viewshedEnabled, focusedNodeId, focusedPrefixNodeIds, setClashPathLines, updatePlannedLinks]);
- const scheduleRefresh = useCallback(() => {
+ const scheduleRefresh = useCallback((flags: Partial = ALL_MAP_SOURCE_DIRTY_FLAGS) => {
+ dirtyFlagsRef.current = mergeMapSourceDirtyFlags(dirtyFlagsRef.current, flags);
if (refreshTimerRef.current !== null) return;
+ const interval = Date.now() - lastArcActivityRef.current < 5_000
+ ? MAP_ARC_REFRESH_INTERVAL_MS
+ : MAP_REFRESH_INTERVAL_MS;
refreshTimerRef.current = window.setTimeout(() => {
refreshTimerRef.current = null;
refreshMapSources();
- }, MAP_REFRESH_INTERVAL_MS);
+ }, interval);
}, [refreshMapSources]);
+ useEffect(() => {
+ const noteArcActivity = () => { lastArcActivityRef.current = Date.now(); };
+ window.addEventListener('meshcore:packet-observed', noteArcActivity);
+ return () => window.removeEventListener('meshcore:packet-observed', noteArcActivity);
+ }, []);
+
useEffect(() => {
viewshedEnabledRef.current = viewshedEnabled;
if (!viewshedEnabled) {
@@ -646,7 +719,7 @@ export function MapLibreMap({
const map = new maplibregl.Map({
container: containerRef.current,
- style: localStorage.getItem('map-theme') === 'light' ? MAP_STYLE_LIGHT : MAP_STYLE,
+ style: mapLight ? MAP_STYLE_LIGHT : MAP_STYLE,
center: initialView ? [initialView.lon, initialView.lat] : [DEFAULT_CENTER[1], DEFAULT_CENTER[0]],
zoom: initialView?.zoom ?? DEFAULT_ZOOM,
maxPitch: 0,
@@ -699,6 +772,52 @@ export function MapLibreMap({
},
});
+ // Observer quality is a separate, cached overlay so live node refreshes do
+ // not rebuild health metrics. A coloured ring keeps the role colour intact.
+ map.addSource('observer-health', { type: 'geojson', data: EMPTY_FC });
+ map.addLayer({
+ id: 'observer-health-rings',
+ type: 'circle',
+ source: 'observer-health',
+ paint: {
+ 'circle-radius': [
+ 'interpolate', ['linear'], ['zoom'],
+ 6, 5.5, 9, 7, 11, 8.5, 13, 11, 16, 14,
+ ],
+ 'circle-color': 'rgba(0,0,0,0)',
+ 'circle-stroke-width': 2,
+ 'circle-stroke-color': [
+ 'match', ['get', 'quality'],
+ 'good', '#22c55e',
+ 'watch', '#fbbf24',
+ '#ef4444',
+ ],
+ 'circle-stroke-opacity': 0.95,
+ },
+ });
+ void fetch('/api/observers/health', { signal: AbortSignal.timeout(10_000) })
+ .then((response) => response.ok ? response.json() as Promise> : [])
+ .then((observers) => {
+ const source = map.getSource('observer-health') as maplibregl.GeoJSONSource | undefined;
+ source?.setData({
+ type: 'FeatureCollection',
+ features: observers.map((observer) => ({
+ type: 'Feature',
+ geometry: { type: 'Point', coordinates: [observer.lon, observer.lat] },
+ properties: {
+ node_id: observer.node_id,
+ name: observer.name,
+ score: observer.score,
+ quality: observer.quality,
+ },
+ })),
+ });
+ })
+ .catch(() => {});
+
// ── Selected-node highlight (recolour + ring) ──────────────────────────
// Two circle layers over node-dots, filtered to the selected node id.
// A soft halo underneath, a bright solid marker on top.
@@ -1068,19 +1187,22 @@ export function MapLibreMap({
map.remove();
mapRef.current = null;
};
+ // Initial theme is captured on mount; subsequent theme changes are applied by
+ // the dedicated theme effect without rebuilding the MapLibre instance.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialView, onMapReady, onNodeSelect, refreshMapSources, setClashPathLines]);
// -- Imperative source updates ---------------------------------------------
useEffect(() => {
inferredNodesRef.current = inferredNodes;
- scheduleRefresh();
+ scheduleRefresh({ nodes: true });
}, [inferredNodes, scheduleRefresh]);
useEffect(() => {
showLinksRef.current = showLinks;
updatePlannedLinks();
- scheduleRefresh();
+ scheduleRefresh({ nodes: true, links: true, plannedLinks: true, clash: true });
}, [showLinks, scheduleRefresh, updatePlannedLinks]);
useEffect(() => {
@@ -1124,17 +1246,17 @@ export function MapLibreMap({
useEffect(() => {
showClientNodesRef.current = showClientNodes;
- scheduleRefresh();
+ scheduleRefresh({ nodes: true });
}, [showClientNodes, scheduleRefresh]);
useEffect(() => {
showHexClashesRef.current = showHexClashes;
- scheduleRefresh();
+ scheduleRefresh({ nodes: true, clash: true, coverage: true });
}, [showHexClashes, scheduleRefresh]);
useEffect(() => {
maxHexClashHopsRef.current = maxHexClashHops;
- scheduleRefresh();
+ scheduleRefresh({ nodes: true, clash: true, coverage: true });
}, [maxHexClashHops, scheduleRefresh]);
useEffect(() => {
@@ -1143,20 +1265,24 @@ export function MapLibreMap({
useEffect(() => {
const unsubscribeNodes = nodeStore.subscribe(() => {
- nodesRef.current = nodeStore.getState().nodes;
- scheduleRefresh();
+ const nextNodes = nodeStore.getState().nodes;
+ // Packet/message updates share the same node Map reference. Ignore those
+ // emissions so live feed traffic does not trigger a 3.6k-node rebuild.
+ if (nextNodes === nodesRef.current) return;
+ nodesRef.current = nextNodes;
+ scheduleRefresh({ nodes: true, privacy: true, links: true, clash: true, plannedLinks: true });
if (selectedNodeIdRef.current) setPopupVersion((value) => value + 1);
});
const unsubscribeCoverage = coverageStore.subscribe(() => {
if (!viewshedEnabledRef.current) return;
coverageRef.current = coverageStore.getState().coverage;
- scheduleRefresh();
+ scheduleRefresh({ coverage: true, clash: true, nodes: true });
});
const unsubscribeLinks = linkStateStore.subscribe(() => {
const linkState = linkStateStore.getState();
viablePairsRef.current = linkState.viablePairsArr;
linkMetricsRef.current = linkState.linkMetrics;
- scheduleRefresh();
+ scheduleRefresh({ links: true, clash: true, nodes: true, plannedLinks: true });
});
const unsubscribeOverlay = useOverlayStore.subscribe((overlayState) => {
if (
@@ -1165,7 +1291,7 @@ export function MapLibreMap({
) return;
pathNodeIdsRef.current = overlayState.pathNodeIds;
replayNodeIdsRef.current = overlayState.replayNodeIds;
- scheduleRefresh();
+ scheduleRefresh({ nodes: true });
});
return () => {
@@ -1177,7 +1303,7 @@ export function MapLibreMap({
}, [scheduleRefresh]);
useEffect(() => {
- scheduleRefresh();
+ scheduleRefresh({ nodes: true, clash: true });
}, [focusedNodeId, focusedPrefixNodeIds, scheduleRefresh]);
const toggleCoverageForNode = useCallback((nodeId: string) => {
@@ -1230,13 +1356,12 @@ export function MapLibreMap({
if (!selectedNodeId) { setPopupLinks(null); return; }
const node = getNode(selectedNodeId);
if (!node) return;
- const controller = new AbortController();
+ let cancelled = false;
setPopupLinks(null);
- fetch(`/api/nodes/${selectedNodeId}/links`, { signal: controller.signal })
- .then((r) => r.json() as Promise)
- .then((rows) => setPopupLinks(Array.isArray(rows) ? rows : []))
- .catch((err) => { if ((err as DOMException).name !== 'AbortError') setPopupLinks([]); });
- return () => controller.abort();
+ void fetchNodeLinks(selectedNodeId)
+ .then((rows) => { if (!cancelled) setPopupLinks(rows); })
+ .catch(() => { if (!cancelled) setPopupLinks([]); });
+ return () => { cancelled = true; };
}, [selectedNodeId, getNode]); // eslint-disable-line react-hooks/exhaustive-deps
// Highlight the selected node on the map (bright recolour + ring). Cleared
@@ -1357,18 +1482,6 @@ export function MapLibreMap({
Repeater
)}
- { e.stopPropagation(); toggleMapTheme(); }}
- onMouseDown={(e) => e.stopPropagation()}
- onMouseUp={(e) => e.stopPropagation()}
- >
- {mapLight
- ? <> Light>
- : <> Dark>
- }
-
{/* Custom LOS status hint */}
diff --git a/frontend/src/components/Map/NodePopupContent.tsx b/frontend/src/components/Map/NodePopupContent.tsx
index d7e3e5d..fd1068a 100644
--- a/frontend/src/components/Map/NodePopupContent.tsx
+++ b/frontend/src/components/Map/NodePopupContent.tsx
@@ -1,7 +1,8 @@
-import React from 'react';
+import React, { useState } from 'react';
import { LoadingIndicator } from '../LoadingIndicator.js';
import { NODE_STALE_AFTER_MS } from './mapConfig.js';
import type { NodeFeatureProps, NodeLink } from './types.js';
+import { LinkQualitySparkline } from './LinkQualitySparkline.js';
const GPU_ROLE_LABELS: Record = {
1: 'Companion Radio', 2: 'Repeater', 3: 'Room Server', 4: 'Sensor',
@@ -58,10 +59,19 @@ export const NodePopupContent: React.FC<{
const displayName = props.is_prohibited
? `Redacted ${fallbackName}`
: (props.name ?? `Unknown ${fallbackName}`);
+ const [tab, setTab] = useState<'info' | 'links' | 'activity' | 'path' | 'status'>('info');
return (
{!hideName &&
{displayName}
}
+
+ {(['info', 'links', 'activity', 'path', 'status'] as const).map((value) => (
+ setTab(value)}>
+ {value[0].toUpperCase() + value.slice(1)}
+
+ ))}
+
+ {tab === 'info' && <>
{props.public_key && (
Public key
@@ -110,6 +120,17 @@ export const NodePopupContent: React.FC<{
{Math.round(props.elevation_m)} m ASL
)}
+ >}
+ {tab === 'status' && <>
+
Status {statusLabel}
+ {props.hardware_model &&
Hardware {props.hardware_model}
}
+
Freshness {timeAgo(props.last_seen)}
+ >}
+ {tab === 'activity' && <>
+
Last seen {timeAgo(props.last_seen)}
+
Adverts {props.advert_count ?? 'No samples'}
+ >}
+ {tab === 'path' && <>
{viewshedEnabled && isRepeater && !props.is_prohibited && (
)}
+ >}
+ {tab === 'links' && <>
{links === null && (
@@ -172,11 +195,14 @@ export const NodePopupContent: React.FC<{
{lk.observed_count}× seen
{lk.itm_path_loss_db != null && <> · {Math.round(lk.itm_path_loss_db)} dB>}
+
);
})}
)}
+ {links !== null && links.length === 0 && No confirmed neighbours.
}
+ >}
);
};
diff --git a/frontend/src/components/Map/NodeSearch.tsx b/frontend/src/components/Map/NodeSearch.tsx
index b325408..edbc7ab 100644
--- a/frontend/src/components/Map/NodeSearch.tsx
+++ b/frontend/src/components/Map/NodeSearch.tsx
@@ -1,4 +1,5 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
+import Fuse from 'fuse.js';
import type maplibregl from 'maplibre-gl';
import type { MeshNode } from '../../hooks/useNodes.js';
import { useNodeMap } from '../../hooks/useNodes.js';
@@ -16,19 +17,37 @@ export const NodeSearch: React.FC = ({ map, onNodeSelect }) =>
const [open, setOpen] = useState(false);
const containerRef = useRef(null);
const watchlist = useWatchlist();
+ const [nearbyNodes, setNearbyNodes] = useState(null);
+ const [geoStatus, setGeoStatus] = useState(null);
+
+ const searchableNodes = useMemo(() => Array.from(nodes.values()).filter(
+ (node) => isValidMapCoord(node.lat, node.lon) && !node.name?.includes('🚫'),
+ ), [nodes]);
+ const fuse = useMemo(() => new Fuse(searchableNodes, {
+ keys: [
+ { name: 'name', weight: 0.55 },
+ { name: 'iata', weight: 0.3 },
+ { name: 'node_id', weight: 0.1 },
+ { name: 'public_key', weight: 0.05 },
+ ],
+ threshold: 0.35,
+ ignoreLocation: true,
+ includeScore: true,
+ }), [searchableNodes]);
const results = useMemo(() => {
- if (!query.trim()) return [];
- const q = query.toLowerCase();
- return Array.from(nodes.values())
- .filter((n) => {
- if (!isValidMapCoord(n.lat, n.lon)) return false;
- const nameMatch = n.name && !n.name.includes('🚫') && n.name.toLowerCase().includes(q);
- const keyMatch = n.public_key && n.public_key.toLowerCase().includes(q);
- return nameMatch || keyMatch;
- })
+ if (!query.trim()) return nearbyNodes ?? [];
+ const q = query.trim().toLowerCase();
+ const prefixMatches = searchableNodes.filter((node) =>
+ node.name?.toLowerCase().startsWith(q)
+ || node.iata?.toLowerCase().startsWith(q)
+ || node.node_id.toLowerCase().startsWith(q)
+ || node.public_key?.toLowerCase().startsWith(q),
+ );
+ const fuzzyMatches = fuse.search(q).map((result) => result.item);
+ return Array.from(new Map([...prefixMatches, ...fuzzyMatches].map((node) => [node.node_id, node])).values())
.slice(0, 6);
- }, [query, nodes]);
+ }, [fuse, nearbyNodes, query, searchableNodes]);
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -39,38 +58,85 @@ export const NodeSearch: React.FC = ({ map, onNodeSelect }) =>
return () => document.removeEventListener('mousedown', handler);
}, []);
+ useEffect(() => {
+ const focusSearch = () => {
+ const input = containerRef.current?.querySelector('input');
+ input?.focus();
+ setOpen(true);
+ };
+ window.addEventListener('meshcore:focus-search', focusSearch);
+ return () => window.removeEventListener('meshcore:focus-search', focusSearch);
+ }, []);
+
+ const findNearby = () => {
+ if (!navigator.geolocation) {
+ setGeoStatus('Location is not supported by this browser.');
+ setOpen(true);
+ return;
+ }
+ setGeoStatus('Requesting location permission…');
+ setOpen(true);
+ navigator.geolocation.getCurrentPosition(
+ ({ coords }) => {
+ const distanceKm = (node: MeshNode) => {
+ const lat1 = coords.latitude * Math.PI / 180;
+ const lat2 = node.lat! * Math.PI / 180;
+ const dLat = lat2 - lat1;
+ const dLon = (node.lon! - coords.longitude) * Math.PI / 180;
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
+ return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ };
+ const nearest = searchableNodes
+ .filter((node) => node.role === undefined || node.role === 2)
+ .sort((a, b) => distanceKm(a) - distanceKm(b))
+ .slice(0, 6);
+ setNearbyNodes(nearest);
+ setGeoStatus(nearest.length ? 'Nearest repeaters' : 'No nearby repeaters found.');
+ map?.flyTo({ center: [coords.longitude, coords.latitude], zoom: 11 });
+ },
+ (error) => setGeoStatus(error.code === error.PERMISSION_DENIED
+ ? 'Location permission was denied.'
+ : 'Unable to determine your location.'),
+ { enableHighAccuracy: false, timeout: 10_000, maximumAge: 300_000 },
+ );
+ };
+
const select = (node: MeshNode) => {
// MapLibre flyTo: center is [lon, lat]
map?.flyTo({ center: [node.lon!, node.lat!], zoom: 15 });
onNodeSelect?.(node.node_id);
setQuery('');
+ setNearbyNodes(null);
+ setGeoStatus(null);
setOpen(false);
};
return (
-
{ setQuery(e.target.value); setOpen(true); }}
- onFocus={() => { if (query) setOpen(true); }}
- onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }}
- />
- {open && results.length > 0 && (
+
+ { setQuery(e.target.value); setNearbyNodes(null); setGeoStatus(null); setOpen(true); }}
+ onFocus={() => { if (query || nearbyNodes) setOpen(true); }}
+ onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }}
+ />
+ ⌖
+
+ {open && (results.length > 0 || geoStatus) && (
+ {geoStatus &&
{geoStatus}
}
{results.map((node) => (
select(node)}>
{node.name}
- {node.public_key && (
- {node.public_key.slice(0, 8)}…
- )}
+ {node.iata ?? node.public_key?.slice(0, 8) ?? node.node_id.slice(0, 8)}
))}
-
{ watchlist.toggle('search', query.trim().toLowerCase(), query.trim()); setOpen(false); }}>
+ {query.trim() && { watchlist.toggle('search', query.trim().toLowerCase(), query.trim()); setOpen(false); }}>
{watchlist.isWatched('search', query.trim().toLowerCase()) ? '★ Saved search' : '☆ Save this search'}
-
+ }
)}
diff --git a/frontend/src/components/Map/geojsonBuilders.ts b/frontend/src/components/Map/geojsonBuilders.ts
index 1e9c217..92f7514 100644
--- a/frontend/src/components/Map/geojsonBuilders.ts
+++ b/frontend/src/components/Map/geojsonBuilders.ts
@@ -19,6 +19,38 @@ import {
} from './mapConfig.js';
import type { ClashComputation, NodeFeatureProps, PlannedRepeater } from './types.js';
+export type MapSourceDirtyFlags = {
+ nodes: boolean;
+ privacy: boolean;
+ links: boolean;
+ coverage: boolean;
+ clash: boolean;
+ plannedLinks: boolean;
+};
+
+export const ALL_MAP_SOURCE_DIRTY_FLAGS: MapSourceDirtyFlags = {
+ nodes: true,
+ privacy: true,
+ links: true,
+ coverage: true,
+ clash: true,
+ plannedLinks: true,
+};
+
+export function mergeMapSourceDirtyFlags(
+ current: MapSourceDirtyFlags,
+ incoming: Partial,
+): MapSourceDirtyFlags {
+ return {
+ nodes: current.nodes || !!incoming.nodes,
+ privacy: current.privacy || !!incoming.privacy,
+ links: current.links || !!incoming.links,
+ coverage: current.coverage || !!incoming.coverage,
+ clash: current.clash || !!incoming.clash,
+ plannedLinks: current.plannedLinks || !!incoming.plannedLinks,
+ };
+}
+
function circleLineString(
lat: number,
lon: number,
diff --git a/frontend/src/components/Map/mapConfig.ts b/frontend/src/components/Map/mapConfig.ts
index fb78cbf..0ab009d 100644
--- a/frontend/src/components/Map/mapConfig.ts
+++ b/frontend/src/components/Map/mapConfig.ts
@@ -6,7 +6,8 @@ export const DEFAULT_ZOOM = 11;
// stale nodes are still visible as stale instead of disappearing immediately.
export const NODE_STALE_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
export const NODE_HIDE_AFTER_MS = 28 * 24 * 60 * 60 * 1000;
-export const MAP_REFRESH_INTERVAL_MS = 100;
+export const MAP_REFRESH_INTERVAL_MS = 250;
+export const MAP_ARC_REFRESH_INTERVAL_MS = 100;
export const LINK_GREEN_THRESHOLD_DB = 121.5;
export const LINK_AMBER_THRESHOLD_DB = 129.5;
diff --git a/frontend/src/components/Map/types.ts b/frontend/src/components/Map/types.ts
index 5f3b2bc..5a90a45 100644
--- a/frontend/src/components/Map/types.ts
+++ b/frontend/src/components/Map/types.ts
@@ -81,6 +81,7 @@ export interface MapLibreMapProps {
selectedNodeId?: string | null;
onNodeSelect?: (nodeId: string | null) => void;
onMapReady?: (map: maplibregl.Map) => void;
+ mapLight: boolean;
}
export interface PopupNodeView {
diff --git a/frontend/src/components/ObserverRegistrationForm.tsx b/frontend/src/components/ObserverRegistrationForm.tsx
new file mode 100644
index 0000000..13aa0d2
--- /dev/null
+++ b/frontend/src/components/ObserverRegistrationForm.tsx
@@ -0,0 +1,41 @@
+import { FormEvent, useState } from 'react';
+
+export function ObserverRegistrationForm() {
+ const [publicKey, setPublicKey] = useState('');
+ const [iata, setIata] = useState('');
+ const [name, setName] = useState('');
+ const [contact, setContact] = useState('');
+ const [message, setMessage] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+ setSubmitting(true);
+ setMessage(null);
+ fetch('/api/observers/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ publicKey, iata, name, contact }),
+ })
+ .then(async (response) => {
+ const value = await response.json().catch(() => ({})) as { error?: string; requestId?: string };
+ if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`);
+ setMessage(`Request ${value.requestId ?? ''} submitted. An operator will provision the MQTT account.`);
+ })
+ .catch((error: Error) => setMessage(error.message))
+ .finally(() => setSubmitting(false));
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/PacketDetailPanel.tsx b/frontend/src/components/PacketDetailPanel.tsx
new file mode 100644
index 0000000..d94ee31
--- /dev/null
+++ b/frontend/src/components/PacketDetailPanel.tsx
@@ -0,0 +1,2 @@
+export { PacketDetailPanel } from '../pages/ukmesh/PacketDetailPanel.js';
+export type { LazyPath, LazyPathNode, LazyPathResult, ResolvedPath } from '../pages/ukmesh/PacketDetailPanel.js';
diff --git a/frontend/src/components/PacketFeed.tsx b/frontend/src/components/PacketFeed.tsx
index 65d9d51..3736570 100644
--- a/frontend/src/components/PacketFeed.tsx
+++ b/frontend/src/components/PacketFeed.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { useMessages, useNodeMap } from '../hooks/useNodes.js';
import { useOverlayStore } from '../store/overlayStore.js';
import { useWatchlist } from '../hooks/useWatchlist.js';
+import type { AggregatedPacket } from '../hooks/useNodes.js';
const TYPE_LABELS: Record = {
0: 'REQ',
@@ -17,6 +18,66 @@ const TYPE_LABELS: Record = {
11: 'CTL',
};
+type PacketFeedItemProps = {
+ packet: AggregatedPacket;
+ observerIata?: string;
+ isPinned: boolean;
+ isNew: boolean;
+ isWatched: boolean;
+ onTogglePacket: (packet: AggregatedPacket) => void;
+ onToggleWatch: (category: 'packet_type', id: string, label: string) => void;
+};
+
+const PacketFeedItem: React.FC = React.memo(({
+ packet: p,
+ observerIata,
+ isPinned,
+ isNew,
+ isWatched,
+ onTogglePacket,
+ onToggleWatch,
+}) => {
+ const typeLabel = p.packetType !== undefined
+ ? (TYPE_LABELS[p.packetType] ?? `T${p.packetType}`)
+ : '???';
+ const display = p.summary?.includes('🚫') ? '[redacted]' : p.summary;
+ const advertBadge = p.packetType === 4 && typeof p.advertCount === 'number'
+ ? (p.advertCount === 1 ? 'NEW' : `${p.advertCount}`)
+ : undefined;
+ const packetTypeId = String(p.packetType ?? 'unknown');
+
+ return (
+ onTogglePacket(p)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(event) => event.key === 'Enter' && onTogglePacket(p)}
+ >
+ {observerIata && {observerIata} }
+ {p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && {p.pathHashSizeBytes} }
+ {typeLabel}
+ {advertBadge && {advertBadge} }
+ {display ?? '\u00A0'}
+ {p.hopCount !== undefined && p.hopCount > 0 && ↑{p.hopCount} }
+
+ {p.observerIds.length > 0 && {p.observerIds.length}rx }
+ {p.txCount > 0 && {p.txCount}tx }
+
+ {
+ event.stopPropagation();
+ onToggleWatch('packet_type', packetTypeId, `${typeLabel} packets`);
+ }}
+ >{isWatched ? '★' : '☆'}
+ {isPinned && ● }
+
+ );
+});
+
export const PacketFeed: React.FC = React.memo(() => {
// GRP messages only — kept in their own store, never evicted by ADV packets
const messages = useMessages();
@@ -66,58 +127,18 @@ export const PacketFeed: React.FC = React.memo(() => {
return (
{visible.map((p) => {
- const typeLabel = p.packetType !== undefined
- ? (TYPE_LABELS[p.packetType] ?? `T${p.packetType}`)
- : '???';
const observerIata = p.rxNodeId ? nodes.get(p.rxNodeId)?.iata : undefined;
- const rawContent = p.summary;
- const display = rawContent?.includes('🚫') ? '[redacted]' : rawContent;
-
- const advertBadge = p.packetType === 4 && typeof p.advertCount === 'number'
- ? (p.advertCount === 1 ? 'NEW' : `${p.advertCount}`)
- : undefined;
-
- const isPinned = pinnedPacketId === p.id;
-
return (
-
togglePinnedPacket(p)}
- role="button"
- tabIndex={0}
- onKeyDown={(e) => e.key === 'Enter' && togglePinnedPacket(p)}
- >
- {observerIata && (
- {observerIata}
- )}
- {p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && (
- {p.pathHashSizeBytes}
- )}
- {typeLabel}
- {advertBadge && (
- {advertBadge}
- )}
-
- {display ?? '\u00A0'}
-
- {p.hopCount !== undefined && p.hopCount > 0 && (
- ↑{p.hopCount}
- )}
-
- {p.observerIds.length > 0 && (
- {p.observerIds.length}rx
- )}
- {p.txCount > 0 && {p.txCount}tx }
-
- { event.stopPropagation(); watchlist.toggle('packet_type', String(p.packetType ?? 'unknown'), `${typeLabel} packets`); }}
- >{watchlist.isWatched('packet_type', String(p.packetType ?? 'unknown')) ? '★' : '☆'}
- {isPinned && ● }
-
+
);
})}
diff --git a/frontend/src/components/app/AppTopBar.tsx b/frontend/src/components/app/AppTopBar.tsx
index 8c09da9..9c04d9f 100644
--- a/frontend/src/components/app/AppTopBar.tsx
+++ b/frontend/src/components/app/AppTopBar.tsx
@@ -8,6 +8,15 @@ type AppTopBarProps = {
wsState: WSReadyState;
onShowDisclaimer: () => void;
stats: DashboardStats;
+ mapLight: boolean;
+ onToggleMapTheme: () => void;
+ network: string;
+ onNetworkChange: (network: string) => void;
+ annotation: string;
+ onEditAnnotation: () => void;
+ onShowShortcuts: () => void;
+ highContrast: boolean;
+ onToggleContrast: () => void;
};
const ConnIndicator: React.FC<{ state: WSReadyState }> = ({ state }) => (
@@ -36,6 +45,15 @@ export const AppTopBar: React.FC = ({
wsState,
onShowDisclaimer,
stats,
+ mapLight,
+ onToggleMapTheme,
+ network,
+ onNetworkChange,
+ annotation,
+ onEditAnnotation,
+ onShowShortcuts,
+ highContrast,
+ onToggleContrast,
}) => (
@@ -49,6 +67,28 @@ export const AppTopBar: React.FC = ({
+ onNetworkChange(event.target.value)}
+ aria-label="Network region"
+ title="Switch network"
+ >
+ UK Mesh
+ Teesside
+
+
+ {mapLight ? '☀ Light' : '☾ Dark'}
+
+
+ ◐ Contrast
+
+
+ Note
+
+
+ ?
+
void;
shareLabel: string;
onNodeSelect: (nodeId: string) => void;
+ fullScreenMap: boolean;
+ onToggleFullScreenMap: () => void;
};
export const MobileControls: React.FC = ({
@@ -27,19 +29,29 @@ export const MobileControls: React.FC = ({
onShare,
shareLabel,
onNodeSelect,
+ fullScreenMap,
+ onToggleFullScreenMap,
}) => {
const [showFilters, setShowFilters] = useState(false);
return (
-
+ {fullScreenMap ? 'Restore panels' : 'Full-screen map'}
+
+ {!fullScreenMap &&
- }
+ {!fullScreenMap && setShowFilters((v) => !v)}
@@ -47,8 +59,8 @@ export const MobileControls: React.FC = ({
>
Layers
{showFilters ? 'Hide' : 'Show'}
-
-
+ }
+ {!fullScreenMap &&
{FILTER_ROWS.map(({ key, label, color, hollow }) => (
= ({
/>
)}
-
-
+
}
+ {!fullScreenMap &&
-
+
}
);
};
diff --git a/frontend/src/components/app/WatchlistPanel.tsx b/frontend/src/components/app/WatchlistPanel.tsx
index b149a61..83c0643 100644
--- a/frontend/src/components/app/WatchlistPanel.tsx
+++ b/frontend/src/components/app/WatchlistPanel.tsx
@@ -1,10 +1,23 @@
-import React from 'react';
+import React, { useState } from 'react';
import { useWatchlist } from '../../hooks/useWatchlist.js';
export const WatchlistPanel: React.FC = () => {
const { entries, remove } = useWatchlist();
+ const [open, setOpen] = useState(() => {
+ try { return localStorage.getItem('meshcore-watchlist-collapsed-v1') !== '1'; }
+ catch { return true; }
+ });
return (
-
+ {
+ const next = event.currentTarget.open;
+ setOpen(next);
+ try { localStorage.setItem('meshcore-watchlist-collapsed-v1', next ? '0' : '1'); }
+ catch { /* persistence is best-effort */ }
+ }}
+ >
Watchlist {entries.length}
{entries.length === 0 ? Star nodes, searches, regions, packet types, or spam incidents to keep them here.
: (
+
+
+
{regionalHealth.label}
+
{regionalHealth.score}% aggregate health · {regions.length} reporting regions
+
View regional detail →
+
+
+
+
+
+
Recent feed Open live feed →
+
+ {recent.map((packet) => (
+
+ {packet.packet_hash.slice(0, 12)}
+ {packet.summary || `Packet type ${packet.packet_type ?? 'unknown'}`}
+ {new Date(packet.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+ ))}
+ {recent.length === 0 &&
Waiting for recent public packets…
}
+
+
+
+
diff --git a/frontend/src/pages/ukmesh/UKInstallPage.tsx b/frontend/src/pages/ukmesh/UKInstallPage.tsx
index 8dde8db..5b3f73d 100644
--- a/frontend/src/pages/ukmesh/UKInstallPage.tsx
+++ b/frontend/src/pages/ukmesh/UKInstallPage.tsx
@@ -1,6 +1,17 @@
-import React from 'react';
+import React, { useState } from 'react';
+import { ObserverRegistrationForm } from '../../components/ObserverRegistrationForm.js';
-export const UKInstallPage: React.FC = () => (
+const HARDWARE = [
+ { id: 'v4', name: 'Heltec WiFi LoRa 32 V4', detail: 'ESP32-S3 · 868 MHz · OLED · USB-C · Li-Po', usb: 'USB-C', boot: 'Hold PRG while tapping RST if Web Serial cannot find the board.' },
+ { id: 'v3', name: 'Heltec WiFi LoRa 32 V3', detail: 'ESP32 · 868 MHz · OLED · USB-C', usb: 'USB-C', boot: 'Hold PRG while connecting USB to enter the ROM bootloader.' },
+ { id: 't3s3', name: 'LILYGO T3S3', detail: 'ESP32-S3 · 868 MHz · E-Paper optional', usb: 'USB-C', boot: 'Hold BOOT, tap RESET, then release BOOT.' },
+ { id: 't114', name: 'Heltec Mesh Node T114', detail: 'nRF52840 · 868 MHz · Ultra-compact', usb: 'USB-C', boot: 'Double-tap RESET to expose the UF2 boot drive.' },
+] as const;
+
+export const UKInstallPage: React.FC = () => {
+ const [hardwareId, setHardwareId] = useState<(typeof HARDWARE)[number]['id']>('v4');
+ const hardware = HARDWARE.find((entry) => entry.id === hardwareId)!;
+ return (
<>
@@ -18,6 +29,18 @@ export const UKInstallPage: React.FC = () => (
+ {HARDWARE.map((entry) =>
setHardwareId(entry.id)}
+ >
+ {entry.id === 'v4' && Recommended
}
+ {entry.name}
+ {entry.detail}
+ )}
+ {/* Legacy card markup intentionally replaced by the interactive selector.
Recommended
Heltec WiFi LoRa 32 V4
@@ -35,6 +58,10 @@ export const UKInstallPage: React.FC = () => (
Heltec Mesh Node T114
nRF52840 · 868 MHz · Ultra-compact
+ */}
+
+
+ {hardware.name}: use a {hardware.usb} data cable. {hardware.boot}
@@ -59,7 +86,7 @@ export const UKInstallPage: React.FC = () => (
.
- Select your device hardware from the dropdown, then select{' '}
+ Select {hardware.name} from the hardware dropdown, then select{' '}
Companion Radio (Bluetooth) as the firmware type.
@@ -179,8 +206,12 @@ Password: `}
Flasher ↗ {' '}
to flash a firmware build with the MQTT bridge built in directly to your repeater node — no separate Linux device needed.
+
Request access
+
The API records a provisioning request; MQTT password creation remains operator-controlled so credentials are never exposed by the public site.
+
>
-);
+ );
+};
diff --git a/frontend/src/pages/ukmesh/UKLayout.tsx b/frontend/src/pages/ukmesh/UKLayout.tsx
index a12da19..db9d729 100644
--- a/frontend/src/pages/ukmesh/UKLayout.tsx
+++ b/frontend/src/pages/ukmesh/UKLayout.tsx
@@ -22,6 +22,7 @@ export const UKLayout: React.FC = () => {
showCompanion
showRegions={false}
showTopology
+ showSpam
showAbout={false}
showInstall
showBestPractice
diff --git a/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx b/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx
index 1520d67..68221db 100644
--- a/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx
+++ b/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx
@@ -41,6 +41,35 @@ interface AdvertPacket {
packet_hash: string;
}
+type NodeDetailBundle = { links: NodeLink[]; history: PacketHistory[]; adverts: AdvertPacket[] };
+const NODE_DETAIL_TTL_MS = 5 * 60_000;
+const nodeDetailCache = new Map
();
+
+async function fetchJsonWithTimeout(url: string, timeoutMs = 8_000): Promise {
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json() as Promise;
+}
+
+async function loadNodeDetails(node: MeshNode): Promise {
+ const cacheKey = node.node_id.toUpperCase();
+ const cached = nodeDetailCache.get(cacheKey);
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
+ const publicKey = node.public_key ?? node.node_id;
+ const [links, history, adverts] = await Promise.all([
+ fetchJsonWithTimeout(`/api/nodes/${node.node_id}/links`),
+ fetchJsonWithTimeout(`/api/nodes/${node.node_id}/history?hours=24`),
+ fetchJsonWithTimeout(`/api/nodes/${publicKey}/adverts?hours=168`),
+ ]);
+ const value = {
+ links: Array.isArray(links) ? links : [],
+ history: Array.isArray(history) ? history : [],
+ adverts: Array.isArray(adverts) ? adverts : [],
+ };
+ nodeDetailCache.set(cacheKey, { expiresAt: Date.now() + NODE_DETAIL_TTL_MS, value });
+ return value;
+}
+
function timeAgo(iso: string): string {
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${secs}s ago`;
@@ -154,10 +183,10 @@ export const UKRepeaterSearchPage: React.FC = () => {
// Load nodes on mount
useEffect(() => {
let cancelled = false;
- fetch('/api/nodes?network=ukmesh')
+ fetch('/api/nodes/map?network=ukmesh&fields=node_id,name,lat,lon,iata,role,last_seen,is_online,hardware_model')
.then(r => r.json())
.then(data => {
- if (!cancelled) setNodes(Array.isArray(data) ? data : []);
+ if (!cancelled) setNodes(Array.isArray(data) ? data.map((node: MeshNode) => ({ ...node, public_key: node.node_id })) : []);
})
.catch(() => {
if (!cancelled) setNodes([]);
@@ -215,19 +244,10 @@ export const UKRepeaterSearchPage: React.FC = () => {
setCopiedKey(false);
try {
- const [linksRes, historyRes, advertsRes] = await Promise.all([
- fetch(`/api/nodes/${node.node_id}/links`),
- fetch(`/api/nodes/${node.node_id}/history?hours=24`),
- fetch(`/api/nodes/${node.public_key}/adverts?hours=168`)
- ]);
-
- const linksData = await linksRes.json();
- const historyData = await historyRes.json();
- const advertsData = await advertsRes.json();
-
- setLinks(Array.isArray(linksData) ? linksData : []);
- setHistory(Array.isArray(historyData) ? historyData : []);
- setAdverts(Array.isArray(advertsData) ? advertsData : []);
+ const details = await loadNodeDetails(node);
+ setLinks(details.links);
+ setHistory(details.history);
+ setAdverts(details.adverts);
} catch {
// Ignore errors
} finally {
diff --git a/frontend/src/pages/ukmesh/feed-page.css b/frontend/src/pages/ukmesh/feed-page.css
index b62cdd1..5befc1d 100644
--- a/frontend/src/pages/ukmesh/feed-page.css
+++ b/frontend/src/pages/ukmesh/feed-page.css
@@ -110,6 +110,15 @@
min-width: 0;
}
+.uk-feed-detail-modal {
+ width: min(1100px, calc(100vw - 24px));
+ max-height: calc(100vh - 24px);
+ overflow: auto;
+ border: 1px solid var(--border-bright);
+ border-radius: var(--radius-lg);
+ background: var(--bg-base);
+}
+
/* Mobile-only elements — hidden on desktop */
.uk-feed-mobile-stats,
.uk-feed-mobile-detail {
diff --git a/frontend/src/store/overlayStore.ts b/frontend/src/store/overlayStore.ts
index 58bcfa7..4baef6e 100644
--- a/frontend/src/store/overlayStore.ts
+++ b/frontend/src/store/overlayStore.ts
@@ -15,7 +15,13 @@ type OverlayStoreState = {
betaRemainingHops: number | null;
replayNodeIds: Set | null;
replayTime: string | null;
- pathExplanation: { evidenceLevel: string; summary: string; reasons: string[]; limitations?: string[] } | null;
+ pathExplanation: {
+ evidenceLevel: string;
+ summary: string;
+ reasons: string[];
+ alternativesConsidered?: number;
+ limitations?: string[];
+ } | null;
// Multi-node LOS support
losNodeIds: Set;
losLoadingIds: Set;
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index 878c899..2bd3f5e 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -311,6 +311,46 @@ button .loading-indicator--inline,
background: var(--accent-dim);
}
+.topbar__network,
+.topbar__tool-btn {
+ height: 28px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-panel-alt);
+ color: var(--text-secondary);
+ font: 10px var(--font-mono);
+}
+.topbar__network { max-width: 110px; padding: 0 7px; }
+.topbar__tool-btn { padding: 0 8px; cursor: pointer; }
+.topbar__tool-btn:hover,
+.topbar__tool-btn--active { border-color: var(--accent); color: var(--accent); }
+.topbar__shortcut-btn { width: 28px; padding: 0; font-size: 14px; }
+
+.shortcut-guide {
+ position: fixed;
+ inset: 0;
+ z-index: 2400;
+ display: grid;
+ place-items: center;
+ padding: 18px;
+ background: rgba(0, 0, 0, 0.72);
+}
+.shortcut-guide__panel {
+ width: min(430px, 100%);
+ padding: 20px;
+ border: 1px solid var(--border-bright);
+ border-radius: 12px;
+ background: var(--bg-panel);
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
+}
+.shortcut-guide__panel header { display: flex; align-items: center; justify-content: space-between; }
+.shortcut-guide__panel h2 { color: var(--accent); font: 16px var(--font-mono); }
+.shortcut-guide__panel header button { border: 0; background: transparent; color: var(--text-muted); font-size: 24px; cursor: pointer; }
+.shortcut-guide__panel dl { display: grid; gap: 7px; margin-top: 16px; }
+.shortcut-guide__panel dl div { display: grid; grid-template-columns: 58px 1fr; align-items: center; gap: 10px; }
+.shortcut-guide__panel dt { padding: 4px 7px; border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font: 11px var(--font-mono); text-align: center; }
+.shortcut-guide__panel dd { color: var(--text-secondary); font-size: 12px; }
+
/* Disclaimer modal */
.disclaimer-overlay {
position: fixed;
@@ -525,6 +565,9 @@ button .loading-indicator--inline,
/* ── Analytics app topbar ────────────────────────────────────────────── */
.topbar { gap: 10px; padding: 0 12px; }
.topbar__stats { display: none; }
+ .topbar__divider,
+ .topbar__tool-btn:not(.topbar__shortcut-btn) { display: none; }
+ .topbar__network { margin-left: auto; max-width: 92px; }
/* ── App shell: add rows for mobile controls + map ──────────────────── */
.app-shell { grid-template-rows: 48px auto 1fr; }
diff --git a/frontend/src/styles/map-app.css b/frontend/src/styles/map-app.css
index 9e38f15..a1f0e02 100644
--- a/frontend/src/styles/map-app.css
+++ b/frontend/src/styles/map-app.css
@@ -47,6 +47,21 @@
width: 260px;
}
+.node-search__controls { display: flex; }
+.node-search__controls .node-search__input { border-radius: 6px 0 0 6px; }
+.node-search__nearby {
+ flex: 0 0 34px;
+ border: 1px solid var(--border);
+ border-left: 0;
+ border-radius: 0 6px 6px 0;
+ background: var(--bg-panel-alt);
+ color: var(--accent);
+ font-size: 17px;
+ cursor: pointer;
+}
+.node-search__nearby:hover { background: var(--bg-hover); }
+.node-search__status { padding: 6px 10px; border-bottom: 1px solid var(--border); color: var(--text-muted); font: 9px var(--font-mono); }
+
.node-search__input {
width: 100%;
box-sizing: border-box;
@@ -110,6 +125,33 @@
.node-search__save { width: 100%; padding: 7px 10px; border: 0; border-top: 1px solid var(--border); background: var(--bg-panel-alt); color: var(--accent); font: 10px var(--font-mono); text-align: left; cursor: pointer; }
.node-search__save:hover { background: var(--bg-hover); }
+.map-annotation {
+ position: absolute;
+ top: 62px;
+ left: 50%;
+ z-index: 790;
+ max-width: min(520px, calc(100vw - 500px));
+ overflow: hidden;
+ padding: 7px 12px;
+ transform: translateX(-50%);
+ border: 1px solid rgba(251, 191, 36, 0.6);
+ border-radius: 999px;
+ background: rgba(13, 21, 32, 0.94);
+ color: #fbbf24;
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.path-explanation-popover { min-width: 220px; color: #172033; font-family: var(--font-body); }
+.path-explanation-popover > strong { display: block; margin-bottom: 7px; font-size: 13px; }
+.path-explanation-popover dl { display: grid; gap: 3px; margin: 0; }
+.path-explanation-popover dl div { display: flex; justify-content: space-between; gap: 12px; }
+.path-explanation-popover dt { color: #64748b; }
+.path-explanation-popover dd { margin: 0; font-weight: 600; }
+.path-explanation-popover p { margin: 7px 0 0; color: #475569; font-size: 11px; }
+
/* ─── Filter Panel ───────────────────────────────────────────────────────── */
.filter-panel {
position: absolute;
@@ -1249,6 +1291,29 @@
/* App-specific responsive rules live after the app base rules so lazy-loaded
CSS cannot reverse the mobile cascade. */
@media (max-width: 640px) {
+ .mobile-fullscreen-toggle {
+ position: fixed;
+ z-index: 1200;
+ top: 56px;
+ right: 8px;
+ border: 1px solid var(--accent);
+ border-radius: var(--radius-md);
+ padding: 7px 10px;
+ background: rgba(8, 13, 20, 0.92);
+ color: var(--text-primary);
+ font: 11px var(--font-mono);
+ }
+ .app-shell[data-mobile-fullscreen='true'] .topbar,
+ .app-shell[data-mobile-fullscreen='true'] .node-dock,
+ .app-shell[data-mobile-fullscreen='true'] .node-legend,
+ .app-shell[data-mobile-fullscreen='true'] .timeline-control,
+ .app-shell[data-mobile-fullscreen='true'] .planner-comparison {
+ display: none;
+ }
+ .app-shell[data-mobile-fullscreen='true'] {
+ grid-template-rows: 1fr;
+ }
+ .map-annotation { top: 98px; max-width: calc(100vw - 30px); }
.filter-panel { display: none; }
.filter-launcher { display: none; }
.node-drawer,
@@ -1413,4 +1478,26 @@
display: block;
width: 100%;
height: 28px;
-}
\ No newline at end of file
+}
+.node-popup__tabs {
+ display: flex;
+ gap: 3px;
+ overflow-x: auto;
+ margin: 0 0 8px;
+}
+.node-popup__tabs button {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 4px 6px;
+ background: transparent;
+ color: var(--text-secondary);
+ font: 10px var(--font-mono);
+ cursor: pointer;
+}
+.node-popup__tabs button[aria-selected='true'] {
+ border-color: var(--accent);
+ background: var(--accent-dim);
+ color: var(--text-primary);
+}
+.node-popup__link-spark { width: 100%; margin-top: 4px; }
+.node-popup__muted { color: var(--text-muted); font-size: 11px; }
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
new file mode 100644
index 0000000..d4ce006
--- /dev/null
+++ b/frontend/src/styles/tokens.css
@@ -0,0 +1,71 @@
+:root {
+ --color-bg: #0a0f1a;
+ --color-surface: #111827;
+ --color-primary: #00c4ff;
+ --color-primary-dim: #0099cc;
+ --color-gold: #fbbf24;
+ --color-red: #ef4444;
+ --color-green: #22c55e;
+ --color-amber: #ff9f43;
+ --color-purple: #a78bfa;
+ --color-text: #e2e8f0;
+ --color-text-dim: #6b7280;
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 12px;
+ --space-lg: 16px;
+ --space-xl: 24px;
+ --font-mono: 'Iosevka', 'JetBrains Mono', monospace;
+ --font-sans: 'Inter', -apple-system, 'Segoe UI', sans-serif;
+}
+
+:root[data-contrast='high'] {
+ --color-bg: #000;
+ --color-surface: #101010;
+ --color-primary: #5cdbff;
+ --color-primary-dim: #24bce8;
+ --color-gold: #ffd75e;
+ --color-red: #ff6b6b;
+ --color-green: #55ef88;
+ --color-amber: #ffc266;
+ --color-purple: #c7b5ff;
+ --color-text: #fff;
+ --color-text-dim: #c7d2df;
+ --bg-base: var(--color-bg);
+ --bg-panel: var(--color-surface);
+ --accent: var(--color-primary);
+ --text-primary: var(--color-text);
+ --text-secondary: #d6e2ef;
+ --text-muted: var(--color-text-dim);
+ --border: rgba(255, 255, 255, 0.55);
+ --border-bright: rgba(92, 219, 255, 0.85);
+ --online: var(--color-green);
+ --danger: var(--color-red);
+}
+
+@media (prefers-contrast: more) {
+ :root:not([data-contrast='standard']) {
+ --color-bg: #000;
+ --color-surface: #101010;
+ --color-primary: #5cdbff;
+ --color-text: #fff;
+ --color-text-dim: #c7d2df;
+ --bg-base: var(--color-bg);
+ --bg-panel: var(--color-surface);
+ --accent: var(--color-primary);
+ --text-primary: var(--color-text);
+ --text-secondary: #d6e2ef;
+ --text-muted: var(--color-text-dim);
+ --border: rgba(255, 255, 255, 0.55);
+ --online: #55ef88;
+ --danger: #ff6b6b;
+ }
+}
+
+.maplibregl-map {
+ --map-label-color: var(--color-text);
+ --map-link-color: var(--color-primary);
+}
diff --git a/frontend/src/utils/mapUrlState.ts b/frontend/src/utils/mapUrlState.ts
index aa5a3c6..1072922 100644
--- a/frontend/src/utils/mapUrlState.ts
+++ b/frontend/src/utils/mapUrlState.ts
@@ -6,6 +6,7 @@ const BOOLEAN_FILTER_KEYS = [
'terrain',
'clientNodes',
'packetHistory',
+ 'heatmap',
'betaPaths',
'hexClashes',
] as const satisfies ReadonlyArray;
@@ -16,6 +17,7 @@ const FILTER_QUERY_NAMES: Record<(typeof BOOLEAN_FILTER_KEYS)[number], string> =
terrain: 'terrain',
clientNodes: 'clients',
packetHistory: 'paths',
+ heatmap: 'heatmap',
betaPaths: 'live-path',
hexClashes: 'clashes',
};