Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 169 additions & 5 deletions Meshtastic/Persistence/PerformanceSeedData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import Foundation
enum SeedStyle {
case performance
case marketing
/// A tiny, hand-placed mesh for demoing/QA'ing the map's coincident-node disambiguation picker.
/// See `ClusterDemoSeed`.
case clusterDemo
}

@MainActor
Expand Down Expand Up @@ -62,6 +65,27 @@ enum PerformanceSeedData {
)
}

// Coincident-node demo: a handful of nodes deliberately placed on (near-)coincident coordinates
// so the map's un-splittable stack + disambiguation picker reproduce on demand. Opens straight
// to the map. Gate: `--meshtastic-cluster-demo`.
if arguments.contains("--meshtastic-cluster-demo") || boolValue("MESHTASTIC_CLUSTER_DEMO", environment: environment) {
return PerformanceSeedConfiguration(
nodeCount: ClusterDemoSeed.nodeCount,
telemetryHistoryPerNode: 1,
localStatsHistoryPerNode: 1,
positionHistoryPerNode: 1,
directMessageCount: 0,
channelMessageCount: 0,
resetStore: true,
compactNodeList: false,
disableDiscovery: true,
initialTab: .map,
opensLocalStatsLog: false,
localStatsSameHourSeed: false,
style: .clusterDemo
)
}

let enabled = arguments.contains("--meshtastic-perf-seed") || environment["MESHTASTIC_PERF_SEED_NODES"] != nil
guard enabled else { return nil }

Expand Down Expand Up @@ -97,6 +121,13 @@ enum PerformanceSeedData {
// mesh. All node positions are on land.
UserDefaults.standard.set(false, forKey: "enableMapClustering")
}
if configuration.style == .clusterDemo {
// Force clustering ON by default so coincident nodes collapse into one badge and tapping it
// exercises the disambiguation picker. Pass `--cluster-demo-no-clustering` to force it OFF
// instead, verifying a plain pin tap on a fully-occluded stack still opens the picker.
let clusteringOff = ProcessInfo.processInfo.arguments.contains("--cluster-demo-no-clustering")
UserDefaults.standard.set(!clusteringOff, forKey: "enableMapClustering")
}
}

