This commit is contained in:
Jacob Powers 2025-07-22 03:23:43 +00:00
parent 1e95694c7b
commit ee43a3f4dc
5 changed files with 213 additions and 274 deletions

View file

@ -59,7 +59,6 @@
3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; };
3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; };
3D3417CB2E29D3B0006A988B /* Color+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C92E29D3B0006A988B /* Color+Hex.swift */; };
3D3417CC2E29D3B0006A988B /* Data+Gzip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417CA2E29D3B0006A988B /* Data+Gzip.swift */; };
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; };
3D3417D42E2DC293006A988B /* MapDataUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataUpload.swift */; };
6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; };
@ -335,7 +334,6 @@
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = "<group>"; };
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = "<group>"; };
3D3417C92E29D3B0006A988B /* Color+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.swift"; sourceTree = "<group>"; };
3D3417CA2E29D3B0006A988B /* Data+Gzip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Gzip.swift"; sourceTree = "<group>"; };
3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = "<group>"; };
3D3417D32E2DC293006A988B /* MapDataUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataUpload.swift; sourceTree = "<group>"; };
6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = "<group>"; };
@ -1183,7 +1181,6 @@
isa = PBXGroup;
children = (
3D3417C92E29D3B0006A988B /* Color+Hex.swift */,
3D3417CA2E29D3B0006A988B /* Data+Gzip.swift */,
DD007BB12AA59B9A00F5FA12 /* CoreData */,
DDFFA7462B3A7F3C004730DB /* Bundle.swift */,
DDDB444529F8A96500EE2349 /* Character.swift */,
@ -1595,7 +1592,6 @@
DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */,
DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */,
3D3417CB2E29D3B0006A988B /* Color+Hex.swift in Sources */,
3D3417CC2E29D3B0006A988B /* Data+Gzip.swift in Sources */,
DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */,
BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */,
DDB75A212A12B954006ED576 /* LoRaSignalStrength.swift in Sources */,

View file

@ -1,63 +0,0 @@
import Foundation
import Compression
extension Data {
/// Decompresses raw deflate data
func zlibDecompressed() throws -> Data {
guard self.count > 0 else { return Data() }
// Try Foundation's zlib first
do {
let decompressedData = try (self as NSData).decompressed(using: .zlib) as Data
print("Data+Zlib: Successfully decompressed with Foundation \(count) bytes to \(decompressedData.count) bytes")
return decompressedData
} catch {
print("Data+Zlib: Foundation decompression failed: \(error), trying raw deflate...")
}
// Fallback to Compression framework with raw deflate
let bufferSize = count * 10
let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer { destination.deallocate() }
return try self.withUnsafeBytes { bytes in
let source = bytes.bindMemory(to: UInt8.self)
let result = compression_decode_buffer(
destination, bufferSize,
source.baseAddress!, count,
nil, COMPRESSION_ZLIB
)
guard result > 0 else {
print("Data+Zlib: Raw deflate decompression also failed, result size: \(result)")
throw ZlibError.decompression
}
print("Data+Zlib: Successfully decompressed with raw deflate \(count) bytes to \(result) bytes")
return Data(bytes: destination, count: result)
}
}
}
enum ZlibError: Error {
case decompression
var localizedDescription: String {
switch self {
case .decompression:
return "Failed to decompress data"
}
}
}
enum GzipError: Error {
case decompression
var localizedDescription: String {
switch self {
case .decompression:
return "Failed to decompress gzip data"
}
}
}

View file

