diff --git a/Sources/MapConductorCore/MapCollectionItems.swift b/Sources/MapConductorCore/MapCollectionItems.swift index 8d4207c..8e5e5c4 100644 --- a/Sources/MapConductorCore/MapCollectionItems.swift +++ b/Sources/MapConductorCore/MapCollectionItems.swift @@ -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) } } } @@ -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) } } } @@ -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) } } } @@ -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) } } } @@ -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) } } } diff --git a/Sources/MapConductorCore/MapViewContent.swift b/Sources/MapConductorCore/MapViewContent.swift index 57353e7..4b484a2 100644 --- a/Sources/MapConductorCore/MapViewContent.swift +++ b/Sources/MapConductorCore/MapViewContent.swift @@ -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. @@ -70,6 +74,16 @@ public struct MapViewContent { } } +extension MapViewContent { + fileprivate static func append(_ 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 { @@ -77,6 +91,7 @@ public enum MapViewContentBuilder { } public static func buildBlock(_ components: MapViewContent...) -> MapViewContent { + if components.count == 1 { return components[0] } var content = MapViewContent() for component in components { content.merge(component) diff --git a/Sources/MapConductorCore/overlay/OverlayCollector.swift b/Sources/MapConductorCore/overlay/OverlayCollector.swift index a1a4cd2..4da5d00 100644 --- a/Sources/MapConductorCore/overlay/OverlayCollector.swift +++ b/Sources/MapConductorCore/overlay/OverlayCollector.swift @@ -84,6 +84,12 @@ public final class OverlayCollector { /// 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 @@ -115,9 +121,28 @@ public final class OverlayCollector { } 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 } } } diff --git a/Sources/MapConductorCore/polygon/PolygonController.swift b/Sources/MapConductorCore/polygon/PolygonController.swift index b7fb270..1fab4e5 100644 --- a/Sources/MapConductorCore/polygon/PolygonController.swift +++ b/Sources/MapConductorCore/polygon/PolygonController.swift @@ -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 = [] + public init( polygonManager: PolygonManager, renderer: Renderer, @@ -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] = [] var removed: [PolygonEntity] = [] @@ -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() } } @@ -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 } @@ -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() 後も地図に残る。 @@ -136,6 +191,7 @@ where Renderer.ActualPolygon == ActualPolygon { open func onCameraChanged(mapCameraPosition: MapCameraPosition) async {} open func destroy() { + tiler?.dispose() polygonManager.destroy() } // ── SlottedOverlayController ──────────────────────────────────────── diff --git a/Sources/MapConductorCore/polygon/PolygonEntity.swift b/Sources/MapConductorCore/polygon/PolygonEntity.swift index fe294f2..1543d6c 100644 --- a/Sources/MapConductorCore/polygon/PolygonEntity.swift +++ b/Sources/MapConductorCore/polygon/PolygonEntity.swift @@ -10,9 +10,53 @@ public final class PolygonEntity: 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 } } diff --git a/Sources/MapConductorCore/polygon/PolygonManager.swift b/Sources/MapConductorCore/polygon/PolygonManager.swift index 76ccbad..fd4f70a 100644 --- a/Sources/MapConductorCore/polygon/PolygonManager.swift +++ b/Sources/MapConductorCore/polygon/PolygonManager.swift @@ -89,8 +89,13 @@ public final class PolygonManager: 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 } @@ -200,13 +205,17 @@ public final class PolygonManager: 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)] { diff --git a/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift b/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift new file mode 100644 index 0000000..107cdc6 --- /dev/null +++ b/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift @@ -0,0 +1,576 @@ +import CoreGraphics +import Foundation +import UIKit + +/// One polygon as the tile renderer takes it: plain values, read from a +/// ``PolygonState`` once, so drawing never touches the state again. +struct PolygonTileShape { + /// Rings as flat `[lat, lng, lat, lng, ...]`; the first is the outline, the rest holes. + var rings: [[Double]] + var fill: CGColor? + var stroke: CGColor? + var strokeWidth: Double + + init(rings: [[Double]], fill: CGColor?, stroke: CGColor?, strokeWidth: Double) { + self.rings = rings + self.fill = fill + self.stroke = stroke + self.strokeWidth = strokeWidth + } + + init(state: PolygonState) { + let flat: ([GeoPointProtocol]) -> [Double] = { ring in + var out = [Double]() + out.reserveCapacity(ring.count * 2) + for p in ring { + out.append(p.latitude) + out.append(p.longitude) + } + return out + } + var rings = [flat(state.points)] + for hole in state.holes { rings.append(flat(hole)) } + self.init( + rings: rings, + fill: state.fillColor.cgColor, + stroke: state.strokeColor.cgColor, + strokeWidth: state.strokeWidth + ) + } +} + +/** + Draws a set of polygons into raster tiles. + + The native counterpart of the "Polygon tiles" section of js-sdk-core's + `tile-sw.js`, and drawn the same way: + + - Every polygon is kept in Web Mercator coordinates in [0, 1], its outline + wound one way and its holes the other, so a nonzero ("winding") fill leaves + out the union of the holes without computing it. + - Polygons are found through a grid of their bounding boxes (one z12 tile per + cell); one covering too many cells is kept in a list every query checks. + - Each zoom gets its own simplified copy of a polygon, built the first time a + tile at that zoom needs it: vertices snapped to that zoom's pixel grid, + repeats dropped, Douglas-Peucker at half a pixel, and a polygon smaller than + a pixel drawn as one pixel. A few zooms are kept. + - The outline only is stroked, and not at all for a polygon under 4 px on + screen. Tiles query with a margin of the widest outline. + - Draw order is the order given, which the caller sorted by zIndex. + + Taps never see any of this; they are resolved against the original shapes. + + Tiles are cached without a version. A change (``update(_:)``) drops only the + cached tiles the changed polygons overlap, so the map, which must ask again + because the URL's version changed, gets every other tile from the cache. + */ +final class PolygonTileRenderer: TileProvider { + /// Cells of the polygon index, in Web Mercator units: one z12 tile. + static let cellSize = 1.0 / 4096.0 + /// A polygon covering more cells than this is kept in a list every query checks. + static let maxCells = 256 + /// Simplified copies kept, most recent zooms first. + static let zoomsKept = 4 + static let dpTolerancePx = 0.5 + /// Below this size on screen a polygon's outline is left out: a 1 px line + /// round a 3 px parcel is the whole parcel, and a city of them zoomed out + /// turns into the outline colour. + static let minOutlinedPx = 4.0 + + /// Logical tile size (points), the grid the map asks tiles on. + let tileSize: Int + /// Pixels per point in the drawn tile. + let pixelRatio: Int + + // MARK: Polygons + + final class Polygon { + /// Mercator rings, flat `[x, y, ...]`; outline positive area, holes negative. + let rings: [[Double]] + let minX: Double, minY: Double, maxX: Double, maxY: Double + let fill: CGColor? + let stroke: CGColor? + let strokeWidth: Double + + init?(_ shape: PolygonTileShape) { + var rings: [[Double]] = [] + for (r, latLngs) in shape.rings.enumerated() { + var ring = PolygonTileRenderer.toMercator(latLngs) + if ring.count < 6 { + if r == 0 { return nil } + continue + } + let area = PolygonTileRenderer.signedArea(ring) + if (r == 0 && area < 0) || (r > 0 && area > 0) { ring = PolygonTileRenderer.reversed(ring) } + rings.append(ring) + } + let outer = rings[0] + var minX = Double.infinity, minY = Double.infinity, maxX = -Double.infinity, maxY = -Double.infinity + var i = 0 + while i < outer.count { + minX = Swift.min(minX, outer[i]); maxX = Swift.max(maxX, outer[i]) + minY = Swift.min(minY, outer[i + 1]); maxY = Swift.max(maxY, outer[i + 1]) + i += 2 + } + self.rings = rings + self.minX = minX; self.minY = minY; self.maxX = maxX; self.maxY = maxY + self.fill = PolygonTileRenderer.isVisible(shape.fill) ? shape.fill : nil + self.stroke = shape.strokeWidth > 0 && PolygonTileRenderer.isVisible(shape.stroke) ? shape.stroke : nil + self.strokeWidth = shape.strokeWidth + } + + func sameBox(_ other: Polygon) -> Bool { + minX == other.minX && minY == other.minY && maxX == other.maxX && maxY == other.maxY + } + } + + struct Index { + var cells: [Int64: [Int32]] = [:] + var large: [Int32] = [] + + init(_ polygons: [Polygon?]) { + for (i, p) in polygons.enumerated() { + guard let p else { continue } + let cx0 = Int64(floor(p.minX / PolygonTileRenderer.cellSize)) + let cx1 = Int64(floor(p.maxX / PolygonTileRenderer.cellSize)) + let cy0 = Int64(floor(p.minY / PolygonTileRenderer.cellSize)) + let cy1 = Int64(floor(p.maxY / PolygonTileRenderer.cellSize)) + if (cx1 - cx0 + 1) * (cy1 - cy0 + 1) > Int64(PolygonTileRenderer.maxCells) { + large.append(Int32(i)) + continue + } + for cx in cx0...cx1 { + for cy in cy0...cy1 { + cells[cx &* 1_048_576 &+ cy, default: []].append(Int32(i)) + } + } + } + } + } + + /// What is drawn; replaced as a whole (copy-on-write) so a tile being drawn + /// keeps the version it started with. + private struct Data { + var polygons: [Polygon?] = [] + var index = Index([]) + var maxStrokePx = 0.0 + /// Bumped by every change, so a tile drawn before it is not cached after it. + var generation = 0 + } + + /// A polygon as drawn at one zoom. + enum Simplified { + case none + /// Under a pixel: one pixel at this world-pixel position. + case dot(Double, Double) + /// World-pixel rings, the first the outline. + case rings([[Double]], outlined: Bool) + } + + private final class ZoomCache { + var entries: [Simplified?] + init(count: Int) { entries = Array(repeating: nil, count: count) } + } + + private struct TileKey: Hashable { + let z: Int, x: Int, y: Int, ratio: Int + } + + private let lock = NSLock() + private var data = Data() + /// Per zoom key (`z`), most recently used last. + private var simplified: [(zoom: Int, cache: ZoomCache)] = [] + private var tiles: [TileKey: Foundation.Data] = [:] + private var tileOrder: [TileKey] = [] + private var tileBytes = 0 + private let tileCacheBytes: Int + + init(tileSize: Int = 256, pixelRatio: Int = 1, tileCacheBytes: Int = 24 * 1024 * 1024) { + self.tileSize = tileSize + self.pixelRatio = max(1, pixelRatio) + self.tileCacheBytes = tileCacheBytes + } + + // MARK: Changes + + /// Replaces every polygon. Draw order is the order of `shapes`. + func setPolygons(_ shapes: [PolygonTileShape]) { + let polygons = shapes.map { Polygon($0) } + var maxStroke = 0.0 + for p in polygons { if let p, p.stroke != nil { maxStroke = max(maxStroke, p.strokeWidth) } } + let index = Index(polygons) + lock.lock() + data = Data(polygons: polygons, index: index, maxStrokePx: maxStroke, generation: data.generation + 1) + simplified.removeAll() + dropAllTilesLocked() + lock.unlock() + } + + /// Replaces the polygons at the given positions (as passed to ``setPolygons(_:)``); + /// a nil shape empties the slot (a polygon taken out of the tiles). + /// Only the cached tiles those polygons overlap, before or after, are dropped. + func update(_ changes: [(index: Int, shape: PolygonTileShape?)]) { + guard !changes.isEmpty else { return } + let decoded = changes.map { (index: $0.index, polygon: $0.shape.flatMap { Polygon($0) }) } + lock.lock() + defer { lock.unlock() } + var next = data + next.generation += 1 + var touched: [Polygon] = [] + var moved = false + for change in decoded where change.index >= 0 && change.index < next.polygons.count { + let before = next.polygons[change.index] + let after = change.polygon + next.polygons[change.index] = after + for zoom in simplified { zoom.cache.entries[change.index] = nil } + if let before { touched.append(before) } + if let after { + touched.append(after) + if after.stroke != nil { next.maxStrokePx = max(next.maxStrokePx, after.strokeWidth) } + } + // A shape that moved needs the index rebuilt; a restyle does not. + if (before == nil) != (after == nil) { moved = true } + if let before, let after, !before.sameBox(after) { moved = true } + } + if moved { next.index = Index(next.polygons) } + data = next + dropTilesLocked(overlapping: touched, strokePx: next.maxStrokePx) + } + + func clear() { + lock.lock() + data = Data(generation: data.generation + 1) + simplified.removeAll() + dropAllTilesLocked() + lock.unlock() + } + + // MARK: TileProvider + + func renderTile(request: TileRequest) -> Foundation.Data? { + (try? renderTile(request: request, isCancelled: { false })) ?? nil + } + + func renderTile(request: TileRequest, isCancelled: () -> Bool) throws -> Foundation.Data? { + let z = request.z + let worldTiles = 1 << z + guard z >= 0, z < 31, request.y >= 0, request.y < worldTiles else { return nil } + let x = ((request.x % worldTiles) + worldTiles) % worldTiles + let ratio = request.pixelRatio > 1 ? request.pixelRatio : pixelRatio + let key = TileKey(z: z, x: x, y: request.y, ratio: ratio) + + lock.lock() + if let cached = tiles[key] { + lock.unlock() + return cached + } + let snapshot = data + lock.unlock() + + let outcome = try draw(snapshot, z: z, x: x, y: request.y, ratio: ratio, isCancelled: isCancelled) + + guard let png = outcome else { return nil } + lock.lock() + // The polygons may have changed while this tile was drawn. + if data.generation == snapshot.generation { storeTileLocked(key, png) } + lock.unlock() + return png + } + + // MARK: Drawing + + private struct Cancelled: Error {} + + private func draw( + _ data: Data, z: Int, x: Int, y: Int, ratio: Int, isCancelled: () -> Bool + ) throws -> Foundation.Data? { + let size = Double(tileSize) + let scale = size * Double(1 << z) + let originX = Double(x) * size, originY = Double(y) * size + let pad = data.maxStrokePx / 2 + 1 + let candidates = Self.query( + data, + minX: (originX - pad) / scale, minY: (originY - pad) / scale, + maxX: (originX + size + pad) / scale, maxY: (originY + size + pad) / scale + ) + if candidates.isEmpty { return nil } + + let pixels = tileSize * ratio + let bytesPerRow = pixels * 4 + guard let buffer = NSMutableData(length: bytesPerRow * pixels), + let context = CGContext( + data: buffer.mutableBytes, width: pixels, height: pixels, bitsPerComponent: 8, + bytesPerRow: bytesPerRow, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw Cancelled() + } + // y down, in points of this tile. + context.translateBy(x: 0, y: CGFloat(pixels)) + context.scaleBy(x: CGFloat(ratio), y: -CGFloat(ratio)) + context.setLineJoin(.round) + context.setLineCap(.round) + + let zoomCache = zoomCache(for: z, count: data.polygons.count) + var drew = false + for (n, index) in candidates.enumerated() { + if n & 255 == 255, isCancelled() { throw Cancelled() } + guard let p = data.polygons[Int(index)] else { continue } + let shape = simplified(p, index: Int(index), z: z, scale: scale, cache: zoomCache) + switch shape { + case .none: + continue + case let .dot(px, py): + guard let color = p.fill ?? p.stroke else { continue } + context.setFillColor(color) + context.fill(CGRect(x: px - originX, y: py - originY, width: 1, height: 1)) + drew = true + case let .rings(rings, outlined): + if let fill = p.fill { + let path = CGMutablePath() + for ring in rings { Self.add(ring, to: path, originX: originX, originY: originY) } + context.addPath(path) + context.setFillColor(fill) + context.fillPath(using: .winding) + drew = true + } + if outlined, let stroke = p.stroke { + // Outline only, as the map SDKs draw it. + let path = CGMutablePath() + Self.add(rings[0], to: path, originX: originX, originY: originY) + context.addPath(path) + context.setStrokeColor(stroke) + context.setLineWidth(CGFloat(p.strokeWidth)) + context.strokePath() + drew = true + } + } + } + if !drew { return nil } + if isCancelled() { throw Cancelled() } + + if let png = TilePngEncoder.encode( + rgba: buffer.mutableBytes, width: pixels, height: pixels, premultiplied: true + ) { + return png + } + guard let image = context.makeImage(), let png = UIImage(cgImage: image).pngData() else { + throw Cancelled() + } + return png + } + + private static func add(_ ring: [Double], to path: CGMutablePath, originX: Double, originY: Double) { + path.move(to: CGPoint(x: ring[0] - originX, y: ring[1] - originY)) + var i = 2 + while i < ring.count { + path.addLine(to: CGPoint(x: ring[i] - originX, y: ring[i + 1] - originY)) + i += 2 + } + path.closeSubpath() + } + + /// Indices of the polygons whose box meets the given one, in draw order. + private static func query(_ data: Data, minX: Double, minY: Double, maxX: Double, maxY: Double) -> [Int32] { + var out: [Int32] = [] + let take: (Int32) -> Void = { i in + guard let p = data.polygons[Int(i)] else { return } + if p.minX <= maxX && p.maxX >= minX && p.minY <= maxY && p.maxY >= minY { out.append(i) } + } + for i in data.index.large { take(i) } + let cx0 = Int64(floor(minX / cellSize)), cx1 = Int64(floor(maxX / cellSize)) + let cy0 = Int64(floor(minY / cellSize)), cy1 = Int64(floor(maxY / cellSize)) + if cx0 <= cx1, cy0 <= cy1 { + for cx in cx0...cx1 { + for cy in cy0...cy1 { + if let bucket = data.index.cells[cx &* 1_048_576 &+ cy] { bucket.forEach(take) } + } + } + } + // A polygon spanning several cells is found once per cell; draw order is list order. + out.sort() + var unique: [Int32] = [] + unique.reserveCapacity(out.count) + for i in out where unique.last != i { unique.append(i) } + return unique + } + + private func zoomCache(for z: Int, count: Int) -> ZoomCache { + lock.lock() + defer { lock.unlock() } + if let at = simplified.firstIndex(where: { $0.zoom == z }) { + let entry = simplified.remove(at: at) + simplified.append(entry) + if entry.cache.entries.count == count { return entry.cache } + } + let cache = ZoomCache(count: count) + simplified.removeAll { $0.zoom == z } + simplified.append((z, cache)) + while simplified.count > Self.zoomsKept { simplified.removeFirst() } + return cache + } + + private func simplified(_ p: Polygon, index: Int, z: Int, scale: Double, cache: ZoomCache) -> Simplified { + lock.lock() + let cached = cache.entries[index] + lock.unlock() + if let cached { return cached } + let result = Self.simplify(p, scale: scale) + lock.lock() + cache.entries[index] = result + lock.unlock() + return result + } + + /// The polygon at the zoom whose world is `scale` pixels wide. + static func simplify(_ p: Polygon, scale: Double) -> Simplified { + let w = (p.maxX - p.minX) * scale, h = (p.maxY - p.minY) * scale + if w < 1 && h < 1 { + return .dot(floor((p.minX + p.maxX) / 2 * scale), floor((p.minY + p.maxY) / 2 * scale)) + } + guard let outer = simplifyRing(p.rings[0], scale: scale) else { + return .dot(((p.minX + p.maxX) / 2 * scale).rounded(), ((p.minY + p.maxY) / 2 * scale).rounded()) + } + var rings = [outer] + for r in 1..= minOutlinedPx) + } + + static func simplifyRing(_ ring: [Double], scale: Double) -> [Double]? { + var snapped: [Double] = [] + snapped.reserveCapacity(ring.count) + var px = Double.nan, py = Double.nan + var i = 0 + while i < ring.count { + let x = (ring[i] * scale).rounded(), y = (ring[i + 1] * scale).rounded() + i += 2 + if x == px && y == py { continue } + snapped.append(x); snapped.append(y) + px = x; py = y + } + let out = douglasPeucker(snapped, tolerance: dpTolerancePx) + return out.count >= 6 ? out : nil + } + + /// Douglas-Peucker on flat points; returns the kept points. + static func douglasPeucker(_ points: [Double], tolerance: Double) -> [Double] { + let n = points.count / 2 + if n < 3 { return points } + var keep = [Bool](repeating: false, count: n) + keep[0] = true + keep[n - 1] = true + var stack: [(Int, Int)] = [(0, n - 1)] + while let (a, b) = stack.popLast() { + let ax = points[a * 2], ay = points[a * 2 + 1], bx = points[b * 2], by = points[b * 2 + 1] + let dx = bx - ax, dy = by - ay, len = (dx * dx + dy * dy).squareRoot() + var best = -1.0, bestIndex = -1 + var i = a + 1 + while i < b { + let qx = points[i * 2], qy = points[i * 2 + 1] + let d = len > 0 + ? abs(dy * (qx - ax) - dx * (qy - ay)) / len + : ((qx - ax) * (qx - ax) + (qy - ay) * (qy - ay)).squareRoot() + if d > best { best = d; bestIndex = i } + i += 1 + } + if best > tolerance { + keep[bestIndex] = true + stack.append((a, bestIndex)) + stack.append((bestIndex, b)) + } + } + var out: [Double] = [] + for i in 0.. tileCacheBytes, !tileOrder.isEmpty { + let oldest = tileOrder.removeFirst() + if let dropped = tiles.removeValue(forKey: oldest) { tileBytes -= dropped.count } + } + } + + private func dropAllTilesLocked() { + tiles.removeAll() + tileOrder.removeAll() + tileBytes = 0 + } + + private func dropTilesLocked(overlapping boxes: [Polygon], strokePx: Double) { + guard !boxes.isEmpty, !tiles.isEmpty else { return } + let size = Double(tileSize) + var dropped = Set() + for key in tiles.keys { + let scale = size * Double(1 << key.z) + let pad = (strokePx + 2) / scale + let tx0 = Double(key.x) * size / scale - pad, tx1 = Double(key.x + 1) * size / scale + pad + let ty0 = Double(key.y) * size / scale - pad, ty1 = Double(key.y + 1) * size / scale + pad + if boxes.contains(where: { $0.minX <= tx1 && $0.maxX >= tx0 && $0.minY <= ty1 && $0.maxY >= ty0 }) { + dropped.insert(key) + } + } + for key in dropped { if let png = tiles.removeValue(forKey: key) { tileBytes -= png.count } } + if !dropped.isEmpty { tileOrder.removeAll { dropped.contains($0) } } + } + + /// Number of tiles in the cache, for tests. + var cachedTileCount: Int { + lock.lock() + defer { lock.unlock() } + return tiles.count + } + + // MARK: Geometry + + static func toMercator(_ latLngs: [Double]) -> [Double] { + let n = latLngs.count / 2 + var ring = [Double](repeating: 0, count: n * 2) + for i in 0.. Double { + let n = ring.count / 2 + var area = 0.0 + var j = n - 1 + for i in 0.. [Double] { + let n = ring.count / 2 + var out = [Double](repeating: 0, count: ring.count) + for i in 0.. Bool { + guard let color else { return false } + return color.alpha > 0 + } +} diff --git a/Sources/MapConductorCore/polygon/PolygonTileSet.swift b/Sources/MapConductorCore/polygon/PolygonTileSet.swift new file mode 100644 index 0000000..7b349c5 --- /dev/null +++ b/Sources/MapConductorCore/polygon/PolygonTileSet.swift @@ -0,0 +1,198 @@ +import Foundation +import UIKit + +/** + The polygons of one map that are drawn as raster tiles, and their route on the + local tile server. + + Provider-independent: a provider decides which polygons to tile + (``PolygonTilingOptions/shouldUseTiles(polygonCount:vertexCount:)``, + ``canTile(_:)``), hands them to ``setPolygons(_:)``, and shows the returned URL + template as a raster layer. In-place changes go through ``update(_:)``; each + batch ends in a new template (the version in it is what makes the map ask + again), reported to ``onTemplateChanged``. Tiles the change did not touch are + still cached by the renderer and come back without being redrawn. + + The iOS counterpart of js-sdk-core's `PolygonTileSet`. There the polygons cross + to a service worker as Encoded Polylines; here the renderer is in the same + process and takes them as they are. + */ +@_spi(MapConductorDriver) +public final class PolygonTileSet { + /// In-place changes arriving within this window become one new template. + static let updateCoalesceMs = 16 + + public let routeId = "polygons-\(UUID().uuidString.prefix(8).lowercased())" + public let tileSize = 256 + /// Called on the main thread with the template after a batch of in-place changes. + public var onTemplateChanged: ((String) -> Void)? + + let renderer: PolygonTileRenderer + private let lock = NSLock() + private var indexById: [String: Int] = [:] + private var version = 0 + private var registered = false + private var pending: [String: PolygonState] = [:] + private var pendingRemovals: [Int] = [] + private var flushScheduled = false + + public init(pixelRatio: Int = max(1, Int(UIScreen.main.scale.rounded()))) { + renderer = PolygonTileRenderer(tileSize: 256, pixelRatio: pixelRatio) + } + + deinit { + if registered { TileServerRegistry.get().unregister(routeId: routeId) } + } + + /// Whether a polygon can be drawn in a tile. Geodesic edges are curves the + /// tile does not trace, and a ring spanning half the world or more crosses + /// the antimeridian; the map SDK draws both. + public static func canTile(_ state: PolygonState) -> Bool { + if state.geodesic { return false } + let points = state.points + if points.count < 3 { return false } + var minLng = Double.infinity, maxLng = -Double.infinity + for p in points { + minLng = min(minLng, p.longitude) + maxLng = max(maxLng, p.longitude) + } + return maxLng - minLng < 180 + } + + public static func vertexCount(_ states: [PolygonState]) -> Int { + var n = 0 + for state in states { + n += state.points.count + for hole in state.holes { n += hole.count } + } + return n + } + + /// Registers `states` (replacing whatever was registered) and returns the URL + /// template of their tiles. The geometry is read and projected off the + /// calling thread. + public func setPolygons(_ states: [PolygonState]) async -> String? { + cancelPending() + let renderer = self.renderer + let indexById = await Task.detached(priority: .userInitiated) { () -> [String: Int] in + // Draw order is list order: zIndex, then the order given. Each zIndex + // is read once: a state's properties are not free to read, and a + // sort reads them n log n times. + let zIndex = states.map(\.zIndex) + let ordered = states.indices + .sorted { zIndex[$0] != zIndex[$1] ? zIndex[$0] < zIndex[$1] : $0 < $1 } + .map { states[$0] } + var indexById: [String: Int] = [:] + indexById.reserveCapacity(ordered.count) + var shapes: [PolygonTileShape] = [] + shapes.reserveCapacity(ordered.count) + for (i, state) in ordered.enumerated() { + indexById[state.id] = i + shapes.append(PolygonTileShape(state: state)) + } + renderer.setPolygons(shapes) + return indexById + }.value + + let server = TileServerRegistry.get() + server.register(routeId: routeId, provider: renderer) + lock.lock() + self.indexById = indexById + registered = true + version += 1 + lock.unlock() + return template() + } + + public func has(_ id: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return indexById[id] != nil + } + + /// One polygon changed in place. A change of zIndex reorders the list and + /// needs ``setPolygons(_:)`` instead; everything else is applied here. + public func update(_ state: PolygonState) { + lock.lock() + guard indexById[state.id] != nil else { + lock.unlock() + return + } + pending[state.id] = state + scheduleFlushLocked() + } + + /// Takes one polygon out of the tiles. Its slot in the renderer's list is + /// left empty, so the others keep their indices and only the tiles it was + /// in are drawn again. + public func remove(_ id: String) { + lock.lock() + guard let index = indexById.removeValue(forKey: id) else { + lock.unlock() + return + } + pending[id] = nil + pendingRemovals.append(index) + scheduleFlushLocked() + } + + /// Called with ``lock`` held; releases it. + private func scheduleFlushLocked() { + let schedule = !flushScheduled + flushScheduled = true + lock.unlock() + if schedule { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Self.updateCoalesceMs)) { [weak self] in + self?.flush() + } + } + } + + /// The URL template of the tiles as they are now. Read from the server each + /// time: it moves to a new port when the app comes back from the background. + public func template() -> String { + lock.lock() + let version = self.version + lock.unlock() + return TileServerRegistry.get().urlTemplate(routeId: routeId, tileSize: tileSize, cacheKey: "v\(version)") + } + + public func dispose() { + cancelPending() + lock.lock() + let wasRegistered = registered + registered = false + indexById.removeAll() + lock.unlock() + if wasRegistered { TileServerRegistry.get().unregister(routeId: routeId) } + renderer.clear() + } + + private func flush() { + lock.lock() + flushScheduled = false + let updates = pending.values.compactMap { state -> (Int, PolygonState)? in + indexById[state.id].map { ($0, state) } + } + let removals = pendingRemovals + pending.removeAll() + pendingRemovals.removeAll() + let live = registered + lock.unlock() + guard live, !updates.isEmpty || !removals.isEmpty else { return } + var changes: [(index: Int, shape: PolygonTileShape?)] = updates.map { (index: $0.0, shape: PolygonTileShape(state: $0.1)) } + for index in removals { changes.append((index: index, shape: nil)) } + renderer.update(changes) + lock.lock() + version += 1 + lock.unlock() + onTemplateChanged?(template()) + } + + private func cancelPending() { + lock.lock() + pending.removeAll() + pendingRemovals.removeAll() + lock.unlock() + } +} diff --git a/Sources/MapConductorCore/polygon/PolygonTiler.swift b/Sources/MapConductorCore/polygon/PolygonTiler.swift new file mode 100644 index 0000000..cd260fe --- /dev/null +++ b/Sources/MapConductorCore/polygon/PolygonTiler.swift @@ -0,0 +1,165 @@ +import Foundation + +/** + Decides which of a map's polygons are drawn as tiles, keeps them registered + with the local tile server, and tells the provider where the tiles are. + + Every provider's polygon controller uses it the same way (``PolygonController/tiler``): + + 1. ``route(_:)`` with the full list on every membership change. It returns the + ids now in tiles; the controller keeps those in its `PolygonManager` (taps + are resolved there, against the original shapes) but gives the map SDK + nothing for them, and removes any native polygon it had drawn for one. + 2. ``update(_:)`` for an in-place change of a tiled polygon, or ``remove(_:)`` + when the change means it can no longer be tiled. + 3. ``onTemplate`` is how the tiles reach the map: a URL template to show as a + raster layer, or nil to remove it. A provider with a raster layer controller + hands ``rasterLayerState(template:tileSize:)`` to its `upsert(state:)` and + removes ``layerId`` with `removeById(_:)` — never through the app's own + raster layer list, which would drop one or the other. + + The iOS counterpart of js-sdk-core's `PolygonTiler`. + */ +@_spi(MapConductorDriver) +public final class PolygonTiler { + /// The id of the raster layer that shows tiled polygons (js-sdk-core's `POLYGON_TILE_LAYER_ID`). + public static let layerId = "mc-polygon-tiles" + + /// The raster layer state for tiled polygons at `template`. + public static func rasterLayerState(template: String, tileSize: Int = 256) -> RasterLayerState { + RasterLayerState(source: .urlTemplate(template: template, tileSize: tileSize), id: layerId) + } + + /// Called with the template to show, or nil to remove the layer, and the tile size. + public var onTemplate: ((String?, Int) async -> Void)? + public let options: PolygonTilingOptions + + let tileSet: PolygonTileSet + /// What is tiled. ``route(_:)`` runs wherever the polygon controller's + /// `add` runs (off the main thread), while ``refreshIfServerMoved()`` is + /// called from the map's content update on the main thread: everything below + /// is read and written under this lock. + private let lock = NSLock() + private var tiled: Set = [] + private var states: [PolygonState] = [] + private var shownBaseUrl: String? + + public init(options: PolygonTilingOptions = .Default) { + self.options = options + self.tileSet = PolygonTileSet() + tileSet.onTemplateChanged = { [weak self] template in + guard let self else { return } + Task { await self.onTemplate?(template, self.tileSet.tileSize) } + } + } + + private func withLock(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } + + /// Re-decides tiling for `data` and returns the ids drawn as tiles. + public func route(_ data: [PolygonState]) async -> Set { + var tileable: [PolygonState] = [] + tileable.reserveCapacity(data.count) + var vertices = 0 + for state in data where PolygonTileSet.canTile(state) { + tileable.append(state) + vertices += state.points.count + for hole in state.holes { vertices += hole.count } + } + let useTiles = options.shouldUseTiles(polygonCount: tileable.count, vertexCount: vertices) + let template = useTiles ? await tileSet.setPolygons(tileable) : nil + if let template { + let ids = Set(tileable.map(\.id)) + let baseUrl = TileServerRegistry.get().baseUrl + withLock { + tiled = ids + states = tileable + shownBaseUrl = baseUrl + } + await onTemplate?(template, tileSet.tileSize) + return ids + } + let hadTiles = withLock { () -> Bool in + let had = !tiled.isEmpty + tiled = [] + states = [] + shownBaseUrl = nil + return had + } + tileSet.dispose() + if hadTiles { await onTemplate?(nil, tileSet.tileSize) } + return [] + } + + /// The states drawn as tiles, as of the last ``route(_:)``. + public func tiledStates() -> [PolygonState] { + withLock { states } + } + + public func isTiled(_ id: String) -> Bool { + withLock { tiled.contains(id) } + } + + /// An in-place change of a tiled polygon; only the tiles it touches are redrawn. + public func update(_ state: PolygonState) { + tileSet.update(state) + } + + /// Takes one polygon out of the tiles, for a change that left it no longer + /// ``PolygonTileSet/canTile(_:)``; only the tiles it was in are redrawn. The + /// controller then hands it to the map SDK. + public func remove(_ id: String) { + let removed = withLock { () -> Bool in + guard tiled.remove(id) != nil else { return false } + states.removeAll { $0.id == id } + return true + } + if removed { tileSet.remove(id) } + } + + /// Shows the tiles again if the local tile server came back on another port + /// (it does after the app has been in the background). Cheap enough to call + /// on every content update, which is where the marker tiles check too. + public func refreshIfServerMoved() { + let current = TileServerRegistry.get().baseUrl + let moved = withLock { () -> Bool in + guard !tiled.isEmpty, let shown = shownBaseUrl, shown != current else { return false } + shownBaseUrl = current + return true + } + guard moved else { return } + let template = tileSet.template() + Task { await onTemplate?(template, tileSet.tileSize) } + } + + /// The current template, or nil when nothing is tiled. A provider whose map + /// re-reads its layers (a style reload, a server that moved) asks here. + public func currentTemplate() -> String? { + withLock { tiled.isEmpty } ? nil : tileSet.template() + } + + /// Unregisters the tiles without telling the provider; for a map being torn down. + public func dispose() { + withLock { + tiled = [] + states = [] + shownBaseUrl = nil + } + tileSet.dispose() + } + + public func clear() async { + let hadTiles = withLock { () -> Bool in + let had = !tiled.isEmpty + tiled = [] + states = [] + shownBaseUrl = nil + return had + } + tileSet.dispose() + if hadTiles { await onTemplate?(nil, tileSet.tileSize) } + } +} diff --git a/Sources/MapConductorCore/polygon/PolygonTilingOptions.swift b/Sources/MapConductorCore/polygon/PolygonTilingOptions.swift new file mode 100644 index 0000000..cf88fc6 --- /dev/null +++ b/Sources/MapConductorCore/polygon/PolygonTilingOptions.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Options for polygon tiling. +/// +/// When enabled and a map holds enough polygons, they are drawn into raster +/// tiles by the SDK's local tile server instead of being handed to the map SDK +/// one by one. Taps are still resolved against the original shapes in +/// `PolygonManager`, so tiling changes how polygons look, never what a tap hits. +/// +/// The counterpart of ``MarkerTilingOptions``. js-sdk-core's and android-sdk's +/// `PolygonTilingOptions` carry the same fields and the same defaults. +public struct PolygonTilingOptions: Equatable { + public let enabled: Bool + /// Tile once a map holds at least this many polygons… + public let minPolygonCount: Int + /// …or at least this many vertices across them, whichever comes first. + public let minVertexCount: Int + + public static let Default = PolygonTilingOptions() + public static let Disabled = PolygonTilingOptions(enabled: false) + + public init( + enabled: Bool = true, + minPolygonCount: Int = 2000, + minVertexCount: Int = 50_000 + ) { + self.enabled = enabled + self.minPolygonCount = minPolygonCount + self.minVertexCount = minVertexCount + } + + /// The one place the threshold is decided; callers must not restate it. + public func shouldUseTiles(polygonCount: Int, vertexCount: Int) -> Bool { + enabled && (polygonCount >= minPolygonCount || vertexCount >= minVertexCount) + } +} diff --git a/Sources/MapConductorCore/polygon/PolygonUnion.swift b/Sources/MapConductorCore/polygon/PolygonUnion.swift index 8326512..8343910 100644 --- a/Sources/MapConductorCore/polygon/PolygonUnion.swift +++ b/Sources/MapConductorCore/polygon/PolygonUnion.swift @@ -84,8 +84,12 @@ func unionHoleRings(_ holes: [[GeoPointProtocol]]) -> [[GeoPoint]]? { let rings = holes.compactMap { toRing($0, origin: origin) } guard rings.count > 1 else { return nil } - - let merged = unionRings(rings) + // Holes that neither cross, touch nor contain one another are already their + // own union. Checking that is linear; the union is quadratic in the hole + // edges, which a street-network parcel with 80 holes and 8,000 hole + // vertices turns into seconds. js-sdk-core and android-sdk take the same + // shortcut. The rings come back as the union would return them: clockwise. + let merged = holesAreDisjoint(rings) ? rings : unionRings(rings) guard !merged.isEmpty else { return nil } return merged.map { ring -> [GeoPoint] in @@ -414,6 +418,47 @@ private func intersectionPoints(_ seg: Edge, _ other: Edge) -> [Vec] { return [add2(seg.a, scale(r, clamp01(t)))] } +/// True when no two rings cross, touch or nest. The edges go into the same +/// EdgeGrid the union uses, and each edge is tested only against the edges filed +/// near its box, so the cost grows with the edge count rather than its square. +private func holesAreDisjoint(_ rings: [[Vec]]) -> Bool { + let bounds = rings.map(ringBounds) + // A ring inside another: one of its vertices is inside it. + for i in rings.indices { + let p = rings[i][0] + for j in rings.indices where i != j { + let b = bounds[j] + if p.x >= b.minX && p.x <= b.maxX && p.y >= b.minY && p.y <= b.maxY && pointInRing(p, rings[j]) { + return false + } + } + } + + var edges: [Edge] = [] + var ringOf: [Int] = [] + for (r, ring) in rings.enumerated() { + for i in ring.indices { + edges.append(Edge(a: ring[i], b: ring[(i + 1) % ring.count])) + ringOf.append(r) + } + } + // Only an edge whose box comes within meetPad of this one's can meet it. + var grid = EdgeGrid(edges, pad: meetPad) + var near: [Int] = [] + for i in edges.indices { + let seg = edges[i] + grid.near( + min(seg.a.x, seg.b.x) - meetPad, min(seg.a.y, seg.b.y) - meetPad, + max(seg.a.x, seg.b.x) + meetPad, max(seg.a.y, seg.b.y) + meetPad, + into: &near + ) + for j in near where j > i && ringOf[j] != ringOf[i] { + if !intersectionPoints(seg, edges[j]).isEmpty { return false } + } + } + return true +} + /// How many rings' interiors contain the point. A ring whose latitudes do not /// straddle the point's has no edge pointInRing would count, so it is skipped. private func coverage(_ point: Vec, _ rings: [[Vec]], _ bounds: [Bounds]) -> Int { diff --git a/Sources/MapConductorCore/polyline/PolylineController.swift b/Sources/MapConductorCore/polyline/PolylineController.swift index 8ee5bb5..4e15f89 100644 --- a/Sources/MapConductorCore/polyline/PolylineController.swift +++ b/Sources/MapConductorCore/polyline/PolylineController.swift @@ -11,7 +11,18 @@ where Renderer.ActualPolyline == ActualPolyline { public let zIndex: Int = 5 private let semaphore = AsyncSemaphore(1) - private var currentCameraPosition: MapCameraPosition? + /// Written from the map's camera callbacks on the main thread and from + /// `onCameraChanged`, which the overlay registry runs in a detached task. + /// Two unguarded writes of a class reference release the old camera twice: + /// with 100,000 polygons in tiles a pan ran fast enough to hit it, and the app + /// crashed in the camera's release a few pans in. + private let cameraLock = NSLock() + private var cameraPositionValue: MapCameraPosition? + private var currentCameraPosition: MapCameraPosition? { + cameraLock.lock() + defer { cameraLock.unlock() } + return cameraPositionValue + } public var clickListener: ((PolylineEvent) -> Void)? @@ -143,7 +154,12 @@ where Renderer.ActualPolyline == ActualPolyline { } public func setCurrentCameraPosition(_ cameraPosition: MapCameraPosition?) { - currentCameraPosition = cameraPosition + cameraLock.lock() + let previous = cameraPositionValue + cameraPositionValue = cameraPosition + cameraLock.unlock() + // Released outside the lock. + _ = previous } open func onCameraChanged(mapCameraPosition: MapCameraPosition) async { diff --git a/Tests/MapConductorCoreTests/OverlayCollectorScaleTests.swift b/Tests/MapConductorCoreTests/OverlayCollectorScaleTests.swift index 8fdc90b..29852e9 100644 --- a/Tests/MapConductorCoreTests/OverlayCollectorScaleTests.swift +++ b/Tests/MapConductorCoreTests/OverlayCollectorScaleTests.swift @@ -49,6 +49,55 @@ final class OverlayCollectorScaleTests: XCTestCase { } } + /// SwiftUI re-evaluates the map's content on every camera frame and hands the + /// collector the same list again. That must not cost the size of the list in + /// sets and listeners (it was 260 ms a frame for Boston's 98,843 parcels on an + /// iPad Pro, and every pan ran at 5 fps), and must not count as a change. + func testResyncingTheSameListIsFreeAndChangesNothing() async { + let states = (0..<100_000).map { i in + PolygonState( + points: [ + GeoPoint(latitude: 42.3, longitude: -71.1 + Double(i) * 1e-5), + GeoPoint(latitude: 42.3, longitude: -71.1 + Double(i) * 1e-5 + 5e-6), + GeoPoint(latitude: 42.3001, longitude: -71.1 + Double(i) * 1e-5), + ], + id: "p\(i)" + ) + } + let collector = OverlayCollector() + var memberships = 0 + var updates: [String] = [] + collector.onMembershipChange { _ in memberships += 1 } + collector.onStateChange { updates.append($0.id) } + let first = Date() + collector.sync(states) + let firstSync = Date().timeIntervalSince(first) + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual(memberships, 1) + + let started = Date() + for _ in 0..<10 { collector.sync(Array(states)) } + let perSync = Date().timeIntervalSince(started) / 10 + print(String(format: "IOSCOLLECTOR n=100000 first=%.1fms resync=%.1fms", firstSync * 1000, perSync * 1000)) + // A pointer comparison per state, not a diff (the test build is unoptimised, + // so this is a ratio rather than a number of milliseconds). + XCTAssertLessThan(perSync, firstSync / 4) + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual(memberships, 1, "同じ一覧は membership の変化ではない") + + // 変更の通知はこれまでどおり届く。 + states[7].fillColor = .red + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual(updates, ["p7"]) + + // 1 件差し替えた一覧は、同じ長さでも変化として扱う。 + var replaced = states + replaced[3] = PolygonState(points: states[3].points, id: "p3") + collector.sync(replaced) + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual(memberships, 2) + } + /// The per-state publisher on its own, with the collector out of the way. func testPublisherSetupCost() { for count in [1_000, 10_000, 50_000] { diff --git a/Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift b/Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift new file mode 100644 index 0000000..3785527 --- /dev/null +++ b/Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift @@ -0,0 +1,124 @@ +import XCTest + +@_spi(MapConductorDriver) @testable import MapConductorCore + +/// `PolygonController.tiler` の配線。js-sdk-core の `PolygonController` と同じ規則: +/// +/// - タイルにしたポリゴンは `PolygonManager` に残す(タップは元の形で判定する)が、 +/// レンダラには渡さず、エンティティの `polygon` は nil。 +/// - しきい値を下回ったら、タイルにしていたものをネイティブ描画へ戻す。 +/// - タイルにしたポリゴンのその場の変更はタイルへ(レンダラは呼ばない)。 +final class PolygonControllerTilingTests: XCTestCase { + private final class Renderer: AbstractPolygonOverlayRenderer { + var onMap = Set() + var changed = 0 + override func createPolygon(state: PolygonState) async -> String? { + onMap.insert(state.id) + return state.id + } + override func updatePolygonProperties( + polygon: String, current: PolygonEntity, prev: PolygonEntity + ) async -> String? { + changed += 1 + return polygon + } + override func removePolygon(entity: PolygonEntity) async { + onMap.remove(entity.state.id) + } + } + + private func states(_ count: Int) -> [PolygonState] { + (0..(polygonManager: PolygonManager(), renderer: renderer) + let tiler = PolygonTiler(options: PolygonTilingOptions(minPolygonCount: 10)) + var templates: [String?] = [] + tiler.onTemplate = { template, _ in templates.append(template) } + controller.tiler = tiler + + let all = states(20) + await controller.add(data: all) + XCTAssertTrue(renderer.onMap.isEmpty) + XCTAssertEqual(controller.polygonManager.allEntities().count, 20) + XCTAssertTrue(controller.polygonManager.allEntities().allSatisfy { $0.polygon == nil }) + XCTAssertNotNil(templates.last ?? nil) + // 元の形でタップを判定する + let hit = controller.find(position: GeoPoint(latitude: 42.30025, longitude: -71.09975)) + XCTAssertEqual(hit?.state.id, "p0") + + // 下回ったらネイティブへ戻る(残ったものは描かれ、消えたものは忘れる) + await controller.add(data: Array(all.prefix(5))) + XCTAssertEqual(renderer.onMap, Set((0..<5).map { "p\($0)" })) + XCTAssertEqual(controller.polygonManager.allEntities().count, 5) + XCTAssertTrue(controller.polygonManager.allEntities().allSatisfy { $0.polygon != nil }) + XCTAssertNil(templates.last ?? "not removed") + + // また届いたらタイルへ(ネイティブで描いていたものは地図から外す) + await controller.add(data: all) + XCTAssertTrue(renderer.onMap.isEmpty) + XCTAssertEqual(controller.polygonManager.allEntities().count, 20) + } + + func testAnInPlaceChangeOfATiledPolygonGoesToTheTiles() async throws { + let renderer = Renderer() + let controller = PolygonController(polygonManager: PolygonManager(), renderer: renderer) + let tiler = PolygonTiler(options: PolygonTilingOptions(minPolygonCount: 10)) + let changed = expectation(description: "a new template after the change") + var initial: String? + tiler.onTemplate = { template, _ in + if initial == nil { initial = template } else if template != initial { changed.fulfill() } + } + controller.tiler = tiler + let all = states(20) + await controller.add(data: all) + + all[3].fillColor = .blue + await controller.update(state: all[3]) + await fulfillment(of: [changed], timeout: 2) + XCTAssertEqual(renderer.changed, 0) + XCTAssertTrue(controller.polygonManager.getEntity("p3")?.state === all[3]) + } + + func testClearRemovesTheTiles() async { + let renderer = Renderer() + let controller = PolygonController(polygonManager: PolygonManager(), renderer: renderer) + let tiler = PolygonTiler(options: PolygonTilingOptions(minPolygonCount: 10)) + var templates: [String?] = [] + tiler.onTemplate = { template, _ in templates.append(template) } + controller.tiler = tiler + await controller.add(data: states(20)) + await controller.clear() + XCTAssertEqual(templates.count, 2) + XCTAssertNil(templates[1]) + XCTAssertTrue(controller.polygonManager.allEntities().isEmpty) + XCTAssertFalse(tiler.isTiled("p0")) + } + + func testTapsPickTheTappedPolygonAmongManyQuickly() async { + // 10 万件のうち 1 件を引くのに、全件の state を読まない。 + let manager = PolygonManager() + let all = states(10_000) + for state in all { manager.registerEntity(PolygonEntity(polygon: nil, state: state)) } + let start = DispatchTime.now().uptimeNanoseconds + for _ in 0..<20 { + XCTAssertEqual(manager.find(position: GeoPoint(latitude: 42.30525, longitude: -71.09475))?.state.id, "p505") + } + let ms = Double(DispatchTime.now().uptimeNanoseconds - start) / 1e6 / 20 + XCTAssertLessThan(ms, 50, String(format: "%.1f ms per tap", ms)) + } +} diff --git a/Tests/MapConductorCoreTests/PolygonHitToleranceTests.swift b/Tests/MapConductorCoreTests/PolygonHitToleranceTests.swift new file mode 100644 index 0000000..000cee3 --- /dev/null +++ b/Tests/MapConductorCoreTests/PolygonHitToleranceTests.swift @@ -0,0 +1,66 @@ +import XCTest + +@testable import MapConductorCore + +/// A tap counts as on a polygon's edge only when it is within `eps` (1e-6°, +/// about 0.1 m) of that edge. Taps outside the polygon's bounding box never get +/// that far, so the tolerance only matters on and just inside the outline. +/// +/// js-sdk-core の `PolygonHitTolerance.test.mjs`、android-sdk の `PolygonHitToleranceTest` と +/// 1 対 1 で対応する。 +/// +/// 辺上の判定が度の 2 乗の量(内積)を 1e-6 と比べていて、短い辺から約 100 m 以内の +/// タップをすべて「辺上」として拾っていた。建物の輪郭のように辺が数 m のポリゴンを +/// 並べると、タップした建物ではなく、リストで先にある近所の建物が返る。大きな図形では +/// 起きないので、サンプルの六角形では見えなかった。 +final class PolygonHitToleranceTests: XCTestCase { + // ボストン中心部の、一辺が約 10 m の建物 + private let building: [GeoPointProtocol] = [ + GeoPoint(latitude: 42.3550, longitude: -71.0600), + GeoPoint(latitude: 42.3550, longitude: -71.0599), + GeoPoint(latitude: 42.3551, longitude: -71.0599), + GeoPoint(latitude: 42.3551, longitude: -71.0600), + GeoPoint(latitude: 42.3550, longitude: -71.0600), + ] + + private func managerWith(_ states: PolygonState...) -> PolygonManager { + let manager = PolygonManager() + for state in states { + manager.registerEntity(PolygonEntity(polygon: nil, state: state)) + } + return manager + } + + func test50mAwayDoesNotHitABuildingWithShortEdges() { + let manager = managerWith(PolygonState(points: building, id: "small")) + // 建物の東の辺から東へ約 50 m(経度 0.0006°) + XCTAssertNil(manager.find(position: GeoPoint(latitude: 42.35505, longitude: -71.0593))) + // 北へ約 50 m + XCTAssertNil(manager.find(position: GeoPoint(latitude: 42.3556, longitude: -71.05995))) + } + + func testInsideAndOnTheEdgeHitButHalfAMetreOutDoesNot() { + let manager = managerWith(PolygonState(points: building, id: "small")) + XCTAssertEqual(manager.find(position: GeoPoint(latitude: 42.35505, longitude: -71.05995))?.state.id, "small") + XCTAssertEqual( + manager.find(position: GeoPoint(latitude: 42.3550, longitude: -71.05995))?.state.id, "small", + "南の辺の上" + ) + XCTAssertNil( + manager.find(position: GeoPoint(latitude: 42.354995, longitude: -71.05995)), + "南の辺から外へ約 0.6 m" + ) + } + + func testReturnsTheTappedBuildingNotItsNeighbour() { + // 西隣(約 20 m 西)にもう 1 棟。リストでは先に入れる + let west: [GeoPointProtocol] = building.map { + GeoPoint(latitude: $0.latitude, longitude: $0.longitude - 0.00025) + } + let manager = managerWith( + PolygonState(points: west, id: "west"), + PolygonState(points: building, id: "tapped") + ) + XCTAssertEqual(manager.find(position: GeoPoint(latitude: 42.35505, longitude: -71.05995))?.state.id, "tapped") + } +} diff --git a/Tests/MapConductorCoreTests/PolygonTileRendererTests.swift b/Tests/MapConductorCoreTests/PolygonTileRendererTests.swift new file mode 100644 index 0000000..3182bf6 --- /dev/null +++ b/Tests/MapConductorCoreTests/PolygonTileRendererTests.swift @@ -0,0 +1,293 @@ +import CoreGraphics +import ImageIO +import UIKit +import XCTest + +@testable import MapConductorCore + +/// ポリゴンのタイル描画(`PolygonTileRenderer`)を、出てきたタイルの画素で固定する。 +/// +/// js-sdk-core の tile-sw.js「Polygon tiles」節、android-sdk の `PolygonTileRenderer` と同じ描き方: +/// 巻きの正規化(外周と穴を逆向きにして nonzero)、ズームごとの簡略化(画素格子に丸める → +/// 重複を捨てる → Douglas-Peucker 0.5 px → 1 px 未満は点)、4 px 未満は枠線なし、 +/// グリッド索引、変更は重なるタイルだけ作り直す。 +final class PolygonTileRendererTests: XCTestCase { + private let red = UIColor(red: 1, green: 0, blue: 0, alpha: 1).cgColor + private let blue = UIColor(red: 0, green: 0, blue: 1, alpha: 1).cgColor + private let clear = UIColor.clear.cgColor + + // MARK: helpers + + /// World pixel (256-px tiles) of a coordinate at `z`. + private func worldPx(_ lat: Double, _ lng: Double, z: Int) -> (x: Double, y: Double) { + let scale = 256.0 * Double(1 << z) + let s = sin(lat * .pi / 180) + return ((lng + 180) / 360 * scale, (0.5 - log((1 + s) / (1 - s)) / (4 * .pi)) * scale) + } + + /// The coordinate at a world pixel. + private func latLng(_ x: Double, _ y: Double, z: Int) -> (lat: Double, lng: Double) { + let scale = 256.0 * Double(1 << z) + let lng = x / scale * 360 - 180 + let n = .pi - 2 * .pi * y / scale + return (atan(sinh(n)) * 180 / .pi, lng) + } + + /// A rectangle given in world pixels at `z`, as flat lat/lng, counter-clockwise on the map. + private func rect(_ x0: Double, _ y0: Double, _ x1: Double, _ y1: Double, z: Int, clockwise: Bool = false) -> [Double] { + var corners = [(x0, y1), (x1, y1), (x1, y0), (x0, y0)] + if clockwise { corners.reverse() } + return corners.flatMap { c -> [Double] in + let p = latLng(c.0, c.1, z: z) + return [p.lat, p.lng] + } + } + + private func shape(_ rings: [[Double]], fill: CGColor?, stroke: CGColor? = nil, width: Double = 0) -> PolygonTileShape { + PolygonTileShape(rings: rings, fill: fill, stroke: stroke, strokeWidth: width) + } + + private struct Pixels { + let width: Int + let bytes: [UInt8] + func rgba(_ x: Int, _ y: Int) -> (UInt8, UInt8, UInt8, UInt8) { + let i = (y * width + x) * 4 + return (bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]) + } + func isRed(_ x: Int, _ y: Int) -> Bool { let p = rgba(x, y); return p.0 > 200 && p.2 < 50 && p.3 > 200 } + func isBlue(_ x: Int, _ y: Int) -> Bool { let p = rgba(x, y); return p.2 > 200 && p.0 < 50 && p.3 > 200 } + func isEmpty(_ x: Int, _ y: Int) -> Bool { rgba(x, y).3 == 0 } + var opaqueCount: Int { stride(from: 3, to: bytes.count, by: 4).filter { bytes[$0] > 0 }.count } + var blueCount: Int { + var n = 0 + for i in stride(from: 0, to: bytes.count, by: 4) where bytes[i + 2] > 128 && bytes[i] < 80 { n += 1 } + return n + } + } + + private func decode(_ png: Data) throws -> Pixels { + let source = try XCTUnwrap(CGImageSourceCreateWithData(png as CFData, nil)) + let image = try XCTUnwrap(CGImageSourceCreateImageAtIndex(source, 0, nil)) + var bytes = [UInt8](repeating: 0, count: image.width * image.height * 4) + let context = try XCTUnwrap(CGContext( + data: &bytes, width: image.width, height: image.height, bitsPerComponent: 8, + bytesPerRow: image.width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return Pixels(width: image.width, bytes: bytes) + } + + private func render(_ renderer: PolygonTileRenderer, z: Int, x: Int, y: Int) throws -> Pixels? { + guard let png = renderer.renderTile(request: TileRequest(x: x, y: y, z: z)) else { return nil } + return try decode(png) + } + + // Tile (z=16) that holds downtown Boston; every shape below is placed in its pixels. + private let z = 16 + private var tile: (x: Int, y: Int) { + let p = worldPx(42.3555, -71.0605, z: z) + return (Int(p.x / 256), Int(p.y / 256)) + } + private var origin: (x: Double, y: Double) { (Double(tile.x) * 256, Double(tile.y) * 256) } + + /// A rectangle given in this tile's own pixels. + private func local(_ x0: Double, _ y0: Double, _ x1: Double, _ y1: Double, clockwise: Bool = false) -> [Double] { + rect(origin.x + x0, origin.y + y0, origin.x + x1, origin.y + y1, z: z, clockwise: clockwise) + } + + // MARK: fill and winding + + func testFillsInsideAndLeavesOutsideTransparent() throws { + let renderer = PolygonTileRenderer() + renderer.setPolygons([shape([local(40, 40, 120, 120)], fill: red)]) + let pixels = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + XCTAssertTrue(pixels.isRed(80, 80)) + XCTAssertTrue(pixels.isEmpty(10, 10)) + XCTAssertTrue(pixels.isEmpty(200, 200)) + } + + func testHoleIsEmptyWhicheverWayTheRingsAreWound() throws { + // 外周と穴が同じ向きでも、逆向きでも、穴は空く(描く前に向きをそろえる)。 + for (outerCW, holeCW) in [(false, false), (false, true), (true, false), (true, true)] { + let renderer = PolygonTileRenderer() + renderer.setPolygons([shape( + [local(20, 20, 200, 200, clockwise: outerCW), local(80, 80, 140, 140, clockwise: holeCW)], + fill: red + )]) + let pixels = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + XCTAssertTrue(pixels.isRed(50, 50), "outer cw=\(outerCW) hole cw=\(holeCW)") + XCTAssertTrue(pixels.isEmpty(110, 110), "outer cw=\(outerCW) hole cw=\(holeCW)") + } + } + + func testEmptyTileIsNilSoTheServerAnswersTransparent() throws { + let renderer = PolygonTileRenderer() + renderer.setPolygons([shape([local(40, 40, 120, 120)], fill: red)]) + XCTAssertNil(renderer.renderTile(request: TileRequest(x: tile.x + 3, y: tile.y, z: z))) + // 色が透明で枠線も無いものは、重なっていても描くものが無い。 + let invisible = PolygonTileRenderer() + invisible.setPolygons([shape([local(40, 40, 120, 120)], fill: clear)]) + XCTAssertNil(invisible.renderTile(request: TileRequest(x: tile.x, y: tile.y, z: z))) + } + + func testDrawsAtTheRequestedPixelRatio() throws { + let renderer = PolygonTileRenderer(pixelRatio: 2) + renderer.setPolygons([shape([local(40, 40, 120, 120)], fill: red)]) + let pixels = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + XCTAssertEqual(pixels.width, 512) + XCTAssertTrue(pixels.isRed(160, 160)) + XCTAssertTrue(pixels.isEmpty(60, 60)) + } + + // MARK: draw order + + func testLaterPolygonsAreDrawnOnTop() throws { + let renderer = PolygonTileRenderer() + renderer.setPolygons([ + shape([local(20, 20, 150, 150)], fill: red), + shape([local(100, 100, 220, 220)], fill: blue), + ]) + let pixels = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + XCTAssertTrue(pixels.isBlue(120, 120)) + XCTAssertTrue(pixels.isRed(40, 40)) + } + + // MARK: outline + + func testOutlineIsDrawnOnTheOutlineOnly() throws { + let renderer = PolygonTileRenderer() + renderer.setPolygons([shape( + [local(40, 40, 200, 200), local(100, 100, 140, 140)], + fill: clear, stroke: blue, width: 2 + )]) + let pixels = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + XCTAssertTrue(pixels.isBlue(40, 120), "外周の左辺") + XCTAssertTrue(pixels.isEmpty(100, 120), "穴の辺には枠線を引かない") + XCTAssertTrue(pixels.isEmpty(70, 70)) + } + + func testOutlineIsLeftOutUnderFourPixels() throws { + // 3 px の区画は枠線を描かない(描くと区画全体が枠線の色になる)。10 px なら描く。 + let small = PolygonTileRenderer() + small.setPolygons([shape([local(100, 100, 103, 103)], fill: clear, stroke: blue, width: 1)]) + XCTAssertNil(small.renderTile(request: TileRequest(x: tile.x, y: tile.y, z: z))) + + let large = PolygonTileRenderer() + large.setPolygons([shape([local(100, 100, 110, 110)], fill: clear, stroke: blue, width: 1)]) + let pixels = try XCTUnwrap(try render(large, z: z, x: tile.x, y: tile.y)) + XCTAssertGreaterThan(pixels.blueCount, 10) + } + + func testOutlineCrossingTheTileEdgeIsDrawnInTheNeighbour() throws { + // 右隣のタイルにある図形の枠線が、こちらの右端に 2 px かかる。 + let renderer = PolygonTileRenderer() + renderer.setPolygons([shape([local(257, 40, 300, 200)], fill: clear, stroke: blue, width: 6)]) + let pixels = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + XCTAssertTrue(pixels.isBlue(255, 120)) + } + + // MARK: simplification + + func testUnderAPixelIsDrawnAsOnePixel() throws { + // z16 で 0.4 px の建物 → 1 px の点。 + let renderer = PolygonTileRenderer() + renderer.setPolygons([shape([local(100.1, 100.1, 100.5, 100.5)], fill: red)]) + let pixels = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + XCTAssertEqual(pixels.opaqueCount, 1) + XCTAssertTrue(pixels.isRed(100, 100)) + } + + func testSimplificationSnapsDropsRepeatsAndThinsToHalfAPixel() { + // 1000 頂点の円(半径 0.0001 度 ≒ z10 で 0.07 px、z16 で 4.8 px)。 + let ring = (0..<1000).flatMap { k -> [Double] in + let a = 2 * Double.pi * Double(k) / 1000 + return [42.3555 + 0.0001 * sin(a), -71.0605 + 0.0001 * cos(a)] + } + let polygon = try! XCTUnwrap(PolygonTileRenderer.Polygon(PolygonTileShape(rings: [ring], fill: red, stroke: nil, strokeWidth: 0))) + if case .dot = PolygonTileRenderer.simplify(polygon, scale: 256 * Double(1 << 10)) {} else { + XCTFail("1 px 未満は点になる") + } + guard case let .rings(rings, outlined) = PolygonTileRenderer.simplify(polygon, scale: 256 * Double(1 << 16)) else { + return XCTFail("z16 では輪郭が残る") + } + XCTAssertTrue(outlined, "直径 9.6 px") + let n = rings[0].count / 2 + XCTAssertLessThan(n, 40, "画素格子に丸めて間引くと 1000 頂点が数十に減る: \(n)") + XCTAssertGreaterThanOrEqual(n, 3) + // 丸めたあとの点は整数。 + XCTAssertTrue(rings[0].allSatisfy { $0 == $0.rounded() }) + } + + func testDouglasPeuckerKeepsCornersAndDropsNearlyStraightPoints() { + let points: [Double] = [0, 0, 5, 0.3, 10, 0, 10, 10, 0, 10] + XCTAssertEqual(PolygonTileRenderer.douglasPeucker(points, tolerance: 0.5), [0, 0, 10, 0, 10, 10, 0, 10]) + XCTAssertEqual(PolygonTileRenderer.douglasPeucker(points, tolerance: 0.2), points) + } + + // MARK: index + + func testFindsPolygonsInManyCellsAndOnesSpanningTheGrid() throws { + // 1000 個の小さな四角を z16 タイルの中に並べ、大きな 1 個(索引の「大」リスト行き)を下に敷く。 + var shapes: [PolygonTileShape] = [] + let big = rect(origin.x - 50_000, origin.y - 50_000, origin.x + 50_000, origin.y + 50_000, z: z) + shapes.append(shape([big], fill: blue)) + for i in 0..<1000 { + let x = Double(i % 40) * 6 + 4, y = Double(i / 40) * 6 + 4 + shapes.append(shape([local(x, y, x + 4, y + 4)], fill: red)) + } + let renderer = PolygonTileRenderer() + renderer.setPolygons(shapes) + let pixels = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + XCTAssertTrue(pixels.isRed(6, 6)) + XCTAssertTrue(pixels.isRed(6 + 6 * 39, 6 + 6 * 24)) + XCTAssertTrue(pixels.isBlue(3, 3), "大きなポリゴンは隙間に見える") + XCTAssertTrue(pixels.isBlue(250, 250)) + // 遠く(別の z12 セル)のタイルにも大きなポリゴンは届く。 + let far = try XCTUnwrap(try render(renderer, z: z, x: tile.x + 100, y: tile.y + 100)) + XCTAssertTrue(far.isBlue(128, 128)) + } + + // MARK: in-place changes + + func testAChangeRedrawsOnlyTheTilesItTouches() throws { + let renderer = PolygonTileRenderer() + renderer.setPolygons([ + shape([local(40, 40, 120, 120)], fill: red), + shape([local(256 + 40, 40, 256 + 120, 120)], fill: red), + ]) + _ = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + let neighbour = try XCTUnwrap(renderer.renderTile(request: TileRequest(x: tile.x + 1, y: tile.y, z: z))) + XCTAssertEqual(renderer.cachedTileCount, 2) + + renderer.update([(index: 0, shape: shape([local(40, 40, 120, 120)], fill: blue))]) + XCTAssertEqual(renderer.cachedTileCount, 1, "変えたポリゴンに重なるタイルだけ捨てる") + let after = try XCTUnwrap(try render(renderer, z: z, x: tile.x, y: tile.y)) + XCTAssertTrue(after.isBlue(80, 80)) + let neighbourAgain = try XCTUnwrap(renderer.renderTile(request: TileRequest(x: tile.x + 1, y: tile.y, z: z))) + XCTAssertEqual(neighbour, neighbourAgain, "触れていないタイルはキャッシュから同じものが返る") + } + + func testMovingAPolygonFindsItAtItsNewPlace() throws { + let renderer = PolygonTileRenderer() + renderer.setPolygons([shape([local(40, 40, 120, 120)], fill: red)]) + _ = try render(renderer, z: z, x: tile.x, y: tile.y) + // 40 タイル先(索引のセルは z12 タイル = 16 タイル分なので、別のセル)へ動かす。 + let far = 256.0 * 40 + renderer.update([(index: 0, shape: shape([local(far + 40, 40, far + 120, 120)], fill: red))]) + XCTAssertNil(renderer.renderTile(request: TileRequest(x: tile.x, y: tile.y, z: z))) + let moved = try XCTUnwrap(try render(renderer, z: z, x: tile.x + 40, y: tile.y)) + XCTAssertTrue(moved.isRed(80, 80)) + } + + func testCancelledRenderThrowsInsteadOfAnsweringEmpty() { + var shapes: [PolygonTileShape] = [] + for i in 0..<600 { + let x = Double(i % 30) * 8, y = Double(i / 30) * 8 + shapes.append(shape([local(x, y, x + 6, y + 6)], fill: red)) + } + let renderer = PolygonTileRenderer() + renderer.setPolygons(shapes) + XCTAssertThrowsError(try renderer.renderTile(request: TileRequest(x: tile.x, y: tile.y, z: z), isCancelled: { true })) + } +} diff --git a/Tests/MapConductorCoreTests/PolygonTilingInPlaceTests.swift b/Tests/MapConductorCoreTests/PolygonTilingInPlaceTests.swift new file mode 100644 index 0000000..3fd5dba --- /dev/null +++ b/Tests/MapConductorCoreTests/PolygonTilingInPlaceTests.swift @@ -0,0 +1,213 @@ +import ImageIO +import UIKit +import XCTest + +@_spi(MapConductorDriver) @testable import MapConductorCore + +/// タイルにできないポリゴン(geodesic・経度の幅 180° 以上・頂点 3 未満)は地図 SDK が描く。 +/// +/// タイルにするかは add(新しいリスト)でだけ決めていた。タイルで描いているポリゴンを +/// その場で geodesic=true にしたり、点を書き換えてタイルにできない形にしたりすると、 +/// update はタイルの経路に入ったまま canTile を見直さず、直線のままタイルに描き続けた。 +/// update で canTile を満たさなくなったら、タイルから外して地図 SDK に描かせる。 +/// +/// js-sdk-core の `PolygonTilingInPlace.test.mjs`、android-sdk-core の `PolygonTilerDeviceTest` と同じ内容。 +final class PolygonTilingInPlaceTests: XCTestCase { + private final class Renderer: AbstractPolygonOverlayRenderer { + var added: [String] = [] + var changed: [String] = [] + var removed: [String] = [] + override func createPolygon(state: PolygonState) async -> String? { + added.append(state.id) + return state.id + } + override func updatePolygonProperties( + polygon: String, current: PolygonEntity, prev: PolygonEntity + ) async -> String? { + changed.append(current.state.id) + return polygon + } + override func removePolygon(entity: PolygonEntity) async { + removed.append(entity.state.id) + } + } + + private let lat = 35.68, lng = 139.76 + + private func square(_ id: String, _ lat: Double, _ lng: Double, _ color: UIColor, geodesic: Bool = false) -> PolygonState { + let s = 0.002 + return PolygonState( + points: [ + GeoPoint(latitude: lat, longitude: lng), + GeoPoint(latitude: lat, longitude: lng + s), + GeoPoint(latitude: lat + s, longitude: lng + s), + GeoPoint(latitude: lat + s, longitude: lng), + ], + id: id, + strokeColor: .clear, + fillColor: color, + geodesic: geodesic + ) + } + + /// Three squares in central Tokyo (one z14 tile) and one in Osaka. + private func tokyo() -> [PolygonState] { + [ + square("a", lat, lng, .red), + square("b", lat, lng + 0.003, .green), + square("c", lat + 0.003, lng, .blue), + square("osaka", 34.69, 135.5, .yellow), + ] + } + + private func tileAt(_ lat: Double, _ lng: Double, z: Int) -> TileRequest { + let n = Double(1 << z) + let s = sin(lat * .pi / 180) + let x = Int((lng + 180) / 360 * n) + let y = Int((0.5 - log((1 + s) / (1 - s)) / (4 * .pi)) * n) + return TileRequest(x: x, y: y, z: z, pixelRatio: 1) + } + + /// The pixel at a coordinate inside the tile at `z`, as RGBA. + private func pixel(_ tiler: PolygonTiler, _ lat: Double, _ lng: Double, z: Int) throws -> (UInt8, UInt8, UInt8, UInt8) { + let request = tileAt(lat, lng, z: z) + guard let png = tiler.tileSet.renderer.renderTile(request: request) else { return (0, 0, 0, 0) } + let source = try XCTUnwrap(CGImageSourceCreateWithData(png as CFData, nil)) + let image = try XCTUnwrap(CGImageSourceCreateImageAtIndex(source, 0, nil)) + var bytes = [UInt8](repeating: 0, count: image.width * image.height * 4) + let context = try XCTUnwrap(CGContext( + data: &bytes, width: image.width, height: image.height, bitsPerComponent: 8, + bytesPerRow: image.width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let scale = 256.0 * Double(1 << z) + let s = sin(lat * .pi / 180) + let wx = (lng + 180) / 360 * scale - Double(request.x * 256) + let wy = (0.5 - log((1 + s) / (1 - s)) / (4 * .pi)) * scale - Double(request.y * 256) + let k = image.width / 256 + let i = (Int(wy) * k * image.width + Int(wx) * k) * 4 + return (bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]) + } + + private func isRed(_ p: (UInt8, UInt8, UInt8, UInt8)) -> Bool { p.0 > 200 && p.1 < 50 && p.2 < 50 && p.3 > 200 } + private func isGreen(_ p: (UInt8, UInt8, UInt8, UInt8)) -> Bool { p.1 > 200 && p.0 < 50 && p.3 > 200 } + + private func setup() -> (PolygonController, Renderer, PolygonTiler) { + let renderer = Renderer() + let controller = PolygonController(polygonManager: PolygonManager(), renderer: renderer) + let tiler = PolygonTiler(options: PolygonTilingOptions(minPolygonCount: 3)) + tiler.onTemplate = { _, _ in } + controller.tiler = tiler + return (controller, renderer, tiler) + } + + /// Lets the tile set's coalesced update (16 ms on the main queue) run. + private func settle() async { + try? await Task.sleep(nanoseconds: 80_000_000) + await MainActor.run {} + } + + func testCanTileRefusesGeodesicWideAndDegenerate() { + XCTAssertTrue(PolygonTileSet.canTile(square("x", 0, 0, .red))) + XCTAssertFalse(PolygonTileSet.canTile(square("x", 0, 0, .red, geodesic: true))) + let p = { (lat: Double, lng: Double) in GeoPoint(latitude: lat, longitude: lng) } + XCTAssertFalse(PolygonTileSet.canTile(PolygonState(points: [p(0, -100), p(0, 80), p(10, 0)]))) + XCTAssertTrue(PolygonTileSet.canTile(PolygonState(points: [p(0, -90), p(0, 89), p(10, 0)]))) + XCTAssertFalse(PolygonTileSet.canTile(PolygonState(points: [p(0, 0), p(1, 1)]))) + } + + func testInAMixedListOnlyTheGeodesicOneIsDrawnNatively() async throws { + let (controller, renderer, tiler) = setup() + let g = square("g", lat + 0.003, lng + 0.003, .magenta, geodesic: true) + await controller.add(data: tokyo() + [g]) + XCTAssertEqual(renderer.added, ["g"]) + XCTAssertFalse(tiler.isTiled("g")) + XCTAssertTrue(tiler.isTiled("a")) + XCTAssertEqual(try pixel(tiler, lat + 0.004, lng + 0.004, z: 16).3, 0, "not in the tiles") + XCTAssertTrue(isRed(try pixel(tiler, lat + 0.001, lng + 0.001, z: 16))) + await controller.clear() + } + + /// Tiles everything, applies `change` to "a" in place, and checks it moved to native drawing. + private func checkMovesToNative( + _ change: (PolygonState) -> Void + ) async throws -> (PolygonController, Renderer, PolygonTiler, [PolygonState]) { + let (controller, renderer, tiler) = setup() + let list = tokyo() + await controller.add(data: list) + XCTAssertEqual(renderer.added, [], "all tiled at first") + XCTAssertTrue(isRed(try pixel(tiler, lat + 0.001, lng + 0.001, z: 16))) + _ = try pixel(tiler, 34.691, 135.501, z: 16) // Osaka, now cached + let cachedBefore = tiler.tileSet.renderer.cachedTileCount + + change(list[0]) + await controller.update(state: list[0]) + await settle() + + XCTAssertEqual(renderer.added, ["a"], "the map SDK draws it now") + XCTAssertFalse(tiler.isTiled("a")) + XCTAssertFalse(tiler.tiledStates().contains { $0.id == "a" }) + XCTAssertEqual(controller.polygonManager.getEntity("a")?.polygon, "a") + // Only the Tokyo tile it was in was dropped; Osaka's stays cached. + XCTAssertEqual(tiler.tileSet.renderer.cachedTileCount, cachedBefore - 1) + // Gone from the tile; the polygon next to it is still there. + XCTAssertEqual(try pixel(tiler, lat + 0.001, lng + 0.001, z: 16).3, 0) + XCTAssertTrue(isGreen(try pixel(tiler, lat + 0.001, lng + 0.004, z: 16))) + return (controller, renderer, tiler, list) + } + + func testSettingGeodesicInPlaceMovesItToNativeDrawing() async throws { + let (controller, renderer, tiler, list) = try await checkMovesToNative { $0.geodesic = true } + + // Taps still find it, now drawn natively; the tiled ones too. + let hit = controller.find(position: GeoPoint(latitude: lat + 0.001, longitude: lng + 0.001)) + XCTAssertEqual(hit?.state.id, "a") + XCTAssertEqual(hit?.polygon, "a") + XCTAssertEqual(controller.find(position: GeoPoint(latitude: lat + 0.001, longitude: lng + 0.004))?.state.id, "b") + + // Further in-place changes go to the native polygon. + list[0].fillColor = .purple + await controller.update(state: list[0]) + XCTAssertEqual(renderer.changed, ["a"]) + + // The same list again: it stays native, nothing is added or removed. + await controller.add(data: list) + XCTAssertEqual(renderer.added, ["a"]) + XCTAssertEqual(renderer.removed, []) + XCTAssertFalse(tiler.isTiled("a")) + await controller.clear() + } + + func testPointsSpanningHalfTheWorldInPlaceMoveItToNativeDrawing() async throws { + let (controller, _, _, _) = try await checkMovesToNative { [lat, lng] state in + state.points = [ + GeoPoint(latitude: lat, longitude: lng), + GeoPoint(latitude: lat, longitude: lng + 0.002), + GeoPoint(latitude: lat + 0.002, longitude: lng + 0.002), + GeoPoint(latitude: lat + 0.002, longitude: -60), + ] + } + await controller.clear() + } + + func testFewerThanThreePointsInPlaceMoveItToNativeDrawing() async throws { + let (controller, _, _, _) = try await checkMovesToNative { [lat, lng] state in + state.points = [GeoPoint(latitude: lat, longitude: lng), GeoPoint(latitude: lat, longitude: lng + 0.002)] + } + await controller.clear() + } + + func testATileableChangeStaysInTheTiles() async throws { + let (controller, renderer, tiler) = setup() + let list = tokyo() + await controller.add(data: list) + list[0].fillColor = .green + await controller.update(state: list[0]) + await settle() + XCTAssertEqual(renderer.added, []) + XCTAssertTrue(tiler.isTiled("a")) + XCTAssertTrue(isGreen(try pixel(tiler, lat + 0.001, lng + 0.001, z: 16))) + await controller.clear() + } +} diff --git a/Tests/MapConductorCoreTests/PolygonTilingTests.swift b/Tests/MapConductorCoreTests/PolygonTilingTests.swift new file mode 100644 index 0000000..e39c793 --- /dev/null +++ b/Tests/MapConductorCoreTests/PolygonTilingTests.swift @@ -0,0 +1,78 @@ +import XCTest + +@_spi(MapConductorDriver) @testable import MapConductorCore + +/// ポリゴンのタイル描画を支える部品。js-sdk-core の `PolygonTiling.test.mjs`、 +/// android-sdk の `PolygonTilingTest` と 1 対 1 で対応する。 +/// +/// - しきい値: ポリゴン数か頂点数のどちらかが届けばタイル。判定はここ 1 か所。 +/// - 穴のユニオンの近道: 交わらない穴は計算しない。道路網の区画(穴 80 個・穴の頂点 +/// 8,000)で 1 個数秒かかっていた二乗の計算を、交わる穴があるときだけに絞る。 +/// +/// web の Encoded Polyline のテストに当たるものは無い。ネイティブはタイル描画が同じ +/// プロセスにあり、座標を文字列にして渡さない。 +final class PolygonTilingTests: XCTestCase { + private func square(_ lat: Double, _ lng: Double, _ size: Double) -> [GeoPointProtocol] { + [ + GeoPoint(latitude: lat, longitude: lng), + GeoPoint(latitude: lat, longitude: lng + size), + GeoPoint(latitude: lat + size, longitude: lng + size), + GeoPoint(latitude: lat + size, longitude: lng), + ] + } + + func testTilesOnceEitherThePolygonOrTheVertexCountIsReached() { + let o = PolygonTilingOptions.Default + XCTAssertFalse(o.shouldUseTiles(polygonCount: 1999, vertexCount: 49_999)) + XCTAssertTrue(o.shouldUseTiles(polygonCount: 2000, vertexCount: 0)) + XCTAssertTrue(o.shouldUseTiles(polygonCount: 10, vertexCount: 50_000)) + XCTAssertFalse(PolygonTilingOptions.Disabled.shouldUseTiles(polygonCount: 100_000, vertexCount: 10_000_000)) + } + + func testDisjointHolesComeBackAsTheyAreWithoutTheUnion() throws { + let holes = [square(0, 0, 1), square(0, 2, 1), square(2, 0, 1)] + let result = try XCTUnwrap(unionHoleRings(holes)) + XCTAssertEqual(result.count, 3) + // 同じ点の集合(向きは時計回りにそろえる)。 + for (out, input) in zip(result, holes) { + XCTAssertEqual(Set(out.map { "\($0.latitude),\($0.longitude)" }), + Set(input.map { "\($0.latitude),\($0.longitude)" })) + } + } + + func testOverlappingNestedAndTouchingHolesAreStillMergedIntoOne() throws { + XCTAssertEqual(try XCTUnwrap(unionHoleRings([square(0, 0, 2), square(1, 1, 2)])).count, 1, "重なる") + XCTAssertEqual(try XCTUnwrap(unionHoleRings([square(0, 0, 4), square(1, 1, 1)])).count, 1, "中に入った") + XCTAssertEqual(try XCTUnwrap(unionHoleRings([square(0, 0, 1), square(0, 1, 1)])).count, 1, "辺で接する") + } + + func testManyDisjointHolesAreFast() throws { + // 穴 80 個 x 100 頂点 + var holes: [[GeoPointProtocol]] = [] + for i in 0..<80 { + let cx = Double(i % 10) * 3, cy = Double(i / 10) * 3 + holes.append((0..<100).map { k in + let a = 2 * Double.pi * Double(k) / 100 + return GeoPoint(latitude: cy + sin(a), longitude: cx + cos(a)) + }) + } + let start = DispatchTime.now().uptimeNanoseconds + let result = try XCTUnwrap(unionHoleRings(holes)) + let ms = Double(DispatchTime.now().uptimeNanoseconds - start) / 1e6 + XCTAssertEqual(result.count, 80) + XCTAssertLessThan(ms, 200, String(format: "%.1f ms", ms)) + } + + func testOnlyNonGeodesicPolygonsNarrowerThanHalfTheWorldAreTiled() { + XCTAssertTrue(PolygonTileSet.canTile(PolygonState(points: square(42, -71, 0.001)))) + XCTAssertFalse(PolygonTileSet.canTile(PolygonState(points: square(42, -71, 0.001), geodesic: true))) + XCTAssertFalse(PolygonTileSet.canTile(PolygonState(points: [ + GeoPoint(latitude: 0, longitude: -100), GeoPoint(latitude: 0, longitude: 100), GeoPoint(latitude: 10, longitude: 0), + ]))) + XCTAssertFalse(PolygonTileSet.canTile(PolygonState(points: Array(square(0, 0, 1).prefix(2))))) + XCTAssertEqual(PolygonTileSet.vertexCount([ + PolygonState(points: square(0, 0, 3), holes: [square(1, 1, 1)]), + PolygonState(points: square(5, 5, 1)), + ]), 12) + } +} diff --git a/Tests/MapConductorCoreTests/RasterLayerCoexistenceTests.swift b/Tests/MapConductorCoreTests/RasterLayerCoexistenceTests.swift new file mode 100644 index 0000000..60740e3 --- /dev/null +++ b/Tests/MapConductorCoreTests/RasterLayerCoexistenceTests.swift @@ -0,0 +1,64 @@ +import XCTest + +@_spi(MapConductorDriver) @testable import MapConductorCore + +/// 1 枚の地図に載るラスターレイヤーは 3 系統ある。アプリの RasterLayer(コレクタ経由の +/// `add(data:)`)、マーカータイル、ポリゴンタイル(mc-polygon-tiles)。 +/// js-sdk-core の `RasterLayerCoexistence.test.mjs`、android-sdk の +/// `RasterLayerCoexistenceTest` と 1 対 1 で対応する。 +/// +/// ## なぜここを固定するか +/// +/// web では SDK が自分で載せるタイルをアプリのレイヤー一覧と同じ経路で載せ外ししていた +/// プロバイダがあり、片方を載せるともう片方が消えた。SDK が載せるレイヤーは +/// `upsert` / `removeById` で扱い、`add(data:)` はアプリのレイヤーだけを入れ替える。 +final class RasterLayerCoexistenceTests: XCTestCase { + private final class Renderer: AbstractRasterLayerOverlayRenderer { + var onMap = Set() + override func createLayer(state: RasterLayerState) async -> String? { + onMap.insert(state.id) + return state.id + } + override func updateLayerProperties( + layer: String, current: RasterLayerEntity, prev: RasterLayerEntity + ) async -> String? { layer } + override func removeLayer(entity: RasterLayerEntity) async { + onMap.remove(entity.state.id) + } + } + + private func layer(_ id: String) -> RasterLayerState { + RasterLayerState(source: .urlTemplate(template: "https://example.com/\(id)/{z}/{x}/{y}.png"), id: id) + } + + private func markerTiles(_ version: Int) -> RasterLayerState { + RasterLayerState(source: .urlTemplate(template: "http://127.0.0.1:1/tiles/m/256/v\(version)/{z}/{x}/{y}.png"), id: "mc-marker-tiles") + } + + func testAppLayersMarkerTilesAndPolygonTilesDoNotRemoveEachOther() async { + let renderer = Renderer() + let controller = RasterLayerController( + rasterLayerManager: RasterLayerManager(), renderer: renderer + ) + await controller.add(data: [layer("relief")]) + await controller.upsert(state: PolygonTiler.rasterLayerState(template: "http://127.0.0.1:1/tiles/p/256/v1/{z}/{x}/{y}.png")) + await controller.upsert(state: markerTiles(1)) + XCTAssertEqual(renderer.onMap.sorted(), ["mc-marker-tiles", "mc-polygon-tiles", "relief"]) + + // アプリの RasterLayer が変わっても、SDK のタイルは残る + await controller.add(data: [layer("photo")]) + XCTAssertEqual(renderer.onMap.sorted(), ["mc-marker-tiles", "mc-polygon-tiles", "photo"]) + + // マーカータイルの版が変わっても、ほかは残る + await controller.upsert(state: markerTiles(2)) + XCTAssertEqual(renderer.onMap.sorted(), ["mc-marker-tiles", "mc-polygon-tiles", "photo"]) + + // マーカータイルを外しても、ほかは残る + await controller.removeById("mc-marker-tiles") + XCTAssertEqual(renderer.onMap.sorted(), ["mc-polygon-tiles", "photo"]) + + // アプリのレイヤーが全部無くなっても、ポリゴンタイルは残る + await controller.add(data: []) + XCTAssertEqual(renderer.onMap.sorted(), ["mc-polygon-tiles"]) + } +} diff --git a/api/MapConductorCore.api.swift b/api/MapConductorCore.api.swift index f551c60..ea1211a 100644 --- a/api/MapConductorCore.api.swift +++ b/api/MapConductorCore.api.swift @@ -2471,6 +2471,16 @@ final public class PolygonState : Combine::ObservableObject, Swift::Identifiable public protocol PolygonSyncHandler : AnyObject { func bindPolygonSync(_ polygonSync: @escaping @_Concurrency::MainActor ([MapConductorCore::PolygonState]) async -> Swift::Void) } +public struct PolygonTilingOptions : Swift::Equatable { + public let enabled: Swift::Bool + public let minPolygonCount: Swift::Int + public let minVertexCount: Swift::Int + public static let Default: MapConductorCore::PolygonTilingOptions + public static let Disabled: MapConductorCore::PolygonTilingOptions + public init(enabled: Swift::Bool = true, minPolygonCount: Swift::Int = 2000, minVertexCount: Swift::Int = 50_000) + public func shouldUseTiles(polygonCount: Swift::Int, vertexCount: Swift::Int) -> Swift::Bool + public static func == (a: MapConductorCore::PolygonTilingOptions, b: MapConductorCore::PolygonTilingOptions) -> Swift::Bool +} extension MapConductorCore::PolygonState { final public func unionHoles() -> MapConductorCore::PolygonState final public func unionHolesInBackground() async -> MapConductorCore::PolygonState