mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Cache map data better, fix delete all dm message crash
This commit is contained in:
parent
4a83444c13
commit
6d14a23998
5 changed files with 75 additions and 49 deletions
|
|
@ -174,10 +174,20 @@ struct GeoJSONStyledFeature: Identifiable {
|
|||
let id = UUID()
|
||||
let feature: GeoJSONFeature
|
||||
let overlayId: String
|
||||
/// MKOverlay pre-computed once at init — avoids repeated JSONSerialization + MKGeoJSONDecoder
|
||||
/// calls on every map render pass.
|
||||
let precomputedOverlay: MKOverlay?
|
||||
|
||||
/// Create MKOverlay from this styled feature
|
||||
func createOverlay() -> MKOverlay? {
|
||||
// Convert feature to standard GeoJSON format for MKGeoJSONDecoder
|
||||
init(feature: GeoJSONFeature, overlayId: String) {
|
||||
self.feature = feature
|
||||
self.overlayId = overlayId
|
||||
// Call the static helper after all stored properties are assigned so `self` is available
|
||||
// for the instance — but we don't actually need self here, so this is safe.
|
||||
self.precomputedOverlay = GeoJSONStyledFeature.makeOverlay(for: feature)
|
||||
}
|
||||
|
||||
/// Builds an MKOverlay from a GeoJSON feature. Static so it can be called from init.
|
||||
private static func makeOverlay(for feature: GeoJSONFeature) -> MKOverlay? {
|
||||
let featureDict: [String: Any] = [
|
||||
"type": feature.type,
|
||||
"geometry": [
|
||||
|
|
@ -188,31 +198,23 @@ struct GeoJSONStyledFeature: Identifiable {
|
|||
]
|
||||
|
||||
do {
|
||||
// Serialize feature dictionary to JSON data
|
||||
let geojsonData = try JSONSerialization.data(withJSONObject: featureDict)
|
||||
do {
|
||||
// Decode GeoJSON data into MKGeoJSONFeature objects
|
||||
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
|
||||
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature {
|
||||
// Extract geometry and create overlay
|
||||
if let geometry = mkFeature.geometry.first as? MKOverlay {
|
||||
// Successfully created overlay
|
||||
return geometry
|
||||
} else {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - Geometry is not an MKOverlay.")
|
||||
}
|
||||
} else {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON - No valid MKGeoJSONFeature found.")
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON data: \(error.localizedDescription)")
|
||||
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
|
||||
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature,
|
||||
let geometry = mkFeature.geometry.first as? MKOverlay {
|
||||
return geometry
|
||||
} else {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - no valid MKOverlay geometry.")
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to serialize feature dictionary to JSON: \(error.localizedDescription)")
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to build overlay: \(error.localizedDescription)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the pre-computed overlay. Retained for API compatibility.
|
||||
func createOverlay() -> MKOverlay? { precomputedOverlay }
|
||||
|
||||
/// Get stroke style for this feature
|
||||
var strokeStyle: StrokeStyle {
|
||||
let dashArray = feature.lineDashArray
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ class GeoJSONOverlayManager {
|
|||
private init() {}
|
||||
|
||||
private var featureCollection: GeoJSONFeatureCollection?
|
||||
// Cache the last styled-features result keyed by the enabled-configs set.
|
||||
// GeoJSONStyledFeature instances have stable UUIDs once created, so SwiftUI's
|
||||
// ForEach diffing correctly skips unchanged overlays between renders.
|
||||
private var styledFeaturesCache: (configs: Set<UUID>, features: [GeoJSONStyledFeature])?
|
||||
|
||||
/// Load raw GeoJSON feature collection from user uploads
|
||||
func loadFeatureCollection() -> GeoJSONFeatureCollection? {
|
||||
|
|
@ -24,36 +28,35 @@ class GeoJSONOverlayManager {
|
|||
return nil
|
||||
}
|
||||
|
||||
/// Load styled features for specific enabled configs
|
||||
/// Load styled features for specific enabled configs.
|
||||
/// Results are cached per unique `enabledConfigs` set — file I/O and JSON decoding
|
||||
/// only happen when the set changes, not on every map render.
|
||||
func loadStyledFeaturesForConfigs(_ enabledConfigs: Set<UUID>) -> [GeoJSONStyledFeature] {
|
||||
// Get files that match the enabled configs
|
||||
let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) }
|
||||
if let cache = styledFeaturesCache, cache.configs == enabledConfigs {
|
||||
return cache.features
|
||||
}
|
||||
|
||||
let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) }
|
||||
guard !enabledFiles.isEmpty else {
|
||||
styledFeaturesCache = (configs: enabledConfigs, features: [])
|
||||
return []
|
||||
}
|
||||
|
||||
// Load feature collection from enabled files only
|
||||
guard let collection = MapDataManager.shared.loadFeatureCollectionForFiles(enabledFiles) else {
|
||||
styledFeaturesCache = (configs: enabledConfigs, features: [])
|
||||
return []
|
||||
}
|
||||
|
||||
var styledFeatures: [GeoJSONStyledFeature] = []
|
||||
|
||||
for feature in collection.features {
|
||||
// Skip invisible features
|
||||
guard feature.isVisible else {
|
||||
continue
|
||||
}
|
||||
|
||||
let layerId = feature.layerId ?? "default"
|
||||
let styledFeature = GeoJSONStyledFeature(
|
||||
guard feature.isVisible else { continue }
|
||||
styledFeatures.append(GeoJSONStyledFeature(
|
||||
feature: feature,
|
||||
overlayId: layerId
|
||||
)
|
||||
styledFeatures.append(styledFeature)
|
||||
overlayId: feature.layerId ?? "default"
|
||||
))
|
||||
}
|
||||
|
||||
styledFeaturesCache = (configs: enabledConfigs, features: styledFeatures)
|
||||
return styledFeatures
|
||||
}
|
||||
|
||||
|
|
@ -106,9 +109,10 @@ class GeoJSONOverlayManager {
|
|||
return Array(layerIds).sorted()
|
||||
}
|
||||
|
||||
/// Clear cached data (useful for testing or memory management)
|
||||
/// Clear cached data (called when files are added, deleted, or toggled).
|
||||
func clearCache() {
|
||||
featureCollection = nil
|
||||
styledFeaturesCache = nil
|
||||
}
|
||||
|
||||
/// Check if user-uploaded data is available (regardless of active state)
|
||||
|
|
|
|||
|
|
@ -184,7 +184,12 @@ extension MeshPackets {
|
|||
|
||||
nonisolated public func deleteUserMessages(user: UserEntity, context: NSManagedObjectContext) {
|
||||
do {
|
||||
let objects = user.messageList
|
||||
// Fetch messages using the same context that will perform the deletes.
|
||||
// user.messageList fetches from viewContext, which would cause a context-mismatch
|
||||
// crash when this method is called with a background context.
|
||||
let fetchRequest = MessageEntity.fetchRequest()
|
||||
fetchRequest.predicate = user.messageFetchRequest.predicate
|
||||
let objects = (try? context.fetch(fetchRequest)) ?? []
|
||||
for object in objects {
|
||||
context.delete(object)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ struct MeshMapContent: MapContent {
|
|||
@AppStorage("mapOverlaysEnabled") private var showMapOverlays = false
|
||||
@Binding var enabledOverlayConfigs: Set<UUID>
|
||||
|
||||
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn)
|
||||
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .none)
|
||||
var positions: FetchedResults<PositionEntity>
|
||||
|
||||
@FetchRequest(fetchRequest: WaypointEntity.allWaypointssFetchRequest(), animation: .none)
|
||||
|
|
@ -184,10 +184,13 @@ struct MeshMapContent: MapContent {
|
|||
|
||||
@MapContentBuilder
|
||||
var meshMap: some MapContent {
|
||||
let loraNodes = positions.filter { $0.nodePosition?.viaMqtt ?? true == false }
|
||||
let loraCoords = Array(loraNodes).compactMap({(position) -> CLLocationCoordinate2D in
|
||||
return position.nodeCoordinate ?? LocationsHandler.DefaultLocation
|
||||
})
|
||||
// Only compute LoRa node coordinates when the convex hull is actually displayed.
|
||||
// The filter scans the entire positions array on every render, so guard it.
|
||||
let loraCoords: [CLLocationCoordinate2D] = showConvexHull
|
||||
? positions
|
||||
.filter { !($0.nodePosition?.viaMqtt ?? true) }
|
||||
.compactMap { $0.nodeCoordinate ?? LocationsHandler.DefaultLocation }
|
||||
: []
|
||||
/// Convex Hull
|
||||
if showConvexHull {
|
||||
if loraCoords.count > 0 {
|
||||
|
|
@ -214,8 +217,10 @@ struct MeshMapContent: MapContent {
|
|||
let allStyledFeatures = GeoJSONOverlayManager.shared.loadStyledFeaturesForConfigs(enabledOverlayConfigs)
|
||||
|
||||
return Group {
|
||||
ForEach(0..<allStyledFeatures.count, id: \.self) { index in
|
||||
let styledFeature = allStyledFeatures[index]
|
||||
// GeoJSONStyledFeature is Identifiable with a stable UUID assigned at creation.
|
||||
// Using ForEach with Identifiable gives SwiftUI stable identity for diffing,
|
||||
// avoiding full teardown/rebuild of overlay views on each render.
|
||||
ForEach(allStyledFeatures) { styledFeature in
|
||||
let feature = styledFeature.feature
|
||||
let geometryType = feature.geometry.type
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ struct NodeMapContent: MapContent {
|
|||
@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?.array as? [PositionEntity] ?? []
|
||||
|
|
@ -160,18 +166,20 @@ struct NodeMapContent: MapContent {
|
|||
}
|
||||
|
||||
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.
|
||||
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
|
||||
return renderer.uiImage!
|
||||
let image = renderer.uiImage!
|
||||
NodeMapContent.circleImageCache[node.num] = image
|
||||
return image
|
||||
}
|
||||
|
||||
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.
|
||||
if let cached = NodeMapContent.arrowImageCache[node.num] { return cached }
|
||||
let content = Image(systemName: "location.north.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
|
|
@ -181,6 +189,8 @@ struct NodeMapContent: MapContent {
|
|||
.frame(width: 16, height: 16)
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = UIScreen.main.scale
|
||||
return renderer.uiImage!
|
||||
let image = renderer.uiImage!
|
||||
NodeMapContent.arrowImageCache[node.num] = image
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue