~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
352 lines · 16.3 KB Raw History
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<String> = ["place-hit-targets", "place-points"]
        static let clusterLayers: Set<String> = ["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")
    }
}