static func seedIfNeeded(using controller: PersistenceController, configuration: PerformanceSeedConfiguration, router: Router) {
Expand Down Expand Up @@ -219,6 +250,14 @@ enum PerformanceSeedData {
// content (names, hardware, roles, healthy telemetry, recent last-heard).
if configuration.style == .marketing {
MarketingSeed.apply(to: node, user: user, metadata: metadata, index: index, now: now)
} else if configuration.style == .clusterDemo {
user.longName = ClusterDemoSeed.longName(for: index)
user.shortName = ClusterDemoSeed.shortName(for: index)
user.hwDisplayName = user.hwModel
node.hopsAway = 0
node.viaMqtt = false
node.lastHeard = now
metadata.time = now
}

context.insert(node)
Expand Down Expand Up @@ -328,17 +367,23 @@ enum PerformanceSeedData {
configuration: PerformanceSeedConfiguration,
context: ModelContext
) {
let baseCoordinate = configuration.style == .marketing
? MarketingSeed.coordinate(for: index)
: bayAreaCoordinate(for: index)
let baseCoordinate: (latitude: Double, longitude: Double)
switch configuration.style {
case .marketing: baseCoordinate = MarketingSeed.coordinate(for: index)
case .clusterDemo: baseCoordinate = ClusterDemoSeed.coordinate(for: index)
case .performance: baseCoordinate = bayAreaCoordinate(for: index)
}
// The cluster demo relies on the *exact* coincident coordinates, so it must not add the
// per-sample walk offset the perf seed uses (it seeds a single position anyway).
let sampleWalk = configuration.style == .clusterDemo ? 0.0 : 0.0001

for sample in 0..<configuration.positionHistoryPerNode {
let position = PositionEntity()
position.altitude = Int32(5 + (index % 600))
position.heading = Int32((index * 17 + sample * 23) % 360)
position.latest = sample == 0
position.latitudeI = Int32((baseCoordinate.latitude + Double(sample) * 0.0001) * 1e7)
position.longitudeI = Int32((baseCoordinate.longitude + Double(sample) * 0.0001) * 1e7)
position.latitudeI = Int32((baseCoordinate.latitude + Double(sample) * sampleWalk) * 1e7)
position.longitudeI = Int32((baseCoordinate.longitude + Double(sample) * sampleWalk) * 1e7)
position.precisionBits = 32
position.rssi = node.rssi
position.satsInView = Int32(5 + (index % 8))
Expand Down Expand Up @@ -907,6 +952,125 @@ extension PerformanceSeedData {
}
}

// MARK: - Coincident-node demo seed

/// A tiny, deterministic mesh for demoing/verifying the map's coincident-node disambiguation picker.
///
/// With the map fan-out removed, nodes that sit within a few meters of each other stay stacked. When
/// clustering is on they collapse into one count badge (tap -> picker); with `--cluster-demo-no-clustering`
/// they render as overlapping pins (a plain pin tap must still open the picker, per the clustering-off
/// path in `MeshMapMK.presentNodeSelection`).
///
/// Scenarios (pick with a launch arg, or `MESHTASTIC_CLUSTER_DEMO_SCENARIO=<name>`; no rebuild needed
/// to switch once compiled):
/// • `pairs` (default) — local + two 2-node pairs ~0.1 m apart.
/// • `--cluster-demo-colocated10/30/50` — one exact-coincident stack of N nodes.
/// • `--cluster-demo-stacks` — three separate small coincident stacks near each other.
/// The map's tight-frame span (`frameSpanDegrees`) scales with the scenario so the stack(s) fill the view.
@MainActor
enum ClusterDemoSeed {
/// Center point all scenarios build around (3rd Ave S, Seattle — matches the earlier screenshots).
private static let center = (lat: 47.6001, lon: -122.3301)
private static let stackMemberCount = 4
private struct StackAnchor { let label: String; let lat: Double; let lon: Double }
/// Three coincident stacks arranged in a ~50 m triangle around `center`; members of each share one coordinate.
private static let stackAnchors: [StackAnchor] = [
StackAnchor(label: "A", lat: 47.60055, lon: -122.33010), // ~50 m north
StackAnchor(label: "B", lat: 47.59970, lon: -122.32948), // ~50 m southeast
StackAnchor(label: "C", lat: 47.59970, lon: -122.33072) // ~50 m southwest
]

/// Raw scenario token: env var wins, else a `--cluster-demo-<token>` launch arg, else `pairs`.
private static var rawScenario: String {
if let env = ProcessInfo.processInfo.environment["MESHTASTIC_CLUSTER_DEMO_SCENARIO"]?.lowercased() {
return env
}
let args = ProcessInfo.processInfo.arguments
for token in ["colocated10", "colocated30", "colocated50", "stacks", "pairs"] where args.contains("--cluster-demo-\(token)") {
return token
}
return "pairs"
}

/// For a `colocatedN` scenario, the coincident node count N; nil otherwise.
private static var colocatedCount: Int? {
switch rawScenario {
case "colocated10": return 10
case "colocated30": return 30
case "colocated50": return 50
default: return nil
}
}
private static var isStacks: Bool { rawScenario == "stacks" }

/// Total seeded nodes (index 0 is always the standalone local node).
static var nodeCount: Int {
if let count = colocatedCount { return 1 + count }
if isStacks { return 1 + stackAnchors.count * stackMemberCount }
return 5
}

/// Map tight-frame span (degrees latitude). Override with `MESHTASTIC_CLUSTER_DEMO_SPAN=<deg>`.
static var frameSpanDegrees: Double {
if let raw = ProcessInfo.processInfo.environment["MESHTASTIC_CLUSTER_DEMO_SPAN"], let value = Double(raw) {
return value
}
switch rawScenario {
case "stacks": return 0.0024
default: return 0.0016
}
}

static func coordinate(for index: Int) -> (latitude: Double, longitude: Double) {
if colocatedCount != nil {
// Local node offset clear of the stack; every other node on the *exact* same point.
return index == 0 ? (center.lat + 0.0003, center.lon - 0.0004) : (center.lat, center.lon)
}
if isStacks {
if index == 0 { return (center.lat + 0.0006, center.lon - 0.0009) } // local, NW, clear of stacks
let anchor = stackAnchors[(index - 1) / stackMemberCount % stackAnchors.count]
return (anchor.lat, anchor.lon) // members coincident per stack
}
// pairs (the near-coincident default)
switch index {
case 1: return (47.6000045, -122.33000) // pair 1
case 2: return (47.6000055, -122.33000) // pair 1, ~0.1 m away
case 3: return (47.6001845, -122.33000) // pair 2, ~20 m north
case 4: return (47.6001855, -122.33000) // pair 2, ~0.1 m away
default: return (47.6000950, -122.33028) // local node, standalone (~10 m N, ~21 m W)
}
}

static func longName(for index: Int) -> String {
if index == 0 { return "My Node" }
if colocatedCount != nil { return "Node \(index)" }
if isStacks { return "Stack \(shortName(for: index))" }
switch index {
case 1: return "Demo A (pair 1)"
case 2: return "Demo B (pair 1)"
case 3: return "Demo C (pair 2)"
case 4: return "Demo D (pair 2)"
default: return "Demo Node \(index)"
}
}

static func shortName(for index: Int) -> String {
if index == 0 { return "ME" }
if colocatedCount != nil { return "\(index)" }
if isStacks {
let anchor = stackAnchors[(index - 1) / stackMemberCount % stackAnchors.count]
return "\(anchor.label)\((index - 1) % stackMemberCount + 1)"
}
switch index {
case 1: return "A"
case 2: return "B"
case 3: return "C"
case 4: return "D"
default: return "N\(index)"
}
}
}

// MARK: - Marketing seed content

/// Curated, all-on-land Seattle-metro data for the App Store screenshot seed (`--meshtastic-marketing-seed`).
Expand Down
10 changes: 5 additions & 5 deletions Meshtastic/Resources/docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,21 +240,21 @@
"navOrder": 6,
"keywords": [
"map",
"tap",
"nodes",
"node",
"tap",
"from",
"waypoint",
"show",
"position",
"only",
"coverage",
"position",
"open",
"mesh",
"location",
"site",
"radio",
"open",
"lora",
"location",
"estimate",
"planner",
"path",
Expand All @@ -270,7 +270,7 @@
"via",
"route"
],
"charCount": 8086
"charCount": 8616
},
{
"id": "messages",
Expand Down
4 changes: 4 additions & 0 deletions Meshtastic/Resources/docs/markdown/user/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Each node that has reported a GPS position appears as a colored circle pin on th

Pins update automatically when a new position packet is received from the mesh.

### Overlapping nodes at the same spot

When several nodes report the *same* (or nearly the same) location — for example radios sitting on one desk, or nodes broadcasting a reduced-precision position — their pins stack on top of each other and can't be pulled apart by zooming. Tapping the stack (or the numbered count badge, when node clustering is on) opens a **Select a Node** list of every node at that spot. Pick one to open its detail view. This works whether or not clustering is enabled, so an occluded node is always reachable.

## Filtering Nodes on the Map

Tap the **filter button** (funnel icon, `line.3.horizontal.decrease.circle`) in the bottom-right toolbar to open the Map Filters sheet. When any filter is active, the icon appears **filled** to indicate filtering is in effect.
Expand Down
2 changes: 2 additions & 0 deletions Meshtastic/Resources/docs/user/map.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ <h1>Map &amp; Waypoints</h1>
<h2>Node Pins</h2>
<p>Each node that has reported a GPS position appears as a colored circle pin on the map. The <strong>green solid line</strong> shows a directly connected node; <strong>orange dashed lines</strong> show nodes reached via the mesh. A purple star marks a waypoint. Tap a pin to see the node name, last heard time, signal info, and a shortcut to send a direct message.</p>
<p>Pins update automatically when a new position packet is received from the mesh.</p>
<h3>Overlapping nodes at the same spot</h3>
<p>When several nodes report the <em>same</em> (or nearly the same) location — for example radios sitting on one desk, or nodes broadcasting a reduced-precision position — their pins stack on top of each other and can't be pulled apart by zooming. Tapping the stack (or the numbered count badge, when node clustering is on) opens a <strong>Select a Node</strong> list of every node at that spot. Pick one to open its detail view. This works whether or not clustering is enabled, so an occluded node is always reachable.</p>
<h2>Filtering Nodes on the Map</h2>
<p>Tap the <strong>filter button</strong> (funnel icon, <code>line.3.horizontal.decrease.circle</code>) in the bottom-right toolbar to open the Map Filters sheet. When any filter is active, the icon appears <strong>filled</strong> to indicate filtering is in effect.</p>
<table>
Expand Down
38 changes: 36 additions & 2 deletions Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: UIViewRepre
@ViewBuilder let clusterContent: (Int) -> Cluster
/// Called when the user taps an item's pin. (Tapping a cluster zooms to fit its members instead.)
let onSelect: ((Item) -> Void)?
/// Called when a tapped cluster's members are effectively coincident, so zoom-to-fit can't split
/// them (it would just slam to max zoom on a still-merged stack). Hands the members to the caller
/// for a disambiguation menu. `nil` = fall back to the normal zoom-to-fit.
let onColocatedStack: (([Item]) -> Void)?
/// Apple basemap type + map controls. The offline raster overlay (if any) draws on top.
let configuration: ClusterMapConfiguration
/// Vector overlays (accuracy circles, convex hull, routes, GeoJSON shapes). Diffed by `id`.
Expand Down Expand Up @@ -206,6 +210,7 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: UIViewRepre
cameraCommand: ClusterMapCameraCommand? = nil,
clustering: Bool = true,
onSelect: ((Item) -> Void)? = nil,
onColocatedStack: (([Item]) -> Void)? = nil,
configuration: ClusterMapConfiguration = .init(),
overlays: [ClusterMapOverlay] = [],
coverageAreas: [GeoBounds] = [],
Expand All @@ -223,6 +228,7 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: UIViewRepre
self.cameraCommand = cameraCommand
self.clustering = clustering
self.onSelect = onSelect
self.onColocatedStack = onColocatedStack
self.configuration = configuration
self.overlays = overlays
self.coverageAreas = coverageAreas
Expand Down Expand Up @@ -352,6 +358,7 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: UIViewRepre
coordinator.clusterContent = { AnyView(clusterContent($0)) }
coordinator.regionBinding = region
coordinator.onSelect = onSelect
coordinator.onColocatedStack = onColocatedStack
coordinator.onMapTap = onMapTap
coordinator.onMapLongPress = onMapLongPress
coordinator.suppressRegionUpdates = suppressRegionUpdates
Expand All @@ -369,6 +376,13 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: UIViewRepre
var regionBinding: Binding<MKCoordinateRegion?>?
/// Called when an item pin is tapped (set each render).
var onSelect: ((Item) -> Void)?
/// Called when a tapped cluster is a coincident stack (see `ClusterMapView.onColocatedStack`).
var onColocatedStack: (([Item]) -> Void)?
/// Members closer than this (max span, meters) can't be visually separated by zooming, so a
/// tapped cluster of them is treated as a coincident stack and routed to `onColocatedStack`
/// instead of a max-zoom lurch. Single source of truth in `MapColocation.spreadMeters`, shared
/// with `MeshMapMK`'s clustering-off pin-tap path.
static var colocatedSpreadMeters: Double { MapColocation.spreadMeters }
/// Empty-map tap / long-press handlers (create waypoint), set each render.
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
var onMapLongPress: ((CLLocationCoordinate2D) -> Void)?
Expand Down Expand Up @@ -744,15 +758,33 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: UIViewRepre

func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
if let cluster = view.annotation as? MKClusterAnnotation {
// Expand a cluster: zoom to the bounding rect of its members.
mapView.deselectAnnotation(cluster, animated: false)
// Bounding rect of the members' coordinates.
let rect = cluster.memberAnnotations.reduce(MKMapRect.null) { acc, member in
acc.union(MKMapRect(origin: MKMapPoint(member.coordinate), size: MKMapSize(width: 0, height: 0)))
}
// How far apart are the members on the ground? If they're effectively coincident, zooming
// to fit can't separate them, so route the members to a disambiguation menu instead.
let metersPerPoint = MKMetersPerMapPointAtLatitude(cluster.coordinate.latitude)
// Diagonal (corner-to-corner) of the members' bounding box, so this matches the
// CLLocation.distance metric the clustering-off pin path uses: a 4x4 m box is ~5.7 m
// across, not 4 m, and shouldn't be treated as an un-splittable coincident stack.
let spreadMeters = hypot(rect.size.width, rect.size.height) * metersPerPoint
if spreadMeters < Self.colocatedSpreadMeters, let onColocatedStack {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let items = cluster.memberAnnotations.compactMap { ($0 as? ItemAnnotation<Item>)?.item }
// Only present the picker for a genuine multi-node stack; a lone item (e.g. the rest of
// the cluster was non-Item annotations) falls through to the normal zoom-to-fit so we
// never show a one-row "Select a Node (1)". Mirrors the clustering-off `count > 1` guard.
if items.count > 1 {
onColocatedStack(items)
return
}
}
// Otherwise expand the cluster: zoom to the bounding rect of its members.
if !rect.isNull {
let padded = rect.insetBy(dx: -rect.size.width * 0.3 - 1, dy: -rect.size.height * 0.3 - 1)
mapView.setVisibleMapRect(padded, animated: true)
}
mapView.deselectAnnotation(cluster, animated: false)
return
}
if let deco = view.annotation as? DecorationAnnotation {
Expand Down Expand Up @@ -874,6 +906,7 @@ extension ClusterMapView where Cluster == ClusterBadge {
cameraCommand: ClusterMapCameraCommand? = nil,
clustering: Bool = true,
onSelect: ((Item) -> Void)? = nil,
onColocatedStack: (([Item]) -> Void)? = nil,
configuration: ClusterMapConfiguration = .init(),
overlays: [ClusterMapOverlay] = [],
coverageAreas: [GeoBounds] = [],
Expand All @@ -890,6 +923,7 @@ extension ClusterMapView where Cluster == ClusterBadge {
cameraCommand: cameraCommand,
clustering: clustering,
onSelect: onSelect,
onColocatedStack: onColocatedStack,
configuration: configuration,
overlays: overlays,
coverageAreas: coverageAreas,
Expand Down
Loading
Loading