From dc0daae6283925c6c489c6359db0a959727a3398 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 00:05:09 -0800 Subject: [PATCH 01/11] Polygon hit test: count a tap as on an edge only within eps of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pointOnSegment compared the dot product (p-a)·(p-b), a quantity in square degrees, with eps = 1e-6, so any tap within about 0.001° (~100 m) of the middle of a short edge counted as on that edge. With building footprints the first nearby building in the list was returned instead of the tapped one. Now it is the plain distance to the segment, within 1e-6° (~0.1 m). --- .../polygon/PolygonManager.swift | 14 ++-- .../PolygonHitToleranceTests.swift | 66 +++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 Tests/MapConductorCoreTests/PolygonHitToleranceTests.swift diff --git a/Sources/MapConductorCore/polygon/PolygonManager.swift b/Sources/MapConductorCore/polygon/PolygonManager.swift index c06bdf4..32696f3 100644 --- a/Sources/MapConductorCore/polygon/PolygonManager.swift +++ b/Sources/MapConductorCore/polygon/PolygonManager.swift @@ -197,13 +197,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/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") + } +} From bb86d23fa8c71a5464153ac645dca7190427c1eb Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 04:26:49 -0800 Subject: [PATCH 02/11] Skip the hole union when the holes are already disjoint Holes that neither cross, touch nor contain one another are their own union. The check bins the edges into a grid, so it is linear; the union is quadratic in the hole edges, which a street-network parcel with 80 holes and 8,000 hole vertices turned into seconds. The rings still come back clockwise, as the union returns them. js-sdk-core takes the same shortcut. --- .../polygon/PolygonUnion.swift | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/Sources/MapConductorCore/polygon/PolygonUnion.swift b/Sources/MapConductorCore/polygon/PolygonUnion.swift index f234252..78ab69b 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 @@ -290,6 +294,71 @@ 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. Edges are binned into a uniform +/// grid and only pairs sharing a cell are tested, so the cost grows with the +/// edge count rather than its square. +private func holesAreDisjoint(_ rings: [[Vec]]) -> Bool { + let boxes = rings.map(ringBox) + // 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 = boxes[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: Edge, ring: Int)] = [] + for (r, ring) in rings.enumerated() { + for i in ring.indices { + edges.append((Edge(a: ring[i], b: ring[(i + 1) % ring.count]), r)) + } + } + var minX = Double.infinity, minY = Double.infinity, maxX = -Double.infinity, maxY = -Double.infinity + for b in boxes { + minX = min(minX, b.minX); minY = min(minY, b.minY) + maxX = max(maxX, b.maxX); maxY = max(maxY, b.maxY) + } + let side = max(1, min(1024, Int(Double(edges.count).squareRoot().rounded(.up)))) + let cellW = (maxX - minX) / Double(side) > 0 ? (maxX - minX) / Double(side) : 1 + let cellH = (maxY - minY) / Double(side) > 0 ? (maxY - minY) / Double(side) : 1 + func cellOf(_ v: Double, _ lo: Double, _ size: Double) -> Int { + max(0, min(side - 1, Int(floor((v - lo) / size)))) + } + // Padded by the tolerance so an edge lying on a cell border is in both cells. + var cells: [Int: [Int]] = [:] + for (e, item) in edges.enumerated() { + let a = item.edge.a, b = item.edge.b + let x0 = cellOf(min(a.x, b.x) - unionEps, minX, cellW), x1 = cellOf(max(a.x, b.x) + unionEps, minX, cellW) + let y0 = cellOf(min(a.y, b.y) - unionEps, minY, cellH), y1 = cellOf(max(a.y, b.y) + unionEps, minY, cellH) + for cx in x0...x1 { + for cy in y0...y1 { + cells[cx * side + cy, default: []].append(e) + } + } + } + for bucket in cells.values { + for i in bucket.indices { + for j in (i + 1).. (minX: Double, minY: Double, maxX: Double, maxY: Double) { + var minX = Double.infinity, minY = Double.infinity, maxX = -Double.infinity, maxY = -Double.infinity + for p in ring { + minX = min(minX, p.x); maxX = max(maxX, p.x) + minY = min(minY, p.y); maxY = max(maxY, p.y) + } + return (minX, minY, maxX, maxY) +} + /// How many rings' interiors contain the point. private func coverage(_ point: Vec, _ rings: [[Vec]]) -> Int { rings.reduce(0) { count, ring in From cd82075b1639834f0bd89c09569f4c9f9c95b71e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 04:26:49 -0800 Subject: [PATCH 03/11] OverlayCollector: return early when handed the same list again SwiftUI re-evaluates the map content on every camera frame and the host syncs the same states back into the collector. Diffing them cost a set, a dictionary and a listener per state: 260 ms a frame for Boston's 98,843 parcels on an iPad Pro, so every pan ran at 5 fps. The same instances in the same order have not changed membership and are already listened to. --- .../overlay/OverlayCollector.swift | 12 +++++ .../OverlayCollectorScaleTests.swift | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/Sources/MapConductorCore/overlay/OverlayCollector.swift b/Sources/MapConductorCore/overlay/OverlayCollector.swift index a1a4cd2..2be2efa 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 @@ -122,6 +128,12 @@ public final class OverlayCollector { } } + private func isSameList(_ states: [S]) -> Bool { + guard states.count == latest.count, !states.isEmpty else { return false } + for i in states.indices where states[i] !== latest[i] { return false } + return true + } + /// membership 配信を 5ms の無入力窓にためる。窓の中で `sync(_:)` が続く限り窓を延長し、 /// 合流した回数が打ち切り弁に達したら待たずに出す。 private func scheduleMembership() { 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] { From e0eba5be13747d9a2f15b9758b0b6fc4c31c7b78 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 04:26:49 -0800 Subject: [PATCH 04/11] Draw large polygon sets as raster tiles (PolygonTiler) The iOS counterpart of js-sdk-core's PolygonTiler / PolygonTileSet and the polygon section of tile-sw.js, drawn the same way: - PolygonTilingOptions: tile from 2,000 polygons or 50,000 vertices. - PolygonTileRenderer (a TileProvider on the LocalTileServer): Web Mercator rings with the outline and holes wound opposite ways and filled nonzero; a grid index of bounding boxes (one z12 tile per cell); per-zoom simplified copies (snap to the pixel grid, drop repeats, Douglas-Peucker at 0.5 px, a dot under 1 px), four zooms kept; outline only, left out under 4 px; tiles cached without the version, and an in-place change drops only the tiles the polygon overlaps. - PolygonTileSet: route, version in the URL, 16 ms coalescing of changes. - PolygonController.tiler: tiled polygons stay in the PolygonManager for taps (original shapes) with no native polygon; the renderer never sees them. PolygonManager.find tests only the polygons whose bounds hold the tap, so a tap among 100,000 tiled parcels no longer reads every state. Driver-facing types are @_spi(MapConductorDriver); PolygonTilingOptions is public. Tests mirror PolygonTiling.test / RasterLayerCoexistence.test. --- .../polygon/PolygonController.swift | 44 +- .../polygon/PolygonEntity.swift | 56 ++ .../polygon/PolygonManager.swift | 9 +- .../polygon/PolygonTileRenderer.swift | 575 ++++++++++++++++++ .../polygon/PolygonTileSet.swift | 173 ++++++ .../polygon/PolygonTiler.swift | 109 ++++ .../polygon/PolygonTilingOptions.swift | 36 ++ .../PolygonControllerTilingTests.swift | 124 ++++ .../PolygonTileRendererTests.swift | 293 +++++++++ .../PolygonTilingTests.swift | 78 +++ .../RasterLayerCoexistenceTests.swift | 64 ++ 11 files changed, 1557 insertions(+), 4 deletions(-) create mode 100644 Sources/MapConductorCore/polygon/PolygonTileRenderer.swift create mode 100644 Sources/MapConductorCore/polygon/PolygonTileSet.swift create mode 100644 Sources/MapConductorCore/polygon/PolygonTiler.swift create mode 100644 Sources/MapConductorCore/polygon/PolygonTilingOptions.swift create mode 100644 Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift create mode 100644 Tests/MapConductorCoreTests/PolygonTileRendererTests.swift create mode 100644 Tests/MapConductorCoreTests/PolygonTilingTests.swift create mode 100644 Tests/MapConductorCoreTests/RasterLayerCoexistenceTests.swift diff --git a/Sources/MapConductorCore/polygon/PolygonController.swift b/Sources/MapConductorCore/polygon/PolygonController.swift index 4a795a1..c4173ce 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(tiledState: state)) + } + } + await renderer.onPostProcess() } } @@ -101,6 +132,11 @@ 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) { + polygonManager.registerEntity(PolygonEntity(tiledState: state)) + tiler?.update(state) + return + } let currentFinger = state.fingerPrint() let prevFinger = prevEntity.fingerPrint if currentFinger == prevFinger { return } @@ -120,7 +156,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) polygonManager.clear() } @@ -133,6 +172,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..7831e07 100644 --- a/Sources/MapConductorCore/polygon/PolygonEntity.swift +++ b/Sources/MapConductorCore/polygon/PolygonEntity.swift @@ -10,9 +10,65 @@ 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) + } + + /// A polygon drawn in tiles: kept for taps, never diffed against a renderer, + /// so its fingerprint (a hash of every vertex) is not worth computing. + init(tiledState state: PolygonState) { + self.polygon = nil + self.state = state + self.fingerPrint = PolygonFingerPrint( + id: 0, strokeColor: 0, strokeWidth: 0, fillColor: 0, geodesic: 0, + zIndex: 0, points: 0, holes: 0, extra: 0 + ) + 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 32696f3..06682ee 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 } diff --git a/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift b/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift new file mode 100644 index 0000000..608ee9d --- /dev/null +++ b/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift @@ -0,0 +1,575 @@ +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(_:)``). + /// 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: Polygon($0.shape)) } + 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..5921d7d --- /dev/null +++ b/Sources/MapConductorCore/polygon/PolygonTileSet.swift @@ -0,0 +1,173 @@ +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 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 + 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) } + } + pending.removeAll() + let live = registered + lock.unlock() + guard live, !updates.isEmpty else { return } + renderer.update(updates.map { (index: $0.0, shape: PolygonTileShape(state: $0.1)) }) + lock.lock() + version += 1 + lock.unlock() + onTemplateChanged?(template()) + } + + private func cancelPending() { + lock.lock() + pending.removeAll() + lock.unlock() + } +} diff --git a/Sources/MapConductorCore/polygon/PolygonTiler.swift b/Sources/MapConductorCore/polygon/PolygonTiler.swift new file mode 100644 index 0000000..125065b --- /dev/null +++ b/Sources/MapConductorCore/polygon/PolygonTiler.swift @@ -0,0 +1,109 @@ +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. + 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 + private var tiled: Set = [] + private var states: [PolygonState] = [] + + 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) } + } + } + + /// 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 { + tiled = Set(tileable.map(\.id)) + states = tileable + await onTemplate?(template, tileSet.tileSize) + } else { + let hadTiles = !tiled.isEmpty + tileSet.dispose() + tiled = [] + states = [] + if hadTiles { await onTemplate?(nil, tileSet.tileSize) } + } + return tiled + } + + /// The states drawn as tiles, as of the last ``route(_:)``. + public func tiledStates() -> [PolygonState] { + states + } + + public func isTiled(_ id: String) -> Bool { + 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) + } + + /// 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? { + tiled.isEmpty ? nil : tileSet.template() + } + + /// Unregisters the tiles without telling the provider; for a map being torn down. + public func dispose() { + tileSet.dispose() + tiled = [] + states = [] + } + + public func clear() async { + let hadTiles = !tiled.isEmpty + tileSet.dispose() + tiled = [] + states = [] + 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/Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift b/Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift new file mode 100644 index 0000000..627e005 --- /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(tiledState: 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/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/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"]) + } +} From 336755b9b0c73e98a9c29d802d954f9a6abeba2e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 05:04:41 -0800 Subject: [PATCH 05/11] OverlayCollector: compare a re-synced list as bytes, and hold no weak state Two costs that every camera frame paid for each overlay, measured on an iPad Pro with Boston's 98,843 parcels: - The same-list check compared states one by one through the generic, 14 ms a frame. The states are class references, so the two buffers are compared with memcmp. - The listener captured each state weakly. A weak reference gives the object a side table, and from then on every retain and release of it takes the slow path; the map content holds each state again on every frame, so that was a third of a pan's main-thread time. The listener now finds the state by id and checks its identity instead. --- .../overlay/OverlayCollector.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Sources/MapConductorCore/overlay/OverlayCollector.swift b/Sources/MapConductorCore/overlay/OverlayCollector.swift index 2be2efa..4da5d00 100644 --- a/Sources/MapConductorCore/overlay/OverlayCollector.swift +++ b/Sources/MapConductorCore/overlay/OverlayCollector.swift @@ -121,17 +121,30 @@ 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 } - for i in states.indices where states[i] !== latest[i] { return false } - return true + return states.withUnsafeBytes { a in + latest.withUnsafeBytes { b in + a.count == b.count && memcmp(a.baseAddress!, b.baseAddress!, a.count) == 0 + } + } } /// membership 配信を 5ms の無入力窓にためる。窓の中で `sync(_:)` が続く限り窓を延長し、 From 30730655cb33e08daaeb2082fdd7d143abea4d17 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 05:04:41 -0800 Subject: [PATCH 06/11] Map content: share overlay lists instead of copying them element by element SwiftUI rebuilds the map content on every camera frame. A single-expression builder went through buildBlock, which merged it into a fresh content, and Markers/Polygons/... built a list and then appended it: two copies of every list, each a retain and a release per overlay. Merging into an empty list now takes the other list's buffer, buildBlock returns a lone component as it is, and the collection items assign into an empty list. With 98,843 parcels in tiles, a pan went from about 30 to about 80 fps on the iPad Pro (117 fps with 605 polygons). --- .../MapConductorCore/MapCollectionItems.swift | 15 ++++++--- Sources/MapConductorCore/MapViewContent.swift | 31 ++++++++++++++----- 2 files changed, 33 insertions(+), 13 deletions(-) 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) From 5059594263065973f22104d74a0b1762ee5b9c66 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 05:55:45 -0800 Subject: [PATCH 07/11] PolygonTiler: refresh the tiles when the tile server moves, and guard its state The marker tiles check the server's port on every content update; the polygon tiles now do the same (refreshIfServerMoved), since the server comes back on another port after the app has been in the background. route() runs wherever PolygonController.add runs, off the main thread, while the content update calls refreshIfServerMoved() on the main thread, so the tiled ids, states and the shown base URL are read and written under a lock. --- .../polygon/PolygonTiler.swift | 73 +++++++++++++++---- 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/Sources/MapConductorCore/polygon/PolygonTiler.swift b/Sources/MapConductorCore/polygon/PolygonTiler.swift index 125065b..b8c89bf 100644 --- a/Sources/MapConductorCore/polygon/PolygonTiler.swift +++ b/Sources/MapConductorCore/polygon/PolygonTiler.swift @@ -34,8 +34,14 @@ public final class PolygonTiler { 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 @@ -46,6 +52,12 @@ public final class PolygonTiler { } } + 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] = [] @@ -59,26 +71,35 @@ public final class PolygonTiler { let useTiles = options.shouldUseTiles(polygonCount: tileable.count, vertexCount: vertices) let template = useTiles ? await tileSet.setPolygons(tileable) : nil if let template { - tiled = Set(tileable.map(\.id)) - states = tileable + let ids = Set(tileable.map(\.id)) + let baseUrl = TileServerRegistry.get().baseUrl + withLock { + tiled = ids + states = tileable + shownBaseUrl = baseUrl + } await onTemplate?(template, tileSet.tileSize) - } else { - let hadTiles = !tiled.isEmpty - tileSet.dispose() + return ids + } + let hadTiles = withLock { () -> Bool in + let had = !tiled.isEmpty tiled = [] states = [] - if hadTiles { await onTemplate?(nil, tileSet.tileSize) } + shownBaseUrl = nil + return had } - return tiled + tileSet.dispose() + if hadTiles { await onTemplate?(nil, tileSet.tileSize) } + return [] } /// The states drawn as tiles, as of the last ``route(_:)``. public func tiledStates() -> [PolygonState] { - states + withLock { states } } public func isTiled(_ id: String) -> Bool { - tiled.contains(id) + withLock { tiled.contains(id) } } /// An in-place change of a tiled polygon; only the tiles it touches are redrawn. @@ -86,24 +107,46 @@ public final class PolygonTiler { tileSet.update(state) } + /// 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? { - tiled.isEmpty ? nil : tileSet.template() + 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() - tiled = [] - states = [] } public func clear() async { - let hadTiles = !tiled.isEmpty + let hadTiles = withLock { () -> Bool in + let had = !tiled.isEmpty + tiled = [] + states = [] + shownBaseUrl = nil + return had + } tileSet.dispose() - tiled = [] - states = [] if hadTiles { await onTemplate?(nil, tileSet.tileSize) } } } From 9552cf81431b06b464f4575df4d814c846b2581b Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 06:05:08 -0800 Subject: [PATCH 08/11] PolylineController: guard the current camera with a lock The map hosts set it from their camera callbacks on the main thread, and onCameraChanged, which the overlay registry runs in a task off the main thread, sets it too. Two unguarded writes of a class reference release the old camera twice. Once pans ran at 75 fps with 100,000 tiled parcels on MapLibre, the app crashed a few pans in (EXC_BAD_ACCESS releasing the camera captured by the regionIsChanging task); with the lock, none in four runs of six pans each. --- .../polyline/PolylineController.swift | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Sources/MapConductorCore/polyline/PolylineController.swift b/Sources/MapConductorCore/polyline/PolylineController.swift index 00e21f6..12f6cd0 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)? @@ -142,7 +153,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 { From 5e923e5984ce248de90d2d65ddb74d9d925a09a1 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 06:07:29 -0800 Subject: [PATCH 09/11] Register tiled polygons through the public PolygonEntity initializer An internal initializer that skipped the fingerprint made the class lose its complete set of public designated initializers, which changes the published interface (@_hasMissingDesignatedInitializers). The fingerprint is computed after the tiles have been handed to the map, so it only delays when taps start resolving, not when the polygons show. --- .../MapConductorCore/polygon/PolygonController.swift | 4 ++-- Sources/MapConductorCore/polygon/PolygonEntity.swift | 12 ------------ .../PolygonControllerTilingTests.swift | 2 +- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/Sources/MapConductorCore/polygon/PolygonController.swift b/Sources/MapConductorCore/polygon/PolygonController.swift index c4173ce..60f7c99 100644 --- a/Sources/MapConductorCore/polygon/PolygonController.swift +++ b/Sources/MapConductorCore/polygon/PolygonController.swift @@ -120,7 +120,7 @@ where Renderer.ActualPolygon == ActualPolygon { } if !tiled.isEmpty, let tiler { for state in tiler.tiledStates() { - polygonManager.registerEntity(PolygonEntity(tiledState: state)) + polygonManager.registerEntity(PolygonEntity(polygon: nil, state: state)) } } @@ -133,7 +133,7 @@ where Renderer.ActualPolygon == ActualPolygon { await semaphore.withPermit { guard let prevEntity = polygonManager.getEntity(state.id) else { return } if tiledIds.contains(state.id) { - polygonManager.registerEntity(PolygonEntity(tiledState: state)) + polygonManager.registerEntity(PolygonEntity(polygon: nil, state: state)) tiler?.update(state) return } diff --git a/Sources/MapConductorCore/polygon/PolygonEntity.swift b/Sources/MapConductorCore/polygon/PolygonEntity.swift index 7831e07..1543d6c 100644 --- a/Sources/MapConductorCore/polygon/PolygonEntity.swift +++ b/Sources/MapConductorCore/polygon/PolygonEntity.swift @@ -20,18 +20,6 @@ public final class PolygonEntity: PolygonEntityProtocol { self.fingerPrint = state.fingerPrint() self.hitBounds = PolygonHitBounds(state) } - - /// A polygon drawn in tiles: kept for taps, never diffed against a renderer, - /// so its fingerprint (a hash of every vertex) is not worth computing. - init(tiledState state: PolygonState) { - self.polygon = nil - self.state = state - self.fingerPrint = PolygonFingerPrint( - id: 0, strokeColor: 0, strokeWidth: 0, fillColor: 0, geodesic: 0, - zIndex: 0, points: 0, holes: 0, extra: 0 - ) - self.hitBounds = PolygonHitBounds(state) - } } /// Where a tap can possibly hit a polygon, for skipping the rest cheaply. diff --git a/Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift b/Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift index 627e005..3785527 100644 --- a/Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift +++ b/Tests/MapConductorCoreTests/PolygonControllerTilingTests.swift @@ -113,7 +113,7 @@ final class PolygonControllerTilingTests: XCTestCase { // 10 万件のうち 1 件を引くのに、全件の state を読まない。 let manager = PolygonManager() let all = states(10_000) - for state in all { manager.registerEntity(PolygonEntity(tiledState: state)) } + 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") From e88cb6472d12e7df2ca0d62763184a98750eb6c6 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 06:07:29 -0800 Subject: [PATCH 10/11] Record the public API: PolygonTilingOptions --- api/MapConductorCore.api.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/api/MapConductorCore.api.swift b/api/MapConductorCore.api.swift index 54c7308..df15523 100644 --- a/api/MapConductorCore.api.swift +++ b/api/MapConductorCore.api.swift @@ -2465,6 +2465,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 From e482e9a298ba73ecb176b583932b0a4959d6c6af Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 19:48:57 -0800 Subject: [PATCH 11/11] PolygonController: move a tiled polygon to native drawing when a change makes it untileable Whether a polygon is tiled was decided only on add. Setting geodesic on a tiled polygon in place, or giving it points that span half the world or fewer than three, kept it in the tiles, drawn with straight edges. update() now checks canTile: a polygon that fails it leaves the tiles (its slot in the renderer is emptied, so only the tiles it was in are drawn again) and is handed to the renderer. It stays on the map, so this is not an add. The reverse is left alone: a native polygon that could now be tiled already looks right. Same as js-sdk-core. --- .../polygon/PolygonController.swift | 20 +- .../polygon/PolygonTileRenderer.swift | 7 +- .../polygon/PolygonTileSet.swift | 29 ++- .../polygon/PolygonTiler.swift | 15 +- .../PolygonTilingInPlaceTests.swift | 213 ++++++++++++++++++ 5 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 Tests/MapConductorCoreTests/PolygonTilingInPlaceTests.swift diff --git a/Sources/MapConductorCore/polygon/PolygonController.swift b/Sources/MapConductorCore/polygon/PolygonController.swift index 60f7c99..24b099e 100644 --- a/Sources/MapConductorCore/polygon/PolygonController.swift +++ b/Sources/MapConductorCore/polygon/PolygonController.swift @@ -133,8 +133,24 @@ where Renderer.ActualPolygon == ActualPolygon { await semaphore.withPermit { guard let prevEntity = polygonManager.getEntity(state.id) else { return } if tiledIds.contains(state.id) { - polygonManager.registerEntity(PolygonEntity(polygon: nil, state: state)) - tiler?.update(state) + 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() diff --git a/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift b/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift index 608ee9d..107cdc6 100644 --- a/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift +++ b/Sources/MapConductorCore/polygon/PolygonTileRenderer.swift @@ -206,11 +206,12 @@ final class PolygonTileRenderer: TileProvider { lock.unlock() } - /// Replaces the polygons at the given positions (as passed to ``setPolygons(_:)``). + /// 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)]) { + func update(_ changes: [(index: Int, shape: PolygonTileShape?)]) { guard !changes.isEmpty else { return } - let decoded = changes.map { (index: $0.index, polygon: Polygon($0.shape)) } + let decoded = changes.map { (index: $0.index, polygon: $0.shape.flatMap { Polygon($0) }) } lock.lock() defer { lock.unlock() } var next = data diff --git a/Sources/MapConductorCore/polygon/PolygonTileSet.swift b/Sources/MapConductorCore/polygon/PolygonTileSet.swift index 5921d7d..7b349c5 100644 --- a/Sources/MapConductorCore/polygon/PolygonTileSet.swift +++ b/Sources/MapConductorCore/polygon/PolygonTileSet.swift @@ -33,6 +33,7 @@ public final class PolygonTileSet { 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()))) { @@ -118,6 +119,25 @@ public final class PolygonTileSet { 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() @@ -154,11 +174,15 @@ public final class PolygonTileSet { 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 else { return } - renderer.update(updates.map { (index: $0.0, shape: PolygonTileShape(state: $0.1)) }) + 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() @@ -168,6 +192,7 @@ public final class PolygonTileSet { 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 index b8c89bf..cd260fe 100644 --- a/Sources/MapConductorCore/polygon/PolygonTiler.swift +++ b/Sources/MapConductorCore/polygon/PolygonTiler.swift @@ -10,7 +10,8 @@ import Foundation 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. + 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 @@ -107,6 +108,18 @@ public final class PolygonTiler { 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. 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() + } +}