mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
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:
parent
8df71404b3
commit
402cb836b5
2 changed files with 114 additions and 68 deletions
|
|
@ -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!
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue