More map cleanup

This commit is contained in:
Garth Vander Houwen 2023-11-09 17:43:38 -08:00
parent 0c70921935
commit 70e1bdb835
13 changed files with 210 additions and 126 deletions

View file

@ -0,0 +1,153 @@
//
// LocalMBTileOverlay.swift
// MeshtasticApple
//
// Copyright(c) Joshua Pirihi 16/01/22.
//
import UIKit
import MapKit
import SQLite
extension MKMapRect {
init(coordinates: [CLLocationCoordinate2D]) {
self = MKMapRect()
var coordinates = coordinates
if !coordinates.isEmpty {
let first = coordinates.removeFirst()
var top = first.latitude
var bottom = first.latitude
var left = first.longitude
var right = first.longitude
coordinates.forEach { coordinate in
top = max(top, coordinate.latitude)
bottom = min(bottom, coordinate.latitude)
left = min(left, coordinate.longitude)
right = max(right, coordinate.longitude)
}
let topLeft = MKMapPoint(CLLocationCoordinate2D(latitude: top, longitude: left))
let bottomRight = MKMapPoint(CLLocationCoordinate2D(latitude: bottom, longitude: right))
self = MKMapRect(x: topLeft.x, y: topLeft.y,
width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y)
}
}
}
enum MapTileError: Error {
case invalidFormat
case other
}
class LocalMBTileOverlay: MKTileOverlay {
var path: String!
var mb: Connection!
private var _boundingMapRect: MKMapRect!
override var boundingMapRect: MKMapRect {
return _boundingMapRect
}
init?(mbTilePath path: String) {
super.init(urlTemplate: nil)
self.path = path
do {
self.mb = try Connection(self.path, readonly: true)
let metadata = Table("metadata")
let name = Expression<String>("name")
let value = Expression<String>("value")
// make sure it's raster
let formatQuery = try mb.pluck(metadata.select(value).filter(name == "format"))
if formatQuery?[value] == nil || (formatQuery![value] != "jpg" && formatQuery![value] != "png") {
throw MapTileError.invalidFormat
}
let minZQuery = try mb.pluck(metadata.select(value).filter(name == "minzoom"))
self.minimumZ = Int(minZQuery![value])!
let maxZQuery = try mb.pluck(metadata.select(value).filter(name == "maxzoom"))
self.maximumZ = Int(maxZQuery![value])!
self.isGeometryFlipped = true
let boundingBoxString = try mb.pluck(metadata.select(value).filter(name == "bounds"))
let boundCoords = boundingBoxString![value].split(separator: ",")
let coords = [
CLLocationCoordinate2D(latitude: Double(boundCoords[1]) ?? 0,
longitude: Double(boundCoords[0]) ?? 0),
CLLocationCoordinate2D(latitude: Double(boundCoords[3]) ?? 0,
longitude: Double(boundCoords[2]) ?? 0)
]
self._boundingMapRect = MKMapRect(coordinates: coords)
} catch {
print("💥 Map tile error: \(error)")
return nil
}
}
override func loadTile(at path: MKTileOverlayPath, result: @escaping (Data?, Error?) -> Void) {
let tileX = Int64(path.x)
let tileY = Int64(path.y)
let tileZ = Int64(path.z)
let tileData = Expression<SQLite.Blob>("tile_data")
let zoomLevel = Expression<Int64>("zoom_level")
let tileColumn = Expression<Int64>("tile_column")
let tileRow = Expression<Int64>("tile_row")
if let dataQuery = try? self.mb.pluck(Table("tiles").select(tileData).filter(zoomLevel == tileZ).filter(tileColumn == tileX).filter(tileRow == tileY)) {
let data = Data(bytes: dataQuery[tileData].bytes, count: dataQuery[tileData].bytes.count)// dataQuery![tileData].bytes
result(data, nil)
} else {
print("💥 No tile here: x:\(tileX) y:\(tileY) z:\(tileZ)")
let error = NSError(domain: "LocalMBTileOverlay", code: 1, userInfo: ["reason": "no_tile"])
result(nil, error)
}
}
}
// public class CustomMapOverlaySource: MKTileOverlay {
//
// // requires folder: tiles/{mapName}/z/y/y,{tileType}
// private var parent: MapViewSwiftUI
// private let mapName: String
// private let tileType: String
// private let defaultTile: DefaultTile?
//
// public init(
// parent: MapViewSwiftUI,
// mapName: String,
// tileType: String,
// defaultTile: DefaultTile?
// ) {
// self.parent = parent
// self.mapName = mapName
// self.tileType = tileType
// self.defaultTile = defaultTile
// super.init(urlTemplate: "")
// }
//
// public override func url(forTilePath path: MKTileOverlayPath) -> URL {
// if let tileUrl = Bundle.main.url(
// forResource: "\(path.y)",
// withExtension: self.tileType,
// subdirectory: "tiles/\(self.mapName)/\(path.z)/\(path.x)",
// localization: nil
// ) {
// return tileUrl
// } else if let defaultTile = self.defaultTile, let defaultTileUrl = Bundle.main.url(
// forResource: defaultTile.tileName,
// withExtension: defaultTile.tileType,
// subdirectory: "tiles/\(self.mapName)",
// localization: nil
// ) {
// return defaultTileUrl
// } else {
// let urlstring = self.mapName+"\(path.z)/\(path.x)/\(path.y).png"
// return URL(string: urlstring)!
// }
// }
// }

