1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
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")
}
}