@ -115,7 +115,7 @@ class MapDataManager {
}
// Check file extension
let allowedExtensions = ["json", "geojson", "kml", "kmz", "gz", "zlib"]
let allowedExtensions = ["json", "geojson"]
let fileExtension = url.pathExtension.lowercased()
guard allowedExtensions.contains(fileExtension) else {
throw MapDataError.unsupportedFormat
@ -133,15 +133,22 @@ class MapDataManager {
Task.detached {
do {
let data = try Data(contentsOf: url)
let processedData = try self.processData(data, filename: url.lastPathComponent)
let overlayCount = try self.getOverlayCount(from: processedData)
continuation.resume(returning: (processedData, overlayCount))
let overlayCount = try self.getOverlayCount(from: data)
continuation.resume(returning: (data, overlayCount))
} catch {
continuation.resume(throwing: error)
}
}
}
// TODO: Add proper GeoJSON schema validation here
// - Validate required properties (type, features)
// - Validate geometry types and coordinates
// - Validate feature structure
// - Consider using JSONSchema validation
// - Ensure coordinates are within valid ranges (lat: -90 to 90, lon: -180 to 180)
// - Validate that feature properties follow expected patterns
// If this is the first file uploaded, make it active by default
let isFirstFile = uploadedFiles.isEmpty
@ -158,17 +165,6 @@ class MapDataManager {
)
}
/// Process data (decompress if needed)
private func processData(_ data: Data, filename: String) throws -> Data {
let fileExtension = filename.components(separatedBy: ".").last?.lowercased() ?? ""
switch fileExtension {
case "gz", "zlib":
return try data.zlibDecompressed()
default:
return data
}
}
/// Get overlay count from raw GeoJSON data
private func getOverlayCount(from data: Data) throws -> Int {
@ -180,6 +176,16 @@ class MapDataManager {
throw MapDataError.invalidContent
}
/// Load feature collection from a single file
private func loadFeatureCollectionFromFile(_ file: MapDataMetadata) throws -> GeoJSONFeatureCollection? {
guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(file.filename) else {
throw MapDataError.fileNotFound
}
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data)
}
// MARK: - Configuration Loading
/// Load combined feature collection from specific files
@ -248,8 +254,7 @@ class MapDataManager {
do {
let data = try Data(contentsOf: fileURL)
let processedData = try processData(data, filename: activeFile.filename)
let featureCollection = try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: processedData)
let featureCollection = try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data)
allFeatures.append(contentsOf: featureCollection.features)
} catch {
@ -291,7 +296,7 @@ class MapDataManager {
}
/// Delete uploaded file
func deleteFile(_ metadata: MapDataMetadata) throws {
func deleteFile(_ metadata: MapDataMetadata) async throws {
guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(metadata.filename) else {
Logger.services.error("🗑️ MapDataManager: Could not construct file URL for: \(metadata.filename, privacy: .public)")
@ -334,27 +339,6 @@ class MapDataManager {
}
/// Toggle file active status
func toggleFileActive(_ metadata: MapDataMetadata) throws {
if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) {
let newActiveState = !uploadedFiles[index].isActive
// If making this file active, deactivate all others (only one can be active)
if newActiveState {
for i in uploadedFiles.indices {
uploadedFiles[i].isActive = (i == index)
}
} else {
// Just deactivate this file
uploadedFiles[index].isActive = false
}
try saveMetadata()
// Clear cache to force reload
activeFeatureCollection = nil
}
}
// MARK: - Metadata Persistence

View file

@ -30,6 +30,7 @@ struct NodeMapSwiftUI: View {
@State var isShowingAltitude = false
@State var isEditingSettings = false
@State var isMeshMap = false
@State var enabledOverlayConfigs: Set<UUID> = Set()
@State private var mapRegion = MKCoordinateRegion.init()
@ -40,165 +41,191 @@ struct NodeMapSwiftUI: View {
private var waypoints: FetchedResults<WaypointEntity>
var body: some View {
var mostRecent = node.positions?.lastObject as? PositionEntity
if node.hasPositions {
mapWithNavigation
} else {
ContentUnavailableView("No Positions", systemImage: "mappin.slash")
}
}
private var mapWithNavigation: some View {
ZStack {
MapReader { _ in
configuredMap
}
}
.navigationBarTitle(String((node.user?.shortName ?? "Unknown".localized) + (" \(node.positions?.count ?? 0) points")), displayMode: .inline)
.navigationBarItems(trailing:
ZStack {
MapReader { _ in
Map(position: $position, bounds: MapCameraBounds(minimumDistance: 0, maximumDistance: .infinity), scope: mapScope) {
NodeMapContent(node: node)
}
.mapScope(mapScope)
.mapStyle(mapStyle)
.mapControls {
MapScaleView(scope: mapScope)
.mapControlVisibility(.visible)
if showUserLocation {
MapUserLocationButton(scope: mapScope)
.mapControlVisibility(.visible)
}
MapPitchToggle(scope: mapScope)
.mapControlVisibility(.visible)
MapCompass(scope: mapScope)
.mapControlVisibility(.visible)
}
.controlSize(.regular)
.overlay(alignment: .bottom) {
if scene != nil && isLookingAround {
LookAroundPreview(initialScene: scene)
.frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 20)
}
}
.overlay(alignment: .bottom) {
if !isLookingAround && isShowingAltitude {
PositionAltitudeChart(node: node)
.frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 20)
}
}
.sheet(isPresented: $isEditingSettings) {
MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap)
.onChange(of: (selectedMapLayer)) { _, newMapLayer in
switch selectedMapLayer {
case .standard:
UserDefaults.mapLayer = newMapLayer
mapStyle = MapStyle.standard(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic)
case .hybrid:
UserDefaults.mapLayer = newMapLayer
mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic)
case .satellite:
UserDefaults.mapLayer = newMapLayer
mapStyle = MapStyle.imagery(elevation: .flat)
case .offline:
return
}
}
}
.onChange(of: node) {
isLookingAround = false
isShowingAltitude = false
mostRecent = node.positions?.lastObject as? PositionEntity
if node.positions?.count ?? 0 > 1 {
position = .automatic
} else {
position = .camera(MapCamera(centerCoordinate: mostRecent!.coordinate, distance: distance, heading: 0, pitch: 0))
}
if let mostRecent {
Task {
scene = try? await fetchScene(for: mostRecent.coordinate)
}
}
}
.onAppear {
UIApplication.shared.isIdleTimerDisabled = true
switch selectedMapLayer {
case .standard:
mapStyle = MapStyle.standard(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic)
case .hybrid:
mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic)
case .satellite:
mapStyle = MapStyle.imagery(elevation: .flat)
case .offline:
mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic)
}
mostRecent = node.positions?.lastObject as? PositionEntity
if node.positions?.count ?? 0 > 1 {
position = .automatic
} else {
if let mrCoord = mostRecent?.coordinate {
position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0))
}
}
if self.scene == nil {
Task {
scene = try? await fetchScene(for: mostRecent!.coordinate)
}
}
}
.safeAreaInset(edge: .bottom, alignment: .trailing) {
HStack {
Button(action: {
withAnimation {
isEditingSettings = !isEditingSettings
}
}) {
Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
/// Look Around Button
if self.scene != nil {
Button(action: {
if isShowingAltitude {
isShowingAltitude = false
}
isLookingAround = !isLookingAround
}) {
Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
}
/// Altitude Button
if node.positions?.count ?? 0 > 1 {
Button(action: {
if isLookingAround {
isLookingAround = false
}
isShowingAltitude = !isShowingAltitude
}) {
Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
}
}
.controlSize(.regular)
.padding(5)
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
}}
.navigationBarTitle(String((node.user?.shortName ?? "Unknown".localized) + (" \(node.positions?.count ?? 0) points")), displayMode: .inline)
.navigationBarItems(trailing:
ZStack {
ConnectedDevice(
bluetoothOn: bleManager.isSwitchedOn,
deviceConnected: bleManager.connectedPeripheral != nil,
name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
})
} else {
ContentUnavailableView("No Positions", systemImage: "mappin.slash")
}
private var configuredMap: some View {
baseMap
.overlay(alignment: .bottom) {
lookAroundView
}
.overlay(alignment: .bottom) {
altitudeView
}
.sheet(isPresented: $isEditingSettings) {
MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs)
}
.onChange(of: selectedMapLayer) { _, newMapLayer in
updateMapStyle(for: newMapLayer)
}
.onChange(of: node) {
handleNodeChange()
}
.onAppear {
handleAppear()
}
.safeAreaInset(edge: .bottom, alignment: .trailing) {
controlButtons
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
}
private var baseMap: some View {
Map(position: $position, bounds: MapCameraBounds(minimumDistance: 0, maximumDistance: .infinity), scope: mapScope) {
NodeMapContent(node: node)
}
.mapScope(mapScope)
.mapStyle(mapStyle)
.mapControls {
MapScaleView(scope: mapScope)
.mapControlVisibility(.visible)
if showUserLocation {
MapUserLocationButton(scope: mapScope)
.mapControlVisibility(.visible)
}
MapPitchToggle(scope: mapScope)
.mapControlVisibility(.visible)
MapCompass(scope: mapScope)
.mapControlVisibility(.visible)
}
.controlSize(.regular)
}
private var lookAroundView: some View {
Group {
if scene != nil && isLookingAround {
LookAroundPreview(initialScene: scene)
.frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 20)
}
}
}
private var altitudeView: some View {
Group {
if !isLookingAround && isShowingAltitude {
PositionAltitudeChart(node: node)
.frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 20)
}
}
}
private var controlButtons: some View {
HStack {
Button(action: {
withAnimation {
isEditingSettings = !isEditingSettings
}
}) {
Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
if scene != nil {
Button(action: {
if isShowingAltitude {
isShowingAltitude = false
}
isLookingAround = !isLookingAround
}) {
Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
}
if node.positions?.count ?? 0 > 1 {
Button(action: {
if isLookingAround {
isLookingAround = false
}
isShowingAltitude = !isShowingAltitude
}) {
Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2")
.padding(.vertical, 5)
}
.tint(Color(UIColor.secondarySystemBackground))
.foregroundColor(.accentColor)
.buttonStyle(.borderedProminent)
}
}
.controlSize(.regular)
.padding(5)
}
private func updateMapStyle(for layer: MapLayer) {
UserDefaults.mapLayer = layer
switch layer {
case .standard:
mapStyle = MapStyle.standard(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic)
case .hybrid:
mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic)
case .satellite:
mapStyle = MapStyle.imagery(elevation: .flat)
case .offline:
break
}
}
private func handleNodeChange() {
isLookingAround = false
isShowingAltitude = false
let newMostRecent = node.positions?.lastObject as? PositionEntity
if node.positions?.count ?? 0 > 1 {
position = .automatic
} else if let mrCoord = newMostRecent?.coordinate {
position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0))
}
if let newMostRecent {
Task {
scene = try? await fetchScene(for: newMostRecent.coordinate)
}
}
}
private func handleAppear() {
UIApplication.shared.isIdleTimerDisabled = true
updateMapStyle(for: selectedMapLayer)
let mostRecent = node.positions?.lastObject as? PositionEntity
if node.positions?.count ?? 0 > 1 {
position = .automatic
} else if let mrCoord = mostRecent?.coordinate {
position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0))
}
if scene == nil, let mrCoord = mostRecent?.coordinate {
Task {
scene = try? await fetchScene(for: mrCoord)
}
}
}
/// Get the look around scene

