Merge pull request #1318 from meshtastic/burning_man

Map Overlays - UI to add and manage geojson based map overlays
This commit is contained in:
Garth Vander Houwen 2025-07-27 11:56:37 -07:00 committed by GitHub
commit de84fef98c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2492 additions and 228 deletions

View file

@ -1346,6 +1346,9 @@
}
}
}
},
"%lld features" : {
},
"%lld or less hops away" : {
"localizations" : {
@ -1381,6 +1384,40 @@
}
}
},
"%lld overlays" : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld sovrapposizioni"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lldオーバーレイ"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld slojeva"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld覆盖层"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld覆蓋層"
}
}
}
},
"%lld Readings Total" : {
"localizations" : {
"it" : {
@ -1545,6 +1582,9 @@
}
}
},
"•" : {
"shouldTranslate" : false
},
"• %@" : {
"shouldTranslate" : false
},
@ -14398,6 +14438,9 @@
}
}
},
"Files Available" : {
"comment" : "Data source label when files exist but none are active"
},
"Find a contact" : {
"localizations" : {
"de" : {
@ -20177,6 +20220,44 @@
}
}
},
"Manage custom map overlays" : {
"comment" : "Subtitle for map data management",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Gestisci sovrapposizioni mappa personalizzate"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "カスタムマップオーバーレイを管理"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Upravljaj prilagođenim slojevima mape"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "管理自定义地图覆盖层"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "管理自訂地圖覆蓋層"
}
}
}
},
"Manage map data" : {
"comment" : "Link to manage uploaded map data"
},
"Managed Device" : {
"localizations" : {
"it" : {
@ -20275,6 +20356,40 @@
}
}
},
"Map Data" : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dati Mappa"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マップデータ"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Podaci Mape"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "地图数据"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "地圖資料"
}
}
}
},
"Map Options" : {
"localizations" : {
"de" : {
@ -20315,6 +20430,70 @@
}
}
},
"Map Overlays" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Karten-Overlays"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Superpositions de cartes"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "שכבות מפה"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sovrapposizioni mappa"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マップオーバーレイ"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nakładki map"
}
},
"se" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kartöverlägg"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Преклапања мапе"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "地图覆盖层"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "地圖覆蓋層"
}
}
}
},
"Map Publish Interval" : {
"localizations" : {
"it" : {
@ -22765,6 +22944,41 @@
}
}
},
"No Data" : {
"comment" : "Data source label when no files are available",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nessun Dato"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "データなし"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bez Podataka"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "无数据"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "無資料"
}
}
}
},
"No device connected" : {
"localizations" : {
"de" : {
@ -22891,6 +23105,41 @@
}
}
},
"No files uploaded yet" : {
"comment" : "Empty state text when no files are uploaded",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nessun file caricato ancora"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "まだファイルがアップロードされていません"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Još uvek nema otpremljenih datoteka"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "尚未上传文件"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "尚未上傳檔案"
}
}
}
},
"No Interface" : {
"localizations" : {
"de" : {
@ -22955,6 +23204,9 @@
}
}
},
"No map data files uploaded" : {
"comment" : "Message when no files are uploaded"
},
"No PAX Counter Logs" : {
"localizations" : {
"it" : {
@ -27283,6 +27535,40 @@
}
}
},
"Processing file..." : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Elaborazione file..."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ファイル処理中..."
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Obrada datoteke..."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在处理文件..."
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在處理檔案..."
}
}
}
},
"Project information" : {
"localizations" : {
"it" : {
@ -31297,6 +31583,41 @@
}
}
},
"Select Map Data File" : {
"comment" : "Button text for selecting map data file",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Seleziona File Dati Mappa"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マップデータファイルを選択"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Izaberi Datoteku Podataka Mape"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选择地图数据文件"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "選擇地圖資料檔案"
}
}
}
},
"Select Node" : {
"localizations" : {
"de" : {
@ -39312,6 +39633,213 @@
}
}
},
"Upload Error" : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Errore Caricamento"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "アップロードエラー"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Greška Otpremanja"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传错误"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳錯誤"
}
}
}
},
"Upload GeoJSON files to display custom map overlays. Files are stored locally and can be up to 10MB." : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Carica file GeoJSON per visualizzare sovrapposizioni mappa personalizzate. I file sono memorizzati localmente e possono essere fino a 10MB."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "カスタムマップオーバーレイを表示するためにGeoJSONファイルをアップロードしてください。ファイルはローカルに保存され、最大10MBまでです。"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremi GeoJSON datoteke da prikažeš prilagođene slojeve mape. Datoteke se čuvaju lokalno i mogu biti do 10MB."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传GeoJSON文件以显示自定义地图覆盖层。文件本地存储最大10MB。"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳GeoJSON檔案以顯示自訂地圖覆蓋層。檔案本機儲存最大10MB。"
}
}
}
},
"Upload Map Data" : {
"comment" : "Title for map data upload screen",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Carica Dati Mappa"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マップデータをアップロード"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremi Podatke Mape"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传地图数据"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳地圖資料"
}
}
}
},
"Upload map data to enable overlays" : {
"comment" : "Prompt to upload map data when none is available",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Carica dati mappa per abilitare sovrapposizioni"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "オーバーレイを有効にするにはマップデータをアップロード"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremi podatke mape da omogućiš slojeve"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传地图数据以启用覆盖层"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳地圖資料以啟用覆蓋層"
}
}
}
},
"Upload Success" : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Caricamento Riuscito"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "アップロード成功"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Uspešno Otpremanje"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传成功"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳成功"
}
}
}
},
"Uploaded Files" : {
"comment" : "Section header for uploaded files",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "File Caricati"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "アップロードされたファイル"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremljene Datoteke"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已上传文件"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "已上傳檔案"
}
}
}
},
"Uptime" : {
"localizations" : {
"it" : {
@ -39746,6 +40274,41 @@
}
}
},
"User Uploaded" : {
"comment" : "Data source label for user uploaded files",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Caricato dall'Utente"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ユーザーがアップロード"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremio Korisnik"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "用户上传"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用者上傳"
}
}
}
},
"Username" : {
"localizations" : {
"de" : {
@ -39838,6 +40401,41 @@
}
}
},
"Using %@ data" : {
"comment" : "Shows which data source is being used",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Utilizzo dati %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@データを使用"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Koristi %@ podatke"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用%@数据"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用%@資料"
}
}
}
},
"Utilizes the network connection on your phone to connect to MQTT." : {
"localizations" : {
"it" : {

View file

@ -56,6 +56,11 @@
25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; };
25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; };
25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; };
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 */; };
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; };
3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataFiles.swift */; };
6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; };
6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */; };
6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; };
@ -326,6 +331,11 @@
25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = "<group>"; };
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>"; };
3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = "<group>"; };
3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = "<group>"; };
6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = "<group>"; };
6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = "<group>"; };
6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = "<group>"; };
@ -788,6 +798,7 @@
DD4A911C2708C57100501B7E /* Settings */ = {
isa = PBXGroup;
children = (
3D3417D32E2DC293006A988B /* MapDataManager.swift */,
DDD5BB0E2C285F92007E03CA /* Logs */,
DD93800C2BA74CE3008BEC06 /* Channels */,
DD61937A2863876A00E59241 /* Config */,
@ -1100,6 +1111,8 @@
DDC2E1A526CEB32B0042C5E4 /* Helpers */ = {
isa = PBXGroup;
children = (
3D3417D12E2DC260006A988B /* MapDataManager.swift */,
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */,
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
DDD43FE12A78C86B0083A3E9 /* Mqtt */,
DDAF8C5226EB1DF10058C060 /* BLEManager.swift */,
@ -1109,6 +1122,7 @@
DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */,
DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */,
6D825E612C34786C008DBEE4 /* CommonRegex.swift */,
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */,
);
path = Helpers;
sourceTree = "<group>";
@ -1167,6 +1181,7 @@
DDDB443E29F79A9400EE2349 /* Extensions */ = {
isa = PBXGroup;
children = (
3D3417C92E29D3B0006A988B /* Color+Hex.swift */,
DD007BB12AA59B9A00F5FA12 /* CoreData */,
DDFFA7462B3A7F3C004730DB /* Bundle.swift */,
DDDB444529F8A96500EE2349 /* Character.swift */,
@ -1439,6 +1454,7 @@
DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */,
DDDB444C29F8AAA600EE2349 /* Color.swift in Sources */,
DDDFE73F2D0D48FF0044463C /* IgnoreNodeButton.swift in Sources */,
3D3417D42E2DC293006A988B /* MapDataManager.swift in Sources */,
DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */,
DDB75A0F2A05920E006ED576 /* FileManager.swift in Sources */,
DD3D17E02C3FB67200561584 /* LocalWeatherConditions.swift in Sources */,
@ -1477,6 +1493,8 @@
DDDB445429F8AD1600EE2349 /* Data.swift in Sources */,
DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */,
DDE5B4042B2279A700FCDD05 /* TraceRouteLog.swift in Sources */,
3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */,
3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */,
237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */,
DD6193792863875F00E59241 /* SerialConfig.swift in Sources */,
DDDB263F2AABEE20003AFCB7 /* NodeList.swift in Sources */,
@ -1574,6 +1592,7 @@
DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */,
DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */,
DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */,
3D3417CB2E29D3B0006A988B /* Color+Hex.swift in Sources */,
DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */,
BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */,
DDB75A212A12B954006ED576 /* LoRaSignalStrength.swift in Sources */,
@ -1582,6 +1601,7 @@
251926922C3CB52300249DF5 /* DeleteNodeButton.swift in Sources */,
DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */,
DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */,
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */,
D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */,
DDF82CBD2D5BC69200DC25EC /* NavigateToButton.swift in Sources */,
8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */,

View file

@ -0,0 +1,69 @@
{
"originHash" : "2569905853aec088d5bac6b540eac77f78963f88b406e8dd95a88c40623cc8b4",
"pins" : [
{
"identity" : "cocoamqtt",
"kind" : "remoteSourceControl",
"location" : "https://github.com/emqx/CocoaMQTT",
"state" : {
"revision" : "22b98acc75bdca77917a1093bd3e1b45ef6e9718",
"version" : "2.1.9"
}
},
{
"identity" : "dd-sdk-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/DataDog/dd-sdk-ios.git",
"state" : {
"revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54",
"version" : "2.29.0"
}
},
{
"identity" : "mqttcocoaasyncsocket",
"kind" : "remoteSourceControl",
"location" : "https://github.com/leeway1208/MqttCocoaAsyncSocket",
"state" : {
"revision" : "ce3e18607fd01079495f86ff6195d8a3ca469f73",
"version" : "1.0.8"
}
},
{
"identity" : "opentelemetry-swift-packages",
"kind" : "remoteSourceControl",
"location" : "https://github.com/DataDog/opentelemetry-swift-packages.git",
"state" : {
"revision" : "4a7295600d4ebb9525a23c11586c5fdb74ae8b7e",
"version" : "1.13.1"
}
},
{
"identity" : "plcrashreporter",
"kind" : "remoteSourceControl",
"location" : "https://github.com/microsoft/plcrashreporter.git",
"state" : {
"revision" : "8c61e5e38e9f737dd68512ed1ea5ab081244ad65",
"version" : "1.12.0"
}
},
{
"identity" : "starscream",
"kind" : "remoteSourceControl",
"location" : "https://github.com/daltoniam/Starscream.git",
"state" : {
"revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a",
"version" : "4.0.8"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "102a647b573f60f73afdce5613a51d71349fe507",
"version" : "1.30.0"
}
}
],
"version" : 3
}

View file

@ -0,0 +1,30 @@
import SwiftUI
extension Color {
/// Initialize a Color from a hex string (e.g., "#FF0000" or "FF0000")
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View file

@ -19,11 +19,11 @@ struct UserDefault<T: Decodable> {
var wrappedValue: T {
get {
if defaultValue as? any RawRepresentable != nil {
if defaultValue is any RawRepresentable {
let storedValue = UserDefaults.standard.object(forKey: key.rawValue)
guard let storedValue,
let jsonString = (storedValue as? String != nil) ? "\"\(storedValue)\"" : "\(storedValue)",
let jsonString = (storedValue is String) ? "\"\(storedValue)\"" : "\(storedValue)",
let data = jsonString.data(using: .utf8),
let value = (try? JSONDecoder().decode(T.self, from: data)) else { return defaultValue }

View file

@ -0,0 +1,343 @@
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
/// 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
}
}
} 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 {
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
}
}

View file

@ -0,0 +1,148 @@
import SwiftUI
import MapKit
import OSLog
/// Manager for loading and managing raw GeoJSON feature collections with embedded styling
class GeoJSONOverlayManager {
static let shared = GeoJSONOverlayManager()
private init() {}
private var featureCollection: GeoJSONFeatureCollection?
/// Load raw GeoJSON feature collection from user uploads
func loadFeatureCollection() -> GeoJSONFeatureCollection? {
if let cached = featureCollection {
return cached
}
// Load user-uploaded feature collection
if let userFeatures = MapDataManager.shared.loadFeatureCollection() {
featureCollection = userFeatures
return userFeatures
}
return nil
}
/// Load styled features for specific enabled configs
func loadStyledFeaturesForConfigs(_ enabledConfigs: Set<UUID>) -> [GeoJSONStyledFeature] {
// Get files that match the enabled configs
let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) }
guard !enabledFiles.isEmpty else {
return []
}
// Load feature collection from enabled files only
guard let collection = MapDataManager.shared.loadFeatureCollectionForFiles(enabledFiles) else {
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(
feature: feature,
overlayId: layerId
)
styledFeatures.append(styledFeature)
}
return styledFeatures
}
/// Load styled features for direct rendering (legacy method)
func loadStyledFeatures() -> [GeoJSONStyledFeature] {
guard let collection = loadFeatureCollection() else {
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(
feature: feature,
overlayId: layerId
)
styledFeatures.append(styledFeature)
}
return styledFeatures
}
/// 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 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()
}
/// Clear cached data (useful for testing or memory management)
func clearCache() {
featureCollection = nil
}
/// 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 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) {
MapDataManager.shared.toggleFileActive(fileId)
// Clear cache to force reload with new file states
clearCache()
}
}

View file

@ -0,0 +1,463 @@
import Foundation
import MapKit
import OSLog
import Combine
/// Manager for handling user-uploaded map data files
class MapDataManager: ObservableObject {
static let shared = MapDataManager()
private init() {}
// MARK: - Constants
private let maxFileSize: Int64 = 10 * 1024 * 1024 // 10MB
private let mapDataDirectory = "MapData"
private let userUploadedDirectory = "user_uploaded"
private let metadataFileName = "upload_history.json"
// MARK: - Properties
@Published private var uploadedFiles: [MapDataMetadata] = []
private var activeFeatureCollection: GeoJSONFeatureCollection?
// MARK: - File Management
/// Get the base URL for map data storage
private func getMapDataDirectory() -> URL? {
guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
Logger.services.error("🗂️ Could not access documents directory")
return nil
}
return documentsURL.appendingPathComponent(mapDataDirectory)
}
/// Get the URL for user uploaded files
private func getUserUploadedDirectory() -> URL? {
guard let baseURL = getMapDataDirectory() else { return nil }
return baseURL.appendingPathComponent(userUploadedDirectory)
}
/// Get the URL for metadata file
private func getMetadataFileURL() -> URL? {
guard let baseURL = getMapDataDirectory() else { return nil }
return baseURL.appendingPathComponent(metadataFileName)
}
/// Create necessary directories
private func createDirectoriesIfNeeded() -> Bool {
guard let userDir = getUserUploadedDirectory() else { return false }
do {
try FileManager.default.createDirectory(at: userDir, withIntermediateDirectories: true)
return true
} catch {
Logger.services.error("🗂️ Failed to create directories: \(error.localizedDescription, privacy: .public)")
return false
}
}
// MARK: - File Upload & Processing
/// Process and store an uploaded file
func processUploadedFile(from sourceURL: URL) async throws -> MapDataMetadata {
// 1. Start accessing security-scoped resource
let isAccessing = sourceURL.startAccessingSecurityScopedResource()
defer {
if isAccessing {
sourceURL.stopAccessingSecurityScopedResource()
}
}
// 2. Validate file
try validateFile(at: sourceURL)
// 2. Create directories if needed
guard createDirectoriesIfNeeded() else {
throw MapDataError.directoryCreationFailed
}
// 3. Generate destination filename
let timestamp = Date().timeIntervalSince1970
let originalName = sourceURL.deletingPathExtension().lastPathComponent
let fileExtension = sourceURL.pathExtension
let newFilename = "\(originalName)_\(Int(timestamp)).\(fileExtension)"
guard let destURL = getUserUploadedDirectory()?.appendingPathComponent(newFilename) else {
throw MapDataError.invalidDestination
}
// 4. Copy file to app storage
try FileManager.default.copyItem(at: sourceURL, to: destURL)
// 5. Process and validate content
let metadata = try await processFileContent(at: destURL, originalName: originalName)
// 6. Save metadata and update UI on main thread
await MainActor.run {
uploadedFiles.append(metadata)
// Clear cached configuration to force reload
activeFeatureCollection = nil
}
try saveMetadata()
return metadata
}
/// Validate uploaded file
private func validateFile(at url: URL) throws {
let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
// Check file size
guard let fileSize = fileAttributes.fileSize, fileSize <= maxFileSize else {
throw MapDataError.fileTooLarge
}
// Check if it's a regular file
guard fileAttributes.isRegularFile == true else {
throw MapDataError.invalidFileType
}
// Check file extension
let allowedExtensions = ["json", "geojson"]
let fileExtension = url.pathExtension.lowercased()
guard allowedExtensions.contains(fileExtension) else {
throw MapDataError.unsupportedFormat
}
}
/// Process file content and extract metadata
private func processFileContent(at url: URL, originalName: String) async throws -> MapDataMetadata {
let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .creationDateKey])
let fileSize = fileAttributes.fileSize ?? 0
let uploadDate = fileAttributes.creationDate ?? Date()
// Read and process file content on background queue
let (_, overlayCount) = try await withCheckedThrowingContinuation { continuation in
Task.detached {
do {
let data = try Data(contentsOf: url)
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
return MapDataMetadata(
filename: url.lastPathComponent,
originalName: originalName,
uploadDate: uploadDate,
fileSize: Int64(fileSize),
format: url.pathExtension.lowercased(),
license: nil, // Will be extracted from content if available
attribution: nil, // Will be extracted from content if available
overlayCount: overlayCount,
isActive: isFirstFile
)
}
/// Get overlay count from raw GeoJSON data
private func getOverlayCount(from data: Data) throws -> Int {
// 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
}
/// 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
func loadFeatureCollectionForFiles(_ files: [MapDataMetadata]) -> GeoJSONFeatureCollection? {
guard !files.isEmpty else {
return nil
}
var allFeatures: [GeoJSONFeature] = []
for file in files {
do {
if let featureCollection = try loadFeatureCollectionFromFile(file) {
allFeatures.append(contentsOf: featureCollection.features)
}
} catch {
Logger.services.error("📁 MapDataManager: Failed to load feature collection from \(file.filename, privacy: .public): \(error.localizedDescription, privacy: .public)")
continue
}
}
guard !allFeatures.isEmpty else {
return nil
}
return GeoJSONFeatureCollection(type: "FeatureCollection", features: allFeatures)
}
/// Load and combine raw GeoJSON feature collections from all active files
func loadFeatureCollection() -> GeoJSONFeatureCollection? {
if let cached = activeFeatureCollection {
return cached
}
// Find active user files
let activeFiles = uploadedFiles.filter { $0.isActive }
guard !activeFiles.isEmpty else {
return nil
}
var allFeatures: [GeoJSONFeature] = []
// Load features from all active files
for activeFile in activeFiles {
guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(activeFile.filename) else {
Logger.services.error("📁 MapDataManager: Could not construct file URL for: \(activeFile.filename, privacy: .public)")
continue
}
// 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)")
// Remove the missing file from our metadata
if let index = uploadedFiles.firstIndex(where: { $0.filename == activeFile.filename }) {
uploadedFiles.remove(at: index)
do {
try saveMetadata()
} catch {
Logger.services.error("📁 MapDataManager: Failed to save cleaned metadata: \(error.localizedDescription, privacy: .public)")
}
}
continue
}
do {
let data = try Data(contentsOf: fileURL)
let featureCollection = try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data)
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
)
activeFeatureCollection = combinedCollection
return combinedCollection
}
// MARK: - File Management
/// Get all uploaded files
func getUploadedFiles() -> [MapDataMetadata] {
return uploadedFiles
}
/// Toggle the active state of an uploaded file
func toggleFileActive(_ fileId: UUID) {
if let index = uploadedFiles.firstIndex(where: { $0.id == fileId }) {
uploadedFiles[index].isActive.toggle()
// 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)")
}
}
}
/// Delete uploaded file
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)")
throw MapDataError.fileNotFound
}
// 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)
} catch {
Logger.services.error("🗑️ MapDataManager: Failed to remove file: \(error.localizedDescription, privacy: .public)")
throw error
}
// Update UI-related properties on main thread
await MainActor.run {
if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) {
uploadedFiles.remove(at: index)
} else {
Logger.services.warning("🗑️ MapDataManager: File not found in uploadedFiles array")
}
}
do {
try saveMetadata()
} catch {
Logger.services.error("🗑️ MapDataManager: Failed to save metadata: \(error.localizedDescription, privacy: .public)")
throw error
}
// Clear cache if this was the active file
await MainActor.run {
if activeFeatureCollection != nil {
activeFeatureCollection = nil
}
}
// Clear GeoJSON overlay manager cache
GeoJSONOverlayManager.shared.clearCache()
// Notify UI components that a file was deleted
await MainActor.run {
NotificationCenter.default.post(name: Foundation.Notification.Name.mapDataFileDeleted, object: metadata.id)
}
}
// MARK: - Metadata Persistence
/// Load metadata from disk
func loadMetadata() {
guard let metadataURL = getMetadataFileURL(),
let data = try? Data(contentsOf: metadataURL),
let files = try? JSONDecoder().decode([MapDataMetadata].self, from: data) else {
uploadedFiles = []
return
}
uploadedFiles = files
}
/// Save metadata to disk
private func saveMetadata() throws {
guard let metadataURL = getMetadataFileURL() else {
throw MapDataError.invalidDestination
}
let data = try JSONEncoder().encode(uploadedFiles)
try data.write(to: metadataURL)
}
// MARK: - Initialization
/// Initialize the manager
func initialize() {
loadMetadata()
}
}
// MARK: - Supporting Types
/// Metadata for uploaded map data files
struct MapDataMetadata: Codable, Identifiable {
let id: UUID
let filename: String
let originalName: String
let uploadDate: Date
let fileSize: Int64
let format: String
let license: String?
let attribution: String?
let overlayCount: Int
var isActive: Bool
init(filename: String, originalName: String, uploadDate: Date, fileSize: Int64, format: String, license: String?, attribution: String?, overlayCount: Int, isActive: Bool) {
self.id = UUID()
self.filename = filename
self.originalName = originalName
self.uploadDate = uploadDate
self.fileSize = fileSize
self.format = format
self.license = license
self.attribution = attribution
self.overlayCount = overlayCount
self.isActive = isActive
}
var fileSizeString: String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useKB, .useMB]
formatter.countStyle = .file
return formatter.string(fromByteCount: fileSize)
}
var uploadDateString: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: uploadDate)
}
}
/// Errors that can occur during map data operations
enum MapDataError: Error, LocalizedError {
case fileTooLarge
case invalidFileType
case unsupportedFormat
case invalidContent
case directoryCreationFailed
case invalidDestination
case fileNotFound
case saveFailed
var errorDescription: String? {
switch self {
case .fileTooLarge:
return "File is too large. Maximum size is 10MB."
case .invalidFileType:
return "Invalid file type. Please select a regular file."
case .unsupportedFormat:
return "Unsupported file format. Supported formats: JSON, GeoJSON, KML, KMZ, GZ, ZLIB."
case .invalidContent:
return "Invalid file content. Please check the file format."
case .directoryCreationFailed:
return "Failed to create storage directory."
case .invalidDestination:
return "Invalid destination path."
case .fileNotFound:
return "File not found."
case .saveFailed:
return "Failed to save file."
}
}
}
// MARK: - Notification Names
extension Foundation.Notification.Name {
static let mapDataFileDeleted = Foundation.Notification.Name("mapDataFileDeleted")
}

