fix(map-analysis): give the 3D view the view-state, follow, and layer plumbing 2D has (#4371) - #4373
Conversation
… plumbing 2D has (#4371) The 3D branch of MapAnalysisCanvas was never wired up with the plumbing the 2D branch has, which surfaced as three separate-looking bugs: - Switching to 3D discarded the current view and re-centered on the Default Map Center, or the hardcoded [30, -90] fallback when none was configured. - Follow and Auto-zoom did nothing in 3D: FollowController is a useMap() controller rendered only inside <BaseMap>'s children. - Map layers couldn't be switched in 3D: no tileset selector, and the node markers ignored their own layer toggle. All three trace to the same gap, so this fixes them together rather than as three point patches. View state (A). Both surfaces publish their live camera into a shared `mapViewRef` on MapAnalysisContext — 2D via a new MapViewStateController, 3D via a new `onViewChange` prop on Base3DMap — and whichever branch mounts next seeds center/zoom from it, falling back to the Default Map Center only when no map has reported yet. A ref rather than state on purpose: moveend fires continuously while panning, and nothing renders from this value, so re-rendering every layer per frame would be pure cost. Base3DMap also takes `pitch`/`bearing` now, so a 3D -> 2D -> 3D round-trip restores the camera angle Leaflet can't hold. Follow (B). New Follow3DController mirrors FollowController case for case against the MapLibre camera, and FollowResumeButton is mounted in 3D too. Base3DMap gained an `onMapReady` handle for it, since MapLibre has no useMap() child context — the 3D analogue of BaseMap's `mapRef`. Auto-zoom deliberately avoids `map.fitBounds`: it routes through flyTo, which ignores `animate:false` and animates anyway, and its cameraForBounds call defaults bearing to 0 — so a rotated 3D camera would be yanked north-up on every follow update. Resolving the camera explicitly with the current bearing and jumpTo-ing it keeps bearing and pitch and fires moveend synchronously, matching what the 2D controller's `animate:false` does. Layers (C). The tileset selector is mounted in the 3D branch (Base3DMap already swaps its raster source in place when `basemap` changes), and the 3D node markers now honor config.layers.markers.enabled. The layers a MapLibre canvas genuinely cannot draw — heatmap, trails, hop shading, SNR overlay, accuracy regions, polar grid, waypoints, ATAK contacts — get their toggle disabled with a "2D view only" tooltip instead of appearing to work and drawing nothing. Their stored state is untouched, so each returns exactly as it was on the switch back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyRkje9qCerXExJMxjeBRT
|
Claude finished @Yeraze's task —— View job PR Review: fix(map-analysis) — 3D view plumbingTasks:
SummaryThis is a well-structured PR that correctly addresses three related gaps in the 3D map branch — view-state continuity, Follow/Auto-zoom, and layer toggling. The architecture choices are sound and well-documented inline. I found a few items worth attention, but nothing blocking. ObservationsA. View-state carryover (
|
…maps load (#4371) Selecting the "OSM Humanitarian" tileset and switching to 3D produced a blank basemap and a flood of console errors: Fetch API cannot load https://a.tile.openstreetmap.fr/hot/5/8/13.png. Refused to connect because it violates the document's Content Security Policy. `connect-src` carried a hand-maintained list of built-in tile hosts that had drifted from the actual TILESETS catalog — `tile.openstreetmap.fr` was never added. This stayed invisible because the two map surfaces fetch tiles through different CSP directives: Leaflet (2D) loads them as `<img>`, governed by `img-src`, which is `'self' data: http: https:` and allows anything; MapLibre (3D) fetches them, governed by `connect-src`. So the tileset worked in 2D and was blocked in 3D. Also rewrite a leading `{s}` subdomain placeholder to a `*` wildcard when deriving a host from a custom tileset URL. `new URL()` happily parses `https://{s}.example.com/...` and yields the literal hostname `{s}.example.com`, which as a CSP source matches nothing — the same blank-basemap-in-3D failure, for anyone using a custom tileset with a subdomain placeholder. dynamicCsp.test.ts now imports the real TILESETS catalog and asserts every built-in tileset's host is reachable under connect-src, so adding a tileset without its CSP entry fails CI rather than someone's browser. Verified the guard fails when the .fr entry is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyRkje9qCerXExJMxjeBRT
|
Claude finished @Yeraze's task —— View job PR Review: fix(map-analysis) — 3D view plumbingTasks:
SummaryThis PR correctly addresses three interconnected gaps in the 3D map branch (view-state carryover, Follow/Auto-zoom, and layer toggling). The architecture is sound: shared ref for camera state, a mirrored 3D follow controller, and explicit 2D-only gating with explanatory tooltips. Test coverage is thorough. A few items worth attention below, none blocking. Code Quality
The controller reads the previous ref value before overwriting it, so pitch/bearing set by a 3D session survive the 2D leg intact. This is correct and the test at
const selKey = config.selectedNodeIds.join('|');
Reading Potential Bugs / Issues
The 2D branch (lines 317-319) renders
return () => {
setLoaded(false);
onMapReadyRef.current?.(null);
map.remove();
mapRef.current = null;
};The sequence is: clear loaded → signal consumers (null map) → remove map.
const raf = typeof requestAnimationFrame !== 'undefined'
? requestAnimationFrame
: (cb: () => void) => setTimeout(cb, 0);
raf(() => { programmaticRef.current = false; });The comment ("safety net for a move MapLibre treats as a no-op") is accurate. However there's a subtle timing window: a user pan that starts within the same animation frame as a programmatic no-op move would see Performance
const handle3DViewChange = useCallback(
(view: Map3DViewState) => {
mapViewRef.current = view;
},
[mapViewRef],
);
This includes follow-mode updates from SecurityNo security concerns. The CSP changes in Test CoverageCoverage is strong across the board. Specific observations: Well-covered:
Gaps:
VerdictLogic is correct. The
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyRkje9qCerXExJMxjeBRT
|
Claude finished @Yeraze's task —— View job PR Review: fix(map-analysis) — 3D view plumbingTasks:
SummaryThis is a well-structured PR that correctly addresses three bugs in the 3D map branch (view-state carryover, Follow/Auto-zoom, layer toggling) plus a pre-existing CSP regression. The architecture choices are sound and well-reasoned. I found several items worth attention, none of which are blocking. Code Quality
The controller reads
Setting
const camera = map.cameraForBounds([[west, south], [east, north]], {
bearing: map.getBearing(),
});Explicitly threading the current bearing is the correct fix for the north-snap issue (MapLibre's default is
return () => {
setLoaded(false);
onMapReadyRef.current?.(null);
map.remove();
mapRef.current = null;
};The sequence is correct: disabling
const hostname = parsed.hostname.startsWith('{s}.')
? `*.${parsed.hostname.slice('{s}.'.length)}`
: parsed.hostname;This is narrowly correct for a leading Potential Issues
The 2D branch (lines 317-319) renders
A comment in the code (or a note in the PR description) would prevent the next reviewer from asking the same question.
const selKey = config.selectedNodeIds.join('|');
const handle3DViewChange = useCallback(
(view: Map3DViewState) => { mapViewRef.current = view; },
[mapViewRef], // mapViewRef is a stable RefObject — [] would be clearer
);
const in3D = config.viewMode === '3d';
Performance
Follow-mode
const lines3D: Line3DFeature[] = useMemo(
() => [...neighborLines3D.lines, ...tracerouteLines3D.selectionByKey],
[neighborLines3D.lines, tracerouteLines3D.lines],
);This is correct — both hook results are already memoized inside their respective hooks, so this spread is cheap. SecurityNo concerns. The CSP changes add legitimate tile hosts to The catalog-driven test in Test CoverageWell-covered:
Gaps worth noting:
VerdictLogic is correct. The
|
…#4371) Addresses PR review on #4373. MapAnalysisToolbar read raw `config.viewMode` to decide which layer toggles to grey out as "2D view only", while MapAnalysisCanvas renders its branch from `effectiveViewMode` — the force-2D guard that falls back when terrain capabilities resolve unavailable. The two disagreed for the window between the canvas falling back and its effect writing `viewMode:'2d'` back to the persisted config: a 2D map on screen with the 2D-only toggles greyed out. Extract the derivation into `useEffectiveViewMode` and have both read it, rather than duplicating the capabilities expression in a second file. The canvas keeps the correcting write-back — it owns the branch; the toolbar is a read-only consumer. Mid-flight capabilities still report '3d', so an available 3D view doesn't flicker its toggles on and off while the fetch lands. Also from the review: - Assert FollowResumeButton in the 3D branch. Driven into its visible state (follow on + paused) rather than asserting mere presence, which would pass even if the button could never show. - Pin TimeSliderControl/MapLegend as 2D-only — both documented non-goals of the #3826 3D epic — with a comment at the 3D branch and tests asserting absence in 3D + presence in 2D, so an accidental add is caught. Both new guards were mutation-tested: each fails when its fix is reverted. Not taken: the suggestion to change `handle3DViewChange`'s dep array from `[mapViewRef]` to `[]`. ESLint's exhaustive-deps only treats a ref as stable when it can see the `useRef()` call in the same component; `mapViewRef` arrives via context destructuring, so it is an ordinary value and `[]` raises "missing dependency: 'mapViewRef'" — a new violation in a baselined file, which fails the lint ratchet. Verified by applying it and running ESLint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyRkje9qCerXExJMxjeBRT
|
Claude finished @Yeraze's task —— View job PR Review: fix(map-analysis) — 3D view plumbingTasks:
SummaryWell-structured fix that correctly addresses three connected gaps in the 3D map branch (view-state carryover, Follow/Auto-zoom, layer toggling) plus a pre-existing CSP regression. The architecture choices are sound, implementation is careful, and test coverage is thorough. No blocking issues found. A few observations below. Code Quality
The controller reads
The comment explaining why
Extracting this into a shared hook was the right call. The toolbar previously read raw
setLoaded(false); // prevent sync effects accessing being-removed map
onMapReadyRef.current?.(null); // signal Follow3DController to bail
map.remove(); // tear down
mapRef.current = null;The sequence is correct for StrictMode's dev double-mount. Observations
const lines3D: Line3DFeature[] = useMemo(
() => [...neighborLines3D.lines, ...tracerouteLines3D.lines],
[neighborLines3D.lines, tracerouteLines3D.lines], // ← correct
);This is correct. But note the comment at line 168 in the original code was updated to say
const selKey = config.selectedNodeIds.join('|');
const handle3DViewChange = useCallback(
(view: Map3DViewState) => { mapViewRef.current = view; },
[mapViewRef], // mapViewRef is a stable RefObject — [] expresses intent more clearly
);Functionally correct since
The inline comment explaining this is an intentional 3D epic non-goal (not a gap), and the test at
const hostname = parsed.hostname.startsWith('{s}.')
? `*.${parsed.hostname.slice('{s}.'.length)}`
: parsed.hostname;Handles the leading Test CoverageCoverage is excellent. Key suites added:
SecurityNo concerns. The CSP changes add legitimate tile hosts to Performance
VerdictThe logic is correct, the architecture choices (ref for camera state, The two minor items ( |
|
Thanks — all five items triaged, addressed in 855380e. 1. You're right that these can disagree. The window is between the canvas falling back (immediate, on Rather than duplicating the capabilities expression into a second file, I extracted it into Mutation-tested: reverting the toolbar to 2. 3. 4. The reasoning holds generally, but not in this codebase. ESLint's That's a new violation in a baselined file, so 5. Browser validation — the tileset selector overlay is confirmed on the dev container; that session is what surfaced the CSP bug now covered in section D of the description. The Full suite 11,876 passed / 0 failed; both typechecks and |
Closes #4371.
Reported by Wilhelm via Discord as three bugs in the Map Analysis 3D view. They all trace to one gap — the 3D branch of
MapAnalysisCanvaswas never given the plumbing the 2D branch has — so this fixes them together rather than as three point patches, as the issue asked. A fourth bug surfaced during browser testing (see D).A. Switching to 3D reset the view to a fixed fallback
FALLBACK_CENTER = [30, -90]fed staticcenter/zoominto<Base3DMap>, with no mechanism to carry the 2D view across.Both surfaces now publish their live camera into a shared
mapViewRefonMapAnalysisContext— 2D via a newMapViewStateController, 3D via a newonViewChangeprop onBase3DMap. Whichever branch mounts next seedscenter/zoomfrom it, falling back to the Default Map Center only when no map has reported yet.A ref rather than state on purpose:
moveendfires continuously while panning and nothing renders from this value, so re-rendering every context consumer (all the map layers) per frame would be pure cost.Base3DMapalso takespitch/bearingnow, so a 3D → 2D → 3D round-trip restores the camera angle Leaflet has no way to hold.B. The Follow button did nothing in 3D
<FollowController />is auseMap()controller, rendered only inside<BaseMap>'s children. NewFollow3DControllermirrors it case for case against the MapLibre camera, andFollowResumeButtonis mounted in the 3D branch too.Base3DMapgained anonMapReadyhandle for it — the 3D analogue ofBaseMap'smapRef, since MapLibre has nouseMap()child context.Auto-zoom deliberately does not call
map.fitBounds. Two MapLibre behaviors make the obvious port wrong:fitBoundsroutes throughflyTo, which ignoresanimate: false— onlyprefers-reduced-motionmakes it jump — so every follow update would animate.cameraForBoundscall defaults bearing to0, so a rotated 3D camera would be yanked north-up on every update.Resolving the camera explicitly with the current bearing and
jumpTo-ing it keeps bearing and pitch, and firesmoveendsynchronously the way the 2D controller'sanimate: falsedoes — which is what the shared programmatic-move/pause guard depends on.C. Map layers couldn't be switched in 3D
Base3DMaptakes an already-resolvedbasemapand already swaps its raster source in place, so the selector goes in as a sibling rather than behind a prop.config.layers.markers.enabled(they were unconditional).D. Blank 3D basemap on some tilesets (CSP) — found in browser testing
With C making the tileset selector reachable from 3D, testing on the dev container surfaced a console flood and a blank basemap:
The
connect-srclist of built-in tile hosts indynamicCsp.tshad drifted from theTILESETScatalog —tile.openstreetmap.fr(the "OSM Humanitarian" tileset) was never added.This stayed invisible because the two map surfaces fetch tiles through different CSP directives:
<img src>img-src'self' data: http: https:— allows anythingfetch()connect-srcSo that tileset has worked in 2D and been blank in 3D since the 3D view shipped in #3826. This is a pre-existing bug, not a regression from this branch — folded in here because it is the same user-visible complaint (a layer you "switched to" that renders nothing), and because C is what makes it reachable. Happy to split it into its own issue if you'd rather keep the PR narrow.
Also fixed in the same commit:
extractHostFromUrlnow rewrites a leading{s}subdomain placeholder to a*wildcard.new URL()parseshttps://{s}.example.com/…and returns the literal hostname{s}.example.com, which as a CSP source matches nothing — the same blank-basemap-in-3D failure for anyone on a custom tileset with a subdomain placeholder.dynamicCsp.test.tsnow imports the realTILESETScatalog and asserts every built-in tileset's host is reachable underconnect-src, so adding a tileset without its CSP entry fails CI rather than someone's browser. The guard was mutation-tested: removing the.frentry fails exactly theosmHotcase.Tests
New suites for both controllers (
Follow3DController.test.tsx,MapViewStateController.test.tsx), plus new blocks inBase3DMap.test.tsx(camera reporting, pitch/bearing defaults, the map handle's load/unmount lifecycle),MapAnalysisCanvas.test.tsx(view carryover both directions, follow wiring, tileset selector, markers gating),MapAnalysisToolbar.test.tsx(2D-only gating, and that it leaves persisted state alone), anddynamicCsp.test.ts(catalog-driven host coverage +{s}handling).Follow3DController.test.tsxmirrorsFollowController.test.tsxcase for case, so the two stay comparable — including pinning the same 15%-padded bounds box, transposed into MapLibre's lng-first order.Verification
tsc --noEmitclean on bothtsconfig.jsonandtsconfig.server.json;lint:ciclean.--no-cachebuild from an isolated worktree, bundle grepped to confirm this code was actually served). That run is what surfaced D; the servedconnect-srcnow includeshttps://*.tile.openstreetmap.frand the 3D basemap renders.🤖 Generated with Claude Code
https://claude.ai/code/session_01JyRkje9qCerXExJMxjeBRT