feat(map): spiderfy overlapping markers on Map Analysis and Unified maps (#3612)#3618
Conversation
…aps (#3612) The per-source NodesTab map fanned out overlapping/co-located markers via the shared SpiderfierController + useMarkerSpiderfier (OverlappingMarkerSpiderfier). Map Analysis used the hook with weak library defaults (20px nearbyDistance), and the Unified/Dashboard map had no spiderfy at all. Make all three maps use the SAME spiderfy behavior: - Extract the per-source map's tuning into SHARED_SPIDERFIER_OPTIONS (50px nearbyDistance etc.) in useMarkerSpiderfier; SpiderfierController and NodeMarkersLayer now consume it so every surface fans out identically. The larger radius reliably groups co-located and estimated-position nodes that collapse onto the same anchor point. - Add the shared SpiderfierController to DashboardMap and bridge each <Marker> ref into it (same stable per-key ref-handler pattern as NodeMarkersLayer), preserving custom icons, popups/click-to-select, transport/age filters, and source attribution. - Make SpiderfierController's zoomLevel prop optional so the shared maps can mount it without a zoom prop (NodesTab still passes one). Custom marker glyphs, the Map Analysis node-type filter, and per-node "Hide from Map" are untouched — filtered-out nodes never render a Marker, so they never enter the spiderfier. Tests: new SpiderfierController.test.tsx (shared tuning + imperative ref API); DashboardMap.test.tsx asserts the controller mounts. Closes #3612 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
|
Claude finished @Yeraze's task —— View job PR Review: feat(map): spiderfy overlapping markers on Map Analysis and Unified mapsTasks
OverviewThis PR is well-structured and correctly extends the existing spiderfy pattern across all three map surfaces. The code is consistent, the approach is sound, and the test coverage is appropriately scoped. Below are observations from most critical to least. Issues Found1.
|
…nified maps (#3685) (#3687) * fix(map): make spiderfy work + stop it auto-collapsing on Map Analysis & Unified maps Two bugs left spiderfy non-functional on the Map Analysis view and the Unified/MeshCore (Dashboard) map, even though #3618 added the controller. 1) Registration timing (spiderfy never fired). React invokes a <Marker ref> callback during the commit phase, BEFORE useMarkerSpiderfier's init effect creates the OverlappingMarkerSpiderfier. Markers present at first mount (Map Analysis / Unified data is already cached on revisit) therefore tried to register while the instance was still null and were silently dropped, so clicking their pile never fanned out. The per-source NodesTab map only 'worked' by timing luck (its markers mount on a later render after async data loads). Fix: buffer markers added before init and flush them into the spiderfier the moment it's created. 2) Auto-collapse after a few seconds (#3685). On every poll the node lists rebuilt fresh icon objects and [lat,lng] position arrays. react-leaflet calls marker.setLatLng()/setIcon() whenever those prop references change, and doing so on a spiderfied marker snaps it back to its anchor, collapsing the fan. Fix: cache position+icon by value (per spiderfier key) in DashboardMap and NodeMarkersLayer so an unchanged marker keeps identical refs across refreshes and the fan persists — the same churn the NodesTab map avoids via a memoized marker. Adds a regression test that registers a marker in a layout effect (pre-init timing, like a real <Marker ref>) and asserts it lands in the spiderfier. Full suite: 7338 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5 * fix(map): stop spiderfy collapse on re-render (ref null-bounce) react-leaflet forwards its <Marker> ref via useImperativeHandle(ref, () => instance) with NO dependency array, so React bounces the parent ref callback null→instance on EVERY re-render — not just mount/unmount. The bridge's null branch treated that as a removal and called removeMarker on a still-present, often spiderfied marker; OMS auto-unspiderfies when a spiderfied marker is removed, so any selection change (Map Analysis) or polled-data refresh (Unified) instantly collapsed the fan (#3685). Fix: register on an instance only (addMarker is idempotent) and ignore the null bounce. Genuine removals are reconciled by a new effect keyed off the rendered key set, so a node that ages out / is filtered still unregisters. Applied to both NodeMarkersLayer (Map Analysis) and DashboardMap (Unified). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5 * fix(map): spiderfy MeshCore markers + popup z-order on Map Analysis Two Map-Analysis-specific issues surfaced in live testing: 1. MeshCore markers never spiderfied. NodeMarkersLayer keyed markers by `${sourceId}:${nodeNum}`, but every MeshCore node has nodeNum 0, so all MeshCore nodes in a source collapsed onto one key (`…:0`) — only one registered with the spiderfier, so their piles never fanned. Meshtastic/MQTT nodes have real nodeNums and were unaffected. Key MeshCore nodes by their unique nodeId instead (matching DashboardMap, which already did this). Verified live: a co-located MeshCore group that registered 1 marker before now registers all of them and fans on click. 2. Detail popup rendered under the markers. MapAnalysisCanvas wraps the layer in <Pane name="markers" zIndex=600>, so the <Popup> inherited that pane instead of Leaflet's default popupPane (z700) and markers painted over it. Pin the popup to popupPane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5 * feat(meshcore): spiderfy co-located contacts on the MeshCore source map The dedicated per-MeshCore-source map (MeshCoreMap, shown under a MeshCore source's Nodes view) had no spiderfier at all, so co-located contacts piled up and couldn't be individually selected. Add the same SpiderfierController bridge the other maps use, with the robust marker registration (ignore react-leaflet's null ref-bounce, reconcile genuine removals from the rendered key set, keep position/icon refs stable). Markers key on publicKey (already unique here). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5 * test(map): resolve spiderfier ESM build under Vitest Adding the spiderfier to MeshCoreMap means MeshCoreSourcePage.test (which renders it with a real react-leaflet map) now constructs an OverlappingMarkerSpiderfier. The package's `main` is a UMD bundle whose named export isn't a constructor once Vitest externalizes it, so the test threw 'OverlappingMarkerSpiderfier is not a constructor' on CI. Alias the package to its ESM `dist/omsleaflet.js` (real class export) for Vitest only — the app build already uses the ESM entry, so it's unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Brings the per-source NodesTab map's marker spiderfy behavior to the Map Analysis view and the Unified/Dashboard map, so co-located / overlapping markers (same tower, same building, or estimated-position nodes that collapse onto a single anchor) can be fanned out and individually selected on every map surface.
Closes #3612
What was wrong
SpiderfierController+useMarkerSpiderfierhook, which wrapsOverlappingMarkerSpiderfier(ts-overlapping-marker-spiderfier-leaflet) with a tuned 50pxnearbyDistance.NodeMarkersLayer): used the hook but with the library's weak defaults ({ keepSpiderfied: true }→ 20pxnearbyDistance), missing near-but-not-identical co-located nodes.DashboardMap): plain<Marker>s with no spiderfy at all.Approach — reuse, don't reinvent
SHARED_SPIDERFIER_OPTIONSinuseMarkerSpiderfier.ts(50pxnearbyDistance, 50px foot separation, keep-spiderfied, shared leg colors).SpiderfierControllerandNodeMarkersLayernow both consumeSHARED_SPIDERFIER_OPTIONS, so all surfaces fan out identically.SpiderfierControllertoDashboardMapand bridged each<Marker>ref into it using the same stable per-key ref-handler pattern already used byNodeMarkersLayer.SpiderfierController'szoomLevelprop optional (it was unused) so the shared maps can mount it with no zoom prop; NodesTab keeps passing one.No new clustering library —
OverlappingMarkerSpiderfierdraws its legs withL.Polyline, so no extra CSS/assets are needed (consistent with the per-source map, which imports none).Preserved
createNodeIcon), popups, and click-to-select.<Marker>, so they never enter the spiderfier or its clusters.SpiderfierControllerwith the same values, now sourced from the shared constant).Tests
SpiderfierController.test.tsx: asserts the shared tuning (50px etc.), that the controller initializes the spiderfier withSHARED_SPIDERFIER_OPTIONS, that its imperative add/remove ref API works (the marker bridge), and thatzoomLevelis optional.DashboardMap.test.tsx: asserts the sharedSpiderfierControllermounts.Verification
success=true, 7113 passed, 0 failed (JSON reporter).tsc -p tsconfig.server.json --noEmit: no new errors in changed files (5 pre-existing errors inTelemetryChart.tsx, untouched).tsc --noEmit(frontend): 57 pre-existing errors, none in changed files.🤖 Generated with Claude Code