View file

@ -0,0 +1,64 @@
//
// MapButtons.swift
// Meshtastic
//
// Copyright © Garth Vander Houwen 4/23/23.
//
import SwiftUI
struct MapButtons: View {
let buttonWidth: CGFloat = 22
let width: CGFloat = 45
@Binding var tracking: UserTrackingModes
@Binding var isPresentingInfoSheet: Bool
var body: some View {
VStack {
let impactLight = UIImpactFeedbackGenerator(style: .light)
Button(action: {
self.isPresentingInfoSheet.toggle()
}) {
Image(systemName: isPresentingInfoSheet ? "info.circle.fill" : "info.circle")
.resizable()
.frame(width: buttonWidth, height: buttonWidth, alignment: .center)
.offset(y: -2)
}
Divider()
Button(action: {
switch self.tracking {
case .none:
self.tracking = .follow
case .follow:
self.tracking = .followWithHeading
case .followWithHeading:
self.tracking = .none
}
impactLight.impactOccurred()
}) {
Image(systemName: tracking.icon)
.frame(width: buttonWidth, height: buttonWidth, alignment: .center)
.offset(y: 3)
}
}
.frame(width: width, height: width*2, alignment: .center)
.background(Color(UIColor.systemBackground))
.cornerRadius(8)
.shadow(radius: 1)
.offset(x: 3, y: 25)
}
}
// MARK: Previews
struct MapControl_Previews: PreviewProvider {
@State static var tracking: UserTrackingModes = .none
@State static var isPresentingInfoSheet = false
static var previews: some View {
Group {
MapButtons(tracking: $tracking, isPresentingInfoSheet: $isPresentingInfoSheet)
.environment(\.colorScheme, .light)
MapButtons(tracking: $tracking, isPresentingInfoSheet: $isPresentingInfoSheet)
.environment(\.colorScheme, .dark)
}
.previewLayout(.fixed(width: 60, height: 100))
}
}

View file

@ -0,0 +1,37 @@
//
// MapViewFitExtension.swift
// Meshtastic
//
// Created by Garth Vander Houwen on 1/15/23.
//
import MapKit
extension MKMapView {
func fitAllAnnotations(with padding: UIEdgeInsets = UIEdgeInsets(top: 100, left: 100, bottom: 100, right: 100)) {
var zoomRect: MKMapRect = .null
annotations.forEach({
let annotationPoint = MKMapPoint($0.coordinate)
let pointRect = MKMapRect(x: annotationPoint.x, y: annotationPoint.y, width: 0.01, height: 0.01)
zoomRect = zoomRect.union(pointRect)
})
setVisibleMapRect(zoomRect, edgePadding: padding, animated: true)
}
func fit(annotations: [MKAnnotation], andShow show: Bool, with padding: UIEdgeInsets = UIEdgeInsets(top: 100, left: 100, bottom: 100, right: 100)) {
var zoomRect: MKMapRect = .null
annotations.forEach({
let aPoint = MKMapPoint($0.coordinate)
let rect = MKMapRect(x: aPoint.x, y: aPoint.y, width: 0.1, height: 0.1)
zoomRect = zoomRect.isNull ? rect : zoomRect.union(rect)
})
if show {
addAnnotations(annotations)
}
setVisibleMapRect(zoomRect, edgePadding: padding, animated: true)
}
}

View file

