From bc02c0b037cb013e06d1c8124da1b3245686ea37 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 4 Nov 2026 18:03:16 -0800 Subject: [PATCH] Rebuild the marker spatial index less, and build it with quickselect HexCellRegistry marked the K-D tree dirty on every setPoint and removePoint, including updates that left the marker in the same cell and additions to a cell that already existed. The tree holds cells, not markers, so the next spatial query rebuilt it over every cell for nothing. It is now marked dirty only when the set of cells changes, and an existing cell is not replaced, so the cell returned is the one the tree holds. KDTree sorted a copy of the cells at every level. It now selects only the median inside one array (Wirth's quickselect) and recurses on index ranges. Hex cell centres share x per column, so the partition closes in from both ends; a one-sided partition is O(n^2) on equal keys. Same change as MapConductor/js-sdk-core#20. android-sdk-core already had the registry part. --- .../geocell/HexCellRegistry.swift | 31 ++- Sources/MapConductorCore/geocell/KDTree.swift | 56 ++++- .../MarkerSpatialIndexTests.swift | 233 ++++++++++++++++++ 3 files changed, 301 insertions(+), 19 deletions(-) create mode 100644 Tests/MapConductorCoreTests/MarkerSpatialIndexTests.swift diff --git a/Sources/MapConductorCore/geocell/HexCellRegistry.swift b/Sources/MapConductorCore/geocell/HexCellRegistry.swift index bd67d63..9675335 100644 --- a/Sources/MapConductorCore/geocell/HexCellRegistry.swift +++ b/Sources/MapConductorCore/geocell/HexCellRegistry.swift @@ -29,29 +29,43 @@ public final class HexCellRegistry { } /// Register or update an entity in the registry. + /// + /// K-D 木が持つのはセルなので、**セルの集合が変わったときだけ**作り直しの印を付ける。 + /// 位置の変わらない更新や、既にあるセルへの追加のたびに作り直すと、マーカーを + /// 差し替えるたびに次の空間検索(タップの当たり判定など)が全件の再構築を待つことになる。 @discardableResult public func setPoint(entity: MarkerEntity) -> HexCell { lock.lock() defer { lock.unlock() } let entityId = entity.state.id + let cell = getCell(entity: entity) + let cellId = cell.id + + let oldCellId = allEntries[entityId] + // 同じセルのまま(位置が変わらない更新など) + if oldCellId == cellId, let sameCell = allCells[cellId] { return sameCell } - if let oldCellId = allEntries[entityId] { + var cellsChanged = false + if let oldCellId { _ = removeFromCell(cellId: oldCellId, entityId: entityId) + cellsChanged = allCells[oldCellId] == nil } - let cell = getCell(entity: entity) - let cellId = cell.id - - allCells[cellId] = cell + // 新しいセルへ。既にあるセルは置き換えない(木が持っているものと揃える) + let existing = allCells[cellId] + if existing == nil { + allCells[cellId] = cell + cellsChanged = true + } allEntries[entityId] = cellId var entryIds = entryIDsByCell[cellId] ?? Set() entryIds.insert(entityId) entryIDsByCell[cellId] = entryIds - markDirty() - return cell + if cellsChanged { markDirty() } + return existing ?? cell } public func contains(hexId: String) -> Bool { @@ -72,7 +86,8 @@ public final class HexCellRegistry { let removed = removeFromCell(cellId: cellId, entityId: entityId) if removed { allEntries.removeValue(forKey: entityId) - markDirty() + // セルにまだ他のエンティティが残っていれば、木は変わらない + if allCells[cellId] == nil { markDirty() } } return removed } diff --git a/Sources/MapConductorCore/geocell/KDTree.swift b/Sources/MapConductorCore/geocell/KDTree.swift index 00f221a..34b4be9 100644 --- a/Sources/MapConductorCore/geocell/KDTree.swift +++ b/Sources/MapConductorCore/geocell/KDTree.swift @@ -20,27 +20,61 @@ public final class KDTree: Sendable { private let root: Node? public init(points: [HexCell]) { - self.root = points.isEmpty ? nil : Self.build(items: points, depth: 0) + // 呼び出し元の配列は並べ替えない(値型なのでコピーに対して並べ替える) + var items = points + self.root = items.isEmpty ? nil : Self.build(items: &items, from: 0, to: items.count, depth: 0) } - private static func build(items: [HexCell], depth: Int) -> Node? { - if items.isEmpty { return nil } + /// 再帰的に K-D 木を構築する。 + /// + /// `items[from.. Node? { + if from >= to { return nil } let axis = depth % 2 - let sorted = items.sorted { lhs, rhs in - if axis == 0 { lhs.centerXY.x < rhs.centerXY.x } - else { lhs.centerXY.y < rhs.centerXY.y } - } - let mid = sorted.count / 2 + let mid = from + (to - from) / 2 + selectNth(&items, left: from, right: to - 1, k: mid, axis: axis) return Node( - cell: sorted[mid], - left: build(items: Array(sorted[.. CGFloat { + axis == 0 ? cell.centerXY.x : cell.centerXY.y + } + + /// `items[left...right]` を、`items[k]` が軸の値で k 番目になるよう並べ替える + /// (左は以下、右は以上)。Wirth の quickselect。 + /// + /// 六角セルの中心は列ごとに同じ x を持つので、等しい値が大量に並ぶ。 + /// 左右から寄せる分割にしてあるのはそのため(片側に寄せる分割だと O(n²) になる)。 + private static func selectNth(_ items: inout [HexCell], left: Int, right: Int, k: Int, axis: Int) { + var lo = left + var hi = right + while lo < hi { + let pivot = axisValue(items[k], axis) + var i = lo + var j = hi + repeat { + while axisValue(items[i], axis) < pivot { i += 1 } + while pivot < axisValue(items[j], axis) { j -= 1 } + if i <= j { + items.swapAt(i, j) + i += 1 + j -= 1 + } + } while i <= j + if j < k { lo = i } + if k < i { hi = j } + } + } + public func nearest(query: CGPoint) -> HexCell? { guard let root else { return nil } return nearest(node: root, query: query, best: nil, bestDistSq: .greatestFiniteMagnitude) diff --git a/Tests/MapConductorCoreTests/MarkerSpatialIndexTests.swift b/Tests/MapConductorCoreTests/MarkerSpatialIndexTests.swift new file mode 100644 index 0000000..b0495e8 --- /dev/null +++ b/Tests/MapConductorCoreTests/MarkerSpatialIndexTests.swift @@ -0,0 +1,233 @@ +import CoreGraphics +import XCTest + +@testable import MapConductorCore + +/// マーカーの空間インデックス(`HexCellRegistry` / `KDTree`)。 +/// +/// ## なぜここを固定するか +/// +/// 数万件のマーカーを置いた地図で、マーカーを差し替えるたびに次の空間検索が K-D 木の +/// 全件再構築を待っていた。レジストリは位置の変わらない更新でも作り直しの印を付け、 +/// 木の構築は階層ごとにソートして配列を作っていた。 +/// +/// 速くしたことで結果が変わってはいけない。インデックスの取り違えは何も落とさず、 +/// マーカーが静かに見つからなくなるだけなので、総当たりの結果と比べる。 +final class MarkerSpatialIndexTests: XCTestCase { + + // MARK: - KDTree + + private func cellAt(_ id: String, _ x: Double, _ y: Double) -> HexCell { + HexCell( + coord: HexCoord(q: 0, r: 0), + centerLatLng: GeoPoint(latitude: 0, longitude: 0), + centerXY: CGPoint(x: x, y: y), + id: id + ) + } + + /// A deterministic spread, so a failure is reproducible. + private func random(_ seed: UInt64) -> () -> Double { + var state = seed + return { + state = state &* 6364136223846793005 &+ 1442695040888963407 + return Double(state >> 11) / Double(UInt64(1) << 53) + } + } + + /// 六角格子のように、同じ x(列)と同じ y が大量に並ぶ点。分割の取り違えはここで出る + private func gridCells() -> [HexCell] { + (0..<40).flatMap { col in + (0..<40).map { row in + cellAt("c\(col)_\(row)", Double(col) * 3, Double(row) * 2 + Double(col % 2)) + } + } + } + + private func scatteredCells(_ count: Int) -> [HexCell] { + let next = random(123_456_789) + return (0.. Double { + Double((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)) + } + + private func assertMatchesBruteForce(_ cells: [HexCell], file: StaticString = #filePath, line: UInt = #line) { + let tree = KDTree(points: cells) + // 全部を覆う半径で、木が全セルを持っていること + XCTAssertEqual( + tree.withinRadius(query: CGPoint(x: 500, y: 500), radius: 2_000).count, + cells.count, + file: file, + line: line + ) + + let next = random(42) + for _ in 0..<200 { + let query = CGPoint(x: next() * 130 - 5, y: next() * 90 - 5) + + guard let nearest = tree.nearest(query: query) else { + return XCTFail("nearest returned nil", file: file, line: line) + } + let best = cells.map { distSq($0.centerXY, query) }.min()! + XCTAssertEqual(distSq(nearest.centerXY, query), best, file: file, line: line) + + let radius = next() * 10 + let within = tree.withinRadiusWithDistance(query: query, radius: radius).map(\.cell.id).sorted() + let expected = cells + .filter { distSq($0.centerXY, query) <= radius * radius } + .map(\.id) + .sorted() + XCTAssertEqual(within, expected, file: file, line: line) + + let k = 1 + Int(next() * 8) + let knn = tree.nearestKWithDistance(query: query, k: k).map { $0.distanceMeters * $0.distanceMeters } + let expectedKnn = Array(cells.map { distSq($0.centerXY, query) }.sorted().prefix(k)) + XCTAssertEqual(knn.count, expectedKnn.count, file: file, line: line) + for (actual, wanted) in zip(knn, expectedKnn) { + XCTAssertEqual(actual, wanted, accuracy: 1e-6, file: file, line: line) + } + } + } + + func testKDTreeMatchesBruteForceOnGrid() { + assertMatchesBruteForce(gridCells()) + } + + func testKDTreeMatchesBruteForceOnScatteredPoints() { + assertMatchesBruteForce(scatteredCells(3_000)) + } + + // MARK: - HexCellRegistry + + private func registry() -> HexCellRegistry { + HexCellRegistry(geocell: HexGeocell.defaultGeocell(), zoom: 20) + } + + private func entity(_ id: String, _ latitude: Double, _ longitude: Double) -> MarkerEntity { + MarkerEntity( + marker: nil, + state: MarkerState(position: GeoPoint(latitude: latitude, longitude: longitude), id: id) + ) + } + + /// 検索を一度通して木を建て、作り直しの印を落とした状態にする。 + private func settle(_ registry: HexCellRegistry, file: StaticString = #filePath, line: UInt = #line) { + _ = registry.findWithinRadiusWithDistance(point: GeoPoint(latitude: 35.68, longitude: 139.76), radius: 1) + XCTAssertFalse(registry.getStats().needsRebuild, "前提: この時点で印は落ちている", file: file, line: line) + } + + /// 同じ位置での再登録は木を汚さない。ここが本命。 + func testReRegisteringAtSamePositionDoesNotRequestRebuild() { + let registry = registry() + registry.setPoint(entity: entity("a", 35.68, 139.76)) + settle(registry) + + for _ in 0..<5 { registry.setPoint(entity: entity("a", 35.68, 139.76)) } + + XCTAssertFalse(registry.getStats().needsRebuild) + XCTAssertEqual(registry.getStats().totalCells, 1) + } + + /// 既にあるセルへの追加も、セルの集合は変わらない。 + func testAddingToExistingCellDoesNotRequestRebuild() { + let registry = registry() + // zoom 20 のセルは 10cm 程度なので、同じセルに入れるには座標を完全に一致させる。 + let cell = registry.setPoint(entity: entity("a", 35.68, 139.76)) + settle(registry) + + let again = registry.setPoint(entity: entity("b", 35.68, 139.76)) + + XCTAssertFalse(registry.getStats().needsRebuild) + XCTAssertEqual(again, cell) + XCTAssertEqual(registry.getEntryIDsByHexCell(cell), ["a", "b"]) + } + + /// 位置が変わればセルの集合が変わるので、作り直しを要求する。 + func testMovingToAnotherCellRequestsRebuild() { + let registry = registry() + let before = registry.setPoint(entity: entity("a", 35.68, 139.76)) + settle(registry) + + let after = registry.setPoint(entity: entity("a", 34.70, 135.50)) + + XCTAssertNotEqual(before.id, after.id) + XCTAssertTrue(registry.getStats().needsRebuild) + XCTAssertEqual(registry.getStats().totalCells, 1) + XCTAssertEqual(registry.getEntryIDsByHexCell(after), ["a"]) + } + + /// 同じセルに残りがあるなら、1 件消してもセルの集合は変わらない。 + func testRemovingOneOfSharedCellDoesNotRequestRebuild() { + let registry = registry() + let cell = registry.setPoint(entity: entity("a", 35.68, 139.76)) + registry.setPoint(entity: entity("b", 35.68, 139.76)) + settle(registry) + + registry.removePoint(entity: entity("a", 35.68, 139.76)) + + XCTAssertFalse(registry.getStats().needsRebuild) + XCTAssertEqual(registry.getEntryIDsByHexCell(cell), ["b"]) + } + + /// 最後の 1 件を消すとセルが消えるので、作り直しを要求する。 + func testRemovingLastOfCellRequestsRebuild() { + let registry = registry() + registry.setPoint(entity: entity("a", 35.68, 139.76)) + settle(registry) + + registry.removePoint(entity: entity("a", 35.68, 139.76)) + + XCTAssertTrue(registry.getStats().needsRebuild) + XCTAssertEqual(registry.getStats().totalCells, 0) + } + + /// 更新・追加・移動・削除を重ねたあとも、最初から作り直したレジストリと同じ答えを返す。 + func testIncrementalUpdatesMatchFreshRegistry() { + let next = random(7) + let base = (0..<400).map { i in + (id: "m\(i)", lat: 35.0 + next() * 0.01, lng: 139.0 + next() * 0.01) + } + let incremental = HexCellRegistry(geocell: HexGeocell.defaultGeocell(), zoom: 14) + base.forEach { incremental.setPoint(entity: entity($0.id, $0.lat, $0.lng)) } + let center = GeoPoint(latitude: 35.005, longitude: 139.005) + _ = incremental.findWithinRadius(point: center, radius: 1) + + var final: [String: (lat: Double, lng: Double)] = [:] + for (i, m) in base.enumerated() { + switch i % 4 { + case 0: // 位置の変わらない更新 + incremental.setPoint(entity: entity(m.id, m.lat, m.lng)) + final[m.id] = (m.lat, m.lng) + case 1: // 移動 + let moved = (lat: 35.0 + next() * 0.01, lng: 139.0 + next() * 0.01) + incremental.setPoint(entity: entity(m.id, moved.lat, moved.lng)) + final[m.id] = moved + case 2: // 削除 + incremental.removePoint(entity: entity(m.id, m.lat, m.lng)) + default: + final[m.id] = (m.lat, m.lng) + } + // 同じ座標への追加 + if i % 10 == 0 { + incremental.setPoint(entity: entity("dup\(i)", m.lat, m.lng)) + final["dup\(i)"] = (m.lat, m.lng) + } + } + + let fresh = HexCellRegistry(geocell: HexGeocell.defaultGeocell(), zoom: 14) + final.forEach { fresh.setPoint(entity: entity($0.key, $0.value.lat, $0.value.lng)) } + + for radius in [0.0, 50.0, 200.0, 2_000.0] { + XCTAssertEqual( + Set(incremental.findWithinRadius(point: center, radius: radius).map(\.id)), + Set(fresh.findWithinRadius(point: center, radius: radius).map(\.id)), + "radius \(radius)" + ) + } + XCTAssertEqual(incremental.findNearest(point: center)?.id, fresh.findNearest(point: center)?.id) + XCTAssertEqual(incremental.getStats().totalCells, fresh.getStats().totalCells) + XCTAssertEqual(incremental.getStats().totalEntries, fresh.getStats().totalEntries) + } +}