Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions Sources/MapConductorCore/MapCollectionItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public struct Markers: MapOverlayItemProtocol {
}

public func append(to content: inout MapViewContent) {
content.markers.append(contentsOf: states.map { Marker(state: $0) })
let items = states.map { Marker(state: $0) }
if content.markers.isEmpty { content.markers = items } else { content.markers.append(contentsOf: items) }
}
}

Expand All @@ -25,7 +26,8 @@ public struct Circles: MapOverlayItemProtocol {
}

public func append(to content: inout MapViewContent) {
content.circles.append(contentsOf: states.map { Circle(state: $0) })
let items = states.map { Circle(state: $0) }
if content.circles.isEmpty { content.circles = items } else { content.circles.append(contentsOf: items) }
}
}

Expand All @@ -37,7 +39,8 @@ public struct Polylines: MapOverlayItemProtocol {
}

public func append(to content: inout MapViewContent) {
content.polylines.append(contentsOf: states.map { Polyline(state: $0) })
let items = states.map { Polyline(state: $0) }
if content.polylines.isEmpty { content.polylines = items } else { content.polylines.append(contentsOf: items) }
}
}

Expand All @@ -49,7 +52,8 @@ public struct Polygons: MapOverlayItemProtocol {
}

public func append(to content: inout MapViewContent) {
content.polygons.append(contentsOf: states.map { Polygon(state: $0) })
let items = states.map { Polygon(state: $0) }
if content.polygons.isEmpty { content.polygons = items } else { content.polygons.append(contentsOf: items) }
}
}

Expand All @@ -61,7 +65,8 @@ public struct GroundImages: MapOverlayItemProtocol {
}

public func append(to content: inout MapViewContent) {
content.groundImages.append(contentsOf: states.map { GroundImage(state: $0) })
let items = states.map { GroundImage(state: $0) }
if content.groundImages.isEmpty { content.groundImages = items } else { content.groundImages.append(contentsOf: items) }
}
}

Expand Down
31 changes: 23 additions & 8 deletions Sources/MapConductorCore/MapViewContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,18 @@ public struct MapViewContent {
}

mutating func merge(_ other: MapViewContent) {
markers.append(contentsOf: other.markers)
infoBubbles.append(contentsOf: other.infoBubbles)
polylines.append(contentsOf: other.polylines)
polygons.append(contentsOf: other.polygons)
circles.append(contentsOf: other.circles)
groundImages.append(contentsOf: other.groundImages)
rasterLayers.append(contentsOf: other.rasterLayers)
views.append(contentsOf: other.views)
// Into an empty list the other list is taken as it is (one shared buffer)
// rather than copied element by element: the content is rebuilt on every
// camera frame, and a copy of 100,000 overlays is a retain and a release
// of each.
Self.append(other.markers, to: &markers)
Self.append(other.infoBubbles, to: &infoBubbles)
Self.append(other.polylines, to: &polylines)
Self.append(other.polygons, to: &polygons)
Self.append(other.circles, to: &circles)
Self.append(other.groundImages, to: &groundImages)
Self.append(other.rasterLayers, to: &rasterLayers)
Self.append(other.views, to: &views)
polygonSyncHandlers.append(contentsOf: other.polygonSyncHandlers)
// An explicit setting wins over an unset one, whether it enables tiling or not;
// between two explicit settings the later one wins.
Expand All @@ -70,13 +74,24 @@ public struct MapViewContent {
}
}

extension MapViewContent {
fileprivate static func append<T>(_ items: [T], to list: inout [T]) {
if list.isEmpty {
list = items
} else {
list.append(contentsOf: items)
}
}
}

