From c0d6b8ed29b9a40ec7cd9a1b0a77a063fa49647a Mon Sep 17 00:00:00 2001 From: bruschill <621389+bruschill@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:04:41 -0500 Subject: [PATCH 1/2] feat(map): coincident-node disambiguation picker (#2049) 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). --- .../Persistence/PerformanceSeedData.swift | 174 ++++++++++++++- Meshtastic/Resources/docs/index.json | 10 +- .../Resources/docs/markdown/user/map.md | 4 + Meshtastic/Resources/docs/user/map.html | 2 + .../Nodes/Helpers/Map/ClusterMapView.swift | 35 ++- .../Map/MapContent/MeshMapContent.swift | 51 +++++ Meshtastic/Views/Nodes/MeshMapMK.swift | 141 +++++++++--- MeshtasticTests/MapColocatedNodesTests.swift | 210 ++++++++++++++++++ docs/user/map.md | 4 + 9 files changed, 585 insertions(+), 46 deletions(-) create mode 100644 MeshtasticTests/MapColocatedNodesTests.swift diff --git a/Meshtastic/Persistence/PerformanceSeedData.swift b/Meshtastic/Persistence/PerformanceSeedData.swift index c41bde0bd..11121216a 100644 --- a/Meshtastic/Persistence/PerformanceSeedData.swift +++ b/Meshtastic/Persistence/PerformanceSeedData.swift @@ -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 @@ -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 } @@ -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) { @@ -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) @@ -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.. 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=`; 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-` 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=`. + 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`). diff --git a/Meshtastic/Resources/docs/index.json b/Meshtastic/Resources/docs/index.json index 0cd9d182d..53ab9073c 100644 --- a/Meshtastic/Resources/docs/index.json +++ b/Meshtastic/Resources/docs/index.json @@ -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", @@ -270,7 +270,7 @@ "via", "route" ], - "charCount": 8086 + "charCount": 8616 }, { "id": "messages", diff --git a/Meshtastic/Resources/docs/markdown/user/map.md b/Meshtastic/Resources/docs/markdown/user/map.md index 2081f2ccb..32a59d0f1 100644 --- a/Meshtastic/Resources/docs/markdown/user/map.md +++ b/Meshtastic/Resources/docs/markdown/user/map.md @@ -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. diff --git a/Meshtastic/Resources/docs/user/map.html b/Meshtastic/Resources/docs/user/map.html index ee3cc5098..477c07b8a 100644 --- a/Meshtastic/Resources/docs/user/map.html +++ b/Meshtastic/Resources/docs/user/map.html @@ -12,6 +12,8 @@

Map & Waypoints

Node Pins

Each node that has reported a GPS position appears as a colored circle pin on the map. The green solid line shows a directly connected node; orange dashed lines 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.

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.

diff --git a/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift b/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift index e5d86cc35..7b2a8abcf 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift @@ -176,6 +176,10 @@ struct ClusterMapView: 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`. @@ -206,6 +210,7 @@ struct ClusterMapView: UIViewRepre cameraCommand: ClusterMapCameraCommand? = nil, clustering: Bool = true, onSelect: ((Item) -> Void)? = nil, + onColocatedStack: (([Item]) -> Void)? = nil, configuration: ClusterMapConfiguration = .init(), overlays: [ClusterMapOverlay] = [], coverageAreas: [GeoBounds] = [], @@ -223,6 +228,7 @@ struct ClusterMapView: UIViewRepre self.cameraCommand = cameraCommand self.clustering = clustering self.onSelect = onSelect + self.onColocatedStack = onColocatedStack self.configuration = configuration self.overlays = overlays self.coverageAreas = coverageAreas @@ -352,6 +358,7 @@ struct ClusterMapView: UIViewRepre coordinator.clusterContent = { AnyView(clusterContent($0)) } coordinator.regionBinding = region coordinator.onSelect = onSelect + coordinator.onColocatedStack = onColocatedStack coordinator.onMapTap = onMapTap coordinator.onMapLongPress = onMapLongPress coordinator.suppressRegionUpdates = suppressRegionUpdates @@ -369,6 +376,13 @@ struct ClusterMapView: UIViewRepre var regionBinding: Binding? /// 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)? @@ -744,15 +758,30 @@ struct ClusterMapView: 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) + let spreadMeters = max(rect.size.width, rect.size.height) * metersPerPoint + if spreadMeters < Self.colocatedSpreadMeters, let onColocatedStack { + let items = cluster.memberAnnotations.compactMap { ($0 as? ItemAnnotation)?.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 { @@ -874,6 +903,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] = [], @@ -890,6 +920,7 @@ extension ClusterMapView where Cluster == ClusterBadge { cameraCommand: cameraCommand, clustering: clustering, onSelect: onSelect, + onColocatedStack: onColocatedStack, configuration: configuration, overlays: overlays, coverageAreas: coverageAreas, diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 3cd37bf31..54da8ab6d 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -11,6 +11,16 @@ import CoreLocation +/// Single source of truth for the map's coincident-node threshold, shared by both tap paths +/// (cluster tap in `ClusterMapView`, plain pin tap in `MeshMapMK`) and the grouping tests so the +/// value can't drift between them. +enum MapColocation { + /// Ground distance (meters) within which two nodes are treated as an un-splittable coincident + /// stack that zooming can't separate — below this, the map offers the "Select a Node" picker + /// instead of a max-zoom lurch (clustering on) or selecting only the topmost pin (clustering off). + static let spreadMeters = 5.0 +} + /// Dedup key for reduced-precision accuracy circles (one circle per location + precision). struct ReducedPrecisionMapCircleKey: Hashable { let latitudeI: Int32 @@ -22,6 +32,14 @@ struct MeshMapSelectedNode: Identifiable, Equatable { let id: Int64 } +/// A tapped coincident stack of nodes (same location, can't be split by zoom), presented in the +/// map's disambiguation picker. Carries its own nodes so the sheet reads them via `.sheet(item:)` +/// rather than a separately-updated `@State` array (which can present before the array is observed). +struct ColocatedNodeStack: Identifiable { + let id = UUID() + let nodes: [MeshMapPositionSnapshot] +} + /// Lightweight snapshot of a position's node data, extracted outside the render pass so MapKit /// reevaluations do not repeatedly fault SwiftData relationships. struct MeshMapPositionSnapshot: Identifiable { @@ -37,3 +55,36 @@ struct MeshMapPositionSnapshot: Identifiable { let viaMqtt: Bool let calculatedDelay: Double } + +extension MeshMapPositionSnapshot { + /// The nodes in `snapshots` that sit within `spreadMeters` (ground distance) of `origin`, + /// including `origin` itself. + /// + /// Drives the map's coincident-stack disambiguation on a plain pin tap: when clustering is off, + /// MapKit forms no `MKClusterAnnotation`, so a tap lands only on the topmost of a set of + /// overlapping pins. Grouping the tapped node with its coincident neighbors here lets the map + /// offer the same "Select a Node" picker instead of leaving the occluded nodes untappable. + /// A free/static function so this policy is unit-testable without a live SwiftUI view. + static func colocated( + with origin: MeshMapPositionSnapshot, + in snapshots: [MeshMapPositionSnapshot], + withinMeters spreadMeters: Double + ) -> [MeshMapPositionSnapshot] { + let originLocation = CLLocation(latitude: origin.coordinate.latitude, longitude: origin.coordinate.longitude) + return snapshots.filter { + CLLocation(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude) + .distance(from: originLocation) < spreadMeters + } + } + + /// The picker-ready ordering of a coincident group: de-duplicated by `nodeNum` (the disambiguation + /// `List`'s identity is `snapshot.id`, which equals `nodeNum`, so two snapshots sharing a num — + /// e.g. positions whose node is nil, both 0 — would collide into duplicate List IDs and mis-render) + /// then sorted by display name. Keeps the first snapshot seen for each num. + static func dedupedByNodeNumSortedByName(_ snapshots: [MeshMapPositionSnapshot]) -> [MeshMapPositionSnapshot] { + var seenNodeNums = Set() + return snapshots + .filter { seenNodeNums.insert($0.nodeNum).inserted } + .sorted { $0.longName < $1.longName } + } +} diff --git a/Meshtastic/Views/Nodes/MeshMapMK.swift b/Meshtastic/Views/Nodes/MeshMapMK.swift index 75431088b..86fe95258 100644 --- a/Meshtastic/Views/Nodes/MeshMapMK.swift +++ b/Meshtastic/Views/Nodes/MeshMapMK.swift @@ -56,7 +56,6 @@ struct MeshMapMK: View { @State private var mapOverlays: [ClusterMapOverlay] = [] /// Display-coordinate overrides for nodes that sit on (nearly) the same point, fanned out into /// a small ring so a stacked cluster always breaks into individual, tappable node circles. - @State private var spreadOverrides: [Int64: CLLocationCoordinate2D] = [:] /// Guards the one-time initial camera framing (GPS-centered, zoomed out, ~100 miles max). @State private var didInitialFrame = false /// One-shot camera move fed to the map. Set ONCE by the initial framing; after that the user @@ -97,6 +96,10 @@ struct MeshMapMK: View { @State private var editingSettings = false @State private var editingFilters = false @State var selectedNode: MeshMapSelectedNode? + /// A tapped un-splittable coincident stack, shown in a disambiguation picker (nil = hidden). + @State private var colocatedStack: ColocatedNodeStack? + /// A node chosen in the picker, opened only after that sheet dismisses (so the two don't collide). + @State private var pendingColocatedSelection: Int64? @State private var visiblePositionSnapshots: [MeshMapPositionSnapshot] = [] @State var editingWaypoint: WaypointEntity? @State var selectedWaypoint: WaypointEntity? @@ -298,11 +301,15 @@ struct MeshMapMK: View { @ViewBuilder private var meshClusterMapView: some View { ClusterMapView( items: visiblePositionSnapshots, - coordinate: { spreadOverrides[$0.nodeNum] ?? $0.coordinate }, + coordinate: { $0.coordinate }, region: $visibleRegion, cameraCommand: cameraCommand, clustering: enableMapClustering, - onSelect: { snapshot in selectedWaypoint = nil; editingWaypoint = nil; selectedNode = MeshMapSelectedNode(id: snapshot.nodeNum) }, + onSelect: { snapshot in presentNodeSelection(for: snapshot) }, + onColocatedStack: { snapshots in + // Coincident stack that zoom-to-fit can't separate -> let the user pick a node by name. + presentColocatedStack(snapshots) + }, configuration: clusterConfiguration, overlays: combinedMapOverlays(), coverageAreas: isMapVisible ? offlineCoverageAreas : [], @@ -404,6 +411,50 @@ struct MeshMapMK: View { #endif } } + .sheet(item: $colocatedStack, onDismiss: { + // Open the chosen node's detail only after the picker has fully dismissed, so the two + // sheets don't fight over presentation. + if let nodeNum = pendingColocatedSelection { + pendingColocatedSelection = nil + selectNode(nodeNum) + } + }) { stack in + NavigationStack { + List(stack.nodes) { snapshot in + Button { + pendingColocatedSelection = snapshot.nodeNum + colocatedStack = nil + } label: { + // Reuse the standard node-list cell so the disambiguation picker matches + // the Nodes tab. Fall back to the snapshot's name if the live node was + // pruned between the tap and the sheet appearing. + if let node = getNodeInfo(id: snapshot.nodeNum, context: context) { + NodeListItem( + node: node, + isDirectlyConnected: snapshot.nodeNum == accessoryManager.activeDeviceNum, + connectedNode: accessoryManager.activeConnection?.device.num ?? -1 + ) + } else { + Text(snapshot.longName) + } + } + .buttonStyle(.plain) + } + .navigationTitle(String.localizedStringWithFormat("Select a Node (%@)".localized, String(stack.nodes.count))) + #if !targetEnvironment(macCatalyst) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { colocatedStack = nil } + } + } + } + .presentationDetents([.medium, .large]) + #if !targetEnvironment(macCatalyst) + .presentationDragIndicator(.visible) + #endif + } .sheet(item: $selectedWaypoint) { selection in WaypointForm(waypoint: selection) .presentationDetents([.large]) // full screen @@ -787,49 +838,71 @@ struct MeshMapMK: View { return key } + /// Route a tapped pin to node detail, or — when other visible nodes sit on (nearly) the same + /// point — to the disambiguation picker. MapKit only forms `MKClusterAnnotation`s (which drive + /// `onColocatedStack`) when clustering is enabled, so with clustering OFF a pin tap can land on a + /// fully-occluded stack; without this the covered nodes would be permanently untappable. Detecting + /// coincident siblings here keeps every stacked node reachable regardless of the clustering setting. + private func presentNodeSelection(for snapshot: MeshMapPositionSnapshot) { + let coincident = MeshMapPositionSnapshot.colocated( + with: snapshot, + in: visiblePositionSnapshots, + withinMeters: MapColocation.spreadMeters + ) + if coincident.count > 1 { + presentColocatedStack(coincident) + } else { + selectNode(snapshot.nodeNum) + } + } + + /// Present the colocated disambiguation picker for a set of coincident nodes. De-dupes by + /// `nodeNum` first: the picker's `List` is keyed on `snapshot.id` (== `nodeNum`), so two snapshots + /// sharing a num (e.g. positions whose node is nil, both 0) would collide into duplicate List IDs + /// and mis-render. + private func presentColocatedStack(_ snapshots: [MeshMapPositionSnapshot]) { + selectedWaypoint = nil + editingWaypoint = nil + colocatedStack = ColocatedNodeStack(nodes: MeshMapPositionSnapshot.dedupedByNodeNumSortedByName(snapshots)) + } + + /// Open a single node's detail sheet, clearing any in-flight waypoint selection first. + private func selectNode(_ nodeNum: Int64) { + selectedWaypoint = nil + editingWaypoint = nil + selectedNode = MeshMapSelectedNode(id: nodeNum) + } + private func refreshVisiblePositionSnapshots(from positions: [PositionEntity]) { visiblePositionSnapshots = makePositionSnapshots(from: positions) - spreadOverrides = computeSpreadOverrides(visiblePositionSnapshots) frameInitialRegionIfNeeded() rebuildOverlays() } - /// Fan out nodes that sit on (nearly) the same coordinate into a small ring so a stacked cluster - /// always breaks into individual, tappable node circles instead of an un-splittable pin. The - /// accuracy circle stays at the true location; only the pin's display coordinate is offset. - private func computeSpreadOverrides(_ snaps: [MeshMapPositionSnapshot]) -> [Int64: CLLocationCoordinate2D] { - var groups: [Int64: [MeshMapPositionSnapshot]] = [:] - for snap in snaps { - // Quantize to ~1 m so only (near-)coincident nodes are grouped together. - let latKey = Int64((snap.coordinate.latitude * 1e5).rounded()) - let lonKey = Int64((snap.coordinate.longitude * 1e5).rounded()) - groups[latKey &* 100_000_000 &+ lonKey, default: []].append(snap) - } - var overrides: [Int64: CLLocationCoordinate2D] = [:] - let metersPerDegLat = 111_320.0 - for members in groups.values where members.count > 1 { - let sorted = members.sorted { $0.nodeNum < $1.nodeNum } - let base = sorted[0].coordinate - let metersPerDegLon = max(1.0, metersPerDegLat * cos(base.latitude * .pi / 180)) - // Grow the ring with crowd size so even a big pile stays individually tappable. - let radius = 14.0 + Double(sorted.count) * 1.5 - for (index, member) in sorted.enumerated() { - let angle = 2 * Double.pi * Double(index) / Double(sorted.count) - overrides[member.nodeNum] = CLLocationCoordinate2D( - latitude: base.latitude + (radius * sin(angle)) / metersPerDegLat, - longitude: base.longitude + (radius * cos(angle)) / metersPerDegLon - ) - } - } - return overrides - } - /// One-time initial camera framing. Centers on the phone's GPS (else the connected device's GPS, /// else the node centroid) and zooms out to fit nearby nodes -- capped at ~100 miles so we "start /// zoomed out but local." After it fires once the user drives the camera; we never re-frame, even /// as positions pour in. private func frameInitialRegionIfNeeded() { guard !didInitialFrame else { return } + #if DEBUG + // Coincident-node demo: frame tight on the seeded pins so the stack(s) fill the view without the + // user having to pinch-zoom for a screenshot. Overrides the usual "start zoomed out" framing. + if ProcessInfo.processInfo.arguments.contains("--meshtastic-cluster-demo") { + let coords = mapEligiblePositions.compactMap { $0.nodeCoordinate ?? $0.fuzzedNodeCoordinate } + if let center = coordinateCentroid(of: coords) { + didInitialFrame = true + let span = ClusterDemoSeed.frameSpanDegrees + let region = MKCoordinateRegion( + center: center, + span: MKCoordinateSpan(latitudeDelta: span, longitudeDelta: span) + ) + visibleRegion = region + cameraCommand = ClusterMapCameraCommand(id: UUID(), region: region) + return + } + } + #endif // Frame from the same set the map actually shows, so "Precise Locations Only" doesn't start // the camera centered on hidden reduced-precision nodes. let nodeCoords = mapEligiblePositions.compactMap { $0.nodeCoordinate ?? $0.fuzzedNodeCoordinate } diff --git a/MeshtasticTests/MapColocatedNodesTests.swift b/MeshtasticTests/MapColocatedNodesTests.swift new file mode 100644 index 000000000..b545d1c59 --- /dev/null +++ b/MeshtasticTests/MapColocatedNodesTests.swift @@ -0,0 +1,210 @@ +// MapColocatedNodesTests.swift +// MeshtasticTests +// +// Coverage for the map's coincident-node disambiguation grouping +// (`MeshMapPositionSnapshot.colocated(with:in:withinMeters:)`). +// +// This is the pure policy behind the "Select a Node" picker: when map clustering is OFF, MapKit +// forms no cluster annotation, so a pin tap lands only on the topmost of a set of overlapping pins. +// Grouping the tapped node with its coincident neighbors is what keeps the occluded nodes reachable +// (the map presents the picker when the group has more than one member). + +import Testing +import Foundation +import CoreLocation +@testable import Meshtastic + +@Suite("Map colocated-node grouping") +struct MapColocatedNodesTests { + + /// ~1.1132 m of latitude per 0.00001°, at the test latitude. Handy for building known offsets. + private static let baseLat = 47.6001 + private static let baseLon = -122.3301 + /// The production threshold, referenced (not re-hardcoded) so the tests track the single source of + /// truth and offsets stay valid if it changes. + private static let spread = MapColocation.spreadMeters + + private func snapshot(_ nodeNum: Int64, lat: Double, lon: Double) -> MeshMapPositionSnapshot { + MeshMapPositionSnapshot( + id: nodeNum, + coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon), + latitudeI: Int32(lat * 1e7), + longitudeI: Int32(lon * 1e7), + precisionBits: 32, + nodeNum: nodeNum, + longName: "Node \(nodeNum)", + shortName: "\(nodeNum)", + isOnline: true, + viaMqtt: false, + calculatedDelay: 0 + ) + } + + /// A node offset `meters` due north of the base point. + private func northOffset(_ nodeNum: Int64, meters: Double) -> MeshMapPositionSnapshot { + // 1° latitude ≈ 111_320 m. + snapshot(nodeNum, lat: Self.baseLat + meters / 111_320.0, lon: Self.baseLon) + } + + private func base(_ nodeNum: Int64) -> MeshMapPositionSnapshot { + snapshot(nodeNum, lat: Self.baseLat, lon: Self.baseLon) + } + + // MARK: - Membership + + @Test("A lone node returns only itself") + func loneNode() { + let origin = base(1) + let result = MeshMapPositionSnapshot.colocated(with: origin, in: [origin], withinMeters: Self.spread) + #expect(result.count == 1) + #expect(result.first?.nodeNum == 1) + } + + @Test("Origin is always included, even with distant others") + func originAlwaysIncluded() { + let origin = base(1) + let far = northOffset(2, meters: 50) + let result = MeshMapPositionSnapshot.colocated(with: origin, in: [origin, far], withinMeters: Self.spread) + #expect(result.map(\.nodeNum) == [1]) + } + + @Test("Exactly coincident pair groups both") + func exactlyCoincidentPair() { + let a = base(1) + let b = base(2) // identical coordinate + let result = MeshMapPositionSnapshot.colocated(with: a, in: [a, b], withinMeters: Self.spread) + #expect(Set(result.map(\.nodeNum)) == [1, 2]) + } + + @Test("Ten exactly-coincident nodes all group") + func tenCoincident() { + let nodes = (1...10).map { base(Int64($0)) } + let result = MeshMapPositionSnapshot.colocated(with: nodes[0], in: nodes, withinMeters: Self.spread) + #expect(result.count == 10) + } + + @Test("Sub-meter near-coincident pair (GPS jitter / precision fuzz) groups") + func subMeterPairGroups() { + // ~0.11 m apart — the boundary-straddling "pairs" case that used to escape same-cell grouping. + let a = snapshot(1, lat: 47.6000045, lon: -122.33000) + let b = snapshot(2, lat: 47.6000055, lon: -122.33000) + let result = MeshMapPositionSnapshot.colocated(with: a, in: [a, b], withinMeters: Self.spread) + #expect(Set(result.map(\.nodeNum)) == [1, 2]) + } + + // MARK: - Threshold behavior + + @Test("A node within the threshold is included") + func withinThreshold() { + let origin = base(1) + let near = northOffset(2, meters: Self.spread * 0.8) // comfortably inside + let result = MeshMapPositionSnapshot.colocated(with: origin, in: [origin, near], withinMeters: Self.spread) + #expect(Set(result.map(\.nodeNum)) == [1, 2]) + } + + @Test("A node beyond the threshold is excluded") + func beyondThreshold() { + let origin = base(1) + let far = northOffset(2, meters: Self.spread * 1.2) // comfortably outside + let result = MeshMapPositionSnapshot.colocated(with: origin, in: [origin, far], withinMeters: Self.spread) + #expect(result.map(\.nodeNum) == [1]) + } + + @Test("The threshold is strict: a node just past it is excluded, just inside is included") + func strictThresholdBoundary() { + let origin = base(1) + // Bracket the threshold tightly (±1 cm) to pin the `<` (not `<=`) comparison. + let justInside = northOffset(2, meters: Self.spread - 0.01) + let justOutside = northOffset(3, meters: Self.spread + 0.01) + let result = MeshMapPositionSnapshot.colocated(with: origin, in: [origin, justInside, justOutside], withinMeters: Self.spread) + #expect(Set(result.map(\.nodeNum)) == [1, 2]) + } + + @Test("An empty snapshot list returns nothing (no crash, no phantom origin)") + func emptySnapshots() { + let origin = base(1) + // The origin isn't in the list, so it isn't returned; the map only ever calls this with the + // tapped node present in `visiblePositionSnapshots`, but the helper must be total. + #expect(MeshMapPositionSnapshot.colocated(with: origin, in: [], withinMeters: Self.spread).isEmpty) + } + + @Test("A wider custom threshold groups nodes that the default would exclude") + func customThreshold() { + let origin = base(1) + let node = northOffset(2, meters: 6) // excluded at 5 m, included at 10 m + #expect(MeshMapPositionSnapshot.colocated(with: origin, in: [origin, node], withinMeters: 5.0).count == 1) + #expect(MeshMapPositionSnapshot.colocated(with: origin, in: [origin, node], withinMeters: 10.0).count == 2) + } + + // MARK: - Mixed scenes + + @Test("Only the coincident members of a mixed scene are returned") + func mixedScene() { + // Three coincident at base, plus two clearly separate nodes. + let stack = [base(1), base(2), base(3)] + let farA = northOffset(4, meters: 40) + let farB = northOffset(5, meters: 80) + let scene = stack + [farA, farB] + let result = MeshMapPositionSnapshot.colocated(with: stack[0], in: scene, withinMeters: Self.spread) + #expect(Set(result.map(\.nodeNum)) == [1, 2, 3]) + } + + @Test("Grouping is scoped to the tapped node's neighborhood, not the whole scene") + func neighborhoodScoped() { + // Two distinct coincident stacks ~50 m apart; tapping one must not pull in the other. + let stackA = [base(1), base(2)] + let stackB = [northOffset(3, meters: 50), northOffset(4, meters: 50)] + let scene = stackA + stackB + let resultA = MeshMapPositionSnapshot.colocated(with: stackA[0], in: scene, withinMeters: Self.spread) + #expect(Set(resultA.map(\.nodeNum)) == [1, 2]) + let resultB = MeshMapPositionSnapshot.colocated(with: stackB[0], in: scene, withinMeters: Self.spread) + #expect(Set(resultB.map(\.nodeNum)) == [3, 4]) + } + + // MARK: - Decision semantics + // + // The map opens the disambiguation picker exactly when the group has more than one member, and + // otherwise selects the single node. These assertions pin that "count > 1" contract so the two + // call sites (clustering-on cluster tap and clustering-off pin tap) stay consistent. + + @Test("Group of one drives a direct selection (no picker)") + func singleMemberMeansDirectSelect() { + let origin = base(1) + let result = MeshMapPositionSnapshot.colocated(with: origin, in: [origin, northOffset(2, meters: 30)], withinMeters: Self.spread) + #expect(result.count == 1) // count == 1 → selectNode, not the picker + } + + @Test("Group of many drives the picker") + func manyMembersMeanPicker() { + let nodes = [base(1), base(2)] + let result = MeshMapPositionSnapshot.colocated(with: nodes[0], in: nodes, withinMeters: Self.spread) + #expect(result.count > 1) // count > 1 → present the "Select a Node" picker + } + + // MARK: - Picker ordering / de-duplication + // + // `dedupedByNodeNumSortedByName` is what the map feeds the picker's `List`, whose row identity is + // `snapshot.id` (== `nodeNum`). Duplicate nums would collide into duplicate List IDs. + + @Test("Picker input is de-duplicated by nodeNum") + func dedupesByNodeNum() { + // Two snapshots sharing nodeNum 0 (positions whose node is nil) plus a distinct node. + let dupA = snapshot(0, lat: Self.baseLat, lon: Self.baseLon) + let dupB = snapshot(0, lat: Self.baseLat, lon: Self.baseLon) + let other = snapshot(7, lat: Self.baseLat, lon: Self.baseLon) + let result = MeshMapPositionSnapshot.dedupedByNodeNumSortedByName([dupA, dupB, other]) + #expect(result.map(\.nodeNum) == [0, 7]) // one row per num, no collision + } + + @Test("Picker input is sorted by display name") + func sortsByName() { + let charlie = snapshot(1, lat: Self.baseLat, lon: Self.baseLon) // longName "Node 1" + let alpha = MeshMapPositionSnapshot( + id: 2, coordinate: CLLocationCoordinate2D(latitude: Self.baseLat, longitude: Self.baseLon), + latitudeI: 0, longitudeI: 0, precisionBits: 32, nodeNum: 2, + longName: "Aardvark", shortName: "A", isOnline: true, viaMqtt: false, calculatedDelay: 0 + ) + let result = MeshMapPositionSnapshot.dedupedByNodeNumSortedByName([charlie, alpha]) + #expect(result.map(\.longName) == ["Aardvark", "Node 1"]) + } +} diff --git a/docs/user/map.md b/docs/user/map.md index 2081f2ccb..32a59d0f1 100644 --- a/docs/user/map.md +++ b/docs/user/map.md @@ -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. From b8dfbd23cdb10f19b3a28d2eebf3bcffa97fc1b3 Mon Sep 17 00:00:00 2001 From: bruschill <621389+bruschill@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:23:30 -0500 Subject: [PATCH 2/2] review: address CodeRabbit feedback - 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. --- .../Views/Nodes/Helpers/Map/ClusterMapView.swift | 5 ++++- Meshtastic/Views/Nodes/MeshMapMK.swift | 14 +++++++++----- MeshtasticTests/MapColocatedNodesTests.swift | 11 +++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift b/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift index 7b2a8abcf..f22273635 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift @@ -766,7 +766,10 @@ struct ClusterMapView: UIViewRepre // 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) - let spreadMeters = max(rect.size.width, rect.size.height) * metersPerPoint + // 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 { let items = cluster.memberAnnotations.compactMap { ($0 as? ItemAnnotation)?.item } // Only present the picker for a genuine multi-node stack; a lone item (e.g. the rest of diff --git a/Meshtastic/Views/Nodes/MeshMapMK.swift b/Meshtastic/Views/Nodes/MeshMapMK.swift index 86fe95258..f91750ece 100644 --- a/Meshtastic/Views/Nodes/MeshMapMK.swift +++ b/Meshtastic/Views/Nodes/MeshMapMK.swift @@ -844,10 +844,14 @@ struct MeshMapMK: View { /// fully-occluded stack; without this the covered nodes would be permanently untappable. Detecting /// coincident siblings here keeps every stacked node reachable regardless of the clustering setting. private func presentNodeSelection(for snapshot: MeshMapPositionSnapshot) { - let coincident = MeshMapPositionSnapshot.colocated( - with: snapshot, - in: visiblePositionSnapshots, - withinMeters: MapColocation.spreadMeters + // De-dupe by nodeNum before deciding: two coincident snapshots that share a num (e.g. positions + // whose node is nil, both 0) are one selectable node, not a two-row picker. + let coincident = MeshMapPositionSnapshot.dedupedByNodeNumSortedByName( + MeshMapPositionSnapshot.colocated( + with: snapshot, + in: visiblePositionSnapshots, + withinMeters: MapColocation.spreadMeters + ) ) if coincident.count > 1 { presentColocatedStack(coincident) @@ -888,7 +892,7 @@ struct MeshMapMK: View { #if DEBUG // Coincident-node demo: frame tight on the seeded pins so the stack(s) fill the view without the // user having to pinch-zoom for a screenshot. Overrides the usual "start zoomed out" framing. - if ProcessInfo.processInfo.arguments.contains("--meshtastic-cluster-demo") { + if PerformanceSeedData.configuration?.style == .clusterDemo { let coords = mapEligiblePositions.compactMap { $0.nodeCoordinate ?? $0.fuzzedNodeCoordinate } if let center = coordinateCentroid(of: coords) { didInitialFrame = true diff --git a/MeshtasticTests/MapColocatedNodesTests.swift b/MeshtasticTests/MapColocatedNodesTests.swift index b545d1c59..9c458895c 100644 --- a/MeshtasticTests/MapColocatedNodesTests.swift +++ b/MeshtasticTests/MapColocatedNodesTests.swift @@ -196,6 +196,17 @@ struct MapColocatedNodesTests { #expect(result.map(\.nodeNum) == [0, 7]) // one row per num, no collision } + @Test("Coincident duplicates of one node collapse to a single selectable entry") + func coincidentDuplicatesCollapse() { + // Two snapshots for the SAME nodeNum at the same point (e.g. duplicate/nil-node positions). + let a = snapshot(5, lat: Self.baseLat, lon: Self.baseLon) + let b = snapshot(5, lat: Self.baseLat, lon: Self.baseLon) + let coincident = MeshMapPositionSnapshot.colocated(with: a, in: [a, b], withinMeters: Self.spread) + #expect(coincident.count == 2) // both fall within range... + let pickerReady = MeshMapPositionSnapshot.dedupedByNodeNumSortedByName(coincident) + #expect(pickerReady.count == 1) // ...but they are one node, so no picker (count 1 -> direct select) + } + @Test("Picker input is sorted by display name") func sortsByName() { let charlie = snapshot(1, lat: Self.baseLat, lon: Self.baseLon) // longName "Node 1"