import CoreLocation import MapLibre import SwiftUI import UIKit /// A place the map should mark: an accent ring at its point and its name. struct MapMarker: Equatable { let coordinate: CLLocationCoordinate2D let label: String static func == (lhs: MapMarker, rhs: MapMarker) -> Bool { lhs.label == rhs.label && lhs.coordinate.latitude == rhs.coordinate.latitude && lhs.coordinate.longitude == rhs.coordinate.longitude } } /// Where the map should go. Each request carries an id so the view applies /// it once and leaves the camera to the reader afterwards. struct MapCameraRequest: Equatable { enum Move: Equatable { /// Frame every pin, no closer than `maxZoom`. case fit(maxZoom: Double) /// Fly to a point, no further out than `minZoom`. case fly(latitude: Double, longitude: Double, minZoom: Double) } let id = UUID() let move: Move } /// grain's own basemap — the same Protomaps tiles grain.social draws, styled /// by the same theme package — with grain's pins on it. /// /// A `UIViewRepresentable` over MapLibre's map view. The style JSON ships as /// two bundle resources generated by tools/basemap; see the note there for /// why the web assembles it at runtime and the app can't. struct GrainMapView: UIViewRepresentable { @Environment(\.colorScheme) private var colorScheme /// Every place, as clustered circles sized by count and zoom. var pins: [LocationPin] = [] /// The place on show, ringed and named over everything else. var marker: MapMarker? var camera: MapCameraRequest? /// Where the map opens, before any request: a point and a zoom. var initialCenter: (coordinate: CLLocationCoordinate2D, zoom: Double)? var interactive = true /// What the sheet and the bars cover, so the camera frames what is left. var contentInsets = UIEdgeInsets.zero var onSelectPin: ((LocationPin) -> Void)? func makeUIView(context: Context) -> MLNMapView { let mapView = MLNMapView(frame: .zero, styleURL: Self.styleURL(dark: colorScheme == .dark)) mapView.delegate = context.coordinator mapView.logoView.isHidden = true mapView.compassView.isHidden = true // ODbL requires attribution wherever the basemap is shown, and // `MapAttribution` draws it: the SDK's own button is placed relative // to the content insets the sheet imposes, which put it a third of // the way down the screen. mapView.attributionButton.isHidden = true mapView.allowsTilting = false mapView.allowsRotating = false if let initialCenter { mapView.setCenter(initialCenter.coordinate, zoomLevel: initialCenter.zoom, animated: false) } let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) // The map's own double tap zooms; a single tap must wait for it to fail // or every zoom would also open whatever was under the first tap. for recognizer in mapView.gestureRecognizers ?? [] where recognizer is UITapGestureRecognizer { tap.require(toFail: recognizer) } mapView.addGestureRecognizer(tap) context.coordinator.mapView = mapView return mapView } func updateUIView(_ mapView: MLNMapView, context: Context) { let coordinator = context.coordinator coordinator.parent = self let styleURL = Self.styleURL(dark: colorScheme == .dark) if mapView.styleURL != styleURL { // The style reload tears the layers down; `didFinishLoading` puts // them back. mapView.styleURL = styleURL } mapView.isUserInteractionEnabled = interactive mapView.isScrollEnabled = interactive mapView.isZoomEnabled = interactive mapView.contentInset = contentInsets coordinator.accent = UIColor(named: "AccentColor") ?? .systemBlue coordinator.syncData() coordinator.apply(camera) } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } static func styleURL(dark: Bool) -> URL { let name = dark ? "basemap-dark" : "basemap-light" guard let url = Bundle.main.url(forResource: name, withExtension: "json") else { preconditionFailure("\(name).json is missing from the bundle; run `just basemap-style`") } return url } @MainActor final class Coordinator: NSObject, MLNMapViewDelegate { var parent: GrainMapView weak var mapView: MLNMapView? var accent: UIColor = .systemBlue private var appliedCamera: UUID? private var pendingCamera: MapCameraRequest? private var styleReady = false static let placesSource = "places" static let markerSource = "place-marker" static let pointLayers: Set = ["place-hit-targets", "place-points"] static let clusterLayers: Set = ["cluster-hit-targets", "place-clusters"] init(parent: GrainMapView) { self.parent = parent } // MARK: - Style /// The delegate hands over the style it loaded, but the parameters /// aren't Sendable and this is called on the main thread anyway — so /// read the style back off the map view once inside the actor. nonisolated func mapView(_: MLNMapView, didFinishLoading _: MLNStyle) { MainActor.assumeIsolated { guard let style = self.mapView?.style else { return } styleReady = true install(in: style) syncData() if let pendingCamera { self.pendingCamera = nil apply(pendingCamera) } } } /// The web's LocationsMap, layer for layer. Tuned on the web against /// the real distribution — ~700 places, concentrated in North America /// and Europe: tight enough that a continent is several clusters /// rather than one dot, wide enough that they don't overlap at world /// zoom. Past 11 the places have separated enough to stand alone. private func install(in style: MLNStyle) { let places = MLNShapeSource( identifier: Self.placesSource, shape: MLNShapeCollectionFeature(shapes: []), options: [.clustered: true, .clusterRadius: 45, .maximumZoomLevelForClustering: 11] ) style.addSource(places) let marker = MLNShapeSource(identifier: Self.markerSource, shape: MLNShapeCollectionFeature(shapes: []), options: nil) style.addSource(marker) let isCluster = NSPredicate(format: "cluster == YES") let isPlace = NSPredicate(format: "cluster != YES") let white = NSExpression(forConstantValue: UIColor.white) // A tap lands on whatever is drawn under it and nothing more, and // a 3pt dot is not a target. These two layers are the same pins // drawn invisibly at finger size, beneath the visible ones: a tap // that hits a dot is answered by the layer on top, and a near // miss by these. for (id, predicate) in [("place-hit-targets", isPlace), ("cluster-hit-targets", isCluster)] { let hit = MLNCircleStyleLayer(identifier: id, source: places) hit.predicate = predicate hit.circleOpacity = NSExpression(forConstantValue: 0) hit.circleRadius = NSExpression(forConstantValue: 22) style.addLayer(hit) } let clusters = MLNCircleStyleLayer(identifier: "place-clusters", source: places) clusters.predicate = isCluster clusters.circleColor = NSExpression(forConstantValue: accent) clusters.circleOpacity = NSExpression(forConstantValue: 0.85) clusters.circleRadius = Self.zoomScaled( by: "point_count", atWorld: (5, [5: 7, 20: 9, 60: 12]), atSix: (10, [5: 14, 20: 18, 60: 24]) ) clusters.circleStrokeColor = white clusters.circleStrokeWidth = NSExpression(forConstantValue: 1.5) clusters.circleStrokeOpacity = NSExpression(forConstantValue: 0.85) style.addLayer(clusters) let points = MLNCircleStyleLayer(identifier: "place-points", source: places) points.predicate = isPlace points.circleColor = NSExpression(forConstantValue: accent) points.circleRadius = Self.zoomScaled( by: "count", atWorld: (3, [5: 4, 25: 5, 100: 6]), atSix: (6, [5: 8, 25: 10, 100: 12]) ) points.circleStrokeColor = white points.circleStrokeWidth = NSExpression(forConstantValue: 1.5) points.circleStrokeOpacity = NSExpression(forConstantValue: 0.85) style.addLayer(points) // The picked place, ringed and named, over everything else. The // basemap labels a town by its size and the zoom — a small town // has no label at city scale — so the name is ours, placed over // everything and exempt from the basemap's collision rules. let ring = MLNCircleStyleLayer(identifier: "place-marker-ring", source: marker) ring.circleColor = NSExpression(forConstantValue: accent) ring.circleOpacity = NSExpression(forConstantValue: 0.25) ring.circleRadius = NSExpression(forConstantValue: 11) ring.circleStrokeColor = NSExpression(forConstantValue: accent) ring.circleStrokeWidth = NSExpression(forConstantValue: 2) style.addLayer(ring) let label = MLNSymbolStyleLayer(identifier: "place-marker-label", source: marker) label.text = NSExpression(forKeyPath: "name") // The theme's own face, so the name reads as part of the map. label.textFontNames = NSExpression(forConstantValue: ["Noto Sans Medium"]) label.textFontSize = NSExpression(forConstantValue: 14) label.textColor = NSExpression(forConstantValue: UIColor.label) label.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) label.textHaloWidth = NSExpression(forConstantValue: 1.5) label.textAnchor = NSExpression(forConstantValue: "top") label.textOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: 1))) label.textAllowsOverlap = NSExpression(forConstantValue: true) label.textIgnoresPlacement = NSExpression(forConstantValue: true) style.addLayer(label) } /// A radius that steps with a count and grows with zoom: a circle that /// reads well over one city is a blot when the whole world is on screen. private static func zoomScaled( by attribute: String, atWorld: (base: Double, stops: [Double: Double]), atSix: (base: Double, stops: [Double: Double]) ) -> NSExpression { let world = NSExpression(format: "mgl_step:from:stops:(%K, %@, %@)", attribute, NSNumber(value: atWorld.base), atWorld.stops) let six = NSExpression(format: "mgl_step:from:stops:(%K, %@, %@)", attribute, NSNumber(value: atSix.base), atSix.stops) return NSExpression( format: "mgl_interpolate:withCurveType:parameters:stops:($zoomLevel, 'linear', nil, %@)", [0: world, 6: six] ) } // MARK: - Data func syncData() { guard styleReady, let style = mapView?.style else { return } if let places = style.source(withIdentifier: Self.placesSource) as? MLNShapeSource { places.shape = MLNShapeCollectionFeature(shapes: parent.pins.map { pin in let feature = MLNPointFeature() feature.coordinate = pin.coordinate feature.attributes = ["name": pin.name, "h3": pin.h3Index, "count": pin.galleryCount] return feature }) } if let marker = style.source(withIdentifier: Self.markerSource) as? MLNShapeSource { let shapes: [MLNShape] = parent.marker.map { marker in let feature = MLNPointFeature() feature.coordinate = marker.coordinate feature.attributes = ["name": marker.label] return [feature] } ?? [] marker.shape = MLNShapeCollectionFeature(shapes: shapes) } } // MARK: - Camera func apply(_ request: MapCameraRequest?) { guard let request, request.id != appliedCamera, let mapView else { return } guard styleReady else { pendingCamera = request return } appliedCamera = request.id switch request.move { case let .fit(maxZoom): guard let bounds = Self.bounds(of: parent.pins) else { return } mapView.setVisibleCoordinateBounds(bounds, edgePadding: UIEdgeInsets(top: 40, left: 40, bottom: 40, right: 40), animated: false) if mapView.zoomLevel > maxZoom { mapView.setCenter(mapView.centerCoordinate, zoomLevel: maxZoom, animated: false) } case let .fly(latitude, longitude, minZoom): let target = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) mapView.setCenter(target, zoomLevel: max(mapView.zoomLevel, minZoom), animated: true) } } private static func bounds(of pins: [LocationPin]) -> MLNCoordinateBounds? { guard let first = pins.first else { return nil } var sw = first.coordinate var ne = first.coordinate for pin in pins.dropFirst() { sw.latitude = min(sw.latitude, pin.coordinate.latitude) sw.longitude = min(sw.longitude, pin.coordinate.longitude) ne.latitude = max(ne.latitude, pin.coordinate.latitude) ne.longitude = max(ne.longitude, pin.coordinate.longitude) } return MLNCoordinateBounds(sw: sw, ne: ne) } // MARK: - Taps @objc func handleTap(_ gesture: UITapGestureRecognizer) { guard let mapView, parent.interactive else { return } let point = gesture.location(in: mapView) if let feature = mapView.visibleFeatures(at: point, styleLayerIdentifiers: Self.pointLayers).first, let cell = feature.attribute(forKey: "h3") as? String, let name = feature.attribute(forKey: "name") as? String { let count = (feature.attribute(forKey: "count") as? NSNumber)?.intValue ?? 0 let coordinate = (feature as? MLNPointFeature)?.coordinate ?? mapView.convert(point, toCoordinateFrom: mapView) parent.onSelectPin?(LocationPin(name: name, h3Index: cell, galleryCount: count, coordinate: coordinate)) return } // A cluster tap zooms in on it, rather than opening anything. if let cluster = mapView.visibleFeatures(at: point, styleLayerIdentifiers: Self.clusterLayers).first as? MLNPointFeature { mapView.setCenter(cluster.coordinate, zoomLevel: min(mapView.zoomLevel + 2, 12), animated: true) } } } } /// "Nazaré, Leiria, PT" -> "Nazaré": the part of a place name the map wants. func localityOf(_ name: String) -> String { let locality = name.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false).first .map { $0.trimmingCharacters(in: .whitespaces) } ?? "" return locality.isEmpty ? name : locality } /// The attribution ODbL asks for wherever the basemap is shown. Small, out of /// the way, and a link to the licence. struct MapAttribution: View { var body: some View { Link(destination: URL(string: "https://www.openstreetmap.org/copyright")!) { Text("© OpenStreetMap") .font(.system(size: 10)) .foregroundStyle(.secondary) .padding(.horizontal, 6) .padding(.vertical, 3) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 4)) } .accessibilityLabel("OpenStreetMap copyright") } }