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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions Sources/MapConductorCore/geocell/HexCellRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,29 +29,43 @@ public final class HexCellRegistry<ActualMarker> {
}

/// Register or update an entity in the registry.
///
/// K-D 木が持つのはセルなので、**セルの集合が変わったときだけ**作り直しの印を付ける。
/// 位置の変わらない更新や、既にあるセルへの追加のたびに作り直すと、マーカーを
/// 差し替えるたびに次の空間検索(タップの当たり判定など)が全件の再構築を待つことになる。
@discardableResult
public func setPoint(entity: MarkerEntity<ActualMarker>) -> 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<String>()
entryIds.insert(entityId)
entryIDsByCell[cellId] = entryIds

markDirty()
return cell
if cellsChanged { markDirty() }
return existing ?? cell
}

public func contains(hexId: String) -> Bool {
Expand All @@ -72,7 +86,8 @@ public final class HexCellRegistry<ActualMarker> {
let removed = removeFromCell(cellId: cellId, entityId: entityId)
if removed {
allEntries.removeValue(forKey: entityId)
markDirty()
// セルにまだ他のエンティティが残っていれば、木は変わらない
if allCells[cellId] == nil { markDirty() }
}
return removed
}
Expand Down
56 changes: 45 additions & 11 deletions Sources/MapConductorCore/geocell/KDTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<to]` の中で中央値だけを選び(quickselect)、左右を同じ配列のまま
/// 再帰する。階層ごとにソートして配列を作ると、数万件で数百ミリ秒かかり、
/// マーカーを足し引きした直後の空間検索がその間止まる。
private static func build(items: inout [HexCell], from: Int, to: Int, depth: Int) -> 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[..<mid]), depth: depth + 1),
right: build(items: mid + 1 < sorted.count ? Array(sorted[(mid + 1)...]) : [], depth: depth + 1),
cell: items[mid],
left: build(items: &items, from: from, to: mid, depth: depth + 1),
right: build(items: &items, from: mid + 1, to: to, depth: depth + 1),
axis: axis
)
}

private static func axisValue(_ cell: HexCell, _ axis: Int) -> 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)
Expand Down
233 changes: 233 additions & 0 deletions Tests/MapConductorCoreTests/MarkerSpatialIndexTests.swift
Original file line number Diff line number Diff line change
@@ -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..<count).map { cellAt("s\($0)", next() * 1000, next() * 1000) }
}

private func distSq(_ a: CGPoint, _ b: CGPoint) -> 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<Int> {
HexCellRegistry(geocell: HexGeocell.defaultGeocell(), zoom: 20)
}

private func entity(_ id: String, _ latitude: Double, _ longitude: Double) -> MarkerEntity<Int> {
MarkerEntity(
marker: nil,
state: MarkerState(position: GeoPoint(latitude: latitude, longitude: longitude), id: id)
)
}

/// 検索を一度通して木を建て、作り直しの印を落とした状態にする。
private func settle(_ registry: HexCellRegistry<Int>, 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<Int>(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<Int>(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)
}
}
Loading