From 1e7cd3bbff79dcd7d4a4167734687a75e0220f54 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:31:38 +0100 Subject: [PATCH] fix RF coverage terrain artifacts --- frontend/src/App.tsx | 38 ++++++++++++---- .../components/FilterPanel/FilterPanel.tsx | 5 +++ .../src/components/app/MobileControls.tsx | 5 +++ .../src/utils/mapLayerCompatibility.test.ts | 45 +++++++++++++++++++ frontend/src/utils/mapLayerCompatibility.ts | 22 +++++++++ frontend/test/e2e/dashboard.spec.ts | 14 ++++++ 6 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 frontend/src/utils/mapLayerCompatibility.test.ts create mode 100644 frontend/src/utils/mapLayerCompatibility.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6a21bb..a03ba9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,7 @@ import { writeFiltersToUrl, writeMapViewToUrl, } from './utils/mapUrlState.js'; +import { resolveTerrainCoverageConflict } from './utils/mapLayerCompatibility.js'; type PacketHistorySegment = { positions: [[number, number], [number, number]]; @@ -106,7 +107,10 @@ export const App: React.FC = () => { const withRfVisibility = savedRfVisibility === null ? modeFilters : { ...modeFilters, coverage: savedRfVisibility === '1' }; - return filtersFromUrl(withRfVisibility); + return resolveTerrainCoverageConflict( + { terrain: false, coverage: false }, + filtersFromUrl(withRfVisibility), + ); }); const [activeMode, setActiveMode] = useState(() => { const requested = new URLSearchParams(window.location.search).get('mode'); @@ -193,7 +197,9 @@ export const App: React.FC = () => { if (!RF_COVERAGE_ENABLED || !rfCoverage.availableTiers.includes('standard')) return; if (localStorage.getItem(RF_VISIBILITY_KEY) !== null) return; if (initialLayersSpecifiedRef.current) return; - setFilters((current) => current.coverage ? current : { ...current, coverage: true }); + setFilters((current) => current.coverage || current.terrain + ? current + : { ...current, coverage: true }); }, [rfCoverage.availableTiers]); useEffect(() => { @@ -240,11 +246,12 @@ export const App: React.FC = () => { const handleFiltersChange = useCallback((next: Filters) => { setActiveMode(null); useOverlayStore.getState().setPlanRepeaterMode(false); - if (next.coverage !== filters.coverage) { - localStorage.setItem(RF_VISIBILITY_KEY, next.coverage ? '1' : '0'); + const compatible = resolveTerrainCoverageConflict(filters, next); + if (compatible.coverage !== filters.coverage) { + localStorage.setItem(RF_VISIBILITY_KEY, compatible.coverage ? '1' : '0'); } - setFilters(next); - }, [filters.coverage]); + setFilters(compatible); + }, [filters]); const handleRfTierChange = useCallback((tier: RfCoverageTierName) => { localStorage.setItem(RF_TIER_KEY, tier); @@ -253,7 +260,13 @@ export const App: React.FC = () => { const handleModeChange = useCallback((mode: MapMode) => { setActiveMode(mode); - setFilters((current) => filtersForMapMode(mode, current)); + setFilters((current) => { + const compatible = resolveTerrainCoverageConflict(current, filtersForMapMode(mode, current)); + if (compatible.coverage !== current.coverage) { + localStorage.setItem(RF_VISIBILITY_KEY, compatible.coverage ? '1' : '0'); + } + return compatible; + }); useOverlayStore.getState().setPlanRepeaterMode(mode === 'plan'); }, []); @@ -560,7 +573,16 @@ export const App: React.FC = () => { event.preventDefault(); void handleShare(); } else if (!isEditing && event.key.toLowerCase() === 't') { - setFilters((current) => ({ ...current, terrain: !current.terrain })); + setFilters((current) => { + const compatible = resolveTerrainCoverageConflict( + current, + { ...current, terrain: !current.terrain }, + ); + if (compatible.coverage !== current.coverage) { + localStorage.setItem(RF_VISIBILITY_KEY, compatible.coverage ? '1' : '0'); + } + return compatible; + }); } else if (!isEditing && event.key.toLowerCase() === 'l') { setFilters((current) => ({ ...current, betaPaths: !current.betaPaths })); } diff --git a/frontend/src/components/FilterPanel/FilterPanel.tsx b/frontend/src/components/FilterPanel/FilterPanel.tsx index 34d2277..2ff6d98 100644 --- a/frontend/src/components/FilterPanel/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel/FilterPanel.tsx @@ -154,6 +154,11 @@ export const FilterPanel: React.FC = ({ className="filter-row" onClick={() => toggle(key)} aria-pressed={filters[key] as boolean} + title={key === 'terrain' + ? '3D Terrain and RF Coverage render separately; enabling terrain turns RF coverage off.' + : key === 'coverage' + ? 'RF Coverage uses the flat map; enabling it turns 3D terrain off.' + : undefined} > {hollow ? ( diff --git a/frontend/src/components/app/MobileControls.tsx b/frontend/src/components/app/MobileControls.tsx index c8ad8d3..ffd6963 100644 --- a/frontend/src/components/app/MobileControls.tsx +++ b/frontend/src/components/app/MobileControls.tsx @@ -114,6 +114,11 @@ export const MobileControls: React.FC = ({ className={`filter-row${filters[key] ? ' filter-row--on' : ''}`} onClick={() => onFiltersChange({ ...filters, [key]: !filters[key] })} aria-pressed={!!filters[key]} + title={key === 'terrain' + ? '3D Terrain and RF Coverage render separately; enabling terrain turns RF coverage off.' + : key === 'coverage' + ? 'RF Coverage uses the flat map; enabling it turns 3D terrain off.' + : undefined} > {hollow ? ( diff --git a/frontend/src/utils/mapLayerCompatibility.test.ts b/frontend/src/utils/mapLayerCompatibility.test.ts new file mode 100644 index 0000000..e098255 --- /dev/null +++ b/frontend/src/utils/mapLayerCompatibility.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Filters } from '../components/FilterPanel/FilterPanel.js'; +import { resolveTerrainCoverageConflict } from './mapLayerCompatibility.js'; + +const filters = (terrain: boolean, coverage: boolean): Filters => ({ + livePackets: true, + links: false, + terrain, + clientNodes: false, + packetHistory: false, + coverage, + heatmap: false, + betaPaths: false, + betaPathThreshold: 0.45, + hexClashes: false, + hexClashMaxHops: 3, +}); + +test('enabling RF coverage turns off 3D terrain', () => { + assert.deepEqual( + resolveTerrainCoverageConflict(filters(true, false), filters(true, true)), + filters(false, true), + ); +}); + +test('enabling 3D terrain turns off RF coverage', () => { + assert.deepEqual( + resolveTerrainCoverageConflict(filters(false, true), filters(true, true)), + filters(true, false), + ); +}); + +test('ambiguous persisted state and shared URLs prefer RF coverage', () => { + assert.deepEqual( + resolveTerrainCoverageConflict(filters(false, false), filters(true, true)), + filters(false, true), + ); +}); + +test('compatible layer combinations are unchanged', () => { + for (const next of [filters(false, false), filters(true, false), filters(false, true)]) { + assert.equal(resolveTerrainCoverageConflict(filters(false, false), next), next); + } +}); diff --git a/frontend/src/utils/mapLayerCompatibility.ts b/frontend/src/utils/mapLayerCompatibility.ts new file mode 100644 index 0000000..cf59b46 --- /dev/null +++ b/frontend/src/utils/mapLayerCompatibility.ts @@ -0,0 +1,22 @@ +import type { Filters } from '../components/FilterPanel/FilterPanel.js'; + +type TerrainCoverageState = Pick; + +/** + * MapLibre cannot reliably drape our georeferenced HopReach image sources over + * its 3D terrain mesh: the images become large black/distorted quadrilaterals. + * Keep the controls deterministic by preserving whichever incompatible layer + * the user just enabled. Existing state/URLs that already contain both prefer + * RF coverage, which is the more specific data view. + */ +export function resolveTerrainCoverageConflict( + current: TerrainCoverageState, + next: Filters, +): Filters { + if (!next.terrain || !next.coverage) return next; + + const enabledTerrain = !current.terrain && next.terrain; + const enabledCoverage = !current.coverage && next.coverage; + if (enabledTerrain && !enabledCoverage) return { ...next, coverage: false }; + return { ...next, terrain: false }; +} diff --git a/frontend/test/e2e/dashboard.spec.ts b/frontend/test/e2e/dashboard.spec.ts index fe9220c..c5a3a1d 100644 --- a/frontend/test/e2e/dashboard.spec.ts +++ b/frontend/test/e2e/dashboard.spec.ts @@ -162,6 +162,20 @@ test('enabled HopReach coverage survives metadata winning the initial map-load r await expect.poll(() => tileRequests.length, { timeout: 15_000 }).toBeGreaterThan(0); }); +test('RF coverage and 3D terrain cannot create black terrain polygons together', async ({ page }, testInfo) => { + await page.goto('/?layers=feed%2Cterrain%2Ccoverage', { waitUntil: 'domcontentloaded' }); + await page.getByText('Live Map', { exact: true }).waitFor({ state: 'visible', timeout: 15_000 }); + if (testInfo.project.name === 'dashboard-mobile') { + await page.getByRole('button', { name: 'Layers' }).first().click(); + } + + const terrain = page.getByRole('button', { name: '3D Terrain' }).first(); + const coverage = page.getByRole('button', { name: 'RF Coverage' }).first(); + await expect(coverage).toHaveAttribute('aria-pressed', 'true'); + await expect(terrain).toHaveAttribute('aria-pressed', 'false'); + await expect(page).toHaveURL(/layers=(?![^&]*terrain)[^&]*coverage/); +}); + test('map modes update layers and produce a shareable URL', async ({ page }) => { await page.goto('/'); await expect(page.getByText('Live Map', { exact: true })).toBeVisible();