From 8df71404b3ac7fdc412c4c3f4fcae64fd75159da Mon Sep 17 00:00:00 2001 From: Mike Robbins Date: Thu, 30 Oct 2025 17:15:18 -0400 Subject: [PATCH] MeshMap performance: quick wins (#1490) * MeshMap: change onMapCameraChange frequency to .onEnd so that zooming doesn't cause continuous SwiftUI reevaluation on every frame * MeshMapContent: factor out reducedPrecisionMapCircles into a separate function * MeshMapContent: when multiple reducedPrecisionCircles have the same (lat,lon,radius), just draw one (big perf boost in dense areas) --- .../Map/MapContent/MeshMapContent.swift | 61 +++++++++++++++---- Meshtastic/Views/Nodes/MeshMap.swift | 3 +- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index f1fd931f..480a5cba 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -15,6 +15,12 @@ struct IdentifiableOverlay: Identifiable { var id: ObjectIdentifier { ObjectIdentifier(overlay as AnyObject) } } +struct ReducedPrecisionMapCircleKey: Hashable { + let latitudeI: Int32 + let longitudeI: Int32 + let precisionBits: Int32 +} + struct MeshMapContent: MapContent { /// Parameters @@ -43,7 +49,7 @@ struct MeshMapContent: MapContent { @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)], predicate: NSPredicate(format: "enabled == true", ""), animation: .none) private var routes: FetchedResults - + @MapContentBuilder var positionAnnotations: some MapContent { ForEach(positions, id: \.id) { position in @@ -60,16 +66,7 @@ struct MeshMapContent: MapContent { let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0)) let positionName = position.nodePosition?.user?.longName ?? "?" - /// Reduced Precision Map Circle - if 12...15 ~= position.precisionBits { - let pp = PositionPrecision(rawValue: Int(position.precisionBits)) - let radius: CLLocationDistance = pp?.precisionMeters ?? 0 - if radius > 0.0 { - MapCircle(center: position.coordinate, radius: radius) - .foregroundStyle(Color(nodeColor).opacity(0.25)) - .stroke(.white, lineWidth: 1) - } - } + // Use a hash of the position ID to stagger animation delays for each node, preventing synchronized animations and improving visual distinction. let calculatedDelay = Double(position.id.hashValue % 100) / 100.0 * 0.5 @@ -91,7 +88,46 @@ struct MeshMapContent: MapContent { } } } - + + private var reducedPrecisionCircleItems: [(nodeNum: Int64, circleKey: ReducedPrecisionMapCircleKey)] { + // Precompute *unique* reduced-precision circles so we don't have to redraw tons of identical (center, radius) circles in dense map areas. (Since they're all transparent, this causes severe FPS drop when zoomed into areas where there are a ton of overlapping circles.) + var lowestNumForKey: [ReducedPrecisionMapCircleKey: Int64] = [:] + // Populate a dict where the key is (lat, lon, bits) and the value is the *lowest* node.num seen for that key. + // That lowest node.num value is used to create a stable color for the MapCircle and stable id for ForEach. + for position in positions { + // Same filter criteria as positionAnnotations: + if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) { + if 12...15 ~= position.precisionBits { + let nodeNum = position.nodePosition?.num ?? 0 + let key = ReducedPrecisionMapCircleKey(latitudeI: position.latitudeI, longitudeI: position.longitudeI, precisionBits: position.precisionBits) + if let existing = lowestNumForKey[key] { + if nodeNum < existing { lowestNumForKey[key] = nodeNum } + } else { + lowestNumForKey[key] = nodeNum + } + } + } + } + // Sort by nodeNum just to keep draw order stable. + return lowestNumForKey.map { ($0.value, $0.key) }.sorted { $0.nodeNum < $1.nodeNum } + } + + @MapContentBuilder + var reducedPrecisionMapCircles: some MapContent { + ForEach(reducedPrecisionCircleItems, id: \.nodeNum) { item in + let circleKey = item.circleKey + let nodeNum = item.nodeNum + let radius = PositionPrecision(rawValue: Int(circleKey.precisionBits))?.precisionMeters ?? 0 + if radius > 0.0 { + let center = CLLocationCoordinate2D(latitude: Double(circleKey.latitudeI) / 1e7, longitude: Double(circleKey.longitudeI) / 1e7) + let nodeColor = UIColor(hex: UInt32(nodeNum)) + MapCircle(center: center, radius: radius) + .foregroundStyle(Color(nodeColor).opacity(0.25)) + .stroke(.white, lineWidth: 1) + } + } + } + @MapContentBuilder var routeAnnotations: some MapContent { ForEach(routes) { route in @@ -167,6 +203,7 @@ struct MeshMapContent: MapContent { } positionAnnotations + reducedPrecisionMapCircles routeAnnotations waypointAnnotations } diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index 9d8911ab..d72987da 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -75,7 +75,8 @@ struct MeshMap: View { } .controlSize(.regular) .offset(y: 100) - .onMapCameraChange(frequency: MapCameraUpdateFrequency.continuous, { context in + .onMapCameraChange(frequency: MapCameraUpdateFrequency.onEnd, { context in + // distance is only used for long-press waypoint creation, so we don't need continuous updates which touch @State and force rerenders as we pan and (for distance in particular) zoom around the map. onEnd is more than enough. distance = context.camera.distance }) .onTapGesture(count: 1, perform: { position in