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
12 changes: 7 additions & 5 deletions src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ class HexCellRegistry<ActualMarker>(
// 位置が変わらない更新は珍しくない — アイコンやフラグの差し替え(
// `MarkerManager.updateEntity` は毎回 setPoint を呼ぶ)や、
// [com.mapconductor.core.marker.MarkerViewportSwitch] のタイル⇔ネイティブ切り替えが該当する。
if (previousCellId == cellId && allCells.containsKey(cellId)) {
val sameCell = if (previousCellId == cellId) allCells[cellId] else null
if (sameCell != null) {
entryIDsByCell.compute(cellId) { _, existingSet ->
(existingSet ?: mutableSetOf()).apply { add(entityId) }
}
return cell
return sameCell
}

var cellsChanged = false
Expand All @@ -80,8 +81,9 @@ class HexCellRegistry<ActualMarker>(
}
}

// Add to new cell
if (allCells.put(cellId, cell) == null) cellsChanged = true
// Add to new cell. 既にあるセルは置き換えない(木が持っているものと揃える)
val existing = allCells.putIfAbsent(cellId, cell)
if (existing == null) cellsChanged = true
allEntries[entityId] = cellId

// Add entity to cell's entry list
Expand All @@ -90,7 +92,7 @@ class HexCellRegistry<ActualMarker>(
}

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

/**
Expand Down
73 changes: 62 additions & 11 deletions src/main/java/com/mapconductor/core/geocell/KDTree.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import kotlin.math.sqrt
class KDTree(
points: List<HexCell>,
) {
private val root = if (points.isNotEmpty()) build(points, 0) else null
// 呼び出し元のリストは並べ替えない
private val root =
if (points.isNotEmpty()) {
val items = points.toTypedArray()
build(items, 0, items.size, 0)
} else {
null
}

private class Node(
val cell: HexCell,
Expand All @@ -23,24 +30,27 @@ class KDTree(

/**
* Build the K-D tree recursively
*
* `items` の from から to の手前までの中で中央値だけを選び(quickselect)、左右を同じ配列のまま
* 再帰する。階層ごとにソートしてリストを作ると、数万件で数百ミリ秒かかり、
* マーカーを足し引きした直後の空間検索がその間止まる。
*/
private fun build(
items: List<HexCell>,
items: Array<HexCell>,
from: Int,
to: Int,
depth: Int,
): Node? {
if (items.isEmpty()) return null
if (from >= to) return null

val axis = depth % 2
val sorted =
items.sortedBy {
if (axis == 0) it.centerXY.x else it.centerXY.y
}
val mid = sorted.size / 2
val mid = from + (to - from) / 2
selectNth(items, from, to - 1, mid, axis)

return Node(
cell = sorted[mid],
left = build(sorted.subList(0, mid), depth + 1),
right = build(sorted.subList(mid + 1, sorted.size), depth + 1),
cell = items[mid],
left = build(items, from, mid, depth + 1),
right = build(items, mid + 1, to, depth + 1),
axis = axis,
)
}
Expand Down Expand Up @@ -326,3 +336,44 @@ data class KDTreeStats(
val maxDepth: Int,
val isEmpty: Boolean,
)

private fun axisValue(
cell: HexCell,
axis: Int,
): Double = if (axis == 0) cell.centerXY.x else cell.centerXY.y

/**
* `items` の left から right まで(両端を含む)を、k 番目の要素が軸の値で k 番目になるよう並べ替える
* (左は以下、右は以上)。Wirth の quickselect。
*
* 六角セルの中心は列ごとに同じ x を持つので、等しい値が大量に並ぶ。
* 左右から寄せる分割にしてあるのはそのため(片側に寄せる分割だと O(n²) になる)。
*/
private fun selectNth(
items: Array<HexCell>,
left: Int,
right: Int,
k: Int,
axis: Int,
) {
var lo = left
var hi = right
while (lo < hi) {
val pivot = axisValue(items[k], axis)
var i = lo
var j = hi
do {
while (axisValue(items[i], axis) < pivot) i++
while (pivot < axisValue(items[j], axis)) j--
if (i <= j) {
val tmp = items[i]
items[i] = items[j]
items[j] = tmp
i++
j--
}
} while (i <= j)
if (j < k) lo = i
if (k < i) hi = j
}
}
95 changes: 95 additions & 0 deletions src/test/java/com/mapconductor/core/geocell/KDTreeBuildTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.mapconductor.core.geocell

import com.mapconductor.core.features.GeoPoint
import com.mapconductor.core.projection.ProjectedPoint
import org.junit.Assert.assertEquals
import org.junit.Test

/**
* [KDTree] の構築(quickselect)が、総当たりと同じ答えを返すこと。
*
* 階層ごとにソートしてリストを作っていた構築を、1 本の配列の中で中央値だけを選ぶ形に
* 変えた。木の取り違えは何も落とさず、マーカーが静かに見つからなくなるだけなので、
* 総当たりの結果と比べる。六角セルの中心は列ごとに同じ x を持つので、同じ座標が
* 大量に並ぶ格子でも確かめる(分割の取り違えはそこで出る)。
*/
class KDTreeBuildTest {
private fun cellAt(
id: String,
x: Double,
y: Double,
) = HexCell(
coord = HexCoord(0, 0),
centerLatLng = GeoPoint(0.0, 0.0),
centerXY = ProjectedPoint(x, y),
id = id,
)

private fun random(seed: Long): () -> Double {
var state = seed
return {
state = state * 6364136223846793005L + 1442695040888963407L
(state ushr 11).toDouble() / (1L shl 53).toDouble()
}
}

private fun gridCells(): List<HexCell> =
(0 until 40).flatMap { col ->
(0 until 40).map { row ->
cellAt("c${col}_$row", col * 3.0, row * 2.0 + (col % 2))
}
}

private fun scatteredCells(count: Int): List<HexCell> {
val next = random(123456789L)
return (0 until count).map { cellAt("s$it", next() * 1000.0, next() * 1000.0) }
}

private fun distSq(
a: ProjectedPoint,
b: ProjectedPoint,
): Double = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)

private fun assertMatchesBruteForce(cells: List<HexCell>) {
val tree = KDTree(cells)
assertEquals(cells.size, tree.getStats().nodeCount)

val next = random(42L)
repeat(200) {
val query = ProjectedPoint(next() * 130.0 - 5.0, next() * 90.0 - 5.0)

val nearest = tree.nearest(query)!!
val best = cells.minOf { distSq(it.centerXY, query) }
assertEquals(best, distSq(nearest.centerXY, query), 0.0)

val radius = next() * 10.0
val within = tree.withinRadiusWithDistance(query, radius).map { it.cell.id }.sorted()
val expected =
cells
.filter { distSq(it.centerXY, query) <= radius * radius }
.map { it.id }
.sorted()
assertEquals(expected, within)

val k = 1 + (next() * 8).toInt()
val knn = tree.nearestKWithDistance(query, k).map { it.distanceMeters * it.distanceMeters }
val expectedKnn = cells.map { distSq(it.centerXY, query) }.sorted().take(k)
assertEquals(expectedKnn.size, knn.size)
expectedKnn.zip(knn).forEach { (e, a) -> assertEquals(e, a, 1e-6) }
}
}

@Test
fun `同じ座標が並ぶ格子で総当たりと一致する`() = assertMatchesBruteForce(gridCells())

@Test
fun `ばらばらの点で総当たりと一致する`() = assertMatchesBruteForce(scatteredCells(3_000))

@Test
fun `渡したリストを並べ替えない`() {
val cells = scatteredCells(500)
val before = cells.map { it.id }
KDTree(cells)
assertEquals(before, cells.map { it.id })
}
}
Loading