@resultBuilder
public enum MapViewContentBuilder {
public static func buildBlock() -> MapViewContent {
MapViewContent()
}

public static func buildBlock(_ components: MapViewContent...) -> MapViewContent {
if components.count == 1 { return components[0] }
var content = MapViewContent()
for component in components {
content.merge(component)
Expand Down
31 changes: 28 additions & 3 deletions Sources/MapConductorCore/overlay/OverlayCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ public final class OverlayCollector<S: OverlayCollectableState> {

/// Diff the full union list against the current contents and apply changes.
public func sync(_ states: [S]) {
// SwiftUI re-evaluates the map's content on every camera frame and hands
// back the very same list. Diffing it costs a set, a dictionary and a
// listener per state: 260 ms a frame for 100,000 parcels on an iPad Pro,
// which made every pan run at 5 fps. The same instances in the same order
// cannot have changed membership, and their listeners are already in place.
if isSameList(states) { return }
let newIds = Set(states.map { $0.id })
let oldIds = Set(statesById.keys)
var membershipChanged = newIds != oldIds
Expand Down Expand Up @@ -115,9 +121,28 @@ public final class OverlayCollector<S: OverlayCollectableState> {
}
listening = next
for state in states {
state.mutations.listen { [weak self, weak state] in
guard let self, let state, self.statesById[state.id] === state else { return }
self.scheduleUpdate(state)
// The state is found again by id rather than held weakly. A weak
// reference gives the object a side table, and from then on every
// retain and release of it takes the slow path: with 100,000 polygons
// rebuilt into the map content on every camera frame, that was a
// third of a pan's main-thread time on the iPad.
let id = state.id
let identity = ObjectIdentifier(state)
state.mutations.listen { [weak self] in
guard let self, let current = self.statesById[id], ObjectIdentifier(current) == identity else { return }
self.scheduleUpdate(current)
}
}
}

/// The same instances in the same order. `S` is a class, so each element is
/// one reference and the two buffers can be compared as bytes: element by
/// element, through the generic, it cost 14 ms for 100,000 states on the iPad.
private func isSameList(_ states: [S]) -> Bool {
guard states.count == latest.count, !states.isEmpty else { return false }
return states.withUnsafeBytes { a in
latest.withUnsafeBytes { b in
a.count == b.count && memcmp(a.baseAddress!, b.baseAddress!, a.count) == 0
}
}
}
Expand Down
60 changes: 58 additions & 2 deletions Sources/MapConductorCore/polygon/PolygonController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ where Renderer.ActualPolygon == ActualPolygon {

public var clickListener: ((PolygonEvent) -> Void)?

/// Set by a provider that can show polygon tiles (see ``PolygonTiler``).
/// Polygons it tiles stay in ``polygonManager``, for taps, with no native
/// overlay: their entity's `polygon` is nil and the renderer never sees them.
@_spi(MapConductorDriver)
public var tiler: PolygonTiler?
private var tiledIds: Set<String> = []

public init(
polygonManager: PolygonManager<ActualPolygon>,
renderer: Renderer,
Expand All @@ -34,7 +41,16 @@ where Renderer.ActualPolygon == ActualPolygon {
if polygonManager.isDestroyed { return }
await semaphore.withPermit {
if polygonManager.isDestroyed { return }
var previous = Set(polygonManager.allEntities().map { $0.state.id })
let wasTiled = tiledIds
let tiled = await tiler?.route(data) ?? []
tiledIds = tiled
let data = tiled.isEmpty ? data : data.filter { !tiled.contains($0.id) }
// Only natively drawn polygons are diffed against the renderer.
var previous = Set(
polygonManager.allEntities()
.map { $0.state.id }
.filter { !wasTiled.contains($0) }
)
var added: [PolygonOverlayAddParams] = []
var updated: [PolygonOverlayChangeParams<ActualPolygon>] = []
var removed: [PolygonEntity<ActualPolygon>] = []
Expand Down Expand Up @@ -93,6 +109,21 @@ where Renderer.ActualPolygon == ActualPolygon {
}
}

// Tiled polygons: kept for taps, nothing native. Those that left the
// tiles and the list are forgotten; those that left the tiles for
// native drawing were added above.
if !wasTiled.isEmpty {
let nativeIds = Set(data.map(\.id))
for id in wasTiled where !tiled.contains(id) && !nativeIds.contains(id) {
_ = polygonManager.removeEntity(id)
}
}
if !tiled.isEmpty, let tiler {
for state in tiler.tiledStates() {
polygonManager.registerEntity(PolygonEntity(polygon: nil, state: state))
}
}

await renderer.onPostProcess()
}
}
Expand All @@ -101,6 +132,27 @@ where Renderer.ActualPolygon == ActualPolygon {
if polygonManager.isDestroyed { return }
await semaphore.withPermit {
guard let prevEntity = polygonManager.getEntity(state.id) else { return }
if tiledIds.contains(state.id) {
if PolygonTileSet.canTile(state) {
polygonManager.registerEntity(PolygonEntity(polygon: nil, state: state))
tiler?.update(state)
return
}
// No longer drawable in a tile (geodesic, half the world wide, or
// under 3 points): the map SDK draws it from now on. Still on the
// map, so this is not an add. Whether the rest stay in tiles is
// left to the next add, which decides tiling for the whole list.
tiledIds.remove(state.id)
tiler?.remove(state.id)
let polygons = await renderer.onAdd(data: [PolygonOverlayAddParams(state: state)])
if polygons.count == 1, let actualPolygon = polygons[0] {
polygonManager.registerEntity(PolygonEntity(polygon: actualPolygon, state: state))
} else {
_ = polygonManager.removeEntity(state.id)
}
await renderer.onPostProcess()
return
}
let currentFinger = state.fingerPrint()
let prevFinger = prevEntity.fingerPrint
if currentFinger == prevFinger { return }
Expand All @@ -120,7 +172,10 @@ where Renderer.ActualPolygon == ActualPolygon {
open func clear() async {
if polygonManager.isDestroyed { return }
await semaphore.withPermit {
let entities = polygonManager.allEntities()
let tiled = tiledIds
tiledIds = []
await tiler?.clear()
let entities = polygonManager.allEntities().filter { !tiled.contains($0.state.id) }
await renderer.onRemove(data: entities)
// manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は remove では何もせず、
// onPostProcess で manager の全部を描き直すので、逆の順だと clear() 後も地図に残る。
Expand All @@ -136,6 +191,7 @@ where Renderer.ActualPolygon == ActualPolygon {
open func onCameraChanged(mapCameraPosition: MapCameraPosition) async {}

open func destroy() {
tiler?.dispose()
polygonManager.destroy()
}
// ── SlottedOverlayController ────────────────────────────────────────
Expand Down
44 changes: 44 additions & 0 deletions Sources/MapConductorCore/polygon/PolygonEntity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,53 @@ public final class PolygonEntity<ActualPolygon>: PolygonEntityProtocol {
public let state: PolygonState
public let fingerPrint: PolygonFingerPrint

/// The outline's latitude range and, when it spans less than half the world,
/// its longitude range; read once here so a tap need not read every state.
let hitBounds: PolygonHitBounds

public init(polygon: ActualPolygon?, state: PolygonState) {
self.polygon = polygon
self.state = state
self.fingerPrint = state.fingerPrint()
self.hitBounds = PolygonHitBounds(state)
}
}

/// Where a tap can possibly hit a polygon, for skipping the rest cheaply.
struct PolygonHitBounds {
let minLat: Double
let maxLat: Double
/// Nil when the longitudes cannot be compared directly (a geodesic or very wide polygon).
let lng: (min: Double, max: Double)?

init(_ state: PolygonState) {
let points = state.points
guard !state.geodesic, !points.isEmpty else {
// A geodesic edge bulges past its vertices' latitudes; test it every time.
minLat = -.infinity
maxLat = .infinity
lng = nil
return
}
var minLat = Double.infinity, maxLat = -Double.infinity
var minLng = Double.infinity, maxLng = -Double.infinity
for p in points {
minLat = min(minLat, p.latitude); maxLat = max(maxLat, p.latitude)
minLng = min(minLng, p.longitude); maxLng = max(maxLng, p.longitude)
}
self.minLat = minLat
self.maxLat = maxLat
self.lng = maxLng - minLng < 180 ? (minLng, maxLng) : nil
}

/// False only when the point is certainly outside (with the hit test's own tolerance).
func mayContain(latitude: Double, longitude: Double) -> Bool {
let eps = 1e-6
if latitude < minLat - eps || latitude > maxLat + eps { return false }
guard let lng else { return true }
for x in [longitude, longitude - 360, longitude + 360] where x >= lng.min - eps && x <= lng.max + eps {
return true
}
return false
}
}
23 changes: 16 additions & 7 deletions Sources/MapConductorCore/polygon/PolygonManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,13 @@ public final class PolygonManager<ActualPolygon>: PolygonManagerProtocol {
let testX = normalizeLng(position.longitude)
let testY = position.latitude

let sorted = allEntities().sorted { $0.state.zIndex > $1.state.zIndex }
for entity in sorted {
// Only the polygons whose bounds hold the point are tested, in zIndex
// order. Reading every state's zIndex and points per tap is what this
// avoids: with 100,000 tiled parcels that was most of a second.
let candidates = allEntities()
.filter { $0.hitBounds.mayContain(latitude: testY, longitude: testX) }
.sorted { $0.state.zIndex > $1.state.zIndex }
for entity in candidates {
let state = entity.state
let basePoints = state.points
if basePoints.count < 3 { continue }
Expand Down Expand Up @@ -200,13 +205,17 @@ public final class PolygonManager<ActualPolygon>: PolygonManagerProtocol {
by: Double,
eps: Double
) -> Bool {
// 線分までの距離が eps(度)以内なら辺上とみなす。
// 以前は外積と内積を eps と直接比べていた。内積は度の 2 乗なので、
// `(p-a)·(p-b) <= 1e-6` は「辺の中点から約 0.001°(~100 m)以内」を意味し、
// 短い辺を持つ建物ほど、離れたタップを辺上として拾っていた。
let dx = bx - ax
let dy = by - ay
let cross = dx * (py - ay) - dy * (px - ax)
let segLen = sqrt(dx * dx + dy * dy)
if abs(cross) > eps * max(1.0, segLen) { return false }
let dot = (px - ax) * (px - bx) + (py - ay) * (py - by)
return dot <= eps * max(1.0, segLen)
let lenSq = dx * dx + dy * dy
let t = lenSq > 0 ? min(1.0, max(0.0, ((px - ax) * dx + (py - ay) * dy) / lenSq)) : 0
let ex = px - (ax + t * dx)
let ey = py - (ay + t * dy)
return ex * ex + ey * ey <= eps * eps
}

private func unwrapLongitudesAround(points: [GeoPointProtocol], refLng: Double) -> [(Double, Double)] {
Expand Down
Loading
Loading