Meshtastic-Apple/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift
copilot-swe-agent[bot] 497bed8884
Merge branch '2.7.10' into swiftdata - resolve conflicts with CarPlay, channels, and map content
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
2026-04-18 18:04:59 +00:00

196 lines
6.7 KiB
Swift

//
// RouteLines.swift
// Meshtastic
//
// Created by Garth Vander Houwen on 3/14/24.
//
import SwiftUI
import MapKit
import SwiftData
struct NodeMapContent: MapContent {
@Bindable var node: NodeInfoEntity
/// Map State User Defaults
@AppStorage("meshMapShowNodeHistory") private var showNodeHistory = false
@AppStorage("meshMapShowRouteLines") private var showRouteLines = false
@AppStorage("enableMapConvexHull") private var showConvexHull = false
// Map Configuration
@Namespace var mapScope
@State var selectedPosition: PositionEntity?
// Static UIImage caches keyed by node.num.
// Node colors are deterministic from node.num (via UIColor(hex:)), so caching by num is correct.
// nonisolated(unsafe) is required for static mutable state in Swift 6.
private nonisolated(unsafe) static var circleImageCache: [Int64: UIImage] = [:]
private nonisolated(unsafe) static var arrowImageCache: [Int64: UIImage] = [:]
@MapContentBuilder
var nodeMap: some MapContent {
let positionArray = node.positions
/// 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(positionArray, id: \.id) { position in
let headingDegrees = Angle.degrees(Double(position.heading))
/// Reduced Precision Map Circle
if position.latest && 12...15 ~= position.precisionBits {
let pp = PositionPrecision(rawValue: Int(position.precisionBits))
let radius: CLLocationDistance = pp?.precisionMeters ?? 0
if radius > 0.0 {
MapCircle(center: position.nodeCoordinate ?? LocationsHandler.DefaultLocation, radius: radius)
.foregroundStyle(Color(nodeColor).opacity(0.25))
.stroke(.white, lineWidth: 2)
}
}
/// Lastest Position Pin
if position.latest {
/// Node Annotations
Annotation(position.latest ? node.user?.shortName ?? "?": "", coordinate: position.nodeCoordinate ?? LocationsHandler.DefaultLocation) {
LazyVStack {
ZStack {
if pf.contains(.Heading) {
Image(systemName: pf.contains(.Speed) && position.speed > 1 ? "location.north" : "octagon")
.padding(5)
.foregroundStyle(nodeBorderColor)
.background(Color(nodeColor.darker()))
.clipShape(Circle())
.rotationEffect(headingDegrees)
.onTapGesture {
selectedPosition = (selectedPosition == position ? nil : position)
}
.popover(item: $selectedPosition) { selection in
PositionPopover(position: selection)
.padding()
.opacity(0.8)
.presentationCompactAdaptation(.popover)
}
} else {
Image(systemName: "flipphone")
.symbolEffect(.pulse.byLayer)
.padding(5)
.foregroundStyle(nodeBorderColor)
.background(Color(UIColor(hex: UInt32(node.num)).darker()))
.clipShape(Circle())
.onTapGesture {
selectedPosition = (selectedPosition == position ? nil : position)
}
.popover(item: $selectedPosition) { selection in
PositionPopover(position: selection)
.padding()
.opacity(0.8)
.presentationCompactAdaptation(.popover)
}
}
}
}
}
.tag(position.time)
.annotationTitles(.automatic)
.annotationSubtitles(.automatic)
}
/// Node History
if showNodeHistory {
// 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.nodeCoordinate ?? LocationsHandler.DefaultLocation) {
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)
.annotationSubtitles(.hidden)
}
}
}
// 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
var body: some MapContent {
if node.positions.count > 0 {
nodeMap
}
}
private func prerenderHistoryPointCircle(fill: Color, stroke: Color) -> UIImage {
if let cached = NodeMapContent.circleImageCache[node.num] { return cached }
let content = Circle()
.fill(fill)
.strokeBorder(stroke, lineWidth: 2)
.frame(width: 12, height: 12)
let renderer = ImageRenderer(content: content)
renderer.scale = UIScreen.main.scale
let image = renderer.uiImage!
NodeMapContent.circleImageCache[node.num] = image
return image
}
private func prerenderHistoryPointArrow(fill: Color, stroke: Color) -> UIImage {
if let cached = NodeMapContent.arrowImageCache[node.num] { return cached }
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
let image = renderer.uiImage!
NodeMapContent.arrowImageCache[node.num] = image
return image
}
}