mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
wip
This commit is contained in:
parent
7e0d37d76f
commit
909ec06fd9
6 changed files with 593 additions and 190 deletions
|
|
@ -1346,6 +1346,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld features" : {
|
||||
|
||||
},
|
||||
"%lld or less hops away" : {
|
||||
"localizations" : {
|
||||
|
|
@ -14554,6 +14557,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Files Available" : {
|
||||
"comment" : "Data source label when files exist but none are active"
|
||||
},
|
||||
"Find a contact" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
|
@ -20402,6 +20408,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Manage map data" : {
|
||||
"comment" : "Link to manage uploaded map data"
|
||||
},
|
||||
"Managed Device" : {
|
||||
"localizations" : {
|
||||
"it" : {
|
||||
|
|
@ -23415,6 +23424,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"No map data files uploaded" : {
|
||||
"comment" : "Message when no files are uploaded"
|
||||
},
|
||||
"No PAX Counter Logs" : {
|
||||
"localizations" : {
|
||||
"it" : {
|
||||
|
|
@ -42813,4 +42825,4 @@
|
|||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +1,10 @@
|
|||
import Foundation
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
import OSLog
|
||||
|
||||
// MARK: - Configuration Models
|
||||
|
||||
struct GeoJSONOverlayConfiguration: Codable {
|
||||
let version: String
|
||||
let metadata: OverlayMetadata
|
||||
let overlays: [OverlayDefinition]
|
||||
}
|
||||
|
||||
struct OverlayMetadata: Codable {
|
||||
let name: String
|
||||
let description: String
|
||||
let generated: String
|
||||
}
|
||||
|
||||
struct OverlayDefinition: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String
|
||||
let rendering: RenderingProperties
|
||||
let geojson: GeoJSONFeatureCollection
|
||||
}
|
||||
|
||||
struct RenderingProperties: Codable {
|
||||
let lineColor: String // Hex color (e.g., "#FF0000")
|
||||
let lineOpacity: Double // 0.0 to 1.0
|
||||
let lineThickness: Double // Line width in points
|
||||
let fillOpacity: Double // 0.0 to 1.0
|
||||
}
|
||||
// MARK: - Raw GeoJSON Support Only
|
||||
|
||||
struct GeoJSONFeatureCollection: Codable {
|
||||
let type: String // Always "FeatureCollection"
|
||||
|
|
@ -40,6 +16,218 @@ struct GeoJSONFeature: Codable {
|
|||
let id: Int?
|
||||
let geometry: GeoJSONGeometry
|
||||
let properties: [String: AnyCodableValue]?
|
||||
|
||||
// MARK: - GeoJSON Styling Properties
|
||||
|
||||
/// Extract layer metadata from properties
|
||||
var layerId: String? {
|
||||
if case .string(let value) = properties?["layer_id"] {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var layerName: String? {
|
||||
if case .string(let value) = properties?["layer_name"] {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var layerDescription: String? {
|
||||
if case .string(let value) = properties?["description"] {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var isVisible: Bool {
|
||||
if case .bool(let value) = properties?["visible"] {
|
||||
return value
|
||||
}
|
||||
return true // Default to visible
|
||||
}
|
||||
|
||||
// MARK: - Point/Marker Styling
|
||||
|
||||
var markerColor: String? {
|
||||
if case .string(let value) = properties?["marker-color"] {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var markerSize: String? {
|
||||
if case .string(let value) = properties?["marker-size"] {
|
||||
return value
|
||||
}
|
||||
return "medium" // Default size
|
||||
}
|
||||
|
||||
var markerSymbol: String? {
|
||||
if case .string(let value) = properties?["marker-symbol"] {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Stroke/Line Styling
|
||||
|
||||
var strokeColor: String? {
|
||||
if case .string(let value) = properties?["stroke"] {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var strokeWidth: Double {
|
||||
if case .double(let value) = properties?["stroke-width"] {
|
||||
return value
|
||||
} else if case .int(let value) = properties?["stroke-width"] {
|
||||
return Double(value)
|
||||
}
|
||||
return 1.0 // Default width
|
||||
}
|
||||
|
||||
var strokeOpacity: Double {
|
||||
if case .double(let value) = properties?["stroke-opacity"] {
|
||||
return value
|
||||
} else if case .int(let value) = properties?["stroke-opacity"] {
|
||||
return Double(value)
|
||||
}
|
||||
return 1.0 // Default opacity
|
||||
}
|
||||
|
||||
var lineDashArray: [Double]? {
|
||||
if case .array(let values) = properties?["line-dasharray"] {
|
||||
return values.compactMap { value in
|
||||
switch value {
|
||||
case .double(let d): return d
|
||||
case .int(let i): return Double(i)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Fill Styling
|
||||
|
||||
var fillColor: String? {
|
||||
if case .string(let value) = properties?["fill"] {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var fillOpacity: Double {
|
||||
if case .double(let value) = properties?["fill-opacity"] {
|
||||
return value
|
||||
} else if case .int(let value) = properties?["fill-opacity"] {
|
||||
return Double(value)
|
||||
}
|
||||
return 0.0 // Default to no fill
|
||||
}
|
||||
|
||||
// MARK: - Computed Rendering Properties
|
||||
|
||||
/// Get effective stroke color (fallback to marker color for points)
|
||||
var effectiveStrokeColor: String {
|
||||
return strokeColor ?? markerColor ?? "#000000"
|
||||
}
|
||||
|
||||
/// Get effective fill color (fallback to stroke color if fill opacity > 0)
|
||||
var effectiveFillColor: String {
|
||||
if fillOpacity > 0 {
|
||||
return fillColor ?? effectiveStrokeColor
|
||||
}
|
||||
return "#000000"
|
||||
}
|
||||
|
||||
/// Convert marker size to point radius
|
||||
var markerRadius: CGFloat {
|
||||
switch markerSize {
|
||||
case "small": return 8.0
|
||||
case "medium": return 12.0
|
||||
case "large": return 16.0
|
||||
default: return 12.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Styled Feature Wrapper
|
||||
|
||||
/// Wrapper for a GeoJSON feature with its styling properties and metadata
|
||||
struct GeoJSONStyledFeature: Identifiable {
|
||||
let id = UUID()
|
||||
let feature: GeoJSONFeature
|
||||
let overlayId: String
|
||||
|
||||
/// Create MKOverlay from this styled feature
|
||||
func createOverlay() -> MKOverlay? {
|
||||
do {
|
||||
// Convert feature to standard GeoJSON format for MKGeoJSONDecoder
|
||||
let featureDict: [String: Any] = [
|
||||
"type": feature.type,
|
||||
"geometry": [
|
||||
"type": feature.geometry.type,
|
||||
"coordinates": feature.geometry.coordinates.toAnyObject()
|
||||
],
|
||||
"properties": feature.properties?.mapValues { $0.toAnyObject() } ?? [:]
|
||||
]
|
||||
|
||||
// Creating overlay for geometry
|
||||
|
||||
let geojsonData = try JSONSerialization.data(withJSONObject: featureDict)
|
||||
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
|
||||
|
||||
// MKGeoJSONDecoder processing
|
||||
|
||||
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature {
|
||||
// Processing geometry objects
|
||||
if let geometry = mkFeature.geometry.first as? MKOverlay {
|
||||
// Successfully created overlay
|
||||
return geometry
|
||||
} else {
|
||||
Logger.services.warning("🗺️ GeoJSONStyledFeature: First geometry is not an MKOverlay: \(type(of: mkFeature.geometry.first))")
|
||||
}
|
||||
} else {
|
||||
Logger.services.warning("🗺️ GeoJSONStyledFeature: First feature is not an MKGeoJSONFeature: \(type(of: mkFeatures.first))")
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to convert feature to overlay: \(error.localizedDescription)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Get stroke style for this feature
|
||||
var strokeStyle: StrokeStyle {
|
||||
let dashArray = feature.lineDashArray
|
||||
if let dashArray = dashArray, !dashArray.isEmpty {
|
||||
return StrokeStyle(
|
||||
lineWidth: feature.strokeWidth,
|
||||
lineCap: .round,
|
||||
lineJoin: .round,
|
||||
dash: dashArray.map { CGFloat($0) }
|
||||
)
|
||||
} else {
|
||||
return StrokeStyle(
|
||||
lineWidth: feature.strokeWidth,
|
||||
lineCap: .round,
|
||||
lineJoin: .round
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stroke color with opacity
|
||||
var strokeColor: Color {
|
||||
return Color(hex: feature.effectiveStrokeColor).opacity(feature.strokeOpacity)
|
||||
}
|
||||
|
||||
/// Get fill color with opacity
|
||||
var fillColor: Color {
|
||||
return Color(hex: feature.effectiveFillColor).opacity(feature.fillOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
struct GeoJSONGeometry: Codable {
|
||||
|
|
@ -120,4 +308,28 @@ enum AnyCodableValue: Codable {
|
|||
return dict.mapValues { $0.toAnyObject() }
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to convert Point coordinates to CLLocationCoordinate2D
|
||||
func toCoordinate() -> CLLocationCoordinate2D? {
|
||||
if case .array(let coords) = self,
|
||||
coords.count >= 2 {
|
||||
let lon: Double
|
||||
let lat: Double
|
||||
|
||||
switch coords[0] {
|
||||
case .double(let d): lon = d
|
||||
case .int(let i): lon = Double(i)
|
||||
default: return nil
|
||||
}
|
||||
|
||||
switch coords[1] {
|
||||
case .double(let d): lat = d
|
||||
case .int(let i): lat = Double(i)
|
||||
default: return nil
|
||||
}
|
||||
|
||||
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +1,131 @@
|
|||
import SwiftUI
|
||||
import MapKit
|
||||
import OSLog
|
||||
|
||||
/// Manager for loading and managing GeoJSON overlays from consolidated configuration
|
||||
/// Manager for loading and managing raw GeoJSON feature collections with embedded styling
|
||||
class GeoJSONOverlayManager {
|
||||
static let shared = GeoJSONOverlayManager()
|
||||
private init() {}
|
||||
|
||||
private var configuration: GeoJSONOverlayConfiguration?
|
||||
private var overlays: [String: [MKOverlay]] = [:]
|
||||
private var featureCollection: GeoJSONFeatureCollection?
|
||||
|
||||
/// Load user-uploaded configuration only
|
||||
func loadConfiguration() -> GeoJSONOverlayConfiguration? {
|
||||
if let cached = configuration {
|
||||
/// Load raw GeoJSON feature collection from user uploads
|
||||
func loadFeatureCollection() -> GeoJSONFeatureCollection? {
|
||||
Logger.services.debug("🗺️ GeoJSONOverlayManager: loadFeatureCollection() called")
|
||||
|
||||
if let cached = featureCollection {
|
||||
Logger.services.debug("🗺️ GeoJSONOverlayManager: Returning cached feature collection with \(cached.features.count) features")
|
||||
return cached
|
||||
}
|
||||
|
||||
// Load user-uploaded configuration
|
||||
if let userConfig = MapDataManager.shared.loadUserConfiguration() {
|
||||
configuration = userConfig
|
||||
return userConfig
|
||||
// Load user-uploaded feature collection
|
||||
Logger.services.debug("🗺️ GeoJSONOverlayManager: Loading feature collection from MapDataManager")
|
||||
if let userFeatures = MapDataManager.shared.loadFeatureCollection() {
|
||||
Logger.services.info("🗺️ GeoJSONOverlayManager: Loaded feature collection with \(userFeatures.features.count) features")
|
||||
featureCollection = userFeatures
|
||||
return userFeatures
|
||||
}
|
||||
|
||||
// No configuration available
|
||||
// No feature collection available
|
||||
Logger.services.debug("🗺️ GeoJSONOverlayManager: No feature collection available")
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Load overlays for a specific overlay ID
|
||||
func loadOverlays(for overlayId: String) -> [MKOverlay] {
|
||||
if let cached = overlays[overlayId] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let config = loadConfiguration() else {
|
||||
/// Load styled features for direct rendering
|
||||
func loadStyledFeatures() -> [GeoJSONStyledFeature] {
|
||||
Logger.services.debug("🗺️ GeoJSONOverlayManager: loadStyledFeatures() called")
|
||||
|
||||
guard let collection = loadFeatureCollection() else {
|
||||
Logger.services.debug("🗺️ GeoJSONOverlayManager: No feature collection available, returning empty array")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let overlayDef = config.overlays.first(where: { $0.id == overlayId }) else {
|
||||
return []
|
||||
}
|
||||
|
||||
do {
|
||||
// Convert our custom GeoJSON structure to the format expected by MKGeoJSONDecoder
|
||||
let standardGeoJSON: [String: Any] = [
|
||||
"type": overlayDef.geojson.type,
|
||||
"features": overlayDef.geojson.features.map { feature in
|
||||
var featureDict: [String: Any] = [
|
||||
"type": feature.type,
|
||||
"geometry": [
|
||||
"type": feature.geometry.type,
|
||||
"coordinates": feature.geometry.coordinates.toAnyObject()
|
||||
],
|
||||
"properties": [:]
|
||||
]
|
||||
|
||||
if let id = feature.id {
|
||||
featureDict["id"] = id
|
||||
}
|
||||
|
||||
return featureDict
|
||||
}
|
||||
]
|
||||
|
||||
let geojsonData = try JSONSerialization.data(withJSONObject: standardGeoJSON)
|
||||
let features = try MKGeoJSONDecoder().decode(geojsonData)
|
||||
|
||||
var allOverlays: [MKOverlay] = []
|
||||
for (index, feature) in features.enumerated() {
|
||||
if let mkFeature = feature as? MKGeoJSONFeature {
|
||||
for (geoIndex, geometry) in mkFeature.geometry.enumerated() {
|
||||
if let overlay = geometry as? MKOverlay {
|
||||
allOverlays.append(overlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var styledFeatures: [GeoJSONStyledFeature] = []
|
||||
|
||||
Logger.services.info("🗺️ GeoJSONOverlayManager: Processing \(collection.features.count) features")
|
||||
|
||||
for feature in collection.features {
|
||||
// Skip invisible features
|
||||
guard feature.isVisible else {
|
||||
Logger.services.debug("🗺️ GeoJSONOverlayManager: Skipping invisible feature")
|
||||
continue
|
||||
}
|
||||
|
||||
overlays[overlayId] = allOverlays
|
||||
return allOverlays
|
||||
} catch {
|
||||
return []
|
||||
|
||||
let layerId = feature.layerId ?? "default"
|
||||
let styledFeature = GeoJSONStyledFeature(
|
||||
feature: feature,
|
||||
overlayId: layerId
|
||||
)
|
||||
styledFeatures.append(styledFeature)
|
||||
}
|
||||
|
||||
Logger.services.info("🗺️ GeoJSONOverlayManager: Returning \(styledFeatures.count) styled features")
|
||||
return styledFeatures
|
||||
}
|
||||
|
||||
/// Get rendering properties for an overlay
|
||||
func getRenderingProperties(for overlayId: String) -> RenderingProperties? {
|
||||
guard let config = loadConfiguration() else { return nil }
|
||||
return config.overlays.first(where: { $0.id == overlayId })?.rendering
|
||||
/// Get all features grouped by layer ID
|
||||
func getFeaturesByLayer() -> [String: [GeoJSONFeature]] {
|
||||
guard let collection = loadFeatureCollection() else { return [:] }
|
||||
|
||||
var featuresByLayer: [String: [GeoJSONFeature]] = [:]
|
||||
|
||||
for feature in collection.features {
|
||||
let layerId = feature.layerId ?? "default"
|
||||
if featuresByLayer[layerId] == nil {
|
||||
featuresByLayer[layerId] = []
|
||||
}
|
||||
featuresByLayer[layerId]?.append(feature)
|
||||
}
|
||||
|
||||
return featuresByLayer
|
||||
}
|
||||
|
||||
/// Get all available overlay IDs
|
||||
func getAvailableOverlayIds() -> [String] {
|
||||
guard let config = loadConfiguration() else { return [] }
|
||||
return config.overlays.map { $0.id }
|
||||
/// Get all available layer IDs from features
|
||||
func getAvailableLayerIds() -> [String] {
|
||||
guard let collection = loadFeatureCollection() else { return [] }
|
||||
let layerIds = Set(collection.features.compactMap { $0.layerId ?? "default" })
|
||||
return Array(layerIds).sorted()
|
||||
}
|
||||
|
||||
/// Get overlay definition by ID
|
||||
func getOverlayDefinition(for overlayId: String) -> OverlayDefinition? {
|
||||
guard let config = loadConfiguration() else { return nil }
|
||||
return config.overlays.first(where: { $0.id == overlayId })
|
||||
}
|
||||
|
||||
/// Clear cached overlays (useful for testing or memory management)
|
||||
/// Clear cached data (useful for testing or memory management)
|
||||
func clearCache() {
|
||||
overlays.removeAll()
|
||||
configuration = nil
|
||||
Logger.services.info("🗺️ GeoJSONOverlayManager: Clearing cache")
|
||||
featureCollection = nil
|
||||
}
|
||||
|
||||
/// Check if user-uploaded data is available
|
||||
/// Check if user-uploaded data is available (regardless of active state)
|
||||
func hasUserData() -> Bool {
|
||||
return !MapDataManager.shared.getUploadedFiles().isEmpty
|
||||
}
|
||||
|
||||
/// Check if there are any active files
|
||||
func hasActiveData() -> Bool {
|
||||
return MapDataManager.shared.getUploadedFiles().contains { $0.isActive }
|
||||
}
|
||||
|
||||
/// Get the active data source name
|
||||
func getActiveDataSource() -> String {
|
||||
if hasUserData() {
|
||||
if hasActiveData() {
|
||||
return NSLocalizedString("User Uploaded", comment: "Data source label for user uploaded files")
|
||||
} else if hasUserData() {
|
||||
return NSLocalizedString("Files Available", comment: "Data source label when files exist but none are active")
|
||||
} else {
|
||||
return NSLocalizedString("No Data", comment: "Data source label when no files are available")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File-based Filtering
|
||||
|
||||
/// Get all uploaded files with their active states for UI display
|
||||
func getUploadedFilesWithState() -> [MapDataMetadata] {
|
||||
return MapDataManager.shared.getUploadedFiles()
|
||||
}
|
||||
|
||||
/// Toggle the active state of an uploaded file
|
||||
func toggleFileActive(_ fileId: UUID) {
|
||||
Logger.services.debug("🗺️ GeoJSONOverlayManager: Toggling active state for file: \(fileId)")
|
||||
MapDataManager.shared.toggleFileActive(fileId)
|
||||
// Clear cache to force reload with new file states
|
||||
clearCache()
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ class MapDataManager {
|
|||
|
||||
// MARK: - Properties
|
||||
private var uploadedFiles: [MapDataMetadata] = []
|
||||
private var activeConfiguration: GeoJSONOverlayConfiguration?
|
||||
private var activeFeatureCollection: GeoJSONFeatureCollection?
|
||||
|
||||
// MARK: - File Management
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ class MapDataManager {
|
|||
try saveMetadata()
|
||||
|
||||
// 7. Clear cached configuration to force reload
|
||||
activeConfiguration = nil
|
||||
activeFeatureCollection = nil
|
||||
|
||||
Logger.services.info("📁 Successfully processed file: \(newFilename, privacy: .public)")
|
||||
return metadata
|
||||
|
|
@ -131,7 +131,7 @@ class MapDataManager {
|
|||
let uploadDate = fileAttributes.creationDate ?? Date()
|
||||
|
||||
// Read and process file content on background queue
|
||||
let (processedData, overlayCount) = try await withCheckedThrowingContinuation { continuation in
|
||||
let (_, overlayCount) = try await withCheckedThrowingContinuation { continuation in
|
||||
Task.detached {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
|
|
@ -172,50 +172,84 @@ class MapDataManager {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get overlay count from processed data
|
||||
/// Get overlay count from raw GeoJSON data
|
||||
private func getOverlayCount(from data: Data) throws -> Int {
|
||||
do {
|
||||
let config = try JSONDecoder().decode(GeoJSONOverlayConfiguration.self, from: data)
|
||||
return config.overlays.count
|
||||
} catch {
|
||||
// Try parsing as raw GeoJSON
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let features = json["features"] as? [[String: Any]] {
|
||||
return features.count
|
||||
}
|
||||
throw MapDataError.invalidContent
|
||||
// Parse as raw GeoJSON FeatureCollection
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let features = json["features"] as? [[String: Any]] {
|
||||
return features.count
|
||||
}
|
||||
throw MapDataError.invalidContent
|
||||
}
|
||||
|
||||
// MARK: - Configuration Loading
|
||||
|
||||
/// Load user configuration (priority over bundled)
|
||||
func loadUserConfiguration() -> GeoJSONOverlayConfiguration? {
|
||||
if let cached = activeConfiguration {
|
||||
/// Load and combine raw GeoJSON feature collections from all active files
|
||||
func loadFeatureCollection() -> GeoJSONFeatureCollection? {
|
||||
if let cached = activeFeatureCollection {
|
||||
Logger.services.debug("📁 MapDataManager: Returning cached feature collection")
|
||||
return cached
|
||||
}
|
||||
|
||||
// Find active user files
|
||||
let activeFiles = uploadedFiles.filter { $0.isActive }
|
||||
guard let activeFile = activeFiles.first else {
|
||||
Logger.services.debug("📁 MapDataManager: Found \(activeFiles.count) active files out of \(self.uploadedFiles.count) total files")
|
||||
|
||||
guard !activeFiles.isEmpty else {
|
||||
Logger.services.debug("📁 MapDataManager: No active files found")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(activeFile.filename) else {
|
||||
return nil
|
||||
}
|
||||
var allFeatures: [GeoJSONFeature] = []
|
||||
|
||||
// Load features from all active files
|
||||
for activeFile in activeFiles {
|
||||
Logger.services.info("📁 MapDataManager: Attempting to load active file: \(activeFile.filename, privacy: .public)")
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
let processedData = try processData(data, filename: activeFile.filename)
|
||||
let config = try JSONDecoder().decode(GeoJSONOverlayConfiguration.self, from: processedData)
|
||||
guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(activeFile.filename) else {
|
||||
Logger.services.error("📁 MapDataManager: Could not construct file URL for: \(activeFile.filename, privacy: .public)")
|
||||
continue
|
||||
}
|
||||
|
||||
activeConfiguration = config
|
||||
return config
|
||||
} catch {
|
||||
Logger.services.error("📁 Failed to load user configuration: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
// Check if file exists before trying to load it
|
||||
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
Logger.services.error("📁 MapDataManager: Active file does not exist at path: \(fileURL.path, privacy: .public)")
|
||||
Logger.services.info("📁 MapDataManager: Removing missing file from metadata")
|
||||
|
||||
// Remove the missing file from our metadata
|
||||
if let index = uploadedFiles.firstIndex(where: { $0.filename == activeFile.filename }) {
|
||||
uploadedFiles.remove(at: index)
|
||||
do {
|
||||
try saveMetadata()
|
||||
Logger.services.info("📁 MapDataManager: Successfully cleaned up missing file from metadata")
|
||||
} catch {
|
||||
Logger.services.error("📁 MapDataManager: Failed to save cleaned metadata: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
let processedData = try processData(data, filename: activeFile.filename)
|
||||
let featureCollection = try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: processedData)
|
||||
|
||||
Logger.services.info("📁 MapDataManager: Successfully loaded \(featureCollection.features.count) features from \(activeFile.filename, privacy: .public)")
|
||||
allFeatures.append(contentsOf: featureCollection.features)
|
||||
} catch {
|
||||
Logger.services.error("📁 MapDataManager: Failed to load feature collection from \(activeFile.filename, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// Create combined feature collection
|
||||
let combinedCollection = GeoJSONFeatureCollection(
|
||||
type: "FeatureCollection",
|
||||
features: allFeatures
|
||||
)
|
||||
|
||||
Logger.services.info("📁 MapDataManager: Successfully combined \(allFeatures.count) total features from \(activeFiles.count) active files")
|
||||
activeFeatureCollection = combinedCollection
|
||||
return combinedCollection
|
||||
}
|
||||
|
||||
// MARK: - File Management
|
||||
|
|
@ -224,27 +258,77 @@ class MapDataManager {
|
|||
func getUploadedFiles() -> [MapDataMetadata] {
|
||||
return uploadedFiles
|
||||
}
|
||||
|
||||
/// Toggle the active state of an uploaded file
|
||||
func toggleFileActive(_ fileId: UUID) {
|
||||
Logger.services.debug("📁 MapDataManager: Toggling active state for file: \(fileId)")
|
||||
|
||||
if let index = uploadedFiles.firstIndex(where: { $0.id == fileId }) {
|
||||
uploadedFiles[index].isActive.toggle()
|
||||
Logger.services.info("📁 MapDataManager: File '\(self.uploadedFiles[index].filename)' active state: \(self.uploadedFiles[index].isActive)")
|
||||
|
||||
// Save metadata changes
|
||||
do {
|
||||
try saveMetadata()
|
||||
// Clear cached data to force reload
|
||||
activeFeatureCollection = nil
|
||||
} catch {
|
||||
Logger.services.error("📁 MapDataManager: Failed to save metadata after toggling file: \(error.localizedDescription)")
|
||||
}
|
||||
} else {
|
||||
Logger.services.error("📁 MapDataManager: Could not find file with ID: \(fileId)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete uploaded file
|
||||
func deleteFile(_ metadata: MapDataMetadata) throws {
|
||||
Logger.services.info("🗑️ MapDataManager: Attempting to delete file: \(metadata.filename, privacy: .public)")
|
||||
|
||||
guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(metadata.filename) else {
|
||||
Logger.services.error("🗑️ MapDataManager: Could not construct file URL for: \(metadata.filename, privacy: .public)")
|
||||
throw MapDataError.fileNotFound
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
Logger.services.debug("🗑️ MapDataManager: File URL: \(fileURL.path, privacy: .public)")
|
||||
|
||||
// Check if file exists before trying to delete
|
||||
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
Logger.services.warning("🗑️ MapDataManager: File does not exist at path: \(fileURL.path, privacy: .public)")
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
Logger.services.info("🗑️ MapDataManager: Successfully removed file from filesystem")
|
||||
} catch {
|
||||
Logger.services.error("🗑️ MapDataManager: Failed to remove file: \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
|
||||
if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) {
|
||||
uploadedFiles.remove(at: index)
|
||||
Logger.services.debug("🗑️ MapDataManager: Removed file from uploadedFiles array at index \(index)")
|
||||
} else {
|
||||
Logger.services.warning("🗑️ MapDataManager: File not found in uploadedFiles array")
|
||||
}
|
||||
|
||||
try saveMetadata()
|
||||
do {
|
||||
try saveMetadata()
|
||||
Logger.services.debug("🗑️ MapDataManager: Successfully saved updated metadata")
|
||||
} catch {
|
||||
Logger.services.error("🗑️ MapDataManager: Failed to save metadata: \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
|
||||
// Clear cache if this was the active file
|
||||
if activeConfiguration != nil {
|
||||
activeConfiguration = nil
|
||||
if activeFeatureCollection != nil {
|
||||
activeFeatureCollection = nil
|
||||
Logger.services.debug("🗑️ MapDataManager: Cleared active configuration cache")
|
||||
}
|
||||
|
||||
// Clear GeoJSON overlay manager cache
|
||||
GeoJSONOverlayManager.shared.clearCache()
|
||||
|
||||
Logger.services.info("🗑️ Deleted file: \(metadata.filename, privacy: .public)")
|
||||
Logger.services.info("🗑️ MapDataManager: Successfully deleted file: \(metadata.filename, privacy: .public)")
|
||||
}
|
||||
|
||||
/// Toggle file active status
|
||||
|
|
@ -265,7 +349,7 @@ class MapDataManager {
|
|||
try saveMetadata()
|
||||
|
||||
// Clear cache to force reload
|
||||
activeConfiguration = nil
|
||||
activeFeatureCollection = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
import CoreLocation
|
||||
import OSLog
|
||||
|
||||
struct IdentifiableOverlay: Identifiable {
|
||||
let overlay: MKOverlay
|
||||
|
|
@ -231,37 +233,9 @@ struct MeshMapContent: MapContent {
|
|||
}
|
||||
}
|
||||
|
||||
/// GeoJSON Overlays (Configuration-Driven)
|
||||
/// GeoJSON Overlays with embedded styling
|
||||
if showMapOverlays {
|
||||
let overlayManager = GeoJSONOverlayManager.shared
|
||||
let availableOverlays = overlayManager.getAvailableOverlayIds()
|
||||
|
||||
ForEach(Array(availableOverlays.enumerated()), id: \.element) { _, overlayId in
|
||||
let overlays = overlayManager.loadOverlays(for: overlayId)
|
||||
let rendering = overlayManager.getRenderingProperties(for: overlayId)
|
||||
|
||||
ForEach(Array(overlays.enumerated()), id: \.offset) { _, overlay in
|
||||
if let polygon = overlay as? MKPolygon {
|
||||
MapPolygon(polygon)
|
||||
.stroke(
|
||||
Color(hex: rendering?.lineColor ?? "#000000")
|
||||
.opacity(rendering?.lineOpacity ?? 1.0),
|
||||
lineWidth: rendering?.lineThickness ?? 1.0
|
||||
)
|
||||
.foregroundStyle(
|
||||
Color(hex: rendering?.lineColor ?? "#000000")
|
||||
.opacity(rendering?.fillOpacity ?? 0.0)
|
||||
)
|
||||
} else if let polyline = overlay as? MKPolyline {
|
||||
MapPolyline(polyline)
|
||||
.stroke(
|
||||
Color(hex: rendering?.lineColor ?? "#000000")
|
||||
.opacity(rendering?.lineOpacity ?? 1.0),
|
||||
lineWidth: rendering?.lineThickness ?? 1.0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
overlayContent
|
||||
}
|
||||
|
||||
positionAnnotations
|
||||
|
|
@ -269,6 +243,42 @@ struct MeshMapContent: MapContent {
|
|||
waypointAnnotations
|
||||
}
|
||||
|
||||
var overlayContent: some MapContent {
|
||||
let styledFeatures = GeoJSONOverlayManager.shared.loadStyledFeatures()
|
||||
|
||||
return Group {
|
||||
ForEach(0..<styledFeatures.count, id: \.self) { index in
|
||||
let styledFeature = styledFeatures[index]
|
||||
let feature = styledFeature.feature
|
||||
let geometryType = feature.geometry.type
|
||||
|
||||
if geometryType == "Point" {
|
||||
if let coordinate = feature.geometry.coordinates.toCoordinate() {
|
||||
Annotation("", coordinate: coordinate) {
|
||||
Circle()
|
||||
.fill(styledFeature.fillColor)
|
||||
.stroke(styledFeature.strokeColor, style: styledFeature.strokeStyle)
|
||||
.frame(width: feature.markerRadius * 2, height: feature.markerRadius * 2)
|
||||
}
|
||||
.annotationTitles(.hidden)
|
||||
.annotationSubtitles(.hidden)
|
||||
}
|
||||
} else if geometryType == "LineString" {
|
||||
if let overlay = styledFeature.createOverlay() as? MKPolyline {
|
||||
MapPolyline(overlay)
|
||||
.stroke(styledFeature.strokeColor, style: styledFeature.strokeStyle)
|
||||
}
|
||||
} else if geometryType == "Polygon" {
|
||||
if let overlay = styledFeature.createOverlay() as? MKPolygon {
|
||||
MapPolygon(overlay)
|
||||
.foregroundStyle(styledFeature.fillColor)
|
||||
.stroke(styledFeature.strokeColor, style: styledFeature.strokeStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MapContentBuilder
|
||||
var body: some MapContent {
|
||||
meshMap
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
import OSLog
|
||||
|
||||
struct MapSettingsForm: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
|
@ -16,6 +17,8 @@ struct MapSettingsForm: View {
|
|||
@AppStorage("enableMapConvexHull") private var convexHull = false
|
||||
@AppStorage("enableMapWaypoints") private var enableMapWaypoints = true
|
||||
@AppStorage("enableMapShowFavorites") private var enableMapShowFavorites = false
|
||||
@AppStorage("mapOverlaysEnabled") private var mapOverlaysEnabled = false
|
||||
@State private var uploadedFiles: [MapDataMetadata] = []
|
||||
@Binding var traffic: Bool
|
||||
@Binding var pointsOfInterest: Bool
|
||||
@Binding var mapLayer: MapLayer
|
||||
|
|
@ -120,10 +123,7 @@ struct MapSettingsForm: View {
|
|||
let hasUserData = GeoJSONOverlayManager.shared.hasUserData()
|
||||
|
||||
// Master toggle for map overlays
|
||||
Toggle(isOn: Binding(
|
||||
get: { hasUserData && UserDefaults.standard.bool(forKey: "mapOverlaysEnabled") },
|
||||
set: { UserDefaults.standard.set($0, forKey: "mapOverlaysEnabled") }
|
||||
)) {
|
||||
Toggle(isOn: $mapOverlaysEnabled) {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Map Overlays")
|
||||
|
|
@ -138,19 +138,91 @@ struct MapSettingsForm: View {
|
|||
}
|
||||
.tint(.accentColor)
|
||||
.disabled(!hasUserData)
|
||||
.onChange(of: mapOverlaysEnabled) { oldValue, newValue in
|
||||
Logger.services.info("🔧 MapSettingsForm: Master overlay toggle changed from \(oldValue) to \(newValue)")
|
||||
}
|
||||
|
||||
// Show data source info or upload prompt
|
||||
if hasUserData && UserDefaults.standard.bool(forKey: "mapOverlaysEnabled") {
|
||||
HStack {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: NSLocalizedString("Using %@ data", comment: "Shows which data source is being used"), GeoJSONOverlayManager.shared.getActiveDataSource()))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
// Show individual file toggles when overlays are enabled
|
||||
if mapOverlaysEnabled && hasUserData {
|
||||
if !uploadedFiles.isEmpty {
|
||||
// Data source info
|
||||
HStack {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: NSLocalizedString("Using %@ data", comment: "Shows which data source is being used"), GeoJSONOverlayManager.shared.getActiveDataSource()))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 35)
|
||||
|
||||
// Individual file toggles
|
||||
ForEach(uploadedFiles) { file in
|
||||
Toggle(isOn: Binding(
|
||||
get: {
|
||||
Logger.services.debug("🔧 MapSettingsForm: File '\(file.originalName)' toggle getter - current state: \(file.isActive)")
|
||||
return file.isActive
|
||||
},
|
||||
set: { newValue in
|
||||
Logger.services.info("🔧 MapSettingsForm: File '\(file.originalName)' toggle setter - changing to: \(newValue)")
|
||||
GeoJSONOverlayManager.shared.toggleFileActive(file.id)
|
||||
// Update local state
|
||||
uploadedFiles = GeoJSONOverlayManager.shared.getUploadedFilesWithState()
|
||||
Logger.services.info("🔧 MapSettingsForm: Updated local uploadedFiles state after toggle")
|
||||
}
|
||||
)) {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text(file.originalName)
|
||||
.font(.subheadline)
|
||||
HStack {
|
||||
Text("\(file.overlayCount) features")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .file))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: file.isActive ? "doc.fill" : "doc")
|
||||
.foregroundColor(file.isActive ? .accentColor : .secondary)
|
||||
}
|
||||
}
|
||||
.tint(.accentColor)
|
||||
.padding(.leading, 35)
|
||||
}
|
||||
|
||||
// Manage data link
|
||||
NavigationLink(destination: MapDataUpload()) {
|
||||
HStack {
|
||||
Image(systemName: "folder")
|
||||
.foregroundColor(.accentColor)
|
||||
Text(NSLocalizedString("Manage map data", comment: "Link to manage uploaded map data"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 35)
|
||||
} else {
|
||||
// No files uploaded
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.orange)
|
||||
Text(NSLocalizedString("No map data files uploaded", comment: "Message when no files are uploaded"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 35)
|
||||
}
|
||||
.padding(.leading, 35)
|
||||
} else if !hasUserData {
|
||||
// Upload prompt when no data available
|
||||
NavigationLink(destination: MapDataUpload()) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.doc")
|
||||
|
|
@ -186,6 +258,10 @@ Spacer()
|
|||
.presentationContentInteraction(.scrolls)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
|
||||
.onAppear {
|
||||
// Load files on appear
|
||||
uploadedFiles = GeoJSONOverlayManager.shared.getUploadedFilesWithState()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue