Skip to content

feat(map): spiderfy overlapping markers on Map Analysis and Unified maps (#3612)#3618

Merged
Yeraze merged 1 commit into
mainfrom
feat/3612-spiderfy-all-maps
Jun 22, 2026
Merged

feat(map): spiderfy overlapping markers on Map Analysis and Unified maps (#3612)#3618
Yeraze merged 1 commit into
mainfrom
feat/3612-spiderfy-all-maps

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 22, 2026

Copy link
Copy Markdown
Owner

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

  • Per-source NodesTab map (working reference): fans out overlapping markers via the shared SpiderfierController + useMarkerSpiderfier hook, which wraps OverlappingMarkerSpiderfier (ts-overlapping-marker-spiderfier-leaflet) with a tuned 50px nearbyDistance.
  • Map Analysis (NodeMarkersLayer): used the hook but with the library's weak defaults ({ keepSpiderfied: true } → 20px nearbyDistance), missing near-but-not-identical co-located nodes.
  • Unified/Dashboard map (DashboardMap): plain <Marker>s with no spiderfy at all.

Approach — reuse, don't reinvent

  • Extracted the per-source map's tuning into SHARED_SPIDERFIER_OPTIONS in useMarkerSpiderfier.ts (50px nearbyDistance, 50px foot separation, keep-spiderfied, shared leg colors).
  • SpiderfierController and NodeMarkersLayer now both consume SHARED_SPIDERFIER_OPTIONS, so all surfaces fan out identically.
  • Added the same SpiderfierController to DashboardMap and bridged each <Marker> ref into it using the same stable per-key ref-handler pattern already used by NodeMarkersLayer.
  • Made SpiderfierController's zoomLevel prop optional (it was unused) so the shared maps can mount it with no zoom prop; NodesTab keeps passing one.

No new clustering library — OverlappingMarkerSpiderfier draws its legs with L.Polyline, so no extra CSS/assets are needed (consistent with the per-source map, which imports none).

Preserved

  • Custom marker glyphs (role / advType icons via createNodeIcon), popups, and click-to-select.
  • Map Analysis node-type filter and per-node "Hide from Map" — filtered-out nodes never render a <Marker>, so they never enter the spiderfier or its clusters.
  • Source attribution, transport/age filters, and all layer toggles on the Unified map.
  • Per-source NodesTab map behavior is unchanged (it already used SpiderfierController with the same values, now sourced from the shared constant).

Tests

  • New SpiderfierController.test.tsx: asserts the shared tuning (50px etc.), that the controller initializes the spiderfier with SHARED_SPIDERFIER_OPTIONS, that its imperative add/remove ref API works (the marker bridge), and that zoomLevel is optional.
  • DashboardMap.test.tsx: asserts the shared SpiderfierController mounts.
  • The Leaflet DOM bridge inside the map components is hard to unit-test (the real spiderfier needs a live Leaflet map), so the imperative bridge contract is covered by the controller test; the live fan-out is verified by reading against the working per-source reference.

Verification

  • Full Vitest suite: success=true, 7113 passed, 0 failed (JSON reporter).
  • tsc -p tsconfig.server.json --noEmit: no new errors in changed files (5 pre-existing errors in TelemetryChart.tsx, untouched).
  • tsc --noEmit (frontend): 57 pre-existing errors, none in changed files.

🤖 Generated with Claude Code

…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
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(map): spiderfy overlapping markers on Map Analysis and Unified maps

Tasks

  • Gather context and understand the request
  • Read changed files
  • Analyze code quality, bugs, performance, security, and test coverage
  • Post review feedback

Overview

This 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 Found

1. getMarkerRef is recreated on every render — potential stale-closure / ref leak

DashboardMap.tsx:168getMarkerRef is defined as a plain function body inside the component's render function. On every re-render, a new getMarkerRef function is created; while the refHandlers Map is stable (it's a useRef), the function that mutates it is not memoized. This is identical to the same pattern already present in NodeMarkersLayer.tsx:59, so it's a pre-existing pattern and consistent — but it means the Marker ref prop receives a new function object on every render, which forces React to call ref(null) then ref(element) on every re-render. In practice this calls removeMarker then addMarker on every prop change.

The same pattern exists in NodeMarkersLayer.tsx, so this is pre-existing behavior, but now it's duplicated. A shared utility or useCallback-based wrapper would prevent the unnecessary remove/add cycle.

2. Marker key uses nodeId but markerKey (spiderfier) uses nodeNum

DashboardMap.tsx:425:

<Marker
  key={nodeId}           // React reconciliation key
  ref={getMarkerRef(markerKey)}  // spiderfier key = sourceId:nodeNum

When nodeId is undefined (nodes without a resolved nodeId), the fallback at line 418-422 produces a non-null markerKey, but key={nodeId} would be key={undefined}. React doesn't error on an undefined key but treats it as key={null}, potentially causing instability in reconciliation. The marker key and React key should be in sync, or at least both non-null.

3. SpiderfierOptions type is defined in the hook file but used externally

useMarkerSpiderfier.ts:45SpiderfierOptions is a manually maintained interface that duplicates what ts-overlapping-marker-spiderfier-leaflet should export as OmsOptions or similar. If the library updates its option types, this local interface could silently drift. Consider importing the library's type directly and aliasing it, or adding a type-compatibility check.

This is pre-existing but worth noting since SHARED_SPIDERFIER_OPTIONS is now typed against this local interface.

4. SpiderfierController displays an unused destructured props warning

SpiderfierController.tsx:36:

({}, ref) => {

The empty destructure {} for props is unusual but harmless. A more idiomatic form is (_props, ref) or simply (_, ref) to signal intentional unused parameter. Minor, but could confuse readers.


Code Quality Observations

Duplication of getMarkerRef pattern

The identical ref-handler factory appears in both DashboardMap.tsx:168-185 and NodeMarkersLayer.tsx:59-76. Now that this pattern is used in three places (including the NodesTab which presumably contains the reference implementation), it should be extracted to a shared hook, e.g. useSpiderfierRefHandlers(spiderfierRef). This would eliminate the duplication and make the pattern testable in isolation.

SHARED_SPIDERFIER_OPTIONS is well-documented

The JSDoc on lines 11-21 of useMarkerSpiderfier.ts is clear and explains the rationale for the 50px choice. Good.

SpiderfierController.tsx is now minimal and clean

The simplification from removing the zoom-dependent logic is good — the component is now a thin adapter that delegates entirely to the hook.


Test Coverage Assessment

Good:

  • SpiderfierController.test.tsx correctly verifies the shared tuning constants and the imperative ref API using mock injection — this is the right testing strategy given the Leaflet DOM dependency.
  • DashboardMap.test.tsx correctly stubs SpiderfierController so it renders without a live map and asserts it's mounted — appropriate smoke test.

Missing / Gaps:

  • No test verifies that NodeMarkersLayer now uses SHARED_SPIDERFIER_OPTIONS (the equivalent of the SpiderfierController test). A short test similar to SpiderfierController.test.tsx that mocks useMarkerSpiderfier and checks the options passed in would close this gap.
  • The ref-handler bridge behavior (add/remove cycle on remount, deduplication of same-position markers) is only tested at the useMarkerSpiderfier level, not at the DashboardMap integration level. Acceptable given the Leaflet mock complexity, but worth noting.

Performance Considerations

  • 50px nearbyDistance means the spiderfier checks a larger radius on every map interaction. This is the intended behavior, but on very large meshes (hundreds of markers) it could slow down the grouping calculation. This matches the existing per-source map behavior, so it's consistent.
  • The useEffect in useMarkerSpiderfier.ts:154-158 that updates nearbyDistance without recreating the instance is a good optimization for live tuning, but it's not called by SpiderfierController (which passes SHARED_SPIDERFIER_OPTIONS as a constant object). This is fine — the object reference is stable and the effect won't re-run.

Summary

The PR achieves its goal cleanly. The main issues to consider:

  1. Moderate: The getMarkerRef duplication between DashboardMap.tsx and NodeMarkersLayer.tsx — should be extracted to a shared utility before this pattern spreads further.
  2. Minor: key={nodeId} can be undefined for some nodes; should align with markerKey.
  3. Minor: Missing test for NodeMarkersLayer receiving SHARED_SPIDERFIER_OPTIONS.
  4. Nitpick: SpiderfierController's ({}, ref) destructure style.

The approach is correct and the consistency across all three map surfaces is well-executed. No security concerns. No regressions to existing behavior are apparent.

@Yeraze Yeraze merged commit 3e4c2df into main Jun 22, 2026
19 of 20 checks passed
@Yeraze Yeraze deleted the feat/3612-spiderfy-all-maps branch June 22, 2026 01:04
Yeraze added a commit that referenced this pull request Jun 24, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spiderfy missing on Map Analysis and Unified map — overlapping markers are not separable

1 participant