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)
This commit is contained in:
Mike Robbins 2025-10-30 17:32:27 -04:00 committed by GitHub
parent 8df71404b3
commit 402cb836b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 114 additions and 68 deletions

View file

@ -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!
}
}

View file

@ -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<Content: View>: 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<Content>, rhs: NodeMapContentEquatableWrapper<Content>) -> 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 {