View file

@ -1027,7 +1027,7 @@ func textMessageAppPacket(
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
content: messageText!,
target: "messages",
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
messageId: newMessage.messageId,
channel: newMessage.channel,
userNum: Int64(packet.from),

View file

@ -18,6 +18,39 @@
<string>gvh.MeshtasticApple.mbtiles</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>GeoJSON Map Data</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.json</string>
<string>gvh.MeshtasticApple.geojson</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>KML Map Data</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.xml</string>
<string>gvh.MeshtasticApple.kml</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>KMZ Map Data</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.zip-archive</string>
<string>gvh.MeshtasticApple.kmz</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
@ -138,6 +171,63 @@
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.json</string>
</array>
<key>UTTypeDescription</key>
<string>GeoJSON Map Data</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>gvh.MeshtasticApple.geojson</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>geojson</string>
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeDescription</key>
<string>KML Map Data</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>gvh.MeshtasticApple.kml</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>kml</string>
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
</array>
<key>UTTypeDescription</key>
<string>KMZ Map Data</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>gvh.MeshtasticApple.kmz</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>kmz</string>
</array>
</dict>
</dict>
</array>
<key>com.apple.developer.carplay-communication</key>
<true/>

View file

@ -74,6 +74,9 @@ struct MeshtasticAppleApp: App {
self.persistenceController = persistenceController
// Wire up router
self.appDelegate.router = appState.router
// Initialize map data manager
MapDataManager.shared.initialize()
#if DEBUG
// Show tips in development
try? Tips.resetDatastore()

View file

@ -6,79 +6,79 @@ A view draws the indicator used in the upper right corner for views using BLE
import SwiftUI
struct ConnectedDevice: View {
var bluetoothOn: Bool
var deviceConnected: Bool
var name: String
var bluetoothOn: Bool
var deviceConnected: Bool
var name: String
var mqttProxyConnected: Bool = false
var mqttUplinkEnabled: Bool = false
var mqttDownlinkEnabled: Bool = false
var mqttTopic: String = ""
var phoneOnly: Bool = false
var mqttProxyConnected: Bool = false
var mqttUplinkEnabled: Bool = false
var mqttDownlinkEnabled: Bool = false
var mqttTopic: String = ""
var phoneOnly: Bool = false
var body: some View {
HStack {
if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly {
if bluetoothOn {
if deviceConnected {
// Create an HStack for connected state with proper accessibility
HStack {
if mqttUplinkEnabled || mqttDownlinkEnabled {
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
.accessibilityHidden(true)
}
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
.imageScale(.large)
.foregroundColor(.green)
.symbolRenderingMode(.hierarchical)
.accessibilityHidden(true)
Text(name.addingVariationSelectors)
.font(name.isEmoji() ? .title : .callout)
.foregroundColor(.gray)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver())
} else {
// Create a container for disconnected state
HStack {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.imageScale(.medium)
.foregroundColor(.red)
.symbolRenderingMode(.hierarchical)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("No Bluetooth device connected".localized)
}
} else {
// Create a container for Bluetooth off state
HStack {
Text("Bluetooth is off".localized)
.font(.subheadline)
.foregroundColor(.red)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Bluetooth is off".localized)
}
}
}
}
var body: some View {
HStack {
if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly {
if bluetoothOn {
if deviceConnected {
// Create an HStack for connected state with proper accessibility
HStack {
if mqttUplinkEnabled || mqttDownlinkEnabled {
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
.accessibilityHidden(true)
}
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
.imageScale(.large)
.foregroundColor(.green)
.symbolRenderingMode(.hierarchical)
.accessibilityHidden(true)
Text(name.addingVariationSelectors)
.font(name.isEmoji() ? .title : .callout)
.foregroundColor(.gray)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver())
} else {
// Create a container for disconnected state
HStack {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.imageScale(.medium)
.foregroundColor(.red)
.symbolRenderingMode(.hierarchical)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("No Bluetooth device connected".localized)
}
} else {
// Create a container for Bluetooth off state
HStack {
Text("Bluetooth is off".localized)
.font(.subheadline)
.foregroundColor(.red)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Bluetooth is off".localized)
}
}
}
}
}
struct ConnectedDevice_Previews: PreviewProvider {
static var previews: some View {
VStack(alignment: .trailing) {
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#")
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: false, name: "MEMO", mqttProxyConnected: false)
}.previewLayout(.fixed(width: 150, height: 275))
}
static var previews: some View {
VStack(alignment: .trailing) {
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#")
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true)
ConnectedDevice(bluetoothOn: true, deviceConnected: false, name: "MEMO", mqttProxyConnected: false)
}.previewLayout(.fixed(width: 150, height: 275))
}
}

View file

@ -7,6 +7,13 @@
import SwiftUI
import MapKit
import CoreLocation
import OSLog
struct IdentifiableOverlay: Identifiable {
let overlay: MKOverlay
var id: ObjectIdentifier { ObjectIdentifier(overlay as AnyObject) }
}
struct MeshMapContent: MapContent {
@ -24,6 +31,10 @@ struct MeshMapContent: MapContent {
@AppStorage("enableMapWaypoints") private var showWaypoints = true
@Binding var selectedWaypoint: WaypointEntity?
// Map overlays
@AppStorage("mapOverlaysEnabled") private var showMapOverlays = false
@Binding var enabledOverlayConfigs: Set<UUID>
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn)
var positions: FetchedResults<PositionEntity>
@ -222,11 +233,54 @@ struct MeshMapContent: MapContent {
.foregroundStyle(.indigo.opacity(0.4))
}
}
/// GeoJSON Overlays with embedded styling
if showMapOverlays {
overlayContent
}
positionAnnotations
routeAnnotations
waypointAnnotations
}
var overlayContent: some MapContent {
// Get all features but filter by enabled configs
let allStyledFeatures = GeoJSONOverlayManager.shared.loadStyledFeaturesForConfigs(enabledOverlayConfigs)
return Group {
ForEach(0..<allStyledFeatures.count, id: \.self) { index in
let styledFeature = allStyledFeatures[index]
let feature = styledFeature.feature
let geometryType = feature.geometry.type
if geometryType == "Point" {
if let coordinate = feature.geometry.coordinates.toCoordinate() {
Annotation(feature.name, coordinate: coordinate) {
Circle()
.fill(styledFeature.fillColor)
.stroke(styledFeature.strokeColor, style: styledFeature.strokeStyle)
.frame(width: feature.markerRadius * 2, height: feature.markerRadius * 2)
}
.annotationTitles(.automatic)
.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

View file

@ -7,6 +7,7 @@
import SwiftUI
import MapKit
import OSLog
struct MapSettingsForm: View {
@Environment(\.dismiss) private var dismiss
@ -16,11 +17,14 @@ 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
@ObservedObject private var mapDataManager = MapDataManager.shared
@Binding var traffic: Bool
@Binding var pointsOfInterest: Bool
@Binding var mapLayer: MapLayer
@AppStorage("meshMapDistance") private var meshMapDistance: Double = 800000
@Binding var meshMap: Bool
@Binding var enabledOverlayConfigs: Set<UUID>
var body: some View {
@ -114,6 +118,124 @@ struct MapSettingsForm: View {
self.pointsOfInterest.toggle()
UserDefaults.enableMapPointsOfInterest = self.pointsOfInterest
}
}
Section(header: Text("Map Overlays")) {
let hasUserData = GeoJSONOverlayManager.shared.hasUserData()
// Master toggle for map overlays
Toggle(isOn: $mapOverlaysEnabled) {
Label {
VStack(alignment: .leading) {
Text("Map Overlays")
Text(GeoJSONOverlayManager.shared.getActiveDataSource())
.font(.caption)
.foregroundColor(.secondary)
}
} icon: {
Image(systemName: "map")
.foregroundColor(hasUserData ? .accentColor : .secondary)
}
}
.tint(.accentColor)
.disabled(!hasUserData)
// Show individual file toggles when overlays are enabled
if mapOverlaysEnabled && hasUserData {
if !mapDataManager.getUploadedFiles().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(mapDataManager.getUploadedFiles()) { file in
Toggle(isOn: Binding(
get: {
return enabledOverlayConfigs.contains(file.id)
},
set: { newValue in
if newValue {
enabledOverlayConfigs.insert(file.id)
} else {
enabledOverlayConfigs.remove(file.id)
}
}
)) {
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: {
let isEnabled = enabledOverlayConfigs.contains(file.id)
Image(systemName: isEnabled ? "doc.fill" : "doc")
.foregroundColor(isEnabled ? .accentColor : .secondary)
}
}
.tint(.accentColor)
.padding(.leading, 35)
}
// Manage data link
NavigationLink(destination: MapDataFiles()) {
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)
}
} else if !hasUserData {
// Upload prompt when no data available
NavigationLink(destination: MapDataFiles()) {
HStack {
Image(systemName: "arrow.up.doc")
.foregroundColor(.accentColor)
Text(NSLocalizedString("Upload map data to enable overlays", comment: "Prompt to upload map data when none is available"))
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.leading, 35)
}
}
}
@ -134,6 +256,10 @@ Spacer()
.presentationContentInteraction(.scrolls)
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
.onAppear {
// Initialize map data manager
mapDataManager.initialize()
}
}
}

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

@ -26,6 +26,8 @@ struct MeshMap: View {
@AppStorage("enableMapTraffic") private var showTraffic: Bool = false
@AppStorage("enableMapPointsOfInterest") private var showPointsOfInterest: Bool = false
@AppStorage("mapLayer") private var selectedMapLayer: MapLayer = .standard
/// Map overlay configs
@State private var enabledOverlayConfigs: Set<UUID> = []
// Map Configuration
@Namespace var mapScope
@State var mapStyle: MapStyle = MapStyle.standard(elevation: .flat, emphasis: MapStyle.StandardEmphasis.muted, pointsOfInterest: .excludingAll, showsTraffic: false)
@ -70,7 +72,8 @@ struct MeshMap: View {
showPointsOfInterest: $showPointsOfInterest,
selectedMapLayer: $selectedMapLayer,
selectedPosition: $selectedPosition,
selectedWaypoint: $selectedWaypoint
selectedWaypoint: $selectedWaypoint,
enabledOverlayConfigs: $enabledOverlayConfigs
)
}
.mapScope(mapScope)
@ -134,7 +137,7 @@ struct MeshMap: View {
.padding()
}
.sheet(isPresented: $editingSettings) {
MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap)
MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs)
}
.onChange(of: router.navigationState) {
guard case .map = router.navigationState.selectedTab else { return }
@ -196,6 +199,10 @@ struct MeshMap: View {
.onFirstAppear {
UIApplication.shared.isIdleTimerDisabled = true
// Initialize enabled overlay configs with all active files
let activeFiles = GeoJSONOverlayManager.shared.getUploadedFilesWithState().filter { $0.isActive }
enabledOverlayConfigs = Set(activeFiles.map { $0.id })
// let wayPointEntity = getWaypoint(id: Int64(deepLinkManager.waypointId) ?? -1, context: context)
// if wayPointEntity.id > 0 {
// position = .camera(MapCamera(centerCoordinate: wayPointEntity.coordinate, distance: 1000, heading: 0, pitch: 60))
@ -213,6 +220,11 @@ struct MeshMap: View {
.onDisappear(perform: {
UIApplication.shared.isIdleTimerDisabled = false
})
.onReceive(NotificationCenter.default.publisher(for: Foundation.Notification.Name.mapDataFileDeleted)) { notification in
if let deletedFileId = notification.object as? UUID {
enabledOverlayConfigs.remove(deletedFileId)
}
}
}
// moves the map to a new coordinate

View file

@ -25,6 +25,33 @@ struct AppData: View {
GPSStatus()
}
Divider()
// Map Data Section
Section(header: Text("Map Data")) {
NavigationLink(destination: MapDataFiles()) {
HStack {
Image(systemName: "map")
.symbolRenderingMode(.hierarchical)
.font(idiom == .phone ? .callout : .title)
.frame(width: 35)
VStack(alignment: .leading) {
Text(NSLocalizedString("Upload Map Data", comment: "Title for map data upload screen"))
.font(.headline)
Text(NSLocalizedString("Manage custom map overlays", comment: "Subtitle for map data management"))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Divider()
}
List(files, id: \.self) { file in

View file

@ -230,7 +230,7 @@ struct SecurityConfig: View {
name: "\(bleManager.connectedPeripheral?.shortName ?? "?")"
)
})
.onChange(of: node) { _, newNode in
.onChange(of: node) { _, _ in
setSecurityValues()
}
.onChange(of: isManaged) { _, newIsManaged in

View file

@ -0,0 +1,254 @@
import SwiftUI
import UniformTypeIdentifiers
import OSLog
struct MapDataFiles: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
@ObservedObject private var mapDataManager = MapDataManager.shared
@State private var isShowingFilePicker = false
@State private var isProcessing = false
@State private var processingProgress: Double = 0.0
@State private var showError = false
@State private var errorMessage = ""
@State private var showSuccess = false
@State private var successMessage = ""
var body: some View {
VStack(spacing: 20) {
// Header
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Upload Map Data", comment: "Title for map data upload screen"))
.font(.title2)
.fontWeight(.bold)
Text("Upload GeoJSON files to display custom map overlays. Files are stored locally and can be up to 10MB.")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
// Upload Button
Button(action: {
isShowingFilePicker = true
}) {
HStack {
Image(systemName: "doc.badge.plus")
.font(.title2)
Text(NSLocalizedString("Select Map Data File", comment: "Button text for selecting map data file"))
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(isProcessing)
.padding(.horizontal)
// Processing Indicator
if isProcessing {
VStack(spacing: 12) {
ProgressView(value: processingProgress)
.progressViewStyle(LinearProgressViewStyle())
.padding(.horizontal)
Text("Processing file...")
.font(.caption)
.foregroundColor(.secondary)
}
}
// Current Files Section
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("Uploaded Files", comment: "Section header for uploaded files"))
.font(.headline)
.padding(.horizontal)
let uploadedFiles = mapDataManager.getUploadedFiles()
if uploadedFiles.isEmpty {
VStack(spacing: 8) {
Image(systemName: "doc.text")
.font(.title)
.foregroundColor(.secondary)
Text(NSLocalizedString("No files uploaded yet", comment: "Empty state text when no files are uploaded"))
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(uploadedFiles) { file in
MapDataFileRow(file: file) {
deleteFile(file)
}
}
}
.padding(.horizontal)
}
}
}
Spacer()
}
.navigationTitle("Map Data")
.navigationBarTitleDisplayMode(.inline)
.fileImporter(
isPresented: $isShowingFilePicker,
allowedContentTypes: [
UTType.json,
UTType(filenameExtension: "geojson") ?? UTType.json
],
allowsMultipleSelection: false
) { result in
handleFileSelection(result)
}
.alert("Upload Error", isPresented: $showError) {
Button("OK") { }
} message: {
Text(errorMessage)
}
.alert("Upload Success", isPresented: $showSuccess) {
Button("OK") { }
} message: {
Text(successMessage)
}
.onAppear {
// Initialize map data manager if needed
mapDataManager.initialize()
}
}
// MARK: - File Handling
private func handleFileSelection(_ result: Result<[URL], Error>) {
do {
guard let selectedFile = try result.get().first else { return }
// Start processing
isProcessing = true
processingProgress = 0.0
// Process file asynchronously
Task {
do {
// Simulate progress
await simulateProgress()
let metadata = try await mapDataManager.processUploadedFile(from: selectedFile)
await MainActor.run {
isProcessing = false
processingProgress = 1.0
successMessage = "Successfully uploaded '\(metadata.originalName)' with \(metadata.overlayCount) overlays"
showSuccess = true
}
} catch {
await MainActor.run {
isProcessing = false
processingProgress = 0.0
errorMessage = error.localizedDescription
showError = true
}
}
}
} catch {
errorMessage = "Failed to access file: \(error.localizedDescription)"
showError = true
}
}
private func simulateProgress() async {
for i in 1...10 {
await MainActor.run {
processingProgress = Double(i) / 10.0
}
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
}
}
private func deleteFile(_ file: MapDataMetadata) {
Task {
do {
try await mapDataManager.deleteFile(file)
} catch {
await MainActor.run {
errorMessage = "Failed to delete file: \(error.localizedDescription)"
showError = true
}
}
}
}
}
// MARK: - Supporting Views
struct MapDataFileRow: View {
let file: MapDataMetadata
let onDelete: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(file.originalName)
.font(.headline)
.lineLimit(1)
Spacer()
}
HStack {
Text(file.format.uppercased())
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.2))
.cornerRadius(4)
Text(file.fileSizeString)
.font(.caption)
.foregroundColor(.secondary)
Text("")
.font(.caption)
.foregroundColor(.secondary)
Text("\(file.overlayCount) overlays")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(file.uploadDateString)
.font(.caption)
.foregroundColor(.secondary)
}
}
Button(action: onDelete) {
Image(systemName: "trash")
.foregroundColor(.red)
}
.buttonStyle(BorderlessButtonStyle())
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(8)
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
}
}
#Preview {
NavigationView {
MapDataFiles()
}
}