From 402cb836b5dc271b68e421d660eaf29cc9439ae8 Mon Sep 17 00:00:00 2001 From: Mike Robbins Date: Thu, 30 Oct 2025 17:32:27 -0400 Subject: [PATCH] NodeMap performance improvements for high # positions history (#1480) * NodeMapContent: move Route Lines out of ForEach * NodeMapContent: move Convex Hull out of ForEach * NodeMapContent: Replace `position.nodePosition?` with `node` * NodeMapContent: drop unnecessary LazyVStack in showNodeHistory * NodeMapContent: hoist out nodeColorSwift * Move lineCoords, loraCoords calculations within showRouteLines, showConvexHull respectively * Hoist out repeated node.metadata?.positionFlags lookups / PositionFlags creation * NodeMapContent: remove unused @State * NodeMapSwiftUI: add NodeMapContentEquatableWrapper and NodeMapContentSignature to prevent frequent NodeMapContent recomputation and infinite render loops * NodeMapSwiftUI: disable animation during SwiftUI transactions * NodeMapContent: hoist nodeBorderColor and set allowsHitTesting(false) on history point views * NodeMapContent: prerenderHistoryPointCircle and prerenderHistoryPointArrow to avoid thousands of vector draw operations * NodeMapContent: Shared coordinate list for Route Lines and Convex Hull * NodeMapContent.prerenderHistoryPointArrow: add .frame(width: 16, height: 16) --- .../Map/MapContent/NodeMapContent.swift | 146 ++++++++++-------- .../Nodes/Helpers/Map/NodeMapSwiftUI.swift | 36 ++++- 2 files changed, 114 insertions(+), 68 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift index 6059a57c..02635dd8 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift @@ -11,42 +11,32 @@ import CoreData struct NodeMapContent: MapContent { @ObservedObject var node: NodeInfoEntity - @State var showUserLocation: Bool = false - @State var positions: [PositionEntity] = [] /// Map State User Defaults @AppStorage("meshMapShowNodeHistory") private var showNodeHistory = false @AppStorage("meshMapShowRouteLines") private var showRouteLines = false - @AppStorage("enableMapWaypoints") private var showWaypoints = true @AppStorage("enableMapConvexHull") private var showConvexHull = false - @AppStorage("enableMapTraffic") private var showTraffic: Bool = false - @AppStorage("enableMapPointsOfInterest") private var showPointsOfInterest: Bool = false - @AppStorage("mapLayer") private var selectedMapLayer: MapLayer = .hybrid // Map Configuration @Namespace var mapScope - @State var mapStyle: MapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: .all, showsTraffic: true) - @State var position = MapCameraPosition.automatic - @State var scene: MKLookAroundScene? - @State var isLookingAround = false - @State var isShowingAltitude = false - @State var isEditingSettings = false @State var selectedPosition: PositionEntity? - @State var isMeshMap = false @MapContentBuilder var nodeMap: some MapContent { let positionArray = node.positions?.array as? [PositionEntity] ?? [] - let lineCoords = positionArray.compactMap({(position) -> CLLocationCoordinate2D in - return position.nodeCoordinate ?? LocationsHandler.DefaultLocation - }) /// Node Color from node.num let nodeColor = UIColor(hex: UInt32(node.num)) + let nodeColorSwift = Color(nodeColor) + let nodeBorderColor: Color = nodeColorSwift.isLight() ? .black : .white + + // Prerender node history point views as UIImages for speedup when there are thousands of history points + let prerenderedHistoryPointCircleImage = showNodeHistory ? prerenderHistoryPointCircle(fill: nodeColorSwift, stroke: nodeBorderColor) : UIImage() + let prerenderedHistoryPointArrowImage = showNodeHistory ? prerenderHistoryPointArrow(fill: nodeColorSwift, stroke: nodeBorderColor) : UIImage() + + let pf = PositionFlags(rawValue: Int(node.metadata?.positionFlags ?? 771)) /// Node Annotations - ForEach(node.positions?.array as? [PositionEntity] ?? [], id: \.id) { position in - - let pf = PositionFlags(rawValue: Int(position.nodePosition?.metadata?.positionFlags ?? 771)) + ForEach(positionArray, id: \.id) { position in let headingDegrees = Angle.degrees(Double(position.heading)) /// Reduced Precision Map Circle if position.latest && 12...15 ~= position.precisionBits { @@ -58,32 +48,6 @@ struct NodeMapContent: MapContent { .stroke(.white, lineWidth: 2) } } - let loraNodes = positions.filter { $0.nodePosition?.viaMqtt ?? true == false } - let loraCoords = Array(loraNodes).compactMap({(position) -> CLLocationCoordinate2D in - return position.nodeCoordinate ?? LocationsHandler.DefaultLocation - }) - /// Convex Hull - if showConvexHull { - if loraCoords.count > 0 { - let hull = loraCoords.getConvexHull() - MapPolygon(coordinates: hull) - .stroke(.blue, lineWidth: 3) - .foregroundStyle(.indigo.opacity(0.4)) - } - } - /// Route Lines - if showRouteLines { - let gradient = LinearGradient( - colors: [Color(nodeColor.lighter().lighter().lighter()), Color(nodeColor.lighter()), Color(nodeColor)], - startPoint: .leading, endPoint: .trailing - ) - let dashed = StrokeStyle( - lineWidth: 3, - lineCap: .round, lineJoin: .round, dash: [10, 10] - ) - MapPolyline(coordinates: lineCoords) - .stroke(gradient, style: dashed) - } /// Lastest Position Pin if position.latest { /// Node Annotations @@ -93,7 +57,7 @@ struct NodeMapContent: MapContent { if pf.contains(.Heading) { Image(systemName: pf.contains(.Speed) && position.speed > 1 ? "location.north" : "octagon") .padding(5) - .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) + .foregroundStyle(nodeBorderColor) .background(Color(nodeColor.darker())) .clipShape(Circle()) .rotationEffect(headingDegrees) @@ -111,7 +75,7 @@ struct NodeMapContent: MapContent { Image(systemName: "flipphone") .symbolEffect(.pulse.byLayer) .padding(5) - .foregroundStyle(Color(nodeColor).isLight() ? .black : .white) + .foregroundStyle(nodeBorderColor) .background(Color(UIColor(hex: UInt32(node.num)).darker())) .clipShape(Circle()) .onTapGesture { @@ -133,27 +97,25 @@ struct NodeMapContent: MapContent { } /// Node History if showNodeHistory { - if position.latest == false && position.nodePosition?.favorite ?? false { - let pf = PositionFlags(rawValue: Int(position.nodePosition?.metadata?.positionFlags ?? 771)) + // Having showNodeHistory enabled can be quite slow if there are thousands of history points. + if position.latest == false && node.favorite { let headingDegrees = Angle.degrees(Double(position.heading)) Annotation("", coordinate: position.coordinate) { - LazyVStack { - if pf.contains(.Heading) { - Image(systemName: "location.north.circle") - .resizable() - .scaledToFit() - .foregroundStyle(Color(UIColor(hex: UInt32(position.nodePosition?.num ?? 0))).isLight() ? .black : .white) - .background(Color(UIColor(hex: UInt32(position.nodePosition?.num ?? 0)))) - .clipShape(Circle()) - .rotationEffect(headingDegrees) - .frame(width: 16, height: 16) - - } else { - Circle() - .fill(Color(UIColor(hex: UInt32(position.nodePosition?.num ?? 0)))) - .strokeBorder(Color(UIColor(hex: UInt32(position.nodePosition?.num ?? 0))).isLight() ? .black : .white, lineWidth: 2) - .frame(width: 12, height: 12) - } + if pf.contains(.Heading) { + Image(uiImage: prerenderedHistoryPointArrowImage) + .renderingMode(.original) + .interpolation(.none) + .rotationEffect(headingDegrees) + .frame(width: 16, height: 16) + .allowsHitTesting(false) + .accessibilityHidden(true) + } else { + Image(uiImage: prerenderedHistoryPointCircleImage) + .renderingMode(.original) + .interpolation(.none) + .frame(width: 12, height: 12) + .allowsHitTesting(false) + .accessibilityHidden(true) } } .annotationTitles(.hidden) @@ -161,6 +123,33 @@ struct NodeMapContent: MapContent { } } } + + // Shared coordinate list for Route Lines and Convex Hull + let allCoords: [CLLocationCoordinate2D] = (showRouteLines || showConvexHull) ? positionArray.compactMap(\.nodeCoordinate) : [] + + /// Route Lines + if showRouteLines { + let gradient = LinearGradient( + colors: [Color(nodeColor.lighter().lighter().lighter()), Color(nodeColor.lighter()), Color(nodeColor)], + startPoint: .leading, endPoint: .trailing + ) + let dashed = StrokeStyle( + lineWidth: 3, + lineCap: .round, lineJoin: .round, dash: [10, 10] + ) + MapPolyline(coordinates: allCoords) + .stroke(gradient, style: dashed) + } + + /// Convex Hull + if showConvexHull { + if allCoords.count > 0 { + let hull = allCoords.getConvexHull() + MapPolygon(coordinates: hull) + .stroke(.blue, lineWidth: 3) + .foregroundStyle(.indigo.opacity(0.4)) + } + } } @MapContentBuilder @@ -169,4 +158,29 @@ struct NodeMapContent: MapContent { nodeMap } } + + private func prerenderHistoryPointCircle(fill: Color, stroke: Color) -> UIImage { + // Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points. + let content = Circle() + .fill(fill) + .strokeBorder(stroke, lineWidth: 2) + .frame(width: 12, height: 12) + let renderer = ImageRenderer(content: content) + renderer.scale = UIScreen.main.scale + return renderer.uiImage! + } + + private func prerenderHistoryPointArrow(fill: Color, stroke: Color) -> UIImage { + // Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points. + let content = Image(systemName: "location.north.circle") + .resizable() + .scaledToFit() + .foregroundStyle(stroke) + .background(fill) + .clipShape(Circle()) + .frame(width: 16, height: 16) + let renderer = ImageRenderer(content: content) + renderer.scale = UIScreen.main.scale + return renderer.uiImage! + } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index 67707590..5181586c 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -9,6 +9,26 @@ import SwiftUI import CoreLocation import MapKit +struct NodeMapContentSignature: Equatable { + // Used to decide if NodeMapContent needs to be reevaluated. + // Only include fields that are used within NodeMapContent (or approximations like positionCount and lastPositionTime). + let nodeNum: Int64 + let positionCount: Int + let lastPositionTime: Date? + let showNodeHistory: Bool + let showRouteLines: Bool + let showConvexHull: Bool + let favorite: Bool +} + +private struct NodeMapContentEquatableWrapper: View, Equatable { + // Prevent slow, needless recomputation of NodeMapContent if the NodeMapContentSignature hasn't changed. + let signature: NodeMapContentSignature + @ViewBuilder let content: () -> Content + static func == (lhs: NodeMapContentEquatableWrapper, rhs: NodeMapContentEquatableWrapper) -> Bool { lhs.signature == rhs.signature } + var body: some View { content() } +} + struct NodeMapSwiftUI: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var accessoryManager: AccessoryManager @@ -17,6 +37,9 @@ struct NodeMapSwiftUI: View { @State var showUserLocation: Bool = false @State var positions: [PositionEntity] = [] /// Map State User Defaults + @AppStorage("meshMapShowNodeHistory") private var showNodeHistory = false + @AppStorage("meshMapShowRouteLines") private var showRouteLines = false + @AppStorage("enableMapConvexHull") private var showConvexHull = false @AppStorage("enableMapTraffic") private var showTraffic: Bool = false @AppStorage("enableMapPointsOfInterest") private var showPointsOfInterest: Bool = false @AppStorage("mapLayer") private var selectedMapLayer: MapLayer = .hybrid @@ -91,9 +114,17 @@ struct NodeMapSwiftUI: View { } } + private var mapContentSignature: NodeMapContentSignature { + let positionCount = node.positions?.count ?? 0 + let lastPositionTime = (node.positions?.lastObject as? PositionEntity)?.time + return NodeMapContentSignature(nodeNum: node.num, positionCount: positionCount, lastPositionTime: lastPositionTime, showNodeHistory: showNodeHistory, showRouteLines: showRouteLines, showConvexHull: showConvexHull, favorite: node.favorite) + } + private var baseMap: some View { - Map(position: $position, bounds: MapCameraBounds(minimumDistance: 0, maximumDistance: .infinity), scope: mapScope) { - NodeMapContent(node: node) + NodeMapContentEquatableWrapper(signature: mapContentSignature) { + Map(position: $position, bounds: MapCameraBounds(minimumDistance: 0, maximumDistance: .infinity), scope: mapScope) { + NodeMapContent(node: node) + } } .mapScope(mapScope) .mapStyle(mapStyle) @@ -110,6 +141,7 @@ struct NodeMapSwiftUI: View { .mapControlVisibility(.visible) } .controlSize(.regular) + .transaction { $0.animation = nil } } private var lookAroundView: some View {