@ -0,0 +1,468 @@
//
// MapViewSwitUI.swift
// Meshtastic
//
// Copyright(c) Josh Pirihi & Garth Vander Houwen 1/16/22.
import Foundation
import SwiftUI
import MapKit
struct PolygonInfo: Codable {
let stroke: String?
let strokeWidth, strokeOpacity: Int?
let fill: String?
let fillOpacity: Double?
let title, subtitle: String?
}
func degreesToRadians(_ number: Double) -> Double {
return number * .pi / 180
}
var currentMapLayer: MapLayer?
struct MapViewSwiftUI: UIViewRepresentable {
var onLongPress: (_ waypointCoordinate: CLLocationCoordinate2D) -> Void
var onWaypointEdit: (_ waypointId: Int ) -> Void
let mapView = MKMapView()
// Parameters
let selectedMapLayer: MapLayer
let selectedWeatherLayer: MapOverlayServer = UserDefaults.mapOverlayServer
let positions: [PositionEntity]
let waypoints: [WaypointEntity]
let userTrackingMode: MKUserTrackingMode
let showNodeHistory: Bool
let showRouteLines: Bool
let mapViewType: MKMapType = MKMapType.standard
// Offline Map Tiles
@AppStorage("lastUpdatedLocalMapFile") private var lastUpdatedLocalMapFile = 0
@State private var loadedLastUpdatedLocalMapFile = 0
var customMapOverlay: CustomMapOverlay?
@State private var presentCustomMapOverlayHash: CustomMapOverlay?
// MARK: Private methods
private func configureMap(mapView: MKMapView) {
// Map View Parameters
mapView.mapType = mapViewType
mapView.addAnnotations(waypoints)
// Do the initial map centering
let latest = positions
.filter { $0.latest == true }
.sorted { $0.nodePosition?.num ?? 0 > $1.nodePosition?.num ?? -1 }
let span = MKCoordinateSpan(latitudeDelta: 0.003, longitudeDelta: 0.003)
let center = (latest.count > 0 && userTrackingMode == MKUserTrackingMode.none) ? latest[0].coordinate : LocationHelper.currentLocation
let region = MKCoordinateRegion(center: center, span: span)
mapView.addAnnotations(showNodeHistory ? positions : latest)
mapView.setRegion(region, animated: true)
// Set user (phone gps) tracking options
mapView.setUserTrackingMode(userTrackingMode, animated: true)
if userTrackingMode == MKUserTrackingMode.none {
if latest.count == 1 {
mapView.fit(annotations: showNodeHistory ? positions: latest, andShow: false)
} else {
mapView.fitAllAnnotations()
}
mapView.showsUserLocation = false
} else {
mapView.showsUserLocation = true
}
// Other MKMapView Settings
mapView.preferredConfiguration.elevationStyle = .realistic// .flat
mapView.pointOfInterestFilter = MKPointOfInterestFilter.excludingAll
mapView.isPitchEnabled = true
mapView.isRotateEnabled = true
mapView.isScrollEnabled = true
mapView.isZoomEnabled = true
mapView.showsBuildings = true
mapView.showsScale = true
mapView.showsTraffic = true
mapView.showsCompass = false
let compass = MKCompassButton(mapView: mapView)
compass.translatesAutoresizingMaskIntoConstraints = false
#if targetEnvironment(macCatalyst)
// Show the default always visible compass and the mac only controls
compass.compassVisibility = .visible
mapView.addSubview(compass)
mapView.showsZoomControls = true
mapView.showsPitchControl = true
compass.trailingAnchor.constraint(equalTo: mapView.trailingAnchor, constant: -115).isActive = true
compass.bottomAnchor.constraint(equalTo: mapView.bottomAnchor, constant: -5).isActive = true
#else
compass.compassVisibility = .adaptive
mapView.addSubview(compass)
compass.trailingAnchor.constraint(equalTo: mapView.trailingAnchor, constant: -5).isActive = true
compass.topAnchor.constraint(equalTo: mapView.topAnchor, constant: 145).isActive = true
#endif
}
private func setMapBaseLayer(mapView: MKMapView) {
// Avoid refreshing UI if selectedLayer has not changed
guard currentMapLayer != selectedMapLayer else { return }
currentMapLayer = selectedMapLayer
for overlay in mapView.overlays {
if overlay is MKTileOverlay {
mapView.removeOverlay(overlay)
}
}
switch selectedMapLayer {
case .offline:
mapView.mapType = .standard
if !UserDefaults.enableOfflineMapsMBTiles {
let overlay = TileOverlay()
overlay.canReplaceMapContent = false
overlay.minimumZ = UserDefaults.mapTileServer.zoomRange.startIndex
overlay.maximumZ = UserDefaults.mapTileServer.zoomRange.endIndex
mapView.addOverlay(overlay, level: UserDefaults.mapTilesAboveLabels ? .aboveLabels : .aboveRoads)
}
case .satellite:
mapView.mapType = .satellite
case .hybrid:
mapView.mapType = .hybrid
default:
mapView.mapType = .standard
}
}
private func setMapOverlays(mapView: MKMapView) {
// Weather radar
if UserDefaults.enableOverlayServer {
let locale = Locale.current
if locale.region?.identifier ?? "no locale" == "US" {
let overlay = MKTileOverlay(urlTemplate: selectedWeatherLayer.tileUrl)
overlay.canReplaceMapContent = false
overlay.minimumZ = selectedWeatherLayer.zoomRange.startIndex
overlay.maximumZ = selectedWeatherLayer.zoomRange.endIndex
mapView.addOverlay(overlay, level: .aboveLabels)
}
}
}
private func setMbtilesOverlay(mapView: MKMapView) {
// MBTiles Offline
if UserDefaults.enableOfflineMaps && UserDefaults.enableOfflineMapsMBTiles {
if self.customMapOverlay != self.presentCustomMapOverlayHash || self.loadedLastUpdatedLocalMapFile != self.lastUpdatedLocalMapFile {
mapView.removeOverlays(mapView.overlays)
if self.customMapOverlay != nil {
let fileManager = FileManager.default
let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let tilePath = documentsDirectory.appendingPathComponent("offline_map.mbtiles", isDirectory: false).path
if fileManager.fileExists(atPath: tilePath) {
print("Loading local map file")
if let overlay = LocalMBTileOverlay(mbTilePath: tilePath) {
overlay.canReplaceMapContent = false// customMapOverlay.canReplaceMapContent
mapView.addOverlay(overlay)
}
} else {
print("Couldn't find a local map file to load")
}
}
DispatchQueue.main.async {
self.presentCustomMapOverlayHash = self.customMapOverlay
self.loadedLastUpdatedLocalMapFile = self.lastUpdatedLocalMapFile
}
}
}
}
func makeUIView(context: Context) -> MKMapView {
currentMapLayer = nil
mapView.delegate = context.coordinator
self.configureMap(mapView: mapView)
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
// Set MBTiles overlay layer
setMbtilesOverlay(mapView: mapView)
// Set selected map base layer
setMapBaseLayer(mapView: mapView)
// Set map tile server and weather overlay layers
setMapOverlays(mapView: mapView)
let latest = positions
.filter { $0.latest == true }
.sorted { $0.nodePosition?.num ?? 0 > $1.nodePosition?.num ?? -1 }
// Node Route Lines
if showRouteLines {
// Remove all existing PolyLine Overlays
for overlay in mapView.overlays {
if overlay is MKPolyline {
mapView.removeOverlay(overlay)
}
}
var lineIndex = 0
for position in latest {
let nodePositions = positions.filter { $0.nodeCoordinate != nil && $0.nodePosition?.num ?? 0 == position.nodePosition?.num ?? -1 }
let lineCoords = nodePositions.compactMap({(position) -> CLLocationCoordinate2D in
return position.nodeCoordinate ?? LocationHelper.DefaultLocation
})
let polyline = MKPolyline(coordinates: lineCoords, count: nodePositions.count)
polyline.title = "\(String(position.nodePosition?.num ?? 0))"
mapView.addOverlay(polyline, level: .aboveLabels)
lineIndex += 1
// There are 18 colors for lines, start over if we are at index 17
if lineIndex > 17 {
lineIndex = 0
}
}
} else {
// Remove all existing PolyLine Overlays
for overlay in mapView.overlays {
if overlay is MKPolyline {
mapView.removeOverlay(overlay)
}
}
}
let annotationCount = waypoints.count + (showNodeHistory ? positions.count : latest.count)
if annotationCount != mapView.annotations.count {
print("Annotation Count: \(annotationCount) Map Annotations: \(mapView.annotations.count)")
mapView.removeAnnotations(mapView.annotations)
mapView.addAnnotations(waypoints)
}
mapView.addAnnotations(showNodeHistory ? positions : latest)
if userTrackingMode == MKUserTrackingMode.none {
mapView.showsUserLocation = false
if UserDefaults.enableMapRecentering {
if latest.count == 1 {
mapView.fit(annotations: showNodeHistory ? positions : latest, andShow: true)
} else {
mapView.fitAllAnnotations()
}
}
} else {
mapView.showsUserLocation = true
}
mapView.setUserTrackingMode(userTrackingMode, animated: true)
}
func makeCoordinator() -> MapCoordinator {
return Coordinator(self)
}
final class MapCoordinator: NSObject, MKMapViewDelegate, UIGestureRecognizerDelegate {
var parent: MapViewSwiftUI
var longPressRecognizer = UILongPressGestureRecognizer()
init(_ parent: MapViewSwiftUI) {
self.parent = parent
super.init()
self.longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressHandler))
self.longPressRecognizer.minimumPressDuration = 0.5
self.longPressRecognizer.cancelsTouchesInView = true
self.longPressRecognizer.delegate = self
self.parent.mapView.addGestureRecognizer(longPressRecognizer)
}
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
switch annotation {
case let positionAnnotation as PositionEntity:
let reuseID = String(positionAnnotation.nodePosition?.num ?? 0) + "-" + String(positionAnnotation.time?.timeIntervalSince1970 ?? 0)
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "node") as? MKMarkerAnnotationView ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: reuseID )
annotationView.tag = -1
annotationView.canShowCallout = true
if positionAnnotation.latest {
annotationView.markerTintColor = UIColor(hex: UInt32(positionAnnotation.nodePosition?.num ?? 0)).darker()
annotationView.displayPriority = .required
annotationView.titleVisibility = .visible
} else {
annotationView.markerTintColor = UIColor(hex: UInt32(positionAnnotation.nodePosition?.num ?? 0)).lighter()
annotationView.displayPriority = .defaultHigh
annotationView.titleVisibility = .adaptive
}
annotationView.tag = -1
annotationView.canShowCallout = true
annotationView.titleVisibility = .adaptive
let leftIcon = UIImageView(image: annotationView.glyphText?.image())
leftIcon.backgroundColor = UIColor(.indigo)
annotationView.leftCalloutAccessoryView = leftIcon
let subtitle = UILabel()
subtitle.text = "Long Name: \(positionAnnotation.nodePosition?.user?.longName ?? "Unknown") \n"
subtitle.text? += "Latitude: \(String(format: "%.5f", positionAnnotation.coordinate.latitude)) \n"
subtitle.text! += "Longitude: \(String(format: "%.5f", positionAnnotation.coordinate.longitude)) \n"
let distanceFormatter = MKDistanceFormatter()
subtitle.text! += "Altitude: \(distanceFormatter.string(fromDistance: Double(positionAnnotation.altitude))) \n"
if positionAnnotation.nodePosition?.metadata != nil {
if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.client ||
DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.clientMute ||
DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.routerClient {
annotationView.glyphImage = UIImage(systemName: "flipphone")
} else if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.repeater {
annotationView.glyphImage = UIImage(systemName: "repeat")
} else if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.router {
annotationView.glyphImage = UIImage(systemName: "wifi.router.fill")
} else if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.tracker {
annotationView.glyphImage = UIImage(systemName: "location.viewfinder")
} else if DeviceRoles(rawValue: Int(positionAnnotation.nodePosition!.metadata?.role ?? 0)) == DeviceRoles.sensor {
annotationView.glyphImage = UIImage(systemName: "sensor")
}
let pf = PositionFlags(rawValue: Int(positionAnnotation.nodePosition?.metadata?.positionFlags ?? 3))
if pf.contains(.Satsinview) {
subtitle.text! += "Sats in view: \(String(positionAnnotation.satsInView)) \n"
}
if pf.contains(.SeqNo) {
subtitle.text! += "Sequence: \(String(positionAnnotation.seqNo)) \n"
}
if pf.contains(.Heading) {
if parent.userTrackingMode != MKUserTrackingMode.followWithHeading {
annotationView.glyphImage = UIImage(systemName: "location.north.fill")?.rotate(radians: Float(degreesToRadians(Double(positionAnnotation.heading))))
subtitle.text! += "Heading: \(String(positionAnnotation.heading)) \n"
} else {
annotationView.glyphImage = UIImage(systemName: "flipphone")
}
}
if pf.contains(.Speed) {
let formatter = MeasurementFormatter()
formatter.locale = Locale.current
if positionAnnotation.speed <= 1 {
annotationView.glyphImage = UIImage(systemName: "hexagon")
}
subtitle.text! += "Speed: \(formatter.string(from: Measurement(value: Double(positionAnnotation.speed), unit: UnitSpeed.kilometersPerHour))) \n"
}
} else {
// node metadata is nil
annotationView.glyphImage = UIImage(systemName: "flipphone")
}
if LocationHelper.currentLocation.distance(from: LocationHelper.DefaultLocation) > 0.0 {
let metersAway = positionAnnotation.coordinate.distance(from: LocationHelper.currentLocation)
subtitle.text! += "distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway))) \n"
}
subtitle.text! += positionAnnotation.time?.formatted() ?? "Unknown \n"
subtitle.numberOfLines = 0
annotationView.detailCalloutAccessoryView = subtitle
let detailsIcon = UIButton(type: .detailDisclosure)
detailsIcon.setImage(UIImage(systemName: "trash"), for: .normal)
annotationView.rightCalloutAccessoryView = detailsIcon
return annotationView
case let waypointAnnotation as WaypointEntity:
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "waypoint") as? MKMarkerAnnotationView ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: String(waypointAnnotation.id))
annotationView.tag = Int(waypointAnnotation.id)
annotationView.isEnabled = true
annotationView.canShowCallout = true
if waypointAnnotation.icon == 0 {
annotationView.glyphText = "📍"
} else {
annotationView.glyphText = String(UnicodeScalar(Int(waypointAnnotation.icon)) ?? "📍")
}
annotationView.markerTintColor = UIColor(.accentColor)
annotationView.displayPriority = .required
annotationView.titleVisibility = .adaptive
let leftIcon = UIImageView(image: annotationView.glyphText?.image())
leftIcon.backgroundColor = UIColor(.accentColor)
annotationView.leftCalloutAccessoryView = leftIcon
let subtitle = UILabel()
if waypointAnnotation.longDescription?.count ?? 0 > 0 {
subtitle.text = (waypointAnnotation.longDescription ?? "") + "\n"
} else {
subtitle.text = ""
}
if LocationHelper.currentLocation.distance(from: LocationHelper.DefaultLocation) > 0.0 {
let metersAway = waypointAnnotation.coordinate.distance(from: LocationHelper.currentLocation)
let distanceFormatter = MKDistanceFormatter()
subtitle.text! += "distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway))) \n"
}
if waypointAnnotation.created != nil {
subtitle.text! += "Created: \(waypointAnnotation.created?.formatted() ?? "Unknown") \n"
}
if waypointAnnotation.lastUpdated != nil {
subtitle.text! += "Updated: \(waypointAnnotation.lastUpdated?.formatted() ?? "Unknown") \n"
}
if waypointAnnotation.expire != nil {
subtitle.text! += "Expires: \(waypointAnnotation.expire?.formatted() ?? "Unknown") \n"
}
subtitle.numberOfLines = 0
annotationView.detailCalloutAccessoryView = subtitle
let editIcon = UIButton(type: .detailDisclosure)
editIcon.setImage(UIImage(systemName: "square.and.pencil"), for: .normal)
annotationView.rightCalloutAccessoryView = editIcon
return annotationView
default: return nil
}
}
func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
switch view.annotation {
case _ as WaypointEntity:
// Only Allow Edit for waypoint annotations with a id
if view.tag > 0 {
parent.onWaypointEdit(view.tag)
}
default: break
}
}
@objc func longPressHandler(_ gesture: UILongPressGestureRecognizer) {
if gesture.state != UIGestureRecognizer.State.ended {
return
} else if gesture.state != UIGestureRecognizer.State.began {
// Screen Position - CGPoint
let location = longPressRecognizer.location(in: self.parent.mapView)
// Map Coordinate - CLLocationCoordinate2D
let coordinate = self.parent.mapView.convert(location, toCoordinateFrom: self.parent.mapView)
let annotation = MKPointAnnotation()
annotation.title = "Dropped Pin"
annotation.coordinate = coordinate
parent.mapView.addAnnotation(annotation)
UINotificationFeedbackGenerator().notificationOccurred(.success)
parent.onLongPress(coordinate)
}
}
public func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let tileOverlay = overlay as? MKTileOverlay {
return MKTileOverlayRenderer(tileOverlay: tileOverlay)
} else {
if let routePolyline = overlay as? MKPolyline {
let titleString = routePolyline.title ?? "0"
let renderer = MKPolylineRenderer(polyline: routePolyline)
renderer.strokeColor = UIColor(hex: UInt32(titleString) ?? 0).lighter()
renderer.lineWidth = 8
return renderer
}
if let polygon = overlay as? MKPolygon {
let renderer = MKPolygonRenderer(polygon: polygon)
renderer.fillColor = UIColor.purple.withAlphaComponent(0.2)
renderer.strokeColor = .purple.withAlphaComponent(0.7)
return renderer
}
return MKOverlayRenderer(overlay: overlay)
}
}
}
/// is supposed to be located in the folder with the map name
public struct DefaultTile: Hashable {
let tileName: String
let tileType: String
public init(tileName: String, tileType: String) {
self.tileName = tileName
self.tileType = tileType
}
}
public struct CustomMapOverlay: Equatable, Hashable {
let mapName: String
let tileType: String
var canReplaceMapContent: Bool
var minimumZoomLevel: Int?
var maximumZoomLevel: Int?
let defaultTile: DefaultTile?
public init(
mapName: String,
tileType: String,
canReplaceMapContent: Bool = true, // false for transparent tiles
minimumZoomLevel: Int? = nil,
maximumZoomLevel: Int? = nil,
defaultTile: DefaultTile? = nil
) {
self.mapName = mapName
self.tileType = tileType
self.canReplaceMapContent = canReplaceMapContent
self.minimumZoomLevel = minimumZoomLevel
self.maximumZoomLevel = maximumZoomLevel
self.defaultTile = defaultTile
}
public init?(
mapName: String?,
tileType: String,
canReplaceMapContent: Bool = true, // false for transparent tiles
minimumZoomLevel: Int? = nil,
maximumZoomLevel: Int? = nil,
defaultTile: DefaultTile? = nil
) {
if mapName == nil || mapName! == "" {
return nil
}
self.mapName = mapName!
self.tileType = tileType
self.canReplaceMapContent = canReplaceMapContent
self.minimumZoomLevel = minimumZoomLevel
self.maximumZoomLevel = maximumZoomLevel
self.defaultTile = defaultTile
}
}
}

View file

@ -0,0 +1,164 @@
//
// NodeMapControl.swift
// Meshtastic
//
// Created by Garth Vander Houwen on 9/9/23.
//
import SwiftUI
import CoreLocation
import MapKit
import WeatherKit
struct NodeMapMapkit: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
/// Weather
/// The current weather condition for the city.
@State private var condition: WeatherCondition?
@State private var temperature: Measurement<UnitTemperature>?
@State private var humidity: Int?
@State private var symbolName: String = "cloud.fill"
@State private var attributionLink: URL?
@State private var attributionLogo: URL?
@Environment(\.colorScheme) var colorScheme: ColorScheme
@AppStorage("meshMapType") private var meshMapType = 0
@AppStorage("meshMapShowNodeHistory") private var meshMapShowNodeHistory = false
@AppStorage("meshMapShowRouteLines") private var meshMapShowRouteLines = false
@State private var selectedMapLayer: MapLayer = .standard
@State var waypointCoordinate: WaypointCoordinate?
@State var editingWaypoint: Int = 0
@State private var customMapOverlay: MapViewSwiftUI.CustomMapOverlay? = MapViewSwiftUI.CustomMapOverlay(
mapName: "offlinemap",
tileType: "png",
canReplaceMapContent: true
)
@FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: false)],
predicate: NSPredicate(
format: "expire == nil || expire >= %@", Date() as NSDate
), animation: .none)
private var waypoints: FetchedResults<WaypointEntity>
@ObservedObject var node: NodeInfoEntity
var body: some View {
let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? -1, context: context)
NavigationStack {
GeometryReader { bounds in
VStack {
if node.hasPositions {
ZStack {
let positionArray = node.positions?.array as? [PositionEntity] ?? []
let lastTenThousand = Array(positionArray.prefix(10000))
// let todaysPositions = positionArray.filter { $0.time! >= Calendar.current.startOfDay(for: Date()) }
ZStack {
MapViewSwiftUI(onLongPress: { coord in
waypointCoordinate = WaypointCoordinate(id: .init(), coordinate: coord, waypointId: 0)
}, onWaypointEdit: { wpId in
if wpId > 0 {
waypointCoordinate = WaypointCoordinate(id: .init(), coordinate: nil, waypointId: Int64(wpId))
}
},
selectedMapLayer: selectedMapLayer,
positions: lastTenThousand,
waypoints: Array(waypoints),
userTrackingMode: MKUserTrackingMode.none,
showNodeHistory: meshMapShowNodeHistory,
showRouteLines: meshMapShowRouteLines,
customMapOverlay: self.customMapOverlay
)
VStack(alignment: .leading) {
Spacer()
HStack(alignment: .bottom, spacing: 1) {
Picker("Map Type", selection: $selectedMapLayer) {
ForEach(MapLayer.allCases, id: \.self) { layer in
if layer == MapLayer.offline && UserDefaults.enableOfflineMaps {
Text(layer.localized)
} else if layer != MapLayer.offline {
Text(layer.localized)
}
}
}
.onChange(of: (selectedMapLayer)) { newMapLayer in
UserDefaults.mapLayer = newMapLayer
}
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.pickerStyle(.menu)
.padding(5)
VStack {
VStack {
Label(temperature?.formatted(.measurement(width: .narrow)) ?? "??", systemImage: symbolName)
.font(.caption)
Label("\(humidity ?? 0)%", systemImage: "humidity")
.font(.caption2)
AsyncImage(url: attributionLogo) { image in
image
.resizable()
.scaledToFit()
} placeholder: {
ProgressView()
.controlSize(.mini)
}
.frame(height: 10)
Link("Other data sources", destination: attributionLink ?? URL(string: "https://weather-data.apple.com/legal-attribution.html")!)
.font(.caption2)
}
.padding(5)
}
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(5)
.task {
do {
if node.hasPositions {
let mostRecent = node.positions?.lastObject as? PositionEntity
let weather = try await WeatherService.shared.weather(for: mostRecent?.nodeLocation ?? CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude))
condition = weather.currentWeather.condition
temperature = weather.currentWeather.temperature
humidity = Int(weather.currentWeather.humidity * 100)
symbolName = weather.currentWeather.symbolName
let attribution = try await WeatherService.shared.attribution
attributionLink = attribution.legalPageURL
attributionLogo = colorScheme == .light ? attribution.combinedMarkLightURL : attribution.combinedMarkDarkURL
}
} catch {
print("Could not gather weather information...", error.localizedDescription)
condition = .clear
symbolName = "cloud.fill"
}
}
}
}
}
.ignoresSafeArea(.all, edges: [.top, .leading, .trailing])
.frame(idealWidth: bounds.size.width, minHeight: bounds.size.height / 1.65)
}
} else {
HStack {
}
.padding([.top], 20)
}
}
.edgesIgnoringSafeArea([.leading, .trailing])
.sheet(item: $waypointCoordinate, content: { wpc in
WaypointFormMapKit(coordinate: wpc)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.automatic)
})
.navigationBarTitle(String(node.user?.longName ?? "unknown".localized), displayMode: .inline)
.navigationBarItems(trailing:
ZStack {
ConnectedDevice(
bluetoothOn: bleManager.isSwitchedOn,
deviceConnected: bleManager.connectedPeripheral != nil,
name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
})
}
.padding(.bottom, 2)
}
}
}

