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) + } +}