Skip to content

feat(map): coincident-node disambiguation picker (#2049)#2108

Open
bruschill wants to merge 2 commits into
meshtastic:mainfrom
bruschill:feat/map-coincident-node-picker-2049
Open

feat(map): coincident-node disambiguation picker (#2049)#2108
bruschill wants to merge 2 commits into
meshtastic:mainfrom
bruschill:feat/map-coincident-node-picker-2049

Conversation

@bruschill

@bruschill bruschill commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What changed?

Reworks how the mesh map presents coincident (stacked) nodes, meaning nodes that report the same or nearly the same location, which fixed-size pins can never separate by zooming (#2049).

  • Before: computeSpreadOverrides fanned coincident pins outward into a roughly 14 m ring, moving every pin off its real GPS location.
  • After: coincident nodes stay on their true coordinate. Tapping the stack opens a "Select a Node (N)" picker built from the standard node-list cell (NodeListItem), and tapping a row opens that node's detail.

The picker is reachable whether map clustering is on (tap the count badge) or off (tap the overlapping pins, since the top pin's tap now surfaces the whole occluded stack, so a covered node is never untappable).

Implementation notes:

  • Removed computeSpreadOverrides and spreadOverrides (the fan-out) and the per-pin dictionary lookup in the annotation-sync coordinate: closure.
  • ClusterMapView.didSelect: a cluster whose members span less than 5 m routes to onColocatedStack (guarded to more than one member); wider clusters still zoom to fit.
  • MeshMapMK.presentNodeSelection: clustering-off grouping via the pure MeshMapPositionSnapshot.colocated(with:in:withinMeters:). Picker input is de-duplicated by nodeNum and sorted by name.
  • MapColocation.spreadMeters is the single source of truth for the 5 m threshold, shared by both tap paths and the tests.
  • DEBUG-only QA seed: --meshtastic-cluster-demo (pairs, colocated10|30|50, stacks) plus --cluster-demo-no-clustering.

Why did it change?

The fan-out ring fixed the "stuck cluster" symptom but moved pins off their real location, which is misleading on a map, and it recomputed a spatial-clustering pass on every position refresh. Moving to an on-demand picker keeps pin positions honest and shifts the cost off the hot path:

  • Recurring refresh path (improved): removes the per-refresh O(n) spatial-hash and single-link computeSpreadOverrides clustering, plus the per-pin spreadOverrides[...] dictionary lookup in the sync coordinate: closure, so steady-state map cost goes down.
  • Added work is per discrete tap only: colocated(...) is a single O(n) scan bounded by the existing denseMapPositionLimit = 700 pin cap, run once per tap, never per frame or per packet. dedupedByNodeNumSortedByName is O(n) plus O(n log n), once per picker open.

In short, the change removes recurring O(n) work from the map and adds only bounded, per-tap O(n) work.

How is this tested?

  • Unit: MeshtasticTests/MapColocatedNodesTests.swift, 16 swift-testing cases for the grouping policy: membership (lone, pair, 10-stack, sub-meter GPS jitter), strict less-than threshold boundary, custom threshold, neighborhood scoping (adjacent stacks don't merge), empty input, and picker de-dupe-by-nodeNum plus sort-by-name. All pass.
  • Manual: verified on the iPhone 17 simulator via the DEBUG seed across every scenario, clustering on and off (see screenshots).
  • swiftlint clean on changed files (no new violations); build succeeds.

Screenshots/Videos (when applicable)

All captured on the iPhone 17 simulator via the DEBUG --meshtastic-cluster-demo seed.

Before and after

computeSpreadOverrides used to scatter coincident nodes into a ring, pushing every pin off its real GPS location. Now they stay put on their true coordinate, and you tap to disambiguate.

Before: fan-out ring, pins displaced about 14 m After: one badge, pins on their true location
Before: coincident nodes fanned into a ring After: coincident nodes as a single count badge

Tapping a stack: pick a node, open it

1. Coincident stack (clustering on) 2. "Select a Node (N)", reuses the node-list cell 3. Node detail
Cluster count badge Select a Node picker Node detail after selecting a row

Works with clustering turned off

With clustering off there is no count badge, so the nodes render as overlapping pins. A tap on the stack still opens the picker, so an occluded node is never stranded under the top pin.

Overlapping pins (clustering off) Tapping opens "Select a Node (10)"
Overlapping pins with clustering off Picker opened from a plain pin tap

More scenarios

Boundary-straddling pairs, scale (30 and 50 nodes), and multiple independent stacks
Two boundary-straddling pairs "Select a Node (2)"
Two pairs on the map Select a Node (2)
30 coincident nodes "Select a Node (30)"
30 coincident nodes badge Select a Node (30)
50 coincident nodes "Select a Node (50)"
50 coincident nodes badge Select a Node (50)
Three separate stacks Each opens its own picker
Three independent stacks Per-stack Select a Node picker

Checklist

  • My code adheres to the project's coding and style guidelines.
  • I have conducted a self-review of my code.
  • I have commented my code, particularly in complex areas.
  • I have verified whether these changes require updates to the in-app documentation under docs/user/ or docs/developer/, and updated accordingly. Updated docs/user/map.md (Node Pins section, "Overlapping nodes at the same spot") and regenerated the bundled docs.
  • I have tested the change to ensure that it works as intended.

Summary by CodeRabbit

  • New Features
    • Added node disambiguation for overlapping map pins, including a “Select a Node” flow for coincident nodes.
    • Improved coincident cluster interaction: tapping tightly colocated clusters now offers node selection instead of zoom-only behavior.
    • Introduced a cluster demo mode to seed deterministic coincident-node scenarios for testing.
  • Documentation
    • Expanded the Map documentation with an “Overlapping nodes at the same spot” section.
  • Tests
    • Added automated coverage for coincident-node grouping, sorting/deduping, and selection/picker behavior.

Replace the coincident-node fan-out (computeSpreadOverrides) with an
on-demand "Select a Node" picker so stacked pins stay on their true GPS
location instead of being scattered into a ring.

- Remove the per-refresh O(n) spatial-clustering fan-out and the per-pin
  spreadOverrides dictionary lookup in the annotation-sync coordinate closure.
- ClusterMapView: a tapped cluster whose members span < 5 m routes to
  onColocatedStack (guarded to members > 1); wider clusters still zoom-to-fit.
- MeshMapMK: with clustering OFF (no cluster annotation), a pin tap groups the
  tapped node with coincident neighbors via the pure
  MeshMapPositionSnapshot.colocated(with:in:withinMeters:) so occluded nodes
  stay reachable. Picker input is de-duplicated by nodeNum and sorted by name.
- Picker reuses the standard node-list cell (NodeListItem) with a
  "Select a Node (N)" count title; a row tap opens that node's detail.
- MapColocation.spreadMeters is the single source of truth for the 5 m threshold.
- DEBUG-only QA seed (--meshtastic-cluster-demo: pairs/colocatedN/stacks, plus
  --cluster-demo-no-clustering) to reproduce every case in the simulator.
- Docs: update docs/user/map.md (+ regenerated bundle).

Tests: MeshtasticTests/MapColocatedNodesTests.swift (16 swift-testing cases).
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 38d1cf5b-3a65-4264-a917-8956d61ebed8

📥 Commits

Reviewing files that changed from the base of the PR and between c0d6b8e and b8dfbd2.

📒 Files selected for processing (3)
  • Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift
  • Meshtastic/Views/Nodes/MeshMapMK.swift
  • MeshtasticTests/MapColocatedNodesTests.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift
  • MeshtasticTests/MapColocatedNodesTests.swift
  • Meshtastic/Views/Nodes/MeshMapMK.swift

📝 Walkthrough

Walkthrough

Adds deterministic cluster-demo seed data and replaces map pin fan-out with picker-based disambiguation for coincident nodes. The change updates MapKit selection handling, introduces colocated-node helpers and state, adds coverage, and documents overlapping-pin behavior.

Changes

Coincident Node Handling

Layer / File(s) Summary
Cluster demo seed scenarios
Meshtastic/Persistence/PerformanceSeedData.swift
Adds cluster-demo selection, scenario-driven node generation, coincident coordinates, map defaults, and demo metadata.
Colocation data contracts and helpers
Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift
Defines the shared spread threshold, colocated stack payload, grouping logic, and picker ordering helpers.
Map selection and picker flow
Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift, Meshtastic/Views/Nodes/MeshMapMK.swift
Routes coincident cluster and pin taps to a node picker, then presents the selected node after picker dismissal instead of offsetting coordinates.
Colocation validation and map documentation
MeshtasticTests/MapColocatedNodesTests.swift, Meshtastic/Resources/docs/..., docs/user/map.md
Tests grouping, thresholds, selection semantics, ordering, and documents overlapping-node selection behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MeshMapMK
  participant ClusterMapView
  participant MeshMapPositionSnapshot
  participant ColocatedNodeStack
  participant NodeDetailSheet
  MeshMapMK->>ClusterMapView: register selection callbacks
  ClusterMapView->>MeshMapPositionSnapshot: identify colocated members
  ClusterMapView->>MeshMapMK: send colocated items
  MeshMapMK->>ColocatedNodeStack: present node picker
  ColocatedNodeStack->>MeshMapMK: return selected node
  MeshMapMK->>NodeDetailSheet: present node details
Loading

Poem

A bunny taps where node pins meet,
Finds hidden friends in one neat sheet.
No more hopping pins apart,
Pick the one that has your heart.
Maps now stack with clever art!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: coincident-node disambiguation in the map.
Description check ✅ Passed The description follows the required template and includes what changed, why, testing, screenshots, and checklist items.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift`:
- Around line 763-770: Update the spread calculation in the cluster member
annotation flow to measure diagonal distance rather than max(rect.size.width,
rect.size.height), so a bounding box’s corner-to-corner separation matches the
CLLocation.distance behavior used by the pin path. Preserve the existing
Self.colocatedSpreadMeters comparison and colocated-stack routing.

In `@Meshtastic/Views/Nodes/MeshMapMK.swift`:
- Around line 846-866: Update presentNodeSelection to create the picker-ready
array using MeshMapPositionSnapshot.dedupedByNodeNumSortedByName(coincident)
before branching. Use that deduplicated array to decide whether to call
presentColocatedStack or selectNode, and pass the same array to the picker
without re-deduplicating.
- Around line 891-902: Update the demo activation gate in the framing logic
around PerformanceSeedData.configuration and the --meshtastic-cluster-demo
argument to also honor MESHTASTIC_CLUSTER_DEMO environment activation. Ensure
environment-launched QA runs enter the existing coordinate-centroid and camera
framing path without changing its behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 118a280d-9078-4d3a-a033-bc6a89ba4e2c

📥 Commits

Reviewing files that changed from the base of the PR and between e531f15 and c0d6b8e.

📒 Files selected for processing (9)
  • Meshtastic/Persistence/PerformanceSeedData.swift
  • Meshtastic/Resources/docs/index.json
  • Meshtastic/Resources/docs/markdown/user/map.md
  • Meshtastic/Resources/docs/user/map.html
  • Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift
  • Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift
  • Meshtastic/Views/Nodes/MeshMapMK.swift
  • MeshtasticTests/MapColocatedNodesTests.swift
  • docs/user/map.md

Comment thread Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift
Comment thread Meshtastic/Views/Nodes/MeshMapMK.swift
Comment thread Meshtastic/Views/Nodes/MeshMapMK.swift Outdated
- ClusterMapView: measure the coincident-cluster span as the bounding-box
  diagonal (hypot) instead of max(width,height), so the clustering-on path
  agrees with the CLLocation.distance metric used by the clustering-off pin path.
- MeshMapMK.presentNodeSelection: de-duplicate by nodeNum before branching, so
  duplicate snapshots for one node can't open a one-row picker.
- MeshMapMK: gate the DEBUG tight-frame on PerformanceSeedData.configuration
  style == .clusterDemo so env-var-activated (MESHTASTIC_CLUSTER_DEMO) QA runs
  also get the demo framing, not just the launch-arg path.
- Add a test covering coincident duplicates collapsing to a single entry.
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.

1 participant