Meshtastic-Apple/Meshtastic/Helpers/GeoJSONOverlayConfig.swift
2026-04-18 09:28:33 -07:00

350 lines
8.8 KiB
Swift

import Foundation
import MapKit
import SwiftUI
import CoreLocation
import OSLog
// MARK: - Raw GeoJSON Support Only
struct GeoJSONFeatureCollection: Codable {
let type: String // Always "FeatureCollection"
let features: [GeoJSONFeature]
}
struct GeoJSONFeature: Codable {
let type: String // Always "Feature"
let id: Int?
let geometry: GeoJSONGeometry
let properties: [String: AnyCodableValue]?
// MARK: - GeoJSON Styling Properties
/// Extract feature name from properties, defaulting to empty string
var name: String {
// Check for "NAME" first (uppercase), then "name" (lowercase)
if case .string(let value) = properties?["NAME"] {
return value
}
if case .string(let value) = properties?["name"] {
return value
}
return ""
}
/// 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 4.0
case "medium": return 8.0
case "large": return 12.0
default: return 4.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
/// MKOverlay pre-computed once at init avoids repeated JSONSerialization + MKGeoJSONDecoder
/// calls on every map render pass.
let precomputedOverlay: MKOverlay?
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": [
"type": feature.geometry.type,
"coordinates": feature.geometry.coordinates.toAnyObject()
],
"properties": feature.properties?.mapValues { $0.toAnyObject() } ?? [:]
]
do {
let geojsonData = try JSONSerialization.data(withJSONObject: featureDict)
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 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
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 {
let type: String // "Point", "LineString", "Polygon", etc.
let coordinates: AnyCodableValue // Flexible coordinate structure
}
// MARK: - Flexible JSON Value Type
enum AnyCodableValue: Codable {
case string(String)
case int(Int)
case double(Double)
case bool(Bool)
case array([AnyCodableValue])
case object([String: AnyCodableValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Int.self) {
self = .int(value)
} else if let value = try? container.decode(Double.self) {
self = .double(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([AnyCodableValue].self) {
self = .array(value)
} else if let value = try? container.decode([String: AnyCodableValue].self) {
self = .object(value)
} else {
throw DecodingError.typeMismatch(AnyCodableValue.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to decode AnyCodableValue"))
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .null:
try container.encodeNil()
case .bool(let value):
try container.encode(value)
case .int(let value):
try container.encode(value)
case .double(let value):
try container.encode(value)
case .string(let value):
try container.encode(value)
case .array(let value):
try container.encode(value)
case .object(let value):
try container.encode(value)
}
}
// Helper to convert coordinates to the format expected by MKGeoJSONDecoder
func toAnyObject() -> Any {
switch self {
case .null:
return NSNull()
case .bool(let value):
return value
case .int(let value):
return value
case .double(let value):
return value
case .string(let value):
return value
case .array(let values):
return values.map { $0.toAnyObject() }
case .object(let dict):
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
}
}