From 121d27e25785809f14992b0da158770b8fcebd40 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 4 Nov 2026 18:33:39 -0800 Subject: [PATCH] Remove the unused KDTree and HexCellRegistry 928e7c3 moved MarkerManager's spatial search onto MarkerGridIndex. Since then the hex-cell registry was only built on demand by findByIdPrefix, which nothing calls: no SDK module, sample or doc uses it. KDTree existed only to serve the registry, and HexCellWithDistance only to carry their results. Remove all of them, the lazy registry plumbing in MarkerManager, and the registry's share of the memory estimate, along with the tests and doc pages that covered them. HexGeocell and HexCell stay; MarkerManager still takes a geocell. This removes public classes and a public method, so the API dump shrinks accordingly. --- api/android-sdk-core.api | 74 --- docs/geocell/HexCellRegistry.kt.md | 566 ------------------ docs/geocell/KDTree.kt.md | 297 --------- docs/marker/MarkerManager.kt.md | 28 +- .../core/geocell/HexCellRegistry.kt | 340 ----------- .../core/geocell/HexGeocellImpl.kt | 5 - .../com/mapconductor/core/geocell/KDTree.kt | 328 ---------- .../core/marker/MarkerGridIndex.kt | 12 +- .../mapconductor/core/marker/MarkerManager.kt | 57 +- .../geocell/HexCellRegistryRebuildTest.kt | 116 ---- .../core/geocell/KDTreeRadiusTest.kt | 48 -- 11 files changed, 14 insertions(+), 1857 deletions(-) delete mode 100644 docs/geocell/HexCellRegistry.kt.md delete mode 100644 docs/geocell/KDTree.kt.md delete mode 100644 src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt delete mode 100644 src/main/java/com/mapconductor/core/geocell/KDTree.kt delete mode 100644 src/test/java/com/mapconductor/core/geocell/HexCellRegistryRebuildTest.kt delete mode 100644 src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index cbc7bb1..4515c0d 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -445,36 +445,6 @@ public final class com.mapconductor.core.geocell.HexCell { public int hashCode(); public boolean equals(java.lang.Object); } -public final class com.mapconductor.core.geocell.HexCellRegistry { - public com.mapconductor.core.geocell.HexCellRegistry(com.mapconductor.core.geocell.HexGeocellInterface, double); - public final com.mapconductor.core.geocell.HexCell getCell(com.mapconductor.core.marker.MarkerEntityInterface); - public final com.mapconductor.core.geocell.HexCell setPoint(com.mapconductor.core.marker.MarkerEntityInterface); - public final boolean contains(java.lang.String); - public final boolean removePoint(com.mapconductor.core.marker.MarkerEntityInterface); - public final void clear(); - public final com.mapconductor.core.geocell.HexCell findNearest(com.mapconductor.core.features.GeoPointInterface); - public final com.mapconductor.core.geocell.HexCellWithDistance findNearestWithDistance(com.mapconductor.core.features.GeoPointInterface); - public final java.util.List findNearestKWithDistance(com.mapconductor.core.features.GeoPointInterface, int); - public final java.util.List findWithinRadiusWithDistance(com.mapconductor.core.features.GeoPointInterface, double); - public final java.util.List findWithinRadius(com.mapconductor.core.features.GeoPointInterface, double); - public final java.util.List all(); - public final java.util.Set getEntryIDsByHexCell(com.mapconductor.core.geocell.HexCell); - public final double metersPerPixel(com.mapconductor.core.features.GeoPointInterface, double, double, int); - public final java.util.List findWithinPixelRadius(com.mapconductor.core.features.GeoPointInterface, double, double, int); - public final java.util.List findByIdPrefix(java.lang.String); - public final com.mapconductor.core.geocell.RegistryStats getStats(); -} -public final class com.mapconductor.core.geocell.HexCellWithDistance { - public com.mapconductor.core.geocell.HexCellWithDistance(com.mapconductor.core.geocell.HexCell, double); - public final com.mapconductor.core.geocell.HexCell getCell(); - public final double getDistanceMeters(); - public final com.mapconductor.core.geocell.HexCell component1(); - public final double component2(); - public final com.mapconductor.core.geocell.HexCellWithDistance copy(com.mapconductor.core.geocell.HexCell, double); - public java.lang.String toString(); - public int hashCode(); - public boolean equals(java.lang.Object); -} public final class com.mapconductor.core.geocell.HexCoord { public com.mapconductor.core.geocell.HexCoord(int, int, int); public com.mapconductor.core.geocell.HexCoord(int, int, int, int, kotlin.jvm.internal.DefaultConstructorMarker); @@ -534,49 +504,6 @@ public final class com.mapconductor.core.geocell.IdentifiedHexCell { public int hashCode(); public boolean equals(java.lang.Object); } -public final class com.mapconductor.core.geocell.KDTree { - public com.mapconductor.core.geocell.KDTree(java.util.List); - public final com.mapconductor.core.geocell.HexCell nearest(com.mapconductor.core.projection.ProjectedPoint); - public final com.mapconductor.core.geocell.HexCellWithDistance nearestWithDistance(com.mapconductor.core.projection.ProjectedPoint); - public final java.util.List nearestKWithDistance(com.mapconductor.core.projection.ProjectedPoint, int); - public final java.util.List withinRadiusWithDistance(com.mapconductor.core.projection.ProjectedPoint, double); - public final java.util.List withinRadius(com.mapconductor.core.projection.ProjectedPoint, double); - public final com.mapconductor.core.geocell.KDTreeStats getStats(); -} -final class com.mapconductor.core.geocell.KDTree$Node { - public final com.mapconductor.core.geocell.HexCell getCell(); - public final com.mapconductor.core.geocell.KDTree$Node getLeft(); - public final com.mapconductor.core.geocell.KDTree$Node getRight(); - public final int getAxis(); -} -public final class com.mapconductor.core.geocell.KDTreeStats { - public com.mapconductor.core.geocell.KDTreeStats(int, int, boolean); - public final int getNodeCount(); - public final int getMaxDepth(); - public final boolean isEmpty(); - public final int component1(); - public final int component2(); - public final boolean component3(); - public final com.mapconductor.core.geocell.KDTreeStats copy(int, int, boolean); - public java.lang.String toString(); - public int hashCode(); - public boolean equals(java.lang.Object); -} -public final class com.mapconductor.core.geocell.RegistryStats { - public com.mapconductor.core.geocell.RegistryStats(int, int, boolean, boolean); - public final int getTotalCells(); - public final int getTotalEntries(); - public final boolean getKdTreeBuilt(); - public final boolean getNeedsRebuild(); - public final int component1(); - public final int component2(); - public final boolean component3(); - public final boolean component4(); - public final com.mapconductor.core.geocell.RegistryStats copy(int, int, boolean, boolean); - public java.lang.String toString(); - public int hashCode(); - public boolean equals(java.lang.Object); -} public final class com.mapconductor.core.geometry.CircleGeometryKt { public static final int DEFAULT_CIRCLE_SEGMENTS; public static final java.util.List circleToRing(com.mapconductor.core.features.GeoPointInterface, double, boolean, int); @@ -1795,7 +1722,6 @@ public class com.mapconductor.core.marker.MarkerManager { public com.mapconductor.core.marker.MarkerEntityInterface removeEntity(java.lang.String); public double metersPerPixel(com.mapconductor.core.features.GeoPointInterface, double, double, int); public com.mapconductor.core.marker.MarkerEntityInterface findNearest(com.mapconductor.core.features.GeoPointInterface); - public java.util.List findByIdPrefix(java.lang.String); public void registerEntity(com.mapconductor.core.marker.MarkerEntityInterface); public void updateEntity(com.mapconductor.core.marker.MarkerEntityInterface); public java.util.List> allEntities(); diff --git a/docs/geocell/HexCellRegistry.kt.md b/docs/geocell/HexCellRegistry.kt.md deleted file mode 100644 index be77239..0000000 --- a/docs/geocell/HexCellRegistry.kt.md +++ /dev/null @@ -1,566 +0,0 @@ -# HexCellRegistry - -## Table of Contents -- [HexCellRegistry](#hexcellregistryactualmarker) - - [Table of Contents](#table-of-contents) - - [Class: HexCellRegistry](#class-hexcellregistryactualmarker) - - [Constructor](#constructor) - - [Methods](#methods) - - [getCell](#getcell) - - [setPoint](#setpoint) - - [contains](#contains) - - [removePoint](#removepoint) - - [clear](#clear) - - [findNearest](#findnearest) - - [findNearestWithDistance](#findnearestwithdistance) - - [findNearestKWithDistance](#findnearestkwithdistance) - - [findWithinRadiusWithDistance](#findwithinradiuswithdistance) - - [all](#all) - - [getEntryIDsByHexCell](#getentryidsbyhexcell) - - [metersPerPixel](#metersperpixel) - - [findWithinPixelRadius](#findwithinpixelradius) - - [findByIdPrefix](#findbyidprefix) - - [getStats](#getstats) - - [Data Class: RegistryStats](#data-class-registrystats) - - [Properties](#properties) - -## Class: HexCellRegistry - -A thread-safe class for managing a collection of hexagonal grid cells. It provides efficient spatial -indexing and querying using an internal KDTree. - -The registry maps entities (like map markers) to the hexagonal cells they occupy at a specific zoom -level. It is designed for performance-critical applications, featuring lazy rebuilding of its -spatial index to optimize write operations. All public methods are thread-safe. - -### Constructor - -Initializes a new instance of the `HexCellRegistry`. - -**Signature** -```kotlin -class HexCellRegistry( - private val geocell: HexGeocellInterface, - private val zoom: Double, -) -``` - -**Parameters** - -- `geocell` - - Type: `HexGeocellInterface` - - Description: The hexagonal geocell system used for coordinate logic. -- `zoom` - - Type: `Double` - - Description: The map zoom level this registry is responsible for. - -
- -## Methods - -### getCell - -Calculates and returns the `HexCell` that would contain a given entity, without adding the entity to -the registry. This is a pure calculation method and does not modify the registry's state. - -**Signature** -```kotlin -fun getCell(entity: MarkerEntityInterface): HexCell -``` - -**Parameters** - -- `entity` - - Type: `MarkerEntityInterface` - - Description: The entity for which to calculate the cell. - -**Returns** - -`HexCell` - The hexagonal cell corresponding to the entity's position. - -**Example** -```kotlin -val entity: MarkerEntityInterface = /* ... get your entity ... */ -val correspondingCell = hexRegistry.getCell(entity) -println("Entity belongs in cell: ${correspondingCell.id}") -``` - ---- - -### setPoint - -Registers a new entity or updates the position of an existing one. If the entity was previously -registered in a different cell, it is automatically moved. This operation marks the internal spatial -index as needing a rebuild, which will occur on the next spatial query. - -**Signature** -```kotlin -fun setPoint(entity: MarkerEntityInterface): HexCell -``` - -**Parameters** - -- `entity` - - Type: `MarkerEntityInterface` - - Description: The entity to add or update in the registry. - -**Returns** - -`HexCell` - The hexagonal cell where the entity has been placed. - -**Example** -```kotlin -val newEntity: MarkerEntityInterface = /* ... create your entity ... */ -val cell = hexRegistry.setPoint(newEntity) -println("Entity ${newEntity.state.id} was added to cell ${cell.id}") -``` - ---- - -### contains - -Checks if a hexagonal cell with the specified ID exists in the registry. - -**Signature** -```kotlin -fun contains(hexId: String): Boolean -``` - -**Parameters** - -- `hexId` - - Type: `String` - - Description: The unique ID of the hexagonal cell. - -**Returns** - -`Boolean` - `true` if a cell with the given ID is registered, `false` otherwise. - -**Example** -```kotlin -if (hexRegistry.contains("8c2a1072b59ffff")) { - println("Cell exists in the registry.") -} -``` - ---- - -### removePoint - -Removes an entity from the registry. If the cell containing the entity becomes empty after its -removal, the cell itself is also removed from the registry. - -**Signature** -```kotlin -fun removePoint(entity: MarkerEntityInterface): Boolean -``` - -**Parameters** - -- `entity` - - Type: `MarkerEntityInterface` - - Description: The entity to remove. - -**Returns** - -`Boolean` - `true` if the entity was found and removed, `false` otherwise. - -**Example** -```kotlin -val entityToRemove: MarkerEntityInterface = /* ... get entity to remove ... */ -val wasRemoved = hexRegistry.removePoint(entityToRemove) -if (wasRemoved) { - println("Entity successfully removed.") -} -``` - ---- - -### clear - -Removes all entities and cells from the registry, effectively resetting it to an empty state. The -internal spatial index is also cleared. - -**Signature** -```kotlin -fun clear() -``` - -**Example** -```kotlin -hexRegistry.clear() -println("Registry has been cleared. Total cells: ${hexRegistry.getStats().totalCells}") -// Output: Registry has been cleared. Total cells: 0 -``` - ---- - -### findNearest - -Finds the single nearest registered `HexCell` to a given geographic point. This method may trigger a -rebuild of the spatial index if the registry has been modified since the last query. - -**Signature** -```kotlin -fun findNearest(point: GeoPointInterface): HexCell? -``` - -**Parameters** - -- `point` - - Type: `GeoPointInterface` - - Description: The geographic point to search from. - -**Returns** - -`HexCell?` - The nearest `HexCell`, or `null` if the registry is empty. - -**Example** -```kotlin -val searchPoint = GeoPoint(40.7128, -74.0060) // New York City -val nearestCell = hexRegistry.findNearest(searchPoint) -nearestCell?.let { - println("Nearest cell is ${it.id} at ${it.center.latitude}, ${it.center.longitude}") -} -``` - ---- - -### findNearestWithDistance - -Finds the single nearest registered `HexCell` to a given point and also returns the distance to it. -The distance is calculated in the projected coordinate system (e.g., meters). - -**Signature** -```kotlin -fun findNearestWithDistance(point: GeoPointInterface): HexCellWithDistance? -``` - -**Parameters** - -- `point` - - Type: `GeoPointInterface` - - Description: The geographic point to search from. - -**Returns** - -`HexCellWithDistance?` - An object containing the nearest cell and the distance, or `null` if the -registry is empty. - -**Example** -```kotlin -val searchPoint = GeoPoint(34.0522, -118.2437) // Los Angeles -val result = hexRegistry.findNearestWithDistance(searchPoint) -result?.let { - println("Nearest cell is ${it.cell.id}, distance: ${it.distance} meters") -} -``` - ---- - -### findNearestKWithDistance - -Finds the `k` nearest registered `HexCell` objects to a given point, ordered by distance. - -**Signature** -```kotlin -fun findNearestKWithDistance( - point: GeoPointInterface, - k: Int, -): List -``` - -**Parameters** - -- `point` - - Type: `GeoPointInterface` - - Description: The geographic point to search from. -- `k` - - Type: `Int` - - Description: The maximum number of nearest cells to find. - -**Returns** - -`List` - A list of up to `k` cells with their distances, sorted from nearest to -farthest. The list will be empty if the registry is empty. - -**Example** -```kotlin -val searchPoint = GeoPoint(48.8566, 2.3522) // Paris -val nearestFive = hexRegistry.findNearestKWithDistance(searchPoint, 5) -println("Found ${nearestFive.size} nearby cells:") -nearestFive.forEach { - println("- Cell ${it.cell.id} at distance ${it.distance}") -} -``` - ---- - -### findWithinRadiusWithDistance - -Finds all registered `HexCell` objects within a specified radius of a given point. The radius is -interpreted in the units of the projected coordinate system (e.g., meters). - -**Signature** -```kotlin -fun findWithinRadiusWithDistance( - point: GeoPointInterface, - radius: Double, -): List -``` - -**Parameters** - -- `point` - - Type: `GeoPointInterface` - - Description: The center point of the search area. -- `radius` - - Type: `Double` - - Description: The search radius, in projected units (typically meters). - -**Returns** - -`List` - A list of all cells found within the radius, along with their -respective distances from the center point. - -**Example** -```kotlin -val searchPoint = GeoPoint(51.5074, -0.1278) // London -val radiusInMeters = 1000.0 -val cellsInRadius = hexRegistry.findWithinRadiusWithDistance(searchPoint, radiusInMeters) -println("Found ${cellsInRadius.size} cells within 1km.") -``` - ---- - -### all - -Returns a complete list of all `HexCell` objects currently in the registry. - -**Signature** -```kotlin -fun all(): List -``` - -**Returns** - -`List` - A list containing all registered hexagonal cells. - -**Example** -```kotlin -val allRegisteredCells = hexRegistry.all() -println("Total registered cells: ${allRegisteredCells.size}") -``` - ---- - -### getEntryIDsByHexCell - -Retrieves the set of all entity IDs associated with a specific `HexCell`. - -**Signature** -```kotlin -fun getEntryIDsByHexCell(hexCell: HexCell): Set? -``` - -**Parameters** - -- `hexCell` - - Type: `HexCell` - - Description: The cell for which to retrieve entity IDs. - -**Returns** - -`Set?` - An immutable set of entity IDs within the given cell, or `null` if the cell is not -registered or contains no entities. - -**Example** -```kotlin -val cell: HexCell = /* ... get a cell from a query ... */ -val entityIds = hexRegistry.getEntryIDsByHexCell(cell) -entityIds?.let { - println("Entities in cell ${cell.id}: $it") -} -``` - ---- - -### metersPerPixel - -A utility function to calculate the approximate real-world distance in meters that corresponds to a -given number of pixels on a map display. - -**Note:** This calculation assumes the `HexGeocellInterface` projection returns coordinates in -meters. - -**Signature** -```kotlin -fun metersPerPixel( - position: GeoPointInterface, - zoom: Double, - pixels: Double, - tileSize: Int = 256, -): Double -``` - -**Parameters** - -- `position` - - Type: `GeoPointInterface` - - Description: The geographic location (latitude/longitude) for the calculation. -- `zoom` - - Type: `Double` - - Description: The current map zoom level. -- `pixels` - - Type: `Double` - - Description: The number of pixels for which to calculate the corresponding distance. -- `tileSize` - - Type: `Int` - - Description: (Optional) The size of the map tiles in pixels. Defaults to `256`. - -**Returns** - -`Double` - The calculated distance in meters. - -**Example** -```kotlin -val centerOfScreen = GeoPoint(35.6895, 139.6917) // Tokyo -val currentZoom = 15.0 -val distanceFor100Pixels = hexRegistry.metersPerPixel(centerOfScreen, currentZoom, 100.0) -println("At zoom $currentZoom, 100 pixels is approx. $distanceFor100Pixels meters.") -``` - ---- - -### findWithinPixelRadius - -A convenience method to find all `HexCell` objects within a given pixel radius of a point on the -screen. It internally converts the pixel radius to meters using `metersPerPixel` and then performs a -spatial search. - -**Signature** -```kotlin -fun findWithinPixelRadius( - position: GeoPointInterface, - zoom: Double, - pixels: Double, - tileSize: Int = 256, -): List -``` - -**Parameters** - -- `position` - - Type: `GeoPointInterface` - - Description: The center point of the search area. -- `zoom` - - Type: `Double` - - Description: The current map zoom level. -- `pixels` - - Type: `Double` - - Description: The search radius in pixels. -- `tileSize` - - Type: `Int` - - Description: (Optional) The size of the map tiles in pixels. Defaults to `256`. - -**Returns** - -`List` - A list of all cells found within the pixel radius, along with their -distances. - -**Example** -```kotlin -val tapPoint = GeoPoint(41.9028, 12.4964) // Rome -val currentZoom = 16.0 -val searchRadiusInPixels = 50.0 -val cellsNearTap = hexRegistry.findWithinPixelRadius(tapPoint, currentZoom, searchRadiusInPixels) -println("Found ${cellsNearTap.size} cells within a 50px radius of the tap.") -``` - ---- - -### findByIdPrefix - -Finds all registered cells whose IDs start with the given prefix. This is useful for querying groups -of cells in hierarchical grid systems (e.g., finding all child cells within a parent region). - -**Signature** -```kotlin -fun findByIdPrefix(prefix: String): List -``` - -**Parameters** - -- `prefix` - - Type: `String` - - Description: The cell ID prefix to search for. Must not be empty. - -**Returns** - -`List` - A list of all `HexCell` objects matching the prefix. - -**Example** -```kotlin -// Assuming a hierarchical cell ID system -val regionPrefix = "8a2a107" -val cellsInRegion = hexRegistry.findByIdPrefix(regionPrefix) -println("Found ${cellsInRegion.size} cells in region $regionPrefix.") -``` - ---- - -### getStats - -Retrieves diagnostic statistics about the current state of the registry. This is useful for -debugging and monitoring performance. - -**Signature** -```kotlin -fun getStats(): RegistryStats -``` - -**Returns** - -`RegistryStats` - A data object containing statistics such as total cell and entity counts. - -**Example** -```kotlin -val stats = hexRegistry.getStats() -println("Registry Stats:") -println("- Total Cells: ${stats.totalCells}") -println("- Total Entries: ${stats.totalEntries}") -println("- KDTree Built: ${stats.kdTreeBuilt}") -println("- Needs Rebuild: ${stats.needsRebuild}") -``` - -
- -## Data Class: RegistryStats - -A data class that holds statistics about the `HexCellRegistry`'s state. - -**Signature** -```kotlin -data class RegistryStats( - val totalCells: Int, - val totalEntries: Int, - val kdTreeBuilt: Boolean, - val needsRebuild: Boolean, -) -``` - -### Properties - -- `totalCells` - - Type: `Int` - - Description: The total number of unique hexagonal cells currently in the registry. -- `totalEntries` - - Type: `Int` - - Description: The total number of entities (e.g., markers) registered across all cells. -- `kdTreeBuilt` - - Type: `Boolean` - - Description: `true` if the KDTree spatial index has been built, `false` otherwise. -- `needsRebuild` - - Type: `Boolean` - - Description: `true` if the registry has been modified and the KDTree needs to be rebuilt on - the next spatial query. diff --git a/docs/geocell/KDTree.kt.md b/docs/geocell/KDTree.kt.md deleted file mode 100644 index 19edde2..0000000 --- a/docs/geocell/KDTree.kt.md +++ /dev/null @@ -1,297 +0,0 @@ -# KDTree - -## `KDTree` Class - -A K-D Tree (K-dimensional tree) implementation optimized for efficient 2D spatial queries on -`HexCell` objects. This class partitions a set of points (the centers of hex cells) in a -2-dimensional space, allowing for fast searches. - -It supports the following query types: -* **Nearest Neighbor:** Find the single closest point to a given location. -* **K-Nearest Neighbors (k-NN):** Find the `k` closest points to a given location. -* **Radius Search:** Find all points within a certain distance of a given location. - -### Signature - -```kotlin -class KDTree(points: List) -``` - -### Description - -Constructs a `KDTree` by recursively partitioning the provided list of `HexCell` points. The tree is -balanced by alternating the splitting axis (X and Y) at each level of depth. If the input list is -empty, an empty tree is created. - -### Parameters - -- `points` - - Type: `List` - - Description: A list of `HexCell` objects to build the tree from. - -### Example - -```kotlin -// Assuming HexCell and Offset are defined as: -// data class HexCell(val id: String, val centerXY: Offset) -// import androidx.compose.ui.geometry.Offset - -val hexCells = listOf( - HexCell("A", Offset(2f, 3f)), - HexCell("B", Offset(5f, 4f)), - HexCell("C", Offset(9f, 6f)), - HexCell("D", Offset(4f, 7f)), - HexCell("E", Offset(8f, 1f)), - HexCell("F", Offset(7f, 2f)) -) - -// Create a new KDTree instance -val kdTree = KDTree(hexCells) -``` - ---- - -## Methods - -### `nearest` - -Finds the single nearest `HexCell` to a given query point. - -#### Signature - -```kotlin -fun nearest(query: Offset): HexCell? -``` - -#### Description - -Performs a nearest neighbor search to find the `HexCell` in the tree whose center is closest to the -specified `query` point. - -#### Parameters - -- `query` - - Type: `Offset` - - Description: The coordinate point to search from. - -#### Returns - -The nearest `HexCell` object, or `null` if the tree is empty. - -#### Example - -```kotlin -val queryPoint = Offset(5f, 5f) -val nearestCell = kdTree.nearest(queryPoint) - -if (nearestCell != null) { - println("Nearest cell is ${nearestCell.id} at ${nearestCell.centerXY}") - // Expected output: Nearest cell is B at Offset(5.0, 4.0) -} -``` - ---- - -### `nearestWithDistance` - -Finds the single nearest `HexCell` to a query point and returns it along with the calculated -distance. - -#### Signature - -```kotlin -fun nearestWithDistance(query: Offset): HexCellWithDistance? -``` - -#### Description - -This method extends the `nearest` search by packaging the resulting `HexCell` and its Euclidean -distance from the `query` point into a `HexCellWithDistance` object. - -#### Parameters - -- `query` - - Type: `Offset` - - Description: The coordinate point to search from. - -#### Returns - -A `HexCellWithDistance` object containing the nearest cell and the distance, or `null` if the tree -is empty. - -#### Example - -```kotlin -// Assuming HexCellWithDistance is defined as: -// data class HexCellWithDistance(val cell: HexCell, val distanceMeters: Double) - -val queryPoint = Offset(5.1f, 4.2f) -val result = kdTree.nearestWithDistance(queryPoint) - -result?.let { - println("Nearest cell is ${it.cell.id} with a distance of ${it.distanceMeters}") - // Expected output: Nearest cell is B with a distance of 0.2236... -} -``` - ---- - -### `nearestKWithDistance` - -Finds the `k` nearest `HexCell`s to a query point, along with their respective distances. - -#### Signature - -```kotlin -fun nearestKWithDistance(query: Offset, k: Int): List -``` - -#### Description - -Performs a k-nearest neighbors (k-NN) search. It returns a list of the `k` closest `HexCell`s to the -`query` point, sorted by distance in ascending order. - -#### Parameters - -- `query` - - Type: `Offset` - - Description: The coordinate point to search from. -- `k` - - Type: `Int` - - Description: The number of nearest neighbors to find. Must be a positive integer. - -#### Returns - -A `List` containing the `k` nearest cells and their distances, sorted from -nearest to farthest. Returns an empty list if the tree is empty. - -#### Example - -```kotlin -val queryPoint = Offset(6f, 3f) -val k = 3 -val nearestThree = kdTree.nearestKWithDistance(queryPoint, k) - -println("Found ${nearestThree.size} neighbors:") -nearestThree.forEach { - println("- Cell ${it.cell.id} at distance ${it.distanceMeters}") -} -// Expected output: -// Found 3 neighbors: -// - Cell F at distance 1.414... -// - Cell B at distance 1.414... -// - Cell A at distance 4.0... -``` - ---- - -### `withinRadiusWithDistance` - -Finds all `HexCell`s within a specified radius of a query point. - -#### Signature - -```kotlin -fun withinRadiusWithDistance(query: Offset, radius: Double): List -``` - -#### Description - -Performs a radius search to find all `HexCell`s whose centers fall within the given `radius` of the -`query` point. The results include the distance to each cell and are sorted by distance. - -#### Parameters - -- `query` - - Type: `Offset` - - Description: The center point of the search circle. -- `radius` - - Type: `Double` - - Description: The radius of the search area. Must be non-negative. - -#### Returns - -A `List` of all cells found within the radius, sorted by distance. Returns an -empty list if no cells are within the radius or if the tree is empty. - -#### Example - -```kotlin -val queryPoint = Offset(7.5f, 2.5f) -val searchRadius = 2.0 -val cellsInRadius = kdTree.withinRadiusWithDistance(queryPoint, searchRadius) - -println("Found ${cellsInRadius.size} cells within a radius of $searchRadius:") -cellsInRadius.forEach { - println("- Cell ${it.cell.id} at distance ${it.distanceMeters}") -} -// Expected output: -// Found 2 cells within a radius of 2.0: -// - Cell F at distance 0.707... -// - Cell E at distance 1.581... -``` - ---- - -### `getStats` - -Retrieves statistics about the internal structure of the `KDTree`. - -#### Signature - -```kotlin -fun getStats(): KDTreeStats -``` - -#### Description - -Provides metrics about the constructed tree, which can be useful for debugging or performance -analysis. It returns the total number of nodes, the maximum depth of the tree, and a flag indicating -if the tree is empty. - -#### Returns - -A `KDTreeStats` object containing the tree's structural metrics. - -#### Example - -```kotlin -val stats = kdTree.getStats() - -println("Tree is empty: ${stats.isEmpty}") -println("Node count: ${stats.nodeCount}") -println("Max depth: ${stats.maxDepth}") - -// Example output for the tree created earlier: -// Tree is empty: false -// Node count: 6 -// Max depth: 4 -``` - ---- - -## `KDTreeStats` Data Class - -A data class that holds statistics about a `KDTree`'s structure. - -### Signature - -```kotlin -data class KDTreeStats( - val nodeCount: Int, - val maxDepth: Int, - val isEmpty: Boolean -) -``` - -### Properties - -- `nodeCount` - - Type: `Int` - - Description: The total number of nodes (cells) in the tree. -- `maxDepth` - - Type: `Int` - - Description: The maximum depth from the root to any leaf node. -- `isEmpty` - - Type: `Boolean` - - Description: `true` if the tree contains no nodes, otherwise `false`. diff --git a/docs/marker/MarkerManager.kt.md b/docs/marker/MarkerManager.kt.md index 57aff3e..c58b1c6 100644 --- a/docs/marker/MarkerManager.kt.md +++ b/docs/marker/MarkerManager.kt.md @@ -41,8 +41,9 @@ spatial querying capabilities. `MarkerManager` is designed to handle a large number of markers on a map. It employs a performance optimization strategy: for small datasets (fewer than `minMarkerCount`), it uses simple brute-force -searches. For larger datasets, it automatically creates and uses a spatial index (`HexCellRegistry`) -to perform fast spatial queries like finding the nearest marker or markers within a specific area. +searches. For larger datasets, it automatically creates and uses a spatial index (a lat/lng grid, +`MarkerGridIndex`) to perform fast spatial queries like finding the nearest marker or markers within +a specific area. This lazy initialization of the spatial index saves memory for use cases with a small number of markers. All operations that modify or access the internal collections are thread-safe. @@ -63,7 +64,8 @@ open class MarkerManager( - `geocell` - Type: `HexGeocellInterface` - - Description: The hexagonal geocelling system to use for the spatial index. + - Description: The hexagonal geocelling system. Exposed to subclasses; the spatial index itself + is a lat/lng grid and does not use it. - `minMarkerCount` - Type: `Int` - Description: The threshold for creating the spatial index. Spatial queries will use the index @@ -315,23 +317,3 @@ open fun metersPerPixel( #### Returns The calculated distance in meters. - -### `findByIdPrefix` -Searches the spatial index for `HexCell`s that match a given ID prefix. - -> **Note:** This method will only return results if the spatial index has been initialized (i.e., -when the entity count has exceeded `minMarkerCount`). Otherwise, it returns an empty list. - -#### Signature -```kotlin -open fun findByIdPrefix(prefix: String): List -``` - -#### Parameters -- `prefix` - - Type: `String` - - Description: The `HexCell` ID prefix to search for. - -#### Returns -A `List` of matching `HexCell`s, or an empty list if the index is not active or no matches are -found. diff --git a/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt b/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt deleted file mode 100644 index 0abe2b5..0000000 --- a/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt +++ /dev/null @@ -1,340 +0,0 @@ -package com.mapconductor.core.geocell - -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.marker.MarkerEntityInterface -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.locks.ReentrantReadWriteLock -import kotlin.concurrent.read -import kotlin.concurrent.write -import kotlin.math.pow -import kotlin.math.sqrt - -/** - * HexCellRegistry - Thread-safe hexagonal cell management with KDTree spatial indexing - * - * @param T The actual marker type - * @param geocell The hexagonal geocell system - * @param zoom The zoom level for this registry - */ -class HexCellRegistry( - private val geocell: HexGeocellInterface, - private val zoom: Double, -) { - private var kdTree: KDTree? = null - private val allCells = ConcurrentHashMap() - private val entryIDsByCell = ConcurrentHashMap>() - private val allEntries = ConcurrentHashMap() - - // entityId -> cellId - @Volatile - private var needsRebuild = false - - // Thread safety for complex operations - - private val lock = ReentrantReadWriteLock() - - /** - * Get the hex cell for a given entity without registering it - */ - fun getCell(entity: MarkerEntityInterface): HexCell { - val coord = geocell.latLngToHexCoord(entity.state.position, zoom) - val centerLatLng = geocell.hexToLatLngCenter(coord, entity.state.position.latitude, zoom) - val centerXY = geocell.projection.project(centerLatLng) - val cellId = geocell.hexToCellId(coord, zoom) // Include zoom in ID - return HexCell(coord, centerLatLng, centerXY, cellId) - } - - /** - * Register or update a point in the registry - * @return The hex cell containing the point - */ - fun setPoint(entity: MarkerEntityInterface): HexCell = - lock.write { - val entityId = entity.state.id - val cell = getCell(entity) - val cellId = cell.id - val previousCellId = allEntries[entityId] - - // [kdTree] が索引しているのは **セル**([allCells])であって entity ではない。 - // なのでセル集合が変わらない更新で dirty を立ててはいけない。立てると次の - // 空間検索が全セルから木を作り直す(24,000 件規模で 200ms 超)。 - // - // 位置が変わらない更新は珍しくない — アイコンやフラグの差し替え( - // `MarkerManager.updateEntity` は毎回 setPoint を呼ぶ)や、 - // [com.mapconductor.core.marker.MarkerViewportSwitch] のタイル⇔ネイティブ切り替えが該当する。 - if (previousCellId == cellId && allCells.containsKey(cellId)) { - entryIDsByCell.compute(cellId) { _, existingSet -> - (existingSet ?: mutableSetOf()).apply { add(entityId) } - } - return cell - } - - var cellsChanged = false - - // Remove from old cell if exists - previousCellId?.let { oldCellId -> - if (removeFromCell(oldCellId, entityId) && !allCells.containsKey(oldCellId)) { - // 空になったセルが消えた=セル集合が変わった - cellsChanged = true - } - } - - // Add to new cell - if (allCells.put(cellId, cell) == null) cellsChanged = true - allEntries[entityId] = cellId - - // Add entity to cell's entry list - entryIDsByCell.compute(cellId) { _, existingSet -> - (existingSet ?: mutableSetOf()).apply { add(entityId) } - } - - if (cellsChanged) markDirty() - return cell - } - - /** - * Check if a hex cell exists in the registry - */ - fun contains(hexId: String): Boolean = allCells.containsKey(hexId) - - /** - * Remove a point from the registry - * @return true if the point was removed, false if it wasn't found - */ - fun removePoint(entity: MarkerEntityInterface): Boolean = - lock.write { - val entityId = entity.state.id - val cellId = allEntries[entityId] ?: return false - - val removed = removeFromCell(cellId, entityId) - if (removed) { - allEntries.remove(entityId) - // セルがまだ他の entity を抱えていれば [allCells] は変わらないので、 - // 木を作り直す必要はない([setPoint] と同じ理由)。 - if (!allCells.containsKey(cellId)) markDirty() - } - - return removed - } - - /** - * Remove an entity from a specific cell - * @return true if removed, false if not found - */ - private fun removeFromCell( - cellId: String, - entityId: String, - ): Boolean { - val entryIds = entryIDsByCell[cellId] ?: return false - val removed = entryIds.remove(entityId) - - if (removed && entryIds.isEmpty()) { - // Remove empty cell - allCells.remove(cellId) - entryIDsByCell.remove(cellId) - } - return removed - } - - /** - * Clear all points and rebuild the spatial index - */ - fun clear() = - lock.write { - allCells.clear() - entryIDsByCell.clear() - allEntries.clear() - kdTree = null - needsRebuild = false - } - - /** - * Mark the spatial index as needing rebuild - */ - private fun markDirty() { - needsRebuild = true - } - - /** - * Rebuild the spatial index if needed - */ - private fun rebuildIfNeeded() { - // まず read で「必要か」を見る(短時間) - val dirty = lock.read { needsRebuild } - if (!dirty) return - - // read を解放してから write で再確認&再構築 - lock.write { - if (!needsRebuild) return // ここに来るまでに別スレッドが rebuild 済みの場合 - kdTree = - if (allCells.isNotEmpty()) { - KDTree(allCells.values.toList()) - } else { - null - } - needsRebuild = false - } - } - - /** - * Find the nearest hex cell to a point - */ - fun findNearest(point: GeoPointInterface): HexCell? { - rebuildIfNeeded() - return lock.read { - kdTree?.nearest(geocell.projection.project(point)) - } - } - - /** - * Find the nearest hex cell with distance - */ - fun findNearestWithDistance(point: GeoPointInterface): HexCellWithDistance? { - rebuildIfNeeded() - return lock.read { - kdTree?.nearestWithDistance(geocell.projection.project(point)) - } - } - - /** - * Find k nearest hex cells with distances - */ - fun findNearestKWithDistance( - point: GeoPointInterface, - k: Int, - ): List { - rebuildIfNeeded() - return lock.read { - kdTree?.nearestKWithDistance(geocell.projection.project(point), k).orEmpty() - } - } - - /** - * Find all hex cells within a radius with distances - */ - fun findWithinRadiusWithDistance( - point: GeoPointInterface, - radius: Double, - ): List { - rebuildIfNeeded() - return lock.read { - kdTree?.withinRadiusWithDistance(geocell.projection.project(point), radius).orEmpty() - } - } - - /** Cells within [radius], unordered — see [KDTree.withinRadius]. */ - fun findWithinRadius( - point: GeoPointInterface, - radius: Double, - ): List { - rebuildIfNeeded() - return lock.read { - kdTree?.withinRadius(geocell.projection.project(point), radius).orEmpty() - } - } - - /** - * Get all hex cells - * - * ArrayList(values) avoids the size==1 race in Kotlin's toList() on concurrent maps. - */ - fun all(): List = ArrayList(allCells.values) - - /** - * Get entity IDs for a specific hex cell - */ - fun getEntryIDsByHexCell(hexCell: HexCell): Set? = - entryIDsByCell[hexCell.id]?.toSet() // Return immutable copy - - /** - * Calculate meters per pixel at a given position and zoom level - * - * Note: This assumes the projection returns coordinates in meters. - * Verify that your projection implementation meets this requirement. - */ - fun metersPerPixel( - position: GeoPointInterface, - zoom: Double, - pixels: Double, - tileSize: Int = 256, - ): Double { - require(pixels > 0) { "Pixels must be positive" } - require(tileSize > 0) { "Tile size must be positive" } - - val deltaLng = 360.0 * pixels / (tileSize * 2.0.pow(zoom)) - - // Handle potential longitude overflow - val newLng = - (position.longitude + deltaLng).let { lng -> - when { - lng > 180.0 -> lng - 360.0 - lng < -180.0 -> lng + 360.0 - else -> lng - } - } - - val p1 = geocell.projection.project(position) - val p2 = - geocell.projection.project( - object : GeoPointInterface { - override val latitude = position.latitude - override val longitude = newLng - override val altitude = position.altitude - - override fun wrap(): GeoPointInterface = GeoPoint(latitude, longitude, altitude ?: 0.0).wrap() - }, - ) - - val deltaX = p2.x - p1.x - val deltaY = p2.y - p1.y - return sqrt(deltaX * deltaX + deltaY * deltaY).toDouble() - } - - /** - * Find hex cells within a pixel radius - */ - fun findWithinPixelRadius( - position: GeoPointInterface, - zoom: Double, - pixels: Double, - tileSize: Int = 256, - ): List { - val meters = metersPerPixel(position, zoom, pixels, tileSize) - return findWithinRadiusWithDistance(position, meters) - } - - /** - * Find hex cells by ID prefix (optimized for common prefixes) - */ - fun findByIdPrefix(prefix: String): List { - require(prefix.isNotEmpty()) { "Prefix cannot be empty" } - - return allCells.entries - .asSequence() - .filter { it.key.startsWith(prefix) } - .map { it.value } - .toList() - } - - /** - * Get statistics about the registry - */ - fun getStats(): RegistryStats = - RegistryStats( - totalCells = allCells.size, - totalEntries = allEntries.size, - kdTreeBuilt = kdTree != null, - needsRebuild = needsRebuild, - ) -} - -/** - * Statistics about the registry state - */ -data class RegistryStats( - val totalCells: Int, - val totalEntries: Int, - val kdTreeBuilt: Boolean, - val needsRebuild: Boolean, -) diff --git a/src/main/java/com/mapconductor/core/geocell/HexGeocellImpl.kt b/src/main/java/com/mapconductor/core/geocell/HexGeocellImpl.kt index d9e5473..dc9882b 100644 --- a/src/main/java/com/mapconductor/core/geocell/HexGeocellImpl.kt +++ b/src/main/java/com/mapconductor/core/geocell/HexGeocellImpl.kt @@ -53,11 +53,6 @@ data class HexCell( fun idPrefix(levels: Int): String = id.split("_").take(levels + 1).joinToString("_") } -data class HexCellWithDistance( - val cell: HexCell, - val distanceMeters: Double, -) - /** * Hexagonal geocell system for spatial indexing * diff --git a/src/main/java/com/mapconductor/core/geocell/KDTree.kt b/src/main/java/com/mapconductor/core/geocell/KDTree.kt deleted file mode 100644 index 360ec85..0000000 --- a/src/main/java/com/mapconductor/core/geocell/KDTree.kt +++ /dev/null @@ -1,328 +0,0 @@ -package com.mapconductor.core.geocell - -import com.mapconductor.core.projection.ProjectedPoint -import java.util.PriorityQueue -import kotlin.math.pow -import kotlin.math.sqrt - -/** - * K-D Tree implementation for efficient spatial queries on hex cells - * Supports nearest neighbor, k-NN, and radius searches - */ -class KDTree( - points: List, -) { - private val root = if (points.isNotEmpty()) build(points, 0) else null - - private class Node( - val cell: HexCell, - val left: Node?, - val right: Node?, - val axis: Int, // 0 for x-axis, 1 for y-axis - ) - - /** - * Build the K-D tree recursively - */ - private fun build( - items: List, - depth: Int, - ): Node? { - if (items.isEmpty()) 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 - - return Node( - cell = sorted[mid], - left = build(sorted.subList(0, mid), depth + 1), - right = build(sorted.subList(mid + 1, sorted.size), depth + 1), - axis = axis, - ) - } - - /** - * Find the nearest hex cell to a query point - */ - fun nearest(query: ProjectedPoint): HexCell? = root?.let { nearest(it, query, null, Double.MAX_VALUE) } - - /** - * Recursive nearest neighbor search - */ - private fun nearest( - node: Node, - query: ProjectedPoint, - best: HexCell?, - bestDistSq: Double, - ): HexCell? { - val axis = node.axis - val queryVal = if (axis == 0) query.x else query.y - val nodeVal = if (axis == 0) node.cell.centerXY.x else node.cell.centerXY.y - - val distSq = squaredDistance(query, node.cell.centerXY).toDouble() - - var currentBest = best - var currentBestDistSq = bestDistSq - - // Update best if current node is closer - if (distSq < currentBestDistSq) { - currentBest = node.cell - currentBestDistSq = distSq - } - - // Determine which subtree to search first - val (nearChild, farChild) = - if (queryVal < nodeVal) { - node.left to node.right - } else { - node.right to node.left - } - - // Search near subtree - nearChild?.let { near -> - currentBest = nearest(near, query, currentBest, currentBestDistSq) - currentBestDistSq = currentBest?.let { - squaredDistance(query, it.centerXY).toDouble() - } ?: currentBestDistSq - } - - // Search far subtree if it might contain closer points - farChild?.let { far -> - val axisDist = (queryVal - nodeVal).pow(2).toDouble() - if (axisDist < currentBestDistSq) { - currentBest = nearest(far, query, currentBest, currentBestDistSq) - } - } - - return currentBest - } - - /** - * Find the nearest hex cell with distance - */ - fun nearestWithDistance(query: ProjectedPoint): HexCellWithDistance? { - val cell = nearest(query) ?: return null - val distance = distance(query, cell.centerXY).toDouble() - return HexCellWithDistance(cell, distance) - } - - /** - * Find k nearest hex cells with distances - */ - fun nearestKWithDistance( - query: ProjectedPoint, - k: Int, - ): List { - require(k > 0) { "k must be positive" } - - if (root == null) return emptyList() - - val queue = - PriorityQueue>( - compareByDescending { it.first }, - ) - - nearestK(root, query, k, queue) - - return queue - .map { (distSq, cell) -> - HexCellWithDistance(cell, sqrt(distSq)) - }.sortedBy { it.distanceMeters } - } - - /** - * Recursive k-nearest neighbor search - */ - private fun nearestK( - node: Node, - query: ProjectedPoint, - k: Int, - queue: PriorityQueue>, - ) { - val distSq = squaredDistance(query, node.cell.centerXY).toDouble() - - if (queue.size < k) { - queue.offer(distSq to node.cell) - } else if (distSq < queue.peek()!!.first) { - queue.poll() - queue.offer(distSq to node.cell) - } - - val axis = node.axis - val queryVal = if (axis == 0) query.x else query.y - val nodeVal = if (axis == 0) node.cell.centerXY.x else node.cell.centerXY.y - - val (nearChild, farChild) = - if (queryVal < nodeVal) { - node.left to node.right - } else { - node.right to node.left - } - - // Search near subtree - nearChild?.let { nearestK(it, query, k, queue) } - - // Search far subtree if it might contain closer points - farChild?.let { - val axisDist = (queryVal - nodeVal).pow(2).toDouble() - if (queue.size < k || axisDist < queue.peek()!!.first) { - nearestK(it, query, k, queue) - } - } - } - - /** - * Find all hex cells within a radius with distances - */ - fun withinRadiusWithDistance( - query: ProjectedPoint, - radius: Double, - ): List { - require(radius >= 0) { "Radius must be non-negative" } - - if (root == null) return emptyList() - - val radiusSq = radius * radius - val result = mutableListOf() - withinRadius(root, query, radiusSq, result) - return result.sortedBy { it.distanceMeters } - } - - /** - * Cells within [radius], unordered. - * - * The sorted, distance-carrying variant exists for callers that want the - * nearest first. Bounds queries do not: they use every cell and throw the - * distances away, and sorting them is the dominant cost of the search. A - * cell carries a String, so sorting 20k of them moves 20k references - * through some 286k comparisons — measured at 42 ms of a 64 ms query on an - * iPad Pro, against 1.8 ms without the sort. - */ - fun withinRadius( - query: ProjectedPoint, - radius: Double, - ): List { - require(radius >= 0) { "Radius must be non-negative" } - val start = root ?: return emptyList() - - val result = mutableListOf() - withinRadiusUnordered(start, query, radius * radius, result) - return result - } - - private fun withinRadiusUnordered( - node: Node, - query: ProjectedPoint, - radiusSq: Double, - result: MutableList, - ) { - if (squaredDistance(query, node.cell.centerXY) <= radiusSq) { - result.add(node.cell) - } - - val queryVal = if (node.axis == 0) query.x.toDouble() else query.y.toDouble() - val nodeVal = - if (node.axis == 0) { - node.cell.centerXY.x - .toDouble() - } else { - node.cell.centerXY.y - .toDouble() - } - val nearChild = if (queryVal < nodeVal) node.left else node.right - val farChild = if (queryVal < nodeVal) node.right else node.left - - if (nearChild != null) withinRadiusUnordered(nearChild, query, radiusSq, result) - val axisDistance = queryVal - nodeVal - if (axisDistance * axisDistance <= radiusSq && farChild != null) { - withinRadiusUnordered(farChild, query, radiusSq, result) - } - } - - /** - * Recursive radius search - */ - private fun withinRadius( - node: Node, - query: ProjectedPoint, - radiusSq: Double, - result: MutableList, - ) { - val distSq = squaredDistance(query, node.cell.centerXY).toDouble() - - if (distSq <= radiusSq) { - result.add(HexCellWithDistance(node.cell, sqrt(distSq))) - } - - val axis = node.axis - val queryVal = if (axis == 0) query.x else query.y - val nodeVal = if (axis == 0) node.cell.centerXY.x else node.cell.centerXY.y - - val (nearChild, farChild) = - if (queryVal < nodeVal) { - node.left to node.right - } else { - node.right to node.left - } - - // Search near subtree - nearChild?.let { withinRadius(it, query, radiusSq, result) } - - // Search far subtree if it intersects with query circle - farChild?.let { - val axisDist = (queryVal - nodeVal).pow(2).toDouble() - if (axisDist <= radiusSq) { - withinRadius(it, query, radiusSq, result) - } - } - } - - /** - * Calculate squared Euclidean distance between two points - */ - private fun squaredDistance( - a: ProjectedPoint, - b: ProjectedPoint, - ): Double { - val deltaX = a.x - b.x - val deltaY = a.y - b.y - return deltaX * deltaX + deltaY * deltaY - } - - /** - * Calculate Euclidean distance between two points - */ - private fun distance( - a: ProjectedPoint, - b: ProjectedPoint, - ): Double = sqrt(squaredDistance(a, b)) - - /** - * Get statistics about the tree structure - */ - fun getStats(): KDTreeStats = - KDTreeStats( - nodeCount = countNodes(root), - maxDepth = maxDepth(root), - isEmpty = root == null, - ) - - private fun countNodes(node: Node?): Int = - if (node == null) 0 else 1 + countNodes(node.left) + countNodes(node.right) - - private fun maxDepth(node: Node?): Int = - if (node == null) 0 else 1 + maxOf(maxDepth(node.left), maxDepth(node.right)) -} - -/** - * Statistics about the K-D tree structure - */ -data class KDTreeStats( - val nodeCount: Int, - val maxDepth: Int, - val isEmpty: Boolean, -) diff --git a/src/main/java/com/mapconductor/core/marker/MarkerGridIndex.kt b/src/main/java/com/mapconductor/core/marker/MarkerGridIndex.kt index 58c18e6..13cffb3 100644 --- a/src/main/java/com/mapconductor/core/marker/MarkerGridIndex.kt +++ b/src/main/java/com/mapconductor/core/marker/MarkerGridIndex.kt @@ -8,12 +8,12 @@ import java.util.Arrays /** * A hierarchical lat/lng grid over the markers, held as one sorted primitive array. * - * Replaces the hex-cell registry and its kd-tree on the paths that run per - * tile. That index was built at a fixed zoom of 20, which puts a cell at about - * 12 cm — street trees are metres apart, so every marker landed in its own cell - * and the index grouped nothing. It cost a HexCell object and a String id per - * marker, a pair of concurrent maps and a kd-tree node each; on Tokyo's 144,183 - * street trees, building it aborted the process with a native out-of-memory. + * It replaced a hex-cell registry and its kd-tree, since removed. That index + * was built at a fixed zoom of 20, which puts a cell at about 12 cm — street + * trees are metres apart, so every marker landed in its own cell and the index + * grouped nothing. It cost a HexCell object and a String id per marker, a pair + * of concurrent maps and a kd-tree node each; on Tokyo's 144,183 street trees, + * building it aborted the process with a native out-of-memory. * * Here each marker is one Long: its cell key above its position in a snapshot. * Building the index is an arithmetic pass and a primitive sort, measured at diff --git a/src/main/java/com/mapconductor/core/marker/MarkerManager.kt b/src/main/java/com/mapconductor/core/marker/MarkerManager.kt index a0abe2d..93777c5 100644 --- a/src/main/java/com/mapconductor/core/marker/MarkerManager.kt +++ b/src/main/java/com/mapconductor/core/marker/MarkerManager.kt @@ -1,8 +1,6 @@ package com.mapconductor.core.marker import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.geocell.HexCell -import com.mapconductor.core.geocell.HexCellRegistry import com.mapconductor.core.geocell.HexGeocell import com.mapconductor.core.geocell.HexGeocellInterface import com.mapconductor.core.projection.Earth @@ -30,22 +28,11 @@ open class MarkerManager( private val entities = ConcurrentHashMap>() /** - * The spatial index the per-tile paths use. - * - * A uniform grid rather than the hex-cell registry below: that one is built - * at a fixed zoom of 20, which puts a cell at about 12 cm, so every marker - * lands in its own and it groups nothing. See [MarkerGridIndex]. + * The spatial index the per-tile paths use. A uniform lat/lng grid rather + * than hex cells; see [MarkerGridIndex] for why. */ private val gridIndex = MarkerGridIndex { entities.values } - /** - * Kept only for [findByIdPrefix], which hands out HexCells and has no - * caller inside the SDK. Built the first time that is asked for, so nothing - * pays for it otherwise. - */ - @Volatile - private var cellRegistry: HexCellRegistry? = null - private val semaphore = ReentrantReadWriteLock() private var writeLock: ReentrantReadWriteLock.WriteLock? = null @@ -80,8 +67,6 @@ open class MarkerManager( val removed = entities.remove(id) if (removed != null) { gridIndex.invalidate() - // Only update the hex registry if something asked for one. - cellRegistry?.removePoint(removed) } return removed } @@ -120,37 +105,11 @@ open class MarkerManager( } } - open fun findByIdPrefix(prefix: String): List { - if (!usable("findByIdPrefix")) return emptyList() - // The registry is built here and nowhere else: this is the one caller - // that needs hex cells rather than markers. - return ensureCellRegistry().findByIdPrefix(prefix) - } - open fun registerEntity(entity: MarkerEntityInterface) { if (!usable("registerEntity")) return semaphore.write { entities[entity.state.id] = entity gridIndex.invalidate() - // Only update the hex registry if something asked for one. - cellRegistry?.setPoint(entity) - } - } - - /** - * Lazy-initialize the spatial index only when spatial operations are needed. - * Double-checked locking: the registry is assigned only after it is fully populated, - * preventing concurrent callers from observing an empty non-null registry. - */ - private fun ensureCellRegistry(): HexCellRegistry { - cellRegistry?.let { return it } - return semaphore.write { - cellRegistry ?: run { - val registry = HexCellRegistry(geocell = geocell, zoom = 20.0) - entities.values.forEach { entity -> registry.setPoint(entity) } - cellRegistry = registry - registry - } } } @@ -160,8 +119,6 @@ open class MarkerManager( entities[entity.state.id] = entity // A marker that moved changes which cell it belongs to. gridIndex.invalidate() - // Only update the hex registry if something asked for one. - cellRegistry?.setPoint(entity) } } @@ -214,11 +171,7 @@ open class MarkerManager( // Rough estimation in bytes val entityMapOverhead = entities.size * 64L // Map entry overhead + string key val entityObjects = entities.size * 200L // Rough entity size - val gridSize = gridIndex.estimatedBytes() - // The hex registry is usually absent; when findByIdPrefix has built it, - // it costs a cell object and a string id per marker. - val hexSize = if (cellRegistry != null) entities.size * 100L else 0L - val spatialIndexSize = gridSize + hexSize + val spatialIndexSize = gridIndex.estimatedBytes() return entityMapOverhead + entityObjects + spatialIndexSize } @@ -226,7 +179,6 @@ open class MarkerManager( if (!usable("clear")) return entities.clear() gridIndex.invalidate() - cellRegistry?.clear() } fun findMarkersInBounds( @@ -235,7 +187,6 @@ open class MarkerManager( if (!usable("findMarkersInBounds")) return emptyList() if (bounds.isEmpty) return emptyList() - // For spatial queries, ensure the cell registry is initialized. // Only use the spatial index for larger datasets. if (entities.size > minMarkerCount) { semaphore.read { return gridIndex.inBounds(bounds) } @@ -256,8 +207,6 @@ open class MarkerManager( semaphore.write { isDestroyed = true entities.clear() - cellRegistry?.clear() - cellRegistry = null } } } diff --git a/src/test/java/com/mapconductor/core/geocell/HexCellRegistryRebuildTest.kt b/src/test/java/com/mapconductor/core/geocell/HexCellRegistryRebuildTest.kt deleted file mode 100644 index 580975e..0000000 --- a/src/test/java/com/mapconductor/core/geocell/HexCellRegistryRebuildTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.mapconductor.core.geocell - -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.marker.MarkerEntity -import com.mapconductor.core.marker.MarkerState -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * [HexCellRegistry] の再構築条件。 - * - * KDTree が索引しているのはセル集合なので、同じ位置の entity を入れ直しても木は変わらない。 - * 以前はここで無条件に dirty を立てていたため、位置の変わらない更新(アイコン差し替え、 - * [com.mapconductor.core.marker.MarkerViewportSwitch] のタイル⇔ネイティブ切り替えなど。 - * `MarkerManager.updateEntity` は毎回 setPoint を呼ぶ)のあと、次の空間検索が全セルから - * 木を作り直していた。実機(24,000 件)で 200ms 超のメインスレッド停止として観測されたもの。 - */ -class HexCellRegistryRebuildTest { - private val geocell = HexGeocell.defaultGeocell() - - private fun registry() = HexCellRegistry(geocell = geocell, zoom = 20.0) - - private fun entity( - id: String, - latitude: Double, - longitude: Double, - ) = MarkerEntity( - marker = null, - state = MarkerState(id = id, position = GeoPoint.fromLatLong(latitude, longitude)), - ) - - /** 検索を一度通して木を建て、dirty を落とした状態にする。 */ - private fun HexCellRegistry.settle() { - findWithinRadiusWithDistance(GeoPoint.fromLatLong(35.68, 139.76), 1.0) - assertFalse("前提: この時点で dirty は落ちている", getStats().needsRebuild) - } - - /** 同じ位置での再登録は木を汚さない。ここが本命。 */ - @Test - fun `同じ位置の再登録では再構築を要求しない`() { - val registry = registry() - registry.setPoint(entity("a", 35.68, 139.76)) - registry.settle() - - repeat(5) { registry.setPoint(entity("a", 35.68, 139.76)) } - - assertFalse(registry.getStats().needsRebuild) - assertEquals(1, registry.getStats().totalCells) - } - - /** 位置が変わればセル集合が変わるので、再構築は要求される。 */ - @Test - fun `別セルへ移ったら再構築を要求する`() { - val registry = registry() - val before = registry.setPoint(entity("a", 35.68, 139.76)) - registry.settle() - - val after = registry.setPoint(entity("a", 34.70, 135.50)) - - assertTrue("移動後は別セルであること", before.id != after.id) - assertTrue(registry.getStats().needsRebuild) - assertEquals(1, registry.getStats().totalCells) - assertEquals(setOf("a"), registry.getEntryIDsByHexCell(after)?.toSet()) - } - - /** 同一セルに残りがあるなら、1 件消してもセル集合は変わらない。 */ - @Test - fun `同一セル内の 1 件削除では再構築を要求しない`() { - val registry = registry() - // zoom 20 のセルは 10cm 程度なので、同じセルに入れるには座標を完全に一致させる。 - val cell = registry.setPoint(entity("a", 35.680000, 139.760000)) - registry.setPoint(entity("b", 35.680000, 139.760000)) - assertEquals(setOf("a", "b"), registry.getEntryIDsByHexCell(cell)?.toSet()) - registry.settle() - - registry.removePoint(entity("a", 35.680000, 139.760000)) - - assertFalse(registry.getStats().needsRebuild) - assertEquals(setOf("b"), registry.getEntryIDsByHexCell(cell)?.toSet()) - } - - /** 最後の 1 件を消すとセルが消えるので、再構築は要求される。 */ - @Test - fun `セルが空になったら再構築を要求する`() { - val registry = registry() - registry.setPoint(entity("a", 35.68, 139.76)) - registry.settle() - - registry.removePoint(entity("a", 35.68, 139.76)) - - assertTrue(registry.getStats().needsRebuild) - assertEquals(0, registry.getStats().totalCells) - } - - /** 再登録を挟んでも半径検索の結果は変わらない(最適化で結果を落としていないこと)。 */ - @Test - fun `再登録を挟んでも半径検索の結果が変わらない`() { - val registry = registry() - val center = GeoPoint.fromLatLong(35.68, 139.76) - registry.setPoint(entity("a", 35.6800, 139.7600)) - registry.setPoint(entity("b", 35.6805, 139.7605)) - registry.setPoint(entity("c", 34.7000, 135.5000)) - - val before = registry.findWithinRadiusWithDistance(center, 2000.0).map { it.cell.id }.toSet() - repeat(3) { - registry.setPoint(entity("a", 35.6800, 139.7600)) - registry.setPoint(entity("b", 35.6805, 139.7605)) - } - val after = registry.findWithinRadiusWithDistance(center, 2000.0).map { it.cell.id }.toSet() - - assertTrue("近傍のセルが取れていること", after.isNotEmpty()) - assertEquals(before, after) - } -} diff --git a/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt b/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt deleted file mode 100644 index 7e65bb0..0000000 --- a/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -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 - -/** - * The unordered radius search returns the same cells as the sorted one. - * - * It exists because sorting dominated bounds queries — 42 ms of a 64 ms query - * at 20k markers — and bounds queries throw the distances away. Its traversal - * was written out separately, so this pins the two against each other rather - * than trusting that the pruning was copied correctly. - */ -class KDTreeRadiusTest { - private fun cells(count: Int): List { - var seed = 42L - - fun next(): Double { - seed = seed * 6364136223846793005L + 1442695040888963407L - return ((seed ushr 40).toDouble() / 1000.0) - } - return (0 until count).map { index -> - HexCell( - coord = HexCoord(index, index), - centerLatLng = GeoPoint(0.0, 0.0), - centerXY = ProjectedPoint(next(), next()), - id = "cell-$index", - ) - } - } - - @Test - fun unorderedMatchesSorted() { - val tree = KDTree(cells(2_000)) - val query = ProjectedPoint(8_000.0, 8_000.0) - - // A spread of radii: one that excludes everything, several partial, and - // one that takes the lot. A traversal bug that only shows at the - // boundary would survive a single radius. - for (radius in listOf(0.0, 1.0, 50.0, 500.0, 5_000.0, 20_000.0)) { - val sorted = tree.withinRadiusWithDistance(query, radius).map { it.cell.id }.toSet() - val unordered = tree.withinRadius(query, radius).map { it.id }.toSet() - assertEquals("radius $radius", sorted, unordered) - } - } -}