From 91bd1f26bec6606242cf35ecf636607f0f553940 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 08:35:35 -0800 Subject: [PATCH 1/6] Add onAdded to every overlay state Marker, Circle, Polyline, Polygon, GroundImage and RasterLayer states take an optional onAdded, called on the main thread each time the overlay goes from not on the map to on it: after the provider made its native object, or, for tiled overlays, after the tile template was handed to the map as a raster layer (tile images may still be loading). In-place updates, moves between native drawing and tiles, clear and destroy call nothing; remove then add calls again. A throwing callback is logged and does not stop the others. It is kept by each overlay (OverlayAddedTracker), which asks the map hasXxx after compositionXxx returns, so every provider is covered, including those that add without the core controllers. Old constructors and copy() stay as hidden overloads for binary compatibility. Same as ios-sdk-core and js-sdk-core. --- api/android-sdk-core.api | 68 ++- .../core/OverlayOnAddedDeviceTest.kt | 182 ++++++++ .../mapconductor/core/OverlayAddedTracker.kt | 93 ++++ .../com/mapconductor/core/circle/Circle.kt | 86 ++++ .../mapconductor/core/circle/CircleOverlay.kt | 9 +- .../core/controller/BaseMapViewController.kt | 9 +- .../core/groundimage/GroundImage.kt | 93 ++++ .../core/groundimage/GroundImageOverlay.kt | 9 +- .../com/mapconductor/core/marker/Marker.kt | 89 ++++ .../mapconductor/core/marker/MarkerOverlay.kt | 11 +- .../com/mapconductor/core/polygon/Polygon.kt | 80 ++++ .../core/polygon/PolygonOverlay.kt | 9 +- .../mapconductor/core/polyline/Polyline.kt | 72 +++ .../core/polyline/PolylineOverlay.kt | 9 +- .../mapconductor/core/raster/RasterLayer.kt | 68 +++ .../core/raster/RasterLayerOverlay.kt | 9 +- .../mapconductor/core/OverlayOnAddedTest.kt | 413 ++++++++++++++++++ 17 files changed, 1281 insertions(+), 28 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/core/OverlayOnAddedDeviceTest.kt create mode 100644 src/main/java/com/mapconductor/core/OverlayAddedTracker.kt create mode 100644 src/test/java/com/mapconductor/core/OverlayOnAddedTest.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 95c91b8..ba41bd2 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -202,7 +202,7 @@ public interface com.mapconductor.core.circle.CircleOverlayRendererInterface$Cha public abstract com.mapconductor.core.circle.CircleEntityInterface getPrev(); } public final class com.mapconductor.core.circle.CircleState implements com.mapconductor.core.ComponentState { - public com.mapconductor.core.circle.CircleState(com.mapconductor.core.features.GeoPointInterface, double, boolean, boolean, long, float, long, java.lang.String, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.circle.CircleState(com.mapconductor.core.features.GeoPointInterface, double, boolean, boolean, long, float, long, java.lang.String, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.core.StateMutationSignal getMutations(); public final com.mapconductor.core.features.GeoPointInterface getCenter(); public final void setCenter(com.mapconductor.core.features.GeoPointInterface); @@ -224,11 +224,16 @@ public final class com.mapconductor.core.circle.CircleState implements com.mapco public final void setZIndex(java.lang.Integer); public final kotlin.jvm.functions.Function1 getOnClick(); public final void setOnClick(kotlin.jvm.functions.Function1); + public final kotlin.jvm.functions.Function1 getOnAdded(); + public final void setOnAdded(kotlin.jvm.functions.Function1); + public com.mapconductor.core.circle.CircleState(com.mapconductor.core.features.GeoPointInterface, double, boolean, boolean, long, float, long, java.lang.String, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public java.lang.String getId(); public final com.mapconductor.core.circle.CircleFingerPrint fingerPrint(); - public final com.mapconductor.core.circle.CircleState copy-H0InVkU(com.mapconductor.core.features.GeoPointInterface, double, boolean, boolean, long, float, long, java.lang.String, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.circle.CircleState copy-Zc45R8w(com.mapconductor.core.features.GeoPointInterface, double, boolean, boolean, long, float, long, java.lang.String, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.circle.CircleState copy-H0InVkU(com.mapconductor.core.features.GeoPointInterface, double, boolean, boolean, long, float, long, java.lang.String, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1); public boolean equals(java.lang.Object); public int hashCode(); + public com.mapconductor.core.circle.CircleState(com.mapconductor.core.features.GeoPointInterface, double, boolean, boolean, long, float, long, java.lang.String, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.core.circle.CircleState(com.mapconductor.core.features.GeoPointInterface, double, boolean, boolean, long, float, long, java.lang.String, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); } public abstract class com.mapconductor.core.controller.BaseMapViewController implements com.mapconductor.core.controller.MapViewControllerInterface,com.mapconductor.core.marker.MarkerCapableInterface,com.mapconductor.core.polyline.PolylineCapableInterface,com.mapconductor.core.polygon.PolygonCapableInterface,com.mapconductor.core.circle.CircleCapableInterface,com.mapconductor.core.groundimage.GroundImageCapableInterface,com.mapconductor.core.raster.RasterLayerCapableInterface { @@ -650,8 +655,8 @@ public interface com.mapconductor.core.groundimage.GroundImageOverlayRendererInt public abstract com.mapconductor.core.groundimage.GroundImageEntityInterface getPrev(); } public final class com.mapconductor.core.groundimage.GroundImageState implements com.mapconductor.core.ComponentState { - public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); - public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public java.lang.String getId(); public com.mapconductor.core.StateMutationSignal getMutations(); public final com.mapconductor.core.features.GeoRectBounds getBounds(); @@ -670,10 +675,17 @@ public final class com.mapconductor.core.groundimage.GroundImageState implements public final void setExtra(java.io.Serializable); public final kotlin.jvm.functions.Function1 getOnClick(); public final void setOnClick(kotlin.jvm.functions.Function1); - public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); + public final kotlin.jvm.functions.Function1 getOnAdded(); + public final void setOnAdded(kotlin.jvm.functions.Function1); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); public com.mapconductor.core.groundimage.GroundImageState(com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public final com.mapconductor.core.groundimage.GroundImageFingerPrint fingerPrint(); - public final com.mapconductor.core.groundimage.GroundImageState copy(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.groundimage.GroundImageState copy(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.groundimage.GroundImageState copy(com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, boolean, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1); public boolean equals(java.lang.Object); public int hashCode(); } @@ -1650,11 +1662,11 @@ public final class com.mapconductor.core.marker.MarkerCapableInterface$DefaultIm } public final class com.mapconductor.core.marker.MarkerCollector implements com.mapconductor.core.OverlayCollectorInterface { public com.mapconductor.core.marker.MarkerCollector(long, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); - public kotlinx.coroutines.flow.MutableStateFlow> getFlow(); public java.lang.Object add(com.mapconductor.core.marker.MarkerState, kotlin.coroutines.Continuation); public void remove(java.lang.String); public void setUpdateHandler(kotlin.jvm.functions.Function2, ? extends java.lang.Object>); public void replaceAll(java.util.List); + public kotlinx.coroutines.flow.MutableStateFlow> getFlow(); public java.lang.Object add(com.mapconductor.core.ComponentState, kotlin.coroutines.Continuation); public com.mapconductor.core.marker.MarkerCollector(long, kotlinx.coroutines.CoroutineScope, kotlin.jvm.internal.DefaultConstructorMarker); } @@ -1832,8 +1844,8 @@ public final class com.mapconductor.core.marker.MarkerRenderingSupportKey implem public com.mapconductor.core.map.MapCapability getCapability(); } public final class com.mapconductor.core.marker.MarkerState implements com.mapconductor.core.ComponentState { - public com.mapconductor.core.marker.MarkerState(com.mapconductor.core.features.GeoPointInterface, java.lang.String, java.io.Serializable, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.lang.Integer, boolean, boolean, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); - public com.mapconductor.core.marker.MarkerState(com.mapconductor.core.features.GeoPointInterface, java.lang.String, java.io.Serializable, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.lang.Integer, boolean, boolean, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.MarkerState(com.mapconductor.core.features.GeoPointInterface, java.lang.String, java.io.Serializable, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.lang.Integer, boolean, boolean, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public com.mapconductor.core.marker.MarkerState(com.mapconductor.core.features.GeoPointInterface, java.lang.String, java.io.Serializable, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.lang.Integer, boolean, boolean, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public final java.io.Serializable getExtra(); public final void setExtra(java.io.Serializable); public java.lang.String getId(); @@ -1856,13 +1868,18 @@ public final class com.mapconductor.core.marker.MarkerState implements com.mapco public final void setOnAnimateStart(kotlin.jvm.functions.Function1); public final kotlin.jvm.functions.Function1 getOnAnimateEnd(); public final void setOnAnimateEnd(kotlin.jvm.functions.Function1); + public final kotlin.jvm.functions.Function1 getOnAdded(); + public final void setOnAdded(kotlin.jvm.functions.Function1); public final java.lang.Integer getZIndex(); public final void setZIndex(java.lang.Integer); + public com.mapconductor.core.marker.MarkerState(com.mapconductor.core.features.GeoPointInterface, java.lang.String, java.io.Serializable, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.lang.Integer, boolean, boolean, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public com.mapconductor.core.marker.MarkerState(com.mapconductor.core.features.GeoPointInterface, java.lang.String, java.io.Serializable, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.lang.Integer, boolean, boolean, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public final void animate(com.mapconductor.core.marker.MarkerAnimation); public final com.mapconductor.core.marker.MarkerAnimation getAnimation(); public final com.mapconductor.core.features.GeoPointInterface getPosition(); public final void setPosition(com.mapconductor.core.features.GeoPointInterface); - public final com.mapconductor.core.marker.MarkerState copy(java.lang.String, com.mapconductor.core.features.GeoPointInterface, java.io.Serializable, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.lang.Integer, java.lang.Boolean, java.lang.Boolean, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.marker.MarkerState copy(java.lang.String, com.mapconductor.core.features.GeoPointInterface, java.io.Serializable, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.lang.Integer, java.lang.Boolean, java.lang.Boolean, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.marker.MarkerState copy(java.lang.String, com.mapconductor.core.features.GeoPointInterface, java.io.Serializable, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.lang.Integer, java.lang.Boolean, java.lang.Boolean, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); public boolean equals(java.lang.Object); public int hashCode(); public final com.mapconductor.core.marker.MarkerFingerPrint fingerPrint(); @@ -2251,7 +2268,7 @@ public interface com.mapconductor.core.polygon.PolygonOverlayRendererInterface$C public abstract com.mapconductor.core.polygon.PolygonEntityInterface getPrev(); } public final class com.mapconductor.core.polygon.PolygonState implements com.mapconductor.core.ComponentState { - public com.mapconductor.core.polygon.PolygonState(java.util.List, java.util.List, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.polygon.PolygonState(java.util.List, java.util.List, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public java.lang.String getId(); public com.mapconductor.core.StateMutationSignal getMutations(); public final long getStrokeColor-0d7_KjU(); @@ -2274,10 +2291,15 @@ public final class com.mapconductor.core.polygon.PolygonState implements com.map public final void setExtra(java.io.Serializable); public final kotlin.jvm.functions.Function1 getOnClick(); public final void setOnClick(kotlin.jvm.functions.Function1); + public final kotlin.jvm.functions.Function1 getOnAdded(); + public final void setOnAdded(kotlin.jvm.functions.Function1); + public com.mapconductor.core.polygon.PolygonState(java.util.List, java.util.List, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public boolean equals(java.lang.Object); public int hashCode(); public final com.mapconductor.core.polygon.PolygonFingerPrint fingerPrint(); - public final com.mapconductor.core.polygon.PolygonState copy-lqbfp2Q(java.util.List, java.util.List>, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.polygon.PolygonState copy-OuNi0Z8(java.util.List, java.util.List>, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.polygon.PolygonState copy-lqbfp2Q(java.util.List, java.util.List, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1); + public com.mapconductor.core.polygon.PolygonState(java.util.List, java.util.List, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.core.polygon.PolygonState(java.util.List, java.util.List, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); } public final class com.mapconductor.core.polygon.PolygonTileGeometryKt { @@ -2351,8 +2373,8 @@ public final class com.mapconductor.core.polygon.PolygonTiler { public final java.util.List tiledStates(); public final boolean isTiled(java.lang.String); public final void update(com.mapconductor.core.polygon.PolygonState); - public final java.lang.Object clear(kotlin.coroutines.Continuation); public final void dispose(); + public final java.lang.Object clear(kotlin.coroutines.Continuation); public com.mapconductor.core.polygon.PolygonTiler(); } public final class com.mapconductor.core.polygon.PolygonTilerKt { @@ -2587,7 +2609,7 @@ public interface com.mapconductor.core.polyline.PolylineOverlayRendererInterface public abstract com.mapconductor.core.polyline.PolylineEntityInterface getPrev(); } public final class com.mapconductor.core.polyline.PolylineState implements com.mapconductor.core.ComponentState { - public com.mapconductor.core.polyline.PolylineState(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.polyline.PolylineState(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public java.lang.String getId(); public com.mapconductor.core.StateMutationSignal getMutations(); public final long getStrokeColor-0d7_KjU(); @@ -2606,10 +2628,15 @@ public final class com.mapconductor.core.polyline.PolylineState implements com.m public final void setExtra(java.io.Serializable); public final kotlin.jvm.functions.Function1 getOnClick(); public final void setOnClick(kotlin.jvm.functions.Function1); + public final kotlin.jvm.functions.Function1 getOnAdded(); + public final void setOnAdded(kotlin.jvm.functions.Function1); + public com.mapconductor.core.polyline.PolylineState(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public boolean equals(java.lang.Object); public int hashCode(); - public final com.mapconductor.core.polyline.PolylineState copy-uKGX-YA(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.polyline.PolylineState copy-jb-40ds(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.polyline.PolylineState copy-uKGX-YA(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1); public final com.mapconductor.core.polyline.PolylineFingerPrint fingerPrint(); + public com.mapconductor.core.polyline.PolylineState(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.core.polyline.PolylineState(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); } public final class com.mapconductor.core.projection.Earth { @@ -2862,8 +2889,8 @@ public final class com.mapconductor.core.raster.RasterLayerSource$UrlTemplate ex public final class com.mapconductor.core.raster.RasterLayerState implements com.mapconductor.core.ComponentState { public static final com.mapconductor.core.raster.RasterLayerState$Companion Companion; public static final java.lang.String DEFAULT_USER_AGENT; - public com.mapconductor.core.raster.RasterLayerState(com.mapconductor.core.raster.RasterLayerSource, float, boolean, int, java.lang.String, boolean, java.lang.String, java.util.Map); - public com.mapconductor.core.raster.RasterLayerState(com.mapconductor.core.raster.RasterLayerSource, float, boolean, int, java.lang.String, boolean, java.lang.String, java.util.Map, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.raster.RasterLayerState(com.mapconductor.core.raster.RasterLayerSource, float, boolean, int, java.lang.String, boolean, java.lang.String, java.util.Map, kotlin.jvm.functions.Function1); + public com.mapconductor.core.raster.RasterLayerState(com.mapconductor.core.raster.RasterLayerSource, float, boolean, int, java.lang.String, boolean, java.lang.String, java.util.Map, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); public java.lang.String getId(); public com.mapconductor.core.StateMutationSignal getMutations(); public final com.mapconductor.core.raster.RasterLayerSource getSource(); @@ -2880,9 +2907,14 @@ public final class com.mapconductor.core.raster.RasterLayerState implements com. public final void setDebug(boolean); public final java.util.Map getExtraHeaders(); public final void setExtraHeaders(java.util.Map); + public final kotlin.jvm.functions.Function1 getOnAdded(); + public final void setOnAdded(kotlin.jvm.functions.Function1); + public com.mapconductor.core.raster.RasterLayerState(com.mapconductor.core.raster.RasterLayerSource, float, boolean, int, java.lang.String, boolean, java.lang.String, java.util.Map); + public com.mapconductor.core.raster.RasterLayerState(com.mapconductor.core.raster.RasterLayerSource, float, boolean, int, java.lang.String, boolean, java.lang.String, java.util.Map, int, kotlin.jvm.internal.DefaultConstructorMarker); public boolean equals(java.lang.Object); public int hashCode(); - public final com.mapconductor.core.raster.RasterLayerState copy(com.mapconductor.core.raster.RasterLayerSource, float, boolean, int, boolean, java.lang.String, java.lang.String, java.util.Map); + public final com.mapconductor.core.raster.RasterLayerState copy(com.mapconductor.core.raster.RasterLayerSource, float, boolean, int, boolean, java.lang.String, java.lang.String, java.util.Map, kotlin.jvm.functions.Function1); + public final com.mapconductor.core.raster.RasterLayerState copy(com.mapconductor.core.raster.RasterLayerSource, float, boolean, int, boolean, java.lang.String, java.lang.String, java.util.Map); public final com.mapconductor.core.raster.RasterLayerFingerPrint fingerPrint(); } public final class com.mapconductor.core.raster.RasterLayerState$Companion { diff --git a/src/androidTest/java/com/mapconductor/core/OverlayOnAddedDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/OverlayOnAddedDeviceTest.kt new file mode 100644 index 0000000..7c31ac3 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/OverlayOnAddedDeviceTest.kt @@ -0,0 +1,182 @@ +package com.mapconductor.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.controller.MapViewControllerInterface +import com.mapconductor.core.controller.OverlayControllerInterface +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapViewHolderInterface +import com.mapconductor.core.polygon.OnPolygonEventHandler +import com.mapconductor.core.polygon.PolygonCapableInterface +import com.mapconductor.core.polygon.PolygonController +import com.mapconductor.core.polygon.PolygonEntityInterface +import com.mapconductor.core.polygon.PolygonManager +import com.mapconductor.core.polygon.PolygonOverlay +import com.mapconductor.core.polygon.PolygonOverlayRendererInterface +import com.mapconductor.core.polygon.PolygonOverlayRendererInterface.AddParamsInterface +import com.mapconductor.core.polygon.PolygonOverlayRendererInterface.ChangeParamsInterface +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polygon.PolygonTiler +import com.mapconductor.core.polygon.PolygonTilingOptions +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * `onAdded` on the tile path: the real core PolygonController with a real + * PolygonTiler (its renderer needs android.util.LruCache, hence a device + * test). The native path and the rest are in the JVM OverlayOnAddedTest. + */ +@RunWith(AndroidJUnit4::class) +class OverlayOnAddedDeviceTest { + private val events = mutableListOf() + + private inner class FakeRenderer : PolygonOverlayRendererInterface { + override suspend fun onAdd(data: List): List = + data.map { + events += "native:${it.state.id}" + "native-${it.state.id}" + } + + override suspend fun onChange(data: List>): List = data.map { it.prev.polygon } + + override suspend fun onRemove(data: List>) { + data.forEach { events += "remove:${it.state.id}" } + } + + override suspend fun onPostProcess() = Unit + } + + private inner class FakeMap( + tiling: PolygonTilingOptions, + ) : MapViewControllerInterface, + PolygonCapableInterface { + val polygons = object : PolygonController(PolygonManager(), FakeRenderer()) {} + + init { + polygons.tiler = + PolygonTiler(tiling).also { tiler -> + // What a provider does: upsert the raster layer, or remove it. + tiler.onTemplate = { template, _ -> events += if (template != null) "upsert" else "removeLayer" } + } + } + + override suspend fun compositionPolygons(data: List) = polygons.add(data) + + override suspend fun updatePolygon(state: PolygonState) = polygons.update(state) + + @Deprecated("Use PolygonState.onClick instead.") + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) = Unit + + override fun hasPolygon(state: PolygonState): Boolean = polygons.has(state.id) + + override val holder: MapViewHolderInterface<*, *> get() = error("not used") + + override suspend fun clearOverlays() = polygons.clear() + + override fun moveCamera(position: MapCameraPosition) = Unit + + override fun animateCamera( + position: MapCameraPosition, + duration: Long, + ) = Unit + + override fun fitBounds( + bounds: GeoRectBounds, + padding: Int, + ) = Unit + + override fun registerOverlayController(controller: OverlayControllerInterface<*, *>) = Unit + + override fun destroy() = polygons.destroy() + } + + private val maps = mutableListOf() + + @After + fun tearDown() = maps.forEach { it.destroy() } + + private fun tiling(minPolygonCount: Int) = PolygonTilingOptions(minPolygonCount = minPolygonCount) + + private fun map(minPolygonCount: Int) = FakeMap(tiling(minPolygonCount)).also { maps += it } + + private val recorder: (PolygonState) -> Unit = { events += "onAdded:${it.id}" } + + private fun square(i: Int): PolygonState { + val lat = (i / 100) * 0.01 + val lng = (i % 100) * 0.01 + val points: List = + listOf( + GeoPoint.fromLatLong(lat, lng), + GeoPoint.fromLatLong(lat, lng + 0.005), + GeoPoint.fromLatLong(lat + 0.005, lng + 0.005), + GeoPoint.fromLatLong(lat + 0.005, lng), + ) + return PolygonState(points = points, id = "p$i", onAdded = recorder) + } + + private fun PolygonOverlay.show( + map: FakeMap, + states: List, + ) = runBlocking { render(states.associateBy { it.id }.toMutableMap(), map) } + + private fun overlay() = PolygonOverlay(MutableStateFlow(mutableMapOf())) + + @Test + fun calledAfterTheTileTemplateWasHandedToTheMap() { + val map = map(minPolygonCount = 5) + val states = List(5) { square(it) } + + overlay().show(map, states) + + assertEquals("upsert", events.first()) + assertEquals(states.map { "onAdded:${it.id}" }, events.drop(1)) + assertTrue("nothing drawn natively", events.none { it.startsWith("native:") }) + } + + @Test + fun anInPlaceChangeOfATiledPolygonCallsNothing() { + val map = map(minPolygonCount = 2) + val overlay = overlay() + val states = List(2) { square(it) } + overlay.show(map, states) + events.clear() + + states[0].fillColor = androidx.compose.ui.graphics.Color.Red + runBlocking { map.updatePolygon(states[0]) } + overlay.show(map, states) + + assertTrue(events.toString(), events.none { it.startsWith("onAdded") }) + } + + @Test + fun movingBetweenNativeAndTilesWhileOnTheMapCallsNothing() { + val map = map(minPolygonCount = 4) + val overlay = overlay() + val first = List(3) { square(it) } + overlay.show(map, first) + assertEquals(listOf("onAdded:p0", "onAdded:p1", "onAdded:p2"), events.filter { it.startsWith("onAdded") }) + events.clear() + + // A fourth polygon tips the set into tiles: only the new one is added. + overlay.show(map, first + square(3)) + assertEquals(listOf("upsert", "remove:p0", "remove:p1", "remove:p2", "onAdded:p3"), events) + events.clear() + + // Back to native drawing: p0..p2 were on the map all along. + overlay.show(map, first) + assertEquals(listOf("removeLayer", "native:p0", "native:p1", "native:p2"), events) + events.clear() + + // p3 left; adding it again (into tiles) calls it again. + overlay.show(map, first + square(3)) + assertEquals("onAdded:p3", events.last()) + assertEquals(1, events.count { it.startsWith("onAdded") }) + } +} diff --git a/src/main/java/com/mapconductor/core/OverlayAddedTracker.kt b/src/main/java/com/mapconductor/core/OverlayAddedTracker.kt new file mode 100644 index 0000000..4090a60 --- /dev/null +++ b/src/main/java/com/mapconductor/core/OverlayAddedTracker.kt @@ -0,0 +1,93 @@ +package com.mapconductor.core + +import android.util.Log +import kotlinx.coroutines.yield + +/** + * Calls each state's `onAdded` once the map has it. + * + * One per overlay type per map, kept by the overlay that hands that type's + * states to the map ([com.mapconductor.core.polygon.PolygonOverlay] and the + * others). After every hand-over -- `compositionXxx` has returned, so the + * provider has made its objects or handed the tile template to the map -- it + * asks the map which of the states it does not yet count as "on the map" now + * are (`hasXxx`) and calls `onAdded` on those. + * + * It lives here rather than in each overlay controller because that is the one + * place every provider's overlays pass through: several providers add markers + * or polygons through their own controllers (MapLibre / Mapbox / Mappls + * polygon conductors, the per-provider marker controllers with tiling, Longdo + * and MapTiler's Compose-drawn markers), and `hasXxx` is already how each one + * says what it has. + * + * - An in-place change never reaches here (it goes to `updateXxx`), so it + * calls nothing. Neither does a move between native drawing and tiles: the + * id stays on the map. + * - An id that leaves the list is forgotten, so adding it again calls again. + * - Clearing or destroying the map calls nothing. A different controller (the + * map was recreated) starts from nothing. + * + * Not thread-safe: the overlays call it from the composition's coroutine, + * i.e. the main thread, and that is where the callbacks run. Asking the map + * about 1e5 states takes a couple of hundred ms there, so the asking yields + * every [CHECKS_PER_YIELD] states to let frames through. If the composition + * moves on meanwhile (the coroutine is cancelled), nothing is recorded or + * called; the next hand-over asks again. + */ +@InternalMapConductorApi +internal class OverlayAddedTracker( + private val onAddedOf: (T) -> ((T) -> Unit)?, +) { + private var controller: Any? = null + private var onMap: Set = emptySet() + + /** + * [data] has just been handed to [controller]. Calls `onAdded` on the + * states [isOnMap] now reports that were not on the map before. + */ + suspend fun synced( + controller: Any, + data: Collection, + isOnMap: (T) -> Boolean, + ) { + if (controller !== this.controller) { + this.controller = controller + onMap = emptySet() + } + val previous = onMap + val next = HashSet(data.size * 2) + var added: MutableList? = null + var checked = 0 + for (state in data) { + if (++checked % CHECKS_PER_YIELD == 0) yield() + val id = state.id + if (id in previous) { + next.add(id) + } else if (isOnMap(state)) { + next.add(id) + (added ?: ArrayList().also { added = it }).add(state) + } + } + // Recorded and called without suspending, so a cancellation cannot + // leave some of them called and the rest forgotten. + onMap = next + added?.let(::notify) + } + + private fun notify(states: List) { + for (state in states) { + val callback = onAddedOf(state) ?: continue + try { + callback(state) + } catch (e: Exception) { + // One app callback that throws must not keep the others from running. + runCatching { Log.e("MapConductor", "onAdded of ${state.id} threw", e) } + } + } + } + + @InternalMapConductorApi + private companion object { + const val CHECKS_PER_YIELD = 4096 + } +} diff --git a/src/main/java/com/mapconductor/core/circle/Circle.kt b/src/main/java/com/mapconductor/core/circle/Circle.kt index b7c8265..7bcb354 100644 --- a/src/main/java/com/mapconductor/core/circle/Circle.kt +++ b/src/main/java/com/mapconductor/core/circle/Circle.kt @@ -28,6 +28,7 @@ class CircleState( zIndex: Int? = null, extra: Serializable? = null, onClick: OnCircleEventHandler? = null, + onAdded: OnCircleAddedHandler? = null, ) : ComponentState { /** * Writes to the fields below are announced here rather than discovered by @@ -46,6 +47,57 @@ class CircleState( var zIndex by mutations.notifying(zIndex) var onClick by mutations.notifying(onClick) + /** + * Called once each time this circle goes from not being on the map to being on it: + * after the map SDK's object for it has been created, or, when it is drawn in tiles, + * after the URL template of those tiles has been handed to the map as a raster layer + * (the tile images may still be loading then). Not called for an in-place change, + * nor when it moves between native drawing and tiles while staying on the map; + * called again if it is removed and added again. Clearing or destroying the map calls + * nothing. + * + * Runs on the main thread once the map has finished adding the overlays it was given. + * A callback that throws is logged and does not stop the other overlays' callbacks. + * + * Like `onClick`, it is not part of the id, fingerprint, equality or hash: replacing + * it redraws nothing. If the same state is shown on two maps, each map calls it. + */ + var onAdded: OnCircleAddedHandler? = onAdded + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + constructor( + center: GeoPointInterface, + radiusMeters: Double, + geodesic: Boolean = true, + clickable: Boolean = true, + strokeColor: Color = Color.Red, + strokeWidth: Dp = 1.dp, + fillColor: Color = + Color( + red = 255, + green = 255, + blue = 255, + alpha = 127, + ), + id: String? = null, + zIndex: Int? = null, + extra: Serializable? = null, + onClick: OnCircleEventHandler? = null, + ) : this( + center, + radiusMeters, + geodesic, + clickable, + strokeColor, + strokeWidth, + fillColor, + id, + zIndex, + extra, + onClick, + null, + ) + override val id = ( id ?: circleId( @@ -94,6 +146,7 @@ class CircleState( zIndex: Int? = this.zIndex, extra: Serializable? = this.extra, onClick: OnCircleEventHandler? = this.onClick, + onAdded: OnCircleAddedHandler? = this.onAdded, ): CircleState = CircleState( center = center, @@ -107,6 +160,36 @@ class CircleState( zIndex = zIndex, extra = extra, onClick = onClick, + onAdded = onAdded, + ) + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + fun copy( + center: GeoPointInterface = this.center, + radiusMeters: Double = this.radiusMeters, + geodesic: Boolean = this.geodesic, + clickable: Boolean = this.clickable, + strokeColor: Color = this.strokeColor, + strokeWidth: Dp = this.strokeWidth, + fillColor: Color = this.fillColor, + id: String? = this.id, + zIndex: Int? = this.zIndex, + extra: Serializable? = this.extra, + onClick: OnCircleEventHandler? = this.onClick, + ): CircleState = + copy( + center, + radiusMeters, + geodesic, + clickable, + strokeColor, + strokeWidth, + fillColor, + id, + zIndex, + extra, + onClick, + onAdded, ) override fun equals(other: Any?): Boolean { @@ -151,3 +234,6 @@ class CircleEvent( } typealias OnCircleEventHandler = (CircleEvent) -> Unit + +/** Receives the state whose circle was just added to the map. See [CircleState.onAdded]. */ +typealias OnCircleAddedHandler = (CircleState) -> Unit diff --git a/src/main/java/com/mapconductor/core/circle/CircleOverlay.kt b/src/main/java/com/mapconductor/core/circle/CircleOverlay.kt index 4afb457..53d736f 100644 --- a/src/main/java/com/mapconductor/core/circle/CircleOverlay.kt +++ b/src/main/java/com/mapconductor/core/circle/CircleOverlay.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.circle +import com.mapconductor.core.OverlayAddedTracker import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.map.MapOverlayInterface import kotlinx.coroutines.flow.StateFlow @@ -7,10 +8,16 @@ import kotlinx.coroutines.flow.StateFlow class CircleOverlay( override val flow: StateFlow>, ) : MapOverlayInterface { + /** Calls [CircleState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ + private val added = OverlayAddedTracker { it.onAdded } + override suspend fun render( data: MutableMap, controller: MapViewControllerInterface, ) { - (controller as? CircleCapableInterface)?.compositionCircles(data.values.toList()) + val capable = controller as? CircleCapableInterface ?: return + val states = data.values.toList() + capable.compositionCircles(states) + added.synced(controller, states, capable::hasCircle) } } diff --git a/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt b/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt index c989e3a..5ff9183 100644 --- a/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt +++ b/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt @@ -264,11 +264,16 @@ abstract class BaseMapViewController : .filterIsInstance>() .filter { it.kind == kind } - /** [kind] のいずれかのコントローラがこの id を持っているか。`hasXxx` の既定実装。 */ + /** + * [kind] のいずれかのコントローラがこの id を持っているか。`hasXxx` の既定実装。 + * + * 一覧を作らずに直接なめる。composition のたびに全オーバーレイについて呼ばれる + * (`onAdded` の判定)ので、1 回ごとに 2 本のリストを作ると 10 万件で数百 ms になる。 + */ protected fun hasOverlay( kind: OverlayKind, id: String, - ): Boolean = overlayControllersOf(kind).any { it.has(id) } + ): Boolean = overlayControllers.any { it is SlottedOverlayController<*, *> && it.kind == kind && it.has(id) } // Marker override suspend fun compositionMarkers(data: List) { diff --git a/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt b/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt index a4c5e55..1dee7d7 100644 --- a/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt +++ b/src/main/java/com/mapconductor/core/groundimage/GroundImage.kt @@ -27,6 +27,7 @@ class GroundImageState( id: String? = null, extra: Serializable? = null, onClick: OnGroundImageEventHandler? = null, + onAdded: OnGroundImageAddedHandler? = null, ) : ComponentState { override val id = (id ?: generateId(bounds, image, opacity, tileSize, clickable, extra)).toString() @@ -83,6 +84,23 @@ class GroundImageState( var extra by mutations.notifying(extra) var onClick by mutations.notifying(onClick) + /** + * Called once each time this ground image goes from not being on the map to being on it: + * after the map SDK's object for it has been created, or, when it is drawn in tiles, + * after the URL template of those tiles has been handed to the map as a raster layer + * (the tile images may still be loading then). Not called for an in-place change, + * nor when it moves between native drawing and tiles while staying on the map; + * called again if it is removed and added again. Clearing or destroying the map calls + * nothing. + * + * Runs on the main thread once the map has finished adding the overlays it was given. + * A callback that throws is logged and does not stop the other overlays' callbacks. + * + * Like `onClick`, it is not part of the id, fingerprint, equality or hash: replacing + * it redraws nothing. If the same state is shown on two maps, each map calls it. + */ + var onAdded: OnGroundImageAddedHandler? = onAdded + /** [imageUrl] の画像を貼る。読み終えるまでは透明。ほかの引数は `Drawable` を渡すコンストラクタと同じ。 */ constructor( bounds: GeoRectBounds, @@ -93,6 +111,7 @@ class GroundImageState( id: String? = null, extra: Serializable? = null, onClick: OnGroundImageEventHandler? = null, + onAdded: OnGroundImageAddedHandler? = null, ) : this( bounds = bounds, image = UrlImageDrawable(imageUrl, null), @@ -102,10 +121,55 @@ class GroundImageState( id = id, extra = extra, onClick = onClick, + onAdded = onAdded, ) { this.imageUrl = imageUrl } + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + constructor( + bounds: GeoRectBounds, + image: Drawable, + opacity: Float = 1.0f, + tileSize: Int = GroundImageTileProvider.DEFAULT_TILE_SIZE, + clickable: Boolean = true, + id: String? = null, + extra: Serializable? = null, + onClick: OnGroundImageEventHandler? = null, + ) : this( + bounds, + image, + opacity, + tileSize, + clickable, + id, + extra, + onClick, + null, + ) + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + constructor( + bounds: GeoRectBounds, + imageUrl: String, + opacity: Float = 1.0f, + tileSize: Int = GroundImageTileProvider.DEFAULT_TILE_SIZE, + clickable: Boolean = true, + id: String? = null, + extra: Serializable? = null, + onClick: OnGroundImageEventHandler? = null, + ) : this( + bounds, + imageUrl, + opacity, + tileSize, + clickable, + id, + extra, + onClick, + null, + ) + fun fingerPrint(): GroundImageFingerPrint = GroundImageFingerPrint( id = id.hashCode(), @@ -143,6 +207,7 @@ class GroundImageState( id: String? = this.id, extra: Serializable? = this.extra, onClick: OnGroundImageEventHandler? = this.onClick, + onAdded: OnGroundImageAddedHandler? = this.onAdded, ): GroundImageState { // 画像を差し替えないなら URL ごと引き継ぐ(読み込み中でも、届いたら写る)。 val url = imageUrl @@ -156,6 +221,7 @@ class GroundImageState( id = id, extra = extra, onClick = onClick, + onAdded = onAdded, ) } return GroundImageState( @@ -167,9 +233,33 @@ class GroundImageState( id = id, extra = extra, onClick = onClick, + onAdded = onAdded, ) } + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + fun copy( + bounds: GeoRectBounds = this.bounds, + image: Drawable = this.image, + opacity: Float = this.opacity, + tileSize: Int = this.tileSize, + clickable: Boolean = this.clickable, + id: String? = this.id, + extra: Serializable? = this.extra, + onClick: OnGroundImageEventHandler? = this.onClick, + ): GroundImageState = + copy( + bounds, + image, + opacity, + tileSize, + clickable, + id, + extra, + onClick, + onAdded, + ) + override fun equals(other: Any?): Boolean = (other as? GroundImageState)?.hashCode() == this.hashCode() override fun hashCode(): Int = fingerPrint().hashCode() @@ -194,3 +284,6 @@ class GroundImageEvent( } typealias OnGroundImageEventHandler = (GroundImageEvent) -> Unit + +/** Receives the state whose ground image was just added to the map. See [GroundImageState.onAdded]. */ +typealias OnGroundImageAddedHandler = (GroundImageState) -> Unit diff --git a/src/main/java/com/mapconductor/core/groundimage/GroundImageOverlay.kt b/src/main/java/com/mapconductor/core/groundimage/GroundImageOverlay.kt index b93e1df..2e01d4c 100644 --- a/src/main/java/com/mapconductor/core/groundimage/GroundImageOverlay.kt +++ b/src/main/java/com/mapconductor/core/groundimage/GroundImageOverlay.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.groundimage +import com.mapconductor.core.OverlayAddedTracker import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.map.MapOverlayInterface import kotlinx.coroutines.flow.StateFlow @@ -7,10 +8,16 @@ import kotlinx.coroutines.flow.StateFlow class GroundImageOverlay( override val flow: StateFlow>, ) : MapOverlayInterface { + /** Calls [GroundImageState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ + private val added = OverlayAddedTracker { it.onAdded } + override suspend fun render( data: MutableMap, controller: MapViewControllerInterface, ) { - (controller as? GroundImageCapableInterface)?.compositionGroundImages(data.values.toList()) + val capable = controller as? GroundImageCapableInterface ?: return + val states = data.values.toList() + capable.compositionGroundImages(states) + added.synced(controller, states, capable::hasGroundImage) } } diff --git a/src/main/java/com/mapconductor/core/marker/Marker.kt b/src/main/java/com/mapconductor/core/marker/Marker.kt index bc14c3f..69e6370 100644 --- a/src/main/java/com/mapconductor/core/marker/Marker.kt +++ b/src/main/java/com/mapconductor/core/marker/Marker.kt @@ -28,6 +28,7 @@ class MarkerState( onDragEnd: OnMarkerEventHandler? = null, onAnimateStart: OnMarkerEventHandler? = null, onAnimateEnd: OnMarkerEventHandler? = null, + onAdded: OnMarkerEventHandler? = null, ) : ComponentState { override val id = ( @@ -74,6 +75,23 @@ class MarkerState( var onDragEnd by mutations.notifying(onDragEnd) var onAnimateStart by mutations.notifying(onAnimateStart) var onAnimateEnd by mutations.notifying(onAnimateEnd) + + /** + * Called once each time this marker goes from not being on the map to being on it: + * after the map SDK's object for it has been created, or, when it is drawn in tiles, + * after the URL template of those tiles has been handed to the map as a raster layer + * (the tile images may still be loading then). Not called for an in-place change, + * nor when it moves between native drawing and tiles while staying on the map; + * called again if it is removed and added again. Clearing or destroying the map calls + * nothing. + * + * Runs on the main thread once the map has finished adding the overlays it was given. + * A callback that throws is logged and does not stop the other overlays' callbacks. + * + * Like `onClick`, it is not part of the id, fingerprint, equality or hash: replacing + * it redraws nothing. If the same state is shown on two maps, each map calls it. + */ + var onAdded: OnMarkerEventHandler? = onAdded var zIndex by mutations.notifying(zIndex) private var internalAnimation by mutations.notifying(animation) @@ -99,6 +117,40 @@ class MarkerState( } } + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + constructor( + position: GeoPointInterface, + id: String? = null, + extra: Serializable? = null, + icon: MarkerIconInterface? = null, + animation: MarkerAnimation? = null, + zIndex: Int? = null, + clickable: Boolean = true, + draggable: Boolean = false, + onClick: OnMarkerEventHandler? = null, + onDragStart: OnMarkerEventHandler? = null, + onDrag: OnMarkerEventHandler? = null, + onDragEnd: OnMarkerEventHandler? = null, + onAnimateStart: OnMarkerEventHandler? = null, + onAnimateEnd: OnMarkerEventHandler? = null, + ) : this( + position, + id, + extra, + icon, + animation, + zIndex, + clickable, + draggable, + onClick, + onDragStart, + onDrag, + onDragEnd, + onAnimateStart, + onAnimateEnd, + null, + ) + fun animate(animation: MarkerAnimation?) { internalAnimation = animation } @@ -138,6 +190,7 @@ class MarkerState( onDragEnd: OnMarkerEventHandler? = this.onDragEnd, onAnimateStart: OnMarkerEventHandler? = this.onAnimateStart, onAnimateEnd: OnMarkerEventHandler? = this.onAnimateEnd, + onAdded: OnMarkerEventHandler? = this.onAdded, ): MarkerState = MarkerState( id = id, // Keep marker id @@ -154,6 +207,42 @@ class MarkerState( onDragEnd = onDragEnd, onAnimateStart = onAnimateStart, onAnimateEnd = onAnimateEnd, + onAdded = onAdded, + ) + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + fun copy( + id: String? = this.id, + position: GeoPointInterface = this.position, + extra: Serializable? = this.extra, + icon: MarkerIconInterface? = this.icon, + animation: MarkerAnimation? = this.getAnimation(), + zIndex: Int? = this.zIndex, + clickable: Boolean? = this.clickable, + draggable: Boolean? = this.draggable, + onClick: OnMarkerEventHandler? = this.onClick, + onDragStart: OnMarkerEventHandler? = this.onDragStart, + onDrag: OnMarkerEventHandler? = this.onDrag, + onDragEnd: OnMarkerEventHandler? = this.onDragEnd, + onAnimateStart: OnMarkerEventHandler? = this.onAnimateStart, + onAnimateEnd: OnMarkerEventHandler? = this.onAnimateEnd, + ): MarkerState = + copy( + id, + position, + extra, + icon, + animation, + zIndex, + clickable, + draggable, + onClick, + onDragStart, + onDrag, + onDragEnd, + onAnimateStart, + onAnimateEnd, + onAdded, ) override fun equals(other: Any?): Boolean { diff --git a/src/main/java/com/mapconductor/core/marker/MarkerOverlay.kt b/src/main/java/com/mapconductor/core/marker/MarkerOverlay.kt index a9c3261..1e75ecf 100644 --- a/src/main/java/com/mapconductor/core/marker/MarkerOverlay.kt +++ b/src/main/java/com/mapconductor/core/marker/MarkerOverlay.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.marker +import com.mapconductor.core.OverlayAddedTracker import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.map.MapOverlayInterface import kotlinx.coroutines.flow.StateFlow @@ -7,12 +8,16 @@ import kotlinx.coroutines.flow.StateFlow class MarkerOverlay( override val flow: StateFlow>, ) : MapOverlayInterface { + /** Calls [MarkerState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ + private val added = OverlayAddedTracker { it.onAdded } + override suspend fun render( data: MutableMap, controller: MapViewControllerInterface, ) { - (controller as? MarkerCapableInterface)?.let { markerController -> - markerController.compositionMarkers(data.values.toList()) - } + val capable = controller as? MarkerCapableInterface ?: return + val states = data.values.toList() + capable.compositionMarkers(states) + added.synced(controller, states, capable::hasMarker) } } diff --git a/src/main/java/com/mapconductor/core/polygon/Polygon.kt b/src/main/java/com/mapconductor/core/polygon/Polygon.kt index 685bbba..6a1f37b 100644 --- a/src/main/java/com/mapconductor/core/polygon/Polygon.kt +++ b/src/main/java/com/mapconductor/core/polygon/Polygon.kt @@ -22,6 +22,7 @@ class PolygonState( zIndex: Int = 0, extra: Serializable? = null, onClick: OnPolygonEventHandler? = null, + onAdded: OnPolygonAddedHandler? = null, ) : ComponentState { override val id = ( @@ -65,6 +66,51 @@ class PolygonState( var extra by mutations.notifying(extra) var onClick by mutations.notifying(onClick) + /** + * Called once each time this polygon goes from not being on the map to being on it: + * after the map SDK's object for it has been created, or, when it is drawn in tiles, + * after the URL template of those tiles has been handed to the map as a raster layer + * (the tile images may still be loading then). Not called for an in-place change, + * nor when it moves between native drawing and tiles while staying on the map; + * called again if it is removed and added again. Clearing or destroying the map calls + * nothing. + * + * Runs on the main thread once the map has finished adding the overlays it was given. + * A callback that throws is logged and does not stop the other overlays' callbacks. + * + * Like `onClick`, it is not part of the id, fingerprint, equality or hash: replacing + * it redraws nothing. If the same state is shown on two maps, each map calls it. + */ + var onAdded: OnPolygonAddedHandler? = onAdded + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + constructor( + points: List, + holes: List> = emptyList(), + id: String? = null, + strokeColor: Color = Color.Black, + strokeWidth: Dp = 1.dp, + fillColor: Color = Color.Transparent, + geodesic: Boolean = false, + clickable: Boolean = true, + zIndex: Int = 0, + extra: Serializable? = null, + onClick: OnPolygonEventHandler? = null, + ) : this( + points, + holes, + id, + strokeColor, + strokeWidth, + fillColor, + geodesic, + clickable, + zIndex, + extra, + onClick, + null, + ) + private fun polygonId(hashCodes: List): Int = hashCodes.reduce { result, hashCode -> 31 * result + hashCode @@ -129,6 +175,7 @@ class PolygonState( zIndex: Int = this.zIndex, extra: Serializable? = this.extra, onClick: OnPolygonEventHandler? = this.onClick, + onAdded: OnPolygonAddedHandler? = this.onAdded, ): PolygonState = PolygonState( points = points, @@ -142,6 +189,36 @@ class PolygonState( zIndex = zIndex, extra = extra, onClick = onClick, + onAdded = onAdded, + ) + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + fun copy( + points: List = this.points, + holes: List> = this.holes, + id: String? = this.id, + strokeColor: Color = this.strokeColor, + strokeWidth: Dp = this.strokeWidth, + fillColor: Color = this.fillColor, + geodesic: Boolean = this.geodesic, + clickable: Boolean = this.clickable, + zIndex: Int = this.zIndex, + extra: Serializable? = this.extra, + onClick: OnPolygonEventHandler? = this.onClick, + ): PolygonState = + copy( + points, + holes, + id, + strokeColor, + strokeWidth, + fillColor, + geodesic, + clickable, + zIndex, + extra, + onClick, + onAdded, ) } @@ -167,3 +244,6 @@ class PolygonEvent( } typealias OnPolygonEventHandler = (PolygonEvent) -> Unit + +/** Receives the state whose polygon was just added to the map. See [PolygonState.onAdded]. */ +typealias OnPolygonAddedHandler = (PolygonState) -> Unit diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonOverlay.kt b/src/main/java/com/mapconductor/core/polygon/PolygonOverlay.kt index adf1fb0..665c5e8 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonOverlay.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonOverlay.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.polygon +import com.mapconductor.core.OverlayAddedTracker import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.map.MapOverlayInterface import kotlinx.coroutines.flow.StateFlow @@ -7,10 +8,16 @@ import kotlinx.coroutines.flow.StateFlow class PolygonOverlay( override val flow: StateFlow>, ) : MapOverlayInterface { + /** Calls [PolygonState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ + private val added = OverlayAddedTracker { it.onAdded } + override suspend fun render( data: MutableMap, controller: MapViewControllerInterface, ) { - (controller as? PolygonCapableInterface)?.compositionPolygons(data.values.toList()) + val capable = controller as? PolygonCapableInterface ?: return + val states = data.values.toList() + capable.compositionPolygons(states) + added.synced(controller, states, capable::hasPolygon) } } diff --git a/src/main/java/com/mapconductor/core/polyline/Polyline.kt b/src/main/java/com/mapconductor/core/polyline/Polyline.kt index 45b97bc..6fdb6d0 100644 --- a/src/main/java/com/mapconductor/core/polyline/Polyline.kt +++ b/src/main/java/com/mapconductor/core/polyline/Polyline.kt @@ -20,6 +20,7 @@ class PolylineState( zIndex: Int = 0, extra: Serializable? = null, onClick: OnPolylineEventHandler? = null, + onAdded: OnPolylineAddedHandler? = null, ) : ComponentState { override val id = ( @@ -59,6 +60,47 @@ class PolylineState( var extra by mutations.notifying(extra) var onClick by mutations.notifying(onClick) + /** + * Called once each time this polyline goes from not being on the map to being on it: + * after the map SDK's object for it has been created, or, when it is drawn in tiles, + * after the URL template of those tiles has been handed to the map as a raster layer + * (the tile images may still be loading then). Not called for an in-place change, + * nor when it moves between native drawing and tiles while staying on the map; + * called again if it is removed and added again. Clearing or destroying the map calls + * nothing. + * + * Runs on the main thread once the map has finished adding the overlays it was given. + * A callback that throws is logged and does not stop the other overlays' callbacks. + * + * Like `onClick`, it is not part of the id, fingerprint, equality or hash: replacing + * it redraws nothing. If the same state is shown on two maps, each map calls it. + */ + var onAdded: OnPolylineAddedHandler? = onAdded + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + constructor( + points: List, + id: String? = null, + strokeColor: Color = Color.Black, + strokeWidth: Dp = 1.dp, + geodesic: Boolean = false, + clickable: Boolean = true, + zIndex: Int = 0, + extra: Serializable? = null, + onClick: OnPolylineEventHandler? = null, + ) : this( + points, + id, + strokeColor, + strokeWidth, + geodesic, + clickable, + zIndex, + extra, + onClick, + null, + ) + private fun polylineId(hashCodes: List): Int = hashCodes.reduce { result, hashCode -> 31 * result + hashCode @@ -90,6 +132,7 @@ class PolylineState( zIndex: Int = this.zIndex, extra: Serializable? = this.extra, onClick: OnPolylineEventHandler? = this.onClick, + onAdded: OnPolylineAddedHandler? = this.onAdded, ): PolylineState = PolylineState( points = points, @@ -101,6 +144,32 @@ class PolylineState( zIndex = zIndex, extra = extra, onClick = onClick, + onAdded = onAdded, + ) + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + fun copy( + points: List = this.points, + id: String? = this.id, + strokeColor: Color = this.strokeColor, + strokeWidth: Dp = this.strokeWidth, + geodesic: Boolean = this.geodesic, + clickable: Boolean = this.clickable, + zIndex: Int = this.zIndex, + extra: Serializable? = this.extra, + onClick: OnPolylineEventHandler? = this.onClick, + ): PolylineState = + copy( + points, + id, + strokeColor, + strokeWidth, + geodesic, + clickable, + zIndex, + extra, + onClick, + onAdded, ) private fun listHashCode(list: List): Int { @@ -144,3 +213,6 @@ class PolylineEvent( } typealias OnPolylineEventHandler = (PolylineEvent) -> Unit + +/** Receives the state whose polyline was just added to the map. See [PolylineState.onAdded]. */ +typealias OnPolylineAddedHandler = (PolylineState) -> Unit diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt b/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt index 9fe6bbb..01203f7 100644 --- a/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt +++ b/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.polyline +import com.mapconductor.core.OverlayAddedTracker import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.map.MapOverlayInterface import kotlinx.coroutines.flow.StateFlow @@ -7,10 +8,16 @@ import kotlinx.coroutines.flow.StateFlow class PolylineOverlay( override val flow: StateFlow>, ) : MapOverlayInterface { + /** Calls [PolylineState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ + private val added = OverlayAddedTracker { it.onAdded } + override suspend fun render( data: MutableMap, controller: MapViewControllerInterface, ) { - (controller as? PolylineCapableInterface)?.compositionPolylines(data.values.toList()) + val capable = controller as? PolylineCapableInterface ?: return + val states = data.values.toList() + capable.compositionPolylines(states) + added.synced(controller, states, capable::hasPolyline) } } diff --git a/src/main/java/com/mapconductor/core/raster/RasterLayer.kt b/src/main/java/com/mapconductor/core/raster/RasterLayer.kt index c52c3c8..b851811 100644 --- a/src/main/java/com/mapconductor/core/raster/RasterLayer.kt +++ b/src/main/java/com/mapconductor/core/raster/RasterLayer.kt @@ -14,6 +14,7 @@ class RasterLayerState( debug: Boolean = false, id: String? = null, extraHeaders: Map? = null, + onAdded: OnRasterLayerAddedHandler? = null, ) : ComponentState { override val id = ( @@ -42,6 +43,45 @@ class RasterLayerState( var debug by mutations.notifying(debug) var extraHeaders by mutations.notifying(extraHeaders) + /** + * Called once each time this raster layer goes from not being on the map to being on it: + * after the map SDK's object for it has been created, or, when it is drawn in tiles, + * after the URL template of those tiles has been handed to the map as a raster layer + * (the tile images may still be loading then). Not called for an in-place change, + * nor when it moves between native drawing and tiles while staying on the map; + * called again if it is removed and added again. Clearing or destroying the map calls + * nothing. + * + * Runs on the main thread once the map has finished adding the overlays it was given. + * A callback that throws is logged and does not stop the other overlays' callbacks. + * + * Like `onClick`, it is not part of the id, fingerprint, equality or hash: replacing + * it redraws nothing. If the same state is shown on two maps, each map calls it. + */ + var onAdded: OnRasterLayerAddedHandler? = onAdded + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + constructor( + source: RasterLayerSource, + opacity: Float = 1.0f, + visible: Boolean = true, + zIndex: Int = 0, + userAgent: String = DEFAULT_USER_AGENT, + debug: Boolean = false, + id: String? = null, + extraHeaders: Map? = null, + ) : this( + source, + opacity, + visible, + zIndex, + userAgent, + debug, + id, + extraHeaders, + null, + ) + private fun rasterLayerId(hashCodes: List): Int = hashCodes.reduce { result, hashCode -> 31 * result + hashCode @@ -72,6 +112,7 @@ class RasterLayerState( userAgent: String = this.userAgent, id: String? = this.id, extraHeaders: Map? = this.extraHeaders, + onAdded: OnRasterLayerAddedHandler? = this.onAdded, ): RasterLayerState = RasterLayerState( source = source, @@ -82,6 +123,30 @@ class RasterLayerState( debug = debug, id = id, extraHeaders = extraHeaders, + onAdded = onAdded, + ) + + @Deprecated("Binary compatibility only; use the overload with onAdded.", level = DeprecationLevel.HIDDEN) + fun copy( + source: RasterLayerSource = this.source, + opacity: Float = this.opacity, + visible: Boolean = this.visible, + zIndex: Int = this.zIndex, + debug: Boolean = this.debug, + userAgent: String = this.userAgent, + id: String? = this.id, + extraHeaders: Map? = this.extraHeaders, + ): RasterLayerState = + copy( + source, + opacity, + visible, + zIndex, + debug, + userAgent, + id, + extraHeaders, + onAdded, ) fun fingerPrint(): RasterLayerFingerPrint = @@ -126,3 +191,6 @@ data class RasterLayerEvent( ) typealias OnRasterLayerEventHandler = (RasterLayerEvent) -> Unit + +/** Receives the state whose layer was just added to the map. See [RasterLayerState.onAdded]. */ +typealias OnRasterLayerAddedHandler = (RasterLayerState) -> Unit diff --git a/src/main/java/com/mapconductor/core/raster/RasterLayerOverlay.kt b/src/main/java/com/mapconductor/core/raster/RasterLayerOverlay.kt index 97bb911..7b58c13 100644 --- a/src/main/java/com/mapconductor/core/raster/RasterLayerOverlay.kt +++ b/src/main/java/com/mapconductor/core/raster/RasterLayerOverlay.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.raster +import com.mapconductor.core.OverlayAddedTracker import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.map.MapOverlayInterface import kotlinx.coroutines.flow.StateFlow @@ -7,10 +8,16 @@ import kotlinx.coroutines.flow.StateFlow class RasterLayerOverlay( override val flow: StateFlow>, ) : MapOverlayInterface { + /** Calls [RasterLayerState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ + private val added = OverlayAddedTracker { it.onAdded } + override suspend fun render( data: MutableMap, controller: MapViewControllerInterface, ) { - (controller as? RasterLayerCapableInterface)?.compositionRasterLayers(data.values.toList()) + val capable = controller as? RasterLayerCapableInterface ?: return + val states = data.values.toList() + capable.compositionRasterLayers(states) + added.synced(controller, states, capable::hasRasterLayer) } } diff --git a/src/test/java/com/mapconductor/core/OverlayOnAddedTest.kt b/src/test/java/com/mapconductor/core/OverlayOnAddedTest.kt new file mode 100644 index 0000000..a6b2a4e --- /dev/null +++ b/src/test/java/com/mapconductor/core/OverlayOnAddedTest.kt @@ -0,0 +1,413 @@ +package com.mapconductor.core + +import com.mapconductor.core.circle.CircleCapableInterface +import com.mapconductor.core.circle.CircleOverlay +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.circle.OnCircleEventHandler +import com.mapconductor.core.controller.MapViewControllerInterface +import com.mapconductor.core.controller.OverlayControllerInterface +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageCapableInterface +import com.mapconductor.core.groundimage.GroundImageOverlay +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.groundimage.OnGroundImageEventHandler +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapViewHolderInterface +import com.mapconductor.core.marker.MarkerAnimationOverlayHost +import com.mapconductor.core.marker.MarkerCapableInterface +import com.mapconductor.core.marker.MarkerOverlay +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.marker.OnMarkerEventHandler +import com.mapconductor.core.polygon.OnPolygonEventHandler +import com.mapconductor.core.polygon.PolygonCapableInterface +import com.mapconductor.core.polygon.PolygonController +import com.mapconductor.core.polygon.PolygonEntityInterface +import com.mapconductor.core.polygon.PolygonManager +import com.mapconductor.core.polygon.PolygonOverlay +import com.mapconductor.core.polygon.PolygonOverlayRendererInterface +import com.mapconductor.core.polygon.PolygonOverlayRendererInterface.AddParamsInterface +import com.mapconductor.core.polygon.PolygonOverlayRendererInterface.ChangeParamsInterface +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.PolylineCapableInterface +import com.mapconductor.core.polyline.PolylineOverlay +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.raster.RasterLayerCapableInterface +import com.mapconductor.core.raster.RasterLayerOverlay +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `onAdded` of the overlay states. + * + * Driven the way a map drives it: the overlay hands its states to the + * controller (`compositionXxx`), and `onAdded` runs for the states the map now + * has that it did not have before. The polygon controller here is the real + * core one, so the native path (renderer.onAdd) is the code providers run. + * The tile path needs android.util.LruCache (PolygonTileRenderer) and is in + * androidTest: OverlayOnAddedDeviceTest. + */ +class OverlayOnAddedTest { + private val events = mutableListOf() + + /** A polygon renderer that draws nothing and records what it was asked. */ + private inner class FakeRenderer : PolygonOverlayRendererInterface { + var failIds: Set = emptySet() + + override suspend fun onAdd(data: List): List = + data.map { params -> + val id = params.state.id + if (id in failIds) { + null + } else { + events += "native:$id" + "native-$id" + } + } + + override suspend fun onChange(data: List>): List = + data.map { + events += "change:${it.current.state.id}" + it.prev.polygon + } + + override suspend fun onRemove(data: List>) { + data.forEach { events += "remove:${it.state.id}" } + } + + override suspend fun onPostProcess() = Unit + } + + private inner class FakePolygonController( + renderer: FakeRenderer, + ) : PolygonController(PolygonManager(), renderer) + + /** Just enough of a map: polygons go through the real core controller. */ + private inner class FakeMap : + MapViewControllerInterface, + PolygonCapableInterface { + val renderer = FakeRenderer() + val polygons = FakePolygonController(renderer) + + override suspend fun compositionPolygons(data: List) = polygons.add(data) + + override suspend fun updatePolygon(state: PolygonState) = polygons.update(state) + + @Deprecated("Use PolygonState.onClick instead.") + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) = Unit + + override fun hasPolygon(state: PolygonState): Boolean = polygons.has(state.id) + + override val holder: MapViewHolderInterface<*, *> get() = error("not used") + + override suspend fun clearOverlays() = polygons.clear() + + override fun moveCamera(position: MapCameraPosition) = Unit + + override fun animateCamera( + position: MapCameraPosition, + duration: Long, + ) = Unit + + override fun fitBounds( + bounds: GeoRectBounds, + padding: Int, + ) = Unit + + override fun registerOverlayController(controller: OverlayControllerInterface<*, *>) = Unit + + override fun destroy() { + polygons.destroy() + } + } + + private val maps = mutableListOf() + + @After + fun tearDown() { + maps.forEach { it.destroy() } + } + + private fun map() = FakeMap().also { maps += it } + + private fun square( + i: Int, + onAdded: ((PolygonState) -> Unit)? = null, + ): PolygonState { + val lat = (i / 100) * 0.01 + val lng = (i % 100) * 0.01 + val points: List = + listOf( + GeoPoint.fromLatLong(lat, lng), + GeoPoint.fromLatLong(lat, lng + 0.005), + GeoPoint.fromLatLong(lat + 0.005, lng + 0.005), + GeoPoint.fromLatLong(lat + 0.005, lng), + ) + return PolygonState(points = points, id = "p$i", onAdded = onAdded) + } + + private val recorder: (PolygonState) -> Unit = { events += "onAdded:${it.id}" } + + private fun PolygonOverlay.show( + map: FakeMap, + states: List, + ) = runBlocking { render(states.associateBy { it.id }.toMutableMap(), map) } + + private fun overlay() = PolygonOverlay(MutableStateFlow(mutableMapOf())) + + @Test + fun `native drawing - called once each, after the renderer made the object`() { + val map = map() + val overlay = overlay() + val states = List(3) { square(it, recorder) } + + overlay.show(map, states) + + assertEquals( + listOf("native:p0", "native:p1", "native:p2", "onAdded:p0", "onAdded:p1", "onAdded:p2"), + events, + ) + } + + @Test + fun `an in-place update or the same list again calls nothing`() { + val map = map() + val overlay = overlay() + val states = List(2) { square(it, recorder) } + overlay.show(map, states) + events.clear() + + states[0].fillColor = androidx.compose.ui.graphics.Color.Red + runBlocking { map.updatePolygon(states[0]) } + overlay.show(map, states) + // Replacing the callback redraws nothing and calls nothing either. + states[1].onAdded = { events += "replaced:${it.id}" } + overlay.show(map, states) + + assertEquals(listOf("change:p0"), events) + } + + @Test + fun `removed and added again - called again`() { + val map = map() + val overlay = overlay() + val a = square(0, recorder) + val b = square(1, recorder) + overlay.show(map, listOf(a, b)) + overlay.show(map, listOf(a)) + events.clear() + + overlay.show(map, listOf(a, b)) + + assertEquals(listOf("native:p1", "onAdded:p1"), events) + } + + @Test + fun `a callback that throws does not stop the others`() { + val map = map() + val overlay = overlay() + val states = + listOf( + square(0, recorder), + square(1) { throw IllegalStateException("app bug") }, + square(2, recorder), + ) + + overlay.show(map, states) + + assertEquals(listOf("onAdded:p0", "onAdded:p2"), events.filter { it.startsWith("onAdded") }) + } + + @Test + fun `not called for what the map could not add, until it does`() { + val map = map() + val overlay = overlay() + map.renderer.failIds = setOf("p1") + val states = List(2) { square(it, recorder) } + + overlay.show(map, states) + assertEquals(listOf("onAdded:p0"), events.filter { it.startsWith("onAdded") }) + + events.clear() + map.renderer.failIds = emptySet() + overlay.show(map, states) + assertEquals(listOf("native:p1", "onAdded:p1"), events) + } + + @Test + fun `clearing the map calls nothing, and a new map starts from nothing`() { + val overlay = overlay() + val states = List(2) { square(it, recorder) } + val first = map() + overlay.show(first, states) + runBlocking { first.clearOverlays() } + assertEquals(2, events.count { it.startsWith("onAdded") }) + + events.clear() + overlay.show(map(), states) + assertEquals(listOf("onAdded:p0", "onAdded:p1"), events.filter { it.startsWith("onAdded") }) + } + + // ── the other overlay types use the same tracker; check each is wired ── + + private class RecordingMap : + MapViewControllerInterface, + MarkerCapableInterface, + CircleCapableInterface, + PolylineCapableInterface, + GroundImageCapableInterface, + RasterLayerCapableInterface { + val ids = mutableSetOf() + + override suspend fun compositionMarkers(data: List) = data.forEach { ids += it.id } + + override suspend fun updateMarker(state: MarkerState) = Unit + + override fun hasMarker(state: MarkerState) = state.id in ids + + @Deprecated("x") + override fun setOnMarkerDragStart(listener: OnMarkerEventHandler?) = Unit + + @Deprecated("x") + override fun setOnMarkerDrag(listener: OnMarkerEventHandler?) = Unit + + @Deprecated("x") + override fun setOnMarkerDragEnd(listener: OnMarkerEventHandler?) = Unit + + @Deprecated("x") + override fun setOnMarkerAnimateStart(listener: OnMarkerEventHandler?) = Unit + + @Deprecated("x") + override fun setOnMarkerAnimateEnd(listener: OnMarkerEventHandler?) = Unit + + @Deprecated("x") + override fun setOnMarkerClickListener(listener: OnMarkerEventHandler?) = Unit + + override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) = Unit + + override suspend fun compositionCircles(data: List) = data.forEach { ids += it.id } + + override suspend fun updateCircle(state: CircleState) = Unit + + @Deprecated("x") + override fun setOnCircleClickListener(listener: OnCircleEventHandler?) = Unit + + override fun hasCircle(state: CircleState) = state.id in ids + + override suspend fun compositionPolylines(data: List) = data.forEach { ids += it.id } + + override suspend fun updatePolyline(state: PolylineState) = Unit + + @Deprecated("x") + override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) = Unit + + override fun hasPolyline(state: PolylineState) = state.id in ids + + override suspend fun compositionGroundImages(data: List) = data.forEach { ids += it.id } + + override suspend fun updateGroundImage(state: GroundImageState) = Unit + + @Deprecated("x") + override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) = Unit + + override fun hasGroundImage(state: GroundImageState) = state.id in ids + + override suspend fun compositionRasterLayers(data: List) = data.forEach { ids += it.id } + + override suspend fun updateRasterLayer(state: RasterLayerState) = Unit + + override fun hasRasterLayer(state: RasterLayerState) = state.id in ids + + override val holder: MapViewHolderInterface<*, *> get() = error("not used") + + override suspend fun clearOverlays() = Unit + + override fun moveCamera(position: MapCameraPosition) = Unit + + override fun animateCamera( + position: MapCameraPosition, + duration: Long, + ) = Unit + + override fun fitBounds( + bounds: GeoRectBounds, + padding: Int, + ) = Unit + + override fun registerOverlayController(controller: OverlayControllerInterface<*, *>) = Unit + + override fun destroy() = Unit + } + + @Test + fun `markers, circles, polylines, ground images and raster layers call onAdded too`() { + val map = RecordingMap() + val here = GeoPoint.fromLatLong(35.0, 139.0) + val marker = MarkerState(position = here, id = "m", onAdded = { events += "marker:${it.id}" }) + val circle = + CircleState(center = here, radiusMeters = 10.0, id = "c", onAdded = { events += "circle:${it.id}" }) + val polyline = + PolylineState( + points = listOf(here, GeoPoint.fromLatLong(35.1, 139.1)), + id = "l", + onAdded = { events += "polyline:${it.id}" }, + ) + val groundImage = + GroundImageState( + bounds = + GeoRectBounds().apply { + extend(here) + extend(GeoPoint.fromLatLong(35.1, 139.1)) + }, + image = FakeDrawable(), + id = "g", + onAdded = { events += "groundImage:${it.id}" }, + ) + val raster = + RasterLayerState( + source = RasterLayerSource.UrlTemplate(template = "https://example.com/{z}/{x}/{y}.png"), + id = "r", + onAdded = { events += "raster:${it.id}" }, + ) + runBlocking { + MarkerOverlay(MutableStateFlow(mutableMapOf())).render(mutableMapOf("m" to marker), map) + CircleOverlay(MutableStateFlow(mutableMapOf())).render(mutableMapOf("c" to circle), map) + PolylineOverlay(MutableStateFlow(mutableMapOf())).render(mutableMapOf("l" to polyline), map) + GroundImageOverlay(MutableStateFlow(mutableMapOf())).render(mutableMapOf("g" to groundImage), map) + RasterLayerOverlay(MutableStateFlow(mutableMapOf())).render(mutableMapOf("r" to raster), map) + } + assertEquals(listOf("marker:m", "circle:c", "polyline:l", "groundImage:g", "raster:r"), events) + } + + /** GroundImageState needs a Drawable; nothing here draws it. */ + private class FakeDrawable : android.graphics.drawable.Drawable() { + override fun draw(canvas: android.graphics.Canvas) = Unit + + override fun setAlpha(alpha: Int) = Unit + + override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) = Unit + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = android.graphics.PixelFormat.OPAQUE + } + + @Test + fun `copy carries onAdded, and it is not part of equality`() { + val callback: (PolygonState) -> Unit = {} + val a = square(0, callback) + assertTrue(a.copy().onAdded === callback) + assertEquals(a, square(0, null)) + assertEquals(a.fingerPrint(), square(0, null).fingerPrint()) + val marker = MarkerState(position = GeoPoint.fromLatLong(0.0, 0.0), id = "m", onAdded = { }) + assertTrue(marker.copy().onAdded === marker.onAdded) + assertEquals(marker.fingerPrint(), marker.copy(onAdded = null).fingerPrint()) + } +} From 29235e3e84278c4e6fa62c15fb6a565146c070bf Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:07:32 -0800 Subject: [PATCH 2/6] PolygonController: move a tiled polygon to native drawing when a change makes it untileable Whether a polygon is tiled was decided only on add. Setting geodesic on a tiled polygon in place, or giving it points that span half the world or fewer than three, kept it in the tiles, drawn with straight edges. update() now checks canTile: a polygon that fails it leaves the tiles (its slot in the renderer is emptied, so only the tiles it was in are drawn again) and is handed to the renderer. It stays on the map, so this is not an add. The reverse is left alone: a native polygon that could now be tiled already looks right. Same as js-sdk-core. --- .../polygon/PolygonTileRendererDeviceTest.kt | 17 +++ .../core/polygon/PolygonTilerDeviceTest.kt | 129 +++++++++++++++++- .../core/polygon/PolygonController.kt | 28 +++- .../core/polygon/PolygonTileSet.kt | 41 ++++-- .../mapconductor/core/polygon/PolygonTiler.kt | 15 +- .../core/polygon/PolygonTilingTest.kt | 9 ++ 6 files changed, 224 insertions(+), 15 deletions(-) diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt index 1d09385..8493c5e 100644 --- a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt @@ -140,6 +140,23 @@ class PolygonTileRendererDeviceTest { assertEquals(0x00FF00, rgb(decoded.getPixel(120, 234))) } + /** タイルから外したポリゴン(null)は、重なっていたタイルだけ描き直して消える */ + @Test + fun removedPolygonRedrawsOnlyItsTiles() { + val west = PolygonState(points = box(10.0, -100.0, 20.0, -90.0), fillColor = blue, id = "west") + val east = PolygonState(points = box(10.0, 90.0, 20.0, 100.0), fillColor = blue, id = "east") + val r = renderer(west, east) + val westTile = TileRequest(x = 0, y = 0, z = 1) + val eastTile = TileRequest(x = 1, y = 0, z = 1) + assertNotNull(r.renderTile(westTile)) + val eastBefore = r.renderTile(eastTile)!! + + r.update(listOf(0 to null)) + + assertNull("外したポリゴンしか無いタイルは空", r.renderTile(westTile)) + assertSame("触れていないタイルはキャッシュから返す", eastBefore, r.renderTile(eastTile)) + } + /** 形が変わったら索引も作り直す */ @Test fun movedShapeIsReindexed() { diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt index ae7e618..bf1f638 100644 --- a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt @@ -48,15 +48,26 @@ class PolygonTilerDeviceTest { private class Renderer : PolygonOverlayRendererInterface { val onMap = mutableSetOf() + val added = mutableListOf() + val changed = mutableListOf() + val removed = mutableListOf() override suspend fun onAdd(data: List): List = - data.map { it.state.id.also(onMap::add) } + data.map { params -> + params.state.id.also { + onMap.add(it) + added.add(it) + } + } override suspend fun onChange(data: List>): List = - data.map { it.current.state.id } + data.map { params -> params.current.state.id.also { changed.add(it) } } override suspend fun onRemove(data: List>) { - data.forEach { onMap.remove(it.state.id) } + data.forEach { + onMap.remove(it.state.id) + removed.add(it.state.id) + } } override suspend fun onPostProcess() {} @@ -182,6 +193,118 @@ class PolygonTilerDeviceTest { assertTrue(TileServerRegistry.get().isRunning()) } + // ── タイルにできなくなったポリゴン(geodesic・経度の幅 180° 以上・頂点 3 未満) ────── + // + // タイルにするかは add でだけ決めていた。タイルで描いているポリゴンをその場で + // geodesic=true にしたり、点を書き換えてタイルにできない形にしたりすると、update は + // タイルの経路に入ったまま canTile を見直さず、直線のままタイルに描き続けた。 + // js-sdk-core の PolygonTilingInPlace.test.mjs、ios-sdk-core の PolygonTilingInPlaceTests と同じ内容。 + + /** parcel-101 の中と、隣の parcel-102 の中 */ + private val inside101 = 42.3017 to -71.0983 + private val inside102 = 42.3017 to -71.0971 + + /** 混在リストでは geodesic だけネイティブ、タイルには描かれない */ + @Test + fun inAMixedListOnlyTheGeodesicOneGoesNative() = + runBlocking { + val (controller, renderer, templates) = controller() + val g = + PolygonState( + points = listOf(p(42.29, -71.10), p(42.29, -71.099), p(42.291, -71.099), p(42.291, -71.10)), + id = "g", + fillColor = Color(0xFFDC2626), + geodesic = true, + ) + controller.add(parcels(2000) + g) + assertEquals(setOf("g"), renderer.onMap) + val template = templates.last()!! + assertEquals("タイルには描かない", 0, alphaAt(template, 42.2905, -71.0995, 17)) + assertTrue("区画はタイルに描く", alphaAt(template, inside101.first, inside101.second, 17) > 0) + } + + private suspend fun movesToNative(change: (PolygonState) -> Unit): Triple> { + val (controller, renderer, templates) = controller() + val states = parcels(2000) + controller.add(states) + assertTrue(renderer.onMap.isEmpty()) + val before = templates.last()!! + assertTrue(alphaAt(before, inside101.first, inside101.second, 17) > 0) + + change(states[101]) + controller.update(states[101]) + withTimeout(2000) { + while (synchronized(templates) { templates.last() } == before) delay(5) + } + + assertEquals("ネイティブで描く", listOf("parcel-101"), renderer.added) + assertEquals("parcel-101", controller.polygonManager.getEntity("parcel-101")?.polygon) + val after = templates.last()!! + assertEquals("タイルから消える", 0, alphaAt(after, inside101.first, inside101.second, 17)) + assertTrue("隣はタイルに残る", alphaAt(after, inside102.first, inside102.second, 17) > 0) + return Triple(controller, renderer, states) + } + + /** その場で geodesic=true にしたらネイティブへ移り、タイルから消える。タップは引き続き当たる */ + @Test + fun settingGeodesicInPlaceMovesItToNative() = + runBlocking { + val (controller, renderer, states) = movesToNative { it.geodesic = true } + + val hit = controller.find(p(inside101.first, inside101.second)) + assertEquals("parcel-101", hit?.state?.id) + assertEquals("parcel-101", hit?.polygon) + assertEquals("parcel-102", controller.find(p(inside102.first, inside102.second))?.state?.id) + + // その後のその場の変更はネイティブへ + states[101].fillColor = Color(0xFF16A34A) + controller.update(states[101]) + assertEquals(listOf("parcel-101"), renderer.changed) + + // 同じリストを渡し直しても、ネイティブのまま(足しも消しもしない) + controller.add(states) + assertEquals(listOf("parcel-101"), renderer.added) + assertTrue(renderer.removed.isEmpty()) + assertEquals(setOf("parcel-101"), renderer.onMap) + } + + /** その場で点を書き換えて経度の幅 180° 以上にしても同じ */ + @Test + fun pointsSpanningHalfTheWorldInPlaceMoveItToNative() = + runBlocking { + movesToNative { + it.points = listOf(p(42.3012, -71.0988), p(42.3012, -71.0978), p(42.3022, -71.0978), p(42.3022, 120.0)) + } + Unit + } + + /** その場で点を 3 未満にしても同じ */ + @Test + fun fewerThanThreePointsInPlaceMoveItToNative() = + runBlocking { + movesToNative { it.points = listOf(p(42.3012, -71.0988), p(42.3012, -71.0978)) } + Unit + } + + /** The alpha of the tile pixel at a coordinate. */ + private fun alphaAt( + template: String, + lat: Double, + lng: Double, + z: Int, + ): Int { + val (x, y) = tileOf(lat, lng, z) + val (code, bytes) = get(template, z, x, y) + assertEquals(200, code) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return 0 + val scale = 256.0 * (1 shl z) + val s = kotlin.math.sin(Math.toRadians(lat)) + val px = (lng + 180) / 360 * scale - x * 256 + val py = (0.5 - kotlin.math.ln((1 + s) / (1 - s)) / (4 * Math.PI)) * scale - y * 256 + val k = bitmap.width / 256.0 + return bitmap.getPixel((px * k).toInt(), (py * k).toInt()) ushr 24 + } + private fun tileOf( lat: Double, lng: Double, diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt index f3aa949..6f288da 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt @@ -160,8 +160,32 @@ abstract class PolygonController( semaphore.withPermit { val prevEntity = polygonManager.getEntity(state.id) ?: return if (state.id in tiledIds) { - polygonManager.registerEntity(PolygonEntity(polygon = prevEntity.polygon, state = state)) - tiler?.update(state) + if (PolygonTileSet.canTile(state)) { + polygonManager.registerEntity(PolygonEntity(polygon = prevEntity.polygon, state = state)) + tiler?.update(state) + return + } + // No longer drawable in a tile (geodesic, half the world wide, or + // under 3 points): the map SDK draws it from now on. Still on the + // map, so this is not an add. Whether the rest stay in tiles is + // left to the next add(), which decides tiling for the whole list. + tiledIds = tiledIds - state.id + tiler?.remove(state.id) + val polygons = + renderer.onAdd( + listOf( + object : PolygonOverlayRendererInterface.AddParamsInterface { + override val state: PolygonState = state + }, + ), + ) + val polygon = polygons.firstOrNull() + if (polygon != null) { + polygonManager.registerEntity(PolygonEntity(polygon = polygon, state = state)) + } else { + polygonManager.removeEntity(state.id) + } + renderer.onPostProcess() return } val currentFinger = state.fingerPrint() diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt index 4374627..e835325 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt @@ -47,6 +47,7 @@ class PolygonTileSet( private var version = 0 private var registered = false private val pending = LinkedHashMap() + private val pendingRemovals = ArrayList() private var flushJob: Job? = null /** @@ -83,13 +84,33 @@ class PolygonTileSet( synchronized(lock) { if (!indexById.containsKey(state.id)) return pending[state.id] = state - if (flushJob == null) { - flushJob = - scope.launch { - delay(UPDATE_COALESCE) - flush() - } - } + scheduleFlushLocked() + } + } + + /** + * Takes one polygon out of the tiles. Its slot in the renderer's list is + * left empty, so the others keep their indices and only the tiles it was + * in are drawn again. + */ + fun remove(id: String) { + synchronized(lock) { + val index = indexById[id] ?: return + indexById = indexById - id + pending.remove(id) + pendingRemovals.add(index) + scheduleFlushLocked() + } + } + + /** Called with [lock] held. */ + private fun scheduleFlushLocked() { + if (flushJob == null) { + flushJob = + scope.launch { + delay(UPDATE_COALESCE) + flush() + } } } @@ -109,11 +130,12 @@ class PolygonTileSet( val template = synchronized(lock) { flushJob = null - val updates = + val updates: List> = pending.values.mapNotNull { state -> indexById[state.id]?.let { index -> index to TilePolygon.from(state) } - } + } + pendingRemovals.map { index -> index to null } pending.clear() + pendingRemovals.clear() if (updates.isEmpty() || !registered) return renderer.update(updates) version++ @@ -127,6 +149,7 @@ class PolygonTileSet( flushJob?.cancel() flushJob = null pending.clear() + pendingRemovals.clear() } } diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt index b7e564a..97ed549 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt @@ -27,7 +27,8 @@ fun polygonTileRasterLayerState( * in tiles; the controller keeps those in its [PolygonManager] (taps are * resolved there, against the original shapes) but gives the map SDK * nothing for them, and removes any native polygon it had drawn for one. - * 2. [update] for an in-place change of a tiled polygon. + * 2. [update] for an in-place change of a tiled polygon, or [remove] when the + * change means it can no longer be tiled. * 3. [onTemplate] is how the tiles reach the map: a URL template to show as a * raster layer, or null to remove it. A provider with a RasterLayer * controller hands [polygonTileRasterLayerState] to its `upsert`, and @@ -83,6 +84,18 @@ class PolygonTiler( tileSet.update(state) } + /** + * Takes one polygon out of the tiles, for a change that left it no longer + * [PolygonTileSet.canTile]; only the tiles it was in are drawn again. The + * controller then hands it to the map SDK. + */ + fun remove(id: String) { + if (id !in tiled) return + tiled = tiled - id + states = states.filter { it.id != id } + tileSet.remove(id) + } + /** * Releases the tiles when the map goes away. Unlike [clear], it does not * call [onTemplate]: there is no map left to remove the layer from. diff --git a/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt b/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt index b15662f..fe5eadd 100644 --- a/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt +++ b/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt @@ -46,6 +46,15 @@ class PolygonTilingTest { assertFalse(PolygonTilingOptions.Disabled.shouldUseTiles(100_000, 10_000_000)) } + @Test + fun `geodesic・経度の幅 180 度以上・頂点 3 未満はタイルにしない`() { + assertTrue(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0)))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0), geodesic = true))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, -100.0), p(0.0, 80.0), p(10.0, 0.0))))) + assertTrue(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, -90.0), p(0.0, 89.0), p(10.0, 0.0))))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))))) + } + @Test fun `交わらない穴は計算せずにそのまま返す`() { val holes = listOf(square(0.0, 0.0, 1.0), square(0.0, 2.0, 1.0), square(2.0, 0.0, 1.0)).map(::clockwise) From 8a973d4ed477e3970d266c82764d60e7ef6e590b Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:09:00 -0800 Subject: [PATCH 3/6] Test that a polygon moving from tiles to native drawing gets no onAdded --- .../core/polygon/PolygonTilerDeviceTest.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt index bf1f638..5d36149 100644 --- a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt @@ -286,6 +286,29 @@ class PolygonTilerDeviceTest { Unit } + /** タイルからネイティブへ移っても onAdded は呼ばない(地図に載ったまま) */ + @Test + fun movingFromTilesToNativeCallsNoOnAdded() = + runBlocking { + val (controller, renderer, templates) = controller() + val added = mutableListOf() + val states = parcels(2000).onEach { state -> state.onAdded = { synchronized(added) { added.add(it.id) } } } + controller.add(states) + assertEquals(2000, synchronized(added) { added.size }) + synchronized(added) { added.clear() } + val before = templates.last() + + states[101].geodesic = true + controller.update(states[101]) + withTimeout(2000) { + while (synchronized(templates) { templates.last() } == before) delay(5) + } + assertEquals(listOf("parcel-101"), renderer.added) + // 同じリストを渡し直しても、地図に載ったまま + controller.add(states) + assertTrue(synchronized(added) { added.toList() }.toString(), synchronized(added) { added.isEmpty() }) + } + /** The alpha of the tile pixel at a coordinate. */ private fun alphaAt( template: String, From 7769bba64b0b8713d42dc7718efb64197a38f04c Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:41:22 -0800 Subject: [PATCH 4/6] Device tests: tile one polygon more than the threshold, and check onAdded where it is called The move-to-native tests used exactly 2,000 parcels, so re-adding the list dropped below the threshold. onAdded is called by PolygonOverlay, not by the controller, so the no-onAdded-on-move test belongs in OverlayOnAddedDeviceTest. --- .../core/OverlayOnAddedDeviceTest.kt | 18 +++++++++++++ .../core/polygon/PolygonTilerDeviceTest.kt | 26 ++----------------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/androidTest/java/com/mapconductor/core/OverlayOnAddedDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/OverlayOnAddedDeviceTest.kt index 7c31ac3..e3bff41 100644 --- a/src/androidTest/java/com/mapconductor/core/OverlayOnAddedDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/OverlayOnAddedDeviceTest.kt @@ -155,6 +155,24 @@ class OverlayOnAddedDeviceTest { assertTrue(events.toString(), events.none { it.startsWith("onAdded") }) } + @Test + fun aTiledPolygonMadeGeodesicInPlaceMovesToNativeAndCallsNothing() { + val map = map(minPolygonCount = 2) + val overlay = overlay() + val states = List(3) { square(it) } + overlay.show(map, states) + assertEquals(3, events.count { it.startsWith("onAdded") }) + events.clear() + + // No longer tileable: drawn natively from now on, still on the map. + states[0].geodesic = true + runBlocking { map.updatePolygon(states[0]) } + assertTrue(events.toString(), "native:p0" in events) + overlay.show(map, states) + + assertTrue(events.toString(), events.none { it.startsWith("onAdded") }) + } + @Test fun movingBetweenNativeAndTilesWhileOnTheMapCallsNothing() { val map = map(minPolygonCount = 4) diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt index 5d36149..3d5b30d 100644 --- a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt @@ -225,7 +225,8 @@ class PolygonTilerDeviceTest { private suspend fun movesToNative(change: (PolygonState) -> Unit): Triple> { val (controller, renderer, templates) = controller() - val states = parcels(2000) + // しきい値(2,000)より 1 つ多い。移った 1 つを除いても、渡し直したときに残りはタイルのまま。 + val states = parcels(2001) controller.add(states) assertTrue(renderer.onMap.isEmpty()) val before = templates.last()!! @@ -286,29 +287,6 @@ class PolygonTilerDeviceTest { Unit } - /** タイルからネイティブへ移っても onAdded は呼ばない(地図に載ったまま) */ - @Test - fun movingFromTilesToNativeCallsNoOnAdded() = - runBlocking { - val (controller, renderer, templates) = controller() - val added = mutableListOf() - val states = parcels(2000).onEach { state -> state.onAdded = { synchronized(added) { added.add(it.id) } } } - controller.add(states) - assertEquals(2000, synchronized(added) { added.size }) - synchronized(added) { added.clear() } - val before = templates.last() - - states[101].geodesic = true - controller.update(states[101]) - withTimeout(2000) { - while (synchronized(templates) { templates.last() } == before) delay(5) - } - assertEquals(listOf("parcel-101"), renderer.added) - // 同じリストを渡し直しても、地図に載ったまま - controller.add(states) - assertTrue(synchronized(added) { added.toList() }.toString(), synchronized(added) { added.isEmpty() }) - } - /** The alpha of the tile pixel at a coordinate. */ private fun alphaAt( template: String, From 32b88a0a3dc31e645d2b44cc1d1af42d40120eb8 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 06:59:45 -0800 Subject: [PATCH 5/6] Update the API baseline Additions only (this branch had not run apiDump); in core, one member moved. --- api/android-sdk-core.api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index bf1abd4..755ef29 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -1692,11 +1692,11 @@ public final class com.mapconductor.core.marker.MarkerCapableInterface$DefaultIm } public final class com.mapconductor.core.marker.MarkerCollector implements com.mapconductor.core.OverlayCollectorInterface { public com.mapconductor.core.marker.MarkerCollector(long, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); + public kotlinx.coroutines.flow.MutableStateFlow> getFlow(); public java.lang.Object add(com.mapconductor.core.marker.MarkerState, kotlin.coroutines.Continuation); public void remove(java.lang.String); public void setUpdateHandler(kotlin.jvm.functions.Function2, ? extends java.lang.Object>); public void replaceAll(java.util.List); - public kotlinx.coroutines.flow.MutableStateFlow> getFlow(); public java.lang.Object add(com.mapconductor.core.ComponentState, kotlin.coroutines.Continuation); public com.mapconductor.core.marker.MarkerCollector(long, kotlinx.coroutines.CoroutineScope, kotlin.jvm.internal.DefaultConstructorMarker); } From cc213dda3c59de8ff37cbd23fd576219b34f80b3 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 09:37:58 -0800 Subject: [PATCH 6/6] Draw large polyline sets as raster tiles (PolylineTiler) PolylineTilingOptions / PolylineTileSet / PolylineTiler / POLYLINE_TILE_LAYER_ID / polylineTileRasterLayerState and PolylineController.tiler, the same shape as the polygon tiling: tiled polylines stay in PolylineManager for taps and give the renderer nothing (their entity's polyline is null). The tiles share everything below the polygon ones: ShapeTileSet (route, version in the URL, 16 ms coalescing, removal slots), ShapeTiler (routing), ShapeTileRenderer (grid index, per-zoom simplification, LRU cache and stroke-wide invalidation, pooled scratch bitmaps, PNG, cancellation) and ShapeGridIndex. PolygonTileSet, PolygonTiler and PolygonTileRenderer are thin layers over them now. A polyline tile is an open path: snapped to the zoom's pixel grid, Douglas-Peucker at half a pixel with both ends kept, round caps and joins, the stroke as wide in dp at every zoom, a dot as wide as the line when it collapses to a point. Candidates are taken half the widest stroke beyond the tile and drawn whole, so a line crossing a tile edge is the same on both sides. Taps: tapTolerance + strokeWidth / 2, nearest line, with each line's bounding box (made on registration) checked first. PolylineOverlay takes an optional per-map PolylineTilingOptions flow, applied to the map's tilers before every hand-over. --- api/android-sdk-core.api | 77 +++- .../PolylineTileRendererDeviceTest.kt | 281 ++++++++++++ .../core/polyline/PolylineTilerDeviceTest.kt | 381 ++++++++++++++++ .../core/controller/BaseMapViewController.kt | 6 + .../core/polygon/PolygonTileGeometry.kt | 57 +-- .../core/polygon/PolygonTileRenderer.kt | 397 ++++------------- .../core/polygon/PolygonTileSet.kt | 137 ++---- .../mapconductor/core/polygon/PolygonTiler.kt | 73 +-- .../core/polyline/PolylineController.kt | 84 +++- .../core/polyline/PolylineManager.kt | 70 ++- .../core/polyline/PolylineOverlay.kt | 41 +- .../core/polyline/PolylineTileGeometry.kt | 192 ++++++++ .../core/polyline/PolylineTileSet.kt | 103 +++++ .../core/polyline/PolylineTiler.kt | 90 ++++ .../core/polyline/PolylineTilingOptions.kt | 32 ++ .../core/shapetile/ShapeGridIndex.kt | 90 ++++ .../core/shapetile/ShapeTileRenderer.kt | 417 ++++++++++++++++++ .../core/shapetile/ShapeTileSet.kt | 166 +++++++ .../mapconductor/core/shapetile/ShapeTiler.kt | 82 ++++ .../core/controller/OverlayStackingTest.kt | 53 +++ .../core/polyline/PolylineHitToleranceTest.kt | 169 +++++++ .../core/polyline/PolylineTilingTest.kt | 144 ++++++ .../core/raster/RasterLayerCoexistenceTest.kt | 21 +- 23 files changed, 2595 insertions(+), 568 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/core/polyline/PolylineTileRendererDeviceTest.kt create mode 100644 src/androidTest/java/com/mapconductor/core/polyline/PolylineTilerDeviceTest.kt create mode 100644 src/main/java/com/mapconductor/core/polyline/PolylineTileGeometry.kt create mode 100644 src/main/java/com/mapconductor/core/polyline/PolylineTileSet.kt create mode 100644 src/main/java/com/mapconductor/core/polyline/PolylineTiler.kt create mode 100644 src/main/java/com/mapconductor/core/polyline/PolylineTilingOptions.kt create mode 100644 src/main/java/com/mapconductor/core/shapetile/ShapeGridIndex.kt create mode 100644 src/main/java/com/mapconductor/core/shapetile/ShapeTileRenderer.kt create mode 100644 src/main/java/com/mapconductor/core/shapetile/ShapeTileSet.kt create mode 100644 src/main/java/com/mapconductor/core/shapetile/ShapeTiler.kt create mode 100644 src/test/java/com/mapconductor/core/polyline/PolylineHitToleranceTest.kt create mode 100644 src/test/java/com/mapconductor/core/polyline/PolylineTilingTest.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 755ef29..ed427f5 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -2373,16 +2373,6 @@ public final class com.mapconductor.core.polygon.PolygonTileRenderer implements } public final class com.mapconductor.core.polygon.PolygonTileRenderer$Companion { } -final class com.mapconductor.core.polygon.PolygonTileRenderer$Model { - public final com.mapconductor.core.polygon.TilePolygon[] getPolygons(); - public final com.mapconductor.core.polygon.PolygonGridIndex getIndex(); - public final float getMaxStrokePx(); - public final java.util.LinkedHashMap> getPerZoom(); -} -final class com.mapconductor.core.polygon.PolygonTileRenderer$Simplified { - public final com.mapconductor.core.polygon.TilePolygon getSource(); - public final com.mapconductor.core.polygon.TileShape getShape(); -} public final class com.mapconductor.core.polygon.PolygonTileRenderer$TileRenderCancelled extends java.lang.Exception { } public final class com.mapconductor.core.polygon.PolygonTileSet { @@ -2540,6 +2530,8 @@ public abstract class com.mapconductor.core.polyline.PolylineController getClickListener(); public final void setClickListener(kotlin.jvm.functions.Function1); + public final com.mapconductor.core.polyline.PolylineTiler getTiler(); + public final void setTiler(com.mapconductor.core.polyline.PolylineTiler); public final void dispatchClick(com.mapconductor.core.polyline.PolylineEvent); public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); public java.lang.Object update(com.mapconductor.core.polyline.PolylineState, kotlin.coroutines.Continuation); @@ -2634,9 +2626,11 @@ public interface com.mapconductor.core.polyline.PolylineManagerInterface { - public com.mapconductor.core.polyline.PolylineOverlay(kotlinx.coroutines.flow.StateFlow>); + public com.mapconductor.core.polyline.PolylineOverlay(kotlinx.coroutines.flow.StateFlow>, kotlinx.coroutines.flow.StateFlow); + public com.mapconductor.core.polyline.PolylineOverlay(kotlinx.coroutines.flow.StateFlow, kotlinx.coroutines.flow.StateFlow, int, kotlin.jvm.internal.DefaultConstructorMarker); public kotlinx.coroutines.flow.StateFlow> getFlow(); public java.lang.Object render(java.util.Map, com.mapconductor.core.controller.MapViewControllerInterface, kotlin.coroutines.Continuation); + public com.mapconductor.core.polyline.PolylineOverlay(kotlinx.coroutines.flow.StateFlow>); } public interface com.mapconductor.core.polyline.PolylineOverlayRendererInterface { public abstract java.lang.Object onAdd(java.util.List, kotlin.coroutines.Continuation>); @@ -2682,6 +2676,67 @@ public final class com.mapconductor.core.polyline.PolylineState implements com.m public com.mapconductor.core.polyline.PolylineState(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.core.polyline.PolylineState(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); } +public final class com.mapconductor.core.polyline.PolylineTileSet { + public static final com.mapconductor.core.polyline.PolylineTileSet$Companion Companion; + public com.mapconductor.core.polyline.PolylineTileSet(com.mapconductor.core.tileserver.LocalTileServer, kotlinx.coroutines.CoroutineScope); + public com.mapconductor.core.polyline.PolylineTileSet(com.mapconductor.core.tileserver.LocalTileServer, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final java.lang.String getRouteId(); + public final kotlin.jvm.functions.Function2, java.lang.Object> getOnTemplateChanged(); + public final void setOnTemplateChanged(kotlin.jvm.functions.Function2, ? extends java.lang.Object>); + public final int getTileSize(); + public final java.lang.Object setPolylines(java.util.List, kotlin.coroutines.Continuation); + public final boolean has(java.lang.String); + public final void update(com.mapconductor.core.polyline.PolylineState); + public final void remove(java.lang.String); + public final java.lang.String template(); + public final void dispose(); + public com.mapconductor.core.polyline.PolylineTileSet(); +} +public final class com.mapconductor.core.polyline.PolylineTileSet$Companion { + public final boolean canTile(com.mapconductor.core.polyline.PolylineState); + public final int vertexCount(java.util.List); +} +public final class com.mapconductor.core.polyline.PolylineTiler { + public com.mapconductor.core.polyline.PolylineTiler(com.mapconductor.core.polyline.PolylineTilingOptions); + public com.mapconductor.core.polyline.PolylineTiler(com.mapconductor.core.polyline.PolylineTilingOptions, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final com.mapconductor.core.polyline.PolylineTilingOptions getOptions(); + public final void setOptions(com.mapconductor.core.polyline.PolylineTilingOptions); + public final kotlin.jvm.functions.Function3, java.lang.Object> getOnTemplate(); + public final void setOnTemplate(kotlin.jvm.functions.Function3, ? extends java.lang.Object>); + public final java.lang.Object route(java.util.List, kotlin.coroutines.Continuation>); + public final java.util.List tiledStates(); + public final boolean isTiled(java.lang.String); + public final void update(com.mapconductor.core.polyline.PolylineState); + public final void remove(java.lang.String); + public final void dispose(); + public final java.lang.Object clear(kotlin.coroutines.Continuation); + public com.mapconductor.core.polyline.PolylineTiler(); +} +public final class com.mapconductor.core.polyline.PolylineTilerKt { + public static final java.lang.String POLYLINE_TILE_LAYER_ID; + public static final com.mapconductor.core.raster.RasterLayerState polylineTileRasterLayerState(java.lang.String, int); +} +public final class com.mapconductor.core.polyline.PolylineTilingOptions { + public static final com.mapconductor.core.polyline.PolylineTilingOptions$Companion Companion; + public com.mapconductor.core.polyline.PolylineTilingOptions(boolean, int, int); + public com.mapconductor.core.polyline.PolylineTilingOptions(boolean, int, int, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final boolean getEnabled(); + public final int getMinPolylineCount(); + public final int getMinVertexCount(); + public final boolean shouldUseTiles(int, int); + public final boolean component1(); + public final int component2(); + public final int component3(); + public final com.mapconductor.core.polyline.PolylineTilingOptions copy(boolean, int, int); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public com.mapconductor.core.polyline.PolylineTilingOptions(); +} +public final class com.mapconductor.core.polyline.PolylineTilingOptions$Companion { + public final com.mapconductor.core.polyline.PolylineTilingOptions getDefault(); + public final com.mapconductor.core.polyline.PolylineTilingOptions getDisabled(); +} public final class com.mapconductor.core.projection.Earth { public static final com.mapconductor.core.projection.Earth INSTANCE; public static final double RADIUS_METERS; diff --git a/src/androidTest/java/com/mapconductor/core/polyline/PolylineTileRendererDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polyline/PolylineTileRendererDeviceTest.kt new file mode 100644 index 0000000..029ca43 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/polyline/PolylineTileRendererDeviceTest.kt @@ -0,0 +1,281 @@ +package com.mapconductor.core.polyline + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.shapetile.ShapeTileRenderer +import com.mapconductor.core.tileserver.TileRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.PI +import kotlin.math.ln +import kotlin.math.sin +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import kotlinx.coroutines.runBlocking + +/** + * ポリラインタイルの画素。tile-sw.js の「Shape tiles」節のポリラインと同じ描き方であることを固定する。 + * PolygonTileRendererDeviceTest と同じく、1 単位 = 1 画素(pixelScale = 1)で描かせる。 + * + * z0 のタイルは世界全体が 256 画素。経度 λ は x = (λ + 180) / 360 * 256、赤道は y = 128。 + * js-sdk-core の `PolylineTileRendering.test.mjs`、ios-sdk の `PolylineTileRendererTests` と 1 対 1。 + */ +@RunWith(AndroidJUnit4::class) +class PolylineTileRendererDeviceTest { + private val red = Color(0xFFFF0000) + private val blue = Color(0xFF0000FF) + private val green = Color(0xFF00FF00) + + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + private fun renderer(vararg states: PolylineState) = + ShapeTileRenderer(tileSize = 256, pixelScale = { 1f }, painter = PolylineTilePainter).apply { + setItems(states.map(TilePolyline::from)) + } + + private fun alpha(pixel: Int) = pixel ushr 24 + + private fun rgb(pixel: Int) = pixel and 0xFFFFFF + + private fun centre(bitmap: Bitmap) = bitmap.getPixel(bitmap.width / 2, bitmap.height / 2) + + /** The rows of column [x] that are painted (alpha over half), top to bottom. */ + private fun paintedRows( + bitmap: Bitmap, + x: Int, + ): List = (0 until bitmap.height).filter { alpha(bitmap.getPixel(x, it)) > 0x80 } + + /** World y (in pixels of a 256 * 2^z world) of [lat]. */ + private fun worldY( + lat: Double, + z: Int, + ): Double { + val s = sin(lat * PI / 180) + return (0.5 - ln((1 + s) / (1 - s)) / (4 * PI)) * 256.0 * (1 shl z) + } + + /** 水平線は期待した行に、線幅ぶんの太さで出る */ + @Test + fun horizontalLineIsOnItsRowAsWideAsItsStroke() { + val bitmap = + renderer( + PolylineState(points = listOf(p(0.0, -90.0), p(0.0, 90.0)), strokeColor = red, strokeWidth = 4.dp), + ).renderBitmap(0, 0, 0)!! + // 赤道は y = 128。幅 4 なら 126..129 + assertEquals(listOf(126, 127, 128, 129), paintedRows(bitmap, 128)) + assertEquals(0xFF0000, rgb(bitmap.getPixel(128, 127))) + // 端は丸く、線幅の半分だけ先まで: 経度 -90 度は x = 64、その 2 画素手前まで + assertTrue(alpha(bitmap.getPixel(63, 128)) > 0x80) + assertEquals(0, alpha(bitmap.getPixel(60, 128))) + // 塗らない(線の外は透明) + assertEquals(0, alpha(bitmap.getPixel(128, 100))) + } + + /** タイルの境目にある線は、両側のタイルに同じ行・太さで切れ目なく出る */ + @Test + fun lineOnATileEdgeIsDrawnOnBothSidesWithoutAGap() { + // z1: 赤道は y 方向のタイルの境目(世界 512 画素の y = 256) + val r = + renderer( + PolylineState(points = listOf(p(0.0, -90.0), p(0.0, 90.0)), strokeColor = red, strokeWidth = 4.dp), + ) + val north = r.renderBitmap(0, 0, 1)!! + val south = r.renderBitmap(0, 1, 1)!! + // 経度 -45 度は z1 の x = 192 + assertEquals("北のタイルの下 2 行", listOf(254, 255), paintedRows(north, 192)) + assertEquals("南のタイルの上 2 行", listOf(0, 1), paintedRows(south, 192)) + // 東西のタイルの境目(経度 0 度)を横切っても、両側の端の列で同じ行 + val east = r.renderBitmap(1, 0, 1)!! + assertEquals(paintedRows(north, 255), paintedRows(east, 0)) + assertEquals(listOf(254, 255), paintedRows(east, 0)) + } + + /** 斜めの線もタイルの境目で切れない: 境目の両側の端の列で、塗った行がつながる */ + @Test + fun slantedLineContinuesAcrossTheTileEdge() { + val r = + renderer( + PolylineState(points = listOf(p(30.0, -20.0), p(10.0, 20.0)), strokeColor = blue, strokeWidth = 3.dp), + ) + // z2: 経度 0 度は x 方向のタイル 1 と 2 の境目 + val y = (worldY(20.0, 2) / 256).toInt() + val west = r.renderBitmap(1, y, 2)!! + val east = r.renderBitmap(2, y, 2)!! + val westRows = paintedRows(west, 255) + val eastRows = paintedRows(east, 0) + assertTrue(westRows.isNotEmpty() && eastRows.isNotEmpty()) + // 1 列ぶん右へ下がる線なので、重なるか 1 行ずれるだけ + assertTrue( + "$westRows / $eastRows", + westRows.first() <= eastRows.last() && eastRows.first() <= westRows.last() + 1, + ) + } + + /** ズームを変えても線幅(画素)は同じ */ + @Test + fun strokeKeepsItsWidthAcrossZooms() { + val r = + renderer( + PolylineState(points = listOf(p(20.0, -170.0), p(20.0, 170.0)), strokeColor = blue, strokeWidth = 4.dp), + ) + val widths = + (2..5).map { z -> + val y = (worldY(20.0, z) / 256).toInt() + val bitmap = r.renderBitmap(1, y, z)!! + paintedRows(bitmap, 128).size + } + assertEquals(listOf(4, 4, 4, 4), widths) + } + + /** 1 画素に満たない線は線幅の丸(点)になる */ + @Test + fun subPixelLineIsADotAsWideAsTheLine() { + val bitmap = + renderer( + PolylineState(points = listOf(p(0.0, 0.0), p(0.0, 0.001)), strokeColor = green, strokeWidth = 4.dp), + ).renderBitmap(0, 0, 0)!! + var painted = 0 + for (py in 120..136) { + for (px in 120..136) { + val pixel = bitmap.getPixel(px, py) + if (alpha(pixel) > 0x80) { + painted++ + assertEquals(0x00FF00, rgb(pixel)) + } + } + } + // 直径 4 の丸: 12 画素前後 + assertTrue("painted=$painted", painted in 8..16) + } + + /** zIndex の大きい線が上、同じなら後に渡した線が上 */ + @Test + fun higherZIndexIsOnTop_thenLaterInTheList() = + runBlocking { + val set = PolylineTileSet() + try { + val over = + PolylineState(points = listOf(p(-10.0, 0.0), p(10.0, 0.0)), strokeColor = red, strokeWidth = 6.dp, zIndex = 1) + val under = + PolylineState(points = listOf(p(0.0, -10.0), p(0.0, 10.0)), strokeColor = blue, strokeWidth = 6.dp, zIndex = 0) + set.setPolylines(listOf(over, under)) + // The set draws at the device's density: the crossing is the bitmap's centre. + assertEquals("zIndex 1 が上", 0xFF0000, rgb(centre(set.renderer.renderBitmap(0, 0, 0)!!))) + + val first = + PolylineState(points = listOf(p(-10.0, 0.0), p(10.0, 0.0)), strokeColor = red, strokeWidth = 6.dp) + val second = + PolylineState(points = listOf(p(0.0, -10.0), p(0.0, 10.0)), strokeColor = green, strokeWidth = 6.dp) + set.setPolylines(listOf(first, second)) + assertEquals("後に渡した線が上", 0x00FF00, rgb(centre(set.renderer.renderBitmap(0, 0, 0)!!))) + } finally { + set.dispose() + } + } + + /** 透明な線・幅 0 の線は描かない。何も無いタイルは null(空) */ + @Test + fun invisibleLinesDrawNothing_andEmptyTilesAreNull() { + val r = + renderer( + PolylineState( + points = listOf(p(10.0, 10.0), p(20.0, 20.0)), strokeColor = Color.Transparent, + strokeWidth = + 4 + .dp, + ), + PolylineState(points = listOf(p(10.0, 10.0), p(20.0, 30.0)), strokeColor = red, strokeWidth = 0.dp), + ) + // 北東のタイルにはあるが、見えないものだけ + assertNull(r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + // 北西のタイルには何も無い + assertNull(r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + } + + /** 色を変えたら、その線のタイルだけ描き直す(他はキャッシュ) */ + @Test + fun inPlaceChangeRedrawsOnlyTheTilesItTouches() { + val west = + PolylineState( + points = listOf(p(10.0, -100.0), p(20.0, -90.0)), strokeColor = blue, id = "west", + strokeWidth = + 3 + .dp, + ) + val east = + PolylineState( + points = listOf(p(10.0, 90.0), p(20.0, 100.0)), strokeColor = blue, id = "east", + strokeWidth = + 3 + .dp, + ) + val r = renderer(west, east) + val westTile = TileRequest(x = 0, y = 0, z = 1) + val eastTile = TileRequest(x = 1, y = 0, z = 1) + val westBefore = r.renderTile(westTile)!! + val eastBefore = r.renderTile(eastTile)!! + + west.strokeColor = green + r.update(listOf(0 to TilePolyline.from(west))) + + val westAfter = r.renderTile(westTile)!! + assertNotSame("変えた線のタイルは描き直す", westBefore, westAfter) + assertSame("触れていないタイルはキャッシュから返す", eastBefore, r.renderTile(eastTile)) + val decoded = BitmapFactory.decodeByteArray(westAfter, 0, westAfter.size) + // 線の中点(経度 -95 度、北緯 ~15 度)は z1 で x = 120 + val y = worldY(15.0, 1).toInt() + val mid = (y - 3..y + 3).map { decoded.getPixel(120, it) }.firstOrNull { alpha(it) > 0x80 } + assertNotNull(mid) + assertEquals(0x00FF00, rgb(mid!!)) + } + + /** 外した線(null)は、重なっていたタイルだけ描き直して消える */ + @Test + fun removedLineRedrawsOnlyItsTiles() { + val west = PolylineState(points = listOf(p(10.0, -100.0), p(20.0, -90.0)), strokeColor = blue, id = "west") + val east = PolylineState(points = listOf(p(10.0, 90.0), p(20.0, 100.0)), strokeColor = blue, id = "east") + val r = renderer(west, east) + assertNotNull(r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + val eastBefore = r.renderTile(TileRequest(x = 1, y = 0, z = 1))!! + r.update(listOf(0 to null)) + assertNull(r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + assertSame(eastBefore, r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + } + + /** 太い線の色を変えたら、線幅ぶん隣のタイルにはみ出した所も描き直す */ + @Test + fun aChangeRedrawsTheNeighbourTheStrokeReachesInto() { + // z1 の北のタイルの下端に沿う太い線: 南のタイルの上端にもはみ出す + val line = + PolylineState(points = listOf(p(0.5, -90.0), p(0.5, -10.0)), strokeColor = blue, strokeWidth = 10.dp, id = "l") + val r = renderer(line) + val south = TileRequest(x = 0, y = 1, z = 1) + val before = r.renderTile(south)!! + line.strokeColor = red + r.update(listOf(0 to TilePolyline.from(line))) + val after = r.renderTile(south)!! + assertNotSame(before, after) + val decoded = BitmapFactory.decodeByteArray(after, 0, after.size) + assertEquals(0xFF0000, rgb(decoded.getPixel(192, 0))) + } + + /** 取りやめられたタイルは空ではなく失敗にする */ + @Test + fun cancelledTileFailsInsteadOfComingBackEmpty() { + val r = renderer(PolylineState(points = listOf(p(0.0, 0.0), p(10.0, 10.0)), strokeColor = blue)) + val thrown = runCatching { r.renderTile(TileRequest(x = 0, y = 0, z = 0)) { true } }.exceptionOrNull() + assertTrue(thrown is ShapeTileRenderer.TileRenderCancelled) + } +} diff --git a/src/androidTest/java/com/mapconductor/core/polyline/PolylineTilerDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polyline/PolylineTilerDeviceTest.kt new file mode 100644 index 0000000..894180b --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/polyline/PolylineTilerDeviceTest.kt @@ -0,0 +1,381 @@ +package com.mapconductor.core.polyline + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.controller.BaseMapViewController +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.tileserver.TileServerRegistry +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.net.HttpURLConnection +import java.net.URL +import kotlin.math.ln +import kotlin.math.sin +import android.graphics.BitmapFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +/** + * PolylineController と PolylineTiler の振り分け。PolygonTilerDeviceTest と同じ規則: + * タイルにした線は PolylineManager に残し(タップ用、元の点のまま)、レンダラには渡さない。 + * js-sdk-core の PolylineController(tiler)、ios-sdk-core の PolylineTilerTests と同じ内容。 + * + * PolylineTileSet が本物のタイルサーバーに経路を作るので、端末で動かす。 + */ +@RunWith(AndroidJUnit4::class) +class PolylineTilerDeviceTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + private val events = mutableListOf() + + /** 東西に 0.001 度の道を n 本、ボストンに並べる。 */ + private fun roads( + n: Int, + color: Color = Color(0xFF2563EB), + ): List = + List(n) { i -> + val lat = 42.30 + (i / 100) * 0.0012 + val lng = -71.10 + (i % 100) * 0.0012 + PolylineState( + points = listOf(p(lat, lng), p(lat, lng + 0.0005), p(lat, lng + 0.001)), + id = "road-$i", + strokeColor = color, + strokeWidth = 4.dp, + onAdded = { events += "onAdded:${it.id}" }, + ) + } + + private inner class Renderer : PolylineOverlayRendererInterface { + val onMap = mutableSetOf() + val added = mutableListOf() + val changed = mutableListOf() + val removed = mutableListOf() + + override suspend fun onAdd(data: List): List = + data.map { params -> + events += "native:${params.state.id}" + params.state.id.also { + onMap.add(it) + added.add(it) + } + } + + override suspend fun onChange( + data: List>, + ): List = + data.map { params -> + params.current.state.id + .also { changed.add(it) } + } + + override suspend fun onRemove(data: List>) { + data.forEach { + onMap.remove(it.state.id) + removed.add(it.state.id) + } + } + + override suspend fun onPostProcess() {} + } + + private class Controller( + renderer: PolylineOverlayRendererInterface, + ) : PolylineController(PolylineManager(), renderer) + + private class Templates { + val seen = mutableListOf() + + fun last() = synchronized(this) { seen.lastOrNull() } + } + + private fun controller( + options: PolylineTilingOptions = PolylineTilingOptions.Default, + ): Triple { + val renderer = Renderer() + val controller = Controller(renderer) + val templates = Templates() + controller.tiler = + PolylineTiler(options).apply { + onTemplate = { template, _ -> synchronized(templates) { templates.seen.add(template) } } + } + return Triple(controller, renderer, templates) + } + + /** z16 のボストン(タップの許容距離を画面の大きさで測るため)。 */ + private suspend fun Controller.atZoom16() = + onCameraChanged(MapCameraPosition(position = p(42.30, -71.10), zoom = 16.0)) + + /** しきい値を超えたらネイティブには何も渡さず、タップ用にマネージャへ残す */ + @Test + fun pastThresholdNothingGoesNative_andTapsStillHit() = + runBlocking { + val (controller, renderer, templates) = controller() + controller.atZoom16() + controller.add(roads(2000)) + assertTrue(renderer.onMap.isEmpty()) + assertEquals(2000, controller.polylineManager.allEntities().size) + assertNotNull(templates.last()) + // タップ判定は元の線で: road-101 は北緯 42.3012、経度 -71.0988..-71.0978 + val hit = controller.findWithClosestPoint(p(42.30125, -71.0983)) + assertEquals("road-101", hit?.entity?.state?.id) + assertEquals(42.3012, hit!!.closestPoint.latitude, 1e-7) + // 道と道の間(南北に 0.0012 度 ≒ 133 m 離れている)の真ん中は外れ + assertNull(controller.find(p(42.3018, -71.0983))) + } + + /** しきい値を下回ったらネイティブに戻し、上回ったらネイティブから外す */ + @Test + fun crossingThresholdMovesPolylinesBetweenNativeAndTiles() = + runBlocking { + val (controller, renderer, templates) = controller() + controller.add(roads(1999)) + assertEquals(1999, renderer.onMap.size) + assertNull(templates.last()) + + controller.add(roads(2000)) + assertTrue("タイルへ移ったらネイティブから消す", renderer.onMap.isEmpty()) + assertEquals(2000, controller.polylineManager.allEntities().size) + assertNotNull(templates.last()) + + controller.add(roads(10)) + assertEquals(10, renderer.onMap.size) + assertEquals("リストから消えたものは忘れる", 10, controller.polylineManager.allEntities().size) + assertNull(templates.last()) + } + + /** タイルの線のその場の変更は、版を上げたテンプレートで知らせる */ + @Test + fun inPlaceChangeOfTiledPolylineAnnouncesNewTemplate() = + runBlocking { + val (controller, renderer, templates) = controller() + val states = roads(2000) + controller.add(states) + val before = templates.last()!! + states[5].strokeColor = Color(0xFF16A34A) + controller.update(states[5]) + assertTrue(renderer.onMap.isEmpty()) + withTimeout(2000) { + while (templates.last() == before) delay(5) + } + assertTrue(templates.last()!! != before) + assertEquals( + Color(0xFF16A34A), + controller.polylineManager + .getEntity("road-5") + ?.state + ?.strokeColor, + ) + } + + /** タイルはタイルサーバーから PNG で届き、何も無い場所は透明の 200 */ + @Test + fun tilesAreServedAsPng_andEmptyPlacesAsTransparent200() = + runBlocking { + val (controller, _, templates) = controller() + controller.add(roads(2000)) + val template = templates.last()!! + assertTrue("版が入る: $template", Regex("""/polylines-\w+/256/\d+/\{z\}""").containsMatchIn(template)) + assertTrue(alphaAt(template, 42.3012, -71.0983, 17) > 0) + assertEquals("道と道の間", 0, alphaAt(template, 42.3018, -71.0983, 17)) + val empty = get(template, 14, 100, 12000) + assertEquals(200, empty.first) + } + + /** clear でテンプレートを外し、タイル側の線も忘れる */ + @Test + fun clearRemovesTemplateAndForgetsTiledPolylines() = + runBlocking { + val (controller, _, templates) = controller() + controller.add(roads(2000)) + controller.clear() + assertNull(templates.last()) + assertTrue(controller.polylineManager.allEntities().isEmpty()) + assertTrue(TileServerRegistry.get().isRunning()) + } + + /** 混在リストでは geodesic だけネイティブ、タイルには描かれない */ + @Test + fun inAMixedListOnlyTheGeodesicOneGoesNative() = + runBlocking { + val (controller, renderer, templates) = controller() + val g = + PolylineState( + points = listOf(p(42.2905, -71.10), p(42.2905, -71.099)), + id = "g", + strokeColor = Color(0xFFDC2626), + strokeWidth = 4.dp, + geodesic = true, + ) + controller.add(roads(2000) + g) + assertEquals(setOf("g"), renderer.onMap) + val template = templates.last()!! + assertEquals("タイルには描かない", 0, alphaAt(template, 42.2905, -71.0995, 17)) + assertTrue("道はタイルに描く", alphaAt(template, 42.3012, -71.0983, 17) > 0) + } + + /** その場で geodesic にしたらネイティブへ移り(add ではない)、タイルから消える。隣はタイルに残る */ + @Test + fun settingGeodesicInPlaceMovesItToNative() = + runBlocking { + val (controller, renderer, templates) = controller() + val states = roads(2001) + controller.add(states) + val before = templates.last()!! + assertTrue(alphaAt(before, 42.3012, -71.0983, 17) > 0) + + states[101].geodesic = true + controller.update(states[101]) + withTimeout(2000) { + while (templates.last() == before) delay(5) + } + assertEquals(listOf("road-101"), renderer.added) + assertEquals("road-101", controller.polylineManager.getEntity("road-101")?.polyline) + val after = templates.last()!! + assertEquals("タイルから消える", 0, alphaAt(after, 42.3012, -71.0983, 17)) + assertTrue("隣はタイルに残る", alphaAt(after, 42.3012, -71.0971, 17) > 0) + + // 同じリストを渡し直しても、ネイティブのまま(足しも消しもしない) + controller.add(states) + assertEquals(listOf("road-101"), renderer.added) + assertTrue(renderer.removed.isEmpty()) + } + + // ── onAdded と地図ごとの指定(PolylineOverlay 経由、本物の BaseMapViewController) ────── + + private class Driver( + val polylines: Controller, + ) : BaseMapViewController() { + override val holder get() = throw UnsupportedOperationException() + override val defaultCoroutine = CoroutineScope(Dispatchers.Unconfined) + override val mainCoroutine = defaultCoroutine + + init { + registerOverlayController(polylines) + } + + 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() = polylines.clear() + } + + private fun PolylineOverlay.show( + map: Driver, + states: List, + ) = runBlocking { render(states.associateBy { it.id }.toMutableMap(), map) } + + /** タイルで地図に載った線にも onAdded。その場の変更とネイティブへの移動では呼ばない */ + @Test + fun onAddedOnTheTilePath() { + // 4 本で 3 本以上ならタイル: 1 本がネイティブへ移っても、残りはタイルのまま + val (controller, renderer, _) = controller(PolylineTilingOptions(minPolylineCount = 3)) + val map = Driver(controller) + val overlay = PolylineOverlay(MutableStateFlow(mutableMapOf())) + val states = roads(4) + overlay.show(map, states) + assertEquals(listOf("onAdded:road-0", "onAdded:road-1", "onAdded:road-2", "onAdded:road-3"), events) + assertTrue(renderer.onMap.isEmpty()) + events.clear() + + states[0].strokeColor = Color.Red + runBlocking { map.updatePolyline(states[0]) } + states[1].geodesic = true + runBlocking { map.updatePolyline(states[1]) } + overlay.show(map, states) + assertEquals(listOf("native:road-1"), events) + controller.destroy() + } + + /** 地図ごとの指定(PolylineOverlay の tilingOptions)は、次に線を渡すときに効く */ + @Test + fun perMapOptionsApplyOnTheNextHandOver() { + val (controller, renderer, templates) = controller() + val map = Driver(controller) + val options = MutableStateFlow(PolylineTilingOptions.Disabled) + val overlay = PolylineOverlay(MutableStateFlow(mutableMapOf()), options) + val states = roads(2000) + + overlay.show(map, states) + assertEquals("Disabled: 全部ネイティブ", 2000, renderer.onMap.size) + assertNull(templates.last()) + + options.value = PolylineTilingOptions.Default + overlay.show(map, states) + assertTrue("Default: 全部タイル", renderer.onMap.isEmpty()) + assertNotNull(templates.last()) + assertEquals(PolylineTilingOptions.Default, controller.tiler?.options) + controller.destroy() + } + + /** The alpha of the tile pixel at a coordinate. */ + private fun alphaAt( + template: String, + lat: Double, + lng: Double, + z: Int, + ): Int { + val (x, y) = tileOf(lat, lng, z) + val (code, bytes) = get(template, z, x, y) + assertEquals(200, code) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return 0 + val scale = 256.0 * (1 shl z) + val s = sin(Math.toRadians(lat)) + val px = (lng + 180) / 360 * scale - x * 256 + val py = (0.5 - ln((1 + s) / (1 - s)) / (4 * Math.PI)) * scale - y * 256 + val k = bitmap.width / 256.0 + return bitmap.getPixel((px * k).toInt(), (py * k).toInt()) ushr 24 + } + + private fun tileOf( + lat: Double, + lng: Double, + z: Int, + ): Pair { + val n = 1 shl z + val x = ((lng + 180) / 360 * n).toInt() + val s = sin(Math.toRadians(lat)) + val y = ((0.5 - ln((1 + s) / (1 - s)) / (4 * Math.PI)) * n).toInt() + return x to y + } + + private fun get( + template: String, + z: Int, + x: Int, + y: Int, + ): Pair { + val url = template.replace("{z}", "$z").replace("{x}", "$x").replace("{y}", "$y") + val connection = URL(url).openConnection() as HttpURLConnection + connection.useCaches = false + return try { + val code = connection.responseCode + code to connection.inputStream.use { it.readBytes() } + } finally { + connection.disconnect() + } + } +} diff --git a/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt b/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt index 007a285..aaa2a7c 100644 --- a/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt +++ b/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt @@ -25,7 +25,9 @@ import com.mapconductor.core.polygon.PolygonCapableInterface import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.polyline.OnPolylineEventHandler import com.mapconductor.core.polyline.PolylineCapableInterface +import com.mapconductor.core.polyline.PolylineController import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTiler import com.mapconductor.core.raster.RasterLayerCapableInterface import com.mapconductor.core.raster.RasterLayerController import com.mapconductor.core.raster.RasterLayerState @@ -280,6 +282,10 @@ abstract class BaseMapViewController : .filterIsInstance>() .filter { it.kind == kind } + /** The tilers of this map's polyline controllers, for [com.mapconductor.core.polyline.PolylineOverlay]'s per-map options. */ + internal fun polylineTilers(): List = + overlayControllersOf(OverlayKind.Polyline).mapNotNull { (it as? PolylineController<*>)?.tiler } + /** * [kind] のいずれかのコントローラがこの id を持っているか。`hasXxx` の既定実装。 * diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt index 2dc9ae6..8d1b826 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt @@ -2,6 +2,7 @@ package com.mapconductor.core.polygon import androidx.compose.ui.graphics.toArgb import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.shapetile.ShapeGridIndex import kotlin.math.PI import kotlin.math.abs import kotlin.math.floor @@ -136,11 +137,16 @@ internal fun reversedRing(ring: DoubleArray): DoubleArray { return out } -/** The polygon index: polygons by the grid cells their bounding box covers. */ +/** + * The polygon index: polygons by the grid cells their bounding box covers. + * The grid itself is [ShapeGridIndex], shared with the polyline tiles. + */ internal class PolygonGridIndex( private val cells: HashMap, private val large: IntArray, ) { + private val grid = ShapeGridIndex(cells, large) + /** * The polygons whose bounding box meets the given box, each once, in * ascending index order -- which is draw order. @@ -151,56 +157,13 @@ internal class PolygonGridIndex( minY: Double, maxX: Double, maxY: Double, - ): IntArray { - val out = IntList() - - fun take(i: Int) { - val b = polygons[i]?.bbox ?: return - if (b[0] <= maxX && b[2] >= minX && b[1] <= maxY && b[3] >= minY) out.add(i) - } - for (i in large) take(i) - val cx0 = floor(minX / POLYGON_CELL).toLong() - val cx1 = floor(maxX / POLYGON_CELL).toLong() - val cy0 = floor(minY / POLYGON_CELL).toLong() - val cy1 = floor(maxY / POLYGON_CELL).toLong() - for (cx in cx0..cx1) { - for (cy in cy0..cy1) { - cells[cellKey(cx, cy)]?.forEach(::take) - } - } - // A polygon in several cells was taken once per cell. - return out.toSortedDistinctArray() - } + ): IntArray = grid.query({ polygons[it]?.bbox }, minX, minY, maxX, maxY) companion object { fun build(polygons: Array): PolygonGridIndex { - val cells = HashMap() - val large = IntList() - for (i in polygons.indices) { - val p = polygons[i] ?: continue - val cx0 = floor(p.bbox[0] / POLYGON_CELL).toLong() - val cx1 = floor(p.bbox[2] / POLYGON_CELL).toLong() - val cy0 = floor(p.bbox[1] / POLYGON_CELL).toLong() - val cy1 = floor(p.bbox[3] / POLYGON_CELL).toLong() - if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > POLYGON_MAX_CELLS) { - large.add(i) - continue - } - for (cx in cx0..cx1) { - for (cy in cy0..cy1) { - cells.getOrPut(cellKey(cx, cy)) { IntList() }.add(i) - } - } - } - val packed = HashMap(cells.size * 2) - for ((key, list) in cells) packed[key] = list.toArray() - return PolygonGridIndex(packed, large.toArray()) + val grid = ShapeGridIndex.build(polygons.size) { polygons[it]?.bbox } + return PolygonGridIndex(grid.cells, grid.large) } - - private fun cellKey( - cx: Long, - cy: Long, - ): Long = cx * 1_048_576L + cy } } diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt index 5467497..a1eed80 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt @@ -1,271 +1,126 @@ package com.mapconductor.core.polygon -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Path -import android.util.LruCache -import androidx.core.graphics.createBitmap +import com.mapconductor.core.InternalMapConductorApi import com.mapconductor.core.ResourceProvider -import com.mapconductor.core.tileserver.ScratchPool -import com.mapconductor.core.tileserver.TilePngEncoder +import com.mapconductor.core.shapetile.ShapeTileRenderer import com.mapconductor.core.tileserver.TileProviderInterface import com.mapconductor.core.tileserver.TileRequest -import java.io.ByteArrayOutputStream -import java.util.concurrent.atomic.AtomicLong -import java.util.concurrent.atomic.AtomicReferenceArray -import kotlin.math.max -import kotlin.math.roundToInt +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Path /** - * Draws a set of polygons into raster tiles: the counterpart of the "Polygon - * tiles" section of js-sdk-core's tile-sw.js, served by [com.mapconductor.core.tileserver.LocalTileServer]. + * Draws a set of polygons into raster tiles: the counterpart of the polygon + * part of js-sdk-core's tile-sw.js "Shape tiles", served by + * [com.mapconductor.core.tileserver.LocalTileServer]. + * + * The tiles themselves (index, per-zoom simplification, cache, invalidation, + * scratch bitmaps, encoding, cancellation) are [ShapeTileRenderer]'s, shared + * with the polyline tiles; this is how a polygon is drawn in one: filled + * nonzero, the outline only stroked (never the holes), a polygon under + * [MIN_OUTLINED_PX] without its outline, and one under a pixel as the pixel. * * Geometry is in tile units (a tile is [tileSize] wide whatever its pixels); * a tile is drawn [pixelScale] pixels per unit, so a stroke of 1 dp is one * unit wide, as on the web where it is one CSS pixel. - * - * Tiles are cached here without the version the map sees in their URL. An - * in-place change ([update]) drops only the tiles that overlap what changed; - * the map asks again for every tile because the version moved, and the ones - * the change did not touch come straight back from the cache. - * - * [renderTile] runs concurrently on the tile server's threads. Every change - * replaces the model it reads, so a tile is drawn from one consistent set of - * polygons. */ internal class PolygonTileRenderer( val tileSize: Int = 256, cacheSizeBytes: Int = DEFAULT_CACHE_BYTES, - private val pixelScale: () -> Float = { ResourceProvider.getDensity() }, + pixelScale: () -> Float = { ResourceProvider.getDensity() }, ) : TileProviderInterface { - private class Simplified( - val source: TilePolygon, - val shape: TileShape, - ) - - /** - * What tiles are drawn from. [perZoom] is shared by the models an - * [update] derives from one [setPolygons]: an entry is used only while its - * source is still the polygon at that index, so a changed polygon is - * simplified again without clearing anything. - */ - private class Model( - val polygons: Array, - val index: PolygonGridIndex, - val maxStrokePx: Float, - val perZoom: LinkedHashMap>, - ) - - @Volatile - private var model: Model? = null - - /** Bumped by every change, so a tile drawn before it is not cached after it. */ - private val generation = AtomicLong() - - private val tileCache = - object : LruCache(cacheSizeBytes) { - override fun sizeOf( - key: Long, - value: ByteArray, - ): Int = value.size - } - private val cacheLock = Any() - - // Pools, not thread-locals: tiles are drawn on the map SDK's threads, which can - // outlive this renderer (see ScratchPool) and would keep a tile bitmap each. - private val scratchBitmaps = ScratchPool() - private val byteStreams = ScratchPool() + /** The shared renderer this one draws with; what the tile server is given. */ + internal val shapes: ShapeTileRenderer = + ShapeTileRenderer(tileSize, cacheSizeBytes, pixelScale, PolygonTilePainter) { TileRenderCancelled() } /** Replaces every polygon. Draw order is list order. */ - fun setPolygons(polygons: List) { - val array = polygons.toTypedArray() - val next = Model(array, PolygonGridIndex.build(array), maxStrokeOf(array), LinkedHashMap(8, 0.75f, true)) - model = next - synchronized(cacheLock) { - generation.incrementAndGet() - tileCache.evictAll() - } - } + fun setPolygons(polygons: List) = shapes.setItems(polygons) /** * Replaces the polygons at the given indexes. Only the tiles that * overlapped one of them, before or after, are drawn again. */ - fun update(updates: List>) { - val current = model ?: return - val polygons = current.polygons.copyOf() - val touched = ArrayList(updates.size * 2) - var maxStroke = current.maxStrokePx - var reindex = false - for ((index, after) in updates) { - if (index !in polygons.indices) continue - val before = polygons[index] - polygons[index] = after - before?.let { touched.add(it.bbox) } - after?.let { touched.add(it.bbox) } - if (after != null && after.strokeVisible) maxStroke = max(maxStroke, after.strokeWidth) - // A shape that moved needs the index rebuilt; a restyle does not. - if (before == null || after == null || !before.sameBox(after)) reindex = true - } - val index = if (reindex) PolygonGridIndex.build(polygons) else current.index - model = Model(polygons, index, maxStroke, current.perZoom) - synchronized(cacheLock) { - generation.incrementAndGet() - dropTilesIn(touched, maxStroke) - } - } + fun update(updates: List>) = shapes.update(updates) - fun clear() { - model = null - synchronized(cacheLock) { - generation.incrementAndGet() - tileCache.evictAll() - } - } + fun clear() = shapes.clear() - override fun renderTile(request: TileRequest): ByteArray? = renderTile(request) { false } + override fun renderTile(request: TileRequest): ByteArray? = shapes.renderTile(request) override fun renderTile( request: TileRequest, isCancelled: () -> Boolean, - ): ByteArray? { - val generationAtStart = generation.get() - val m = model ?: return null - val z = request.z - if (z !in 0..MAX_ZOOM) return null - val n = 1 shl z - val x = ((request.x % n) + n) % n - val y = request.y - if (y !in 0 until n) return null - val ratio = request.pixelRatio.coerceIn(1, 3) - val key = cacheKey(z, x, y, ratio) - synchronized(cacheLock) { tileCache.get(key) }?.let { return it } + ): ByteArray? = shapes.renderTile(request, isCancelled) - val bitmap = draw(m, x, y, z, ratio, isCancelled) ?: return null - val bytes = - try { - encode(bitmap) - } finally { - releaseScratch(bitmap) - } - synchronized(cacheLock) { - // The polygons may have changed while this tile was drawn. - if (generation.get() == generationAtStart) tileCache.put(key, bytes) - } - return bytes - } - - /** The tile as a bitmap, or null when nothing of any polygon is in it. For tests and [renderTile]. */ + /** The tile as a bitmap, or null when nothing of any polygon is in it. For tests. */ fun renderBitmap( x: Int, y: Int, z: Int, pixelRatio: Int = 1, - ): Bitmap? = - model?.let { draw(it, x, y, z, pixelRatio) { false } }?.let { scratch -> - try { - scratch.copy(Bitmap.Config.ARGB_8888, false) - } finally { - releaseScratch(scratch) - } - } + ): Bitmap? = shapes.renderBitmap(x, y, z, pixelRatio) - private fun draw( - m: Model, - x: Int, - y: Int, - z: Int, - pixelRatio: Int, - isCancelled: () -> Boolean, - ): Bitmap? { - val scale = tileSize.toDouble() * (1L shl z).toDouble() - val originX = x.toDouble() * tileSize - val originY = y.toDouble() * tileSize - val pad = m.maxStrokePx / 2.0 + 1.0 - val candidates = - m.index.query( - m.polygons, - (originX - pad) / scale, - (originY - pad) / scale, - (originX + tileSize + pad) / scale, - (originY + tileSize + pad) / scale, - ) - if (candidates.isEmpty()) return null + /** Thrown when the map stops wanting a tile mid-draw: a failure it may retry, never an empty tile. */ + class TileRenderCancelled : Exception("polygon tile no longer wanted") - val r = pixelScale() * pixelRatio - val px = (tileSize * r).roundToInt().coerceAtLeast(1) - val bitmap = scratch(px) - try { - return drawInto(bitmap, m, candidates, originX, originY, r, z, scale, isCancelled) - } catch (error: Throwable) { - releaseScratch(bitmap) - throw error - } + companion object { + const val DEFAULT_CACHE_BYTES = ShapeTileRenderer.DEFAULT_CACHE_BYTES } +} + +/** How a polygon is drawn in a shape tile. */ +@InternalMapConductorApi +internal object PolygonTilePainter : ShapeTileRenderer.Painter { + override fun bbox(item: TilePolygon): DoubleArray = item.bbox - /** Draws into [bitmap]; null (and the bitmap back in the pool) when nothing was drawn. */ - private fun drawInto( - bitmap: Bitmap, - m: Model, - candidates: IntArray, + override fun strokeWidth(item: TilePolygon): Float = if (item.strokeVisible) item.strokeWidth else 0f + + override fun simplify( + item: TilePolygon, + scale: Double, + ): Any = simplifyPolygon(item, scale) + + override fun draw( + canvas: Canvas, + item: TilePolygon, + shape: Any, originX: Double, originY: Double, r: Float, - z: Int, - scale: Double, - isCancelled: () -> Boolean, - ): Bitmap? { - val canvas = Canvas(bitmap) - val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } - val strokePaint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - } - val dotPaint = Paint().apply { style = Paint.Style.FILL } - val path = Path().apply { fillType = Path.FillType.WINDING } - val perZoom = perZoomOf(m, z) + paints: ShapeTileRenderer.Paints, + ): Boolean { + val fillVisible = item.fillVisible var drew = false - for ((k, index) in candidates.withIndex()) { - if (k % CANCEL_CHECK_EVERY == 0 && isCancelled()) throw TileRenderCancelled() - val p = m.polygons[index] ?: continue - val shape = simplifiedAt(perZoom, index, p, scale) - val fillVisible = p.fillVisible - when (shape) { - is TileShape.Dot -> { - if (!fillVisible && !p.strokeVisible) continue - dotPaint.color = if (fillVisible) p.fill else p.stroke - val left = ((shape.x - originX) * r).toFloat() - val top = ((shape.y - originY) * r).toFloat() - canvas.drawRect(left, top, left + r, top + r, dotPaint) + when (shape) { + is TileShape.Dot -> { + if (!fillVisible && !item.strokeVisible) return false + paints.dot.color = if (fillVisible) item.fill else item.stroke + val left = ((shape.x - originX) * r).toFloat() + val top = ((shape.y - originY) * r).toFloat() + canvas.drawRect(left, top, left + r, top + r, paints.dot) + drew = true + } + is TileShape.Rings -> { + val path = paints.path + if (fillVisible) { + path.rewind() + path.fillType = Path.FillType.WINDING + for (ring in shape.rings) addRing(path, ring, originX, originY, r) + paints.fill.color = item.fill + canvas.drawPath(path, paints.fill) drew = true } - is TileShape.Rings -> { - if (fillVisible) { - path.rewind() - path.fillType = Path.FillType.WINDING - for (ring in shape.rings) addRing(path, ring, originX, originY, r) - fillPaint.color = p.fill - canvas.drawPath(path, fillPaint) - drew = true - } - if (p.strokeVisible && shape.outlined) { - // Outline only, as the map SDKs draw it. - path.rewind() - addRing(path, shape.rings[0], originX, originY, r) - strokePaint.color = p.stroke - strokePaint.strokeWidth = p.strokeWidth * r - canvas.drawPath(path, strokePaint) - drew = true - } + if (item.strokeVisible && shape.outlined) { + // Outline only, as the map SDKs draw it. + path.rewind() + addRing(path, shape.rings[0], originX, originY, r) + paints.stroke.color = item.stroke + paints.stroke.strokeWidth = item.strokeWidth * r + canvas.drawPath(path, paints.stroke) + drew = true } } } - if (!drew) releaseScratch(bitmap) - return if (drew) bitmap else null + return drew } private fun addRing( @@ -283,106 +138,4 @@ internal class PolygonTileRenderer( } path.close() } - - private fun perZoomOf( - m: Model, - z: Int, - ): AtomicReferenceArray = - synchronized(m.perZoom) { - // Access order: a lookup makes the zoom the most recent one. - m.perZoom[z] ?: AtomicReferenceArray(m.polygons.size).also { created -> - m.perZoom[z] = created - val it = m.perZoom.keys.iterator() - while (m.perZoom.size > ZOOMS_KEPT && it.hasNext()) { - it.next() - it.remove() - } - } - } - - private fun simplifiedAt( - perZoom: AtomicReferenceArray, - index: Int, - p: TilePolygon, - scale: Double, - ): TileShape { - val cached = perZoom.get(index) - if (cached != null && cached.source === p) return cached.shape - val shape = simplifyPolygon(p, scale) - perZoom.set(index, Simplified(p, shape)) - return shape - } - - private fun scratch(px: Int): Bitmap { - val existing = scratchBitmaps.acquire { !it.isRecycled && it.width == px && it.height == px } - if (existing != null) { - existing.eraseColor(Color.TRANSPARENT) - return existing - } - return createBitmap(px, px) - } - - private fun releaseScratch(bitmap: Bitmap) { - scratchBitmaps.release(bitmap) - } - - private fun encode(bitmap: Bitmap): ByteArray { - // Rust first, as for marker tiles: Bitmap.compress is several times slower. - TilePngEncoder.encode(bitmap)?.let { return it } - val out = byteStreams.acquire() ?: ByteArrayOutputStream(16 * 1024) - try { - out.reset() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) - return out.toByteArray() - } finally { - byteStreams.release(out) - } - } - - private fun dropTilesIn( - boxes: List, - strokePx: Float, - ) { - if (boxes.isEmpty()) return - for (key in tileCache.snapshot().keys) { - val z = (key ushr 56).toInt() - val x = ((key ushr 27) and COORD_MASK).toInt() - val y = (key and COORD_MASK).toInt() - val scale = tileSize.toDouble() * (1L shl z).toDouble() - val pad = (strokePx + 2.0) / scale - val tx0 = x * tileSize / scale - pad - val tx1 = (x + 1) * tileSize / scale + pad - val ty0 = y * tileSize / scale - pad - val ty1 = (y + 1) * tileSize / scale + pad - if (boxes.any { b -> b[0] <= tx1 && b[2] >= tx0 && b[1] <= ty1 && b[3] >= ty0 }) { - tileCache.remove(key) - } - } - } - - private fun cacheKey( - z: Int, - x: Int, - y: Int, - ratio: Int, - ): Long = (z.toLong() shl 56) or (ratio.toLong() shl 54) or (x.toLong() shl 27) or y.toLong() - - /** Thrown when the map stops wanting a tile mid-draw: a failure it may retry, never an empty tile. */ - class TileRenderCancelled : Exception("polygon tile no longer wanted") - - companion object { - const val DEFAULT_CACHE_BYTES = 16 * 1024 * 1024 - - /** Simplified copies kept, most recent zooms first. */ - private const val ZOOMS_KEPT = 4 - private const val MAX_ZOOM = 26 - private const val CANCEL_CHECK_EVERY = 256 - private const val COORD_MASK = (1L shl 27) - 1 - - private fun maxStrokeOf(polygons: Array): Float { - var maxStroke = 0f - for (p in polygons) if (p != null && p.strokeVisible) maxStroke = max(maxStroke, p.strokeWidth) - return maxStroke - } - } } diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt index e835325..15f8417 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt @@ -1,16 +1,11 @@ package com.mapconductor.core.polygon +import com.mapconductor.core.shapetile.ShapeTileSet import com.mapconductor.core.tileserver.LocalTileServer import com.mapconductor.core.tileserver.TileServerRegistry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.UUID -import kotlin.time.Duration.Companion.milliseconds /** * The polygons of one map that are drawn as raster tiles, and their route on @@ -24,141 +19,67 @@ import kotlin.time.Duration.Companion.milliseconds * [onTemplateChanged]. Tiles the change did not touch are still cached by the * renderer and come back without being drawn again. * + * The route, its version and the coalescing of changes are + * [ShapeTileSet]'s, shared with [com.mapconductor.core.polyline.PolylineTileSet]. + * * Same shape as js-sdk-core's `PolygonTileSet`. The web draws in its tile * service worker and hands the polygons over as Encoded Polylines; here the * server is in the process, so the renderer reads the states directly. */ class PolygonTileSet( - private val server: LocalTileServer = TileServerRegistry.get(), - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + server: LocalTileServer = TileServerRegistry.get(), + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), ) { - val routeId: String = "polygons-${UUID.randomUUID().toString().take(8)}" + internal val shapes: ShapeTileSet = + ShapeTileSet( + routePrefix = "polygons", + server = server, + scope = scope, + renderer = PolygonTileRenderer(TILE_SIZE).shapes, + idOf = { it.id }, + zIndexOf = { it.zIndex }, + toTile = TilePolygon::from, + ) + + val routeId: String get() = shapes.routeId /** Called with the new template after an [update]. */ - var onTemplateChanged: (suspend (String) -> Unit)? = null + var onTemplateChanged: (suspend (String) -> Unit)? + get() = shapes.onTemplateChanged + set(value) { + shapes.onTemplateChanged = value + } val tileSize: Int get() = TILE_SIZE - private val renderer = PolygonTileRenderer(TILE_SIZE) - private val lock = Any() - private var indexById: Map = emptyMap() - - @Volatile - private var version = 0 - private var registered = false - private val pending = LinkedHashMap() - private val pendingRemovals = ArrayList() - private var flushJob: Job? = null - /** * Registers [states] (replacing whatever was registered) and returns the * URL template of their tiles. */ - suspend fun setPolygons(states: List): String { - cancelPending() - val (polygons, ids) = - withContext(Dispatchers.Default) { - // Draw order is list order: zIndex, then the order given (sortedBy is stable). - val ordered = states.sortedBy { it.zIndex } - val ids = HashMap(ordered.size * 2) - ordered.forEachIndexed { index, state -> ids[state.id] = index } - ordered.map(TilePolygon::from) to ids - } - synchronized(lock) { - renderer.setPolygons(polygons) - if (!registered) server.register(routeId, renderer) - indexById = ids - registered = true - version++ - } - return template() - } + suspend fun setPolygons(states: List): String = shapes.setShapes(states) - fun has(id: String): Boolean = synchronized(lock) { indexById.containsKey(id) } + fun has(id: String): Boolean = shapes.has(id) /** * One polygon changed in place. A change of zIndex reorders the list and * needs [setPolygons] instead; everything else is applied here. */ - fun update(state: PolygonState) { - synchronized(lock) { - if (!indexById.containsKey(state.id)) return - pending[state.id] = state - scheduleFlushLocked() - } - } + fun update(state: PolygonState) = shapes.update(state) /** * Takes one polygon out of the tiles. Its slot in the renderer's list is * left empty, so the others keep their indices and only the tiles it was * in are drawn again. */ - fun remove(id: String) { - synchronized(lock) { - val index = indexById[id] ?: return - indexById = indexById - id - pending.remove(id) - pendingRemovals.add(index) - scheduleFlushLocked() - } - } + fun remove(id: String) = shapes.remove(id) - /** Called with [lock] held. */ - private fun scheduleFlushLocked() { - if (flushJob == null) { - flushJob = - scope.launch { - delay(UPDATE_COALESCE) - flush() - } - } - } - - fun template(): String = server.urlTemplate(routeId, TILE_SIZE, version.toString()) + fun template(): String = shapes.template() - fun dispose() { - cancelPending() - synchronized(lock) { - if (registered) server.unregister(routeId) - registered = false - indexById = emptyMap() - renderer.clear() - } - } - - private suspend fun flush() { - val template = - synchronized(lock) { - flushJob = null - val updates: List> = - pending.values.mapNotNull { state -> - indexById[state.id]?.let { index -> index to TilePolygon.from(state) } - } + pendingRemovals.map { index -> index to null } - pending.clear() - pendingRemovals.clear() - if (updates.isEmpty() || !registered) return - renderer.update(updates) - version++ - template() - } - onTemplateChanged?.invoke(template) - } - - private fun cancelPending() { - synchronized(lock) { - flushJob?.cancel() - flushJob = null - pending.clear() - pendingRemovals.clear() - } - } + fun dispose() = shapes.dispose() companion object { private const val TILE_SIZE = 256 - /** In-place changes arriving within this window are drawn as one. */ - private val UPDATE_COALESCE = 16.milliseconds - /** * Whether a polygon can be drawn in a tile. Geodesic edges are curves * the tile does not trace, and a ring spanning half the world or more diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt index 97ed549..4f6d185 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt @@ -2,8 +2,7 @@ package com.mapconductor.core.polygon import com.mapconductor.core.raster.RasterLayerSource import com.mapconductor.core.raster.RasterLayerState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import com.mapconductor.core.shapetile.ShapeTiler /** The id of the raster layer that shows tiled polygons. */ const val POLYGON_TILE_LAYER_ID: String = "mc-polygon-tiles" @@ -34,6 +33,8 @@ fun polygonTileRasterLayerState( * controller hands [polygonTileRasterLayerState] to its `upsert`, and * removes it with `removeById(POLYGON_TILE_LAYER_ID)`. * + * The routing is [ShapeTiler]'s, shared with [com.mapconductor.core.polyline.PolylineTiler]. + * * Same as js-sdk-core's and ios-sdk-core's `PolygonTiler`. */ class PolygonTiler( @@ -41,75 +42,39 @@ class PolygonTiler( ) { var onTemplate: (suspend (template: String?, tileSize: Int) -> Unit)? = null - private val tileSet = PolygonTileSet() - - @Volatile - private var tiled: Set = emptySet() - - @Volatile - private var states: List = emptyList() - - init { - tileSet.onTemplateChanged = { template -> onTemplate?.invoke(template, tileSet.tileSize) } - } + private val tiler = + ShapeTiler( + tileSet = PolygonTileSet().shapes, + canTile = PolygonTileSet::canTile, + vertexCount = PolygonTileSet::vertexCount, + shouldUseTiles = { count, vertices -> options.shouldUseTiles(count, vertices) }, + idOf = { it.id }, + onTemplate = { template, tileSize -> onTemplate?.invoke(template, tileSize) }, + ) /** Re-decides tiling for [data] and returns the ids drawn as tiles. */ - suspend fun route(data: List): Set { - // A city of polygons is a second of work; none of it belongs on the map's thread. - val (tileable, useTiles) = - withContext(Dispatchers.Default) { - val tileable = data.filter(PolygonTileSet::canTile) - tileable to options.shouldUseTiles(tileable.size, PolygonTileSet.vertexCount(tileable)) - } - val template = if (useTiles) tileSet.setPolygons(tileable) else null - if (template == null) { - tileSet.dispose() - tiled = emptySet() - states = emptyList() - } else { - tiled = withContext(Dispatchers.Default) { tileable.mapTo(HashSet(tileable.size * 2)) { it.id } } - states = tileable - } - onTemplate?.invoke(template, tileSet.tileSize) - return tiled - } + suspend fun route(data: List): Set = tiler.route(data) /** The states drawn as tiles, as of the last [route]. */ - fun tiledStates(): List = states + fun tiledStates(): List = tiler.tiledStates() - fun isTiled(id: String): Boolean = tiled.contains(id) + fun isTiled(id: String): Boolean = tiler.isTiled(id) /** An in-place change of a tiled polygon; only the tiles it touches are drawn again. */ - fun update(state: PolygonState) { - tileSet.update(state) - } + fun update(state: PolygonState) = tiler.update(state) /** * Takes one polygon out of the tiles, for a change that left it no longer * [PolygonTileSet.canTile]; only the tiles it was in are drawn again. The * controller then hands it to the map SDK. */ - fun remove(id: String) { - if (id !in tiled) return - tiled = tiled - id - states = states.filter { it.id != id } - tileSet.remove(id) - } + fun remove(id: String) = tiler.remove(id) /** * Releases the tiles when the map goes away. Unlike [clear], it does not * call [onTemplate]: there is no map left to remove the layer from. */ - fun dispose() { - tileSet.dispose() - tiled = emptySet() - states = emptyList() - } + fun dispose() = tiler.dispose() - suspend fun clear() { - tileSet.dispose() - tiled = emptySet() - states = emptyList() - onTemplate?.invoke(null, tileSet.tileSize) - } + suspend fun clear() = tiler.clear() } diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineController.kt b/src/main/java/com/mapconductor/core/polyline/PolylineController.kt index 0b585b6..761ba9f 100644 --- a/src/main/java/com/mapconductor/core/polyline/PolylineController.kt +++ b/src/main/java/com/mapconductor/core/polyline/PolylineController.kt @@ -6,8 +6,10 @@ import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.controller.SlottedOverlayController import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.map.MapCameraPosition +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext abstract class PolylineController( val polylineManager: PolylineManagerInterface, @@ -23,6 +25,15 @@ abstract class PolylineController( var clickListener: OnPolylineEventHandler? = null + /** + * Set by a provider that can show polyline tiles (see [PolylineTiler]). + * Polylines it tiles stay in [polylineManager], for taps, with no native + * overlay: their entity's `polyline` is null and the renderer never sees + * them. A renderer that redraws from the manager must skip those. + */ + var tiler: PolylineTiler? = null + private var tiledIds: Set = emptySet() + fun dispatchClick(event: PolylineEvent) { // 配送座標の wrap は PolylineEvent の生成時に一元化済み。 event.state.onClick?.invoke(event) @@ -31,13 +42,22 @@ abstract class PolylineController( override suspend fun add(data: List) { semaphore.withPermit { + val wasTiled = tiledIds + val tiled = tiler?.route(data) ?: emptySet() + tiledIds = tiled + val native = if (tiled.isEmpty()) data else data.filter { it.id !in tiled } val modifiedEntities = mutableListOf>() - val previous = polylineManager.allEntities().map { it.state.id }.toMutableSet() + // Only natively drawn polylines are diffed against the renderer. + val previous = + polylineManager + .allEntities() + .map { it.state.id } + .filterTo(mutableSetOf()) { it !in wasTiled } val added = mutableListOf() val updated = mutableListOf>() val removed = mutableListOf>() - data.forEach { state -> + native.forEach { state -> if (previous.contains(state.id)) { // Gone only if destroy() cleared the manager meanwhile; nothing left to update. val prevEntity = polylineManager.getEntity(state.id) ?: return@withPermit @@ -112,13 +132,64 @@ abstract class PolylineController( } } + // Tiled polylines: kept for taps, nothing native. Those that left + // the tiles and the list are forgotten; those that left the tiles + // for native drawing were added above. + if (wasTiled.isNotEmpty()) { + val nativeIds = native.mapTo(HashSet()) { it.id } + for (id in wasTiled) { + if (id !in tiled && id !in nativeIds) polylineManager.removeEntity(id) + } + } + if (tiled.isNotEmpty()) { + val tiledStates = tiler!!.tiledStates() + withContext(Dispatchers.Default) { + for (state in tiledStates) { + polylineManager.registerEntity(PolylineEntity(polyline = inTiles(), state = state)) + } + } + } + renderer.onPostProcess() } } + /** What a tiled polyline's entity holds instead of a native polyline. */ + @Suppress("UNCHECKED_CAST") + private fun inTiles(): ActualPolyline = null as ActualPolyline + override suspend fun update(state: PolylineState) { semaphore.withPermit { val prevEntity = polylineManager.getEntity(state.id) ?: return + if (state.id in tiledIds) { + if (PolylineTileSet.canTile(state)) { + polylineManager.registerEntity(PolylineEntity(polyline = prevEntity.polyline, state = state)) + tiler?.update(state) + return + } + // No longer drawable in a tile (geodesic, half the world wide, or + // under 2 points): the map SDK draws it from now on. Still on the + // map, so this is not an add. Whether the rest stay in tiles is + // left to the next add(), which decides tiling for the whole list. + tiledIds = tiledIds - state.id + tiler?.remove(state.id) + val polylines = + renderer.onAdd( + listOf( + object : PolylineOverlayRendererInterface.AddParamsInterface { + override val state: PolylineState = state + }, + ), + ) + val polyline = polylines.firstOrNull() + if (polyline != null) { + polylineManager.registerEntity(PolylineEntity(polyline = polyline, state = state)) + } else { + polylineManager.removeEntity(state.id) + } + renderer.onPostProcess() + return + } val currentFinger = state.fingerPrint() val prevFinger = prevEntity.fingerPrint if (currentFinger == prevFinger) { @@ -152,7 +223,11 @@ abstract class PolylineController( override suspend fun clear() { semaphore.withPermit { - val entities: List> = polylineManager.allEntities() + val tiled = tiledIds + tiledIds = emptySet() + tiler?.clear() + val entities: List> = + polylineManager.allEntities().filter { it.state.id !in tiled } renderer.onRemove(entities) // manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は remove では何もせず、 // onPostProcess で manager の全部を描き直すので、逆の順だと clear() 後も地図に残る。 @@ -172,6 +247,9 @@ abstract class PolylineController( } override fun destroy() { + // The tile server outlives the map; without this its route keeps every + // tiled polyline (and the renderer's copies of them) alive. + tiler?.dispose() // A destroyed controller can still be referenced (by its map controller, // or a state that has not let go yet); without this it keeps every entity. polylineManager.clear() diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineManager.kt b/src/main/java/com/mapconductor/core/polyline/PolylineManager.kt index a973408..8bc15a5 100644 --- a/src/main/java/com/mapconductor/core/polyline/PolylineManager.kt +++ b/src/main/java/com/mapconductor/core/polyline/PolylineManager.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.polyline +import com.mapconductor.core.InternalMapConductorApi import com.mapconductor.core.ResourceProvider import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.features.GeoRectBounds @@ -9,6 +10,7 @@ import com.mapconductor.core.spherical.WGS84Geodesic import com.mapconductor.core.spherical.calculateMetersPerPixel import com.mapconductor.settings.Settings import java.util.concurrent.ConcurrentHashMap +import kotlin.math.cos import android.util.Log data class PolylineHitResult( @@ -45,6 +47,9 @@ class PolylineManager : PolylineManagerInterface private const val DEBUG_FIND = false private const val TAG = "PolylineManagerInterface" + /** Meters per degree of latitude, rounded up: the box check may only ever let more through. */ + private const val METERS_PER_DEGREE = 111_000.0 + private fun d(msg: String) { if (DEBUG_FIND) Log.d(TAG, msg) } @@ -52,11 +57,34 @@ class PolylineManager : PolylineManagerInterface private val entities = ConcurrentHashMap>() + /** + * Each line's bounding box, made when it is registered (every change of a + * state registers it again). A tap first skips the lines whose box, grown + * by the tolerance, it is not in: a city of roads is 50,000 lines and + * 230,000 points, too many to measure on every tap. Null for a line the + * box would not hold (geodesic, or half the world wide); those are always + * measured. + */ + private val boxes = ConcurrentHashMap() + + @InternalMapConductorApi + private class LineBox( + val minLat: Double, + val maxLat: Double, + val minLng: Double, + val maxLng: Double, + ) + override fun registerEntity(entity: PolylineEntityInterface) { entities[entity.state.id] = entity + val box = boxOf(entity.state) + if (box != null) boxes[entity.state.id] = box else boxes.remove(entity.state.id) } - override fun removeEntity(id: String): PolylineEntityInterface? = entities.remove(id) + override fun removeEntity(id: String): PolylineEntityInterface? { + boxes.remove(id) + return entities.remove(id) + } override fun getEntity(id: String): PolylineEntityInterface? = entities[id] @@ -67,8 +95,18 @@ class PolylineManager : PolylineManagerInterface override fun clear() { entities.clear() + boxes.clear() } + /** + * The line nearest [position] that it is on, with the point on the line + * nearest it (the contract on all three platforms). + * + * A tap is on a line when it is within the finger's size + * ([Settings.tapTolerance]) of the line's edge: of its centre line plus + * half its [PolylineState.strokeWidth], both on screen at the camera's + * zoom. The same whether the line is drawn natively or in tiles. + */ override fun find( position: GeoPointInterface, cameraPosition: MapCameraPosition?, @@ -77,11 +115,21 @@ class PolylineManager : PolylineManagerInterface val candidates = mutableListOf, GeoPointInterface, Double>>() val fingerSize = ResourceProvider.dpToPx(Settings.Default.tapTolerance) val zoom = cameraPosition?.zoom ?: 0.0 - val threshold = calculateMetersPerPixel(position.latitude, zoom) * fingerSize + val metersPerPixel = calculateMetersPerPixel(position.latitude, zoom) + val lngScale = cos(Math.toRadians(position.latitude)).coerceAtLeast(0.01) + // dp → px as ResourceProvider.dpToPx does, without asking for every line. + val density = ResourceProvider.dpToPx(1.0) entities.values.forEach { entity -> // clickable=false のポリラインはタップに対して透過する(CircleManager と同じ方針)。 if (!entity.state.clickable) return@forEach + val threshold = metersPerPixel * (fingerSize + entity.state.strokeWidth.value * density / 2.0) + boxes[entity.state.id]?.let { box -> + val dLat = threshold / METERS_PER_DEGREE + val dLng = dLat / lngScale + if (position.latitude < box.minLat - dLat || position.latitude > box.maxLat + dLat) return@forEach + if (position.longitude < box.minLng - dLng || position.longitude > box.maxLng + dLng) return@forEach + } // 補間せず、元の線分を直接使う for (i in 0 until entity.state.points.size - 1) { val box = GeoRectBounds() @@ -113,7 +161,25 @@ class PolylineManager : PolylineManagerInterface val closest = candidates.minByOrNull { it.third } return closest?.let { (entity, closestPoint, distance) -> + d("hit ${entity.state.id} at $distance m") PolylineHitResult(entity = entity, closestPoint = closestPoint) } } + + private fun boxOf(state: PolylineState): LineBox? { + if (state.geodesic || state.points.isEmpty()) return null + var minLat = Double.POSITIVE_INFINITY + var maxLat = Double.NEGATIVE_INFINITY + var minLng = Double.POSITIVE_INFINITY + var maxLng = Double.NEGATIVE_INFINITY + for (p in state.points) { + if (p.latitude < minLat) minLat = p.latitude + if (p.latitude > maxLat) maxLat = p.latitude + if (p.longitude < minLng) minLng = p.longitude + if (p.longitude > maxLng) maxLng = p.longitude + } + // Across the antimeridian the box would be the rest of the world. + if (maxLng - minLng >= 180) return null + return LineBox(minLat, maxLat, minLng, maxLng) + } } diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt b/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt index 01203f7..0d5ee8d 100644 --- a/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt +++ b/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt @@ -1,23 +1,36 @@ package com.mapconductor.core.polyline import com.mapconductor.core.OverlayAddedTracker +import com.mapconductor.core.controller.BaseMapViewController import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.map.MapOverlayInterface import kotlinx.coroutines.flow.StateFlow -class PolylineOverlay( - override val flow: StateFlow>, -) : MapOverlayInterface { - /** Calls [PolylineState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ - private val added = OverlayAddedTracker { it.onAdded } +class PolylineOverlay + @JvmOverloads + constructor( + override val flow: StateFlow>, + /** + * This map's polyline tiling, when the app sets one (null keeps the + * provider's [PolylineTilingOptions.Default]). Applied to the map's + * [PolylineTiler] before every hand-over, so a change takes effect the + * next time the polylines are handed over. + */ + private val tilingOptions: StateFlow? = null, + ) : MapOverlayInterface { + /** Calls [PolylineState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ + private val added = OverlayAddedTracker { it.onAdded } - override suspend fun render( - data: MutableMap, - controller: MapViewControllerInterface, - ) { - val capable = controller as? PolylineCapableInterface ?: return - val states = data.values.toList() - capable.compositionPolylines(states) - added.synced(controller, states, capable::hasPolyline) + override suspend fun render( + data: MutableMap, + controller: MapViewControllerInterface, + ) { + val capable = controller as? PolylineCapableInterface ?: return + tilingOptions?.value?.let { options -> + (controller as? BaseMapViewController)?.polylineTilers()?.forEach { it.options = options } + } + val states = data.values.toList() + capable.compositionPolylines(states) + added.synced(controller, states, capable::hasPolyline) + } } -} diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineTileGeometry.kt b/src/main/java/com/mapconductor/core/polyline/PolylineTileGeometry.kt new file mode 100644 index 0000000..d25aea3 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polyline/PolylineTileGeometry.kt @@ -0,0 +1,192 @@ +package com.mapconductor.core.polyline + +import androidx.compose.ui.graphics.toArgb +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.polygon.DP_TOLERANCE_PX +import com.mapconductor.core.polygon.douglasPeucker +import com.mapconductor.core.polygon.isVisibleColor +import com.mapconductor.core.polygon.toMercatorRing +import com.mapconductor.core.shapetile.ShapeTileRenderer +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import android.graphics.Canvas + +// The geometry of polyline tiles: the same steps as the polyline part of the +// "Shape tiles" section of js-sdk-core's tile-sw.js and ios-sdk-core's +// PolylineTileGeometry, so the three platforms draw the same pixels from the +// same lines. +// +// A line becomes a Web Mercator path in [0, 1]. Each zoom gets its own +// simplified copy, built the first time a tile at that zoom needs it: points +// snapped to that zoom's pixel grid, repeats dropped, Douglas-Peucker at half +// a pixel with both ends kept. A line that collapses to one point (or whose +// box is under a pixel) is a dot as wide as the line. Taps never see any of +// this -- they are resolved against the original points in PolylineManager. + +/** + * One polyline as the tiles draw it. + * + * [path] is Web Mercator `[x0, y0, x1, y1, ...]` in [0, 1], open (never + * closed by the tiles). [strokeWidth] is in dp, the unit of + * [PolylineState.strokeWidth], which is also the tile unit: the line is as + * wide on screen at every zoom. + */ +@InternalMapConductorApi +internal class TilePolyline( + val path: DoubleArray, + /** minX, minY, maxX, maxY. */ + val bbox: DoubleArray, + val color: Int, + val strokeWidth: Float, +) { + val visible: Boolean get() = strokeWidth > 0f && isVisibleColor(color) + + @InternalMapConductorApi + companion object { + /** Null when the line has fewer than two points. */ + fun from(state: PolylineState): TilePolyline? { + if (state.points.size < 2) return null + val path = toMercatorRing(state.points) + var minX = Double.POSITIVE_INFINITY + var minY = Double.POSITIVE_INFINITY + var maxX = Double.NEGATIVE_INFINITY + var maxY = Double.NEGATIVE_INFINITY + var i = 0 + while (i < path.size) { + minX = min(minX, path[i]) + maxX = max(maxX, path[i]) + minY = min(minY, path[i + 1]) + maxY = max(maxY, path[i + 1]) + i += 2 + } + return TilePolyline( + path = path, + bbox = doubleArrayOf(minX, minY, maxX, maxY), + color = state.strokeColor.toArgb(), + strokeWidth = state.strokeWidth.value, + ) + } + } +} + +/** What one polyline looks like at one zoom. */ +@InternalMapConductorApi +internal sealed class PolylineTileShape { + /** World-pixel points at the zoom, `[x0, y0, ...]`, two or more. */ + @InternalMapConductorApi + class Path( + val points: DoubleArray, + ) : PolylineTileShape() + + /** A line that is one point at this zoom, drawn as a dot as wide as the line, centred at [x], [y]. */ + @InternalMapConductorApi + class Dot( + val x: Double, + val y: Double, + ) : PolylineTileShape() +} + +/** + * The line as drawn at a zoom whose world is [scale] pixels wide: a path of + * world-pixel points, or a dot for a line that is one point there; never + * nothing. + */ +@InternalMapConductorApi +internal fun simplifyPolyline( + line: TilePolyline, + scale: Double, +): PolylineTileShape { + val w = (line.bbox[2] - line.bbox[0]) * scale + val h = (line.bbox[3] - line.bbox[1]) * scale + if (w < 1 && h < 1) { + return PolylineTileShape.Dot( + ((line.bbox[0] + line.bbox[2]) / 2) * scale, + ((line.bbox[1] + line.bbox[3]) / 2) * scale, + ) + } + val path = line.path + val snapped = DoubleArray(path.size) + var n = 0 + var px = Double.NaN + var py = Double.NaN + var i = 0 + while (i < path.size) { + val x = jsRound(path[i] * scale) + val y = jsRound(path[i + 1] * scale) + i += 2 + if (x == px && y == py) continue + snapped[n++] = x + snapped[n++] = y + px = x + py = y + } + if (n < 4) return PolylineTileShape.Dot(snapped[0], snapped[1]) + // Douglas-Peucker keeps both ends, so the line still starts and ends where it did. + return PolylineTileShape.Path(douglasPeucker(snapped, n / 2, DP_TOLERANCE_PX)) +} + +/** `Math.round` as JavaScript has it (halves go up), so pixels land where tile-sw.js puts them. */ +private fun jsRound(v: Double): Double = floor(v + 0.5) + +/** Total points across [states]. */ +@InternalMapConductorApi +internal fun polylineVertexCountOf(states: List): Int { + var n = 0 + for (s in states) n += s.points.size + return n +} + +/** + * How a polyline is drawn in a shape tile: stroked, never filled, with round + * caps and joins (as the map SDKs' lines and the polygon tiles' outlines are), + * the stroke as wide in dp as [PolylineState.strokeWidth] at every zoom. + */ +@InternalMapConductorApi +internal object PolylineTilePainter : ShapeTileRenderer.Painter { + override fun bbox(item: TilePolyline): DoubleArray = item.bbox + + override fun strokeWidth(item: TilePolyline): Float = if (item.visible) item.strokeWidth else 0f + + override fun simplify( + item: TilePolyline, + scale: Double, + ): Any = simplifyPolyline(item, scale) + + override fun draw( + canvas: Canvas, + item: TilePolyline, + shape: Any, + originX: Double, + originY: Double, + r: Float, + paints: ShapeTileRenderer.Paints, + ): Boolean { + if (!item.visible) return false + when (shape) { + is PolylineTileShape.Dot -> { + paints.fill.color = item.color + val radius = max(item.strokeWidth * r, 1f) / 2f + val cx = ((shape.x - originX) * r).toFloat() + val cy = ((shape.y - originY) * r).toFloat() + canvas.drawCircle(cx, cy, radius, paints.fill) + } + is PolylineTileShape.Path -> { + val path = paints.path + path.rewind() + val p = shape.points + path.moveTo(((p[0] - originX) * r).toFloat(), ((p[1] - originY) * r).toFloat()) + var i = 2 + while (i < p.size) { + path.lineTo(((p[i] - originX) * r).toFloat(), ((p[i + 1] - originY) * r).toFloat()) + i += 2 + } + paints.stroke.color = item.color + paints.stroke.strokeWidth = item.strokeWidth * r + canvas.drawPath(path, paints.stroke) + } + else -> return false + } + return true + } +} diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineTileSet.kt b/src/main/java/com/mapconductor/core/polyline/PolylineTileSet.kt new file mode 100644 index 0000000..25296db --- /dev/null +++ b/src/main/java/com/mapconductor/core/polyline/PolylineTileSet.kt @@ -0,0 +1,103 @@ +package com.mapconductor.core.polyline + +import com.mapconductor.core.shapetile.ShapeTileRenderer +import com.mapconductor.core.shapetile.ShapeTileSet +import com.mapconductor.core.tileserver.LocalTileServer +import com.mapconductor.core.tileserver.TileServerRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +/** + * The polylines of one map that are drawn as raster tiles, and their route on + * the local tile server. + * + * Provider-independent: a provider decides which polylines to tile + * ([PolylineTilingOptions.shouldUseTiles], [canTile]), hands them to + * [setPolylines], and shows the returned URL template as a raster layer. + * In-place changes go through [update]; each one ends in a new template (the + * version in it is what makes the map ask again), reported to + * [onTemplateChanged]. Tiles the change did not touch are still cached by the + * renderer and come back without being drawn again. + * + * The same as [com.mapconductor.core.polygon.PolygonTileSet] for lines: both + * are [ShapeTileSet]s, with their own route, version and tile cache. Same + * shape as js-sdk-core's and ios-sdk-core's `PolylineTileSet`. + */ +class PolylineTileSet( + server: LocalTileServer = TileServerRegistry.get(), + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + internal val renderer: ShapeTileRenderer = + ShapeTileRenderer(tileSize = TILE_SIZE, painter = PolylineTilePainter) + + internal val shapes: ShapeTileSet = + ShapeTileSet( + routePrefix = "polylines", + server = server, + scope = scope, + renderer = renderer, + idOf = { it.id }, + zIndexOf = { it.zIndex }, + toTile = TilePolyline::from, + ) + + val routeId: String get() = shapes.routeId + + /** Called with the new template after an [update] or [remove]. */ + var onTemplateChanged: (suspend (String) -> Unit)? + get() = shapes.onTemplateChanged + set(value) { + shapes.onTemplateChanged = value + } + + val tileSize: Int get() = TILE_SIZE + + /** + * Registers [states] (replacing whatever was registered) and returns the + * URL template of their tiles. Draw order is zIndex, then the order given. + */ + suspend fun setPolylines(states: List): String = shapes.setShapes(states) + + fun has(id: String): Boolean = shapes.has(id) + + /** + * One polyline changed in place. A change of zIndex reorders the list and + * needs [setPolylines] instead; everything else is applied here. + */ + fun update(state: PolylineState) = shapes.update(state) + + /** + * Takes one polyline out of the tiles. Only the tiles it was in are drawn + * again. + */ + fun remove(id: String) = shapes.remove(id) + + fun template(): String = shapes.template() + + fun dispose() = shapes.dispose() + + companion object { + private const val TILE_SIZE = 256 + + /** + * Whether a polyline can be drawn in a tile. Geodesic lines are curves + * the tile does not trace, a line spanning half the world or more + * crosses the antimeridian, and a line needs two points; the map SDK + * draws the rest. + */ + fun canTile(state: PolylineState): Boolean { + if (state.geodesic || state.points.size < 2) return false + var min = Double.POSITIVE_INFINITY + var max = Double.NEGATIVE_INFINITY + for (p in state.points) { + if (p.longitude < min) min = p.longitude + if (p.longitude > max) max = p.longitude + } + return max - min < 180 + } + + /** Points across [states]. */ + fun vertexCount(states: List): Int = polylineVertexCountOf(states) + } +} diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineTiler.kt b/src/main/java/com/mapconductor/core/polyline/PolylineTiler.kt new file mode 100644 index 0000000..3f289c6 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polyline/PolylineTiler.kt @@ -0,0 +1,90 @@ +package com.mapconductor.core.polyline + +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.shapetile.ShapeTiler + +/** The id of the raster layer that shows tiled polylines. */ +const val POLYLINE_TILE_LAYER_ID: String = "mc-polyline-tiles" + +/** The raster layer state for tiled polylines at [template]. */ +fun polylineTileRasterLayerState( + template: String, + tileSize: Int = 256, +): RasterLayerState = + RasterLayerState( + id = POLYLINE_TILE_LAYER_ID, + source = RasterLayerSource.UrlTemplate(template = template, tileSize = tileSize), + ) + +/** + * Decides which of a map's polylines are drawn as tiles, keeps them + * registered with the local tile server, and tells the provider where the + * tiles are. Used exactly as [com.mapconductor.core.polygon.PolygonTiler]: + * + * 1. [route] with the full list on every composition. It returns the ids now + * in tiles; the controller keeps those in its [PolylineManager] (taps are + * resolved there, against the original lines) but gives the map SDK + * nothing for them, and removes any native polyline it had drawn for one. + * 2. [update] for an in-place change of a tiled polyline, or [remove] when the + * change means it can no longer be tiled (it became geodesic, say). + * 3. [onTemplate] is how the tiles reach the map: a URL template to show as a + * raster layer, or null to remove it. A provider with a RasterLayer + * controller hands [polylineTileRasterLayerState] to its `upsert`, and + * removes it with `removeById(POLYLINE_TILE_LAYER_ID)`. The layer belongs + * to the polyline band: over circles, under native polylines and markers. + * + * [options] is read at every [route]: setting it decides again the next time + * the polylines are handed over. + * + * Same as js-sdk-core's and ios-sdk-core's `PolylineTiler`. + */ +class PolylineTiler( + options: PolylineTilingOptions = PolylineTilingOptions.Default, +) { + @Volatile + var options: PolylineTilingOptions = options + + var onTemplate: (suspend (template: String?, tileSize: Int) -> Unit)? = null + + private val tileSet = PolylineTileSet() + + private val tiler = + ShapeTiler( + tileSet = tileSet.shapes, + canTile = PolylineTileSet::canTile, + vertexCount = PolylineTileSet::vertexCount, + shouldUseTiles = { count, vertices -> this.options.shouldUseTiles(count, vertices) }, + idOf = { it.id }, + onTemplate = { template, tileSize -> onTemplate?.invoke(template, tileSize) }, + ) + + /** Re-decides tiling for [data] and returns the ids drawn as tiles. */ + suspend fun route(data: List): Set = tiler.route(data) + + /** The states drawn as tiles, as of the last [route]. */ + fun tiledStates(): List = tiler.tiledStates() + + fun isTiled(id: String): Boolean = tiler.isTiled(id) + + /** An in-place change of a tiled polyline; only the tiles it touches are drawn again. */ + fun update(state: PolylineState) = tiler.update(state) + + /** + * Takes one polyline out of the tiles, for a change that left it no longer + * [PolylineTileSet.canTile]; only the tiles it was in are drawn again. The + * controller then hands it to the map SDK. + */ + fun remove(id: String) = tiler.remove(id) + + /** + * Releases the tiles when the map goes away. Unlike [clear], it does not + * call [onTemplate]: there is no map left to remove the layer from. + */ + fun dispose() = tiler.dispose() + + suspend fun clear() = tiler.clear() + + /** The tile set, for tests that look at the pixels. */ + internal val tiles: PolylineTileSet get() = tileSet +} diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineTilingOptions.kt b/src/main/java/com/mapconductor/core/polyline/PolylineTilingOptions.kt new file mode 100644 index 0000000..53da4de --- /dev/null +++ b/src/main/java/com/mapconductor/core/polyline/PolylineTilingOptions.kt @@ -0,0 +1,32 @@ +package com.mapconductor.core.polyline + +/** + * Options for polyline tiling. + * + * When enabled and a map holds enough polylines, they are drawn into raster + * tiles by the SDK's local tile server instead of being handed to the map SDK + * one by one. Taps are still resolved against the original lines in + * [PolylineManager], so tiling changes how polylines look, never what a tap + * hits. + * + * The counterpart of [com.mapconductor.core.polygon.PolygonTilingOptions], and + * the same options as js-sdk-core's and ios-sdk-core's `PolylineTilingOptions`. + */ +data class PolylineTilingOptions( + val enabled: Boolean = true, + /** Tile once a map holds at least this many polylines… */ + val minPolylineCount: Int = 2000, + /** …or at least this many points across them, whichever comes first. */ + val minVertexCount: Int = 50_000, +) { + /** The one place the threshold is decided; callers must not restate it. */ + fun shouldUseTiles( + polylineCount: Int, + vertexCount: Int, + ): Boolean = enabled && (polylineCount >= minPolylineCount || vertexCount >= minVertexCount) + + companion object { + val Default: PolylineTilingOptions = PolylineTilingOptions() + val Disabled: PolylineTilingOptions = PolylineTilingOptions(enabled = false) + } +} diff --git a/src/main/java/com/mapconductor/core/shapetile/ShapeGridIndex.kt b/src/main/java/com/mapconductor/core/shapetile/ShapeGridIndex.kt new file mode 100644 index 0000000..c015e46 --- /dev/null +++ b/src/main/java/com/mapconductor/core/shapetile/ShapeGridIndex.kt @@ -0,0 +1,90 @@ +package com.mapconductor.core.shapetile + +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.polygon.IntList +import com.mapconductor.core.polygon.POLYGON_CELL +import com.mapconductor.core.polygon.POLYGON_MAX_CELLS +import kotlin.math.floor + +/** + * The index the shape tiles (polygons and polylines) draw from: shapes by the + * grid cells their bounding box covers. A cell is one z12 tile + * ([POLYGON_CELL]); a shape covering more than [POLYGON_MAX_CELLS] cells is kept + * in a list every query checks. + * + * A shape is its index in the renderer's list, and [bboxOf] gives its + * Web Mercator `minX, minY, maxX, maxY` (null for an empty slot). + */ +@InternalMapConductorApi +internal class ShapeGridIndex( + val cells: HashMap, + val large: IntArray, +) { + /** + * The shapes whose bounding box meets the given box, each once, in + * ascending index order -- which is draw order. + */ + inline fun query( + bboxOf: (Int) -> DoubleArray?, + minX: Double, + minY: Double, + maxX: Double, + maxY: Double, + ): IntArray { + val out = IntList() + for (i in large) { + val b = bboxOf(i) ?: continue + if (b[0] <= maxX && b[2] >= minX && b[1] <= maxY && b[3] >= minY) out.add(i) + } + val cx0 = floor(minX / POLYGON_CELL).toLong() + val cx1 = floor(maxX / POLYGON_CELL).toLong() + val cy0 = floor(minY / POLYGON_CELL).toLong() + val cy1 = floor(maxY / POLYGON_CELL).toLong() + for (cx in cx0..cx1) { + for (cy in cy0..cy1) { + val inCell = cells[cellKey(cx, cy)] ?: continue + for (i in inCell) { + val b = bboxOf(i) ?: continue + if (b[0] <= maxX && b[2] >= minX && b[1] <= maxY && b[3] >= minY) out.add(i) + } + } + } + // A shape in several cells was taken once per cell. + return out.toSortedDistinctArray() + } + + @InternalMapConductorApi + companion object { + inline fun build( + count: Int, + bboxOf: (Int) -> DoubleArray?, + ): ShapeGridIndex { + val cells = HashMap() + val large = IntList() + for (i in 0 until count) { + val b = bboxOf(i) ?: continue + val cx0 = floor(b[0] / POLYGON_CELL).toLong() + val cx1 = floor(b[2] / POLYGON_CELL).toLong() + val cy0 = floor(b[1] / POLYGON_CELL).toLong() + val cy1 = floor(b[3] / POLYGON_CELL).toLong() + if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > POLYGON_MAX_CELLS) { + large.add(i) + continue + } + for (cx in cx0..cx1) { + for (cy in cy0..cy1) { + cells.getOrPut(cellKey(cx, cy)) { IntList() }.add(i) + } + } + } + val packed = HashMap(cells.size * 2) + for ((key, list) in cells) packed[key] = list.toArray() + return ShapeGridIndex(packed, large.toArray()) + } + + fun cellKey( + cx: Long, + cy: Long, + ): Long = cx * 1_048_576L + cy + } +} diff --git a/src/main/java/com/mapconductor/core/shapetile/ShapeTileRenderer.kt b/src/main/java/com/mapconductor/core/shapetile/ShapeTileRenderer.kt new file mode 100644 index 0000000..9c99c50 --- /dev/null +++ b/src/main/java/com/mapconductor/core/shapetile/ShapeTileRenderer.kt @@ -0,0 +1,417 @@ +package com.mapconductor.core.shapetile + +import androidx.core.graphics.createBitmap +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.tileserver.ScratchPool +import com.mapconductor.core.tileserver.TilePngEncoder +import com.mapconductor.core.tileserver.TileProviderInterface +import com.mapconductor.core.tileserver.TileRequest +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReferenceArray +import kotlin.math.max +import kotlin.math.roundToInt +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.util.LruCache + +/** + * Draws a list of shapes into raster tiles, served by + * [com.mapconductor.core.tileserver.LocalTileServer]: the part the polygon + * tiles and the polyline tiles share. What a shape is and how it is drawn is + * the [Painter]'s; everything else is here -- the grid index, a simplified copy + * per zoom, the tile cache and its invalidation, the scratch bitmaps, PNG + * encoding and cancellation. The counterpart of the "Shape tiles" section of + * js-sdk-core's tile-sw.js and ios-sdk-core's `ShapeTileRenderer`. + * + * Geometry is in tile units (a tile is [tileSize] wide whatever its pixels); + * a tile is drawn [pixelScale] pixels per unit, so a stroke of 1 dp is one + * unit wide, as on the web where it is one CSS pixel, and stays that wide at + * every zoom. + * + * Tiles are cached here without the version the map sees in their URL. An + * in-place change ([update]) drops only the tiles that overlap what changed, + * widened by the widest stroke; the map asks again for every tile because the + * version moved, and the ones the change did not touch come straight back + * from the cache. + * + * [renderTile] runs concurrently on the tile server's threads. Every change + * replaces the model it reads, so a tile is drawn from one consistent list. + */ +@InternalMapConductorApi +internal class ShapeTileRenderer( + val tileSize: Int = 256, + cacheSizeBytes: Int = DEFAULT_CACHE_BYTES, + private val pixelScale: () -> Float = { ResourceProvider.getDensity() }, + private val painter: Painter, + /** What a cancelled tile throws: a failure the map may retry, never an empty tile. */ + private val cancelled: () -> Exception = { TileRenderCancelled() }, +) : TileProviderInterface { + /** What a kind of shape is to the tiles. */ + @InternalMapConductorApi + interface Painter { + /** Web Mercator `minX, minY, maxX, maxY`. */ + fun bbox(item: T): DoubleArray + + /** + * The widest the shape is drawn past its bounding box, as a stroke + * width in tile units (dp): half of it reaches past each side. 0 when + * nothing is drawn outside the box. + */ + fun strokeWidth(item: T): Float + + /** The shape at a zoom whose world is [scale] units wide. Cached per zoom. */ + fun simplify( + item: T, + scale: Double, + ): Any + + /** + * Draws [shape] (from [simplify]) with its origin at [originX], [originY] + * world units and [r] pixels per unit. True if anything was drawn. + */ + fun draw( + canvas: Canvas, + item: T, + shape: Any, + originX: Double, + originY: Double, + r: Float, + paints: Paints, + ): Boolean + } + + /** Paints and a path, one set per tile drawn. */ + @InternalMapConductorApi + class Paints { + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + val stroke = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + } + + /** Not anti-aliased: a polygon under a pixel is one whole pixel. */ + val dot = Paint().apply { style = Paint.Style.FILL } + val path = Path() + } + + @InternalMapConductorApi + private class Simplified( + val source: Any, + val shape: Any, + ) + + /** + * What tiles are drawn from. [perZoom] is shared by the models an + * [update] derives from one [setItems]: an entry is used only while its + * source is still the item at that index, so a changed item is + * simplified again without clearing anything. + */ + @InternalMapConductorApi + private class Model( + val items: Array, + val bboxes: Array, + val index: ShapeGridIndex, + val maxStroke: Float, + val perZoom: LinkedHashMap>, + ) + + @Volatile + private var model: Model? = null + + /** Bumped by every change, so a tile drawn before it is not cached after it. */ + private val generation = AtomicLong() + + private val tileCache = + object : LruCache(cacheSizeBytes) { + override fun sizeOf( + key: Long, + value: ByteArray, + ): Int = value.size + } + private val cacheLock = Any() + + // Pools, not thread-locals: tiles are drawn on the map SDK's threads, which can + // outlive this renderer (see ScratchPool) and would keep a tile bitmap each. + private val scratchBitmaps = ScratchPool() + private val byteStreams = ScratchPool() + + /** Replaces every shape. Draw order is list order. */ + fun setItems(items: List) { + val array: Array = items.toTypedArray() + val bboxes = Array(array.size) { i -> bboxAt(array, i) } + val index = ShapeGridIndex.build(bboxes.size) { bboxes[it] } + model = Model(array, bboxes, index, maxStrokeOf(array), LinkedHashMap(8, 0.75f, true)) + synchronized(cacheLock) { + generation.incrementAndGet() + tileCache.evictAll() + } + } + + /** + * Replaces the shapes at the given indexes. Only the tiles that + * overlapped one of them, before or after, are drawn again. + */ + fun update(updates: List>) { + val current = model ?: return + val items = current.items.copyOf() + val bboxes = current.bboxes.copyOf() + val touched = ArrayList(updates.size * 2) + var maxStroke = current.maxStroke + var reindex = false + for ((index, after) in updates) { + if (index !in items.indices) continue + val before = bboxes[index] + items[index] = after + val afterBox = after?.let(painter::bbox) + bboxes[index] = afterBox + before?.let(touched::add) + afterBox?.let(touched::add) + if (after != null) maxStroke = max(maxStroke, painter.strokeWidth(after)) + // A shape that moved needs the index rebuilt; a restyle does not. + if (before == null || afterBox == null || !before.contentEquals(afterBox)) reindex = true + } + val index = if (reindex) ShapeGridIndex.build(bboxes.size) { bboxes[it] } else current.index + model = Model(items, bboxes, index, maxStroke, current.perZoom) + synchronized(cacheLock) { + generation.incrementAndGet() + dropTilesIn(touched, maxStroke) + } + } + + fun clear() { + model = null + synchronized(cacheLock) { + generation.incrementAndGet() + tileCache.evictAll() + } + } + + override fun renderTile(request: TileRequest): ByteArray? = renderTile(request) { false } + + override fun renderTile( + request: TileRequest, + isCancelled: () -> Boolean, + ): ByteArray? { + val generationAtStart = generation.get() + val m = model ?: return null + val z = request.z + if (z !in 0..MAX_ZOOM) return null + val n = 1 shl z + val x = ((request.x % n) + n) % n + val y = request.y + if (y !in 0 until n) return null + val ratio = request.pixelRatio.coerceIn(1, 3) + val key = cacheKey(z, x, y, ratio) + synchronized(cacheLock) { tileCache.get(key) }?.let { return it } + + val bitmap = draw(m, x, y, z, ratio, isCancelled) ?: return null + val bytes = + try { + encode(bitmap) + } finally { + releaseScratch(bitmap) + } + synchronized(cacheLock) { + // The shapes may have changed while this tile was drawn. + if (generation.get() == generationAtStart) tileCache.put(key, bytes) + } + return bytes + } + + /** The tile as a bitmap, or null when nothing of any shape is in it. For tests and [renderTile]. */ + fun renderBitmap( + x: Int, + y: Int, + z: Int, + pixelRatio: Int = 1, + ): Bitmap? = + model?.let { draw(it, x, y, z, pixelRatio) { false } }?.let { scratch -> + try { + scratch.copy(Bitmap.Config.ARGB_8888, false) + } finally { + releaseScratch(scratch) + } + } + + private fun draw( + m: Model, + x: Int, + y: Int, + z: Int, + pixelRatio: Int, + isCancelled: () -> Boolean, + ): Bitmap? { + val scale = tileSize.toDouble() * (1L shl z).toDouble() + val originX = x.toDouble() * tileSize + val originY = y.toDouble() * tileSize + // A stroke reaches half its width past the shape: take the shapes that + // close to the tile too, and draw them whole (the canvas cuts them), so + // the tile next door draws the same line at the same place. + val pad = m.maxStroke / 2.0 + 1.0 + val bboxes = m.bboxes + val candidates = + m.index.query( + { bboxes[it] }, + (originX - pad) / scale, + (originY - pad) / scale, + (originX + tileSize + pad) / scale, + (originY + tileSize + pad) / scale, + ) + if (candidates.isEmpty()) return null + + val r = pixelScale() * pixelRatio + val px = (tileSize * r).roundToInt().coerceAtLeast(1) + val bitmap = scratch(px) + try { + return drawInto(bitmap, m, candidates, originX, originY, r, z, scale, isCancelled) + } catch (error: Throwable) { + releaseScratch(bitmap) + throw error + } + } + + /** Draws into [bitmap]; null (and the bitmap back in the pool) when nothing was drawn. */ + @Suppress("UNCHECKED_CAST") + private fun drawInto( + bitmap: Bitmap, + m: Model, + candidates: IntArray, + originX: Double, + originY: Double, + r: Float, + z: Int, + scale: Double, + isCancelled: () -> Boolean, + ): Bitmap? { + val canvas = Canvas(bitmap) + val paints = Paints() + val perZoom = perZoomOf(m, z) + var drew = false + for ((k, index) in candidates.withIndex()) { + if (k % CANCEL_CHECK_EVERY == 0 && isCancelled()) throw cancelled() + val item = (m.items[index] ?: continue) as T + val shape = simplifiedAt(perZoom, index, item, scale) + if (painter.draw(canvas, item, shape, originX, originY, r, paints)) drew = true + } + if (!drew) releaseScratch(bitmap) + return if (drew) bitmap else null + } + + private fun perZoomOf( + m: Model, + z: Int, + ): AtomicReferenceArray = + synchronized(m.perZoom) { + // Access order: a lookup makes the zoom the most recent one. + m.perZoom[z] ?: AtomicReferenceArray(m.items.size).also { created -> + m.perZoom[z] = created + val it = m.perZoom.keys.iterator() + while (m.perZoom.size > ZOOMS_KEPT && it.hasNext()) { + it.next() + it.remove() + } + } + } + + private fun simplifiedAt( + perZoom: AtomicReferenceArray, + index: Int, + item: T, + scale: Double, + ): Any { + val cached = perZoom.get(index) + if (cached != null && cached.source === item) return cached.shape + val shape = painter.simplify(item, scale) + perZoom.set(index, Simplified(item, shape)) + return shape + } + + private fun scratch(px: Int): Bitmap { + val existing = scratchBitmaps.acquire { !it.isRecycled && it.width == px && it.height == px } + if (existing != null) { + existing.eraseColor(Color.TRANSPARENT) + return existing + } + return createBitmap(px, px) + } + + private fun releaseScratch(bitmap: Bitmap) { + scratchBitmaps.release(bitmap) + } + + private fun encode(bitmap: Bitmap): ByteArray { + // Rust first, as for marker tiles: Bitmap.compress is several times slower. + TilePngEncoder.encode(bitmap)?.let { return it } + val out = byteStreams.acquire() ?: ByteArrayOutputStream(16 * 1024) + try { + out.reset() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + return out.toByteArray() + } finally { + byteStreams.release(out) + } + } + + private fun dropTilesIn( + boxes: List, + strokePx: Float, + ) { + if (boxes.isEmpty()) return + for (key in tileCache.snapshot().keys) { + val z = (key ushr 56).toInt() + val x = ((key ushr 27) and COORD_MASK).toInt() + val y = (key and COORD_MASK).toInt() + val scale = tileSize.toDouble() * (1L shl z).toDouble() + val pad = (strokePx + 2.0) / scale + val tx0 = x * tileSize / scale - pad + val tx1 = (x + 1) * tileSize / scale + pad + val ty0 = y * tileSize / scale - pad + val ty1 = (y + 1) * tileSize / scale + pad + if (boxes.any { b -> b[0] <= tx1 && b[2] >= tx0 && b[1] <= ty1 && b[3] >= ty0 }) { + tileCache.remove(key) + } + } + } + + private fun cacheKey( + z: Int, + x: Int, + y: Int, + ratio: Int, + ): Long = (z.toLong() shl 56) or (ratio.toLong() shl 54) or (x.toLong() shl 27) or y.toLong() + + @Suppress("UNCHECKED_CAST") + private fun bboxAt( + items: Array, + i: Int, + ): DoubleArray? = (items[i] as T?)?.let(painter::bbox) + + @Suppress("UNCHECKED_CAST") + private fun maxStrokeOf(items: Array): Float { + var maxStroke = 0f + for (item in items) if (item != null) maxStroke = max(maxStroke, painter.strokeWidth(item as T)) + return maxStroke + } + + /** Thrown when the map stops wanting a tile mid-draw: a failure it may retry, never an empty tile. */ + @InternalMapConductorApi + class TileRenderCancelled : Exception("shape tile no longer wanted") + + @InternalMapConductorApi + companion object { + const val DEFAULT_CACHE_BYTES = 16 * 1024 * 1024 + + /** Simplified copies kept, most recent zooms first. */ + private const val ZOOMS_KEPT = 4 + private const val MAX_ZOOM = 26 + private const val CANCEL_CHECK_EVERY = 256 + private const val COORD_MASK = (1L shl 27) - 1 + } +} diff --git a/src/main/java/com/mapconductor/core/shapetile/ShapeTileSet.kt b/src/main/java/com/mapconductor/core/shapetile/ShapeTileSet.kt new file mode 100644 index 0000000..29aa718 --- /dev/null +++ b/src/main/java/com/mapconductor/core/shapetile/ShapeTileSet.kt @@ -0,0 +1,166 @@ +package com.mapconductor.core.shapetile + +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.tileserver.LocalTileServer +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The shapes of one map that are drawn as raster tiles, and their route on the + * local tile server: the part `PolygonTileSet` and `PolylineTileSet` share. + * Each of those adds what a state is as a tile ([toTile]) and which states can + * be tiled. + * + * [setShapes] registers the list and returns the URL template of its tiles; + * [update] and [remove] change one shape in place, coalesced over + * [UPDATE_COALESCE], and each batch ends in a new template -- the version in + * it is what makes the map ask again -- reported to [onTemplateChanged]. Tiles + * the change did not touch are still cached by the renderer and come back + * without being drawn again. + * + * The same as js-sdk-core's and ios-sdk-core's `ShapeTileSet`. + */ +@InternalMapConductorApi +internal class ShapeTileSet( + routePrefix: String, + private val server: LocalTileServer, + private val scope: CoroutineScope, + private val renderer: ShapeTileRenderer, + private val idOf: (S) -> String, + private val zIndexOf: (S) -> Int, + private val toTile: (S) -> T?, +) { + val routeId: String = "$routePrefix-${UUID.randomUUID().toString().take(8)}" + + /** Called with the new template after an [update] or [remove]. */ + var onTemplateChanged: (suspend (String) -> Unit)? = null + + val tileSize: Int get() = renderer.tileSize + + private val lock = Any() + private var indexById: Map = emptyMap() + + @Volatile + private var version = 0 + private var registered = false + private val pending = LinkedHashMap() + private val pendingRemovals = ArrayList() + private var flushJob: Job? = null + + /** + * Registers [states] (replacing whatever was registered) and returns the + * URL template of their tiles. + */ + suspend fun setShapes(states: List): String { + cancelPending() + val (shapes, ids) = + withContext(Dispatchers.Default) { + // Draw order is list order: zIndex, then the order given (sortedBy is stable). + val ordered = states.sortedBy(zIndexOf) + val ids = HashMap(ordered.size * 2) + ordered.forEachIndexed { index, state -> ids[idOf(state)] = index } + ordered.map(toTile) to ids + } + synchronized(lock) { + renderer.setItems(shapes) + if (!registered) server.register(routeId, renderer) + indexById = ids + registered = true + version++ + } + return template() + } + + fun has(id: String): Boolean = synchronized(lock) { indexById.containsKey(id) } + + /** + * One shape changed in place. A change of zIndex reorders the list and + * needs [setShapes] instead; everything else is applied here. + */ + fun update(state: S) { + synchronized(lock) { + val id = idOf(state) + if (!indexById.containsKey(id)) return + pending[id] = state + scheduleFlushLocked() + } + } + + /** + * Takes one shape out of the tiles. Its slot in the renderer's list is + * left empty, so the others keep their indices and only the tiles it was + * in are drawn again. + */ + fun remove(id: String) { + synchronized(lock) { + val index = indexById[id] ?: return + indexById = indexById - id + pending.remove(id) + pendingRemovals.add(index) + scheduleFlushLocked() + } + } + + /** Called with [lock] held. */ + private fun scheduleFlushLocked() { + if (flushJob == null) { + flushJob = + scope.launch { + delay(UPDATE_COALESCE) + flush() + } + } + } + + /** The URL template, with the version that changes on every change. */ + fun template(): String = server.urlTemplate(routeId, renderer.tileSize, version.toString()) + + fun dispose() { + cancelPending() + synchronized(lock) { + if (registered) server.unregister(routeId) + registered = false + indexById = emptyMap() + renderer.clear() + } + } + + private suspend fun flush() { + val template = + synchronized(lock) { + flushJob = null + val updates: List> = + pending.values.mapNotNull { state -> + indexById[idOf(state)]?.let { index -> index to toTile(state) } + } + pendingRemovals.map { index -> index to null } + pending.clear() + pendingRemovals.clear() + if (updates.isEmpty() || !registered) return + renderer.update(updates) + version++ + template() + } + onTemplateChanged?.invoke(template) + } + + private fun cancelPending() { + synchronized(lock) { + flushJob?.cancel() + flushJob = null + pending.clear() + pendingRemovals.clear() + } + } + + @InternalMapConductorApi + companion object { + /** In-place changes arriving within this window are drawn as one. */ + val UPDATE_COALESCE = 16.milliseconds + } +} diff --git a/src/main/java/com/mapconductor/core/shapetile/ShapeTiler.kt b/src/main/java/com/mapconductor/core/shapetile/ShapeTiler.kt new file mode 100644 index 0000000..a1d6049 --- /dev/null +++ b/src/main/java/com/mapconductor/core/shapetile/ShapeTiler.kt @@ -0,0 +1,82 @@ +package com.mapconductor.core.shapetile + +import com.mapconductor.core.InternalMapConductorApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Decides which of a map's shapes are drawn as tiles and keeps them registered + * in [tileSet]: the part `PolygonTiler` and `PolylineTiler` share. See + * `PolygonTiler` for how a provider's controller uses it. + * + * [shouldUseTiles] is read at every [route], so a tiler whose options change + * decides again on the next one. + */ +@InternalMapConductorApi +internal class ShapeTiler( + private val tileSet: ShapeTileSet, + private val canTile: (S) -> Boolean, + private val vertexCount: (List) -> Int, + private val shouldUseTiles: (count: Int, vertices: Int) -> Boolean, + private val idOf: (S) -> String, + private val onTemplate: suspend (template: String?, tileSize: Int) -> Unit, +) { + @Volatile + private var tiled: Set = emptySet() + + @Volatile + private var states: List = emptyList() + + init { + tileSet.onTemplateChanged = { template -> onTemplate(template, tileSet.tileSize) } + } + + /** Re-decides tiling for [data] and returns the ids drawn as tiles. */ + suspend fun route(data: List): Set { + // A city of shapes is a second of work; none of it belongs on the map's thread. + val (tileable, useTiles) = + withContext(Dispatchers.Default) { + val tileable = data.filter(canTile) + tileable to shouldUseTiles(tileable.size, vertexCount(tileable)) + } + val template = if (useTiles) tileSet.setShapes(tileable) else null + if (template == null) { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + } else { + tiled = withContext(Dispatchers.Default) { tileable.mapTo(HashSet(tileable.size * 2), idOf) } + states = tileable + } + onTemplate(template, tileSet.tileSize) + return tiled + } + + fun tiledStates(): List = states + + fun isTiled(id: String): Boolean = tiled.contains(id) + + fun update(state: S) { + tileSet.update(state) + } + + fun remove(id: String) { + if (id !in tiled) return + tiled = tiled - id + states = states.filter { idOf(it) != id } + tileSet.remove(id) + } + + fun dispose() { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + } + + suspend fun clear() { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + onTemplate(null, tileSet.tileSize) + } +} diff --git a/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt b/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt index a3e21f8..fe6ac20 100644 --- a/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt +++ b/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt @@ -107,6 +107,59 @@ class OverlayStackingTest { } } +/** + * SDK が描くタイルの置き場所。プロバイダはこの鍵(GL 系)と flatZIndex(Google など)で + * タイルのレイヤーを置く。ポリラインのタイルはポリラインの帯の一番下: ポリゴンのタイルと + * サークルの上、ネイティブのポリラインとマーカー(のタイル)の下。 + */ +class ShapeTileStackingTest { + private val polygonTile = OverlayStacking.Key(OverlayKind.Polygon, Int.MIN_VALUE) + private val polylineTile = OverlayStacking.Key(OverlayKind.Polyline, Int.MIN_VALUE) + private val markerTile = OverlayStacking.Key(OverlayKind.Marker, Int.MIN_VALUE) + + @Test + fun `ポリラインのタイルはサークルの上、ネイティブのポリラインとマーカーの下`() { + val keys = + listOf( + OverlayStacking.Key(OverlayKind.Marker, Int.MIN_VALUE + 1), + markerTile, + OverlayStacking.Key(OverlayKind.Polyline, -1_000_000), + polylineTile, + OverlayStacking.Key(OverlayKind.Circle, Int.MAX_VALUE), + OverlayStacking.Key(OverlayKind.Polygon, Int.MAX_VALUE), + polygonTile, + ) + assertEquals(keys.reversed(), keys.sorted()) + } + + @Test + fun `flatZIndex でも同じ順`() { + val polylineTileZ = OverlayStacking.flatZIndex(OverlayKind.Polyline, Int.MIN_VALUE) + assertTrue(polylineTileZ > OverlayStacking.flatZIndex(OverlayKind.Circle, Int.MAX_VALUE)) + assertTrue(polylineTileZ > OverlayStacking.flatZIndex(OverlayKind.Polygon, Int.MIN_VALUE)) + assertTrue(polylineTileZ < OverlayStacking.flatZIndex(OverlayKind.Polyline, -1_000)) + assertTrue(polylineTileZ < OverlayStacking.flatZIndex(OverlayKind.Marker, Int.MIN_VALUE)) + } + + @Test + fun `GL 系の層の並びでは、ポリゴンのタイルとサークルの上、ネイティブのポリラインの下へ入る`() { + val stack = OverlayLayerStack() + stack.put("raster-layer-mc-polygon-tiles", polygonTile) + stack.put("circle-layer", OverlayStacking.Key(OverlayKind.Circle)) + stack.put("polyline-layer", OverlayStacking.Key(OverlayKind.Polyline)) + stack.put("raster-layer-marker-tile-1", markerTile) + val layers = + listOf( + "water", + "raster-layer-mc-polygon-tiles", + "circle-layer", + "polyline-layer", + "raster-layer-marker-tile-1", + ) + assertEquals("polyline-layer", stack.beforeId(layers, polylineTile)) + } +} + class OverlayLayerStackTest { @Test fun `台帳に無い層は無視し、スタイルを読み直しても同じ位置を返す`() { diff --git a/src/test/java/com/mapconductor/core/polyline/PolylineHitToleranceTest.kt b/src/test/java/com/mapconductor/core/polyline/PolylineHitToleranceTest.kt new file mode 100644 index 0000000..c0289dc --- /dev/null +++ b/src/test/java/com/mapconductor/core/polyline/PolylineHitToleranceTest.kt @@ -0,0 +1,169 @@ +package com.mapconductor.core.polyline + +import androidx.compose.ui.unit.dp +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.spherical.Planar +import com.mapconductor.core.spherical.calculateMetersPerPixel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import kotlin.random.Random + +/** + * ポリラインのタップ判定。タイルで描いていてもネイティブで描いていても同じ。 + * + * 当たるのは「線の縁から指の大きさ(tapTolerance)以内」: 中心線から tapTolerance + 線幅 / 2 + * (どちらも画面上の大きさ)。複数当たれば一番近い線。clickable = false の線は素通り。 + * 5 万本を毎回測らないよう、外接矩形(許容ぶん広げる)に入らない線は先に飛ばす。飛ばしても結果は変わらない。 + * + * js-sdk-core の `PolylineHitTolerance.test.mjs`、ios-sdk の `PolylineHitToleranceTests` と 1 対 1 で対応する。 + * ユニットテストでは density = 1(ResourceProvider のフォールバック)、tapTolerance は 14 dp = 14 px。 + */ +class PolylineHitToleranceTest { + private val lat = 42.3550 + private val camera = MapCameraPosition(position = GeoPoint.fromLatLong(lat, -71.06), zoom = 16.0) + private val metersPerPixel = calculateMetersPerPixel(lat, 16.0) + + /** 北へ [meters] m。 */ + private fun north(meters: Double) = meters / 111_320.0 + + private fun line( + id: String, + offsetMeters: Double = 0.0, + width: Float = 1f, + clickable: Boolean = true, + ) = PolylineState( + points = listOf(GeoPoint(lat + north(offsetMeters), -71.0610), GeoPoint(lat + north(offsetMeters), -71.0590)), + id = id, + strokeWidth = width.dp, + clickable = clickable, + ) + + private fun managerWith(vararg states: PolylineState): PolylineManager = + PolylineManager().apply { + states.forEach { registerEntity(PolylineEntity(polyline = Unit, state = it)) } + } + + private fun tapNorth(meters: Double) = GeoPoint(lat + north(meters), -71.0600) + + @Test + fun `太い線は縁から指の大きさ以内なら当たり、細い線は同じ距離で外れる`() { + // z16 で約 1.77 m/px。細い線(1 px): 14.5 px ≒ 25.6 m、太い線(10 px): 19 px ≒ 33.5 m + val thirty = 30.0 + assertEquals( + "wide", + managerWith(line("wide", width = 10f)) + .find(tapNorth(thirty), camera) + ?.entity + ?.state + ?.id, + ) + assertNull(managerWith(line("thin", width = 1f)).find(tapNorth(thirty), camera)) + // 境目の内と外 + val edge = (14.0 + 5.0) * metersPerPixel + assertEquals( + "wide", + managerWith(line("wide", width = 10f)) + .find(tapNorth(edge - 1.0), camera) + ?.entity + ?.state + ?.id, + ) + assertNull(managerWith(line("wide", width = 10f)).find(tapNorth(edge + 1.0), camera)) + } + + @Test + fun `当たった点は線上の最近点`() { + val hit = managerWith(line("a", width = 4f)).find(tapNorth(10.0), camera)!! + assertEquals(lat, hit.closestPoint.latitude, 1e-7) + assertEquals(-71.0600, hit.closestPoint.longitude, 1e-7) + } + + @Test + fun `複数当たれば一番近い線`() { + val manager = managerWith(line("south", 0.0, width = 6f), line("north", 20.0, width = 2f)) + assertEquals( + "north", + manager + .find(tapNorth(15.0), camera) + ?.entity + ?.state + ?.id, + ) + assertEquals( + "south", + manager + .find(tapNorth(5.0), camera) + ?.entity + ?.state + ?.id, + ) + } + + @Test + fun `clickable = false の線は素通りして下の線に当たる`() { + val manager = managerWith(line("under", 0.0), line("over", 2.0, clickable = false)) + assertEquals( + "under", + manager + .find(tapNorth(2.0), camera) + ?.entity + ?.state + ?.id, + ) + assertNull(managerWith(line("over", 2.0, clickable = false)).find(tapNorth(2.0), camera)) + } + + @Test + fun `外接矩形で飛ばしても、全部測ったときと同じ線が当たる`() { + val random = Random(7) + val states = + List(300) { i -> + val lat0 = lat + random.nextDouble(-0.01, 0.01) + val lng0 = -71.06 + random.nextDouble(-0.01, 0.01) + val points = + List(2 + random.nextInt(4)) { + GeoPoint(lat0 + random.nextDouble(-0.001, 0.001), lng0 + random.nextDouble(-0.001, 0.001)) + } + PolylineState(points = points, id = "l$i", strokeWidth = random.nextInt(1, 8).dp) + } + val manager = managerWith(*states.toTypedArray()) + var hits = 0 + repeat(400) { + val tap = GeoPoint(lat + random.nextDouble(-0.011, 0.011), -71.06 + random.nextDouble(-0.011, 0.011)) + val expected = bruteForce(states, tap) + val actual = + manager + .find(tap, camera) + ?.entity + ?.state + ?.id + assertEquals(expected, actual) + if (actual != null) hits++ + } + // 当たりも外れも十分に含む + assert(hits in 40..360) { "hits=$hits" } + } + + /** 外接矩形を使わず、全部の線の全部の辺を測る。 */ + private fun bruteForce( + states: List, + tap: GeoPoint, + ): String? { + var best: String? = null + var bestDistance = Double.POSITIVE_INFINITY + val mpp = calculateMetersPerPixel(tap.latitude, 16.0) + for (s in states) { + val threshold = mpp * (14.0 + s.strokeWidth.value / 2.0) + for (i in 0 until s.points.size - 1) { + val hit = Planar.pointOnLineOrNull(s.points[i], s.points[i + 1], tap, threshold) ?: continue + if (hit.second < bestDistance) { + bestDistance = hit.second + best = s.id + } + } + } + return best + } +} diff --git a/src/test/java/com/mapconductor/core/polyline/PolylineTilingTest.kt b/src/test/java/com/mapconductor/core/polyline/PolylineTilingTest.kt new file mode 100644 index 0000000..35d5937 --- /dev/null +++ b/src/test/java/com/mapconductor/core/polyline/PolylineTilingTest.kt @@ -0,0 +1,144 @@ +package com.mapconductor.core.polyline + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * ポリラインのタイル描画を支える部品(画素を見ない部分)。 + * + * js-sdk-core の `PolylineTiling.test.mjs`、ios-sdk の `PolylineTilingTests` と 1 対 1 で対応する。 + * 画素そのものは端末で見る(androidTest の `PolylineTileRendererDeviceTest`)。 + * + * - しきい値: 本数か点の数のどちらかが届けばタイル。判定はここ 1 か所。 + * - タイルにできないもの: geodesic、経度の幅 180 度以上、2 点未満。 + * - ズームごとの簡略化: 画素格子に丸め、続く重複を捨て、半画素の Douglas-Peucker(両端は残す)。 + * 1 点に潰れたら点。 + */ +class PolylineTilingTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + @Test + fun `本数か点の数のどちらかが届けばタイルにする`() { + val o = PolylineTilingOptions.Default + assertFalse(o.shouldUseTiles(1999, 49_999)) + assertTrue(o.shouldUseTiles(2000, 0)) + assertTrue(o.shouldUseTiles(10, 50_000)) + assertFalse(PolylineTilingOptions.Disabled.shouldUseTiles(100_000, 10_000_000)) + } + + @Test + fun `geodesic・経度の幅 180 度以上・2 点未満はタイルにしない`() { + assertTrue(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))))) + assertFalse(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0)), geodesic = true))) + assertFalse(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, -100.0), p(0.0, 80.0))))) + assertTrue(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, -90.0), p(0.0, 89.0))))) + assertFalse(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, 0.0))))) + assertFalse(PolylineTileSet.canTile(PolylineState(points = emptyList()))) + } + + @Test + fun `点の数は全ポリラインの合計`() { + val a = PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0), p(2.0, 2.0))) + val b = PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))) + assertEquals(5, PolylineTileSet.vertexCount(listOf(a, b))) + } + + @Test + fun `タイル用の線は開いた経路のまま、外接矩形を持つ`() { + assertNull(TilePolyline.from(PolylineState(points = listOf(p(0.0, 0.0))))) + val line = TilePolyline.from(PolylineState(points = listOf(p(0.0, 0.0), p(0.0, 90.0), p(0.0, 0.0))))!! + // 閉じない: 3 点のまま + assertEquals(6, line.path.size) + assertArrayEquals(doubleArrayOf(0.5, 0.5, 0.75, 0.5), line.bbox, 1e-12) + } + + @Test + fun `点は画素格子に丸め、続く重複と直線上の点を捨て、両端は残す`() { + // 世界 1000 画素。経度 0 度 = x 500 + val scale = 1000.0 + val line = + TilePolyline( + path = + doubleArrayOf( + 0.500, + 0.500, + 0.5004, + 0.5001, // (500, 500) に丸まる + 0.505, + 0.500, // 直線上 + 0.510, + 0.500, + 0.510, + 0.510, // 曲がる + ), + bbox = doubleArrayOf(0.5, 0.5, 0.51, 0.51), + color = 0xFF000000.toInt(), + strokeWidth = 2f, + ) + val shape = simplifyPolyline(line, scale) + assertTrue(shape is PolylineTileShape.Path) + assertArrayEquals( + doubleArrayOf(500.0, 500.0, 510.0, 500.0, 510.0, 510.0), + (shape as PolylineTileShape.Path) + .points, + 0.0, + ) + } + + @Test + fun `直線は両端の 2 点になる`() { + val line = TilePolyline.from(PolylineState(points = List(20) { i -> p(0.0, i * 0.5) }))!! + val shape = simplifyPolyline(line, 256.0 * 64) as PolylineTileShape.Path + assertEquals(4, shape.points.size) + } + + @Test + fun `1 画素に満たない線は点になる`() { + val line = TilePolyline.from(PolylineState(points = listOf(p(0.0, 0.0), p(0.0, 0.001))))!! + // z0: 世界が 256 画素。0.001 度は 0.0007 画素 + val shape = simplifyPolyline(line, 256.0) + assertTrue(shape is PolylineTileShape.Dot) + shape as PolylineTileShape.Dot + assertEquals(128.0, shape.x, 0.01) + assertEquals(128.0, shape.y, 0.01) + } + + @Test + fun `往復する短い線は、外接矩形が 1 画素以上なら経路、未満なら点`() { + val line = + TilePolyline( + path = doubleArrayOf(0.5, 0.5, 0.5012, 0.5, 0.5001, 0.5), + bbox = doubleArrayOf(0.5, 0.5, 0.5012, 0.5), + color = 0xFF000000.toInt(), + strokeWidth = 2f, + ) + // 世界 1000 画素: 500.0 → 500, 501.2 → 501, 500.1 → 500。2 画素なので経路 + assertTrue(simplifyPolyline(line, 1000.0) is PolylineTileShape.Path) + // 世界 800 画素: 外接矩形 0.96 px < 1 なので点 + assertTrue(simplifyPolyline(line, 800.0) is PolylineTileShape.Dot) + } + + @Test + fun `見えない線(透明・幅 0)は余白にも数えない`() { + val transparent = + TilePolyline + .from(PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0)), strokeColor = Color.Transparent))!! + assertFalse(transparent.visible) + assertEquals(0f, PolylineTilePainter.strokeWidth(transparent)) + val visible = TilePolyline.from(PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0)), strokeWidth = 6.dp))!! + assertNotNull(visible) + assertEquals(6f, PolylineTilePainter.strokeWidth(visible)) + } +} diff --git a/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt b/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt index 93d3f6d..7b880fc 100644 --- a/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt +++ b/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.raster import com.mapconductor.core.polygon.polygonTileRasterLayerState +import com.mapconductor.core.polyline.polylineTileRasterLayerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -8,8 +9,8 @@ import org.junit.Assert.assertEquals import org.junit.Test /** - * 1 枚の地図に載るラスターレイヤーは 3 系統ある。アプリの RasterLayer(add = composition)、 - * マーカータイル(marker-tile-*)、ポリゴンタイル(mc-polygon-tiles)。 + * 1 枚の地図に載るラスターレイヤーは 4 系統ある。アプリの RasterLayer(add = composition)、 + * マーカータイル(marker-tile-*)、ポリゴンタイル(mc-polygon-tiles)、ポリラインタイル(mc-polyline-tiles)。 * * js-sdk-core の `RasterLayerCoexistence.test.mjs`、ios-sdk の `RasterLayerCoexistenceTests` と * 1 対 1 で対応する。 @@ -58,25 +59,31 @@ class RasterLayerCoexistenceTest { ) @Test - fun `アプリの RasterLayer・マーカータイル・ポリゴンタイルは互いを消さない`() = + fun `アプリの RasterLayer・マーカータイル・ポリゴンタイル・ポリラインタイルは互いを消さない`() = runBlocking { val onMap = mutableSetOf() val controller = Controller(Renderer(onMap)) controller.add(listOf(layer("relief"))) controller.upsert(polygonTileRasterLayerState("http://127.0.0.1/tiles/p/256/1/{z}/{x}/{y}.png")) + controller.upsert(polylineTileRasterLayerState("http://127.0.0.1/tiles/l/256/1/{z}/{x}/{y}.png")) controller.upsert(markerTiles(1)) - assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "relief"), onMap.sorted()) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "mc-polyline-tiles", "relief"), onMap.sorted()) // アプリの RasterLayer が変わっても、SDK のタイルは残る controller.add(listOf(layer("photo"))) - assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "photo"), onMap.sorted()) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "mc-polyline-tiles", "photo"), onMap.sorted()) // マーカータイルの版が変わっても、ほかは残る controller.upsert(markerTiles(2)) - assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "photo"), onMap.sorted()) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "mc-polyline-tiles", "photo"), onMap.sorted()) - // マーカータイルを外しても、ほかは残る + // ポリラインタイルの版が変わっても、ほかは残る + controller.upsert(polylineTileRasterLayerState("http://127.0.0.1/tiles/l/256/2/{z}/{x}/{y}.png")) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "mc-polyline-tiles", "photo"), onMap.sorted()) + + // マーカータイルとポリラインタイルを外しても、ほかは残る controller.removeById("marker-tile-m") + controller.removeById("mc-polyline-tiles") assertEquals(listOf("mc-polygon-tiles", "photo"), onMap.sorted()) } }