From 1569cc59479c9de799aa7eea4c5c054a69b790de Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 1 Oct 2026 16:56:28 -0700 Subject: [PATCH 1/5] Expose local tile rendering --- api/android-sdk-core.api | 1 + .../core/tileserver/LocalTileServer.kt | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 1bb95a5..64ed850 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -2669,6 +2669,7 @@ public final class com.mapconductor.core.tileserver.LocalTileServer { public final java.lang.String urlTemplate(java.lang.String, int); public final java.lang.String urlTemplate(java.lang.String, int, java.lang.String); public final java.lang.String urlTemplateWithQueryCacheKey(java.lang.String, int, java.lang.String); + public final byte[] renderLocalTile(java.lang.String, kotlin.jvm.functions.Function0); public final boolean isRunning(); public final void start(); public final void stop(); diff --git a/src/main/java/com/mapconductor/core/tileserver/LocalTileServer.kt b/src/main/java/com/mapconductor/core/tileserver/LocalTileServer.kt index 5f91fa2..33ec029 100644 --- a/src/main/java/com/mapconductor/core/tileserver/LocalTileServer.kt +++ b/src/main/java/com/mapconductor/core/tileserver/LocalTileServer.kt @@ -165,6 +165,30 @@ class LocalTileServer private constructor( cacheKey: String, ): String = "$baseUrl/tiles/$routeId/$tileSize/{z}/{x}/{y}.png?v=$cacheKey" + /** + * Draws the tile a local URL names, in this process, without the HTTP hop. + * + * A map SDK that fetches through its own network stack may refuse to + * while the device reports no connectivity -- HERE will not even ask + * 127.0.0.1 -- so its layer hands the request here instead. Same + * providers, same answers: the bytes of a drawn tile, a transparent tile + * where the provider has nothing, and null when the route is unknown or + * the render failed, which the caller reports as a failure the map may + * retry. The URL is one this server's own templates produced. + */ + fun renderLocalTile( + url: String, + isCancelled: () -> Boolean = { false }, + ): ByteArray? { + if (!url.startsWith("$baseUrl/")) return null + val path = url.removePrefix("$baseUrl/").substringBefore('?') + return when (val outcome = resolveTile(path, isCancelled)) { + is TileOutcome.Tile -> outcome.body + is TileOutcome.Empty -> TransparentTilePng.bytes(outcome.pixelSize) + TileOutcome.NotFound, TileOutcome.Failed -> null + } + } + /** * Whether the server is accepting connections. A stopped server cannot be * restarted (its socket and worker pool are closed) — create a new one. From 7d527bb86b1386a735b48b42e46d19fed0a4f533 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 7 Oct 2026 12:27:07 -0700 Subject: [PATCH 2/5] Fix marker tile seams caused by truncated placement keys --- .../core/marker/MarkerTileSeamDeviceTest.kt | 36 +++++++++ .../core/marker/MarkerTileRenderer.kt | 77 ++++--------------- 2 files changed, 53 insertions(+), 60 deletions(-) diff --git a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt index 29a64f9..99fb800 100644 --- a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt @@ -53,6 +53,42 @@ class MarkerTileSeamDeviceTest { return manager } + @Test + fun distinctRectanglesWithMatchingLowBitsAreAllRendered() { + val manager = MarkerManager.defaultManager(minMarkerCount = 1) + val icon = ImageIcon( + image = android.graphics.drawable.ColorDrawable(android.graphics.Color.RED), + iconSize = androidx.compose.ui.unit.Dp(16f), + ) + val tilePixels = com.mapconductor.core.ResourceProvider.dpToPx(512.0).toInt() + val positions = listOf(100 to 100, 200 to 100, 100 to 356) + for ((index, point) in positions.withIndex()) { + val worldX = 8.0 + point.first.toDouble() / tilePixels + val worldY = 6.0 + point.second.toDouble() / tilePixels + val latitude = Math.toDegrees(Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * worldY / 16.0)))) + manager.registerEntity( + MarkerEntity( + marker = null, + state = MarkerState( + position = GeoPoint.fromLatLong(latitude, worldX * 360.0 / 16.0 - 180.0), + id = "rectangle-$index", + icon = icon, + ), + visible = true, + isRendered = true, + tiling = true, + ), + ) + } + val renderer = MarkerTileRenderer(manager, tileSize = 512, cacheSizeBytes = 1) + val bytes = renderer.renderTile(TileRequest(x = 8, y = 6, z = 4))!! + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + for ((x, y) in positions) { + assertEquals("marker at $x,$y", android.graphics.Color.RED, bitmap.getPixel(x, y)) + } + bitmap.recycle() + } + @Test fun adjacentTilesAgreeAtEveryVerticalSeam() { val renderer = diff --git a/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt b/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt index f87230d..0ba3133 100644 --- a/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt +++ b/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt @@ -487,56 +487,26 @@ class MarkerTileRenderer( // The last of each group wins, which is what painter's order would // have left visible. - // Grouped by sorting, not by hashing. - // - // A HashMap keyed by the group boxes a Long and an Int per marker, - // and on a tile holding the whole dataset that is hundreds of - // thousands of objects — enough that the tile after it failed to - // allocate its bitmap and the process aborted inside - // Canvas::create_canvas. Sorting an array of (group, index) pairs - // needs two primitive arrays and answers the same question: the - // winner of a group is the last index in its run. - val order = LongArray(prepared.size) - var pairs = 0 + // Sort the complete group keys separately from marker indices. + // Packing an index under a 64-bit placement discards its upper bits + // (the x coordinate and part of y), merging unrelated rectangles. + // Each tile would then keep a different marker at its boundary. + val sortedGroups = groups.copyOf() + java.util.Arrays.sort(sortedGroups) + val lastInGroup = IntArray(sortedGroups.size) { -1 } + val mixedIcons = BooleanArray(sortedGroups.size) for (index in prepared.indices) { - if (icons[index] == null) continue - // [INDEX_BITS] of index under the group, so sorting orders by - // group first and by index within it. - order[pairs++] = (groups[index] shl INDEX_BITS) or index.toLong() + val groupIndex = java.util.Arrays.binarySearch(sortedGroups, groups[index]) + val previous = lastInGroup[groupIndex] + if (previous >= 0 && icons[previous] !== icons[index]) { + mixedIcons[groupIndex] = true + } + lastInGroup[groupIndex] = index } - java.util.Arrays.sort(order, 0, pairs) - val draw = BooleanArray(prepared.size) - var runStart = 0 - while (runStart < pairs) { - val group = order[runStart] ushr INDEX_BITS - var runEnd = runStart + 1 - while (runEnd < pairs && (order[runEnd] ushr INDEX_BITS) == group) runEnd++ - - if (declutter) { - // One survivor per cell, whatever it draws. - draw[(order[runEnd - 1] and INDEX_MASK).toInt()] = true - } else { - // Same rectangle, but a different icon may be transparent - // where the one above it is not — so a group holding more - // than one icon keeps all of them. - val first = icons[(order[runStart] and INDEX_MASK).toInt()] - var mixed = false - for (at in runStart + 1 until runEnd) { - if (icons[(order[at] and INDEX_MASK).toInt()] !== first) { - mixed = true - break - } - } - if (mixed) { - for (at in runStart until runEnd) { - draw[(order[at] and INDEX_MASK).toInt()] = true - } - } else { - draw[(order[runEnd - 1] and INDEX_MASK).toInt()] = true - } - } - runStart = runEnd + for (index in prepared.indices) { + val groupIndex = java.util.Arrays.binarySearch(sortedGroups, groups[index]) + draw[index] = lastInGroup[groupIndex] == index || (!declutter && mixedIcons[groupIndex]) } for (index in prepared.indices) { @@ -694,19 +664,6 @@ class MarkerTileRenderer( } private companion object { - /** - * How the declutter pass packs a cell and a marker index into one Long: - * [INDEX_BITS] of index under 40 bits of cell key, which is exactly 64. - * - * The previous key was `(cx shl 32) xor cy`, and shifting that left by - * the index width threw away all but the low 8 bits of `cx`. At the - * default 14 px cell a tile spans about 100 columns and nothing showed; - * at a 1 px cell on a 1344 px tile, columns 0 and 256 shared a key and - * markers 256 px apart were decluttered into one another. Twenty bits a - * side leaves room for every cell a tile can hold. - */ - private const val INDEX_BITS = 24 - private const val INDEX_MASK = (1L shl INDEX_BITS) - 1 private const val CELL_BITS = 20 private const val CELL_ORIGIN = 1L shl (CELL_BITS - 1) From f5bdae9f8e8eb84fe5157dc67b1e4e67b174c961 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 10 Oct 2026 14:18:53 -0700 Subject: [PATCH 3/5] feat: integrate vector style support and pending map fixes --- api/android-sdk-core.api | 198 ++++- .../core/map/StyleMutationParseTest.kt | 82 +++ .../core/marker/MarkerTileRenderBenchmark.kt | 188 ++--- .../core/marker/MarkerTileSeamDeviceTest.kt | 335 +++++---- .../core/tileserver/TilePngEncoderTest.kt | 445 ++++++------ .../core/controller/BaseMapViewController.kt | 101 +++ .../core/geocell/HexCellRegistry.kt | 681 +++++++++--------- .../core/map/ApplyStyleMutations.kt | 171 +++++ .../com/mapconductor/core/map/MapViewStyle.kt | 156 ++++ .../core/map/MapViewStyleEffect.kt | 55 ++ .../mapconductor/core/map/MapViewStyleHost.kt | 136 ++++ .../mapconductor/core/map/StyleMutation.kt | 222 ++++++ .../core/map/VectorStyleMutationSupport.kt | 113 +++ .../core/map/VectorStyleSupport.kt | 38 + .../core/geocell/KDTreeRadiusTest.kt | 96 +-- .../core/map/ApplyStyleMutationsTest.kt | 269 +++++++ .../core/map/MapViewStyleHostTest.kt | 251 +++++++ .../core/map/StyleMutationStoreTest.kt | 123 ++++ .../core/marker/MarkerGridIndexTest.kt | 637 ++++++++-------- .../core/marker/MarkerTileSeamTest.kt | 307 ++++---- .../projection/ProjectionConformanceTest.kt | 176 ++--- 21 files changed, 3361 insertions(+), 1419 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/core/map/StyleMutationParseTest.kt create mode 100644 src/main/java/com/mapconductor/core/map/ApplyStyleMutations.kt create mode 100644 src/main/java/com/mapconductor/core/map/MapViewStyle.kt create mode 100644 src/main/java/com/mapconductor/core/map/MapViewStyleEffect.kt create mode 100644 src/main/java/com/mapconductor/core/map/MapViewStyleHost.kt create mode 100644 src/main/java/com/mapconductor/core/map/StyleMutation.kt create mode 100644 src/main/java/com/mapconductor/core/map/VectorStyleMutationSupport.kt create mode 100644 src/test/java/com/mapconductor/core/map/ApplyStyleMutationsTest.kt create mode 100644 src/test/java/com/mapconductor/core/map/MapViewStyleHostTest.kt create mode 100644 src/test/java/com/mapconductor/core/map/StyleMutationStoreTest.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 64ed850..6a520f3 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -297,6 +297,10 @@ public abstract class com.mapconductor.core.controller.BaseMapViewController imp public java.lang.Object compositionRasterLayers(java.util.List, kotlin.coroutines.Continuation); public java.lang.Object updateRasterLayer(com.mapconductor.core.raster.RasterLayerState, kotlin.coroutines.Continuation); public boolean hasRasterLayer(com.mapconductor.core.raster.RasterLayerState); + public void mountRasterLayer(com.mapconductor.core.raster.RasterLayerState); + public void unmountRasterLayer(java.lang.String); + public final kotlin.jvm.functions.Function0 addStyleLoadedListener(kotlin.jvm.functions.Function0); + protected final void notifyStyleLoaded(); public final void setMapInitializedListener(kotlin.jvm.functions.Function0); protected final void notifyMapInitialized(); protected final java.lang.Object notifyMapCameraPosition(com.mapconductor.core.map.MapCameraPosition, kotlin.coroutines.Continuation); @@ -772,6 +776,9 @@ final class com.mapconductor.core.groundimage.GroundImageTileProvider$WebMercato public final double lonToPixelX(double, double); public final double latToPixelY(double, double); } +public final class com.mapconductor.core.map.ApplyStyleMutationsKt { + public static final java.util.List applyStyleMutations(com.mapconductor.core.map.StyleMutationTarget, java.util.List); +} public final class com.mapconductor.core.map.AttributionRule implements java.io.Serializable { public com.mapconductor.core.map.AttributionRule(java.lang.String, java.lang.Integer, java.lang.Integer, com.mapconductor.core.features.GeoRectBounds); public com.mapconductor.core.map.AttributionRule(java.lang.String, java.lang.Integer, java.lang.Integer, com.mapconductor.core.features.GeoRectBounds, int, kotlin.jvm.internal.DefaultConstructorMarker); @@ -1074,6 +1081,31 @@ public final class com.mapconductor.core.map.MapServiceRegistry$DefaultImpls { public final class com.mapconductor.core.map.MapServiceRegistryKt { public static final androidx.compose.runtime.ProvidableCompositionLocal getLocalMapServiceRegistry(); } +public interface com.mapconductor.core.map.MapStyleHost { + public abstract com.mapconductor.core.map.MapServiceRegistry getServiceRegistry(); + public abstract com.mapconductor.core.tileserver.LocalTileServer getTileServer(); + public default java.lang.String getVectorStyleUrl(); + public abstract com.mapconductor.core.map.MapStyleInstallation onStyleLoaded(kotlin.jvm.functions.Function0); + public abstract void upsertRaster(com.mapconductor.core.raster.RasterLayerState); + public abstract void removeRaster(java.lang.String); + public abstract void report(java.util.List); +} +public final class com.mapconductor.core.map.MapStyleHost$DefaultImpls { + public static java.lang.String getVectorStyleUrl(com.mapconductor.core.map.MapStyleHost); +} +public interface com.mapconductor.core.map.MapStyleInstallation { + public static final com.mapconductor.core.map.MapStyleInstallation$Companion Companion; + public static final com.mapconductor.core.map.MapStyleInstallation NONE; + public abstract void dispose(); + public default boolean update(com.mapconductor.core.map.MapViewStyle); + public static com.mapconductor.core.map.MapStyleInstallation of(kotlin.jvm.functions.Function0); +} +public final class com.mapconductor.core.map.MapStyleInstallation$Companion { + public final com.mapconductor.core.map.MapStyleInstallation of(kotlin.jvm.functions.Function0); +} +public final class com.mapconductor.core.map.MapStyleInstallation$DefaultImpls { + public static boolean update(com.mapconductor.core.map.MapStyleInstallation, com.mapconductor.core.map.MapViewStyle); +} public final class com.mapconductor.core.map.MapUISettings { public static final com.mapconductor.core.map.MapUISettings$Companion Companion; public com.mapconductor.core.map.MapUISettings(boolean, boolean, boolean, boolean, com.mapconductor.core.map.MapPaddings); @@ -1150,6 +1182,27 @@ public interface com.mapconductor.core.map.MapViewStateInterface, com.mapconductor.core.map.MapViewStyle, com.mapconductor.core.controller.BaseMapViewController, kotlin.jvm.functions.Function1, kotlin.Unit>, androidx.compose.runtime.Composer, int, int); +} +public final class com.mapconductor.core.map.MapViewStyleHost implements com.mapconductor.core.map.MapStyleHost { + public com.mapconductor.core.map.MapViewStyleHost(com.mapconductor.core.map.MapServiceRegistry, com.mapconductor.core.controller.BaseMapViewController); + public com.mapconductor.core.map.MapServiceRegistry getServiceRegistry(); + public final void apply(com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>); + public final void dispose(); + public com.mapconductor.core.tileserver.LocalTileServer getTileServer(); + public java.lang.String getVectorStyleUrl(); + public com.mapconductor.core.map.MapStyleInstallation onStyleLoaded(kotlin.jvm.functions.Function0); + public void upsertRaster(com.mapconductor.core.raster.RasterLayerState); + public void removeRaster(java.lang.String); + public void report(java.util.List); +} +public final class com.mapconductor.core.map.MapViewStyleHostKt { +} public final class com.mapconductor.core.map.MutableMapServiceRegistry implements com.mapconductor.core.map.MapServiceRegistry { public com.mapconductor.core.map.MutableMapServiceRegistry(); public final void clear(); @@ -1175,19 +1228,162 @@ public abstract class com.mapconductor.core.map.StaticHolder { public final void remove(java.lang.String); public final void clearAll(); } +public abstract class com.mapconductor.core.map.StyleMutation { + public static final com.mapconductor.core.map.StyleMutation$Companion Companion; + public abstract java.lang.String getLayerId(); + public abstract java.lang.String getTarget(); + public java.lang.String getPropertyName(); + public abstract com.mapconductor.core.map.StyleMutation reversed(); + public static final java.util.List parseList(java.lang.String); + public static final java.lang.String jsonTextOf(java.lang.Object); + public com.mapconductor.core.map.StyleMutation(kotlin.jvm.internal.DefaultConstructorMarker); +} +public final class com.mapconductor.core.map.StyleMutation$AddLayer extends com.mapconductor.core.map.StyleMutation { + public java.lang.String getLayerId(); + public final java.lang.String getLayer(); + public final java.lang.String getBeforeId(); + public java.lang.String getTarget(); + public com.mapconductor.core.map.StyleMutation$RemoveLayer reversed(); + public final java.lang.String component1(); + public final java.lang.String component2(); + public final java.lang.String component3(); + public final com.mapconductor.core.map.StyleMutation$AddLayer copy(java.lang.String, java.lang.String, java.lang.String); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public com.mapconductor.core.map.StyleMutation reversed(); +} +public final class com.mapconductor.core.map.StyleMutation$Companion { + public final java.util.List parseList(java.lang.String); + public final java.lang.String jsonTextOf(java.lang.Object); +} +public final class com.mapconductor.core.map.StyleMutation$RemoveLayer extends com.mapconductor.core.map.StyleMutation { + public java.lang.String getLayerId(); + public java.lang.String getTarget(); + public com.mapconductor.core.map.StyleMutation reversed(); + public final java.lang.String component1(); + public final com.mapconductor.core.map.StyleMutation$RemoveLayer copy(java.lang.String); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); +} +public final class com.mapconductor.core.map.StyleMutation$SetFilter extends com.mapconductor.core.map.StyleMutation { + public java.lang.String getLayerId(); + public final java.lang.String getValue(); + public final java.lang.String getPrevious(); + public java.lang.String getTarget(); + public java.lang.String getPropertyName(); + public com.mapconductor.core.map.StyleMutation$SetFilter reversed(); + public final java.lang.String component1(); + public final java.lang.String component2(); + public final java.lang.String component3(); + public final com.mapconductor.core.map.StyleMutation$SetFilter copy(java.lang.String, java.lang.String, java.lang.String); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public com.mapconductor.core.map.StyleMutation reversed(); +} +public final class com.mapconductor.core.map.StyleMutation$SetLayout extends com.mapconductor.core.map.StyleMutation { + public java.lang.String getLayerId(); + public final java.lang.String getKey(); + public final java.lang.String getValue(); + public final java.lang.String getPrevious(); + public java.lang.String getTarget(); + public java.lang.String getPropertyName(); + public com.mapconductor.core.map.StyleMutation$SetLayout reversed(); + public final java.lang.String component1(); + public final java.lang.String component2(); + public final java.lang.String component3(); + public final java.lang.String component4(); + public final com.mapconductor.core.map.StyleMutation$SetLayout copy(java.lang.String, java.lang.String, java.lang.String, java.lang.String); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public com.mapconductor.core.map.StyleMutation reversed(); +} +public final class com.mapconductor.core.map.StyleMutation$SetPaint extends com.mapconductor.core.map.StyleMutation { + public java.lang.String getLayerId(); + public final java.lang.String getKey(); + public final java.lang.String getValue(); + public final java.lang.String getPrevious(); + public java.lang.String getTarget(); + public java.lang.String getPropertyName(); + public com.mapconductor.core.map.StyleMutation$SetPaint reversed(); + public final java.lang.String component1(); + public final java.lang.String component2(); + public final java.lang.String component3(); + public final java.lang.String component4(); + public final com.mapconductor.core.map.StyleMutation$SetPaint copy(java.lang.String, java.lang.String, java.lang.String, java.lang.String); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public com.mapconductor.core.map.StyleMutation reversed(); +} +public final class com.mapconductor.core.map.StyleMutation$SetZoomRange extends com.mapconductor.core.map.StyleMutation { + public java.lang.String getLayerId(); + public final java.lang.Double getMinZoom(); + public final java.lang.Double getMaxZoom(); + public final java.lang.Double getPreviousMinZoom(); + public final java.lang.Double getPreviousMaxZoom(); + public java.lang.String getTarget(); + public java.lang.String getPropertyName(); + public com.mapconductor.core.map.StyleMutation$SetZoomRange reversed(); + public final java.lang.String component1(); + public final java.lang.Double component2(); + public final java.lang.Double component3(); + public final java.lang.Double component4(); + public final java.lang.Double component5(); + public final com.mapconductor.core.map.StyleMutation$SetZoomRange copy(java.lang.String, java.lang.Double, java.lang.Double, java.lang.Double, java.lang.Double); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public com.mapconductor.core.map.StyleMutation reversed(); +} +public final class com.mapconductor.core.map.StyleMutationStore implements com.mapconductor.core.map.VectorStyleMutationSupport { + public com.mapconductor.core.map.StyleMutationStore(kotlin.jvm.functions.Function1, ? extends java.util.List>); + public java.util.List apply(java.util.List); + public void clear(); + public final void onStyleLoaded(); + public final java.util.List getApplied(); +} +public interface com.mapconductor.core.map.StyleMutationTarget { + public default boolean isReady(); + public abstract boolean hasLayer(java.lang.String); + public abstract void setPaint(java.lang.String, java.lang.String, java.lang.String); + public abstract void setLayout(java.lang.String, java.lang.String, java.lang.String); + public abstract void setFilter(java.lang.String, java.lang.String); + public abstract void setZoomRange(java.lang.String, float, float); + public abstract void addLayer(java.lang.String, java.lang.String, java.lang.String); + public abstract void removeLayer(java.lang.String); +} +public final class com.mapconductor.core.map.StyleMutationTarget$DefaultImpls { + public static boolean isReady(com.mapconductor.core.map.StyleMutationTarget); +} public final class com.mapconductor.core.map.VectorStyleAsDesign> implements com.mapconductor.core.map.VectorStyleSupport { public static final long CLEAR_GRACE_MS; - public com.mapconductor.core.map.VectorStyleAsDesign(com.mapconductor.core.map.MapViewStateInterface, kotlin.jvm.functions.Function2, ? extends Design>); + public com.mapconductor.core.map.VectorStyleAsDesign(com.mapconductor.core.map.MapViewStateInterface, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, ? extends Design>); + public com.mapconductor.core.map.VectorStyleAsDesign(com.mapconductor.core.map.MapViewStateInterface, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, int, kotlin.jvm.internal.DefaultConstructorMarker); public void showStyle(java.lang.String, java.util.List); public void clearStyle(); + public java.lang.String getCurrentStyleUrl(); } final class com.mapconductor.core.map.VectorStyleAsDesign$Companion { } +public interface com.mapconductor.core.map.VectorStyleMutationSupport { + public abstract java.util.List apply(java.util.List); + public abstract void clear(); +} +public final class com.mapconductor.core.map.VectorStyleMutationSupportKey implements com.mapconductor.core.map.MapServiceKey { + public static final com.mapconductor.core.map.VectorStyleMutationSupportKey INSTANCE; + public com.mapconductor.core.map.MapCapability getCapability(); +} public interface com.mapconductor.core.map.VectorStyleSupport { public abstract void showStyle(java.lang.String, java.util.List); public abstract void clearStyle(); + public default java.lang.String getCurrentStyleUrl(); } public final class com.mapconductor.core.map.VectorStyleSupport$DefaultImpls { + public static java.lang.String getCurrentStyleUrl(com.mapconductor.core.map.VectorStyleSupport); } public final class com.mapconductor.core.map.VectorStyleSupportKey implements com.mapconductor.core.map.MapServiceKey { public static final com.mapconductor.core.map.VectorStyleSupportKey INSTANCE; diff --git a/src/androidTest/java/com/mapconductor/core/map/StyleMutationParseTest.kt b/src/androidTest/java/com/mapconductor/core/map/StyleMutationParseTest.kt new file mode 100644 index 0000000..0603676 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/map/StyleMutationParseTest.kt @@ -0,0 +1,82 @@ +package com.mapconductor.core.map + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The parts of [StyleMutation] that need a real `org.json`, which the JVM + * unit tests do not have: the stub in the Android jar throws on every call, + * so reading what the style compiler wrote has to be checked on a device. + */ +@RunWith(AndroidJUnit4::class) +class StyleMutationParseTest { + @Test + fun readsWhatTheCompilerWrites() { + val parsed = + StyleMutation.parseList( + """ + [ + {"op":"setPaint","layerId":"roads","key":"line-color","value":"#fff","previous":null}, + {"op":"setLayout","layerId":"labels","key":"visibility","value":"none","previous":"visible"}, + {"op":"setFilter","layerId":"roads","value":["==","class","motorway"],"previous":null}, + {"op":"setZoomRange","layerId":"roads","minZoom":5,"maxZoom":null, + "previousMinZoom":null,"previousMaxZoom":null}, + {"op":"addLayer","layerId":"bg","layer":{"id":"bg","type":"background"},"beforeId":"water"}, + {"op":"somethingFromTheFuture","layerId":"roads"} + ] + """.trimIndent(), + ) + + assertEquals("the unknown op is dropped, the rest survive", 5, parsed.size) + // A string value keeps its quotes, or no renderer could read it back. + assertEquals("\"#fff\"", (parsed[0] as StyleMutation.SetPaint).value) + assertEquals("null", (parsed[0] as StyleMutation.SetPaint).previous) + assertEquals("\"none\"", (parsed[1] as StyleMutation.SetLayout).value) + assertEquals("[\"==\",\"class\",\"motorway\"]", (parsed[2] as StyleMutation.SetFilter).value) + assertEquals(5.0, (parsed[3] as StyleMutation.SetZoomRange).minZoom) + assertEquals(null, (parsed[3] as StyleMutation.SetZoomRange).maxZoom) + assertEquals("bg", parsed[4].layerId) + assertEquals("water", (parsed[4] as StyleMutation.AddLayer).beforeId) + } + + /** Rubbish in must not take the map down; it means no adjustment. */ + @Test + fun answersWithNothingRatherThanThrowing() { + assertEquals(emptyList(), StyleMutation.parseList("not json")) + assertEquals(emptyList(), StyleMutation.parseList("{}")) + assertEquals(emptyList(), StyleMutation.parseList("[{\"op\":\"setPaint\"}]")) + } + + @Test + fun addingALayerIsUndoneByRemovingIt() { + val add = StyleMutation.AddLayer("bg", """{"id":"bg","type":"background"}""", beforeId = null) + assertEquals(StyleMutation.RemoveLayer("bg"), add.reversed()) + } + + /** + * Applying a set and then its reverse has to leave the map where it + * started, whatever the values were. + */ + @Test + fun everyMutationUndoesItself() { + val mutations = + StyleMutation.parseList( + """ + [ + {"op":"setPaint","layerId":"a","key":"line-color","value":"#fff","previous":"#888"}, + {"op":"setLayout","layerId":"b","key":"visibility","value":"none","previous":null}, + {"op":"setFilter","layerId":"c","value":["all"],"previous":null}, + {"op":"setZoomRange","layerId":"d","minZoom":5,"maxZoom":12, + "previousMinZoom":null,"previousMaxZoom":22} + ] + """.trimIndent(), + ) + for (mutation in mutations) { + val there = mutation.reversed()!! + val andBack = there.reversed()!! + assertEquals("${mutation.target} did not survive a round trip", mutation, andBack) + } + } +} diff --git a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt index 0d4674f..758193c 100644 --- a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt +++ b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt @@ -1,93 +1,95 @@ -package com.mapconductor.core.marker - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.tileserver.TileRequest -import org.junit.Assert.assertNotNull -import org.junit.Test -import org.junit.runner.RunWith -import android.util.Log - -/** - * What one marker tile costs on this device, with and without decluttering. - * - * 144k markers in the street-tree shape, the tiles central Tokyo actually - * requests. Not a pass/fail gate — the point is the logged ms/tile, so a slow - * device can be measured instead of guessed about. The declutter run is the - * configuration the samples ask for; the run without it is what they were - * silently getting while the controllers dropped `declutterPx` on the floor. - */ -@RunWith(AndroidJUnit4::class) -class MarkerTileRenderBenchmark { - private fun manager(): MarkerManager { - val manager = MarkerManager.defaultManager(minMarkerCount = 1) - var seed = 12345uL - fun next(): Double { - seed = seed * 6364136223846793005uL + 1442695040888963407uL - return (seed shr 11).toDouble() / (1uL shl 53).toDouble() - } - repeat(144_000) { at -> - manager.registerEntity( - MarkerEntity( - marker = null, - state = - MarkerState( - position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), - id = "$at", - ), - visible = true, - isRendered = true, - tiling = true, - ), - ) - } - return manager - } - - private fun renderViewport( - renderer: MarkerTileRenderer, - z: Int, - label: String, - ) { - // The tile column/row central Tokyo lands on at this zoom. - val n = 1 shl z - val centerX = ((139.75 + 180.0) / 360.0 * n).toInt() - val centerY = run { - val latRad = Math.toRadians(35.69) - ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() - } - var totalMs = 0L - var tiles = 0 - for (dx in -1..1) { - for (dy in -1..1) { - val start = System.nanoTime() - val bytes = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy, z = z)) - totalMs += (System.nanoTime() - start) / 1_000_000 - tiles += 1 - assertNotNull(bytes) - } - } - Log.i(TAG, "RENDERBENCH $label z=$z tiles=$tiles total=${totalMs}ms perTile=${totalMs / tiles}ms") - } - - @Test - fun viewportCostWithAndWithoutDeclutter() { - val manager = manager() - for (declutter in intArrayOf(0, 14)) { - val renderer = - MarkerTileRenderer( - markerManager = manager, - tileSize = 256, - cacheSizeBytes = 1, // effectively no tile cache: measure the render - declutterPx = declutter, - ) - for (z in intArrayOf(10, 11, 12, 13, 14)) { - renderViewport(renderer, z, label = "declutter=$declutter") - } - } - } - - private companion object { - private const val TAG = "MarkerTileRenderBenchmark" - } -} +package com.mapconductor.core.marker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.tileserver.TileRequest +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import android.util.Log + +/** + * What one marker tile costs on this device, with and without decluttering. + * + * 144k markers in the street-tree shape, the tiles central Tokyo actually + * requests. Not a pass/fail gate — the point is the logged ms/tile, so a slow + * device can be measured instead of guessed about. The declutter run is the + * configuration the samples ask for; the run without it is what they were + * silently getting while the controllers dropped `declutterPx` on the floor. + */ +@RunWith(AndroidJUnit4::class) +class MarkerTileRenderBenchmark { + private fun manager(): MarkerManager { + val manager = MarkerManager.defaultManager(minMarkerCount = 1) + var seed = 12345uL + + fun next(): Double { + seed = seed * 6364136223846793005uL + 1442695040888963407uL + return (seed shr 11).toDouble() / (1uL shl 53).toDouble() + } + repeat(144_000) { at -> + manager.registerEntity( + MarkerEntity( + marker = null, + state = + MarkerState( + position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), + id = "$at", + ), + visible = true, + isRendered = true, + tiling = true, + ), + ) + } + return manager + } + + private fun renderViewport( + renderer: MarkerTileRenderer, + z: Int, + label: String, + ) { + // The tile column/row central Tokyo lands on at this zoom. + val n = 1 shl z + val centerX = ((139.75 + 180.0) / 360.0 * n).toInt() + val centerY = + run { + val latRad = Math.toRadians(35.69) + ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() + } + var totalMs = 0L + var tiles = 0 + for (dx in -1..1) { + for (dy in -1..1) { + val start = System.nanoTime() + val bytes = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy, z = z)) + totalMs += (System.nanoTime() - start) / 1_000_000 + tiles += 1 + assertNotNull(bytes) + } + } + Log.i(TAG, "RENDERBENCH $label z=$z tiles=$tiles total=${totalMs}ms perTile=${totalMs / tiles}ms") + } + + @Test + fun viewportCostWithAndWithoutDeclutter() { + val manager = manager() + for (declutter in intArrayOf(0, 14)) { + val renderer = + MarkerTileRenderer( + markerManager = manager, + tileSize = 256, + cacheSizeBytes = 1, // effectively no tile cache: measure the render + declutterPx = declutter, + ) + for (z in intArrayOf(10, 11, 12, 13, 14)) { + renderViewport(renderer, z, label = "declutter=$declutter") + } + } + } + + private companion object { + private const val TAG = "MarkerTileRenderBenchmark" + } +} diff --git a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt index 99fb800..73fdcca 100644 --- a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt @@ -1,157 +1,178 @@ -package com.mapconductor.core.marker - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.tileserver.TileRequest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test -import org.junit.runner.RunWith -import android.graphics.BitmapFactory -import android.util.Log - -/** - * Adjacent rendered tiles must agree at the seam, declutter included. - * - * `MarkerTileSeamTest` (JVM) checks that the index hands both tiles the same - * candidates. This renders the actual pixels, because the seam can also be - * broken after the query: if the declutter winner inside a boundary cell - * depends on enumeration order, the two tiles keep different markers and a - * circle appears cut in half against the tile edge — found on device with the - * base layer hidden. - * - * The comparison looks at the two pixel columns facing each other across the - * seam. A marker straddling it paints both; a broken seam leaves one side - * opaque and the other transparent for roughly the icon's whole height. An - * icon merely tangent to the seam can differ for a short run, so only long - * runs count. - */ -@RunWith(AndroidJUnit4::class) -class MarkerTileSeamDeviceTest { - private fun manager(): MarkerManager { - val manager = MarkerManager.defaultManager(minMarkerCount = 1) - var seed = 12345uL - fun next(): Double { - seed = seed * 6364136223846793005uL + 1442695040888963407uL - return (seed shr 11).toDouble() / (1uL shl 53).toDouble() - } - repeat(144_000) { at -> - manager.registerEntity( - MarkerEntity( - marker = null, - state = - MarkerState( - position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), - id = "$at", - ), - visible = true, - isRendered = true, - tiling = true, - ), - ) - } - return manager - } - - @Test - fun distinctRectanglesWithMatchingLowBitsAreAllRendered() { - val manager = MarkerManager.defaultManager(minMarkerCount = 1) - val icon = ImageIcon( - image = android.graphics.drawable.ColorDrawable(android.graphics.Color.RED), - iconSize = androidx.compose.ui.unit.Dp(16f), - ) - val tilePixels = com.mapconductor.core.ResourceProvider.dpToPx(512.0).toInt() - val positions = listOf(100 to 100, 200 to 100, 100 to 356) - for ((index, point) in positions.withIndex()) { - val worldX = 8.0 + point.first.toDouble() / tilePixels - val worldY = 6.0 + point.second.toDouble() / tilePixels - val latitude = Math.toDegrees(Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * worldY / 16.0)))) - manager.registerEntity( - MarkerEntity( - marker = null, - state = MarkerState( - position = GeoPoint.fromLatLong(latitude, worldX * 360.0 / 16.0 - 180.0), - id = "rectangle-$index", - icon = icon, - ), - visible = true, - isRendered = true, - tiling = true, - ), - ) - } - val renderer = MarkerTileRenderer(manager, tileSize = 512, cacheSizeBytes = 1) - val bytes = renderer.renderTile(TileRequest(x = 8, y = 6, z = 4))!! - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - for ((x, y) in positions) { - assertEquals("marker at $x,$y", android.graphics.Color.RED, bitmap.getPixel(x, y)) - } - bitmap.recycle() - } - - @Test - fun adjacentTilesAgreeAtEveryVerticalSeam() { - val renderer = - MarkerTileRenderer( - markerManager = manager(), - tileSize = 256, - cacheSizeBytes = 1, - declutterPx = 14, - ) - var seams = 0 - var bad = 0 - // z15+ is a different path: the declutter separation drops below the - // grid's bottom level there, the index declines the thinned query and - // the renderer falls back to the full one. - for (z in intArrayOf(12, 13, 14, 15, 16, 17)) { - val n = 1 shl z - val centerX = ((139.75 + 180.0) / 360.0 * n).toInt() - val centerY = run { - val latRad = Math.toRadians(35.69) - ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() - } - for (dx in -3..2) { - for (dy in -2..2) { - val at = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy, z = z)) - val east = renderer.renderTile(TileRequest(x = centerX + dx + 1, y = centerY + dy, z = z)) - val south = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy + 1, z = z)) - assertNotNull(at) - assertNotNull(east) - assertNotNull(south) - val tile = BitmapFactory.decodeByteArray(at!!, 0, at.size) - val eastTile = BitmapFactory.decodeByteArray(east!!, 0, east.size) - val southTile = BitmapFactory.decodeByteArray(south!!, 0, south.size) - seams += 2 - var worst = 0 - var run = 0 - for (y in 0 until tile.height) { - val a = (tile.getPixel(tile.width - 1, y) ushr 24) > 0x20 - val b = (eastTile.getPixel(0, y) ushr 24) > 0x20 - if (a != b) { run += 1; if (run > worst) worst = run } else run = 0 - } - if (worst > 16) { - bad += 1 - Log.i(TAG, "SEAM broken(vertical) z=$z x=${centerX + dx}/${centerX + dx + 1} y=${centerY + dy} worstRun=$worst") - } - worst = 0 - run = 0 - for (x in 0 until tile.width) { - val a = (tile.getPixel(x, tile.height - 1) ushr 24) > 0x20 - val b = (southTile.getPixel(x, 0) ushr 24) > 0x20 - if (a != b) { run += 1; if (run > worst) worst = run } else run = 0 - } - if (worst > 16) { - bad += 1 - Log.i(TAG, "SEAM broken(horizontal) z=$z x=${centerX + dx} y=${centerY + dy}/${centerY + dy + 1} worstRun=$worst") - } - } - } - } - Log.i(TAG, "SEAM seams=$seams bad=$bad") - assertEquals("seams with a half-drawn marker", 0, bad) - } - - private companion object { - private const val TAG = "MarkerTileSeamDeviceTest" - } -} +package com.mapconductor.core.marker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.tileserver.TileRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import android.graphics.BitmapFactory +import android.util.Log + +/** + * Adjacent rendered tiles must agree at the seam, declutter included. + * + * `MarkerTileSeamTest` (JVM) checks that the index hands both tiles the same + * candidates. This renders the actual pixels, because the seam can also be + * broken after the query: if the declutter winner inside a boundary cell + * depends on enumeration order, the two tiles keep different markers and a + * circle appears cut in half against the tile edge — found on device with the + * base layer hidden. + * + * The comparison looks at the two pixel columns facing each other across the + * seam. A marker straddling it paints both; a broken seam leaves one side + * opaque and the other transparent for roughly the icon's whole height. An + * icon merely tangent to the seam can differ for a short run, so only long + * runs count. + */ +@RunWith(AndroidJUnit4::class) +class MarkerTileSeamDeviceTest { + private fun manager(): MarkerManager { + val manager = MarkerManager.defaultManager(minMarkerCount = 1) + var seed = 12345uL + + fun next(): Double { + seed = seed * 6364136223846793005uL + 1442695040888963407uL + return (seed shr 11).toDouble() / (1uL shl 53).toDouble() + } + repeat(144_000) { at -> + manager.registerEntity( + MarkerEntity( + marker = null, + state = + MarkerState( + position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), + id = "$at", + ), + visible = true, + isRendered = true, + tiling = true, + ), + ) + } + return manager + } + + @Test + fun distinctRectanglesWithMatchingLowBitsAreAllRendered() { + val manager = MarkerManager.defaultManager(minMarkerCount = 1) + val icon = + ImageIcon( + image = android.graphics.drawable.ColorDrawable(android.graphics.Color.RED), + iconSize = + androidx.compose.ui.unit + .Dp(16f), + ) + val tilePixels = + com.mapconductor.core.ResourceProvider + .dpToPx(512.0) + .toInt() + val positions = listOf(100 to 100, 200 to 100, 100 to 356) + for ((index, point) in positions.withIndex()) { + val worldX = 8.0 + point.first.toDouble() / tilePixels + val worldY = 6.0 + point.second.toDouble() / tilePixels + val latitude = Math.toDegrees(Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * worldY / 16.0)))) + manager.registerEntity( + MarkerEntity( + marker = null, + state = + MarkerState( + position = GeoPoint.fromLatLong(latitude, worldX * 360.0 / 16.0 - 180.0), + id = "rectangle-$index", + icon = icon, + ), + visible = true, + isRendered = true, + tiling = true, + ), + ) + } + val renderer = MarkerTileRenderer(manager, tileSize = 512, cacheSizeBytes = 1) + val bytes = renderer.renderTile(TileRequest(x = 8, y = 6, z = 4))!! + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + for ((x, y) in positions) { + assertEquals("marker at $x,$y", android.graphics.Color.RED, bitmap.getPixel(x, y)) + } + bitmap.recycle() + } + + @Test + fun adjacentTilesAgreeAtEveryVerticalSeam() { + val renderer = + MarkerTileRenderer( + markerManager = manager(), + tileSize = 256, + cacheSizeBytes = 1, + declutterPx = 14, + ) + var seams = 0 + var bad = 0 + // z15+ is a different path: the declutter separation drops below the + // grid's bottom level there, the index declines the thinned query and + // the renderer falls back to the full one. + for (z in intArrayOf(12, 13, 14, 15, 16, 17)) { + val n = 1 shl z + val centerX = ((139.75 + 180.0) / 360.0 * n).toInt() + val centerY = + run { + val latRad = Math.toRadians(35.69) + ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() + } + for (dx in -3..2) { + for (dy in -2..2) { + val at = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy, z = z)) + val east = renderer.renderTile(TileRequest(x = centerX + dx + 1, y = centerY + dy, z = z)) + val south = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy + 1, z = z)) + assertNotNull(at) + assertNotNull(east) + assertNotNull(south) + val tile = BitmapFactory.decodeByteArray(at!!, 0, at.size) + val eastTile = BitmapFactory.decodeByteArray(east!!, 0, east.size) + val southTile = BitmapFactory.decodeByteArray(south!!, 0, south.size) + seams += 2 + var worst = 0 + var run = 0 + for (y in 0 until tile.height) { + val a = (tile.getPixel(tile.width - 1, y) ushr 24) > 0x20 + val b = (eastTile.getPixel(0, y) ushr 24) > 0x20 + if (a != b) { + run += 1 + if (run > worst) worst = run + } else { + run = 0 + } + } + if (worst > 16) { + bad += 1 + Log + .i(TAG, "SEAM broken(vertical) z=$z x=${centerX + dx}/${centerX + dx + 1} y=${centerY + dy} worstRun=$worst") + } + worst = 0 + run = 0 + for (x in 0 until tile.width) { + val a = (tile.getPixel(x, tile.height - 1) ushr 24) > 0x20 + val b = (southTile.getPixel(x, 0) ushr 24) > 0x20 + if (a != b) { + run += 1 + if (run > worst) worst = run + } else { + run = 0 + } + } + if (worst > 16) { + bad += 1 + Log + .i(TAG, "SEAM broken(horizontal) z=$z x=${centerX + dx} y=${centerY + dy}/${centerY + dy + 1} worstRun=$worst") + } + } + } + } + Log.i(TAG, "SEAM seams=$seams bad=$bad") + assertEquals("seams with a half-drawn marker", 0, bad) + } + + private companion object { + private const val TAG = "MarkerTileSeamDeviceTest" + } +} diff --git a/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt b/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt index 109b274..cebf1a1 100644 --- a/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt +++ b/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt @@ -1,222 +1,223 @@ -package com.mapconductor.core.tileserver - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Color -import androidx.test.ext.junit.runners.AndroidJUnit4 -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.math.abs -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -/** - * On-device checks for the Rust PNG encoder. - * - * Instrumented rather than local because the thing most likely to be wrong is - * not the Kotlin: it is whether `libtile_png_jni.so` is packaged for the - * device's ABI at all. A JVM test would pass with no native library present, - * since every failure path here deliberately returns null. - */ -@RunWith(AndroidJUnit4::class) -class TilePngEncoderTest { - - /** - * The whole point of putting the library in the core: it has to load in an - * ordinary consumer process, with no help from the caller. - * - * If this fails, everything below it passes vacuously — the encoder would - * return null and the platform fallback would quietly cover for it. - */ - @Test - fun libraryLoadsOnThisDevice() { - assertTrue( - "libtile_png_jni.so did not load — check jniLibs carries this device's ABI", - TilePngEncoder.isAvailable, - ) - } - - /** - * Opaque pixels, where premultiplication is the identity, so the round trip - * has to be exact. This is the test that catches a swapped R and B: the - * classic way a fast encoder produces a plausible-looking wrong image. - */ - @Test - fun opaquePixelsSurviveExactly() { - val source = opaqueFixture() - val png = TilePngEncoder.encode(source) - assertNotNull("encoder declined an ARGB_8888 bitmap", png) - - val decoded = decode(png!!) - assertEquals(source.width, decoded.width) - assertEquals(source.height, decoded.height) - for (y in 0 until source.height) { - for (x in 0 until source.width) { - assertEquals( - "pixel ($x,$y)", - source.getPixel(x, y), - decoded.getPixel(x, y), - ) - } - } - } - - /** - * Partly transparent pixels against the encoder this one replaces. - * - * Both paths undo the same premultiplication, so they may round a channel - * differently, but they must not disagree about the image. Alpha carries no - * rounding at all and is compared exactly; colour is allowed one step, which - * is what integer un-premultiplication can legitimately cost. - */ - @Test - fun transparentPixelsAgreeWithBitmapCompress() { - val source = translucentFixture() - - val rust = decode(TilePngEncoder.encode(source)!!) - val platform = decode(compress(source)) - - var worstColourStep = 0 - for (y in 0 until source.height) { - for (x in 0 until source.width) { - val a = rust.getPixel(x, y) - val b = platform.getPixel(x, y) - assertEquals("alpha at ($x,$y)", Color.alpha(b), Color.alpha(a)) - // Fully transparent pixels carry no recoverable colour: both - // encoders are free to write anything under alpha 0. - if (Color.alpha(b) == 0) continue - val step = maxOf( - abs(Color.red(a) - Color.red(b)), - abs(Color.green(a) - Color.green(b)), - abs(Color.blue(a) - Color.blue(b)), - ) - assertTrue("colour at ($x,$y) differs by $step", step <= 1) - worstColourStep = maxOf(worstColourStep, step) - } - } - println("TILEPNG translucent round trip: worst colour step $worstColourStep") - } - - /** - * A bitmap the encoder cannot read has to come back as null, because the - * caller's fallback is the only thing standing between that and a blank - * tile. RGB_565 has a different byte layout entirely. - */ - @Test - fun unsupportedConfigDeclinesInsteadOfGuessing() { - val source = opaqueFixture().copy(Bitmap.Config.RGB_565, false) - assertNull(TilePngEncoder.encode(source)) - } - - /** A heap buffer's address does not survive the JNI crossing. */ - @Test - fun indirectBufferIsRefused() { - val heap = ByteBuffer.allocate(TILE * TILE * 4).order(ByteOrder.nativeOrder()) - assertNull(TilePngEncoder.encode(heap, TILE, TILE, premultiplied = false)) - } - - /** - * Not an assertion about speed — the numbers vary with the device and - * nothing here should fail because a phone was busy. It prints the - * comparison so the reason this encoder exists stays visible in the log. - * - * Deliberately run on noise rather than on the smooth fixtures above. - * Synthetic tiles mislead badly here: a flat colour under an alpha ramp - * compresses to a couple of kilobytes, which flatters `compress` on size - * and starves it of the work this encoder is meant to save. Noise is not - * realistic either, only wrong in the opposite direction — the honest - * comparison, on tiles the marker renderer actually produced, is - * `MarkerTileCostTest` in android-vectortile. - */ - @Test - fun reportsCostAgainstThePlatformEncoder() { - val source = noiseFixture() - - val platformSamples = ArrayList(REPEATS) - var platformBytes = 0 - repeat(REPEATS) { - val started = System.nanoTime() - platformBytes = compress(source).size - platformSamples.add((System.nanoTime() - started) / 1_000_000.0) - } - - val rustSamples = ArrayList(REPEATS) - var rustBytes = 0 - repeat(REPEATS) { - val started = System.nanoTime() - rustBytes = TilePngEncoder.encode(source)!!.size - rustSamples.add((System.nanoTime() - started) / 1_000_000.0) - } - - println( - "TILEPNG %dx%d compress=%.1fms/%dB rust=%.1fms/%dB".format( - TILE, TILE, - median(platformSamples), platformBytes, - median(rustSamples), rustBytes, - ), - ) - } - - private fun opaqueFixture(): Bitmap { - val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) - for (y in 0 until TILE) { - for (x in 0 until TILE) { - // Distinct per channel, so a channel swap cannot coincide. - bitmap.setPixel(x, y, Color.argb(255, x % 256, y % 256, (x + 2 * y) % 256)) - } - } - return bitmap - } - - private fun translucentFixture(): Bitmap { - val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) - for (y in 0 until TILE) { - for (x in 0 until TILE) { - bitmap.setPixel(x, y, Color.argb((x * 3) % 256, 240, 90, 30)) - } - } - return bitmap - } - - /** Incompressible content, from a fixed seed so runs stay comparable. */ - private fun noiseFixture(): Bitmap { - val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) - val pixels = IntArray(TILE * TILE) - var state = 0x2545F491 - for (index in pixels.indices) { - state = state * 1_664_525 + 1_013_904_223 - val bits = state ushr 8 - pixels[index] = Color.argb( - 128 + (bits and 0x7F), - (bits ushr 7) and 0xFF, - (bits ushr 15) and 0xFF, - (bits ushr 23) and 0xFF, - ) - } - bitmap.setPixels(pixels, 0, TILE, 0, 0, TILE, TILE) - return bitmap - } - - private fun compress(bitmap: Bitmap): ByteArray { - val stream = ByteArrayOutputStream(TILE * TILE) - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) - return stream.toByteArray() - } - - private fun decode(png: ByteArray): Bitmap = - BitmapFactory - .decodeByteArray(png, 0, png.size) - .copy(Bitmap.Config.ARGB_8888, false) - - private fun median(samples: List): Double = samples.sorted()[samples.size / 2] - - private companion object { - const val TILE = 512 - const val REPEATS = 5 - } -} +package com.mapconductor.core.tileserver + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.abs +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color + +/** + * On-device checks for the Rust PNG encoder. + * + * Instrumented rather than local because the thing most likely to be wrong is + * not the Kotlin: it is whether `libtile_png_jni.so` is packaged for the + * device's ABI at all. A JVM test would pass with no native library present, + * since every failure path here deliberately returns null. + */ +@RunWith(AndroidJUnit4::class) +class TilePngEncoderTest { + /** + * The whole point of putting the library in the core: it has to load in an + * ordinary consumer process, with no help from the caller. + * + * If this fails, everything below it passes vacuously — the encoder would + * return null and the platform fallback would quietly cover for it. + */ + @Test + fun libraryLoadsOnThisDevice() { + assertTrue( + "libtile_png_jni.so did not load — check jniLibs carries this device's ABI", + TilePngEncoder.isAvailable, + ) + } + + /** + * Opaque pixels, where premultiplication is the identity, so the round trip + * has to be exact. This is the test that catches a swapped R and B: the + * classic way a fast encoder produces a plausible-looking wrong image. + */ + @Test + fun opaquePixelsSurviveExactly() { + val source = opaqueFixture() + val png = TilePngEncoder.encode(source) + assertNotNull("encoder declined an ARGB_8888 bitmap", png) + + val decoded = decode(png!!) + assertEquals(source.width, decoded.width) + assertEquals(source.height, decoded.height) + for (y in 0 until source.height) { + for (x in 0 until source.width) { + assertEquals( + "pixel ($x,$y)", + source.getPixel(x, y), + decoded.getPixel(x, y), + ) + } + } + } + + /** + * Partly transparent pixels against the encoder this one replaces. + * + * Both paths undo the same premultiplication, so they may round a channel + * differently, but they must not disagree about the image. Alpha carries no + * rounding at all and is compared exactly; colour is allowed one step, which + * is what integer un-premultiplication can legitimately cost. + */ + @Test + fun transparentPixelsAgreeWithBitmapCompress() { + val source = translucentFixture() + + val rust = decode(TilePngEncoder.encode(source)!!) + val platform = decode(compress(source)) + + var worstColourStep = 0 + for (y in 0 until source.height) { + for (x in 0 until source.width) { + val a = rust.getPixel(x, y) + val b = platform.getPixel(x, y) + assertEquals("alpha at ($x,$y)", Color.alpha(b), Color.alpha(a)) + // Fully transparent pixels carry no recoverable colour: both + // encoders are free to write anything under alpha 0. + if (Color.alpha(b) == 0) continue + val step = + maxOf( + abs(Color.red(a) - Color.red(b)), + abs(Color.green(a) - Color.green(b)), + abs(Color.blue(a) - Color.blue(b)), + ) + assertTrue("colour at ($x,$y) differs by $step", step <= 1) + worstColourStep = maxOf(worstColourStep, step) + } + } + println("TILEPNG translucent round trip: worst colour step $worstColourStep") + } + + /** + * A bitmap the encoder cannot read has to come back as null, because the + * caller's fallback is the only thing standing between that and a blank + * tile. RGB_565 has a different byte layout entirely. + */ + @Test + fun unsupportedConfigDeclinesInsteadOfGuessing() { + val source = opaqueFixture().copy(Bitmap.Config.RGB_565, false) + assertNull(TilePngEncoder.encode(source)) + } + + /** A heap buffer's address does not survive the JNI crossing. */ + @Test + fun indirectBufferIsRefused() { + val heap = ByteBuffer.allocate(TILE * TILE * 4).order(ByteOrder.nativeOrder()) + assertNull(TilePngEncoder.encode(heap, TILE, TILE, premultiplied = false)) + } + + /** + * Not an assertion about speed — the numbers vary with the device and + * nothing here should fail because a phone was busy. It prints the + * comparison so the reason this encoder exists stays visible in the log. + * + * Deliberately run on noise rather than on the smooth fixtures above. + * Synthetic tiles mislead badly here: a flat colour under an alpha ramp + * compresses to a couple of kilobytes, which flatters `compress` on size + * and starves it of the work this encoder is meant to save. Noise is not + * realistic either, only wrong in the opposite direction — the honest + * comparison, on tiles the marker renderer actually produced, is + * `MarkerTileCostTest` in android-vectortile. + */ + @Test + fun reportsCostAgainstThePlatformEncoder() { + val source = noiseFixture() + + val platformSamples = ArrayList(REPEATS) + var platformBytes = 0 + repeat(REPEATS) { + val started = System.nanoTime() + platformBytes = compress(source).size + platformSamples.add((System.nanoTime() - started) / 1_000_000.0) + } + + val rustSamples = ArrayList(REPEATS) + var rustBytes = 0 + repeat(REPEATS) { + val started = System.nanoTime() + rustBytes = TilePngEncoder.encode(source)!!.size + rustSamples.add((System.nanoTime() - started) / 1_000_000.0) + } + + println( + "TILEPNG %dx%d compress=%.1fms/%dB rust=%.1fms/%dB".format( + TILE, TILE, + median(platformSamples), platformBytes, + median(rustSamples), rustBytes, + ), + ) + } + + private fun opaqueFixture(): Bitmap { + val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) + for (y in 0 until TILE) { + for (x in 0 until TILE) { + // Distinct per channel, so a channel swap cannot coincide. + bitmap.setPixel(x, y, Color.argb(255, x % 256, y % 256, (x + 2 * y) % 256)) + } + } + return bitmap + } + + private fun translucentFixture(): Bitmap { + val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) + for (y in 0 until TILE) { + for (x in 0 until TILE) { + bitmap.setPixel(x, y, Color.argb((x * 3) % 256, 240, 90, 30)) + } + } + return bitmap + } + + /** Incompressible content, from a fixed seed so runs stay comparable. */ + private fun noiseFixture(): Bitmap { + val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) + val pixels = IntArray(TILE * TILE) + var state = 0x2545F491 + for (index in pixels.indices) { + state = state * 1_664_525 + 1_013_904_223 + val bits = state ushr 8 + pixels[index] = + Color.argb( + 128 + (bits and 0x7F), + (bits ushr 7) and 0xFF, + (bits ushr 15) and 0xFF, + (bits ushr 23) and 0xFF, + ) + } + bitmap.setPixels(pixels, 0, TILE, 0, 0, TILE, TILE) + return bitmap + } + + private fun compress(bitmap: Bitmap): ByteArray { + val stream = ByteArrayOutputStream(TILE * TILE) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + return stream.toByteArray() + } + + private fun decode(png: ByteArray): Bitmap = + BitmapFactory + .decodeByteArray(png, 0, png.size) + .copy(Bitmap.Config.ARGB_8888, false) + + private fun median(samples: List): Double = samples.sorted()[samples.size / 2] + + private companion object { + const val TILE = 512 + const val REPEATS = 5 + } +} diff --git a/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt b/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt index 6de43e1..c989e3a 100644 --- a/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt +++ b/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt @@ -13,6 +13,7 @@ import com.mapconductor.core.groundimage.GroundImageState import com.mapconductor.core.groundimage.OnGroundImageEventHandler import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapDiagnostics import com.mapconductor.core.map.MapViewHolderInterface import com.mapconductor.core.marker.MarkerAnimationOverlayHost import com.mapconductor.core.marker.MarkerCapableInterface @@ -25,6 +26,7 @@ import com.mapconductor.core.polyline.OnPolylineEventHandler import com.mapconductor.core.polyline.PolylineCapableInterface import com.mapconductor.core.polyline.PolylineState import com.mapconductor.core.raster.RasterLayerCapableInterface +import com.mapconductor.core.raster.RasterLayerController import com.mapconductor.core.raster.RasterLayerState import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs @@ -376,6 +378,105 @@ abstract class BaseMapViewController : override fun hasRasterLayer(state: RasterLayerState): Boolean = hasOverlay(OverlayKind.RasterLayer, state.id) + /** + * Adds or replaces a raster layer that is **not** part of the content + * block. + * + * `compositionRasterLayers` replaces the whole set with what the + * composition declared, so anything mounted from outside it would be + * swept away on the next recomposition. [RasterLayerController.upsert] + * keeps its ids out of that sweep, and this is how the two callers that + * need it get there: marker tiling, and a [MapViewStyle] on a backend + * that cannot draw a vector style and has to be handed tiles rendered + * from one. + * + * Provider-agnostic on purpose. The raster controller is already + * registered as an overlay controller by every provider, so this needs + * nothing per provider -- which is the difference between a seam that + * works on eleven backends and one that is wired on three. + */ + open fun mountRasterLayer(state: RasterLayerState) { + val controllers = rasterLayerControllers() + if (controllers.isEmpty()) { + noRasterMount(state.id) + return + } + mainCoroutine.launch { controllers.forEach { it.upsert(state) } } + } + + /** Takes off what [mountRasterLayer] put on. A layer that is not there is not an error. */ + open fun unmountRasterLayer(id: String) { + val controllers = rasterLayerControllers() + if (controllers.isEmpty()) return + mainCoroutine.launch { controllers.forEach { it.removeById(id) } } + } + + /** + * Says so when a provider has nowhere to mount one. + * + * Most providers reach their tiles through a [RasterLayerController] + * registered as an overlay controller, and get [mountRasterLayer] for + * free. TomTom and Longdo compose rasters into their own style instead + * and have no such controller, so they override the pair. A provider + * that does neither would otherwise show an empty map and say nothing, + * which is the failure this whole design is built against. + */ + private fun noRasterMount(id: String) { + MapDiagnostics.sink.log( + "raster layer $id was not mounted: this provider registers no RasterLayerController " + + "and does not override mountRasterLayer", + ) + } + + private fun rasterLayerControllers(): List> = + overlayControllersOf(OverlayKind.RasterLayer).filterIsInstance>() + + private val styleLoadedListeners = CopyOnWriteArrayList<() -> Unit>() + + /** + * Runs [block] every time this map finishes loading a style document, + * and once now if it already has one. + * + * Everything a [MapViewStyle] put on a live renderer -- paint + * properties, a layer it added -- is gone when that renderer loads a + * document again, for any reason: the app switching basemap, a design + * change, the provider rebuilding its view. This is how it goes back on, + * and it is a registry rather than one listener because both the + * mutation store and the style installation need to hear it. + * + * A provider that has no such thing (Google Maps, MapKit, HERE...) never + * calls [notifyStyleLoaded], and a style installed on it is one that was + * rasterised into tiles -- which survives a basemap change by itself. + * + * @return the ticket to stop listening. + */ + fun addStyleLoadedListener(block: () -> Unit): () -> Unit { + styleLoadedListeners.add(block) + if (styleLoaded) block() + return { styleLoadedListeners.remove(block) } + } + + /** + * Called by the provider once the map has a style up **and** the + * overlays are back on it. + * + * Not when the load starts, and not before the provider has put its own + * layers back: a listener that patches a style mid-rebuild patches + * something that is about to be replaced. + */ + protected fun notifyStyleLoaded() { + styleLoaded = true + styleLoadedListeners.forEach { it() } + } + + /** + * Whether a style document is up now. + * + * Sticky, like [mapInitialized] and for the same reason: a provider can + * finish loading before anything has asked to be told about it. + */ + private var styleLoaded = false + fun setMapInitializedListener(listener: OnMapInitializedHandler?) { this.mapInitializedCallback = listener deliverMapInitializedCallbackIfReady() diff --git a/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt b/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt index e436e95..d4991bb 100644 --- a/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt +++ b/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt @@ -1,341 +1,340 @@ -package com.mapconductor.core.geocell - -import com.mapconductor.core.projection.ProjectedPoint -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, -) +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/map/ApplyStyleMutations.kt b/src/main/java/com/mapconductor/core/map/ApplyStyleMutations.kt new file mode 100644 index 0000000..45f8bbe --- /dev/null +++ b/src/main/java/com/mapconductor/core/map/ApplyStyleMutations.kt @@ -0,0 +1,171 @@ +package com.mapconductor.core.map + +/** + * The seven things a renderer has to be able to do for a style to be + * adjusted in place. + * + * A provider implements these against its own SDK and gets + * [applyStyleMutations] for everything above them — what each mutation + * means, which ones can be skipped, and keeping one bad property from + * costing the rest. MapLibre and Mapbox name their style APIs differently, + * so the adapter has to be per-provider; the decisions do not. + * + * Values arrive as JSON text, which is what crossed the FFI boundary: the + * provider turns it into whatever its SDK wants — a Gson tree for + * MapLibre's `Expression.Converter`, a `Value` for Mapbox v11. + * + * js-sdk-core does the same work structurally, because MapLibre GL JS, + * Mapbox GL JS and the MapTiler SDK happen to share their method names. + */ +interface StyleMutationTarget { + /** + * Whether the renderer has a style up to be changed. + * + * **False is not failure, and must not be reported as one.** A renderer + * with no style loaded yet cannot be asked about layers, so without this + * every mutation came back as one the map "cannot" make -- which is how + * an app got told "N adjustments were not applied" about a map that was + * merely still loading, and, under [VectorStyleMutationSupport]'s + * reloading policy, had the adjusted document handed to it for no + * reason. Nothing is lost by waiting: the store puts the whole set back + * when the style loads. + * + * Override it. The default is for a target that is always ready; a + * provider whose style arrives asynchronously -- which is all three of + * them -- answers whether it has one. + */ + val isReady: Boolean + get() = true + + /** + * False for a layer the loaded style does not have. + * + * Asked only when [isReady], so it is about the style's contents and + * never about whether there is a style: answering false for "not loaded + * yet" is what [isReady] exists to stop. A bridge that cannot be asked + * about a document's layers -- MapTiler's can only be asked about the + * ones it was told to add -- answers true and lets the attempt decide. + * + * Only the four property changes ask. [addLayer] and [removeLayer] guard + * themselves, because "is it already there" is a question only the + * renderer can answer about a layer it owns. + */ + fun hasLayer(layerId: String): Boolean + + fun setPaint( + layerId: String, + key: String, + valueJson: String, + ) + + fun setLayout( + layerId: String, + key: String, + valueJson: String, + ) + + /** `"null"` removes the filter. */ + fun setFilter( + layerId: String, + filterJson: String, + ) + + fun setZoomRange( + layerId: String, + minZoom: Float, + maxZoom: Float, + ) + + /** + * Adds a layer, or does nothing if the renderer already has one with + * this id -- which is the usual case, because a style reload puts the + * whole set back. + */ + fun addLayer( + layerId: String, + layerJson: String, + beforeId: String?, + ) + + /** A layer the renderer does not have is not an error. */ + fun removeLayer(layerId: String) +} + +/** + * Applies style mutations to a live renderer through [target]. + * + * The body of a provider's [VectorStyleMutationSupport]: a provider wires + * this into a [StyleMutationStore] and registers that, and every provider + * then behaves identically by construction. + * + * @return the mutations it could not apply. A layer the loaded style does + * not have, or a property the renderer rejected: the caller is told + * rather than left to assume the map looks the way the rules asked. + * Nothing is thrown — one bad property must not cost the other three + * hundred. Empty while the renderer has no style up, because then there + * is nothing it could not do — only something it has not done yet. + */ +fun applyStyleMutations( + target: StyleMutationTarget, + mutations: List, +): List { + // Not ready is not a refusal. See [StyleMutationTarget.isReady]. + if (!target.isReady) return emptyList() + return mutations.filter { mutation -> + runCatching { !applyOne(target, mutation) }.getOrDefault(true) + } +} + +private fun applyOne( + target: StyleMutationTarget, + mutation: StyleMutation, +): Boolean = + when (mutation) { + is StyleMutation.SetPaint -> { + if (!target.hasLayer(mutation.layerId)) { + false + } else { + target.setPaint(mutation.layerId, mutation.key, mutation.value) + true + } + } + is StyleMutation.SetLayout -> { + if (!target.hasLayer(mutation.layerId)) { + false + } else { + target.setLayout(mutation.layerId, mutation.key, mutation.value) + true + } + } + is StyleMutation.SetFilter -> { + if (!target.hasLayer(mutation.layerId)) { + false + } else { + target.setFilter(mutation.layerId, mutation.value) + true + } + } + is StyleMutation.SetZoomRange -> { + if (!target.hasLayer(mutation.layerId)) { + false + } else { + // The spec's own defaults, for a layer that named neither. + // Every renderer takes both ends at once, so a mutation that + // changes one has to restate the other. + target.setZoomRange( + mutation.layerId, + mutation.minZoom?.toFloat() ?: 0f, + mutation.maxZoom?.toFloat() ?: 24f, + ) + true + } + } + is StyleMutation.AddLayer -> { + target.addLayer(mutation.layerId, mutation.layer, mutation.beforeId) + true + } + is StyleMutation.RemoveLayer -> { + target.removeLayer(mutation.layerId) + true + } + } diff --git a/src/main/java/com/mapconductor/core/map/MapViewStyle.kt b/src/main/java/com/mapconductor/core/map/MapViewStyle.kt new file mode 100644 index 0000000..d2c0269 --- /dev/null +++ b/src/main/java/com/mapconductor/core/map/MapViewStyle.kt @@ -0,0 +1,156 @@ +package com.mapconductor.core.map + +import androidx.compose.runtime.Stable +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.tileserver.LocalTileServer + +/** + * How a map looks, as a property of the map rather than a layer inside it. + * + * A vector style *is* the basemap, so passing one to `XxxMapView(style = ...)` + * says what it is rather than mounting something over it. What happens + * underneath depends on the backend and the app does not have to know: + * MapLibre, Mapbox and MapTiler are told the style and draw it themselves; + * Google Maps, MapKit, HERE, ArcGIS and the rest are handed raster tiles + * rendered from the same style on the device. Either way the same + * adjustments produce the same map. + * + * An interface rather than a class, for two reasons. Core must not depend on + * the style compiler or the rasteriser — both are optional modules, and one + * of them is a megabyte of native code — so what core holds is the seam and + * the modules bring the implementation. And a provider's own styling, which + * is nothing like a MapLibre style, can be added later as another + * implementation without this changing. + * + * The concrete implementations live in the vector style module + * (`com.mapconductor:vectorstyle`); an app does not implement this. + * + * ios-sdk-core and js-sdk-core carry the same seam. + */ +@Stable +interface MapViewStyle { + /** + * What identifies this style's content. + * + * The map re-installs a style whose key changed and leaves alone one + * whose key did not. Two instances describing the same thing must have + * the same key: a style built inline in a composable is a new instance + * on every recomposition — which is exactly what a colour picker + * produces — and keying off instance identity would reinstall the whole + * style on every unrelated state change. + */ + val key: String + + /** + * Puts the style on the map. Called once the map has a style loaded, + * and again if it loads another one. + * + * @return the ticket that takes it off again. Disposing must leave the + * map as it was found: a style that cannot be fully undone has no + * business being installed, because the app will switch away from it. + */ + fun install(host: MapStyleHost): MapStyleInstallation +} + +/** + * A ticket to undo, and a chance to take a change without tearing down. + * + * Returned by anything that sets something up on a map. + */ +interface MapStyleInstallation { + fun dispose() + + /** + * Takes [next] in place, when it is near enough to what is installed. + * + * Without this the whole point is lost. A style whose key changed is + * installed again, and installing a style means handing the map a + * document -- which is the reload this design exists to avoid. Moving a + * colour slider changes the adjustments and nothing else, so the + * installed style answers true, applies the difference, and the map + * never reloads anything. + * + * @return false when it cannot, and the caller disposes and installs + * [next] from scratch. False is always correct, only slower. + */ + fun update(next: MapViewStyle): Boolean = false + + companion object { + /** For a style that set nothing up. */ + @JvmField + val NONE: MapStyleInstallation = + object : MapStyleInstallation { + override fun dispose() = Unit + } + + /** The common case: one thing to undo, nothing that can be updated. */ + @JvmStatic + fun of(dispose: () -> Unit): MapStyleInstallation = + object : MapStyleInstallation { + override fun dispose() = dispose() + } + } +} + +/** + * What a map offers to the style installed on it. + * + * The provider implements this; a [MapViewStyle] uses it and never touches + * a provider type. That is what keeps one style implementation working on + * every backend. + */ +interface MapStyleHost { + /** + * Where to look for what this map can do: + * [VectorStyleSupportKey] to hand over a style document, + * [VectorStyleMutationSupportKey] to change a loaded one in place, + * [com.mapconductor.core.raster.RasterTilePreferenceKey] for the tile + * size it would rather have. + */ + val serviceRegistry: MapServiceRegistry + + /** For serving a style document, or tiles rendered from one, to the map. */ + val tileServer: LocalTileServer + + /** + * The vector style the map is drawing now, when it is drawing one. + * + * Null on every backend that has no such thing, which is most of them. + * It is how a rule set that adjusts "whatever is showing" finds + * something to adjust: the style has to be read before a rule can be + * matched against its layers, and only the map knows where it came from. + */ + val vectorStyleUrl: String? + get() = null + + /** + * Runs [block] whenever the map finishes loading a style, including the + * one it has now if it already has one. + * + * Everything a style installs on a live renderer is lost when that + * renderer loads a document again, for any reason. This is how it gets + * put back. + */ + fun onStyleLoaded(block: () -> Unit): MapStyleInstallation + + /** + * Adds or updates a raster layer the app did not declare. + * + * The raster path needs somewhere to put its tiles, and that cannot be + * the content block: the style is a property of the view, so it is + * installed from outside the composition. Marker tiling already mounts + * its layer the same way. + */ + fun upsertRaster(state: RasterLayerState) + + fun removeRaster(id: String) + + /** + * Says what the app should know: a rule that matched nothing, an + * adjustment this backend cannot make, a style that could not be read. + * + * Routed to whatever the host gave the style, so a module can report + * without knowing how the app logs. + */ + fun report(diagnostics: List) +} diff --git a/src/main/java/com/mapconductor/core/map/MapViewStyleEffect.kt b/src/main/java/com/mapconductor/core/map/MapViewStyleEffect.kt new file mode 100644 index 0000000..6b21678 --- /dev/null +++ b/src/main/java/com/mapconductor/core/map/MapViewStyleEffect.kt @@ -0,0 +1,55 @@ +package com.mapconductor.core.map + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import com.mapconductor.core.controller.BaseMapViewController + +/** + * Keeps the map's [MapViewStyle] installed, for every provider. + * + * Each `XxxMapView` takes a `style` and calls this once; nothing else about + * a provider has to know what a vector style is. What a style then does to + * the map depends on what that map declared it can do — draw a style + * document, change a loaded one in place, or neither, in which case the + * style's own rasteriser renders tiles and they go on as a raster layer. + * + * The decisions are all in [MapViewStyleHost.apply]; what this adds is + * *when* to ask. Two of those are Compose's: + * + * - **On every composition, not only when `style` changes.** A style built + * inline is a new instance each time, and the one that matters — the one + * whose rules just moved — is indistinguishable from the one that did + * not. Only the host can tell, by its key, so it is asked every time. + * - **Not before the controller exists.** A style acts on a live map, and + * the provider builds its controller asynchronously. + * + * ios-sdk-core and js-sdk-react do the same work; the shape differs because + * each framework owns its lifecycle differently, and React Native does not + * do it at all — there the style is carried to this SDK rather than + * installed in JavaScript. + */ +@Composable +fun MapViewStyleEffect( + state: MapViewStateInterface<*>, + style: MapViewStyle?, + controller: BaseMapViewController?, + onDiagnostics: ((List) -> Unit)? = null, +) { + val report by rememberUpdatedState(onDiagnostics) + val host = remember(state, controller) { controller?.let { MapViewStyleHost(state.serviceRegistry, it) } } + + // `SideEffect` rather than a call in the composition body: a + // composition can be run more than once and thrown away, and installing + // a style is not something to do speculatively. This runs once per + // *successful* composition, with no keys, which is what "ask every + // time" means. The host is what decides whether anything happens. + SideEffect { host?.apply(style, report) } + + DisposableEffect(host) { + onDispose { host?.dispose() } + } +} diff --git a/src/main/java/com/mapconductor/core/map/MapViewStyleHost.kt b/src/main/java/com/mapconductor/core/map/MapViewStyleHost.kt new file mode 100644 index 0000000..ab4e0c2 --- /dev/null +++ b/src/main/java/com/mapconductor/core/map/MapViewStyleHost.kt @@ -0,0 +1,136 @@ +package com.mapconductor.core.map + +import com.mapconductor.core.controller.BaseMapViewController +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.tileserver.LocalTileServer +import com.mapconductor.core.tileserver.TileServerRegistry + +/** + * What a map offers the style installed on it, for every provider at once. + * + * Provider-agnostic by construction: the three things a style needs that + * differ per backend are all reached through seams every provider already + * fills in — the service registry for what the map can do, the controller's + * overlay controllers for mounting tiles, and the controller's style-loaded + * listeners for putting an adjustment back after a reload. Adding a twelfth + * provider needs nothing here. + */ +class MapViewStyleHost( + /** + * What this map can do. The registry rather than the state, because + * React Native's wrapper has one and no state -- and the registry is + * all a style ever reads. ios-sdk and js-sdk-core take the same. + */ + override val serviceRegistry: MapServiceRegistry, + private val controller: BaseMapViewController, +) : MapStyleHost { + private var installation: MapStyleInstallation? = null + private var installedKey: String? = null + private var diagnosticsHandler: ((List) -> Unit)? = null + + /** How many updates in a row the installed style has refused. See [apply]. */ + private var refusedUpdates = 0 + + /** + * Keeps [style] installed on this map. + * + * Called on every recomposition (and on every React Native prop update), + * so most calls do nothing. Three things have to line up and none of + * them is the update: + * + * - **A style whose key did not change must be left alone.** A style + * built inline is a new instance every time — which is exactly what a + * colour picker produces — so comparing instances would reinstall on + * every unrelated state change. + * - **A changed style is usually an adjustment, not a new style.** + * Disposing and installing again hands the map a document and reloads + * it, which is the cost this whole design exists to avoid. What is + * installed is offered the new one first and only replaced if it + * refuses. + * - **Null means take it off.** An app that stops passing a style gets + * the map it had before, not the one the style left behind. + */ + fun apply( + style: MapViewStyle?, + onDiagnostics: ((List) -> Unit)? = null, + ) { + diagnosticsHandler = onDiagnostics + if (style == null) { + dispose() + return + } + val current = installation + if (current == null) { + installation = style.install(this) + installedKey = style.key + return + } + // Nothing changed at all. The key covers the document *and* the + // rules, so an equal key means there is no work -- and doing it + // anyway is not merely wasteful: recompiling re-reports the + // diagnostics, an app that renders them sets state, that recomposes, + // and the two spin against each other. + if (installedKey == style.key) return + + // The adjustments moved. Offer them to what is installed: taking + // them in place is what keeps a slider from reloading the map. + if (current.update(style)) { + installedKey = style.key + refusedUpdates = 0 + return + } + + // Refused -- correct, only slower. Refusing every time means the + // style cannot recognise its own successor and the map is rebuilt + // on each change; the usual cause is a value the style compares by + // identity being rebuilt too, a rasteriser constructed inline + // rather than held in `remember`. + refusedUpdates++ + if (refusedUpdates == CHURN_THRESHOLD) { + MapDiagnostics.sink.log( + "style: the installed style has refused $CHURN_THRESHOLD updates in a row, so " + + "the map is being torn down and rebuilt on every change. Hold the rasteriser " + + "(and anything else the style compares by identity) in `remember` rather " + + "than rebuilding it.", + ) + } + current.dispose() + installation = style.install(this) + installedKey = style.key + } + + /** Takes the style off. The caller does this when the view goes. */ + fun dispose() { + installation?.dispose() + installation = null + installedKey = null + } + + override val tileServer: LocalTileServer + get() = TileServerRegistry.get() + + override val vectorStyleUrl: String? + get() = serviceRegistry.get(VectorStyleSupportKey)?.currentStyleUrl + + override fun onStyleLoaded(block: () -> Unit): MapStyleInstallation = + MapStyleInstallation.of(controller.addStyleLoadedListener(block)) + + override fun upsertRaster(state: RasterLayerState) = controller.mountRasterLayer(state) + + override fun removeRaster(id: String) = controller.unmountRasterLayer(id) + + override fun report(diagnostics: List) { + if (diagnostics.isEmpty()) return + val handler = diagnosticsHandler + if (handler != null) { + handler(diagnostics) + return + } + // Never dropped. A rule that matched nothing, or an adjustment this + // backend could not make, is the kind of thing that otherwise shows + // up as "the style nearly worked" with no way to find out why. + diagnostics.forEach { MapDiagnostics.sink.log("style: $it") } + } +} + +private const val CHURN_THRESHOLD = 5 diff --git a/src/main/java/com/mapconductor/core/map/StyleMutation.kt b/src/main/java/com/mapconductor/core/map/StyleMutation.kt new file mode 100644 index 0000000..a4fc45e --- /dev/null +++ b/src/main/java/com/mapconductor/core/map/StyleMutation.kt @@ -0,0 +1,222 @@ +package com.mapconductor.core.map + +import org.json.JSONArray +import org.json.JSONObject + +/** + * One change to one layer of a vector style the map has already loaded. + * + * This is what lets a style be adjusted without being reloaded. Handing a + * map a new style document is the obvious way to change how it looks, and on + * every provider it is the expensive way: the renderer drops its tiles, + * re-reads the document, re-tessellates, and the overlays the app added have + * to be put back. A paint property set in place costs none of that. + * + * Mutations are produced by the vector style compiler from the same + * evaluation that produces the adjusted document, so the two can never + * disagree about what the style should look like. A provider that can take + * them declares [VectorStyleMutationSupport]. + * + * ## Why the values are JSON text + * + * A style property's value is anything the style spec allows: a string, a + * number, or an expression tree. Each provider needs it in its own form — + * MapLibre on Android wants a Gson tree, MapLibre on iOS an `NSExpression`, + * MapLibre in a browser the plain parsed value — so there is no shared typed + * representation that saves anyone a conversion. Text is the one form all + * three can start from, and it is what crossed the FFI boundary anyway. + * + * `"null"` means the property had no value, which is also how a renderer is + * told to go back to the spec default. + */ +sealed class StyleMutation { + /** The layer this acts on. Every mutation names one. */ + abstract val layerId: String + + /** + * What this mutation occupies, for working out which of two sets of + * mutations replace each other. Two mutations with the same target are + * the same change with different values. + */ + abstract val target: String + + /** + * What this mutation changes, named the way the style spec names it. + * + * For telling an app *what* a renderer would not do. The layer is the + * wrong thing to name on its own: when a backend has no property for a + * spec key, the same key fails on every layer that uses it, so a list + * of layers is hundreds of names for one cause while the key is the + * cause. + */ + open val propertyName: String + get() = "layer" + + /** The mutation that puts back what was there, or null when there is nothing to put back. */ + abstract fun reversed(): StyleMutation? + + data class SetPaint( + override val layerId: String, + val key: String, + /** JSON text. */ + val value: String, + /** JSON text of what was there before. */ + val previous: String, + ) : StyleMutation() { + override val target get() = "paint|$layerId|$key" + + override val propertyName: String get() = key + + override fun reversed() = SetPaint(layerId, key, previous, value) + } + + data class SetLayout( + override val layerId: String, + val key: String, + val value: String, + val previous: String, + ) : StyleMutation() { + override val target get() = "layout|$layerId|$key" + + override val propertyName: String get() = key + + override fun reversed() = SetLayout(layerId, key, previous, value) + } + + data class SetFilter( + override val layerId: String, + val value: String, + val previous: String, + ) : StyleMutation() { + override val target get() = "filter|$layerId" + + override val propertyName: String get() = "filter" + + override fun reversed() = SetFilter(layerId, previous, value) + } + + data class SetZoomRange( + override val layerId: String, + val minZoom: Double?, + val maxZoom: Double?, + val previousMinZoom: Double?, + val previousMaxZoom: Double?, + ) : StyleMutation() { + override val target get() = "zoom|$layerId" + + override val propertyName: String get() = "zoom range" + + override fun reversed() = SetZoomRange(layerId, previousMinZoom, previousMaxZoom, minZoom, maxZoom) + } + + /** + * Adds a layer the style did not have. + * + * Used for one thing: a background under a style that has none, so that + * "everything else muted" looks the same whether the style is drawn by + * the map or rasterised into tiles over another basemap. Without it the + * raster path leaves the gaps transparent and the two disagree. + */ + data class AddLayer( + /** + * The new layer's id. + * + * Carried rather than read back out of [layer]: the compiler knew + * it, every reader needs it, and three JSON parses of the same + * object to recover it are three chances to get it wrong. + */ + override val layerId: String, + /** JSON text of the whole layer object. */ + val layer: String, + /** Insert below this layer; null appends. */ + val beforeId: String?, + ) : StyleMutation() { + override val target get() = "add|$layerId" + + override fun reversed() = RemoveLayer(layerId) + } + + data class RemoveLayer( + override val layerId: String, + ) : StyleMutation() { + override val target get() = "add|$layerId" + + override fun reversed(): StyleMutation? = null + } + + companion object { + private const val TAG = "StyleMutation" + + /** + * Reads the compiler's `mutations` array. + * + * An entry this version does not know is skipped rather than fatal: + * the array is produced by the native library this host shipped with, + * so an unknown op means the two went out of step, and dropping one + * change is better than losing every change. + */ + @JvmStatic + fun parseList(json: String): List { + val array = runCatching { JSONArray(json) }.getOrNull() ?: return emptyList() + return (0 until array.length()).mapNotNull { index -> + array.optJSONObject(index)?.let(::parse) + } + } + + private fun parse(obj: JSONObject): StyleMutation? { + val layerId = obj.optString("layerId").takeIf { it.isNotEmpty() } + return when (obj.optString("op")) { + "setPaint" -> + layerId?.let { + SetPaint(it, obj.optString("key"), jsonTextOf(obj.opt("value")), jsonTextOf(obj.opt("previous"))) + } + "setLayout" -> + layerId?.let { + SetLayout(it, obj.optString("key"), jsonTextOf(obj.opt("value")), jsonTextOf(obj.opt("previous"))) + } + "setFilter" -> + layerId?.let { + SetFilter(it, jsonTextOf(obj.opt("value")), jsonTextOf(obj.opt("previous"))) + } + "setZoomRange" -> + layerId?.let { + SetZoomRange( + it, + obj.optDoubleOrNull("minZoom"), + obj.optDoubleOrNull("maxZoom"), + obj.optDoubleOrNull("previousMinZoom"), + obj.optDoubleOrNull("previousMaxZoom"), + ) + } + "addLayer" -> + obj.optJSONObject("layer")?.let { layer -> + layerId?.let { + AddLayer(it, layer.toString(), obj.optString("beforeId").takeIf { id -> id.isNotEmpty() }) + } + } + "removeLayer" -> layerId?.let(::RemoveLayer) + else -> null + } + } + + private fun JSONObject.optDoubleOrNull(key: String): Double? = + if (isNull(key)) null else optDouble(key).takeIf { !it.isNaN() } + + /** + * A JSON node as the text that would produce it. + * + * `org.json` has no "write this value" call -- `toString()` on a + * String gives the bare characters, which would turn `"none"` into + * something no parser can read back. + */ + @JvmStatic + fun jsonTextOf(value: Any?): String = + when (value) { + null, JSONObject.NULL -> "null" + is String -> JSONObject.quote(value) + is JSONObject, is JSONArray -> value.toString() + is Boolean, is Number -> value.toString() + else -> JSONObject.quote(value.toString()) + } + } +} diff --git a/src/main/java/com/mapconductor/core/map/VectorStyleMutationSupport.kt b/src/main/java/com/mapconductor/core/map/VectorStyleMutationSupport.kt new file mode 100644 index 0000000..2a820b9 --- /dev/null +++ b/src/main/java/com/mapconductor/core/map/VectorStyleMutationSupport.kt @@ -0,0 +1,113 @@ +package com.mapconductor.core.map + +/** + * A map that can be told to change a loaded style in place. + * + * Separate from [VectorStyleSupport], which is about making a style *be* the + * basemap. This one is about changing the one that is already up, without + * the reload that would otherwise cost every tile on screen and every + * overlay the app added. A provider declares it under + * [VectorStyleMutationSupportKey]. + * + * Not every provider can take every change: MapLibre on iOS exposes its + * paint properties as typed `NSExpression`s rather than by name, so a raw + * style-spec key it has no mapping for cannot be set. That is why [apply] + * answers with what it could not do instead of returning nothing — a caller + * that silently assumed success would show a map that is half adjusted and + * say nothing about it. + * + * ios-sdk-core and js-sdk-core carry the same capability. + */ +interface VectorStyleMutationSupport { + /** + * Applies [mutations] to the live style, replacing any set applied + * before: anything the previous set changed and this one does not is put + * back the way it was. + * + * @return the mutations that could not be applied, empty when all were. + */ + fun apply(mutations: List): List + + /** Puts back everything [apply] changed. */ + fun clear() +} + +/** + * [VectorStyleMutationSupport] の登録キー。 + * + * 宣言しないプロバイダは「生きているスタイルは触れない」。スタイル調整は + * その場合、文書を差し替えるか(地図が読み直す)、ラスター経路で描き直す。 + */ +object VectorStyleMutationSupportKey : MapServiceKey + +/** + * The bookkeeping every provider's [VectorStyleMutationSupport] needs, so no + * provider has to get it right twice. + * + * Two things are easy to miss and both show up as "the adjustment worked, + * and then stopped": + * + * - **Replacing a set has to undo what it drops.** Moving a slider sends a + * whole new set each time. A property the new set no longer mentions must + * go back to the value the style gave it — and only the mutation knows + * that value, because by now the renderer's own idea of it is the one this + * put there. + * - **A style load wipes everything.** The app switching basemap, the + * provider reloading, a design change: whatever the reason, the renderer + * comes back with the document's own values and nothing this applied. The + * provider calls [onStyleLoaded] and the whole set goes on again. + * + * @param applyNow puts mutations on the live style and returns the ones it + * could not. Called on whatever thread [apply] was called on; a provider + * whose renderer is main-thread-only hops there itself. + */ +class StyleMutationStore( + private val applyNow: (List) -> List, +) : VectorStyleMutationSupport { + private val lock = Any() + private var current: List = emptyList() + + override fun apply(mutations: List): List { + val (restores, next) = + synchronized(lock) { + val targets = mutations.map { it.target }.toHashSet() + val restores = current.filter { it.target !in targets }.mapNotNull { it.reversed() } + current = mutations + restores to mutations + } + // Restores first: a property moving from one rule's value to no rule + // at all must pass through the style's own value, not stay on the + // old one because the new set happened to be applied first. + // + // Nothing is logged from here. What could not be applied is the + // caller's to report -- it has the app's diagnostics callback, and + // this class is plain logic that unit tests can run off a device. + return applyNow(restores + next) + } + + override fun clear() { + val restores = + synchronized(lock) { + val restores = current.mapNotNull { it.reversed() } + current = emptyList() + restores + } + if (restores.isNotEmpty()) applyNow(restores) + } + + /** + * Re-applies everything after the map has loaded a style. + * + * The provider calls this from whatever it has that fires on a style + * being ready. Without it an adjustment survives only until the next + * design change, which is the kind of bug that is found weeks later by + * someone switching basemap with a slider open. + */ + fun onStyleLoaded() { + val again = synchronized(lock) { current } + if (again.isNotEmpty()) applyNow(again) + } + + /** What is applied right now. For tests and diagnostics. */ + val applied: List get() = synchronized(lock) { current } +} diff --git a/src/main/java/com/mapconductor/core/map/VectorStyleSupport.kt b/src/main/java/com/mapconductor/core/map/VectorStyleSupport.kt index c732daf..1a410f3 100644 --- a/src/main/java/com/mapconductor/core/map/VectorStyleSupport.kt +++ b/src/main/java/com/mapconductor/core/map/VectorStyleSupport.kt @@ -42,6 +42,22 @@ interface VectorStyleSupport { * has since chosen another one, in which case that choice is left alone. */ fun clearStyle() + + /** + * Where the style the map is drawing came from, when that is knowable. + * + * This is what lets a rule set adjust *whatever is showing* rather than a + * style the app had to name: the document has to be read before a rule + * can be matched against its layers, and only the provider knows where + * it came from. + * + * Null when it is not a URL anything can fetch -- a provider's own named + * design (`mapbox://styles/mapbox/standard`), a style that needs a key + * this does not have. A caller that wanted it says so and leaves the map + * alone rather than showing something unstyled. + */ + val currentStyleUrl: String? + get() = null } /** @@ -73,6 +89,7 @@ object VectorStyleSupportKey : MapServiceKey */ class VectorStyleAsDesign>( private val state: MapViewStateInterface, + private val urlOf: ((Design) -> String?)? = null, private val designFor: (styleUrl: String, attributionRules: List) -> Design, ) : VectorStyleSupport { private val main = Handler(Looper.getMainLooper()) @@ -122,6 +139,27 @@ class VectorStyleAsDesign>( main.postDelayed(clear, CLEAR_GRACE_MS) } + /** + * Where the design **the app chose** points, when it points anywhere + * fetchable. + * + * Deliberately not the URL this capability installed. That one is the + * document the style module served a moment ago, and it is unregistered + * from the tile server the instant the style comes off -- so answering + * with it turns "adjust whatever is showing" into a 404 against our own + * corpse. When one of ours is up, the honest answer is the design it + * replaced. + * + * Null unless the provider supplied [urlOf], and null from that for a + * design that is not a plain style document -- a provider's own named + * basemap, or one that needs a key this does not have. + */ + override val currentStyleUrl: String? + get() { + val design = if (installed != null) previous else state.mapDesignType + return design?.let { urlOf?.invoke(it) } + } + private companion object { /** Longer than a frame, shorter than anyone notices. */ const val CLEAR_GRACE_MS = 100L diff --git a/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt b/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt index 243d0df..d06ef2c 100644 --- a/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt +++ b/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt @@ -1,48 +1,48 @@ -package com.mapconductor.core.geocell - -import com.mapconductor.core.projection.ProjectedPoint -import com.mapconductor.core.features.GeoPoint -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) - } - } -} +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) + } + } +} diff --git a/src/test/java/com/mapconductor/core/map/ApplyStyleMutationsTest.kt b/src/test/java/com/mapconductor/core/map/ApplyStyleMutationsTest.kt new file mode 100644 index 0000000..281144d --- /dev/null +++ b/src/test/java/com/mapconductor/core/map/ApplyStyleMutationsTest.kt @@ -0,0 +1,269 @@ +package com.mapconductor.core.map + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The body of every provider's mutation support, against a renderer that + * only records. + * + * Kept in step with `applyStyleMutations.test.mjs` on the web and + * `ApplyStyleMutationsTests` on iOS. + */ +class ApplyStyleMutationsTest { + private val calls = mutableListOf() + private val layers = mutableSetOf("roads", "labels") + private var refuse: String? = null + private var ready = true + + private val target = + object : StyleMutationTarget { + override val isReady: Boolean get() = ready + + override fun hasLayer(layerId: String) = layerId in layers + + override fun setPaint( + layerId: String, + key: String, + valueJson: String, + ) { + if (key == refuse) throw IllegalStateException("nope") + calls += "paint $layerId $key=$valueJson" + } + + override fun setLayout( + layerId: String, + key: String, + valueJson: String, + ) { + calls += "layout $layerId $key=$valueJson" + } + + override fun setFilter( + layerId: String, + filterJson: String, + ) { + calls += "filter $layerId=$filterJson" + } + + override fun setZoomRange( + layerId: String, + minZoom: Float, + maxZoom: Float, + ) { + calls += "zoom $layerId=$minZoom..$maxZoom" + } + + override fun addLayer( + layerId: String, + layerJson: String, + beforeId: String?, + ) { + calls += "add $layerId $layerJson before=$beforeId" + } + + override fun removeLayer(layerId: String) { + layers -= layerId + calls += "remove $layerId" + } + } + + @Test + fun setsPaintAndLayoutProperties() { + val unapplied = + applyStyleMutations( + target, + listOf( + StyleMutation.SetPaint("roads", "line-color", "\"#fff\"", "\"#888\""), + StyleMutation.SetLayout("labels", "visibility", "\"none\"", "null"), + ), + ) + + assertTrue(unapplied.isEmpty()) + assertEquals( + listOf("paint roads line-color=\"#fff\"", "layout labels visibility=\"none\""), + calls, + ) + } + + /** + * Every renderer takes both ends of a zoom range at once, so a mutation + * that changes one has to restate the other — and a layer that named + * neither gets the spec's own defaults. + */ + @Test + fun aZoomRangeRestatesBothEnds() { + applyStyleMutations( + target, + listOf(StyleMutation.SetZoomRange("roads", minZoom = 5.0, maxZoom = null, null, null)), + ) + assertEquals(listOf("zoom roads=5.0..24.0"), calls) + } + + /** + * A layer the style does not have is not a crash and not a silent pass: + * it comes back, and the caller says so. + */ + @Test + fun whatTheStyleHasNoLayerForComesBack() { + val unapplied = + applyStyleMutations( + target, + listOf( + StyleMutation.SetPaint("roads", "line-color", "\"#fff\"", "null"), + StyleMutation.SetPaint("nope", "line-color", "\"#fff\"", "null"), + ), + ) + assertEquals(1, unapplied.size) + assertEquals("nope", unapplied.single().layerId) + assertEquals("the good one still went through", 1, calls.size) + } + + /** One property the renderer rejects must not cost the other three hundred. */ + @Test + fun aThrowingPropertyIsReportedNotPropagated() { + refuse = "line-gradient" + val unapplied = + applyStyleMutations( + target, + listOf( + StyleMutation.SetPaint("roads", "line-gradient", "[]", "null"), + StyleMutation.SetPaint("roads", "line-color", "\"#fff\"", "null"), + ), + ) + assertEquals(1, unapplied.size) + assertEquals(listOf("paint roads line-color=\"#fff\""), calls) + } + + @Test + fun addsAndRemovesALayer() { + applyStyleMutations( + target, + listOf(StyleMutation.AddLayer("bg", """{"id":"bg","type":"background"}""", "roads")), + ) + assertEquals(listOf("""add bg {"id":"bg","type":"background"} before=roads"""), calls) + + calls.clear() + applyStyleMutations(target, listOf(StyleMutation.RemoveLayer("roads"))) + assertEquals(listOf("remove roads"), calls) + } + + /** + * Adding and removing go through whatever the layer is, every time. + * + * The renderer decides what to do about a layer that is already there, + * or already gone — only it can say, and on the MapTiler bridge it is + * the only one that keeps a record at all. Putting the whole set back + * after a style reload depends on this: a layer missing from the + * reloaded style must still be added, and `hasLayer` cannot be trusted + * to notice. + */ + @Test + fun addAndRemoveAreNotGuardedHere() { + val unapplied = + applyStyleMutations( + target, + listOf( + StyleMutation.AddLayer("roads", """{"id":"roads","type":"background"}""", null), + StyleMutation.RemoveLayer("gone"), + ), + ) + assertTrue(unapplied.isEmpty()) + assertEquals( + listOf("""add roads {"id":"roads","type":"background"} before=null""", "remove gone"), + calls, + ) + } + + /** + * スタイルがまだ無いときは「できなかった」ではなく「まだしていない」。 + * + * これを混同していたのが、読み込み中の地図に対して + * 「N adjustments were not applied: this map cannot set those properties」 + * と言っていた原因。しかも `UnsupportedPolicy.RELOAD` だと、そのまま + * 調整済みドキュメントを渡し直してしまう(=避けたかった再読み込み)。 + * ロードが終われば store が一式を入れ直すので、失われるものは無い。 + */ + @Test + fun `スタイルが無いあいだは何も報告しない`() { + ready = false + + val unapplied = + applyStyleMutations( + target, + listOf( + StyleMutation.SetPaint("roads", "line-color", "\"#fff\"", "null"), + StyleMutation.SetPaint("nope", "line-color", "\"#fff\"", "null"), + ), + ) + + assertEquals(emptyList(), unapplied) + assertEquals("触ってもいない", emptyList(), calls) + } + + /** 準備できたら、いつもどおり本当にできないものだけが返る。 */ + @Test + fun `準備できたあとは判定が戻る`() { + ready = false + applyStyleMutations(target, listOf(StyleMutation.SetPaint("nope", "line-color", "\"#fff\"", "null"))) + ready = true + + val unapplied = + applyStyleMutations(target, listOf(StyleMutation.SetPaint("nope", "line-color", "\"#fff\"", "null"))) + + assertEquals(1, unapplied.size) + } + + /** + * 既定は「常に準備できている」。外部のドライバが実装していなくても + * 以前と同じ挙動になる(=互換のための既定であって、推奨ではない)。 + */ + @Test + fun `isReady の既定は true`() { + val plain = + object : StyleMutationTarget { + override fun hasLayer(layerId: String) = false + + override fun setPaint( + layerId: String, + key: String, + valueJson: String, + ) = Unit + + override fun setLayout( + layerId: String, + key: String, + valueJson: String, + ) = Unit + + override fun setFilter( + layerId: String, + filterJson: String, + ) = Unit + + override fun setZoomRange( + layerId: String, + minZoom: Float, + maxZoom: Float, + ) = Unit + + override fun addLayer( + layerId: String, + layerJson: String, + beforeId: String?, + ) = Unit + + override fun removeLayer(layerId: String) = Unit + } + + assertTrue(plain.isReady) + assertEquals( + 1, + applyStyleMutations( + plain, + listOf(StyleMutation.SetPaint("roads", "line-color", "\"#fff\"", "null")), + ).size, + ) + } +} diff --git a/src/test/java/com/mapconductor/core/map/MapViewStyleHostTest.kt b/src/test/java/com/mapconductor/core/map/MapViewStyleHostTest.kt new file mode 100644 index 0000000..ba35823 --- /dev/null +++ b/src/test/java/com/mapconductor/core/map/MapViewStyleHostTest.kt @@ -0,0 +1,251 @@ +package com.mapconductor.core.map + +import com.mapconductor.core.controller.BaseMapViewController +import com.mapconductor.core.features.GeoRectBounds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * [MapViewStyleHost.apply] の決定表。 + * + * このクラスで実際に意味があるのは「いつ入れ直し、いつ入れ直さないか」だけで、 + * 残りはプロバイダへの受け渡しでしかない。そして一度その条件が逆になったまま + * 気付かれなかった:**同じキーで `update` を呼び**(再コンパイル → 診断 → + * state → 再コンポジション → 無限ループ。ユーザーには左下パネルのチラつきと + * して見えた)、**キーが変わったときは `update` を呼ばなかった**(毎回 + * 破棄して入れ直す。差分をコンパイルする設計の意味がなくなる)。 + * 両方とも「コンパイルは通り地図も描ける」種類の壊れ方なので、ここで表に + * して押さえる。 + * + * ios-sdk の `MapViewStyleHostTests`、js-sdk-core の `MapViewStyleHost.test.mjs` + * と 1 対 1 で対応する。 + */ +class MapViewStyleHostTest { + private val log = mutableListOf() + private lateinit var originalSink: MapDiagnostics.Sink + + @Before + fun setUp() { + originalSink = MapDiagnostics.sink + MapDiagnostics.sink = MapDiagnostics.Sink { log.add(it) } + } + + @After + fun tearDown() { + MapDiagnostics.sink = originalSink + } + + /** 何が呼ばれたかだけを記録するスタイル。 */ + private class RecordingStyle( + override val key: String, + private val calls: MutableList, + /** + * `update` が受けるかどうか。false なら呼び出し側は破棄して入れ直す。 + * 関数なのは、入れたあとに答えを変えられるようにするため(下の + * 「連続カウントは戻る」)。 + */ + private val takesUpdates: () -> Boolean = { true }, + ) : MapViewStyle { + override fun install(host: MapStyleHost): MapStyleInstallation { + calls.add("install($key)") + return object : MapStyleInstallation { + override fun dispose() { + calls.add("dispose($key)") + } + + override fun update(next: MapViewStyle): Boolean { + calls.add("update($key -> ${next.key})") + return takesUpdates() + } + } + } + } + + private val calls = mutableListOf() + + private fun host(): MapViewStyleHost = + MapViewStyleHost(MutableMapServiceRegistry(), NoController()) + + private fun style( + key: String, + takesUpdates: Boolean = true, + ) = RecordingStyle(key, calls) { takesUpdates } + + private fun style( + key: String, + takesUpdates: () -> Boolean, + ) = RecordingStyle(key, calls, takesUpdates) + + @Test + fun `最初の apply で入る`() { + host().apply(style("a")) + assertEquals(listOf("install(a)"), calls) + } + + /** + * キーが同じなら何もしない。これが逆になっていたのがチラつきの原因で、 + * 再コンパイルは無駄なだけでなく診断を再送し、それを描くアプリが state を + * 更新して再コンポジションを呼ぶ。 + */ + @Test + fun `キーが同じ apply は何も呼ばない`() { + val host = host() + host.apply(style("a")) + calls.clear() + + // 中身の同じ別インスタンス。インライン生成されたスタイルはこうなる。 + host.apply(style("a")) + host.apply(style("a")) + + assertEquals(emptyList(), calls) + } + + /** + * キーが変わったら、まず今入っているものに渡す。受けられたなら地図は + * ドキュメントを読み直さない — スライダーを動かして再読み込みが起きない + * のはこの 1 行のおかげ。 + */ + @Test + fun `キーが変わったら入れ直す前に update を試す`() { + val host = host() + host.apply(style("a")) + calls.clear() + + host.apply(style("b")) + + assertEquals(listOf("update(a -> b)"), calls) + } + + /** update が受けたなら、次は新しいキーが「同じ」の基準になる。 */ + @Test + fun `update が受けたキーは据え置かれる`() { + val host = host() + host.apply(style("a")) + host.apply(style("b")) + calls.clear() + + host.apply(style("b")) + + assertEquals(emptyList(), calls) + } + + @Test + fun `update を断られたら破棄して入れ直す`() { + val host = host() + host.apply(style("a", takesUpdates = false)) + calls.clear() + + host.apply(style("b", takesUpdates = false)) + + assertEquals(listOf("update(a -> b)", "dispose(a)", "install(b)"), calls) + } + + /** + * `update` を持たないスタイル(`MapStyleInstallation.of` が返すもの)は + * 既定で false。これが true 扱いになると、入れ替わっていないものに + * 新しいキーが付く。 + */ + @Test + fun `update を持たないスタイルは入れ直される`() { + val plain = + { key: String -> + object : MapViewStyle { + override val key = key + + override fun install(host: MapStyleHost): MapStyleInstallation { + calls.add("install($key)") + return MapStyleInstallation.of { calls.add("dispose($key)") } + } + } + } + val host = host() + host.apply(plain("a")) + calls.clear() + + host.apply(plain("b")) + + assertEquals(listOf("dispose(a)", "install(b)"), calls) + } + + /** 渡さなくなったら外す。スタイルが残した地図ではなく元の地図に戻る。 */ + @Test + fun `null は外す`() { + val host = host() + host.apply(style("a")) + calls.clear() + + host.apply(null) + + assertEquals(listOf("dispose(a)"), calls) + // 外したあとに同じキーが来たら、据え置きではなく入れ直し。 + calls.clear() + host.apply(style("a")) + assertEquals(listOf("install(a)"), calls) + } + + /** + * 毎回断られるのは正しいが遅い。原因はたいてい `remember` に入れていない + * ラスタライザなので、黙って遅くするのではなく言う。 + */ + @Test + fun `断られ続けたら診断を出す`() { + val host = host() + host.apply(style("k0", takesUpdates = false)) + repeat(5) { host.apply(style("k${it + 1}", takesUpdates = false)) } + + assertEquals(1, log.count { it.contains("refused") }) + assertEquals( + "直し方まで言う: identity 比較される値を作り直しているのが原因", + 1, + log.count { it.contains("remember") }, + ) + } + + /** + * 連続して断られたことだけが問題なので、1 回受かったら数え直す。 + * 入っているスタイル自身の答えを途中で変える必要があるため、ここだけ + * フラグ越しに答えさせる。 + */ + @Test + fun `update が受けたら連続カウントは戻る`() { + val host = host() + var takes = false + host.apply(style("k0") { takes }) + repeat(4) { host.apply(style("r$it") { takes }) } + takes = true + host.apply(style("ok") { takes }) + takes = false + repeat(4) { host.apply(style("s$it") { takes }) } + + assertEquals(emptyList(), log.filter { it.contains("refused") }) + } +} + +/** + * コントローラは [MapViewStyleHost] の判断に一切関与しない(使うのは + * `onStyleLoaded` / `upsertRaster` / `removeRaster` の受け渡しだけ)ので、 + * ここでは置けるだけの最小実装。 + */ +private class NoController : BaseMapViewController() { + override val holder get() = throw UnsupportedOperationException() + override val defaultCoroutine = CoroutineScope(Dispatchers.Unconfined) + override val mainCoroutine = defaultCoroutine + + override fun moveCamera(position: MapCameraPosition) = Unit + + override fun animateCamera( + position: MapCameraPosition, + duration: Long, + ) = Unit + + override fun fitBounds( + bounds: GeoRectBounds, + padding: Int, + ) = Unit + + override suspend fun clearOverlays() = Unit +} diff --git a/src/test/java/com/mapconductor/core/map/StyleMutationStoreTest.kt b/src/test/java/com/mapconductor/core/map/StyleMutationStoreTest.kt new file mode 100644 index 0000000..bcafea8 --- /dev/null +++ b/src/test/java/com/mapconductor/core/map/StyleMutationStoreTest.kt @@ -0,0 +1,123 @@ +package com.mapconductor.core.map + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The two things a provider would otherwise have to remember on its own, + * and both of which look fine until they do not: a replaced set has to undo + * what it dropped, and a style load wipes everything that was applied. + */ +class StyleMutationStoreTest { + private val applied = mutableListOf() + + private fun store(unsupported: (StyleMutation) -> Boolean = { false }) = + StyleMutationStore { mutations -> + applied += mutations + mutations.filter(unsupported) + } + + private fun paint( + layer: String, + key: String, + value: String, + previous: String, + ) = StyleMutation.SetPaint(layer, key, value, previous) + + @Test + fun replacingASetPutsBackWhatTheNewOneNoLongerMentions() { + val store = store() + store.apply( + listOf( + paint("roads", "line-color", "\"#fff\"", "\"#888\""), + paint("water", "fill-color", "\"#000\"", "\"#a0c8f0\""), + ), + ) + applied.clear() + + // The second set drops the water rule entirely. + store.apply(listOf(paint("roads", "line-color", "\"#f00\"", "\"#888\""))) + + assertEquals( + "water goes back to the style's own colour, then roads take the new one", + listOf( + paint("water", "fill-color", "\"#a0c8f0\"", "\"#000\""), + paint("roads", "line-color", "\"#f00\"", "\"#888\""), + ), + applied, + ) + } + + /** + * The restore has to be sent before the new value, or a property that + * both sets touch would be put back to the style's value after being + * given its new one. + */ + @Test + fun restoresGoOutBeforeTheNewValues() { + val store = store() + store.apply(listOf(paint("a", "line-color", "\"#1\"", "\"#0\""))) + applied.clear() + store.apply(listOf(paint("b", "line-color", "\"#2\"", "\"#0\""))) + + assertEquals("a", applied.first().layerId) + assertEquals("b", applied.last().layerId) + } + + @Test + fun clearPutsEverythingBack() { + val store = store() + store.apply(listOf(paint("roads", "line-color", "\"#fff\"", "\"#888\""))) + applied.clear() + + store.clear() + + assertEquals(listOf(paint("roads", "line-color", "\"#888\"", "\"#fff\"")), applied) + assertTrue(store.applied.isEmpty()) + } + + /** + * A design change, a provider reloading, anything: the renderer comes + * back with the document's values and nothing that was applied over + * them. Without this the adjustment survives until the first basemap + * switch and then quietly stops. + */ + @Test + fun aStyleLoadPutsTheWholeSetOnAgain() { + val store = store() + val mutations = listOf(paint("roads", "line-color", "\"#fff\"", "\"#888\"")) + store.apply(mutations) + applied.clear() + + store.onStyleLoaded() + + assertEquals(mutations, applied) + } + + @Test + fun nothingAppliedMeansNothingToDoOnAStyleLoad() { + val store = store() + store.onStyleLoaded() + assertTrue(applied.isEmpty()) + } + + /** + * iOS MapLibre cannot set every spec key by name. The caller has to be + * told which ones, rather than being left to assume the map looks the + * way the rules asked. + */ + @Test + fun whatTheMapCouldNotDoComesBack() { + val store = store(unsupported = { (it as? StyleMutation.SetPaint)?.key == "line-gradient" }) + val failed = + store.apply( + listOf( + paint("roads", "line-color", "\"#fff\"", "\"#888\""), + paint("roads", "line-gradient", "[\"interpolate\"]", "null"), + ), + ) + assertEquals(1, failed.size) + assertEquals("line-gradient", (failed.single() as StyleMutation.SetPaint).key) + } +} diff --git a/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt b/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt index 1c8b91f..b773b3f 100644 --- a/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt +++ b/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt @@ -1,318 +1,319 @@ -package com.mapconductor.core.marker - -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoRectBounds -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * [MarkerGridIndex] must answer exactly what a scan would. - * - * The index packs a cell key and an array position into one Long and reads them - * back with shifts and masks, so an off-by-one in the bit layout or in a range - * bound does not throw — it quietly drops markers. Every test here is the same - * shape: ask the index, ask brute force, demand the same set. - */ -class MarkerGridIndexTest { - private fun entity( - id: Int, - latitude: Double, - longitude: Double, - ): MarkerEntityInterface = - MarkerEntity( - marker = id, - state = MarkerState(position = GeoPoint.fromLatLong(latitude, longitude), id = "$id"), - ) - - /** A deterministic spread, so a failure is reproducible. */ - private fun scatter( - count: Int, - latitude: Double, - longitude: Double, - spread: Double, - ): List> { - var seed = 0x2545F4914F6CDD1DuL - - fun next(): Double { - seed = seed * 6364136223846793005uL + 1442695040888963407uL - return (seed shr 11).toDouble() / (1L shl 53).toDouble() - } - return (0 until count).map { index -> - entity( - index, - latitude + (next() - 0.5) * spread, - longitude + (next() - 0.5) * spread, - ) - } - } - - private fun ids(entities: Collection>): Set = - entities.map { it.state.id }.toSet() - - private fun bounds( - south: Double, - west: Double, - north: Double, - east: Double, - ): GeoRectBounds = - GeoRectBounds( - southWest = GeoPoint.fromLatLong(south, west), - northEast = GeoPoint.fromLatLong(north, east), - ) - - @Test - fun boundsQueryMatchesBruteForce() { - val markers = scatter(5000, 35.68, 139.76, 0.6) - val index = MarkerGridIndex { markers } - - // A box inside one cell, one spanning several, one wider than the data, - // and one over empty ground — the last because an index that returns - // nothing is right here and wrong everywhere else. - for (box in listOf( - bounds(35.6812, 139.7612, 35.6814, 139.7614), - bounds(35.68, 139.76, 35.69, 139.77), - bounds(35.0, 139.0, 36.5, 140.5), - bounds(10.0, 100.0, 11.0, 101.0), - )) { - val expected = ids(markers.filter { box.contains(it.state.position) }) - assertEquals("grid disagreed with a scan", expected, ids(index.inBounds(box))) - } - - // The middle two have to be carrying markers, or the comparison above is - // only proving that two empty sets match. - assertFalse(index.inBounds(bounds(35.68, 139.76, 35.69, 139.77)).isEmpty()) - assertEquals(markers.size, index.inBounds(bounds(35.0, 139.0, 36.5, 140.5)).size) - } - - /** - * A box crossing 180° has its east corner west of its west one. - * - * The first version walked `lonFrom..lonTo` straight, which is an empty - * range in Kotlin and a trap in Swift. Fiji and the Chathams are real - * places, and a map centred there is a reversed box every frame. - */ - @Test - fun boundsQueryCrossesTheAntimeridian() { - val markers = - listOf( - entity(0, -18.0, 179.9), - entity(1, -18.0, -179.9), - entity(2, -18.0, 178.0), - entity(3, -18.0, 0.0), - ) + scatter(3000, -18.0, 179.95, 0.4) - - val index = MarkerGridIndex { markers } - val box = bounds(-18.5, 179.5, -17.5, -179.5) - val expected = ids(markers.filter { box.contains(it.state.position) }) - - assertTrue("the box should hold the marker just west of 180", expected.contains("0")) - assertTrue("and the one just east of it", expected.contains("1")) - assertEquals(expected, ids(index.inBounds(box))) - } - - /** - * The thinned query may drop markers, but only ones it was told are - * interchangeable — and never a whole cell. - * - * A caller asks for this when it is about to keep one marker per cell of - * its own anyway. What it must not get back is a hole: a cell holding - * markers inside the bounds that returns nothing, which on a map is a patch - * of the city with no trees in it and no error anywhere. - */ - @Test - fun thinnedQueryKeepsOneMarkerFromEveryCellItCovers() { - val markers = scatter(60000, 35.68, 139.76, 0.4) - val index = MarkerGridIndex { markers } - val box = bounds(35.60, 139.68, 35.76, 139.84) - - val thinned = index.inBoundsThinned(box, minSeparationDegrees = 0.005) - assertNotNull("0.005 degrees is coarser than a cell, so this must be answered", thinned) - val kept = thinned!! - - val inside = markers.filter { box.contains(it.state.position) } - assertTrue("the box should hold something to compare", inside.size > 1000) - - // The level the index picks, worked out here the same way. 0.005 degrees - // gives level 16, an even level, so the cell has a whole-character name - // of 9 characters and can be compared as a string. - assertEquals(16L, MarkerGrid.levelForSeparation(0.005)!!.toLong()) - fun cellOf(entity: MarkerEntityInterface): String = - MarkerGrid.geocell( - entity.state.position.latitude, - entity.state.position.longitude, - characters = 9, - ) - - // Every cell holding something inside the box has to be represented. - // Markers from outside the box are fine and expected: a cell's winner - // is chosen without reference to the box, so a cell on the edge can be - // represented by a marker just outside it. Not trimming those is what - // makes neighbouring tiles agree — see `MarkerTileSeamTest`. - assertTrue( - "some populated cell returned no representative", - kept.map(::cellOf).toSet().containsAll(inside.map(::cellOf).toSet()), - ) - val outside = kept.count { !box.contains(it.state.position) } - assertTrue( - "more markers outside the box than one edge cell can explain", - outside < kept.size * 0.15, - ) - assertEquals("more than one from some cell", kept.map(::cellOf).toSet().size, kept.size) - // The box is 0.16 degrees a side, so at level 16 it spans about 3,400 - // square cells of 0.00275 degrees and holds around 9,600 of these - // markers — nearly three to a cell. Without this the assertions above - // would still pass on data too sparse to thin, proving nothing. - assertTrue("the data is too sparse to be exercising thinning", kept.size * 2 < inside.size) - } - - /** - * Cells coarser than the caller's separation would thin more than asked. - * - * While the grid was flat that floor was 0.005 degrees. Now that it is a - * hierarchy the floor is the bottom level — 0.000687 degrees on a side — - * and anything coarser is answered by picking a level. That difference is - * what makes thinning work at the deeper zooms. - */ - @Test - fun thinnedQueryDeclinesWhenItsCellsAreTooCoarse() { - val index = MarkerGridIndex { scatter(2000, 35.68, 139.76, 0.4) } - val box = bounds(35.6, 139.7, 35.7, 139.8) - assertNull(index.inBoundsThinned(box, 0.0005)) - assertNotNull( - "0.001 degrees is coarser than the bottom level, so a level can be chosen", - index.inBoundsThinned(box, 0.001), - ) - } - - /** The wrap the plain query learned has to hold here too. */ - @Test - fun thinnedQueryCrossesTheAntimeridian() { - val markers = - listOf( - entity(0, -18.0, 179.99), - entity(1, -18.0, -179.99), - entity(2, -18.0, 178.0), - ) - val index = MarkerGridIndex { markers } - val kept = index.inBoundsThinned(bounds(-18.5, 179.5, -17.5, -179.5), 0.01) - assertNotNull(kept) - assertEquals(setOf("0", "1"), ids(kept!!)) - } - - @Test - fun nearestMatchesBruteForce() { - val markers = scatter(5000, 35.68, 139.76, 0.6) - val index = MarkerGridIndex { markers } - - for (point in listOf( - GeoPoint.fromLatLong(35.68, 139.76), - GeoPoint.fromLatLong(35.4, 139.5), - GeoPoint.fromLatLong(36.0, 140.1), - )) { - val expected = markers.minByOrNull { squared(it, point) } - assertEquals(expected?.state?.id, index.nearest(point)?.state?.id) - } - } - - /** - * Beyond the rings the index searches it falls back to a scan rather than - * returning nothing — the answer still has to be right, just slower. - */ - @Test - fun nearestFallsBackWhenNothingIsClose() { - val index = MarkerGridIndex { listOf(entity(0, 35.0, 139.0)) } - assertEquals("0", index.nearest(GeoPoint.fromLatLong(-35.0, -70.0))?.state?.id) - } - - @Test - fun emptyIndexHasNoNearest() { - val index = MarkerGridIndex { emptyList() } - assertNull(index.nearest(GeoPoint.fromLatLong(35.0, 139.0))) - } - - /** The index borrows the manager's markers, so a change has to be seen. */ - @Test - fun invalidateShowsLaterMarkers() { - val markers = mutableListOf(entity(0, 35.0, 139.0)) - val index = MarkerGridIndex { markers } - val box = bounds(34.9, 138.9, 35.1, 139.1) - assertEquals(1, index.inBounds(box).size) - - markers.add(entity(1, 35.001, 139.001)) - assertEquals("a stale index should stay stale until told", 1, index.inBounds(box).size) - - index.invalidate() - assertEquals(2, index.inBounds(box).size) - } - - private fun squared( - entity: MarkerEntityInterface, - point: GeoPoint, - ): Double { - val deltaLat = entity.state.position.latitude - point.latitude - val deltaLon = entity.state.position.longitude - point.longitude - return deltaLat * deltaLat + deltaLon * deltaLon - } - - /** - * Whatever shape the box is, the index has to answer what a scan answers. - * - * Square boxes alone never reach the new level choice's **per-axis cap** — - * the path a very flat band or a very narrow column takes. Get that wrong - * and nothing on the map changes except that queries of that shape quietly - * drop markers. - * - * The near-global box is here because column indices fold: the column - * holding 180 and the one holding -180 are the same, so taking the end of - * the walk from the east corner covers a single column. See - * `MarkerGrid.columnWalk`. - * - * Pairs with ios-sdk's `testBoundsQueryMatchesBruteForceForEveryShapeOfBox` - * and react-sdk's test of the same name. - */ - @Test - fun boundsQueryMatchesBruteForceForEveryShapeOfBox() { - // The second group's ids are shifted. `scatter` numbers from zero every - // time, so adding them straight makes ids collide, and `ids()` folds two - // different markers into one — the index could return duplicates and the - // sets would still agree. - val near = scatter(30000, 35.68, 139.76, 0.8) - val far = - scatter(10000, -18.0, 179.95, 0.6).map { - entity(it.state.id.toInt() + 30000, it.state.position.latitude, it.state.position.longitude) - } - val markers = near + far - val index = MarkerGridIndex { markers } - - var nonEmpty = 0 - val boxes = - listOf( - // Tile-sized, from z=14 down to z=9. - bounds(35.6800, 139.7600, 35.6946, 139.7820), - bounds(35.6000, 139.6000, 35.7000, 139.8000), - bounds(35.2000, 139.2000, 36.2000, 140.2000), - // A very flat band and a very narrow column. Area alone cannot - // choose a level for either. - bounds(35.6790, 139.0000, 35.6810, 140.5000), - bounds(35.0000, 139.7590, 36.4000, 139.7610), - // Nearly the whole globe. - bounds(-85.0, -179.9, 85.0, 179.9), - // Across the antimeridian. - bounds(-18.5, 179.5, -17.5, -179.5), - // Empty ground: the one box where returning nothing is right. - bounds(10.0, 100.0, 11.0, 101.0), - ) - for ((at, box) in boxes.withIndex()) { - val expected = ids(markers.filter { box.contains(it.state.position) }) - val found = index.inBounds(box) - assertEquals("box $at: the grid disagreed with a scan", expected, ids(found)) - assertEquals("box $at: returned the same marker twice", expected.size, found.size) - if (expected.isNotEmpty()) nonEmpty++ - } - assertEquals("only comparing empty sets to empty sets", 7, nonEmpty) - } -} +package com.mapconductor.core.marker + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [MarkerGridIndex] must answer exactly what a scan would. + * + * The index packs a cell key and an array position into one Long and reads them + * back with shifts and masks, so an off-by-one in the bit layout or in a range + * bound does not throw — it quietly drops markers. Every test here is the same + * shape: ask the index, ask brute force, demand the same set. + */ +class MarkerGridIndexTest { + private fun entity( + id: Int, + latitude: Double, + longitude: Double, + ): MarkerEntityInterface = + MarkerEntity( + marker = id, + state = MarkerState(position = GeoPoint.fromLatLong(latitude, longitude), id = "$id"), + ) + + /** A deterministic spread, so a failure is reproducible. */ + private fun scatter( + count: Int, + latitude: Double, + longitude: Double, + spread: Double, + ): List> { + var seed = 0x2545F4914F6CDD1DuL + + fun next(): Double { + seed = seed * 6364136223846793005uL + 1442695040888963407uL + return (seed shr 11).toDouble() / (1L shl 53).toDouble() + } + return (0 until count).map { index -> + entity( + index, + latitude + (next() - 0.5) * spread, + longitude + (next() - 0.5) * spread, + ) + } + } + + private fun ids(entities: Collection>): Set = + entities.map { it.state.id }.toSet() + + private fun bounds( + south: Double, + west: Double, + north: Double, + east: Double, + ): GeoRectBounds = + GeoRectBounds( + southWest = GeoPoint.fromLatLong(south, west), + northEast = GeoPoint.fromLatLong(north, east), + ) + + @Test + fun boundsQueryMatchesBruteForce() { + val markers = scatter(5000, 35.68, 139.76, 0.6) + val index = MarkerGridIndex { markers } + + // A box inside one cell, one spanning several, one wider than the data, + // and one over empty ground — the last because an index that returns + // nothing is right here and wrong everywhere else. + for (box in listOf( + bounds(35.6812, 139.7612, 35.6814, 139.7614), + bounds(35.68, 139.76, 35.69, 139.77), + bounds(35.0, 139.0, 36.5, 140.5), + bounds(10.0, 100.0, 11.0, 101.0), + )) { + val expected = ids(markers.filter { box.contains(it.state.position) }) + assertEquals("grid disagreed with a scan", expected, ids(index.inBounds(box))) + } + + // The middle two have to be carrying markers, or the comparison above is + // only proving that two empty sets match. + assertFalse(index.inBounds(bounds(35.68, 139.76, 35.69, 139.77)).isEmpty()) + assertEquals(markers.size, index.inBounds(bounds(35.0, 139.0, 36.5, 140.5)).size) + } + + /** + * A box crossing 180° has its east corner west of its west one. + * + * The first version walked `lonFrom..lonTo` straight, which is an empty + * range in Kotlin and a trap in Swift. Fiji and the Chathams are real + * places, and a map centred there is a reversed box every frame. + */ + @Test + fun boundsQueryCrossesTheAntimeridian() { + val markers = + listOf( + entity(0, -18.0, 179.9), + entity(1, -18.0, -179.9), + entity(2, -18.0, 178.0), + entity(3, -18.0, 0.0), + ) + scatter(3000, -18.0, 179.95, 0.4) + + val index = MarkerGridIndex { markers } + val box = bounds(-18.5, 179.5, -17.5, -179.5) + val expected = ids(markers.filter { box.contains(it.state.position) }) + + assertTrue("the box should hold the marker just west of 180", expected.contains("0")) + assertTrue("and the one just east of it", expected.contains("1")) + assertEquals(expected, ids(index.inBounds(box))) + } + + /** + * The thinned query may drop markers, but only ones it was told are + * interchangeable — and never a whole cell. + * + * A caller asks for this when it is about to keep one marker per cell of + * its own anyway. What it must not get back is a hole: a cell holding + * markers inside the bounds that returns nothing, which on a map is a patch + * of the city with no trees in it and no error anywhere. + */ + @Test + fun thinnedQueryKeepsOneMarkerFromEveryCellItCovers() { + val markers = scatter(60000, 35.68, 139.76, 0.4) + val index = MarkerGridIndex { markers } + val box = bounds(35.60, 139.68, 35.76, 139.84) + + val thinned = index.inBoundsThinned(box, minSeparationDegrees = 0.005) + assertNotNull("0.005 degrees is coarser than a cell, so this must be answered", thinned) + val kept = thinned!! + + val inside = markers.filter { box.contains(it.state.position) } + assertTrue("the box should hold something to compare", inside.size > 1000) + + // The level the index picks, worked out here the same way. 0.005 degrees + // gives level 16, an even level, so the cell has a whole-character name + // of 9 characters and can be compared as a string. + assertEquals(16L, MarkerGrid.levelForSeparation(0.005)!!.toLong()) + + fun cellOf(entity: MarkerEntityInterface): String = + MarkerGrid.geocell( + entity.state.position.latitude, + entity.state.position.longitude, + characters = 9, + ) + + // Every cell holding something inside the box has to be represented. + // Markers from outside the box are fine and expected: a cell's winner + // is chosen without reference to the box, so a cell on the edge can be + // represented by a marker just outside it. Not trimming those is what + // makes neighbouring tiles agree — see `MarkerTileSeamTest`. + assertTrue( + "some populated cell returned no representative", + kept.map(::cellOf).toSet().containsAll(inside.map(::cellOf).toSet()), + ) + val outside = kept.count { !box.contains(it.state.position) } + assertTrue( + "more markers outside the box than one edge cell can explain", + outside < kept.size * 0.15, + ) + assertEquals("more than one from some cell", kept.map(::cellOf).toSet().size, kept.size) + // The box is 0.16 degrees a side, so at level 16 it spans about 3,400 + // square cells of 0.00275 degrees and holds around 9,600 of these + // markers — nearly three to a cell. Without this the assertions above + // would still pass on data too sparse to thin, proving nothing. + assertTrue("the data is too sparse to be exercising thinning", kept.size * 2 < inside.size) + } + + /** + * Cells coarser than the caller's separation would thin more than asked. + * + * While the grid was flat that floor was 0.005 degrees. Now that it is a + * hierarchy the floor is the bottom level — 0.000687 degrees on a side — + * and anything coarser is answered by picking a level. That difference is + * what makes thinning work at the deeper zooms. + */ + @Test + fun thinnedQueryDeclinesWhenItsCellsAreTooCoarse() { + val index = MarkerGridIndex { scatter(2000, 35.68, 139.76, 0.4) } + val box = bounds(35.6, 139.7, 35.7, 139.8) + assertNull(index.inBoundsThinned(box, 0.0005)) + assertNotNull( + "0.001 degrees is coarser than the bottom level, so a level can be chosen", + index.inBoundsThinned(box, 0.001), + ) + } + + /** The wrap the plain query learned has to hold here too. */ + @Test + fun thinnedQueryCrossesTheAntimeridian() { + val markers = + listOf( + entity(0, -18.0, 179.99), + entity(1, -18.0, -179.99), + entity(2, -18.0, 178.0), + ) + val index = MarkerGridIndex { markers } + val kept = index.inBoundsThinned(bounds(-18.5, 179.5, -17.5, -179.5), 0.01) + assertNotNull(kept) + assertEquals(setOf("0", "1"), ids(kept!!)) + } + + @Test + fun nearestMatchesBruteForce() { + val markers = scatter(5000, 35.68, 139.76, 0.6) + val index = MarkerGridIndex { markers } + + for (point in listOf( + GeoPoint.fromLatLong(35.68, 139.76), + GeoPoint.fromLatLong(35.4, 139.5), + GeoPoint.fromLatLong(36.0, 140.1), + )) { + val expected = markers.minByOrNull { squared(it, point) } + assertEquals(expected?.state?.id, index.nearest(point)?.state?.id) + } + } + + /** + * Beyond the rings the index searches it falls back to a scan rather than + * returning nothing — the answer still has to be right, just slower. + */ + @Test + fun nearestFallsBackWhenNothingIsClose() { + val index = MarkerGridIndex { listOf(entity(0, 35.0, 139.0)) } + assertEquals("0", index.nearest(GeoPoint.fromLatLong(-35.0, -70.0))?.state?.id) + } + + @Test + fun emptyIndexHasNoNearest() { + val index = MarkerGridIndex { emptyList() } + assertNull(index.nearest(GeoPoint.fromLatLong(35.0, 139.0))) + } + + /** The index borrows the manager's markers, so a change has to be seen. */ + @Test + fun invalidateShowsLaterMarkers() { + val markers = mutableListOf(entity(0, 35.0, 139.0)) + val index = MarkerGridIndex { markers } + val box = bounds(34.9, 138.9, 35.1, 139.1) + assertEquals(1, index.inBounds(box).size) + + markers.add(entity(1, 35.001, 139.001)) + assertEquals("a stale index should stay stale until told", 1, index.inBounds(box).size) + + index.invalidate() + assertEquals(2, index.inBounds(box).size) + } + + private fun squared( + entity: MarkerEntityInterface, + point: GeoPoint, + ): Double { + val deltaLat = entity.state.position.latitude - point.latitude + val deltaLon = entity.state.position.longitude - point.longitude + return deltaLat * deltaLat + deltaLon * deltaLon + } + + /** + * Whatever shape the box is, the index has to answer what a scan answers. + * + * Square boxes alone never reach the new level choice's **per-axis cap** — + * the path a very flat band or a very narrow column takes. Get that wrong + * and nothing on the map changes except that queries of that shape quietly + * drop markers. + * + * The near-global box is here because column indices fold: the column + * holding 180 and the one holding -180 are the same, so taking the end of + * the walk from the east corner covers a single column. See + * `MarkerGrid.columnWalk`. + * + * Pairs with ios-sdk's `testBoundsQueryMatchesBruteForceForEveryShapeOfBox` + * and react-sdk's test of the same name. + */ + @Test + fun boundsQueryMatchesBruteForceForEveryShapeOfBox() { + // The second group's ids are shifted. `scatter` numbers from zero every + // time, so adding them straight makes ids collide, and `ids()` folds two + // different markers into one — the index could return duplicates and the + // sets would still agree. + val near = scatter(30000, 35.68, 139.76, 0.8) + val far = + scatter(10000, -18.0, 179.95, 0.6).map { + entity(it.state.id.toInt() + 30000, it.state.position.latitude, it.state.position.longitude) + } + val markers = near + far + val index = MarkerGridIndex { markers } + + var nonEmpty = 0 + val boxes = + listOf( + // Tile-sized, from z=14 down to z=9. + bounds(35.6800, 139.7600, 35.6946, 139.7820), + bounds(35.6000, 139.6000, 35.7000, 139.8000), + bounds(35.2000, 139.2000, 36.2000, 140.2000), + // A very flat band and a very narrow column. Area alone cannot + // choose a level for either. + bounds(35.6790, 139.0000, 35.6810, 140.5000), + bounds(35.0000, 139.7590, 36.4000, 139.7610), + // Nearly the whole globe. + bounds(-85.0, -179.9, 85.0, 179.9), + // Across the antimeridian. + bounds(-18.5, 179.5, -17.5, -179.5), + // Empty ground: the one box where returning nothing is right. + bounds(10.0, 100.0, 11.0, 101.0), + ) + for ((at, box) in boxes.withIndex()) { + val expected = ids(markers.filter { box.contains(it.state.position) }) + val found = index.inBounds(box) + assertEquals("box $at: the grid disagreed with a scan", expected, ids(found)) + assertEquals("box $at: returned the same marker twice", expected.size, found.size) + if (expected.isNotEmpty()) nonEmpty++ + } + assertEquals("only comparing empty sets to empty sets", 7, nonEmpty) + } +} diff --git a/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt b/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt index 19f8cec..5f304db 100644 --- a/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt +++ b/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt @@ -1,152 +1,155 @@ -package com.mapconductor.core.marker - -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoRectBounds -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Neighbouring tiles must agree about the markers they share. - * - * Tiles are drawn one at a time. A marker drawn on tile A but not on tile B - * gets **cut** at the boundary: the left half appears on the left tile and the - * rest is nowhere. - * - * The renderer queries its own box grown by the icon overhang - * (`queryByHalfExtentPx`), so the property that keeps the seam intact is: - * a marker returned for A that also falls inside B's grown box must be - * returned for B. That is all this test looks at — it never draws anything. - * - * ios-sdk's `MarkerTileSeamTests` and react-sdk's `MarkerTileSeam.test.mjs` - * check the same thing. - */ -class MarkerTileSeamTest { - private val tileSize = 512.0 - - /** The sample's declutter. The disagreement shows up as a side effect of thinning. */ - private val declutterPx = 14.0 - - /** Half an icon. The renderer measures it; a fixed value is enough here. */ - private val halfExtentPx = 10.0 - - private fun markers(count: Int): List> { - var seed = 12345uL - fun next(): Double { - seed = seed * 6364136223846793005uL + 1442695040888963407uL - return (seed shr 11).toDouble() / (1uL shl 53).toDouble() - } - return (0 until count).map { at -> - MarkerEntity( - marker = at, - state = - MarkerState( - position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), - id = "$at", - ), - ) - } - } - - /** Tile coordinates to a lat/lng box. Same formulas as the renderer. */ - private fun tileBounds( - x: Int, - y: Int, - z: Int, - ): GeoRectBounds { - val n = Math.pow(2.0, z.toDouble()) - fun lon(tx: Double) = tx / n * 360.0 - 180.0 - fun lat(ty: Double) = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * (ty / n)))) * 180.0 / Math.PI - return GeoRectBounds( - southWest = GeoPoint.fromLatLong(lat((y + 1).toDouble()), lon(x.toDouble())), - northEast = GeoPoint.fromLatLong(lat(y.toDouble()), lon((x + 1).toDouble())), - ) - } - - private data class Asked( - val expanded: GeoRectBounds, - val kept: List>, - val thinned: Boolean, - ) - - /** - * The box and separation the renderer actually asks with. - * - * At zooms where the separation is finer than the bottom level the index - * declines and the renderer falls back to the full query. That path has to - * hold the seam too, so when it declines this checks `inBounds` instead. - */ - private fun ask( - index: MarkerGridIndex, - x: Int, - y: Int, - z: Int, - ): Asked { - val bounds = tileBounds(x, y, z) - val southWest = bounds.southWest!! - val northEast = bounds.northEast!! - val latSpan = northEast.latitude - southWest.latitude - val lonSpan = northEast.longitude - southWest.longitude - val padNorm = halfExtentPx / tileSize - val expanded = - GeoRectBounds( - southWest = - GeoPoint.fromLatLong( - southWest.latitude - latSpan * padNorm, - southWest.longitude - lonSpan * padNorm, - ), - northEast = - GeoPoint.fromLatLong( - northEast.latitude + latSpan * padNorm, - northEast.longitude + lonSpan * padNorm, - ), - ) - val separation = maxOf(latSpan, lonSpan) * declutterPx / tileSize - val thinned = index.inBoundsThinned(expanded, separation) - return if (thinned != null) { - Asked(expanded, thinned, true) - } else { - Asked(expanded, index.inBounds(expanded), false) - } - } - - @Test - fun adjacentTilesAgreeOnTheMarkersTheyShare() { - val all = markers(144_183) - val index = MarkerGridIndex { all } - - var checked = 0 - var thinnedPairs = 0 - for (z in listOf(9, 10, 11, 12, 13, 14)) { - val n = Math.pow(2.0, z.toDouble()) - val x = ((139.75 + 180.0) / 360.0 * n).toInt() - val latRad = 35.68 * Math.PI / 180 - val y = ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() - - // Sideways and downwards. A seam runs in both directions. - for ((dx, dy) in listOf(1 to 0, 0 to 1)) { - val a = ask(index, x, y, z) - val b = ask(index, x + dx, y + dy, z) - // At low zoom the whole cloud fits on one tile and the - // neighbour is empty. Comparing against nothing proves nothing. - if (a.kept.isEmpty() || b.kept.isEmpty()) continue - checked++ - if (a.thinned) thinnedPairs++ - - val inB = b.kept.map { it.state.id }.toSet() - val missing = - a.kept.count { - b.expanded.contains(it.state.position) && it.state.id !in inB - } - assertEquals( - "z=$z d=($dx,$dy) thinned=${a.thinned}: tile A draws $missing markers " + - "that the overlapping tile B does not — the icon is cut at the seam", - 0, - missing, - ) - } - } - assertTrue("too few pairs were comparable", checked >= 8) - assertTrue("no pair exercised thinning", thinnedPairs >= 4) - } -} +package com.mapconductor.core.marker + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Neighbouring tiles must agree about the markers they share. + * + * Tiles are drawn one at a time. A marker drawn on tile A but not on tile B + * gets **cut** at the boundary: the left half appears on the left tile and the + * rest is nowhere. + * + * The renderer queries its own box grown by the icon overhang + * (`queryByHalfExtentPx`), so the property that keeps the seam intact is: + * a marker returned for A that also falls inside B's grown box must be + * returned for B. That is all this test looks at — it never draws anything. + * + * ios-sdk's `MarkerTileSeamTests` and react-sdk's `MarkerTileSeam.test.mjs` + * check the same thing. + */ +class MarkerTileSeamTest { + private val tileSize = 512.0 + + /** The sample's declutter. The disagreement shows up as a side effect of thinning. */ + private val declutterPx = 14.0 + + /** Half an icon. The renderer measures it; a fixed value is enough here. */ + private val halfExtentPx = 10.0 + + private fun markers(count: Int): List> { + var seed = 12345uL + + fun next(): Double { + seed = seed * 6364136223846793005uL + 1442695040888963407uL + return (seed shr 11).toDouble() / (1uL shl 53).toDouble() + } + return (0 until count).map { at -> + MarkerEntity( + marker = at, + state = + MarkerState( + position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), + id = "$at", + ), + ) + } + } + + /** Tile coordinates to a lat/lng box. Same formulas as the renderer. */ + private fun tileBounds( + x: Int, + y: Int, + z: Int, + ): GeoRectBounds { + val n = Math.pow(2.0, z.toDouble()) + + fun lon(tx: Double) = tx / n * 360.0 - 180.0 + + fun lat(ty: Double) = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * (ty / n)))) * 180.0 / Math.PI + return GeoRectBounds( + southWest = GeoPoint.fromLatLong(lat((y + 1).toDouble()), lon(x.toDouble())), + northEast = GeoPoint.fromLatLong(lat(y.toDouble()), lon((x + 1).toDouble())), + ) + } + + private data class Asked( + val expanded: GeoRectBounds, + val kept: List>, + val thinned: Boolean, + ) + + /** + * The box and separation the renderer actually asks with. + * + * At zooms where the separation is finer than the bottom level the index + * declines and the renderer falls back to the full query. That path has to + * hold the seam too, so when it declines this checks `inBounds` instead. + */ + private fun ask( + index: MarkerGridIndex, + x: Int, + y: Int, + z: Int, + ): Asked { + val bounds = tileBounds(x, y, z) + val southWest = bounds.southWest!! + val northEast = bounds.northEast!! + val latSpan = northEast.latitude - southWest.latitude + val lonSpan = northEast.longitude - southWest.longitude + val padNorm = halfExtentPx / tileSize + val expanded = + GeoRectBounds( + southWest = + GeoPoint.fromLatLong( + southWest.latitude - latSpan * padNorm, + southWest.longitude - lonSpan * padNorm, + ), + northEast = + GeoPoint.fromLatLong( + northEast.latitude + latSpan * padNorm, + northEast.longitude + lonSpan * padNorm, + ), + ) + val separation = maxOf(latSpan, lonSpan) * declutterPx / tileSize + val thinned = index.inBoundsThinned(expanded, separation) + return if (thinned != null) { + Asked(expanded, thinned, true) + } else { + Asked(expanded, index.inBounds(expanded), false) + } + } + + @Test + fun adjacentTilesAgreeOnTheMarkersTheyShare() { + val all = markers(144_183) + val index = MarkerGridIndex { all } + + var checked = 0 + var thinnedPairs = 0 + for (z in listOf(9, 10, 11, 12, 13, 14)) { + val n = Math.pow(2.0, z.toDouble()) + val x = ((139.75 + 180.0) / 360.0 * n).toInt() + val latRad = 35.68 * Math.PI / 180 + val y = ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() + + // Sideways and downwards. A seam runs in both directions. + for ((dx, dy) in listOf(1 to 0, 0 to 1)) { + val a = ask(index, x, y, z) + val b = ask(index, x + dx, y + dy, z) + // At low zoom the whole cloud fits on one tile and the + // neighbour is empty. Comparing against nothing proves nothing. + if (a.kept.isEmpty() || b.kept.isEmpty()) continue + checked++ + if (a.thinned) thinnedPairs++ + + val inB = b.kept.map { it.state.id }.toSet() + val missing = + a.kept.count { + b.expanded.contains(it.state.position) && it.state.id !in inB + } + assertEquals( + "z=$z d=($dx,$dy) thinned=${a.thinned}: tile A draws $missing markers " + + "that the overlapping tile B does not — the icon is cut at the seam", + 0, + missing, + ) + } + } + assertTrue("too few pairs were comparable", checked >= 8) + assertTrue("no pair exercised thinning", thinnedPairs >= 4) + } +} diff --git a/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt b/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt index 038cf8c..f8cbcc8 100644 --- a/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt +++ b/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt @@ -1,87 +1,89 @@ -package com.mapconductor.core.projection - -import com.mapconductor.core.features.GeoPoint -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Web Mercator の適合テスト。 - * - * 同じ `projection-vectors.txt` を ios-sdk / react-sdk も読む。3 つが同じ答えを - * 出すことを担保するのが目的で、ここが見ているのはこのプラットフォームの分だけ。 - * 突き合わせは 3 つの出力に対して別途行う。 - * - * `zoom-golden.txt` とは役割が違う。あちらは移行前の値の記録(既知の不具合込み)。 - * こちらは現在の実装どうしが一致しているかを見る。 - */ -class ProjectionConformanceTest { - /** - * 桁を固定する。 - * - * 言語ごとの既定の数値表記は揃わない。Kotlin の `Double.toString()` は - * 2000 万台を `2.0037508342789E7` と書くが、JS と Swift は書かない。 - * f64 のノイズより粗く、実装差より細かい桁で切る。 - */ - private fun fixed(value: Double): String = - when { - value.isNaN() -> "nan" - value == Double.POSITIVE_INFINITY -> "inf" - value == Double.NEGATIVE_INFINITY -> "-inf" - else -> String.format(java.util.Locale.ROOT, "%.6f", value) - } - - private fun runVectors(): String { - val vectors = - requireNotNull(javaClass.classLoader?.getResourceAsStream("projection-vectors.txt")) { - "projection-vectors.txt が見つからない" - }.bufferedReader().readText() - - val out = StringBuilder() - for (line in vectors.lines()) { - val row = line.trim() - if (row.isEmpty() || row.startsWith("#")) continue - val (op, a, b) = row.split("|").let { Triple(it[0], it[1], it[2]) } - when (op) { - "project" -> { - // 実物の GeoPoint を使う。無名実装だと wrap() を握り潰して - // しまい、投影側の wrap が効いていないように見える。 - val p = WebMercator.project(GeoPoint(a.toDouble(), b.toDouble(), 0.0)) - out.append("project|$a|$b|${fixed(p.x)}|${fixed(p.y)}\n") - } - "unproject" -> { - val g = WebMercator.unproject(ProjectedPoint(a.toDouble(), b.toDouble())) - out.append("unproject|$a|$b|${fixed(g.latitude)}|${fixed(g.longitude)}\n") - } - else -> error("未知の演算: $op") - } - } - return out.toString() - } - - @Test - fun `ベクタを 1 行残らず処理できる`() { - val actual = runVectors() - - // 突き合わせに使う。テストの副産物ではなく、これが成果物。 - val destination = File("build/conformance/projection-actual.android.txt") - destination.parentFile?.mkdirs() - destination.writeText(actual) - - val rows = actual.trimEnd().lines() - assertTrue("ベクタが 1 行も処理されていない", rows.isNotEmpty()) - for (row in rows) { - assertEquals("列数が合わない: $row", 5, row.split("|").size) - } - } - - /** 正本が決まるまでは存在しない。3 プラットフォームの差を潰してから凍結する。 */ - @Test - fun `正本があれば完全一致する`() { - val expected = - javaClass.classLoader?.getResourceAsStream("projection-expected.txt") - ?.bufferedReader()?.readText() ?: return - assertEquals(expected, runVectors()) - } -} +package com.mapconductor.core.projection + +import com.mapconductor.core.features.GeoPoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Web Mercator の適合テスト。 + * + * 同じ `projection-vectors.txt` を ios-sdk / react-sdk も読む。3 つが同じ答えを + * 出すことを担保するのが目的で、ここが見ているのはこのプラットフォームの分だけ。 + * 突き合わせは 3 つの出力に対して別途行う。 + * + * `zoom-golden.txt` とは役割が違う。あちらは移行前の値の記録(既知の不具合込み)。 + * こちらは現在の実装どうしが一致しているかを見る。 + */ +class ProjectionConformanceTest { + /** + * 桁を固定する。 + * + * 言語ごとの既定の数値表記は揃わない。Kotlin の `Double.toString()` は + * 2000 万台を `2.0037508342789E7` と書くが、JS と Swift は書かない。 + * f64 のノイズより粗く、実装差より細かい桁で切る。 + */ + private fun fixed(value: Double): String = + when { + value.isNaN() -> "nan" + value == Double.POSITIVE_INFINITY -> "inf" + value == Double.NEGATIVE_INFINITY -> "-inf" + else -> String.format(java.util.Locale.ROOT, "%.6f", value) + } + + private fun runVectors(): String { + val vectors = + requireNotNull(javaClass.classLoader?.getResourceAsStream("projection-vectors.txt")) { + "projection-vectors.txt が見つからない" + }.bufferedReader().readText() + + val out = StringBuilder() + for (line in vectors.lines()) { + val row = line.trim() + if (row.isEmpty() || row.startsWith("#")) continue + val (op, a, b) = row.split("|").let { Triple(it[0], it[1], it[2]) } + when (op) { + "project" -> { + // 実物の GeoPoint を使う。無名実装だと wrap() を握り潰して + // しまい、投影側の wrap が効いていないように見える。 + val p = WebMercator.project(GeoPoint(a.toDouble(), b.toDouble(), 0.0)) + out.append("project|$a|$b|${fixed(p.x)}|${fixed(p.y)}\n") + } + "unproject" -> { + val g = WebMercator.unproject(ProjectedPoint(a.toDouble(), b.toDouble())) + out.append("unproject|$a|$b|${fixed(g.latitude)}|${fixed(g.longitude)}\n") + } + else -> error("未知の演算: $op") + } + } + return out.toString() + } + + @Test + fun `ベクタを 1 行残らず処理できる`() { + val actual = runVectors() + + // 突き合わせに使う。テストの副産物ではなく、これが成果物。 + val destination = File("build/conformance/projection-actual.android.txt") + destination.parentFile?.mkdirs() + destination.writeText(actual) + + val rows = actual.trimEnd().lines() + assertTrue("ベクタが 1 行も処理されていない", rows.isNotEmpty()) + for (row in rows) { + assertEquals("列数が合わない: $row", 5, row.split("|").size) + } + } + + /** 正本が決まるまでは存在しない。3 プラットフォームの差を潰してから凍結する。 */ + @Test + fun `正本があれば完全一致する`() { + val expected = + javaClass.classLoader + ?.getResourceAsStream("projection-expected.txt") + ?.bufferedReader() + ?.readText() ?: return + assertEquals(expected, runVectors()) + } +} From 843a964400da373b4f7474fba3a6810a72f3cd3c Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 11 Oct 2026 14:17:55 -0700 Subject: [PATCH 4/5] Extend raster tile preferences --- api/android-sdk-core.api | 4 ++++ .../core/raster/RasterTilePreference.kt | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 6a520f3..410950e 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -2754,6 +2754,10 @@ public final class com.mapconductor.core.raster.RasterLayerState$Companion { } public interface com.mapconductor.core.raster.RasterTilePreference { public abstract int getPreferredTileSize(); + public default boolean getLayoutInDevicePixels(); +} +public final class com.mapconductor.core.raster.RasterTilePreference$DefaultImpls { + public static boolean getLayoutInDevicePixels(com.mapconductor.core.raster.RasterTilePreference); } public final class com.mapconductor.core.raster.RasterTilePreferenceKey implements com.mapconductor.core.map.MapServiceKey { public static final com.mapconductor.core.raster.RasterTilePreferenceKey INSTANCE; diff --git a/src/main/java/com/mapconductor/core/raster/RasterTilePreference.kt b/src/main/java/com/mapconductor/core/raster/RasterTilePreference.kt index e5298d1..40c934a 100644 --- a/src/main/java/com/mapconductor/core/raster/RasterTilePreference.kt +++ b/src/main/java/com/mapconductor/core/raster/RasterTilePreference.kt @@ -21,6 +21,22 @@ import com.mapconductor.core.map.MapServiceKey interface RasterTilePreference { /** この SDK に渡してほしいタイルの一辺(dp)。 */ val preferredTileSize: Int + + /** + * この SDK がラスタータイルを **物理ピクセル** でレイアウトするなら true。 + * + * 地図 SDK には 2 つの流儀がある。宣言された一辺を dp として敷くものと、 + * 物理ピクセルとして敷くもの。後者に density 2 の端末で「512」と宣言すると + * 256 dp しか覆わず、1 段深いズームを要求してスタイルをそこで読む — + * ラベルは半分、道路は倍の密度になる(web の HERE で実測)。供給側は + * この流儀を知りようがないので、プロバイダが言う。 + * + * true のとき、供給側は [preferredTileSize] × density を宣言し、描画は + * 同じピクセル数で行い、スタイルには [preferredTileSize] dp として + * 読ませる。既定は false。react-sdk / ios-sdk の同名フィールドに対応。 + */ + val layoutInDevicePixels: Boolean + get() = false } /** From 7739ce8e77a47306a4c907622c41d193e530e705 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:50:00 -0700 Subject: [PATCH 5/5] Keep each file's line endings as they are on develop The checkpoint commit rewrote whole files between LF and CRLF, which buried its real changes under line-ending noise. Content is unchanged. --- .../core/marker/MarkerTileRenderBenchmark.kt | 190 ++--- .../core/marker/MarkerTileSeamDeviceTest.kt | 356 ++++----- .../core/tileserver/TilePngEncoderTest.kt | 446 ++++++------ .../core/geocell/HexCellRegistry.kt | 680 +++++++++--------- .../core/geocell/KDTreeRadiusTest.kt | 96 +-- .../core/marker/MarkerGridIndexTest.kt | 638 ++++++++-------- .../core/marker/MarkerTileSeamTest.kt | 310 ++++---- .../projection/ProjectionConformanceTest.kt | 178 ++--- 8 files changed, 1447 insertions(+), 1447 deletions(-) diff --git a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt index 758193c..4292277 100644 --- a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt +++ b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt @@ -1,95 +1,95 @@ -package com.mapconductor.core.marker - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.tileserver.TileRequest -import org.junit.Assert.assertNotNull -import org.junit.Test -import org.junit.runner.RunWith -import android.util.Log - -/** - * What one marker tile costs on this device, with and without decluttering. - * - * 144k markers in the street-tree shape, the tiles central Tokyo actually - * requests. Not a pass/fail gate — the point is the logged ms/tile, so a slow - * device can be measured instead of guessed about. The declutter run is the - * configuration the samples ask for; the run without it is what they were - * silently getting while the controllers dropped `declutterPx` on the floor. - */ -@RunWith(AndroidJUnit4::class) -class MarkerTileRenderBenchmark { - private fun manager(): MarkerManager { - val manager = MarkerManager.defaultManager(minMarkerCount = 1) - var seed = 12345uL - - fun next(): Double { - seed = seed * 6364136223846793005uL + 1442695040888963407uL - return (seed shr 11).toDouble() / (1uL shl 53).toDouble() - } - repeat(144_000) { at -> - manager.registerEntity( - MarkerEntity( - marker = null, - state = - MarkerState( - position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), - id = "$at", - ), - visible = true, - isRendered = true, - tiling = true, - ), - ) - } - return manager - } - - private fun renderViewport( - renderer: MarkerTileRenderer, - z: Int, - label: String, - ) { - // The tile column/row central Tokyo lands on at this zoom. - val n = 1 shl z - val centerX = ((139.75 + 180.0) / 360.0 * n).toInt() - val centerY = - run { - val latRad = Math.toRadians(35.69) - ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() - } - var totalMs = 0L - var tiles = 0 - for (dx in -1..1) { - for (dy in -1..1) { - val start = System.nanoTime() - val bytes = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy, z = z)) - totalMs += (System.nanoTime() - start) / 1_000_000 - tiles += 1 - assertNotNull(bytes) - } - } - Log.i(TAG, "RENDERBENCH $label z=$z tiles=$tiles total=${totalMs}ms perTile=${totalMs / tiles}ms") - } - - @Test - fun viewportCostWithAndWithoutDeclutter() { - val manager = manager() - for (declutter in intArrayOf(0, 14)) { - val renderer = - MarkerTileRenderer( - markerManager = manager, - tileSize = 256, - cacheSizeBytes = 1, // effectively no tile cache: measure the render - declutterPx = declutter, - ) - for (z in intArrayOf(10, 11, 12, 13, 14)) { - renderViewport(renderer, z, label = "declutter=$declutter") - } - } - } - - private companion object { - private const val TAG = "MarkerTileRenderBenchmark" - } -} +package com.mapconductor.core.marker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.tileserver.TileRequest +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import android.util.Log + +/** + * What one marker tile costs on this device, with and without decluttering. + * + * 144k markers in the street-tree shape, the tiles central Tokyo actually + * requests. Not a pass/fail gate — the point is the logged ms/tile, so a slow + * device can be measured instead of guessed about. The declutter run is the + * configuration the samples ask for; the run without it is what they were + * silently getting while the controllers dropped `declutterPx` on the floor. + */ +@RunWith(AndroidJUnit4::class) +class MarkerTileRenderBenchmark { + private fun manager(): MarkerManager { + val manager = MarkerManager.defaultManager(minMarkerCount = 1) + var seed = 12345uL + + fun next(): Double { + seed = seed * 6364136223846793005uL + 1442695040888963407uL + return (seed shr 11).toDouble() / (1uL shl 53).toDouble() + } + repeat(144_000) { at -> + manager.registerEntity( + MarkerEntity( + marker = null, + state = + MarkerState( + position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), + id = "$at", + ), + visible = true, + isRendered = true, + tiling = true, + ), + ) + } + return manager + } + + private fun renderViewport( + renderer: MarkerTileRenderer, + z: Int, + label: String, + ) { + // The tile column/row central Tokyo lands on at this zoom. + val n = 1 shl z + val centerX = ((139.75 + 180.0) / 360.0 * n).toInt() + val centerY = + run { + val latRad = Math.toRadians(35.69) + ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() + } + var totalMs = 0L + var tiles = 0 + for (dx in -1..1) { + for (dy in -1..1) { + val start = System.nanoTime() + val bytes = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy, z = z)) + totalMs += (System.nanoTime() - start) / 1_000_000 + tiles += 1 + assertNotNull(bytes) + } + } + Log.i(TAG, "RENDERBENCH $label z=$z tiles=$tiles total=${totalMs}ms perTile=${totalMs / tiles}ms") + } + + @Test + fun viewportCostWithAndWithoutDeclutter() { + val manager = manager() + for (declutter in intArrayOf(0, 14)) { + val renderer = + MarkerTileRenderer( + markerManager = manager, + tileSize = 256, + cacheSizeBytes = 1, // effectively no tile cache: measure the render + declutterPx = declutter, + ) + for (z in intArrayOf(10, 11, 12, 13, 14)) { + renderViewport(renderer, z, label = "declutter=$declutter") + } + } + } + + private companion object { + private const val TAG = "MarkerTileRenderBenchmark" + } +} diff --git a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt index 73fdcca..431f1f0 100644 --- a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt @@ -1,178 +1,178 @@ -package com.mapconductor.core.marker - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.tileserver.TileRequest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test -import org.junit.runner.RunWith -import android.graphics.BitmapFactory -import android.util.Log - -/** - * Adjacent rendered tiles must agree at the seam, declutter included. - * - * `MarkerTileSeamTest` (JVM) checks that the index hands both tiles the same - * candidates. This renders the actual pixels, because the seam can also be - * broken after the query: if the declutter winner inside a boundary cell - * depends on enumeration order, the two tiles keep different markers and a - * circle appears cut in half against the tile edge — found on device with the - * base layer hidden. - * - * The comparison looks at the two pixel columns facing each other across the - * seam. A marker straddling it paints both; a broken seam leaves one side - * opaque and the other transparent for roughly the icon's whole height. An - * icon merely tangent to the seam can differ for a short run, so only long - * runs count. - */ -@RunWith(AndroidJUnit4::class) -class MarkerTileSeamDeviceTest { - private fun manager(): MarkerManager { - val manager = MarkerManager.defaultManager(minMarkerCount = 1) - var seed = 12345uL - - fun next(): Double { - seed = seed * 6364136223846793005uL + 1442695040888963407uL - return (seed shr 11).toDouble() / (1uL shl 53).toDouble() - } - repeat(144_000) { at -> - manager.registerEntity( - MarkerEntity( - marker = null, - state = - MarkerState( - position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), - id = "$at", - ), - visible = true, - isRendered = true, - tiling = true, - ), - ) - } - return manager - } - - @Test - fun distinctRectanglesWithMatchingLowBitsAreAllRendered() { - val manager = MarkerManager.defaultManager(minMarkerCount = 1) - val icon = - ImageIcon( - image = android.graphics.drawable.ColorDrawable(android.graphics.Color.RED), - iconSize = - androidx.compose.ui.unit - .Dp(16f), - ) - val tilePixels = - com.mapconductor.core.ResourceProvider - .dpToPx(512.0) - .toInt() - val positions = listOf(100 to 100, 200 to 100, 100 to 356) - for ((index, point) in positions.withIndex()) { - val worldX = 8.0 + point.first.toDouble() / tilePixels - val worldY = 6.0 + point.second.toDouble() / tilePixels - val latitude = Math.toDegrees(Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * worldY / 16.0)))) - manager.registerEntity( - MarkerEntity( - marker = null, - state = - MarkerState( - position = GeoPoint.fromLatLong(latitude, worldX * 360.0 / 16.0 - 180.0), - id = "rectangle-$index", - icon = icon, - ), - visible = true, - isRendered = true, - tiling = true, - ), - ) - } - val renderer = MarkerTileRenderer(manager, tileSize = 512, cacheSizeBytes = 1) - val bytes = renderer.renderTile(TileRequest(x = 8, y = 6, z = 4))!! - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - for ((x, y) in positions) { - assertEquals("marker at $x,$y", android.graphics.Color.RED, bitmap.getPixel(x, y)) - } - bitmap.recycle() - } - - @Test - fun adjacentTilesAgreeAtEveryVerticalSeam() { - val renderer = - MarkerTileRenderer( - markerManager = manager(), - tileSize = 256, - cacheSizeBytes = 1, - declutterPx = 14, - ) - var seams = 0 - var bad = 0 - // z15+ is a different path: the declutter separation drops below the - // grid's bottom level there, the index declines the thinned query and - // the renderer falls back to the full one. - for (z in intArrayOf(12, 13, 14, 15, 16, 17)) { - val n = 1 shl z - val centerX = ((139.75 + 180.0) / 360.0 * n).toInt() - val centerY = - run { - val latRad = Math.toRadians(35.69) - ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() - } - for (dx in -3..2) { - for (dy in -2..2) { - val at = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy, z = z)) - val east = renderer.renderTile(TileRequest(x = centerX + dx + 1, y = centerY + dy, z = z)) - val south = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy + 1, z = z)) - assertNotNull(at) - assertNotNull(east) - assertNotNull(south) - val tile = BitmapFactory.decodeByteArray(at!!, 0, at.size) - val eastTile = BitmapFactory.decodeByteArray(east!!, 0, east.size) - val southTile = BitmapFactory.decodeByteArray(south!!, 0, south.size) - seams += 2 - var worst = 0 - var run = 0 - for (y in 0 until tile.height) { - val a = (tile.getPixel(tile.width - 1, y) ushr 24) > 0x20 - val b = (eastTile.getPixel(0, y) ushr 24) > 0x20 - if (a != b) { - run += 1 - if (run > worst) worst = run - } else { - run = 0 - } - } - if (worst > 16) { - bad += 1 - Log - .i(TAG, "SEAM broken(vertical) z=$z x=${centerX + dx}/${centerX + dx + 1} y=${centerY + dy} worstRun=$worst") - } - worst = 0 - run = 0 - for (x in 0 until tile.width) { - val a = (tile.getPixel(x, tile.height - 1) ushr 24) > 0x20 - val b = (southTile.getPixel(x, 0) ushr 24) > 0x20 - if (a != b) { - run += 1 - if (run > worst) worst = run - } else { - run = 0 - } - } - if (worst > 16) { - bad += 1 - Log - .i(TAG, "SEAM broken(horizontal) z=$z x=${centerX + dx} y=${centerY + dy}/${centerY + dy + 1} worstRun=$worst") - } - } - } - } - Log.i(TAG, "SEAM seams=$seams bad=$bad") - assertEquals("seams with a half-drawn marker", 0, bad) - } - - private companion object { - private const val TAG = "MarkerTileSeamDeviceTest" - } -} +package com.mapconductor.core.marker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.tileserver.TileRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import android.graphics.BitmapFactory +import android.util.Log + +/** + * Adjacent rendered tiles must agree at the seam, declutter included. + * + * `MarkerTileSeamTest` (JVM) checks that the index hands both tiles the same + * candidates. This renders the actual pixels, because the seam can also be + * broken after the query: if the declutter winner inside a boundary cell + * depends on enumeration order, the two tiles keep different markers and a + * circle appears cut in half against the tile edge — found on device with the + * base layer hidden. + * + * The comparison looks at the two pixel columns facing each other across the + * seam. A marker straddling it paints both; a broken seam leaves one side + * opaque and the other transparent for roughly the icon's whole height. An + * icon merely tangent to the seam can differ for a short run, so only long + * runs count. + */ +@RunWith(AndroidJUnit4::class) +class MarkerTileSeamDeviceTest { + private fun manager(): MarkerManager { + val manager = MarkerManager.defaultManager(minMarkerCount = 1) + var seed = 12345uL + + fun next(): Double { + seed = seed * 6364136223846793005uL + 1442695040888963407uL + return (seed shr 11).toDouble() / (1uL shl 53).toDouble() + } + repeat(144_000) { at -> + manager.registerEntity( + MarkerEntity( + marker = null, + state = + MarkerState( + position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), + id = "$at", + ), + visible = true, + isRendered = true, + tiling = true, + ), + ) + } + return manager + } + + @Test + fun distinctRectanglesWithMatchingLowBitsAreAllRendered() { + val manager = MarkerManager.defaultManager(minMarkerCount = 1) + val icon = + ImageIcon( + image = android.graphics.drawable.ColorDrawable(android.graphics.Color.RED), + iconSize = + androidx.compose.ui.unit + .Dp(16f), + ) + val tilePixels = + com.mapconductor.core.ResourceProvider + .dpToPx(512.0) + .toInt() + val positions = listOf(100 to 100, 200 to 100, 100 to 356) + for ((index, point) in positions.withIndex()) { + val worldX = 8.0 + point.first.toDouble() / tilePixels + val worldY = 6.0 + point.second.toDouble() / tilePixels + val latitude = Math.toDegrees(Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * worldY / 16.0)))) + manager.registerEntity( + MarkerEntity( + marker = null, + state = + MarkerState( + position = GeoPoint.fromLatLong(latitude, worldX * 360.0 / 16.0 - 180.0), + id = "rectangle-$index", + icon = icon, + ), + visible = true, + isRendered = true, + tiling = true, + ), + ) + } + val renderer = MarkerTileRenderer(manager, tileSize = 512, cacheSizeBytes = 1) + val bytes = renderer.renderTile(TileRequest(x = 8, y = 6, z = 4))!! + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + for ((x, y) in positions) { + assertEquals("marker at $x,$y", android.graphics.Color.RED, bitmap.getPixel(x, y)) + } + bitmap.recycle() + } + + @Test + fun adjacentTilesAgreeAtEveryVerticalSeam() { + val renderer = + MarkerTileRenderer( + markerManager = manager(), + tileSize = 256, + cacheSizeBytes = 1, + declutterPx = 14, + ) + var seams = 0 + var bad = 0 + // z15+ is a different path: the declutter separation drops below the + // grid's bottom level there, the index declines the thinned query and + // the renderer falls back to the full one. + for (z in intArrayOf(12, 13, 14, 15, 16, 17)) { + val n = 1 shl z + val centerX = ((139.75 + 180.0) / 360.0 * n).toInt() + val centerY = + run { + val latRad = Math.toRadians(35.69) + ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() + } + for (dx in -3..2) { + for (dy in -2..2) { + val at = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy, z = z)) + val east = renderer.renderTile(TileRequest(x = centerX + dx + 1, y = centerY + dy, z = z)) + val south = renderer.renderTile(TileRequest(x = centerX + dx, y = centerY + dy + 1, z = z)) + assertNotNull(at) + assertNotNull(east) + assertNotNull(south) + val tile = BitmapFactory.decodeByteArray(at!!, 0, at.size) + val eastTile = BitmapFactory.decodeByteArray(east!!, 0, east.size) + val southTile = BitmapFactory.decodeByteArray(south!!, 0, south.size) + seams += 2 + var worst = 0 + var run = 0 + for (y in 0 until tile.height) { + val a = (tile.getPixel(tile.width - 1, y) ushr 24) > 0x20 + val b = (eastTile.getPixel(0, y) ushr 24) > 0x20 + if (a != b) { + run += 1 + if (run > worst) worst = run + } else { + run = 0 + } + } + if (worst > 16) { + bad += 1 + Log + .i(TAG, "SEAM broken(vertical) z=$z x=${centerX + dx}/${centerX + dx + 1} y=${centerY + dy} worstRun=$worst") + } + worst = 0 + run = 0 + for (x in 0 until tile.width) { + val a = (tile.getPixel(x, tile.height - 1) ushr 24) > 0x20 + val b = (southTile.getPixel(x, 0) ushr 24) > 0x20 + if (a != b) { + run += 1 + if (run > worst) worst = run + } else { + run = 0 + } + } + if (worst > 16) { + bad += 1 + Log + .i(TAG, "SEAM broken(horizontal) z=$z x=${centerX + dx} y=${centerY + dy}/${centerY + dy + 1} worstRun=$worst") + } + } + } + } + Log.i(TAG, "SEAM seams=$seams bad=$bad") + assertEquals("seams with a half-drawn marker", 0, bad) + } + + private companion object { + private const val TAG = "MarkerTileSeamDeviceTest" + } +} diff --git a/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt b/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt index cebf1a1..a03779a 100644 --- a/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt +++ b/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt @@ -1,223 +1,223 @@ -package com.mapconductor.core.tileserver - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.math.abs -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Color - -/** - * On-device checks for the Rust PNG encoder. - * - * Instrumented rather than local because the thing most likely to be wrong is - * not the Kotlin: it is whether `libtile_png_jni.so` is packaged for the - * device's ABI at all. A JVM test would pass with no native library present, - * since every failure path here deliberately returns null. - */ -@RunWith(AndroidJUnit4::class) -class TilePngEncoderTest { - /** - * The whole point of putting the library in the core: it has to load in an - * ordinary consumer process, with no help from the caller. - * - * If this fails, everything below it passes vacuously — the encoder would - * return null and the platform fallback would quietly cover for it. - */ - @Test - fun libraryLoadsOnThisDevice() { - assertTrue( - "libtile_png_jni.so did not load — check jniLibs carries this device's ABI", - TilePngEncoder.isAvailable, - ) - } - - /** - * Opaque pixels, where premultiplication is the identity, so the round trip - * has to be exact. This is the test that catches a swapped R and B: the - * classic way a fast encoder produces a plausible-looking wrong image. - */ - @Test - fun opaquePixelsSurviveExactly() { - val source = opaqueFixture() - val png = TilePngEncoder.encode(source) - assertNotNull("encoder declined an ARGB_8888 bitmap", png) - - val decoded = decode(png!!) - assertEquals(source.width, decoded.width) - assertEquals(source.height, decoded.height) - for (y in 0 until source.height) { - for (x in 0 until source.width) { - assertEquals( - "pixel ($x,$y)", - source.getPixel(x, y), - decoded.getPixel(x, y), - ) - } - } - } - - /** - * Partly transparent pixels against the encoder this one replaces. - * - * Both paths undo the same premultiplication, so they may round a channel - * differently, but they must not disagree about the image. Alpha carries no - * rounding at all and is compared exactly; colour is allowed one step, which - * is what integer un-premultiplication can legitimately cost. - */ - @Test - fun transparentPixelsAgreeWithBitmapCompress() { - val source = translucentFixture() - - val rust = decode(TilePngEncoder.encode(source)!!) - val platform = decode(compress(source)) - - var worstColourStep = 0 - for (y in 0 until source.height) { - for (x in 0 until source.width) { - val a = rust.getPixel(x, y) - val b = platform.getPixel(x, y) - assertEquals("alpha at ($x,$y)", Color.alpha(b), Color.alpha(a)) - // Fully transparent pixels carry no recoverable colour: both - // encoders are free to write anything under alpha 0. - if (Color.alpha(b) == 0) continue - val step = - maxOf( - abs(Color.red(a) - Color.red(b)), - abs(Color.green(a) - Color.green(b)), - abs(Color.blue(a) - Color.blue(b)), - ) - assertTrue("colour at ($x,$y) differs by $step", step <= 1) - worstColourStep = maxOf(worstColourStep, step) - } - } - println("TILEPNG translucent round trip: worst colour step $worstColourStep") - } - - /** - * A bitmap the encoder cannot read has to come back as null, because the - * caller's fallback is the only thing standing between that and a blank - * tile. RGB_565 has a different byte layout entirely. - */ - @Test - fun unsupportedConfigDeclinesInsteadOfGuessing() { - val source = opaqueFixture().copy(Bitmap.Config.RGB_565, false) - assertNull(TilePngEncoder.encode(source)) - } - - /** A heap buffer's address does not survive the JNI crossing. */ - @Test - fun indirectBufferIsRefused() { - val heap = ByteBuffer.allocate(TILE * TILE * 4).order(ByteOrder.nativeOrder()) - assertNull(TilePngEncoder.encode(heap, TILE, TILE, premultiplied = false)) - } - - /** - * Not an assertion about speed — the numbers vary with the device and - * nothing here should fail because a phone was busy. It prints the - * comparison so the reason this encoder exists stays visible in the log. - * - * Deliberately run on noise rather than on the smooth fixtures above. - * Synthetic tiles mislead badly here: a flat colour under an alpha ramp - * compresses to a couple of kilobytes, which flatters `compress` on size - * and starves it of the work this encoder is meant to save. Noise is not - * realistic either, only wrong in the opposite direction — the honest - * comparison, on tiles the marker renderer actually produced, is - * `MarkerTileCostTest` in android-vectortile. - */ - @Test - fun reportsCostAgainstThePlatformEncoder() { - val source = noiseFixture() - - val platformSamples = ArrayList(REPEATS) - var platformBytes = 0 - repeat(REPEATS) { - val started = System.nanoTime() - platformBytes = compress(source).size - platformSamples.add((System.nanoTime() - started) / 1_000_000.0) - } - - val rustSamples = ArrayList(REPEATS) - var rustBytes = 0 - repeat(REPEATS) { - val started = System.nanoTime() - rustBytes = TilePngEncoder.encode(source)!!.size - rustSamples.add((System.nanoTime() - started) / 1_000_000.0) - } - - println( - "TILEPNG %dx%d compress=%.1fms/%dB rust=%.1fms/%dB".format( - TILE, TILE, - median(platformSamples), platformBytes, - median(rustSamples), rustBytes, - ), - ) - } - - private fun opaqueFixture(): Bitmap { - val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) - for (y in 0 until TILE) { - for (x in 0 until TILE) { - // Distinct per channel, so a channel swap cannot coincide. - bitmap.setPixel(x, y, Color.argb(255, x % 256, y % 256, (x + 2 * y) % 256)) - } - } - return bitmap - } - - private fun translucentFixture(): Bitmap { - val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) - for (y in 0 until TILE) { - for (x in 0 until TILE) { - bitmap.setPixel(x, y, Color.argb((x * 3) % 256, 240, 90, 30)) - } - } - return bitmap - } - - /** Incompressible content, from a fixed seed so runs stay comparable. */ - private fun noiseFixture(): Bitmap { - val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) - val pixels = IntArray(TILE * TILE) - var state = 0x2545F491 - for (index in pixels.indices) { - state = state * 1_664_525 + 1_013_904_223 - val bits = state ushr 8 - pixels[index] = - Color.argb( - 128 + (bits and 0x7F), - (bits ushr 7) and 0xFF, - (bits ushr 15) and 0xFF, - (bits ushr 23) and 0xFF, - ) - } - bitmap.setPixels(pixels, 0, TILE, 0, 0, TILE, TILE) - return bitmap - } - - private fun compress(bitmap: Bitmap): ByteArray { - val stream = ByteArrayOutputStream(TILE * TILE) - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) - return stream.toByteArray() - } - - private fun decode(png: ByteArray): Bitmap = - BitmapFactory - .decodeByteArray(png, 0, png.size) - .copy(Bitmap.Config.ARGB_8888, false) - - private fun median(samples: List): Double = samples.sorted()[samples.size / 2] - - private companion object { - const val TILE = 512 - const val REPEATS = 5 - } -} +package com.mapconductor.core.tileserver + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.abs +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color + +/** + * On-device checks for the Rust PNG encoder. + * + * Instrumented rather than local because the thing most likely to be wrong is + * not the Kotlin: it is whether `libtile_png_jni.so` is packaged for the + * device's ABI at all. A JVM test would pass with no native library present, + * since every failure path here deliberately returns null. + */ +@RunWith(AndroidJUnit4::class) +class TilePngEncoderTest { + /** + * The whole point of putting the library in the core: it has to load in an + * ordinary consumer process, with no help from the caller. + * + * If this fails, everything below it passes vacuously — the encoder would + * return null and the platform fallback would quietly cover for it. + */ + @Test + fun libraryLoadsOnThisDevice() { + assertTrue( + "libtile_png_jni.so did not load — check jniLibs carries this device's ABI", + TilePngEncoder.isAvailable, + ) + } + + /** + * Opaque pixels, where premultiplication is the identity, so the round trip + * has to be exact. This is the test that catches a swapped R and B: the + * classic way a fast encoder produces a plausible-looking wrong image. + */ + @Test + fun opaquePixelsSurviveExactly() { + val source = opaqueFixture() + val png = TilePngEncoder.encode(source) + assertNotNull("encoder declined an ARGB_8888 bitmap", png) + + val decoded = decode(png!!) + assertEquals(source.width, decoded.width) + assertEquals(source.height, decoded.height) + for (y in 0 until source.height) { + for (x in 0 until source.width) { + assertEquals( + "pixel ($x,$y)", + source.getPixel(x, y), + decoded.getPixel(x, y), + ) + } + } + } + + /** + * Partly transparent pixels against the encoder this one replaces. + * + * Both paths undo the same premultiplication, so they may round a channel + * differently, but they must not disagree about the image. Alpha carries no + * rounding at all and is compared exactly; colour is allowed one step, which + * is what integer un-premultiplication can legitimately cost. + */ + @Test + fun transparentPixelsAgreeWithBitmapCompress() { + val source = translucentFixture() + + val rust = decode(TilePngEncoder.encode(source)!!) + val platform = decode(compress(source)) + + var worstColourStep = 0 + for (y in 0 until source.height) { + for (x in 0 until source.width) { + val a = rust.getPixel(x, y) + val b = platform.getPixel(x, y) + assertEquals("alpha at ($x,$y)", Color.alpha(b), Color.alpha(a)) + // Fully transparent pixels carry no recoverable colour: both + // encoders are free to write anything under alpha 0. + if (Color.alpha(b) == 0) continue + val step = + maxOf( + abs(Color.red(a) - Color.red(b)), + abs(Color.green(a) - Color.green(b)), + abs(Color.blue(a) - Color.blue(b)), + ) + assertTrue("colour at ($x,$y) differs by $step", step <= 1) + worstColourStep = maxOf(worstColourStep, step) + } + } + println("TILEPNG translucent round trip: worst colour step $worstColourStep") + } + + /** + * A bitmap the encoder cannot read has to come back as null, because the + * caller's fallback is the only thing standing between that and a blank + * tile. RGB_565 has a different byte layout entirely. + */ + @Test + fun unsupportedConfigDeclinesInsteadOfGuessing() { + val source = opaqueFixture().copy(Bitmap.Config.RGB_565, false) + assertNull(TilePngEncoder.encode(source)) + } + + /** A heap buffer's address does not survive the JNI crossing. */ + @Test + fun indirectBufferIsRefused() { + val heap = ByteBuffer.allocate(TILE * TILE * 4).order(ByteOrder.nativeOrder()) + assertNull(TilePngEncoder.encode(heap, TILE, TILE, premultiplied = false)) + } + + /** + * Not an assertion about speed — the numbers vary with the device and + * nothing here should fail because a phone was busy. It prints the + * comparison so the reason this encoder exists stays visible in the log. + * + * Deliberately run on noise rather than on the smooth fixtures above. + * Synthetic tiles mislead badly here: a flat colour under an alpha ramp + * compresses to a couple of kilobytes, which flatters `compress` on size + * and starves it of the work this encoder is meant to save. Noise is not + * realistic either, only wrong in the opposite direction — the honest + * comparison, on tiles the marker renderer actually produced, is + * `MarkerTileCostTest` in android-vectortile. + */ + @Test + fun reportsCostAgainstThePlatformEncoder() { + val source = noiseFixture() + + val platformSamples = ArrayList(REPEATS) + var platformBytes = 0 + repeat(REPEATS) { + val started = System.nanoTime() + platformBytes = compress(source).size + platformSamples.add((System.nanoTime() - started) / 1_000_000.0) + } + + val rustSamples = ArrayList(REPEATS) + var rustBytes = 0 + repeat(REPEATS) { + val started = System.nanoTime() + rustBytes = TilePngEncoder.encode(source)!!.size + rustSamples.add((System.nanoTime() - started) / 1_000_000.0) + } + + println( + "TILEPNG %dx%d compress=%.1fms/%dB rust=%.1fms/%dB".format( + TILE, TILE, + median(platformSamples), platformBytes, + median(rustSamples), rustBytes, + ), + ) + } + + private fun opaqueFixture(): Bitmap { + val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) + for (y in 0 until TILE) { + for (x in 0 until TILE) { + // Distinct per channel, so a channel swap cannot coincide. + bitmap.setPixel(x, y, Color.argb(255, x % 256, y % 256, (x + 2 * y) % 256)) + } + } + return bitmap + } + + private fun translucentFixture(): Bitmap { + val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) + for (y in 0 until TILE) { + for (x in 0 until TILE) { + bitmap.setPixel(x, y, Color.argb((x * 3) % 256, 240, 90, 30)) + } + } + return bitmap + } + + /** Incompressible content, from a fixed seed so runs stay comparable. */ + private fun noiseFixture(): Bitmap { + val bitmap = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888) + val pixels = IntArray(TILE * TILE) + var state = 0x2545F491 + for (index in pixels.indices) { + state = state * 1_664_525 + 1_013_904_223 + val bits = state ushr 8 + pixels[index] = + Color.argb( + 128 + (bits and 0x7F), + (bits ushr 7) and 0xFF, + (bits ushr 15) and 0xFF, + (bits ushr 23) and 0xFF, + ) + } + bitmap.setPixels(pixels, 0, TILE, 0, 0, TILE, TILE) + return bitmap + } + + private fun compress(bitmap: Bitmap): ByteArray { + val stream = ByteArrayOutputStream(TILE * TILE) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + return stream.toByteArray() + } + + private fun decode(png: ByteArray): Bitmap = + BitmapFactory + .decodeByteArray(png, 0, png.size) + .copy(Bitmap.Config.ARGB_8888, false) + + private fun median(samples: List): Double = samples.sorted()[samples.size / 2] + + private companion object { + const val TILE = 512 + const val REPEATS = 5 + } +} diff --git a/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt b/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt index d4991bb..0abe2b5 100644 --- a/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt +++ b/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt @@ -1,340 +1,340 @@ -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, -) +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/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt b/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt index d06ef2c..7e65bb0 100644 --- a/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt +++ b/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt @@ -1,48 +1,48 @@ -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) - } - } -} +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) + } + } +} diff --git a/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt b/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt index b773b3f..a87fcf4 100644 --- a/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt +++ b/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt @@ -1,319 +1,319 @@ -package com.mapconductor.core.marker - -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoRectBounds -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * [MarkerGridIndex] must answer exactly what a scan would. - * - * The index packs a cell key and an array position into one Long and reads them - * back with shifts and masks, so an off-by-one in the bit layout or in a range - * bound does not throw — it quietly drops markers. Every test here is the same - * shape: ask the index, ask brute force, demand the same set. - */ -class MarkerGridIndexTest { - private fun entity( - id: Int, - latitude: Double, - longitude: Double, - ): MarkerEntityInterface = - MarkerEntity( - marker = id, - state = MarkerState(position = GeoPoint.fromLatLong(latitude, longitude), id = "$id"), - ) - - /** A deterministic spread, so a failure is reproducible. */ - private fun scatter( - count: Int, - latitude: Double, - longitude: Double, - spread: Double, - ): List> { - var seed = 0x2545F4914F6CDD1DuL - - fun next(): Double { - seed = seed * 6364136223846793005uL + 1442695040888963407uL - return (seed shr 11).toDouble() / (1L shl 53).toDouble() - } - return (0 until count).map { index -> - entity( - index, - latitude + (next() - 0.5) * spread, - longitude + (next() - 0.5) * spread, - ) - } - } - - private fun ids(entities: Collection>): Set = - entities.map { it.state.id }.toSet() - - private fun bounds( - south: Double, - west: Double, - north: Double, - east: Double, - ): GeoRectBounds = - GeoRectBounds( - southWest = GeoPoint.fromLatLong(south, west), - northEast = GeoPoint.fromLatLong(north, east), - ) - - @Test - fun boundsQueryMatchesBruteForce() { - val markers = scatter(5000, 35.68, 139.76, 0.6) - val index = MarkerGridIndex { markers } - - // A box inside one cell, one spanning several, one wider than the data, - // and one over empty ground — the last because an index that returns - // nothing is right here and wrong everywhere else. - for (box in listOf( - bounds(35.6812, 139.7612, 35.6814, 139.7614), - bounds(35.68, 139.76, 35.69, 139.77), - bounds(35.0, 139.0, 36.5, 140.5), - bounds(10.0, 100.0, 11.0, 101.0), - )) { - val expected = ids(markers.filter { box.contains(it.state.position) }) - assertEquals("grid disagreed with a scan", expected, ids(index.inBounds(box))) - } - - // The middle two have to be carrying markers, or the comparison above is - // only proving that two empty sets match. - assertFalse(index.inBounds(bounds(35.68, 139.76, 35.69, 139.77)).isEmpty()) - assertEquals(markers.size, index.inBounds(bounds(35.0, 139.0, 36.5, 140.5)).size) - } - - /** - * A box crossing 180° has its east corner west of its west one. - * - * The first version walked `lonFrom..lonTo` straight, which is an empty - * range in Kotlin and a trap in Swift. Fiji and the Chathams are real - * places, and a map centred there is a reversed box every frame. - */ - @Test - fun boundsQueryCrossesTheAntimeridian() { - val markers = - listOf( - entity(0, -18.0, 179.9), - entity(1, -18.0, -179.9), - entity(2, -18.0, 178.0), - entity(3, -18.0, 0.0), - ) + scatter(3000, -18.0, 179.95, 0.4) - - val index = MarkerGridIndex { markers } - val box = bounds(-18.5, 179.5, -17.5, -179.5) - val expected = ids(markers.filter { box.contains(it.state.position) }) - - assertTrue("the box should hold the marker just west of 180", expected.contains("0")) - assertTrue("and the one just east of it", expected.contains("1")) - assertEquals(expected, ids(index.inBounds(box))) - } - - /** - * The thinned query may drop markers, but only ones it was told are - * interchangeable — and never a whole cell. - * - * A caller asks for this when it is about to keep one marker per cell of - * its own anyway. What it must not get back is a hole: a cell holding - * markers inside the bounds that returns nothing, which on a map is a patch - * of the city with no trees in it and no error anywhere. - */ - @Test - fun thinnedQueryKeepsOneMarkerFromEveryCellItCovers() { - val markers = scatter(60000, 35.68, 139.76, 0.4) - val index = MarkerGridIndex { markers } - val box = bounds(35.60, 139.68, 35.76, 139.84) - - val thinned = index.inBoundsThinned(box, minSeparationDegrees = 0.005) - assertNotNull("0.005 degrees is coarser than a cell, so this must be answered", thinned) - val kept = thinned!! - - val inside = markers.filter { box.contains(it.state.position) } - assertTrue("the box should hold something to compare", inside.size > 1000) - - // The level the index picks, worked out here the same way. 0.005 degrees - // gives level 16, an even level, so the cell has a whole-character name - // of 9 characters and can be compared as a string. - assertEquals(16L, MarkerGrid.levelForSeparation(0.005)!!.toLong()) - - fun cellOf(entity: MarkerEntityInterface): String = - MarkerGrid.geocell( - entity.state.position.latitude, - entity.state.position.longitude, - characters = 9, - ) - - // Every cell holding something inside the box has to be represented. - // Markers from outside the box are fine and expected: a cell's winner - // is chosen without reference to the box, so a cell on the edge can be - // represented by a marker just outside it. Not trimming those is what - // makes neighbouring tiles agree — see `MarkerTileSeamTest`. - assertTrue( - "some populated cell returned no representative", - kept.map(::cellOf).toSet().containsAll(inside.map(::cellOf).toSet()), - ) - val outside = kept.count { !box.contains(it.state.position) } - assertTrue( - "more markers outside the box than one edge cell can explain", - outside < kept.size * 0.15, - ) - assertEquals("more than one from some cell", kept.map(::cellOf).toSet().size, kept.size) - // The box is 0.16 degrees a side, so at level 16 it spans about 3,400 - // square cells of 0.00275 degrees and holds around 9,600 of these - // markers — nearly three to a cell. Without this the assertions above - // would still pass on data too sparse to thin, proving nothing. - assertTrue("the data is too sparse to be exercising thinning", kept.size * 2 < inside.size) - } - - /** - * Cells coarser than the caller's separation would thin more than asked. - * - * While the grid was flat that floor was 0.005 degrees. Now that it is a - * hierarchy the floor is the bottom level — 0.000687 degrees on a side — - * and anything coarser is answered by picking a level. That difference is - * what makes thinning work at the deeper zooms. - */ - @Test - fun thinnedQueryDeclinesWhenItsCellsAreTooCoarse() { - val index = MarkerGridIndex { scatter(2000, 35.68, 139.76, 0.4) } - val box = bounds(35.6, 139.7, 35.7, 139.8) - assertNull(index.inBoundsThinned(box, 0.0005)) - assertNotNull( - "0.001 degrees is coarser than the bottom level, so a level can be chosen", - index.inBoundsThinned(box, 0.001), - ) - } - - /** The wrap the plain query learned has to hold here too. */ - @Test - fun thinnedQueryCrossesTheAntimeridian() { - val markers = - listOf( - entity(0, -18.0, 179.99), - entity(1, -18.0, -179.99), - entity(2, -18.0, 178.0), - ) - val index = MarkerGridIndex { markers } - val kept = index.inBoundsThinned(bounds(-18.5, 179.5, -17.5, -179.5), 0.01) - assertNotNull(kept) - assertEquals(setOf("0", "1"), ids(kept!!)) - } - - @Test - fun nearestMatchesBruteForce() { - val markers = scatter(5000, 35.68, 139.76, 0.6) - val index = MarkerGridIndex { markers } - - for (point in listOf( - GeoPoint.fromLatLong(35.68, 139.76), - GeoPoint.fromLatLong(35.4, 139.5), - GeoPoint.fromLatLong(36.0, 140.1), - )) { - val expected = markers.minByOrNull { squared(it, point) } - assertEquals(expected?.state?.id, index.nearest(point)?.state?.id) - } - } - - /** - * Beyond the rings the index searches it falls back to a scan rather than - * returning nothing — the answer still has to be right, just slower. - */ - @Test - fun nearestFallsBackWhenNothingIsClose() { - val index = MarkerGridIndex { listOf(entity(0, 35.0, 139.0)) } - assertEquals("0", index.nearest(GeoPoint.fromLatLong(-35.0, -70.0))?.state?.id) - } - - @Test - fun emptyIndexHasNoNearest() { - val index = MarkerGridIndex { emptyList() } - assertNull(index.nearest(GeoPoint.fromLatLong(35.0, 139.0))) - } - - /** The index borrows the manager's markers, so a change has to be seen. */ - @Test - fun invalidateShowsLaterMarkers() { - val markers = mutableListOf(entity(0, 35.0, 139.0)) - val index = MarkerGridIndex { markers } - val box = bounds(34.9, 138.9, 35.1, 139.1) - assertEquals(1, index.inBounds(box).size) - - markers.add(entity(1, 35.001, 139.001)) - assertEquals("a stale index should stay stale until told", 1, index.inBounds(box).size) - - index.invalidate() - assertEquals(2, index.inBounds(box).size) - } - - private fun squared( - entity: MarkerEntityInterface, - point: GeoPoint, - ): Double { - val deltaLat = entity.state.position.latitude - point.latitude - val deltaLon = entity.state.position.longitude - point.longitude - return deltaLat * deltaLat + deltaLon * deltaLon - } - - /** - * Whatever shape the box is, the index has to answer what a scan answers. - * - * Square boxes alone never reach the new level choice's **per-axis cap** — - * the path a very flat band or a very narrow column takes. Get that wrong - * and nothing on the map changes except that queries of that shape quietly - * drop markers. - * - * The near-global box is here because column indices fold: the column - * holding 180 and the one holding -180 are the same, so taking the end of - * the walk from the east corner covers a single column. See - * `MarkerGrid.columnWalk`. - * - * Pairs with ios-sdk's `testBoundsQueryMatchesBruteForceForEveryShapeOfBox` - * and react-sdk's test of the same name. - */ - @Test - fun boundsQueryMatchesBruteForceForEveryShapeOfBox() { - // The second group's ids are shifted. `scatter` numbers from zero every - // time, so adding them straight makes ids collide, and `ids()` folds two - // different markers into one — the index could return duplicates and the - // sets would still agree. - val near = scatter(30000, 35.68, 139.76, 0.8) - val far = - scatter(10000, -18.0, 179.95, 0.6).map { - entity(it.state.id.toInt() + 30000, it.state.position.latitude, it.state.position.longitude) - } - val markers = near + far - val index = MarkerGridIndex { markers } - - var nonEmpty = 0 - val boxes = - listOf( - // Tile-sized, from z=14 down to z=9. - bounds(35.6800, 139.7600, 35.6946, 139.7820), - bounds(35.6000, 139.6000, 35.7000, 139.8000), - bounds(35.2000, 139.2000, 36.2000, 140.2000), - // A very flat band and a very narrow column. Area alone cannot - // choose a level for either. - bounds(35.6790, 139.0000, 35.6810, 140.5000), - bounds(35.0000, 139.7590, 36.4000, 139.7610), - // Nearly the whole globe. - bounds(-85.0, -179.9, 85.0, 179.9), - // Across the antimeridian. - bounds(-18.5, 179.5, -17.5, -179.5), - // Empty ground: the one box where returning nothing is right. - bounds(10.0, 100.0, 11.0, 101.0), - ) - for ((at, box) in boxes.withIndex()) { - val expected = ids(markers.filter { box.contains(it.state.position) }) - val found = index.inBounds(box) - assertEquals("box $at: the grid disagreed with a scan", expected, ids(found)) - assertEquals("box $at: returned the same marker twice", expected.size, found.size) - if (expected.isNotEmpty()) nonEmpty++ - } - assertEquals("only comparing empty sets to empty sets", 7, nonEmpty) - } -} +package com.mapconductor.core.marker + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [MarkerGridIndex] must answer exactly what a scan would. + * + * The index packs a cell key and an array position into one Long and reads them + * back with shifts and masks, so an off-by-one in the bit layout or in a range + * bound does not throw — it quietly drops markers. Every test here is the same + * shape: ask the index, ask brute force, demand the same set. + */ +class MarkerGridIndexTest { + private fun entity( + id: Int, + latitude: Double, + longitude: Double, + ): MarkerEntityInterface = + MarkerEntity( + marker = id, + state = MarkerState(position = GeoPoint.fromLatLong(latitude, longitude), id = "$id"), + ) + + /** A deterministic spread, so a failure is reproducible. */ + private fun scatter( + count: Int, + latitude: Double, + longitude: Double, + spread: Double, + ): List> { + var seed = 0x2545F4914F6CDD1DuL + + fun next(): Double { + seed = seed * 6364136223846793005uL + 1442695040888963407uL + return (seed shr 11).toDouble() / (1L shl 53).toDouble() + } + return (0 until count).map { index -> + entity( + index, + latitude + (next() - 0.5) * spread, + longitude + (next() - 0.5) * spread, + ) + } + } + + private fun ids(entities: Collection>): Set = + entities.map { it.state.id }.toSet() + + private fun bounds( + south: Double, + west: Double, + north: Double, + east: Double, + ): GeoRectBounds = + GeoRectBounds( + southWest = GeoPoint.fromLatLong(south, west), + northEast = GeoPoint.fromLatLong(north, east), + ) + + @Test + fun boundsQueryMatchesBruteForce() { + val markers = scatter(5000, 35.68, 139.76, 0.6) + val index = MarkerGridIndex { markers } + + // A box inside one cell, one spanning several, one wider than the data, + // and one over empty ground — the last because an index that returns + // nothing is right here and wrong everywhere else. + for (box in listOf( + bounds(35.6812, 139.7612, 35.6814, 139.7614), + bounds(35.68, 139.76, 35.69, 139.77), + bounds(35.0, 139.0, 36.5, 140.5), + bounds(10.0, 100.0, 11.0, 101.0), + )) { + val expected = ids(markers.filter { box.contains(it.state.position) }) + assertEquals("grid disagreed with a scan", expected, ids(index.inBounds(box))) + } + + // The middle two have to be carrying markers, or the comparison above is + // only proving that two empty sets match. + assertFalse(index.inBounds(bounds(35.68, 139.76, 35.69, 139.77)).isEmpty()) + assertEquals(markers.size, index.inBounds(bounds(35.0, 139.0, 36.5, 140.5)).size) + } + + /** + * A box crossing 180° has its east corner west of its west one. + * + * The first version walked `lonFrom..lonTo` straight, which is an empty + * range in Kotlin and a trap in Swift. Fiji and the Chathams are real + * places, and a map centred there is a reversed box every frame. + */ + @Test + fun boundsQueryCrossesTheAntimeridian() { + val markers = + listOf( + entity(0, -18.0, 179.9), + entity(1, -18.0, -179.9), + entity(2, -18.0, 178.0), + entity(3, -18.0, 0.0), + ) + scatter(3000, -18.0, 179.95, 0.4) + + val index = MarkerGridIndex { markers } + val box = bounds(-18.5, 179.5, -17.5, -179.5) + val expected = ids(markers.filter { box.contains(it.state.position) }) + + assertTrue("the box should hold the marker just west of 180", expected.contains("0")) + assertTrue("and the one just east of it", expected.contains("1")) + assertEquals(expected, ids(index.inBounds(box))) + } + + /** + * The thinned query may drop markers, but only ones it was told are + * interchangeable — and never a whole cell. + * + * A caller asks for this when it is about to keep one marker per cell of + * its own anyway. What it must not get back is a hole: a cell holding + * markers inside the bounds that returns nothing, which on a map is a patch + * of the city with no trees in it and no error anywhere. + */ + @Test + fun thinnedQueryKeepsOneMarkerFromEveryCellItCovers() { + val markers = scatter(60000, 35.68, 139.76, 0.4) + val index = MarkerGridIndex { markers } + val box = bounds(35.60, 139.68, 35.76, 139.84) + + val thinned = index.inBoundsThinned(box, minSeparationDegrees = 0.005) + assertNotNull("0.005 degrees is coarser than a cell, so this must be answered", thinned) + val kept = thinned!! + + val inside = markers.filter { box.contains(it.state.position) } + assertTrue("the box should hold something to compare", inside.size > 1000) + + // The level the index picks, worked out here the same way. 0.005 degrees + // gives level 16, an even level, so the cell has a whole-character name + // of 9 characters and can be compared as a string. + assertEquals(16L, MarkerGrid.levelForSeparation(0.005)!!.toLong()) + + fun cellOf(entity: MarkerEntityInterface): String = + MarkerGrid.geocell( + entity.state.position.latitude, + entity.state.position.longitude, + characters = 9, + ) + + // Every cell holding something inside the box has to be represented. + // Markers from outside the box are fine and expected: a cell's winner + // is chosen without reference to the box, so a cell on the edge can be + // represented by a marker just outside it. Not trimming those is what + // makes neighbouring tiles agree — see `MarkerTileSeamTest`. + assertTrue( + "some populated cell returned no representative", + kept.map(::cellOf).toSet().containsAll(inside.map(::cellOf).toSet()), + ) + val outside = kept.count { !box.contains(it.state.position) } + assertTrue( + "more markers outside the box than one edge cell can explain", + outside < kept.size * 0.15, + ) + assertEquals("more than one from some cell", kept.map(::cellOf).toSet().size, kept.size) + // The box is 0.16 degrees a side, so at level 16 it spans about 3,400 + // square cells of 0.00275 degrees and holds around 9,600 of these + // markers — nearly three to a cell. Without this the assertions above + // would still pass on data too sparse to thin, proving nothing. + assertTrue("the data is too sparse to be exercising thinning", kept.size * 2 < inside.size) + } + + /** + * Cells coarser than the caller's separation would thin more than asked. + * + * While the grid was flat that floor was 0.005 degrees. Now that it is a + * hierarchy the floor is the bottom level — 0.000687 degrees on a side — + * and anything coarser is answered by picking a level. That difference is + * what makes thinning work at the deeper zooms. + */ + @Test + fun thinnedQueryDeclinesWhenItsCellsAreTooCoarse() { + val index = MarkerGridIndex { scatter(2000, 35.68, 139.76, 0.4) } + val box = bounds(35.6, 139.7, 35.7, 139.8) + assertNull(index.inBoundsThinned(box, 0.0005)) + assertNotNull( + "0.001 degrees is coarser than the bottom level, so a level can be chosen", + index.inBoundsThinned(box, 0.001), + ) + } + + /** The wrap the plain query learned has to hold here too. */ + @Test + fun thinnedQueryCrossesTheAntimeridian() { + val markers = + listOf( + entity(0, -18.0, 179.99), + entity(1, -18.0, -179.99), + entity(2, -18.0, 178.0), + ) + val index = MarkerGridIndex { markers } + val kept = index.inBoundsThinned(bounds(-18.5, 179.5, -17.5, -179.5), 0.01) + assertNotNull(kept) + assertEquals(setOf("0", "1"), ids(kept!!)) + } + + @Test + fun nearestMatchesBruteForce() { + val markers = scatter(5000, 35.68, 139.76, 0.6) + val index = MarkerGridIndex { markers } + + for (point in listOf( + GeoPoint.fromLatLong(35.68, 139.76), + GeoPoint.fromLatLong(35.4, 139.5), + GeoPoint.fromLatLong(36.0, 140.1), + )) { + val expected = markers.minByOrNull { squared(it, point) } + assertEquals(expected?.state?.id, index.nearest(point)?.state?.id) + } + } + + /** + * Beyond the rings the index searches it falls back to a scan rather than + * returning nothing — the answer still has to be right, just slower. + */ + @Test + fun nearestFallsBackWhenNothingIsClose() { + val index = MarkerGridIndex { listOf(entity(0, 35.0, 139.0)) } + assertEquals("0", index.nearest(GeoPoint.fromLatLong(-35.0, -70.0))?.state?.id) + } + + @Test + fun emptyIndexHasNoNearest() { + val index = MarkerGridIndex { emptyList() } + assertNull(index.nearest(GeoPoint.fromLatLong(35.0, 139.0))) + } + + /** The index borrows the manager's markers, so a change has to be seen. */ + @Test + fun invalidateShowsLaterMarkers() { + val markers = mutableListOf(entity(0, 35.0, 139.0)) + val index = MarkerGridIndex { markers } + val box = bounds(34.9, 138.9, 35.1, 139.1) + assertEquals(1, index.inBounds(box).size) + + markers.add(entity(1, 35.001, 139.001)) + assertEquals("a stale index should stay stale until told", 1, index.inBounds(box).size) + + index.invalidate() + assertEquals(2, index.inBounds(box).size) + } + + private fun squared( + entity: MarkerEntityInterface, + point: GeoPoint, + ): Double { + val deltaLat = entity.state.position.latitude - point.latitude + val deltaLon = entity.state.position.longitude - point.longitude + return deltaLat * deltaLat + deltaLon * deltaLon + } + + /** + * Whatever shape the box is, the index has to answer what a scan answers. + * + * Square boxes alone never reach the new level choice's **per-axis cap** — + * the path a very flat band or a very narrow column takes. Get that wrong + * and nothing on the map changes except that queries of that shape quietly + * drop markers. + * + * The near-global box is here because column indices fold: the column + * holding 180 and the one holding -180 are the same, so taking the end of + * the walk from the east corner covers a single column. See + * `MarkerGrid.columnWalk`. + * + * Pairs with ios-sdk's `testBoundsQueryMatchesBruteForceForEveryShapeOfBox` + * and react-sdk's test of the same name. + */ + @Test + fun boundsQueryMatchesBruteForceForEveryShapeOfBox() { + // The second group's ids are shifted. `scatter` numbers from zero every + // time, so adding them straight makes ids collide, and `ids()` folds two + // different markers into one — the index could return duplicates and the + // sets would still agree. + val near = scatter(30000, 35.68, 139.76, 0.8) + val far = + scatter(10000, -18.0, 179.95, 0.6).map { + entity(it.state.id.toInt() + 30000, it.state.position.latitude, it.state.position.longitude) + } + val markers = near + far + val index = MarkerGridIndex { markers } + + var nonEmpty = 0 + val boxes = + listOf( + // Tile-sized, from z=14 down to z=9. + bounds(35.6800, 139.7600, 35.6946, 139.7820), + bounds(35.6000, 139.6000, 35.7000, 139.8000), + bounds(35.2000, 139.2000, 36.2000, 140.2000), + // A very flat band and a very narrow column. Area alone cannot + // choose a level for either. + bounds(35.6790, 139.0000, 35.6810, 140.5000), + bounds(35.0000, 139.7590, 36.4000, 139.7610), + // Nearly the whole globe. + bounds(-85.0, -179.9, 85.0, 179.9), + // Across the antimeridian. + bounds(-18.5, 179.5, -17.5, -179.5), + // Empty ground: the one box where returning nothing is right. + bounds(10.0, 100.0, 11.0, 101.0), + ) + for ((at, box) in boxes.withIndex()) { + val expected = ids(markers.filter { box.contains(it.state.position) }) + val found = index.inBounds(box) + assertEquals("box $at: the grid disagreed with a scan", expected, ids(found)) + assertEquals("box $at: returned the same marker twice", expected.size, found.size) + if (expected.isNotEmpty()) nonEmpty++ + } + assertEquals("only comparing empty sets to empty sets", 7, nonEmpty) + } +} diff --git a/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt b/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt index 5f304db..141b0ac 100644 --- a/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt +++ b/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt @@ -1,155 +1,155 @@ -package com.mapconductor.core.marker - -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoRectBounds -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Neighbouring tiles must agree about the markers they share. - * - * Tiles are drawn one at a time. A marker drawn on tile A but not on tile B - * gets **cut** at the boundary: the left half appears on the left tile and the - * rest is nowhere. - * - * The renderer queries its own box grown by the icon overhang - * (`queryByHalfExtentPx`), so the property that keeps the seam intact is: - * a marker returned for A that also falls inside B's grown box must be - * returned for B. That is all this test looks at — it never draws anything. - * - * ios-sdk's `MarkerTileSeamTests` and react-sdk's `MarkerTileSeam.test.mjs` - * check the same thing. - */ -class MarkerTileSeamTest { - private val tileSize = 512.0 - - /** The sample's declutter. The disagreement shows up as a side effect of thinning. */ - private val declutterPx = 14.0 - - /** Half an icon. The renderer measures it; a fixed value is enough here. */ - private val halfExtentPx = 10.0 - - private fun markers(count: Int): List> { - var seed = 12345uL - - fun next(): Double { - seed = seed * 6364136223846793005uL + 1442695040888963407uL - return (seed shr 11).toDouble() / (1uL shl 53).toDouble() - } - return (0 until count).map { at -> - MarkerEntity( - marker = at, - state = - MarkerState( - position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), - id = "$at", - ), - ) - } - } - - /** Tile coordinates to a lat/lng box. Same formulas as the renderer. */ - private fun tileBounds( - x: Int, - y: Int, - z: Int, - ): GeoRectBounds { - val n = Math.pow(2.0, z.toDouble()) - - fun lon(tx: Double) = tx / n * 360.0 - 180.0 - - fun lat(ty: Double) = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * (ty / n)))) * 180.0 / Math.PI - return GeoRectBounds( - southWest = GeoPoint.fromLatLong(lat((y + 1).toDouble()), lon(x.toDouble())), - northEast = GeoPoint.fromLatLong(lat(y.toDouble()), lon((x + 1).toDouble())), - ) - } - - private data class Asked( - val expanded: GeoRectBounds, - val kept: List>, - val thinned: Boolean, - ) - - /** - * The box and separation the renderer actually asks with. - * - * At zooms where the separation is finer than the bottom level the index - * declines and the renderer falls back to the full query. That path has to - * hold the seam too, so when it declines this checks `inBounds` instead. - */ - private fun ask( - index: MarkerGridIndex, - x: Int, - y: Int, - z: Int, - ): Asked { - val bounds = tileBounds(x, y, z) - val southWest = bounds.southWest!! - val northEast = bounds.northEast!! - val latSpan = northEast.latitude - southWest.latitude - val lonSpan = northEast.longitude - southWest.longitude - val padNorm = halfExtentPx / tileSize - val expanded = - GeoRectBounds( - southWest = - GeoPoint.fromLatLong( - southWest.latitude - latSpan * padNorm, - southWest.longitude - lonSpan * padNorm, - ), - northEast = - GeoPoint.fromLatLong( - northEast.latitude + latSpan * padNorm, - northEast.longitude + lonSpan * padNorm, - ), - ) - val separation = maxOf(latSpan, lonSpan) * declutterPx / tileSize - val thinned = index.inBoundsThinned(expanded, separation) - return if (thinned != null) { - Asked(expanded, thinned, true) - } else { - Asked(expanded, index.inBounds(expanded), false) - } - } - - @Test - fun adjacentTilesAgreeOnTheMarkersTheyShare() { - val all = markers(144_183) - val index = MarkerGridIndex { all } - - var checked = 0 - var thinnedPairs = 0 - for (z in listOf(9, 10, 11, 12, 13, 14)) { - val n = Math.pow(2.0, z.toDouble()) - val x = ((139.75 + 180.0) / 360.0 * n).toInt() - val latRad = 35.68 * Math.PI / 180 - val y = ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() - - // Sideways and downwards. A seam runs in both directions. - for ((dx, dy) in listOf(1 to 0, 0 to 1)) { - val a = ask(index, x, y, z) - val b = ask(index, x + dx, y + dy, z) - // At low zoom the whole cloud fits on one tile and the - // neighbour is empty. Comparing against nothing proves nothing. - if (a.kept.isEmpty() || b.kept.isEmpty()) continue - checked++ - if (a.thinned) thinnedPairs++ - - val inB = b.kept.map { it.state.id }.toSet() - val missing = - a.kept.count { - b.expanded.contains(it.state.position) && it.state.id !in inB - } - assertEquals( - "z=$z d=($dx,$dy) thinned=${a.thinned}: tile A draws $missing markers " + - "that the overlapping tile B does not — the icon is cut at the seam", - 0, - missing, - ) - } - } - assertTrue("too few pairs were comparable", checked >= 8) - assertTrue("no pair exercised thinning", thinnedPairs >= 4) - } -} +package com.mapconductor.core.marker + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Neighbouring tiles must agree about the markers they share. + * + * Tiles are drawn one at a time. A marker drawn on tile A but not on tile B + * gets **cut** at the boundary: the left half appears on the left tile and the + * rest is nowhere. + * + * The renderer queries its own box grown by the icon overhang + * (`queryByHalfExtentPx`), so the property that keeps the seam intact is: + * a marker returned for A that also falls inside B's grown box must be + * returned for B. That is all this test looks at — it never draws anything. + * + * ios-sdk's `MarkerTileSeamTests` and react-sdk's `MarkerTileSeam.test.mjs` + * check the same thing. + */ +class MarkerTileSeamTest { + private val tileSize = 512.0 + + /** The sample's declutter. The disagreement shows up as a side effect of thinning. */ + private val declutterPx = 14.0 + + /** Half an icon. The renderer measures it; a fixed value is enough here. */ + private val halfExtentPx = 10.0 + + private fun markers(count: Int): List> { + var seed = 12345uL + + fun next(): Double { + seed = seed * 6364136223846793005uL + 1442695040888963407uL + return (seed shr 11).toDouble() / (1uL shl 53).toDouble() + } + return (0 until count).map { at -> + MarkerEntity( + marker = at, + state = + MarkerState( + position = GeoPoint.fromLatLong(35.5 + next() * 0.4, 139.5 + next() * 0.5), + id = "$at", + ), + ) + } + } + + /** Tile coordinates to a lat/lng box. Same formulas as the renderer. */ + private fun tileBounds( + x: Int, + y: Int, + z: Int, + ): GeoRectBounds { + val n = Math.pow(2.0, z.toDouble()) + + fun lon(tx: Double) = tx / n * 360.0 - 180.0 + + fun lat(ty: Double) = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * (ty / n)))) * 180.0 / Math.PI + return GeoRectBounds( + southWest = GeoPoint.fromLatLong(lat((y + 1).toDouble()), lon(x.toDouble())), + northEast = GeoPoint.fromLatLong(lat(y.toDouble()), lon((x + 1).toDouble())), + ) + } + + private data class Asked( + val expanded: GeoRectBounds, + val kept: List>, + val thinned: Boolean, + ) + + /** + * The box and separation the renderer actually asks with. + * + * At zooms where the separation is finer than the bottom level the index + * declines and the renderer falls back to the full query. That path has to + * hold the seam too, so when it declines this checks `inBounds` instead. + */ + private fun ask( + index: MarkerGridIndex, + x: Int, + y: Int, + z: Int, + ): Asked { + val bounds = tileBounds(x, y, z) + val southWest = bounds.southWest!! + val northEast = bounds.northEast!! + val latSpan = northEast.latitude - southWest.latitude + val lonSpan = northEast.longitude - southWest.longitude + val padNorm = halfExtentPx / tileSize + val expanded = + GeoRectBounds( + southWest = + GeoPoint.fromLatLong( + southWest.latitude - latSpan * padNorm, + southWest.longitude - lonSpan * padNorm, + ), + northEast = + GeoPoint.fromLatLong( + northEast.latitude + latSpan * padNorm, + northEast.longitude + lonSpan * padNorm, + ), + ) + val separation = maxOf(latSpan, lonSpan) * declutterPx / tileSize + val thinned = index.inBoundsThinned(expanded, separation) + return if (thinned != null) { + Asked(expanded, thinned, true) + } else { + Asked(expanded, index.inBounds(expanded), false) + } + } + + @Test + fun adjacentTilesAgreeOnTheMarkersTheyShare() { + val all = markers(144_183) + val index = MarkerGridIndex { all } + + var checked = 0 + var thinnedPairs = 0 + for (z in listOf(9, 10, 11, 12, 13, 14)) { + val n = Math.pow(2.0, z.toDouble()) + val x = ((139.75 + 180.0) / 360.0 * n).toInt() + val latRad = 35.68 * Math.PI / 180 + val y = ((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) / 2.0 * n).toInt() + + // Sideways and downwards. A seam runs in both directions. + for ((dx, dy) in listOf(1 to 0, 0 to 1)) { + val a = ask(index, x, y, z) + val b = ask(index, x + dx, y + dy, z) + // At low zoom the whole cloud fits on one tile and the + // neighbour is empty. Comparing against nothing proves nothing. + if (a.kept.isEmpty() || b.kept.isEmpty()) continue + checked++ + if (a.thinned) thinnedPairs++ + + val inB = b.kept.map { it.state.id }.toSet() + val missing = + a.kept.count { + b.expanded.contains(it.state.position) && it.state.id !in inB + } + assertEquals( + "z=$z d=($dx,$dy) thinned=${a.thinned}: tile A draws $missing markers " + + "that the overlapping tile B does not — the icon is cut at the seam", + 0, + missing, + ) + } + } + assertTrue("too few pairs were comparable", checked >= 8) + assertTrue("no pair exercised thinning", thinnedPairs >= 4) + } +} diff --git a/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt b/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt index f8cbcc8..366643f 100644 --- a/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt +++ b/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt @@ -1,89 +1,89 @@ -package com.mapconductor.core.projection - -import com.mapconductor.core.features.GeoPoint -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.File - -/** - * Web Mercator の適合テスト。 - * - * 同じ `projection-vectors.txt` を ios-sdk / react-sdk も読む。3 つが同じ答えを - * 出すことを担保するのが目的で、ここが見ているのはこのプラットフォームの分だけ。 - * 突き合わせは 3 つの出力に対して別途行う。 - * - * `zoom-golden.txt` とは役割が違う。あちらは移行前の値の記録(既知の不具合込み)。 - * こちらは現在の実装どうしが一致しているかを見る。 - */ -class ProjectionConformanceTest { - /** - * 桁を固定する。 - * - * 言語ごとの既定の数値表記は揃わない。Kotlin の `Double.toString()` は - * 2000 万台を `2.0037508342789E7` と書くが、JS と Swift は書かない。 - * f64 のノイズより粗く、実装差より細かい桁で切る。 - */ - private fun fixed(value: Double): String = - when { - value.isNaN() -> "nan" - value == Double.POSITIVE_INFINITY -> "inf" - value == Double.NEGATIVE_INFINITY -> "-inf" - else -> String.format(java.util.Locale.ROOT, "%.6f", value) - } - - private fun runVectors(): String { - val vectors = - requireNotNull(javaClass.classLoader?.getResourceAsStream("projection-vectors.txt")) { - "projection-vectors.txt が見つからない" - }.bufferedReader().readText() - - val out = StringBuilder() - for (line in vectors.lines()) { - val row = line.trim() - if (row.isEmpty() || row.startsWith("#")) continue - val (op, a, b) = row.split("|").let { Triple(it[0], it[1], it[2]) } - when (op) { - "project" -> { - // 実物の GeoPoint を使う。無名実装だと wrap() を握り潰して - // しまい、投影側の wrap が効いていないように見える。 - val p = WebMercator.project(GeoPoint(a.toDouble(), b.toDouble(), 0.0)) - out.append("project|$a|$b|${fixed(p.x)}|${fixed(p.y)}\n") - } - "unproject" -> { - val g = WebMercator.unproject(ProjectedPoint(a.toDouble(), b.toDouble())) - out.append("unproject|$a|$b|${fixed(g.latitude)}|${fixed(g.longitude)}\n") - } - else -> error("未知の演算: $op") - } - } - return out.toString() - } - - @Test - fun `ベクタを 1 行残らず処理できる`() { - val actual = runVectors() - - // 突き合わせに使う。テストの副産物ではなく、これが成果物。 - val destination = File("build/conformance/projection-actual.android.txt") - destination.parentFile?.mkdirs() - destination.writeText(actual) - - val rows = actual.trimEnd().lines() - assertTrue("ベクタが 1 行も処理されていない", rows.isNotEmpty()) - for (row in rows) { - assertEquals("列数が合わない: $row", 5, row.split("|").size) - } - } - - /** 正本が決まるまでは存在しない。3 プラットフォームの差を潰してから凍結する。 */ - @Test - fun `正本があれば完全一致する`() { - val expected = - javaClass.classLoader - ?.getResourceAsStream("projection-expected.txt") - ?.bufferedReader() - ?.readText() ?: return - assertEquals(expected, runVectors()) - } -} +package com.mapconductor.core.projection + +import com.mapconductor.core.features.GeoPoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Web Mercator の適合テスト。 + * + * 同じ `projection-vectors.txt` を ios-sdk / react-sdk も読む。3 つが同じ答えを + * 出すことを担保するのが目的で、ここが見ているのはこのプラットフォームの分だけ。 + * 突き合わせは 3 つの出力に対して別途行う。 + * + * `zoom-golden.txt` とは役割が違う。あちらは移行前の値の記録(既知の不具合込み)。 + * こちらは現在の実装どうしが一致しているかを見る。 + */ +class ProjectionConformanceTest { + /** + * 桁を固定する。 + * + * 言語ごとの既定の数値表記は揃わない。Kotlin の `Double.toString()` は + * 2000 万台を `2.0037508342789E7` と書くが、JS と Swift は書かない。 + * f64 のノイズより粗く、実装差より細かい桁で切る。 + */ + private fun fixed(value: Double): String = + when { + value.isNaN() -> "nan" + value == Double.POSITIVE_INFINITY -> "inf" + value == Double.NEGATIVE_INFINITY -> "-inf" + else -> String.format(java.util.Locale.ROOT, "%.6f", value) + } + + private fun runVectors(): String { + val vectors = + requireNotNull(javaClass.classLoader?.getResourceAsStream("projection-vectors.txt")) { + "projection-vectors.txt が見つからない" + }.bufferedReader().readText() + + val out = StringBuilder() + for (line in vectors.lines()) { + val row = line.trim() + if (row.isEmpty() || row.startsWith("#")) continue + val (op, a, b) = row.split("|").let { Triple(it[0], it[1], it[2]) } + when (op) { + "project" -> { + // 実物の GeoPoint を使う。無名実装だと wrap() を握り潰して + // しまい、投影側の wrap が効いていないように見える。 + val p = WebMercator.project(GeoPoint(a.toDouble(), b.toDouble(), 0.0)) + out.append("project|$a|$b|${fixed(p.x)}|${fixed(p.y)}\n") + } + "unproject" -> { + val g = WebMercator.unproject(ProjectedPoint(a.toDouble(), b.toDouble())) + out.append("unproject|$a|$b|${fixed(g.latitude)}|${fixed(g.longitude)}\n") + } + else -> error("未知の演算: $op") + } + } + return out.toString() + } + + @Test + fun `ベクタを 1 行残らず処理できる`() { + val actual = runVectors() + + // 突き合わせに使う。テストの副産物ではなく、これが成果物。 + val destination = File("build/conformance/projection-actual.android.txt") + destination.parentFile?.mkdirs() + destination.writeText(actual) + + val rows = actual.trimEnd().lines() + assertTrue("ベクタが 1 行も処理されていない", rows.isNotEmpty()) + for (row in rows) { + assertEquals("列数が合わない: $row", 5, row.split("|").size) + } + } + + /** 正本が決まるまでは存在しない。3 プラットフォームの差を潰してから凍結する。 */ + @Test + fun `正本があれば完全一致する`() { + val expected = + javaClass.classLoader + ?.getResourceAsStream("projection-expected.txt") + ?.bufferedReader() + ?.readText() ?: return + assertEquals(expected, runVectors()) + } +}