diff --git a/Meshtastic/Helpers/GeoJSONOverlayConfig.swift b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift index b25b8509..e900e4d4 100644 --- a/Meshtastic/Helpers/GeoJSONOverlayConfig.swift +++ b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift @@ -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 diff --git a/Meshtastic/Helpers/GeoJSONOverlayManager.swift b/Meshtastic/Helpers/GeoJSONOverlayManager.swift index 82801db0..c0953668 100644 --- a/Meshtastic/Helpers/GeoJSONOverlayManager.swift +++ b/Meshtastic/Helpers/GeoJSONOverlayManager.swift @@ -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, 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) -> [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) diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index a73651fc..4f67a0d2 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -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) } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 39e42baa..ed6e71d7 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -40,7 +40,7 @@ struct MeshMapContent: MapContent { @AppStorage("mapOverlaysEnabled") private var showMapOverlays = false @Binding var enabledOverlayConfigs: Set - @FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn) + @FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .none) var positions: FetchedResults @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.. 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 } }