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