diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 59298c9..75328a3 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; @@ -2585,6 +2781,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; @@ -2696,6 +2896,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/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..4292277 100644 --- a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt +++ b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileRenderBenchmark.kt @@ -22,6 +22,7 @@ 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() @@ -52,10 +53,11 @@ class MarkerTileRenderBenchmark { // 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() - } + 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) { diff --git a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt index 29a64f9..431f1f0 100644 --- a/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/marker/MarkerTileSeamDeviceTest.kt @@ -31,6 +31,7 @@ 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() @@ -53,6 +54,49 @@ 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 = @@ -70,10 +114,11 @@ class MarkerTileSeamDeviceTest { 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() - } + 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)) @@ -91,22 +136,34 @@ class MarkerTileSeamDeviceTest { 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 (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") + 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 (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 broken(horizontal) z=$z x=${centerX + dx} y=${centerY + dy}/${centerY + dy + 1} worstRun=$worst") } } } diff --git a/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt b/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt index 109b274..a03779a 100644 --- a/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt +++ b/src/androidTest/java/com/mapconductor/core/tileserver/TilePngEncoderTest.kt @@ -1,19 +1,19 @@ 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 +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. @@ -25,7 +25,6 @@ import org.junit.runner.RunWith */ @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. @@ -90,11 +89,12 @@ class TilePngEncoderTest { // 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)), - ) + 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) } @@ -191,12 +191,13 @@ class TilePngEncoderTest { 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, - ) + 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 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..0abe2b5 100644 --- a/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt +++ b/src/main/java/com/mapconductor/core/geocell/HexCellRegistry.kt @@ -1,6 +1,5 @@ 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 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/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) 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 } /** 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. diff --git a/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt b/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt index 243d0df..7e65bb0 100644 --- a/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt +++ b/src/test/java/com/mapconductor/core/geocell/KDTreeRadiusTest.kt @@ -1,7 +1,7 @@ package com.mapconductor.core.geocell -import com.mapconductor.core.projection.ProjectedPoint import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.projection.ProjectedPoint import org.junit.Assert.assertEquals import org.junit.Test 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..a87fcf4 100644 --- a/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt +++ b/src/test/java/com/mapconductor/core/marker/MarkerGridIndexTest.kt @@ -140,6 +140,7 @@ class MarkerGridIndexTest { // 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, diff --git a/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt b/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt index 19f8cec..141b0ac 100644 --- a/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt +++ b/src/test/java/com/mapconductor/core/marker/MarkerTileSeamTest.kt @@ -32,6 +32,7 @@ class MarkerTileSeamTest { 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() @@ -55,7 +56,9 @@ class MarkerTileSeamTest { 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())), diff --git a/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt b/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt index 038cf8c..366643f 100644 --- a/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt +++ b/src/test/java/com/mapconductor/core/projection/ProjectionConformanceTest.kt @@ -1,10 +1,10 @@ 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 +import java.io.File /** * Web Mercator の適合テスト。 @@ -80,8 +80,10 @@ class ProjectionConformanceTest { @Test fun `正本があれば完全一致する`() { val expected = - javaClass.classLoader?.getResourceAsStream("projection-expected.txt") - ?.bufferedReader()?.readText() ?: return + javaClass.classLoader + ?.getResourceAsStream("projection-expected.txt") + ?.bufferedReader() + ?.readText() ?: return assertEquals(expected, runVectors()) } }