View file

@ -0,0 +1,267 @@
//
// WaypointFormView.swift
// Meshtastic
//
// Copyright Garth Vander Houwen 1/10/23.
//
import SwiftUI
import CoreLocation
struct WaypointFormMapKit: View {
@EnvironmentObject var bleManager: BLEManager
@Environment(\.dismiss) private var dismiss
@State var coordinate: WaypointCoordinate
@FocusState private var iconIsFocused: Bool
@State private var name: String = ""
@State private var description: String = ""
@State private var icon: String = "📍"
@State private var latitude: Double = 0
@State private var longitude: Double = 0
@State private var expires: Bool = false
@State private var expire: Date = Date.now.addingTimeInterval(60 * 480) // 1 minute * 480 = 8 Hours
@State private var locked: Bool = false
@State private var lockedTo: Int64 = 0
var body: some View {
Form {
let distance = CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude).distance(from: CLLocation(latitude: coordinate.coordinate?.latitude ?? 0, longitude: coordinate.coordinate?.longitude ?? 0))
Section(header: Text((coordinate.waypointId > 0) ? "Editing Waypoint" : "Create Waypoint")) {
HStack {
Text("Location: \(String(format: "%.5f", latitude) + "," + String(format: "%.5f", longitude))")
.textSelection(.enabled)
.foregroundColor(Color.gray)
.font(.caption2)
if coordinate.coordinate?.latitude ?? 0 != 0 && coordinate.coordinate?.longitude ?? 0 != 0 {
DistanceText(meters: distance)
.foregroundColor(Color.gray)
.font(.caption2)
}
}
HStack {
Text("Name")
Spacer()
TextField(
"Name",
text: $name,
axis: .vertical
)
.foregroundColor(Color.gray)
.onChange(of: name, perform: { _ in
let totalBytes = name.utf8.count
// Only mess with the value if it is too big
if totalBytes > 30 {
let firstNBytes = Data(name.utf8.prefix(30))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the name back to the last place where it was the right size
name = maxBytesString
}
}
})
}
HStack {
Text("Description")
Spacer()
TextField(
"Description",
text: $description,
axis: .vertical
)
.foregroundColor(Color.gray)
.onChange(of: description, perform: { _ in
let totalBytes = description.utf8.count
// Only mess with the value if it is too big
if totalBytes > 100 {
let firstNBytes = Data(description.utf8.prefix(100))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the name back to the last place where it was the right size
description = maxBytesString
}
}
})
}
HStack {
Text("Icon")
Spacer()
EmojiOnlyTextField(text: $icon, placeholder: "Select an emoji")
.font(.title)
.focused($iconIsFocused)
.onChange(of: icon) { value in
// If you have anything other than emojis in your string make it empty
if !value.onlyEmojis() {
icon = ""
}
// If a second emoji is entered delete the first one
if value.count >= 1 {
if value.count > 1 {
let index = value.index(value.startIndex, offsetBy: 1)
icon = String(value[index])
}
iconIsFocused = false
}
}
}
Toggle(isOn: $expires) {
Label("Expires", systemImage: "clock.badge.xmark")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if expires {
DatePicker("Expire", selection: $expire, in: Date.now...)
.datePickerStyle(.compact)
.font(.callout)
}
Toggle(isOn: $locked) {
Label("Locked", systemImage: "lock")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
}
HStack {
Button {
var newWaypoint = Waypoint()
// Loading a waypoint from edit
if coordinate.waypointId > 0 {
newWaypoint.id = UInt32(coordinate.waypointId)
let waypoint = getWaypoint(id: Int64(coordinate.waypointId), context: bleManager.context!)
newWaypoint.latitudeI = waypoint.latitudeI
newWaypoint.longitudeI = waypoint.longitudeI
} else {
// New waypoint
newWaypoint.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
newWaypoint.latitudeI = Int32(Double(coordinate.coordinate?.latitude ?? 0) * 1e7)
newWaypoint.longitudeI = Int32(Double(coordinate.coordinate?.longitude ?? 0) * 1e7)
}
newWaypoint.name = name.count > 0 ? name : "Dropped Pin"
newWaypoint.description_p = description
// Unicode scalar value for the icon emoji string
let unicodeScalers = icon.unicodeScalars
// First element as an UInt32
let unicode = unicodeScalers[unicodeScalers.startIndex].value
newWaypoint.icon = unicode
if locked {
if lockedTo == 0 {
newWaypoint.lockedTo = UInt32(bleManager.connectedPeripheral!.num)
} else {
newWaypoint.lockedTo = UInt32(lockedTo)
}
}
if expires {
newWaypoint.expire = UInt32(expire.timeIntervalSince1970)
} else {
newWaypoint.expire = 0
}
if bleManager.sendWaypoint(waypoint: newWaypoint) {
dismiss()
} else {
dismiss()
print("Send waypoint failed")
}
} label: {
Label("Send", systemImage: "arrow.up")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.regular)
.disabled(bleManager.connectedPeripheral == nil)
.padding(.bottom)
Button(role: .cancel) {
dismiss()
} label: {
Label("cancel", systemImage: "x.circle")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.regular)
.padding(.bottom)
if coordinate.waypointId > 0 {
Menu {
Button("For me", action: {
let waypoint = getWaypoint(id: Int64(coordinate.waypointId), context: bleManager.context!)
bleManager.context!.delete(waypoint)
do {
try bleManager.context!.save()
} catch {
bleManager.context!.rollback()
}
dismiss() })
Button("For everyone", action: {
var newWaypoint = Waypoint()
if coordinate.waypointId > 0 {
newWaypoint.id = UInt32(coordinate.waypointId)
}
newWaypoint.name = name.count > 0 ? name : "Dropped Pin"
newWaypoint.description_p = description
newWaypoint.latitudeI = Int32(coordinate.coordinate?.latitude ?? 0 * 1e7)
newWaypoint.longitudeI = Int32(coordinate.coordinate?.longitude ?? 0 * 1e7)
// Unicode scalar value for the icon emoji string
let unicodeScalers = icon.unicodeScalars
// First element as an UInt32
let unicode = unicodeScalers[unicodeScalers.startIndex].value
newWaypoint.icon = unicode
if locked {
if lockedTo == 0 {
newWaypoint.lockedTo = UInt32(bleManager.connectedPeripheral!.num)
} else {
newWaypoint.lockedTo = UInt32(lockedTo)
}
}
newWaypoint.expire = 1
if bleManager.sendWaypoint(waypoint: newWaypoint) {
dismiss()
} else {
dismiss()
print("Send waypoint failed")
}
})
}
label: {
Label("delete", systemImage: "trash")
.foregroundColor(.red)
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.regular)
.padding(.bottom)
}
}
.onAppear {
if coordinate.waypointId > 0 {
let waypoint = getWaypoint(id: Int64(coordinate.waypointId), context: bleManager.context!)
name = waypoint.name ?? "Dropped Pin"
description = waypoint.longDescription ?? ""
icon = String(UnicodeScalar(Int(waypoint.icon)) ?? "📍")
latitude = Double(waypoint.latitudeI) / 1e7
longitude = Double(waypoint.longitudeI) / 1e7
if waypoint.expire != nil {
expires = true
expire = waypoint.expire ?? Date()
} else {
expires = false
}
if waypoint.locked > 0 {
locked = true
lockedTo = waypoint.locked
}
} else {
name = ""
description = ""
locked = false
expires = false
expire = Date.now.addingTimeInterval(60 * 480)
icon = "📍"
latitude = coordinate.coordinate?.latitude ?? 0
longitude = coordinate.coordinate?.longitude ?? 0
}
}
}
}