View file

@ -106,11 +106,7 @@ struct MapDataUpload: View {
isPresented: $isShowingFilePicker,
allowedContentTypes: [
UTType.json,
UTType(filenameExtension: "geojson") ?? UTType.json,
UTType(filenameExtension: "kml") ?? UTType.xml,
UTType(filenameExtension: "kmz") ?? UTType.zip,
UTType(filenameExtension: "gz") ?? UTType.data,
UTType(filenameExtension: "zlib") ?? UTType.data
UTType(filenameExtension: "geojson") ?? UTType.json
],
allowsMultipleSelection: false
) { result in
@ -183,20 +179,19 @@ struct MapDataUpload: View {
}
private func toggleFileActive(_ file: MapDataMetadata) {
do {
try mapDataManager.toggleFileActive(file)
} catch {
errorMessage = "Failed to toggle file: \(error.localizedDescription)"
showError = true
}
mapDataManager.toggleFileActive(file.id)
}
private func deleteFile(_ file: MapDataMetadata) {
do {
try mapDataManager.deleteFile(file)
} catch {
errorMessage = "Failed to delete file: \(error.localizedDescription)"
showError = true
Task {
do {
try await mapDataManager.deleteFile(file)
} catch {
await MainActor.run {
errorMessage = "Failed to delete file: \(error.localizedDescription)"
showError = true
}
}
}
}
}