diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..671a084 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "attribution": { + "commit": "", + "pr": "" + } +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 79f872f..f0d3bb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,8 +38,29 @@ jobs: cache: 'gradle' - name: Build core and compose locally + env: + HEAD_REF: ${{ github.head_ref }} run: | - git clone --depth 1 https://github.com/MapConductor/android-sdk-core.git _core + # コア側に PR と同名のブランチがあるなら、そちらを組む。 + # + # ここを default ブランチ固定にしていると、**コアの未マージな変更に追随する + # PR は永久に赤いまま**になる。実際 CameraBearing をコアへ足したとき、 + # プロバイダ側の PR が 10 本まとめて Unresolved reference で落ちた。 + # コアが先にマージされるまで待つ、という運用は取り込み順の事故を呼ぶので、 + # 同名ブランチがあれば拾う形にしてある(無ければ従来どおり default)。 + clone_matching() { + repo="$1"; dir="$2" + url="https://github.com/MapConductor/$repo.git" + if [ -n "$HEAD_REF" ] && git ls-remote --exit-code --heads "$url" "$HEAD_REF" >/dev/null 2>&1; then + echo "$repo: matching branch $HEAD_REF" + git clone --depth 1 --branch "$HEAD_REF" "$url" "$dir" + else + echo "$repo: default branch" + git clone --depth 1 "$url" "$dir" + fi + } + + clone_matching android-sdk-core _core cd _core ./gradlew --no-daemon publishToMavenLocal cd .. @@ -53,7 +74,7 @@ jobs: } EOF - git clone --depth 1 https://github.com/MapConductor/android-sdk-compose.git _compose + clone_matching android-sdk-compose _compose cd _compose ./gradlew --no-daemon publishToMavenLocal cd .. diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index 328ad83..6818f8a 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -10,19 +10,27 @@ public final class com.mapconductor.maplibre.GeoRectBoundsKt { public static final com.mapconductor.core.features.GeoRectBounds toGeoRectBounds(org.maplibre.android.geometry.LatLngBounds); } public final class com.mapconductor.maplibre.MapCameraPositionKt { + public static final double MAPLIBRE_VERTICAL_FOV; + public static final double[] toMaplibrePadding(com.mapconductor.core.map.MapPaddingsInterface, float); + public static final com.mapconductor.core.map.MapPaddings toMapPaddings(double[], float); public static final org.maplibre.android.camera.CameraPosition toCameraPosition(com.mapconductor.core.map.MapCameraPosition); + public static final org.maplibre.android.camera.CameraPosition toCameraPosition(com.mapconductor.core.map.MapCameraPosition, java.lang.Float); public static final com.mapconductor.core.map.MapCameraPosition from(com.mapconductor.core.map.MapCameraPosition$Companion, com.mapconductor.core.map.MapCameraPositionInterface); public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(org.maplibre.android.camera.CameraPosition); public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(org.maplibre.android.camera.CameraPosition, java.lang.Double); + public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(org.maplibre.android.camera.CameraPosition, java.lang.Double, java.lang.Float); } public final class com.mapconductor.maplibre.MapLibreCameraStateSnapshot { - public com.mapconductor.maplibre.MapLibreCameraStateSnapshot(org.maplibre.android.camera.CameraPosition, java.lang.Double); + public com.mapconductor.maplibre.MapLibreCameraStateSnapshot(org.maplibre.android.camera.CameraPosition, java.lang.Double, java.lang.Float); + public com.mapconductor.maplibre.MapLibreCameraStateSnapshot(org.maplibre.android.camera.CameraPosition, java.lang.Double, java.lang.Float, int, kotlin.jvm.internal.DefaultConstructorMarker); public final org.maplibre.android.camera.CameraPosition getCameraPosition(); public final java.lang.Double getLogicalTiltHint(); + public final java.lang.Float getDensity(); public final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(); public final org.maplibre.android.camera.CameraPosition component1(); public final java.lang.Double component2(); - public final com.mapconductor.maplibre.MapLibreCameraStateSnapshot copy(org.maplibre.android.camera.CameraPosition, java.lang.Double); + public final java.lang.Float component3(); + public final com.mapconductor.maplibre.MapLibreCameraStateSnapshot copy(org.maplibre.android.camera.CameraPosition, java.lang.Double, java.lang.Float); public java.lang.String toString(); public int hashCode(); public boolean equals(java.lang.Object); @@ -69,6 +77,9 @@ public final class com.mapconductor.maplibre.MapLibreMapViewHolder implements co public androidx.compose.ui.geometry.Offset toScreenOffset-x-9fifI(com.mapconductor.core.features.GeoPointInterface); public com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(long); public java.lang.Object fromScreenOffset-3MmeM6k(long, kotlin.coroutines.Continuation); + public com.mapconductor.core.projection.ProjectedPoint toProjectedPoint(com.mapconductor.core.features.GeoPointInterface); + public com.mapconductor.core.features.GeoPoint fromProjectedPoint(com.mapconductor.core.projection.ProjectedPoint); + public com.mapconductor.core.projection.MapCoordinateSystem getCoordinateSystem(); public java.lang.Object getMapView(); public java.lang.Object getMap(); } @@ -77,13 +88,16 @@ public interface com.mapconductor.maplibre.MapLibreMapViewHolderInterface extend public final class com.mapconductor.maplibre.MapLibreMapViewHolderInterface$DefaultImpls { public static java.lang.Object fromScreenOffset-3MmeM6k(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, long, kotlin.coroutines.Continuation); public static com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, long); + public static com.mapconductor.core.projection.ProjectedPoint toProjectedPoint(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.features.GeoPointInterface); + public static com.mapconductor.core.features.GeoPoint fromProjectedPoint(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.projection.ProjectedPoint); + public static com.mapconductor.core.projection.MapCoordinateSystem getCoordinateSystem(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); } public final class com.mapconductor.maplibre.MapLibreMapViewKt { - public static final void MapLibreMapView(com.mapconductor.maplibre.MapLibreViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void MapLibreMapView(com.mapconductor.maplibre.MapLibreViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); public static final com.mapconductor.maplibre.MapLibreViewController createMapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.MutableMapServiceRegistry); public static final com.mapconductor.maplibre.marker.MapLibreMarkerController getMarkerController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerTilingOptions); public static final com.mapconductor.maplibre.polyline.MapLibrePolylineController getPolylineController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); - public static final com.mapconductor.maplibre.polygon.MapLibrePolygonConductor getPolygonController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); + public static final com.mapconductor.maplibre.polygon.MapLibrePolygonController getPolygonController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); public static final com.mapconductor.maplibre.circle.MapLibreCircleController getCircleController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); public static final com.mapconductor.maplibre.raster.MapLibreRasterLayerController getRasterLayerController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); public static final com.mapconductor.maplibre.groundimage.MapLibreGroundImageController getGroundImageController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); @@ -101,6 +115,18 @@ public final class com.mapconductor.maplibre.MapLibreMapViewSaver extends com.ma public final class com.mapconductor.maplibre.MapLibreMapViewScope extends com.mapconductor.compose.MapViewScope { public com.mapconductor.maplibre.MapLibreMapViewScope(); } +public final class com.mapconductor.maplibre.MapLibreOverlayStacking { + public static final com.mapconductor.maplibre.MapLibreOverlayStacking INSTANCE; + public final com.mapconductor.core.controller.OverlayLayerStack stackOf(org.maplibre.android.maps.MapLibreMap); + public final com.mapconductor.core.controller.OverlayStacking$Key key(com.mapconductor.core.controller.OverlayKind, int); + public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYGON_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getCIRCLE_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYLINE_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_DRAG(); + public final void add(org.maplibre.android.maps.MapLibreMap, org.maplibre.android.maps.Style, org.maplibre.android.style.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); + public final void remove(org.maplibre.android.maps.MapLibreMap, org.maplibre.android.maps.Style, java.lang.String); +} public final class com.mapconductor.maplibre.MapLibrePolyUtilsKt { public static final java.util.List createMapLibreLines-rXlOB7A(java.lang.String, java.util.List, boolean, long, float, int); public static final java.lang.String toMapLibreColorString-8_81llA(long); @@ -109,8 +135,8 @@ public final class com.mapconductor.maplibre.MapLibrePolyUtilsKt { public final class com.mapconductor.maplibre.MapLibreTypeAliasKt { } public final class com.mapconductor.maplibre.MapLibreViewController extends com.mapconductor.core.controller.BaseMapViewController implements com.mapconductor.maplibre.MapLibreViewControllerInterface,org.maplibre.android.maps.MapLibreMap$OnMapClickListener,org.maplibre.android.maps.MapLibreMap$OnMapLongClickListener,org.maplibre.android.maps.MapLibreMap$OnMoveListener,org.maplibre.android.maps.MapLibreMap$OnCameraMoveListener,org.maplibre.android.maps.MapLibreMap$OnCameraIdleListener { - public com.mapconductor.maplibre.MapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.maplibre.marker.MapLibreMarkerController, com.mapconductor.maplibre.polyline.MapLibrePolylineController, com.mapconductor.maplibre.polygon.MapLibrePolygonConductor, com.mapconductor.maplibre.groundimage.MapLibreGroundImageController, com.mapconductor.maplibre.circle.MapLibreCircleController, com.mapconductor.maplibre.raster.MapLibreRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope); - public com.mapconductor.maplibre.MapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.maplibre.marker.MapLibreMarkerController, com.mapconductor.maplibre.polyline.MapLibrePolylineController, com.mapconductor.maplibre.polygon.MapLibrePolygonConductor, com.mapconductor.maplibre.groundimage.MapLibreGroundImageController, com.mapconductor.maplibre.circle.MapLibreCircleController, com.mapconductor.maplibre.raster.MapLibreRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.maplibre.MapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.maplibre.marker.MapLibreMarkerController, com.mapconductor.maplibre.polyline.MapLibrePolylineController, com.mapconductor.maplibre.polygon.MapLibrePolygonController, com.mapconductor.maplibre.groundimage.MapLibreGroundImageController, com.mapconductor.maplibre.circle.MapLibreCircleController, com.mapconductor.maplibre.raster.MapLibreRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope); + public com.mapconductor.maplibre.MapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.maplibre.marker.MapLibreMarkerController, com.mapconductor.maplibre.polyline.MapLibrePolylineController, com.mapconductor.maplibre.polygon.MapLibrePolygonController, com.mapconductor.maplibre.groundimage.MapLibreGroundImageController, com.mapconductor.maplibre.circle.MapLibreCircleController, com.mapconductor.maplibre.raster.MapLibreRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.maplibre.MapLibreMapViewHolderInterface getHolder(); public kotlinx.coroutines.CoroutineScope getMainCoroutine(); public kotlinx.coroutines.CoroutineScope getDefaultCoroutine(); @@ -153,6 +179,9 @@ public final class com.mapconductor.maplibre.MapLibreViewController extends com. } public final class com.mapconductor.maplibre.MapLibreViewControllerCameraKt { public static final com.mapconductor.core.map.MapCameraPosition readLogicalCameraPosition(com.mapconductor.maplibre.MapLibreViewController); + public static final double viewportHeightDp(com.mapconductor.maplibre.MapLibreViewController); + public static final com.mapconductor.core.map.MapCameraPosition toNativeCamera(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPosition); + public static final float density(com.mapconductor.maplibre.MapLibreViewController); public static final com.mapconductor.core.map.MapCameraPosition getMapCameraPosition(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPositionInterface); public static final void handleMoveCamera(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPosition); public static final void handleAnimateCamera(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPosition, long); @@ -296,6 +325,9 @@ public final class com.mapconductor.maplibre.marker.MapLibreMarkerController ext public void destroy(); public java.lang.Object update(java.lang.Object, kotlin.coroutines.Continuation); } +final class com.mapconductor.maplibre.marker.MapLibreMarkerController$Companion { + public final long getTILE_REFRESH_COALESCE-UwyO8pc(); +} public final class com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer extends com.mapconductor.core.marker.AbstractMarkerOverlayRenderer { public com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerManager, com.mapconductor.maplibre.marker.MarkerLayer, com.mapconductor.maplibre.marker.MarkerDragLayer, kotlinx.coroutines.CoroutineScope); public com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerManager, com.mapconductor.maplibre.marker.MarkerLayer, com.mapconductor.maplibre.marker.MarkerDragLayer, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); @@ -311,6 +343,7 @@ public final class com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRendere public java.lang.Object onRemove(java.util.List>, kotlin.coroutines.Continuation); public final void drawDragLayer(); public final void redraw(); + protected java.lang.Object applyDrawOrder(java.util.List>, kotlin.coroutines.Continuation); public java.lang.Object onPostProcess(kotlin.coroutines.Continuation); public java.lang.Object onChange(java.util.List>, kotlin.coroutines.Continuation>); public void onDragSelectionChanged(com.mapconductor.core.marker.MarkerEntityInterface, com.mapconductor.core.marker.MarkerEntityInterface); @@ -358,27 +391,23 @@ public class com.mapconductor.maplibre.marker.MarkerLayer { public final boolean wouldSkipDraw(java.util.List>); public final void draw(java.util.List>, org.maplibre.android.maps.Style); } -public final class com.mapconductor.maplibre.polygon.MapLibrePolygonConductor implements com.mapconductor.core.controller.SlottedOverlayController> { - public com.mapconductor.maplibre.polygon.MapLibrePolygonConductor(com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer, com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer); - public final com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer getPolygonOverlay(); - public final com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer getPolylineOverlay(); +public final class com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer implements com.mapconductor.core.polygon.PolygonOverlayRendererInterface> { + public com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer(com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer, com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer); + public final com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer getFill(); + public final com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer getOutline(); + public java.lang.Object onAdd(java.util.List, kotlin.coroutines.Continuation>>); + public java.lang.Object onChange(java.util.List>>, kotlin.coroutines.Continuation>>); + public java.lang.Object onRemove(java.util.List>>, kotlin.coroutines.Continuation); + public java.lang.Object onPostProcess(kotlin.coroutines.Continuation); +} +public final class com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRendererKt { +} +public final class com.mapconductor.maplibre.polygon.MapLibrePolygonController extends com.mapconductor.core.polygon.PolygonController> { + public com.mapconductor.maplibre.polygon.MapLibrePolygonController(com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer); + public com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer getRenderer(); public int getZIndex(); - public final kotlin.jvm.functions.Function1 getClickListener(); - public final void setClickListener(kotlin.jvm.functions.Function1); - public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); - public java.lang.Object update(com.mapconductor.core.polygon.PolygonState, kotlin.coroutines.Continuation); - public final void dispatchClick(com.mapconductor.core.polygon.PolygonEvent); - public com.mapconductor.core.polygon.PolygonEntityInterface find(com.mapconductor.core.features.GeoPointInterface); - public java.lang.Object clear(kotlin.coroutines.Continuation); - public com.mapconductor.core.controller.OverlayKind getKind(); - public boolean has(java.lang.String); - public void setClickListenerAny(java.lang.Object); - public com.mapconductor.core.controller.OverlayHit resolveTap(com.mapconductor.core.features.GeoPointInterface); public void destroy(); - public java.lang.Object update(java.lang.Object, kotlin.coroutines.Continuation); - public java.lang.Object find(com.mapconductor.core.features.GeoPointInterface); -} -public final class com.mapconductor.maplibre.polygon.MapLibrePolygonConductorKt { + public com.mapconductor.core.polygon.PolygonOverlayRendererInterface getRenderer(); } public final class com.mapconductor.maplibre.polygon.MapLibrePolygonLayer { public com.mapconductor.maplibre.polygon.MapLibrePolygonLayer(java.lang.String, java.lang.String); @@ -401,6 +430,7 @@ public final class com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRende public com.mapconductor.maplibre.MapLibreMapViewHolderInterface getHolder(); public kotlinx.coroutines.CoroutineScope getCoroutine(); public java.lang.Object onRemove(java.util.List>>, kotlin.coroutines.Continuation); + public final void redraw(); public java.lang.Object onPostProcess(kotlin.coroutines.Continuation); public java.lang.Object removePolygon(com.mapconductor.core.polygon.PolygonEntityInterface>, kotlin.coroutines.Continuation); public java.lang.Object createPolygon(com.mapconductor.core.polygon.PolygonState, kotlin.coroutines.Continuation>); @@ -458,7 +488,6 @@ public final class com.mapconductor.maplibre.raster.MapLibreRasterLayerControlle public java.lang.Object update(com.mapconductor.core.raster.RasterLayerState, kotlin.coroutines.Continuation); public java.lang.Object clear(kotlin.coroutines.Continuation); public void destroy(); - public final java.lang.Object reapplyStyle(kotlin.coroutines.Continuation); public java.lang.Object update(java.lang.Object, kotlin.coroutines.Continuation); } public final class com.mapconductor.maplibre.raster.MapLibreRasterLayerHandle { diff --git a/build.gradle.kts b/build.gradle.kts index feef0d7..303c454 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -99,6 +99,14 @@ dependencies { } else { api("com.mapconductor:compose:$libraryVersion") } + + // A real MapView loading real (local) styles: what a design change does to + // the overlays only shows on a map that actually swaps its style. + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.test.manifest) } // Set project version for NMCP plugin diff --git a/docs/MapLibreViewControllerImpl.kt.md b/docs/MapLibreViewControllerImpl.kt.md index 4b0fd76..b58f1a6 100644 --- a/docs/MapLibreViewControllerImpl.kt.md +++ b/docs/MapLibreViewControllerImpl.kt.md @@ -14,7 +14,7 @@ class MapLibreViewController( override val holder: MapLibreMapViewHolderInterface, private val markerController: MapLibreMarkerController, private val polylineController: MapLibrePolylineController, - private val polygonController: MapLibrePolygonConductor, + private val polygonController: MapLibrePolygonController, private val groundImageController: MapLibreGroundImageController, private val circleController: MapLibreCircleController, private val rasterLayerController: MapLibreRasterLayerController, @@ -39,7 +39,7 @@ Initializes a new instance of the `MapLibreViewController`. - Type: `MapLibrePolylineController` - Description: The controller responsible for managing polylines. - `polygonController` - - Type: `MapLibrePolygonConductor` + - Type: `MapLibrePolygonController` - Description: The controller responsible for managing polygons. - `groundImageController` - Type: `MapLibreGroundImageController` diff --git a/docs/polygon/MapLibrePolygonCompositeRenderer.kt.md b/docs/polygon/MapLibrePolygonCompositeRenderer.kt.md new file mode 100644 index 0000000..013734a --- /dev/null +++ b/docs/polygon/MapLibrePolygonCompositeRenderer.kt.md @@ -0,0 +1,37 @@ +# MapLibrePolygonCompositeRenderer + +## Signature + +```kotlin +class MapLibrePolygonCompositeRenderer( + val fill: MapLibrePolygonOverlayRenderer, + val outline: MapLibrePolylineOverlayRenderer, +) : PolygonOverlayRendererInterface +``` + +## Description + +Presents the fill renderer and the outline renderer to the core `PolygonController` as a single +renderer. + +A MapLibre fill layer can only draw a 1 px outline with no width, so to honour `strokeWidth` each +polygon's outer ring is also drawn as a closed polyline with the id `outline-` through +`outline`. Every call the controller makes on this renderer is forwarded to `fill` and mirrored on +`outline`: + +- `onAdd` / `onChange`: the fill is created or rebuilt; when that succeeds, the outline polyline is + (re)created with the polygon's `strokeColor`, `strokeWidth`, `geodesic`, `zIndex` and `extra`. +- `onRemove`: the outline polylines are dropped and both sources are redrawn without the removed + polygons (the controller's `clear()` does not call `onPostProcess`). +- `onPostProcess`: both sources are redrawn. + +Holes are not outlined. + +## Constructor Parameters + +- `fill` + - Type: `MapLibrePolygonOverlayRenderer` + - Description: Draws the polygon fill (with holes) into the fill source. +- `outline` + - Type: `MapLibrePolylineOverlayRenderer` + - Description: Draws the outlines into the polygon outline source. diff --git a/docs/polygon/MapLibrePolygonConductor.kt.md b/docs/polygon/MapLibrePolygonConductor.kt.md deleted file mode 100644 index 45e265e..0000000 --- a/docs/polygon/MapLibrePolygonConductor.kt.md +++ /dev/null @@ -1,244 +0,0 @@ -# MapLibrePolygonConductor - -The `MapLibrePolygonConductor` class is a controller responsible for managing polygon overlays on a -MapLibre map. It implements the `OverlayControllerInterface` to handle the lifecycle of polygons, -including their creation, update, and removal. This conductor also manages the rendering of polygon -outlines by creating corresponding polylines. - -It acts as a bridge between the abstract `PolygonState` data and the concrete rendering -implementation provided by `MapLibrePolygonOverlayRenderer` and `MapLibrePolylineOverlayRenderer`. - -## Signature - -```kotlin -class MapLibrePolygonConductor( - val polygonOverlay: MapLibrePolygonOverlayRenderer, - val polylineOverlay: MapLibrePolylineOverlayRenderer, -) : OverlayControllerInterface< - PolygonState, - PolygonEntityInterface, - PolygonEvent, - > -``` - -## Constructor - -### `MapLibrePolygonConductor(...)` - -Initializes a new instance of the `MapLibrePolygonConductor`. - -#### Signature - -```kotlin -MapLibrePolygonConductor( - val polygonOverlay: MapLibrePolygonOverlayRenderer, - val polylineOverlay: MapLibrePolylineOverlayRenderer, -) -``` - -#### Parameters - -- `polygonOverlay` - - Type: `MapLibrePolygonOverlayRenderer` - - Description: The renderer responsible for drawing the filled area of the polygons. -- `polylineOverlay` - - Type: `MapLibrePolylineOverlayRenderer` - - Description: The renderer responsible for drawing the outlines (strokes) of the polygons. - -## Properties - -### `zIndex` - -Specifies the drawing order of the overlay on the map. Higher values are drawn on top of lower -values. - -#### Signature - -```kotlin -override val zIndex: Int = 2 -``` - -### `clickListener` - -A callback function that is invoked when any polygon managed by this conductor is clicked. This -provides a centralized way to handle click events for all polygons. - -#### Signature - -```kotlin -override var clickListener: ((PolygonEvent) -> Unit)? = null -``` - -## Methods - -### `add` - -Adds a list of polygons to the map. This method efficiently synchronizes the state of the map with -the provided list. It adds new polygons, updates existing ones, and removes any polygons that are -not in the new list. It also creates and manages the corresponding outlines for each polygon. - -#### Signature - -```kotlin -override suspend fun add(data: List) -``` - -#### Parameters - -- `data` - - Type: `List` - - Description: A list of `PolygonState` objects to display on the map. - -### `update` - -Updates a single polygon on the map based on its `PolygonState`. If a polygon with the same ID -already exists, its properties will be updated. If it does not exist, a new polygon will be created. - -#### Signature - -```kotlin -override suspend fun update(state: PolygonState) -``` - -#### Parameters - -- `state` - - Type: `PolygonState` - - Description: The state object representing the polygon to update or add. - -### `dispatchClick` - -Dispatches a click event. This method is typically called by the underlying map framework when a tap -is detected on a polygon. It triggers the `onClick` handler defined within the specific -`PolygonEvent.state` and also invokes the global `clickListener` if it has been set. - -#### Signature - -```kotlin -fun dispatchClick(event: PolygonEvent) -``` - -#### Parameters - -- `event` - - Type: `PolygonEvent` - - Description: The event object containing details about the click. - -### `find` - -Finds the topmost polygon entity at a given geographic position on the map. - -#### Signature - -```kotlin -override fun find(position: GeoPointInterface): PolygonEntityInterface? -``` - -#### Parameters - -- `position` - - Type: `GeoPointInterface` - - Description: The geographic coordinate (latitude and longitude) to search at. - -#### Returns - -- Type: `PolygonEntityInterface?` - - Description: The found polygon entity, or `null` if no polygon is found at the specified - position. - -### `clear` - -Removes all polygons and their outlines that are currently managed by this conductor from the map. - -#### Signature - -```kotlin -override suspend fun clear() -``` - -### `onCameraChanged` - -A lifecycle method called when the map's camera position changes. This implementation is currently -empty. - -#### Signature - -```kotlin -override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) -``` - -### `destroy` - -Cleans up resources used by the conductor. This implementation is currently empty as there are no -specific native resources to release for polygons. - -#### Signature - -```kotlin -override fun destroy() -``` - -## Example - -Here's an example of how to instantiate and use `MapLibrePolygonConductor` to manage polygons on a -map. - -```kotlin -// 1. Assume polygonOverlay and polylineOverlay are already initialized -// val polygonOverlay: MapLibrePolygonOverlayRenderer = ... -// val polylineOverlay: MapLibrePolylineOverlayRenderer = ... - -// 2. Create an instance of the conductor -val polygonConductor = MapLibrePolygonConductor(polygonOverlay, polylineOverlay) - -// 3. Define the state for the polygons you want to add -val polygonState1 = PolygonState( - id = "polygon-1", - points = listOf( - GeoPoint(40.7128, -74.0060), // New York - GeoPoint(34.0522, -118.2437), // Los Angeles - GeoPoint(29.7604, -95.3698) // Houston - ), - fillColor = Color.argb(100, 0, 0, 255), // Semi-transparent blue - strokeColor = Color.BLUE, - strokeWidth = 5f, - onClick = { event -> - println("Clicked on polygon with ID: ${event.state.id}") - } -) - -val polygonState2 = PolygonState( - id = "polygon-2", - points = listOf( - GeoPoint(41.8781, -87.6298), // Chicago - GeoPoint(39.7392, -104.9903), // Denver - GeoPoint(47.6062, -122.3321) // Seattle - ), - fillColor = Color.argb(100, 255, 0, 0) // Semi-transparent red -) - -// 4. Add the polygons to the map -// This needs to be called from a coroutine scope -lifecycleScope.launch { - polygonConductor.add(listOf(polygonState1, polygonState2)) -} - -// 5. Set a global click listener for all polygons -polygonConductor.clickListener = { event -> - println("Global listener: Clicked on polygon ${event.state.id}") - // You can, for example, show an info window here -} - -// 6. Update a polygon later -lifecycleScope.launch { - val updatedPolygonState1 = polygonState1.copy( - fillColor = Color.argb(100, 0, 255, 0) // Change color to green - ) - polygonConductor.update(updatedPolygonState1) -} - -// 7. Clear all polygons from the map -lifecycleScope.launch { - polygonConductor.clear() -} -``` diff --git a/docs/polygon/MapLibrePolygonController.kt.md b/docs/polygon/MapLibrePolygonController.kt.md new file mode 100644 index 0000000..7640446 --- /dev/null +++ b/docs/polygon/MapLibrePolygonController.kt.md @@ -0,0 +1,30 @@ +# MapLibrePolygonController + +## Signature + +```kotlin +class MapLibrePolygonController( + override val renderer: MapLibrePolygonCompositeRenderer, +) : PolygonController(renderer.fill.polygonManager, renderer) +``` + +## Description + +The polygon controller of the MapLibre provider. It extends the core `PolygonController`, so diffing +(add / change / remove), `has`, `find`, the click cascade (`resolveTap`, `dispatchClick`), +`clear` and `destroy` are the core implementation shared with every other provider. + +The only provider-specific part is the renderer: a fill layer can only draw a 1 px outline, so the +outline is drawn as a separate polyline by [`MapLibrePolygonCompositeRenderer`](./MapLibrePolygonCompositeRenderer.kt.md). + +## Constructor Parameters + +- `renderer` + - Type: `MapLibrePolygonCompositeRenderer` + - Description: Draws both the fill and the outline. Its `fill.polygonManager` is used as the + controller's polygon manager, so the controller and the fill renderer always see the same + entities. + +## Properties + +- `zIndex`: `2` (unchanged from the former `MapLibrePolygonConductor`). diff --git a/gradle.properties b/gradle.properties index 26d9949..8b1a332 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,10 +3,10 @@ android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true -compileSdk=35 +compileSdk=37 minSdk=26 javaVersion=17 jvmTarget=17 -libraryVersion=1.3.1 +libraryVersion=1.4.0 android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2cca6ba..faaa6e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,23 @@ [versions] -composeLibs = "1.7.8" -coroutines = "1.9.0" -okhttp = "5.3.2" -agp = "9.2.1" -kotlin = "2.0.21" +composeLibs = "1.12.1" +# material3 は compose-ui とは別のバージョン系列。composeLibs(ui 系)を流用すると +# 存在しない material3:1.7.8 を要求してクラスパスごと解決に失敗する。 +material3 = "1.4.0" +coroutines = "1.11.0" +okhttp = "5.5.1" +agp = "9.4.1" +kotlin = "2.4.20" -composeBom = "2025.02.00" -lifecycleRuntimeKtx = "2.8.7" -mapLibreSdk = "13.3.1" +composeBom = "2026.09.00" +lifecycleRuntimeKtx = "2.11.0" +mapLibreSdk = "13.7.1" mapLibreAnnotation = "3.0.2" -activityCompose = "1.9.3" +activityCompose = "1.13.0" ktLint = "14.2.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +runner = "1.7.0" [libraries] kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } @@ -34,13 +38,19 @@ maplibre-annotation = { module = "org.maplibre.gl:android-plugin-annotation-v9", # Sample app androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "composeLibs" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +# sample-app が Icons.AutoMirrored を使う。material3 は material-icons を推移的に持ってこないので +# 明示的に足す。版は compose BOM 任せ(集約ビルドの catalog と同じ形)。 +androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "composeLibs" } # Test junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "composeLibs" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "composeLibs" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts index c3d7029..0862278 100644 --- a/sample-app/build.gradle.kts +++ b/sample-app/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.core) debugImplementation(platform(libs.androidx.compose.bom)) debugImplementation(libs.androidx.ui.tooling) } diff --git a/src/androidTest/java/com/mapconductor/maplibre/MapLibreCircleTilingDeviceTest.kt b/src/androidTest/java/com/mapconductor/maplibre/MapLibreCircleTilingDeviceTest.kt new file mode 100644 index 0000000..83cae1d --- /dev/null +++ b/src/androidTest/java/com/mapconductor/maplibre/MapLibreCircleTilingDeviceTest.kt @@ -0,0 +1,71 @@ +package com.mapconductor.maplibre + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.mapconductor.compose.circle.Circles +import com.mapconductor.core.circle.CIRCLE_TILE_LAYER_ID +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.features.GeoPoint +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Exercises bulk registration through the real map host, not just the core tiler. */ +@RunWith(AndroidJUnit4::class) +class MapLibreCircleTilingDeviceTest { + @get:Rule val compose = createComposeRule() + + @Test + fun circlesSwitchBetweenTilesAndNativeAndSurviveStyleReload() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + fun design(name: String): MapLibreDesign { + val file = File(context.cacheDir, "circle-device-$name.json") + file.writeText("""{"version":8,"name":"$name","sources":{},"layers":[{"id":"background","type":"background"}]}""") + return MapLibreDesign(id = name, styleJsonURL = "file://${file.absolutePath}") + } + val first = design("first") + val second = design("second") + val state = MapLibreViewState(mapDesignType = first, id = "circle-device") + val many = (0..2000).map { i -> + CircleState(center = GeoPoint(35.6 + i * 0.00001, 139.7), radiusMeters = 20.0, id = "circle-$i") + } + var circles by mutableStateOf(many) + compose.setContent { + MapLibreMapView(state = state, modifier = Modifier.fillMaxSize()) { Circles(circles) } + } + fun tiled(design: String): Boolean { + var result = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val style = state.getMapViewHolder()?.map?.style + result = style?.isFullyLoaded == true && style.uri == (if (design == "first") first.styleJsonURL else second.styleJsonURL) && + style.layers.any { it.id.contains(CIRCLE_TILE_LAYER_ID) } + } + return result + } + compose.waitUntil(30_000) { tiled("first") } + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(30_000) { tiled("second") } + compose.runOnUiThread { circles = many.take(1) } + compose.waitUntil(30_000) { + var native = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val style = state.getMapViewHolder()?.map?.style + native = style?.isFullyLoaded == true && style.layers.none { it.id.contains(CIRCLE_TILE_LAYER_ID) } + } + native + } + compose.runOnUiThread { circles = many } + compose.waitUntil(30_000) { tiled("second") } + assertTrue(tiled("second")) + compose.runOnUiThread { circles = emptyList() } + compose.waitUntil(30_000) { !tiled("second") } + } +} diff --git a/src/androidTest/java/com/mapconductor/maplibre/MapLibreGroundImageStyleChangeTest.kt b/src/androidTest/java/com/mapconductor/maplibre/MapLibreGroundImageStyleChangeTest.kt new file mode 100644 index 0000000..16eb747 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/maplibre/MapLibreGroundImageStyleChangeTest.kt @@ -0,0 +1,153 @@ +package com.mapconductor.maplibre + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.mapconductor.compose.groundimage.GroundImage +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageState +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.maplibre.android.maps.Style +import java.io.File + +/** + * A ground image survives the map design changing under it. + * + * Replacing the style throws away every source and layer added at runtime, so + * the SDK has to put the image back into the new style. iOS kept the handle it + * had made for the old style and skipped an unchanged image (ios-for-maplibre + * #12); this checks the Android path — `setMapDesignType` → `setStyle` → + * `setupStyle` — on a real MapView loading two local styles. + */ +@RunWith(AndroidJUnit4::class) +class MapLibreGroundImageStyleChangeTest { + @get:Rule + val compose = createComposeRule() + + private lateinit var first: MapLibreDesign + private lateinit var second: MapLibreDesign + + @Before + fun writeStyles() { + val dir = InstrumentationRegistry.getInstrumentation().targetContext.cacheDir + fun style(name: String): MapLibreDesign { + val file = File(dir, "mc-groundimage-$name.json") + file.writeText( + """{"version": 8, "name": "$name", "sources": {}, + "layers": [{"id": "background", "type": "background"}]}""", + ) + return MapLibreDesign(id = name, styleJsonURL = "file://${file.absolutePath}") + } + first = style("first") + second = style("second") + } + + private fun groundImage(): GroundImageState { + val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) } + val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources + return GroundImageState( + bounds = GeoRectBounds(GeoPoint(35.6, 139.6), GeoPoint(35.8, 139.9)), + image = BitmapDrawable(resources, bitmap), + id = "tokyo", + ) + } + + private fun currentStyle(state: MapLibreViewState): Style? { + var style: Style? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + style = state.getMapViewHolder()?.map?.style?.takeIf { it.isFullyLoaded } + } + return style + } + + private fun hasGroundImage(style: Style?): Boolean { + var found = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + found = style != null && + style.getSource("mc-gimg-src-tokyo") != null && + style.getLayer("mc-gimg-lyr-tokyo") != null + } + return found + } + + private fun styleNamed(state: MapLibreViewState, design: MapLibreDesign): Style? { + var style: Style? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + style = state.getMapViewHolder()?.map?.style + ?.takeIf { it.isFullyLoaded && it.uri == design.styleJsonURL } + } + return style + } + + private var shown by mutableStateOf(true) + + private fun show(state: MapLibreViewState, image: GroundImageState) { + compose.setContent { + MapLibreMapView(state = state, modifier = Modifier.fillMaxSize()) { + if (shown) GroundImage(image) + } + } + } + + @Test + fun anUnchangedGroundImageIsInTheNewStyle() { + val state = MapLibreViewState(mapDesignType = first, id = "unchanged") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, first)) } + val before = currentStyle(state) + + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(TIMEOUT) { styleNamed(state, second) != null } + val after = currentStyle(state) + assertNotSame("the design change replaced the style", before, after) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, second)) } + assertTrue("drawn again in the new style, with nothing about it changed", hasGroundImage(after)) + } + + @Test + fun theSameDesignSetAgainKeepsTheImage() { + val state = MapLibreViewState(mapDesignType = first, id = "same") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, first)) } + + compose.runOnUiThread { state.mapDesignType = first } + // Give a reload, if one is started, time to land before checking. + Thread.sleep(SETTLE_MS) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, first)) } + } + + @Test + fun aGroundImageRemovedAfterTheChangeLeavesTheNewStyle() { + val state = MapLibreViewState(mapDesignType = first, id = "removed") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, first)) } + + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, second)) } + + compose.runOnUiThread { shown = false } + compose.waitUntil(TIMEOUT) { + val style = styleNamed(state, second) + style != null && !hasGroundImage(style) + } + } + + private companion object { + const val TIMEOUT = 20_000L + const val SETTLE_MS = 1_500L + } +} diff --git a/src/androidTest/java/com/mapconductor/maplibre/MapLibreMapViewSaverTest.kt b/src/androidTest/java/com/mapconductor/maplibre/MapLibreMapViewSaverTest.kt new file mode 100644 index 0000000..f2fb611 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/maplibre/MapLibreMapViewSaverTest.kt @@ -0,0 +1,81 @@ +package com.mapconductor.maplibre + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.AttributionRule +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapUISettings +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import android.os.Bundle +import android.os.Parcel + +/** + * 回転などの構成変更で state が [MapLibreMapViewSaver] を通って作り直されても、デザイン(id・ + * attributionRules ほか)と uiSettings・カメラが落ちないこと。Bundle は実機の Parcel を一度通す。 + */ +@RunWith(AndroidJUnit4::class) +class MapLibreMapViewSaverTest { + private val rules = + listOf(AttributionRule("© test"), AttributionRule("© ranged", minZoom = 3, maxZoom = 15)) + + private val camera = + MapCameraPosition( + position = GeoPoint(21.38, -157.93), + zoom = 12.0, + bearing = 20.0, + tilt = 10.0, + ) + + @Test + fun keepsCustomDesignWithAttributionRules() { + val design = MapLibreDesign.MapTilerTonerJa.copy(attributionRules = rules) + val state = MapLibreViewState(mapDesignType = design, id = "maplibre-1", cameraPosition = camera) + state.uiSettings = MapUISettings(rotateGesture = false, tiltGesture = false) + + val restored = roundTrip(MapLibreMapViewSaver().createSaver(), state) + + // 以前は styleJsonURL しか保存しておらず、id は osm-bright、attributionRules は空に戻っていた。 + assertEquals(design, restored.mapDesignType) + assertCommon(restored, "maplibre-1") + } + + @Test + fun keepsPresetDesign() { + val state = MapLibreViewState(mapDesignType = MapLibreDesign.OsmBrightJa, id = "maplibre-2") + val restored = roundTrip(MapLibreMapViewSaver().createSaver(), state) + assertEquals(MapLibreDesign.OsmBrightJa, restored.mapDesignType) + } + + private fun assertCommon( + restored: com.mapconductor.core.map.MapViewStateInterface<*>, + id: String, + ) { + assertEquals(id, restored.id) + assertEquals(false, restored.uiSettings.rotateGesture) + assertEquals(false, restored.uiSettings.tiltGesture) + assertEquals(true, restored.uiSettings.scrollGesture) + assertEquals(12.0, restored.cameraPosition.zoom, 1e-9) + assertEquals(20.0, restored.cameraPosition.bearing, 1e-9) + assertEquals(21.38, restored.cameraPosition.position.latitude, 1e-9) + } + + private fun roundTrip( + saver: Saver, + value: T, + ): T { + val saved = with(saver) { SaverScope { true }.save(value) }!! + // 一度 Parcel に書いて読み戻す(プロセスが落ちたあとの復元と同じ経路)。 + val parcel = Parcel.obtain() + try { + parcel.writeBundle(saved) + parcel.setDataPosition(0) + return saver.restore(parcel.readBundle(javaClass.classLoader)!!)!! + } finally { + parcel.recycle() + } + } +} diff --git a/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt b/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt index 449b359..d620658 100644 --- a/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt +++ b/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt @@ -1,8 +1,11 @@ package com.mapconductor.maplibre import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.CameraBearing import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapPaddings +import com.mapconductor.core.map.MapPaddingsInterface import com.mapconductor.core.spherical.Spherical import com.mapconductor.maplibre.zoom.ZoomAltitudeConverter import org.maplibre.android.camera.CameraPosition @@ -11,19 +14,48 @@ import kotlin.math.cos import kotlin.math.tan private val converter = ZoomAltitudeConverter() + +/** + * MapLibre のカメラの縦方向の視野角(度)。MapLibre Native の既定値で、地図の大きさに + * よらず一定(焦点距離 = 地図の高さ × 1.5)。TB520FU の縦・横で 36.9° を実測。 + */ +internal const val MAPLIBRE_VERTICAL_FOV = 36.87 + +/** dp のパディング → MapLibre の px のパディング(left, top, right, bottom)。 */ +internal fun MapPaddingsInterface.toMaplibrePadding(density: Float): DoubleArray = + doubleArrayOf(left * density, top * density, right * density, bottom * density) + +/** MapLibre の px のパディング(left, top, right, bottom)→ dp のパディング。 */ +internal fun DoubleArray.toMapPaddings(density: Float): MapPaddings? { + if (size < 4 || density <= 0f) return null + return MapPaddings( + top = this[1] / density, + left = this[0] / density, + bottom = this[3] / density, + right = this[2] / density, + ) +} + private const val NEGATIVE_TILT_TARGET_DISTANCE_SCALE = 1.83 private const val NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT = -0.9 -fun MapCameraPosition.toCameraPosition(): CameraPosition { +fun MapCameraPosition.toCameraPosition(): CameraPosition = toCameraPosition(density = null) + +/** + * [density] があれば [MapCameraPosition.paddings] を MapLibre のパディングとして渡す。 + * MapLibre はパディングを除いた領域の中央にカメラの中心を置き、tilt の遠近もエンジンが解く。 + * MapLibre のパディングは次に変えるまで残るので、null(未指定)のときは渡さない。 + */ +internal fun MapCameraPosition.toCameraPosition(density: Float?): CameraPosition { + val padding = if (density != null) paddings?.toMaplibrePadding(density) else null if (tilt >= 0) { return CameraPosition .Builder() .target(GeoPoint.from(position).toLatLng()) .zoom(ZoomAltitudeConverter.googleZoomToMaplibreZoom(zoom)) .tilt(tilt) - .bearing(bearing) - // TODO: -// .padding(paddings?.toEdgeInsects()) + .bearing(CameraBearing.toNativeHeading(bearing)) + .apply { padding?.let { padding(it) } } .build() } else { // tilt < 0: MapLibre cannot represent an upward pitch directly. @@ -37,7 +69,7 @@ fun MapCameraPosition.toCameraPosition(): CameraPosition { cos(tiltAbsRad) * tan(tiltAbsRad) * NEGATIVE_TILT_TARGET_DISTANCE_SCALE - val target = Spherical.computeOffset(position, distanceForward, bearing) + val target = Spherical.computeOffset(position, distanceForward, CameraBearing.toNativeHeading(bearing)) val adjustedZoom = zoom + NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) return CameraPosition @@ -45,9 +77,8 @@ fun MapCameraPosition.toCameraPosition(): CameraPosition { .target(target.toLatLng()) .zoom(ZoomAltitudeConverter.googleZoomToMaplibreZoom(adjustedZoom)) .tilt(tiltAbsDeg) - .bearing(bearing) - // TODO: -// .padding(paddings?.toEdgeInsects()) + .bearing(CameraBearing.toNativeHeading(bearing)) + .apply { padding?.let { padding(it) } } .build() } } @@ -61,7 +92,9 @@ fun MapCameraPosition.Companion.from(cameraPosition: MapCameraPositionInterface) zoom = cameraPosition.zoom, bearing = cameraPosition.bearing, tilt = cameraPosition.tilt, + paddings = cameraPosition.paddings, visibleRegion = cameraPosition.visibleRegion, + fov = cameraPosition.fov, ) } @@ -70,11 +103,20 @@ fun CameraPosition.toMapCameraPosition() = toMapCameraPosition(logicalTiltHint = internal data class MapLibreCameraStateSnapshot( val cameraPosition: CameraPosition, val logicalTiltHint: Double?, + val density: Float? = null, ) { - fun toMapCameraPosition(): MapCameraPosition = cameraPosition.toMapCameraPosition(logicalTiltHint) + fun toMapCameraPosition(): MapCameraPosition = cameraPosition.toMapCameraPosition(logicalTiltHint, density) } -internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCameraPosition { +internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCameraPosition = + toMapCameraPosition(logicalTiltHint, density = null) + +/** [density] があれば MapLibre のパディングを dp に直して paddings として返す。 */ +internal fun CameraPosition.toMapCameraPosition( + logicalTiltHint: Double?, + density: Float?, +): MapCameraPosition { + val paddings = density?.let { d -> padding?.toMapPaddings(d) } ?: MapPaddings.Zeros val pitch = tilt val pitchAbsDeg = abs(pitch).coerceIn(0.0, 60.0) @@ -82,9 +124,11 @@ internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCa return MapCameraPosition( position = target?.toGeoPoint() ?: GeoPoint.fromLongLat(0.0, 0.0), zoom = ZoomAltitudeConverter.maplibreZoomToGoogleZoom(zoom), - bearing = bearing, + bearing = CameraBearing.bearingFromNativeHeading(bearing), tilt = pitch, + paddings = paddings, visibleRegion = null, + fov = MAPLIBRE_VERTICAL_FOV, ) } @@ -104,8 +148,10 @@ internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCa return MapCameraPosition( position = originalPosition, zoom = originalGoogleZoom, - bearing = bear, + bearing = CameraBearing.bearingFromNativeHeading(bear), tilt = -pitchAbsDeg, + paddings = paddings, visibleRegion = null, + fov = MAPLIBRE_VERTICAL_FOV, ) } diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt index fb057af..4d0bdbe 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt @@ -12,7 +12,14 @@ import com.mapconductor.core.OnMapLoadedHandler import com.mapconductor.core.circle.CircleManager import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapPaddings +import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.map.MutableMapServiceRegistry +import com.mapconductor.core.map.StyleMutationStore +import com.mapconductor.core.map.VectorStyleAsDesign +import com.mapconductor.core.map.VectorStyleMutationSupportKey +import com.mapconductor.core.map.VectorStyleSupportKey +import com.mapconductor.core.map.applyStyleMutations import com.mapconductor.core.marker.MarkerEventControllerInterface import com.mapconductor.core.marker.MarkerManager import com.mapconductor.core.marker.MarkerOverlayRendererInterface @@ -32,7 +39,8 @@ import com.mapconductor.maplibre.marker.MapLibreMarkerController import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer import com.mapconductor.maplibre.marker.MarkerDragLayer import com.mapconductor.maplibre.marker.MarkerLayer -import com.mapconductor.maplibre.polygon.MapLibrePolygonConductor +import com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer +import com.mapconductor.maplibre.polygon.MapLibrePolygonController import com.mapconductor.maplibre.polygon.MapLibrePolygonLayer import com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer import com.mapconductor.maplibre.polyline.MapLibrePolylineController @@ -61,6 +69,15 @@ fun MapLibreMapView( onCameraMoveStart: OnCameraMoveHandler? = null, onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable MapLibreMapViewScope.() -> Unit)? = null, ) { val context = LocalContext.current @@ -74,7 +91,8 @@ fun MapLibreMapView( modifier = modifier, viewProvider = { val cameraPosition = - state.cameraPosition.toCameraPosition() + // パディングは MapLibre に渡さない。接続時の moveCamera が中心のずらしで適用する。 + state.cameraPosition.copy(paddings = MapPaddings.Zeros).toCameraPosition(context.resources.displayMetrics.density) val mapInitOptions = MapLibreMapOptions .createFromAttributes(context) @@ -127,6 +145,49 @@ fun MapLibreMapView( mapController.setMapLongClickListener(onMapLongClick) cameraRestriction?.let { mapController.setCameraRestriction(it) } state.setController(mapController) + // MapLibre draws vector styles natively: a layer with a style + // to show hands it over instead of rasterising it. Registered + // here and not in `createMapLibreViewController` because it + // acts on the *state's* design, which non-Compose hosts do + // not have. + state.serviceRegistry.put( + VectorStyleSupportKey, + VectorStyleAsDesign( + state, + // MapLibre's design is a plain style document, so + // "adjust whatever is showing" has something to + // fetch here. Mapbox's is `mapbox://…` and + // MapTiler's is a cloud id, so those answer null for + // their built-ins and the app is told rather than + // shown an unstyled map. + urlOf = { design -> design.styleJsonURL.takeIf { it.startsWith("http") } }, + ) { url, rules -> + MapLibreDesign(id = "vector-style:$url", styleJsonURL = url, attributionRules = rules) + }, + ) + // MapLibre can also be told to change a loaded style in + // place, so adjusting one need not hand it a new document -- + // which would drop its tiles and rebuild every overlay the + // app added. `VectorStyle` looks this up and takes the cheap + // road when it is here. + // + // The style is read through a lambda rather than captured: + // every design change replaces it, and a target holding the + // old one would patch a style the map has already dropped. + val mutations = + StyleMutationStore { list -> + applyStyleMutations( + MapLibreStyleMutationTarget( + style = { mapController.loadedStyle() }, + ready = { mapController.isShowingRequestedStyle() }, + ), + list, + ) + } + state.serviceRegistry.put(VectorStyleMutationSupportKey, mutations) + // Whatever was applied is lost the moment the map loads a + // style again; this is where it goes back on. + mapController.styleLoadedListener = { mutations.onStyleLoaded() } // Post an initial camera update after layout to compute visibleRegion correctly holder.mapView.post { mapController.sendInitialCameraUpdate() } } @@ -142,6 +203,13 @@ fun MapLibreMapView( // Pass content if it needs to be rendered within the overlay providers in MapViewBase, // or handle it here if it's specific to MapLibreMapView structure before calling MapViewBase. // For now, assuming content relates to overlay definitions. + style = style, + onStyleDiagnostics = onStyleDiagnostics, + // Never started (onStart is not called), so it is not stopped either -- + // MapView.onStop() would deactivate the shared FileSource the next map + // still uses. Destroying it ends the render thread, whose camera + // listeners otherwise keep the controller and every overlay alive. + onDestroyMapView = { mapView -> mapView.onDestroy() }, content = content, // This might need adjustment based on how overlays are handled ) } @@ -254,7 +322,7 @@ fun getPolylineController(holder: MapLibreMapViewHolderInterface): MapLibrePolyl return controller } -fun getPolygonController(holder: MapLibreMapViewHolderInterface): MapLibrePolygonConductor { +fun getPolygonController(holder: MapLibreMapViewHolderInterface): MapLibrePolygonController { val polylineLayer = MapLibrePolylineLayer( sourceId = "polygon-outline-source", @@ -281,9 +349,12 @@ fun getPolygonController(holder: MapLibreMapViewHolderInterface): MapLibrePolygo holder = holder, ) - return MapLibrePolygonConductor( - polygonOverlay = polygonOverlayRenderer, - polylineOverlay = polylineOverlayRenderer, + return MapLibrePolygonController( + renderer = + MapLibrePolygonCompositeRenderer( + fill = polygonOverlayRenderer, + outline = polylineOverlayRenderer, + ), ) } diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt b/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt new file mode 100644 index 0000000..8181e44 --- /dev/null +++ b/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt @@ -0,0 +1,96 @@ +package com.mapconductor.maplibre + +import android.util.Log +import com.mapconductor.core.controller.OverlayKind +import com.mapconductor.core.controller.OverlayLayerStack +import com.mapconductor.core.controller.OverlayStacking +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.Layer +import org.maplibre.android.style.layers.SymbolLayer +import java.util.WeakHashMap + +/** + * オーバーレイの層をスタイルのどこへ入れるか([OverlayStacking] の帯の順)。 + * + * 下から: ラスター < GroundImage < ポリゴン < サークル < ポリライン < マーカー + * (マーカーのタイルはマーカーの帯の一番下、ドラッグ中の層は一番上。 + * ポリゴンのタイルはポリゴンの帯の一番下)。 + * + * 位置は毎回スタイルの今の並びから決めるので、追加した順・送り直し・ + * スタイルの読み直し(デザイン変更)に左右されない。 + * + * ラスターだけはベースマップのラベル(SDK の層でない最初の symbol 層)の下に入れる。 + * それ以外の帯はラベルの上に乗る(従来どおり)。 + */ +internal object MapLibreOverlayStacking { + private val stacks = WeakHashMap() + + fun stackOf(map: MapLibreMap): OverlayLayerStack = synchronized(stacks) { stacks.getOrPut(map) { OverlayLayerStack() } } + + fun key( + kind: OverlayKind, + zIndex: Int = 0, + ) = OverlayStacking.Key(kind, zIndex) + + /** マーカーのタイル(ラスター): マーカーの帯の一番下。 */ + val MARKER_TILE = key(OverlayKind.Marker, Int.MIN_VALUE) + + /** ポリゴンのタイル(ラスター): ポリゴンの帯の一番下。 */ + val POLYGON_TILE = key(OverlayKind.Polygon, Int.MIN_VALUE) + + /** 円のタイル(ラスター): サークルの帯の一番下(ポリゴンの上、ネイティブの円とポリラインのタイルの下)。 */ + val CIRCLE_TILE = key(OverlayKind.Circle, Int.MIN_VALUE) + + /** ポリラインのタイル(ラスター): ポリラインの帯の一番下(サークルの上、ネイティブのポリラインの下)。 */ + val POLYLINE_TILE = key(OverlayKind.Polyline, Int.MIN_VALUE) + + /** ドラッグ中のマーカーの層: マーカーの帯の一番上。 */ + val MARKER_DRAG = key(OverlayKind.Marker, Int.MAX_VALUE) + + /** + * [layer] を [key] の位置へ足す。同じ id の層が既にあれば何もしない + * (位置を変えたいときは先に [remove] する)。 + */ + fun add( + map: MapLibreMap, + style: Style, + layer: Layer, + key: OverlayStacking.Key, + ) { + val stack = stackOf(map) + stack.put(layer.id, key) + if (style.getLayer(layer.id) != null) return + val layers = style.layers + val ids = layers.map { it.id } + var before = stack.beforeId(ids, key) + if (key.kind == OverlayKind.RasterLayer) { + val label = layers.firstOrNull { it is SymbolLayer && stack.keyOf(it.id) == null }?.id + if (label != null && (before == null || ids.indexOf(label) < ids.indexOf(before))) { + before = label + } + } + try { + if (before != null) style.addLayerBelow(layer, before) else style.addLayer(layer) + } catch (e: Exception) { + if (style.getLayer(layer.id) != null) return + try { + style.addLayer(layer) + } catch (e2: Exception) { + Log.w("MapLibre", "Failed to add layer: ${layer.id} (${e2.message})") + } + } + } + + fun remove( + map: MapLibreMap, + style: Style?, + layerId: String, + ) { + stackOf(map).remove(layerId) + try { + style?.removeLayer(layerId) + } catch (_: Exception) { + } + } +} diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreStyleMutationTarget.kt b/src/main/java/com/mapconductor/maplibre/MapLibreStyleMutationTarget.kt new file mode 100644 index 0000000..e77afb3 --- /dev/null +++ b/src/main/java/com/mapconductor/maplibre/MapLibreStyleMutationTarget.kt @@ -0,0 +1,179 @@ +package com.mapconductor.maplibre + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.map.StyleMutationTarget +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.BackgroundLayer +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.FillExtrusionLayer +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.HeatmapLayer +import org.maplibre.android.style.layers.LayoutPropertyValue +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.PaintPropertyValue +import org.maplibre.android.style.layers.SymbolLayer + +/** + * Changes a loaded MapLibre style in place. + * + * The seven calls [StyleMutationTarget] asks for, against MapLibre's style + * API. Everything above them — what a mutation means, which can be skipped, + * keeping one bad property from costing the rest — is + * `applyStyleMutations` in core, shared with every other provider. + * + * Specific to MapLibre on Android, despite the shape being shared. Unlike + * the browser -- where MapLibre, Mapbox and the MapTiler SDK answer to the + * same method names and one applier serves all three -- the Android SDKs + * share nothing: Mapbox is `com.mapbox.maps`, and MapTiler Android is + * `com.maptiler.maptilersdk`, not MapLibre at all. Each gets its own + * adapter, and core keeps the decisions. + * + * ## Why values arrive as text + * + * A style property's value is a literal or an expression tree, and MapLibre + * wants the two as different Java types — a `String`/`Float`/`Boolean`, or + * an `Expression` built from a Gson tree. Text is what crossed the FFI + * boundary from the compiler, and this is where it becomes one or the + * other. + */ +@InternalMapConductorApi +internal class MapLibreStyleMutationTarget( + private val style: () -> Style?, + /** + * Whether the style on screen is the document these deltas are for. + * + * Not `style() != null`: a vector style hands the map a new document + * and applies the deltas compiled for it straight after, and in between + * the map still has the one being replaced. See + * `MapLibreViewController.isShowingRequestedStyle`. + */ + private val ready: () -> Boolean, +) : StyleMutationTarget { + override val isReady: Boolean get() = ready() + + override fun hasLayer(layerId: String): Boolean = style()?.getLayer(layerId) != null + + override fun setPaint( + layerId: String, + key: String, + valueJson: String, + ) { + val layer = style()?.getLayer(layerId) ?: return + layer.setProperties(PaintPropertyValue(key, valueFor(valueJson))) + } + + override fun setLayout( + layerId: String, + key: String, + valueJson: String, + ) { + val layer = style()?.getLayer(layerId) ?: return + layer.setProperties(LayoutPropertyValue(key, valueFor(valueJson))) + } + + override fun setFilter( + layerId: String, + filterJson: String, + ) { + // `setFilter` is declared on each layer type rather than on `Layer`, + // so there is no way to write this once. A layer type that cannot + // be filtered (background, raster, hillshade) throws, which the + // caller reports as not applied -- correct, because it cannot be. + val layer = style()?.getLayer(layerId) ?: return + // `setFilter` takes an expression, never null, so "no filter" is + // the one that matches everything. + val expression = + JsonParser.parseString(filterJson).takeIf { !it.isJsonNull }?.let(::expressionOf) + ?: Expression.literal(true) + when (layer) { + is FillLayer -> layer.setFilter(expression) + is LineLayer -> layer.setFilter(expression) + is SymbolLayer -> layer.setFilter(expression) + is CircleLayer -> layer.setFilter(expression) + is FillExtrusionLayer -> layer.setFilter(expression) + is HeatmapLayer -> layer.setFilter(expression) + else -> throw UnsupportedOperationException("${layer.javaClass.simpleName} has no filter") + } + } + + override fun setZoomRange( + layerId: String, + minZoom: Float, + maxZoom: Float, + ) { + val layer = style()?.getLayer(layerId) ?: return + layer.minZoom = minZoom + layer.maxZoom = maxZoom + } + + override fun addLayer( + layerId: String, + layerJson: String, + beforeId: String?, + ) { + val target = style() ?: return + // Already there: the style was reloaded and the whole set is going + // back on. `addLayer` would throw on the duplicate id. + if (target.getLayer(layerId) != null) return + // MapLibre builds layers from typed constructors; there is no "add + // this JSON" call. The compiler only ever adds one kind of layer -- + // a background under a style that has none -- so that is the one + // kind handled, and anything else is refused rather than guessed at. + val layer = JsonParser.parseString(layerJson).asJsonObject + if (layer.get("type")?.asString != "background") { + throw UnsupportedOperationException("only a background layer can be added here") + } + val background = BackgroundLayer(layerId) + layer.getAsJsonObject("paint")?.let { paint -> + val properties = + paint.entrySet().map { (key, value) -> PaintPropertyValue(key, valueFor(value)) } + if (properties.isNotEmpty()) background.setProperties(*properties.toTypedArray()) + } + if (beforeId != null && target.getLayer(beforeId) != null) { + target.addLayerBelow(background, beforeId) + } else { + target.addLayer(background) + } + } + + /** MapLibre answers `false` for a layer it does not have; nothing to guard. */ + override fun removeLayer(layerId: String) { + style()?.removeLayer(layerId) + } + + private fun valueFor(json: String): Any = valueFor(JsonParser.parseString(json)) + + /** + * A JSON node as the Java value MapLibre wants. + * + * Arrays are expressions; everything else is the literal it looks like. + * A number arrives as `Float` because that is what every numeric paint + * property is declared as — handing it a `Double` makes the native + * setter reject the whole property. + */ + private fun valueFor(node: JsonElement): Any = + when { + node.isJsonArray -> expressionOf(node) + node.isJsonNull -> + // Nothing to set. The compiler fills in the spec default for + // the properties it knows, so this is a raw key it did not, + // and refusing is better than writing something invented. + throw UnsupportedOperationException("no value, and no default to put back") + node.isJsonObject -> throw UnsupportedOperationException("an object is not a style value") + else -> { + val primitive = node.asJsonPrimitive + when { + primitive.isBoolean -> primitive.asBoolean + primitive.isNumber -> primitive.asFloat + else -> primitive.asString + } + } + } + + private fun expressionOf(node: JsonElement): Expression = + Expression.Converter.convert(node as? JsonArray ?: node.asJsonArray) +} diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 00869b8..05685c1 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -1,375 +1,462 @@ -package com.mapconductor.maplibre - -import com.mapconductor.core.circle.OnCircleEventHandler -import com.mapconductor.core.controller.BaseMapViewController -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.features.GeoRectBounds -import com.mapconductor.core.groundimage.OnGroundImageEventHandler -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapUISettings -import com.mapconductor.core.marker.DefaultMarkerEventController -import com.mapconductor.core.marker.MarkerAnimationOverlayHost -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -import com.mapconductor.core.marker.OnMarkerEventHandler -import com.mapconductor.core.marker.StrategyMarkerController -import com.mapconductor.core.marker.dispatchGeoMarkerClick -import com.mapconductor.core.polygon.OnPolygonEventHandler -import com.mapconductor.core.polygon.PolygonState -import com.mapconductor.core.polyline.OnPolylineEventHandler -import com.mapconductor.maplibre.circle.MapLibreCircleController -import com.mapconductor.maplibre.groundimage.MapLibreGroundImageController -import com.mapconductor.maplibre.marker.MapLibreMarkerController -import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer -import com.mapconductor.maplibre.marker.MarkerDragLayer -import com.mapconductor.maplibre.marker.MarkerLayer -import com.mapconductor.maplibre.polygon.MapLibrePolygonConductor -import com.mapconductor.maplibre.polyline.MapLibrePolylineController -import com.mapconductor.maplibre.raster.MapLibreRasterLayerController -import org.maplibre.android.geometry.LatLng -import org.maplibre.android.gestures.MoveGestureDetector -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Style -import java.util.UUID -import android.annotation.SuppressLint -import android.util.Log -import android.view.View -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -typealias MapLibreDesignTypeChangeHandler = (MapLibreMapDesignTypeInterface) -> Unit - -class MapLibreViewController( - override val holder: MapLibreMapViewHolderInterface, - internal val markerController: MapLibreMarkerController, - internal val polylineController: MapLibrePolylineController, - internal val polygonController: MapLibrePolygonConductor, - internal val groundImageController: MapLibreGroundImageController, - internal val circleController: MapLibreCircleController, - internal val rasterLayerController: MapLibreRasterLayerController, - override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), - override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), -) : BaseMapViewController(), - MapLibreViewControllerInterface, - MapLibreMap.OnMapClickListener, - MapLibreMap.OnMapLongClickListener, - MapLibreMap.OnMoveListener, - MapLibreMap.OnCameraMoveListener, - MapLibreMap.OnCameraIdleListener { - // Keep reference to the style instance to avoid getting a new one - internal var styleInstance: Style? = null - internal var wasScrollEnabledBeforeDrag: Boolean? = null - internal var dragTouchInterceptor: View.OnTouchListener? = null - internal val polygonZLayers: MutableSet = mutableSetOf() - internal val markerEventControllers = mutableListOf>() - internal var activeDragController: DefaultMarkerEventController? = null - internal var markerClickListener: OnMarkerEventHandler? = null - internal var markerDragStartListener: OnMarkerEventHandler? = null - internal var markerDragListener: OnMarkerEventHandler? = null - internal var markerDragEndListener: OnMarkerEventHandler? = null - internal var markerAnimateStartListener: OnMarkerEventHandler? = null - internal var markerAnimateEndListener: OnMarkerEventHandler? = null - - internal var lastLogicalCameraPosition: MapCameraPosition? = null - - init { - // Style should already be loaded by holderProvider - val style = holder.map.style - if (style != null) { - setupStyle(style) - // Trigger initial camera update after style is ready - sendInitialCameraUpdate() - } - - setupListeners() - registerOverlayController(markerController) - registerOverlayController(polylineController) - registerOverlayController(polygonController) - registerOverlayController(groundImageController) - registerOverlayController(circleController) - registerOverlayController(rasterLayerController) - registerMarkerEventController(DefaultMarkerEventController(markerController)) - - markerController.setRasterLayerCallback { state -> - if (state != null) { - rasterLayerController.upsert(state) - } else { - val markerTileLayers = - rasterLayerController.rasterLayerManager - .allEntities() - .filter { it.state.id.startsWith("marker-tile-") } - markerTileLayers.forEach { entity -> rasterLayerController.removeById(entity.state.id) } - } - } - } - - fun setupListeners() { - holder.map.addOnCameraMoveListener(this) - holder.map.addOnCameraIdleListener(this) - - holder.map.removeOnMapClickListener(this) - holder.map.addOnMapClickListener(this) - - holder.map.removeOnMapLongClickListener(this) - holder.map.addOnMapLongClickListener(this) - - holder.map.removeOnMoveListener(this) - holder.map.addOnMoveListener(this) - } - - override suspend fun clearOverlays() { - markerController.clear() - polylineController.clear() - polygonController.clear() - groundImageController.clear() - circleController.clear() - rasterLayerController.clear() - } - - override fun moveCamera(position: MapCameraPosition) = handleMoveCamera(position) - - override fun animateCamera( - position: MapCameraPosition, - duration: Long, - ) = handleAnimateCamera(position, duration) - - override fun fitBounds( - bounds: GeoRectBounds, - padding: Int, - ) = handleFitBounds(bounds, padding) - - override fun setCameraRestriction(restriction: CameraRestriction?) = handleCameraRestriction(restriction) - - override fun applyUISettings(settings: MapUISettings) { - holder.map.uiSettings.apply { - isScrollGesturesEnabled = settings.scrollGesture - isZoomGesturesEnabled = settings.zoomGesture - isRotateGesturesEnabled = settings.rotateGesture - isTiltGesturesEnabled = settings.tiltGesture - } - } - - // Provide access to the style instance - fun getStyleInstance(): Style? = styleInstance - - private var mapDesignTypeChangeListener: MapLibreDesignTypeChangeHandler? = null - - override fun setMapDesignType(value: MapLibreMapDesignTypeInterface) { - mainCoroutine.launch { - holder.map.setStyle(value.styleJsonURL) { newStyle -> - Log.d("MapLibre", "Style changed to ${value.styleJsonURL}") - setupStyle(newStyle) - } - } - } - - fun sendInitialCameraUpdate() { - mainCoroutine.launch { - notifyMapInitialized() - val mapWidth = holder.mapView.width.toFloat() - val mapHeight = holder.mapView.height.toFloat() - if (mapWidth <= 0 || mapHeight <= 0) return@launch - - val camera = readLogicalCameraPosition() - getMapCameraPosition(camera)?.let { mapCameraPosition -> - defaultCoroutine.launch { notifyMapCameraPosition(mapCameraPosition) } - } - } - } - - override fun setMapDesignTypeChangeListener(listener: MapLibreDesignTypeChangeHandler) { - mapDesignTypeChangeListener = listener - // Don't call listener immediately - it may trigger style reload - // listener(mapDesignType) - } - - override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { - (markerController.renderer as MapLibreMarkerOverlayRenderer).animationOverlayHost = host - } - - override suspend fun compositionPolygons(data: List) { - polygonController.add(data) - getStyleInstance()?.let { ensurePolygonZLayers(it) } - } - - override suspend fun updatePolygon(state: PolygonState) { - polygonController.update(state) - getStyleInstance()?.let { ensurePolygonZLayers(it) } - } - - @Deprecated("Use MarkerState.onDragStart instead.") - override fun setOnMarkerDragStart(listener: OnMarkerEventHandler?) { - markerDragStartListener = listener - markerEventControllers.forEach { it.setDragStartListener(listener) } - } - - @Deprecated("Use MarkerState.onDrag instead.") - override fun setOnMarkerDrag(listener: OnMarkerEventHandler?) { - markerDragListener = listener - markerEventControllers.forEach { it.setDragListener(listener) } - } - - @Deprecated("Use MarkerState.onDragEnd instead.") - override fun setOnMarkerDragEnd(listener: OnMarkerEventHandler?) { - markerDragEndListener = listener - markerEventControllers.forEach { it.setDragEndListener(listener) } - } - - @Deprecated("Use PolylineState.onClick instead.") - override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { - polylineController.clickListener = listener - } - - @Deprecated("Use PolygonState.onClick instead.") - override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { - polygonController.clickListener = listener - } - - @Deprecated("Use CircleState.onClick instead.") - override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { - this.circleController.clickListener = listener - } - - @Deprecated("Use MarkerState.onAnimateStart instead.") - override fun setOnMarkerAnimateStart(listener: OnMarkerEventHandler?) { - markerAnimateStartListener = listener - markerEventControllers.forEach { it.setAnimateStartListener(listener) } - } - - @Deprecated("Use MarkerState.onAnimateEnd instead.") - override fun setOnMarkerAnimateEnd(listener: OnMarkerEventHandler?) { - markerAnimateEndListener = listener - markerEventControllers.forEach { it.setAnimateEndListener(listener) } - } - - @Deprecated("Use MarkerState.onClick instead.") - override fun setOnMarkerClickListener(listener: OnMarkerEventHandler?) { - markerClickListener = listener - markerEventControllers.forEach { it.setClickListener(listener) } - } - - @Deprecated("Use GroundImageState.onClick instead.") - override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { - this.groundImageController.clickListener = listener - } - - /** - * マーカーのヒットテスト。クリックカスケードの先頭。 - * - * MapLibre は地図クリックの座標からそのまま引けるので、コアの - * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 - */ - override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = - markerEventControllers.dispatchGeoMarkerClick(position) - - // 拡張ファイル(Gestures / Camera)からは基底クラスの protected へ触れないため、 - // ここで internal の入口を用意しておく。 - internal fun emitCameraMoveStart(position: MapCameraPosition) { - cameraMoveStartCallback?.invoke(position) - } - - internal fun emitCameraMoveEnd(position: MapCameraPosition) { - cameraMoveEndCallback?.invoke(position) - } - - override fun onMapClick(point: LatLng): Boolean = handleMapClick(point) - - override fun onMapLongClick(point: LatLng): Boolean = handleMapLongClick(point) - - override fun onMoveBegin(detector: MoveGestureDetector) = handleMoveBegin(detector) - - override fun onMove(detector: MoveGestureDetector) = handleMove(detector) - - override fun onMoveEnd(detector: MoveGestureDetector) = handleMoveEnd(detector) - - @SuppressLint("ClickableViewAccessibility") - override fun onCameraMove() { - mainCoroutine.launch { - getMapCameraPosition(readLogicalCameraPosition())?.let { mapCameraPosition -> - defaultCoroutine.launch { - notifyMapCameraPosition(mapCameraPosition) - } - cameraMoveCallback?.invoke(mapCameraPosition) - } - } - } - - override fun onCameraIdle() { - mainCoroutine.launch { - getMapCameraPosition(readLogicalCameraPosition())?.let { mapCameraPosition -> - defaultCoroutine.launch { - notifyMapCameraPosition(mapCameraPosition) - } - cameraMoveEndCallback?.invoke(mapCameraPosition) - } - } - } - - internal fun registerMarkerEventController(controller: DefaultMarkerEventController) { - if (markerEventControllers.contains(controller)) return - markerEventControllers.add(controller) - controller.setClickListener(markerClickListener) - controller.setDragStartListener(markerDragStartListener) - controller.setDragListener(markerDragListener) - controller.setDragEndListener(markerDragEndListener) - controller.setAnimateStartListener(markerAnimateStartListener) - controller.setAnimateEndListener(markerAnimateEndListener) - - val renderer = controller.renderer as MapLibreMarkerOverlayRenderer - styleInstance?.let { style -> - renderer.ensureDefaultIcon(style) - ensureGeoJsonSource(style, renderer.markerLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.markerLayer.layer, - layerId = renderer.markerLayer.layerId, - aboveId = polylineController.renderer.layer.layerId, - ) - ensureGeoJsonSource(style, renderer.dragLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.dragLayer.layer, - layerId = renderer.dragLayer.layerId, - aboveId = renderer.markerLayer.layerId, - ) - renderer.redraw() - renderer.drawDragLayer() - } - } - - fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface { - val groupId = UUID.randomUUID().toString() - val markerLayer = - MarkerLayer( - sourceId = "markers-source-$groupId", - layerId = "markers-layer-$groupId", - ) - val dragLayer = - MarkerDragLayer( - sourceId = "marker-drag-source-$groupId", - layerId = "marker-drag-layer-$groupId", - ) - return MapLibreMarkerOverlayRenderer( - holder = holder, - markerManager = strategy.markerManager, - markerLayer = markerLayer, - dragLayer = dragLayer, - ) - } - - fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = DefaultMarkerEventController(controller) - - fun registerMarkerEventController(controller: MarkerEventControllerInterface) { - @Suppress("UNCHECKED_CAST") - val typed = controller as? DefaultMarkerEventController ?: return - registerMarkerEventController(typed) - } -} +package com.mapconductor.maplibre + +import com.mapconductor.core.circle.CIRCLE_TILE_LAYER_ID +import com.mapconductor.core.circle.CircleTiler +import com.mapconductor.core.circle.circleTileRasterLayerState +import com.mapconductor.core.circle.OnCircleEventHandler +import com.mapconductor.core.controller.BaseMapViewController +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.OnGroundImageEventHandler +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapPaddings +import com.mapconductor.core.map.MapPaddingsInterface +import com.mapconductor.core.map.MapUISettings +import com.mapconductor.core.marker.DefaultMarkerEventController +import com.mapconductor.core.marker.MarkerAnimationOverlayHost +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +import com.mapconductor.core.marker.OnMarkerEventHandler +import com.mapconductor.core.marker.StrategyMarkerController +import com.mapconductor.core.marker.dispatchGeoMarkerClick +import com.mapconductor.core.polygon.OnPolygonEventHandler +import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polygon.PolygonTiler +import com.mapconductor.core.polygon.polygonTileRasterLayerState +import com.mapconductor.core.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID +import com.mapconductor.core.polyline.PolylineTiler +import com.mapconductor.core.polyline.polylineTileRasterLayerState +import com.mapconductor.maplibre.circle.MapLibreCircleController +import com.mapconductor.maplibre.groundimage.MapLibreGroundImageController +import com.mapconductor.maplibre.marker.MapLibreMarkerController +import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer +import com.mapconductor.maplibre.marker.MarkerDragLayer +import com.mapconductor.maplibre.marker.MarkerLayer +import com.mapconductor.maplibre.polygon.MapLibrePolygonController +import com.mapconductor.maplibre.polyline.MapLibrePolylineController +import com.mapconductor.maplibre.raster.MapLibreRasterLayerController +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.gestures.MoveGestureDetector +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import java.util.UUID +import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +typealias MapLibreDesignTypeChangeHandler = (MapLibreMapDesignTypeInterface) -> Unit + +class MapLibreViewController( + override val holder: MapLibreMapViewHolderInterface, + internal val markerController: MapLibreMarkerController, + internal val polylineController: MapLibrePolylineController, + internal val polygonController: MapLibrePolygonController, + internal val groundImageController: MapLibreGroundImageController, + internal val circleController: MapLibreCircleController, + internal val rasterLayerController: MapLibreRasterLayerController, + override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), + override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), +) : BaseMapViewController(), + MapLibreViewControllerInterface, + MapLibreMap.OnMapClickListener, + MapLibreMap.OnMapLongClickListener, + MapLibreMap.OnMoveListener, + MapLibreMap.OnCameraMoveListener, + MapLibreMap.OnCameraIdleListener { + // Keep reference to the style instance to avoid getting a new one + internal var styleInstance: Style? = null + internal var wasScrollEnabledBeforeDrag: Boolean? = null + internal var dragTouchInterceptor: View.OnTouchListener? = null + internal val polygonZLayers: MutableSet = mutableSetOf() + internal val markerEventControllers = mutableListOf>() + internal var activeDragController: DefaultMarkerEventController? = null + internal var markerClickListener: OnMarkerEventHandler? = null + internal var markerDragStartListener: OnMarkerEventHandler? = null + internal var markerDragListener: OnMarkerEventHandler? = null + internal var markerDragEndListener: OnMarkerEventHandler? = null + internal var markerAnimateStartListener: OnMarkerEventHandler? = null + internal var markerAnimateEndListener: OnMarkerEventHandler? = null + + internal var lastLogicalCameraPosition: MapCameraPosition? = null + + /** アプリが指定したパディング(dp)。MapLibre には渡さず、中心のずらしで再現する。 */ + internal var cameraPaddings: MapPaddingsInterface = MapPaddings.Zeros + + init { + // Style should already be loaded by holderProvider + val style = holder.map.style + if (style != null) { + setupStyle(style) + // Trigger initial camera update after style is ready + sendInitialCameraUpdate() + } + + setupListeners() + registerOverlayController(markerController) + registerOverlayController(polylineController) + registerOverlayController(polygonController) + registerOverlayController(groundImageController) + registerOverlayController(circleController) + registerOverlayController(rasterLayerController) + registerMarkerEventController(DefaultMarkerEventController(markerController)) + + markerController.setRasterLayerCallback { state -> + if (state != null) { + rasterLayerController.upsert(state) + } else { + val markerTileLayers = + rasterLayerController.rasterLayerManager + .allEntities() + .filter { it.state.id.startsWith("marker-tile-") } + markerTileLayers.forEach { entity -> rasterLayerController.removeById(entity.state.id) } + } + } + + // Many polygons are drawn as raster tiles, shown through the same RasterLayer + // controller as the marker tiles (upsert / removeById, never the app's layers). + polygonController.tiler = + PolygonTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(polygonTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(POLYGON_TILE_LAYER_ID) + } + } + } + + // Many circles too: the same raster layer path, in the circle band. + circleController.tiler = + CircleTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(circleTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(CIRCLE_TILE_LAYER_ID) + } + } + } + + // Many polylines too: the same raster layer path, in the polyline band. + polylineController.tiler = + PolylineTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(polylineTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(POLYLINE_TILE_LAYER_ID) + } + } + } + } + + fun setupListeners() { + holder.map.addOnCameraMoveListener(this) + holder.map.addOnCameraIdleListener(this) + + holder.map.removeOnMapClickListener(this) + holder.map.addOnMapClickListener(this) + + holder.map.removeOnMapLongClickListener(this) + holder.map.addOnMapLongClickListener(this) + + holder.map.removeOnMoveListener(this) + holder.map.addOnMoveListener(this) + } + + override suspend fun clearOverlays() { + markerController.clear() + polylineController.clear() + polygonController.clear() + groundImageController.clear() + circleController.clear() + rasterLayerController.clear() + // 拡張モジュール(クラスタリングなど)が登録した Strategy のマーカーも消す。 + clearStrategyMarkerControllers() + } + + override fun moveCamera(position: MapCameraPosition) = handleMoveCamera(position) + + override fun animateCamera( + position: MapCameraPosition, + duration: Long, + ) = handleAnimateCamera(position, duration) + + override fun fitBounds( + bounds: GeoRectBounds, + padding: Int, + ) = handleFitBounds(bounds, padding) + + override fun setCameraRestriction(restriction: CameraRestriction?) = handleCameraRestriction(restriction) + + override fun applyUISettings(settings: MapUISettings) { + holder.map.uiSettings.apply { + isScrollGesturesEnabled = settings.scrollGesture + isZoomGesturesEnabled = settings.zoomGesture + isRotateGesturesEnabled = settings.rotateGesture + isTiltGesturesEnabled = settings.tiltGesture + } + } + + // Provide access to the style instance + fun getStyleInstance(): Style? = styleInstance + + /** + * The style, only while it is the one the map draws. A style that a + * newer one has replaced or is replacing throws on every call + * ("Calling getLayer when a newer style is loading"); overlays added + * then are re-added by `setupStyle` once the new style has loaded. + */ + internal fun loadedStyle(): Style? = styleInstance?.takeIf { it.isFullyLoaded } + + /** The document [loadedStyle] was loaded from, and the one asked for. */ + private var loadedStyleUrl: String? = null + private var requestedStyleUrl: String? = null + + /** + * Whether the style on screen is the document the map was last asked + * for. + * + * What [MapLibreStyleMutationTarget] answers `isReady` with, and not the + * same question as "is a style loaded". A vector style hands over a new + * document and then applies the deltas compiled for it; between those + * two the map still has the document being replaced, whose layers have + * other names, so every delta came back as a property this map + * "cannot set by name" -- five hundred of them, while the map itself + * looked right because the whole set goes on again at + * [styleLoadedListener]. + */ + internal fun isShowingRequestedStyle(): Boolean = + loadedStyle() != null && loadedStyleUrl == requestedStyleUrl + + private var mapDesignTypeChangeListener: MapLibreDesignTypeChangeHandler? = null + + /** + * Called once the map has a style up and the overlays are back on it. + * + * How a vector style adjustment survives a design change: everything it + * set is lost with the old style, and this is the moment the new one is + * known to be complete. + */ + internal var styleLoadedListener: (() -> Unit)? = null + + override fun setMapDesignType(value: MapLibreMapDesignTypeInterface) { + requestedStyleUrl = value.styleJsonURL + mainCoroutine.launch { + holder.map.setStyle(value.styleJsonURL) { newStyle -> + Log.d("MapLibre", "Style changed to ${value.styleJsonURL}") + loadedStyleUrl = value.styleJsonURL + setupStyle(newStyle) + } + } + } + + fun sendInitialCameraUpdate() { + mainCoroutine.launch { + notifyMapInitialized() + val mapWidth = holder.mapView.width.toFloat() + val mapHeight = holder.mapView.height.toFloat() + if (mapWidth <= 0 || mapHeight <= 0) return@launch + + val camera = readLogicalCameraPosition() + getMapCameraPosition(camera)?.let { mapCameraPosition -> + defaultCoroutine.launch { notifyMapCameraPosition(mapCameraPosition) } + } + } + } + + override fun setMapDesignTypeChangeListener(listener: MapLibreDesignTypeChangeHandler) { + mapDesignTypeChangeListener = listener + // Don't call listener immediately - it may trigger style reload + // listener(mapDesignType) + } + + override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { + (markerController.renderer as MapLibreMarkerOverlayRenderer).animationOverlayHost = host + } + + override suspend fun compositionPolygons(data: List) { + polygonController.add(data) + loadedStyle()?.let { ensurePolygonZLayers(it) } + } + + override suspend fun updatePolygon(state: PolygonState) { + polygonController.update(state) + loadedStyle()?.let { ensurePolygonZLayers(it) } + } + + @Deprecated("Use MarkerState.onDragStart instead.") + override fun setOnMarkerDragStart(listener: OnMarkerEventHandler?) { + markerDragStartListener = listener + markerEventControllers.forEach { it.setDragStartListener(listener) } + } + + @Deprecated("Use MarkerState.onDrag instead.") + override fun setOnMarkerDrag(listener: OnMarkerEventHandler?) { + markerDragListener = listener + markerEventControllers.forEach { it.setDragListener(listener) } + } + + @Deprecated("Use MarkerState.onDragEnd instead.") + override fun setOnMarkerDragEnd(listener: OnMarkerEventHandler?) { + markerDragEndListener = listener + markerEventControllers.forEach { it.setDragEndListener(listener) } + } + + @Deprecated("Use PolylineState.onClick instead.") + override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { + polylineController.clickListener = listener + } + + @Deprecated("Use PolygonState.onClick instead.") + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { + polygonController.clickListener = listener + } + + @Deprecated("Use CircleState.onClick instead.") + override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { + this.circleController.clickListener = listener + } + + @Deprecated("Use MarkerState.onAnimateStart instead.") + override fun setOnMarkerAnimateStart(listener: OnMarkerEventHandler?) { + markerAnimateStartListener = listener + markerEventControllers.forEach { it.setAnimateStartListener(listener) } + } + + @Deprecated("Use MarkerState.onAnimateEnd instead.") + override fun setOnMarkerAnimateEnd(listener: OnMarkerEventHandler?) { + markerAnimateEndListener = listener + markerEventControllers.forEach { it.setAnimateEndListener(listener) } + } + + @Deprecated("Use MarkerState.onClick instead.") + override fun setOnMarkerClickListener(listener: OnMarkerEventHandler?) { + markerClickListener = listener + markerEventControllers.forEach { it.setClickListener(listener) } + } + + @Deprecated("Use GroundImageState.onClick instead.") + override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { + this.groundImageController.clickListener = listener + } + + /** + * マーカーのヒットテスト。クリックカスケードの先頭。 + * + * MapLibre は地図クリックの座標からそのまま引けるので、コアの + * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 + */ + override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = + markerEventControllers.dispatchGeoMarkerClick(position) + + // 拡張ファイル(Gestures / Camera)からは基底クラスの protected へ触れないため、 + // ここで internal の入口を用意しておく。 + internal fun emitCameraMoveStart(position: MapCameraPosition) { + cameraMoveStartCallback?.invoke(position) + } + + internal fun emitCameraMoveEnd(position: MapCameraPosition) { + cameraMoveEndCallback?.invoke(position) + } + + override fun onMapClick(point: LatLng): Boolean = handleMapClick(point) + + override fun onMapLongClick(point: LatLng): Boolean = handleMapLongClick(point) + + override fun onMoveBegin(detector: MoveGestureDetector) = handleMoveBegin(detector) + + override fun onMove(detector: MoveGestureDetector) = handleMove(detector) + + override fun onMoveEnd(detector: MoveGestureDetector) = handleMoveEnd(detector) + + @SuppressLint("ClickableViewAccessibility") + override fun onCameraMove() { + mainCoroutine.launch { + getMapCameraPosition(readLogicalCameraPosition())?.let { mapCameraPosition -> + defaultCoroutine.launch { + notifyMapCameraPosition(mapCameraPosition) + } + cameraMoveCallback?.invoke(mapCameraPosition) + } + } + } + + override fun onCameraIdle() { + mainCoroutine.launch { + getMapCameraPosition(readLogicalCameraPosition())?.let { mapCameraPosition -> + defaultCoroutine.launch { + notifyMapCameraPosition(mapCameraPosition) + } + cameraMoveEndCallback?.invoke(mapCameraPosition) + } + } + } + + internal fun registerMarkerEventController(controller: DefaultMarkerEventController) { + if (markerEventControllers.contains(controller)) return + markerEventControllers.add(controller) + controller.setClickListener(markerClickListener) + controller.setDragStartListener(markerDragStartListener) + controller.setDragListener(markerDragListener) + controller.setDragEndListener(markerDragEndListener) + controller.setAnimateStartListener(markerAnimateStartListener) + controller.setAnimateEndListener(markerAnimateEndListener) + + val renderer = controller.renderer as MapLibreMarkerOverlayRenderer + loadedStyle()?.let { style -> + renderer.ensureDefaultIcon(style) + ensureGeoJsonSource(style, renderer.markerLayer.sourceId) + MapLibreOverlayStacking.add( + holder.map, + style, + renderer.markerLayer.layer, + MapLibreOverlayStacking.key(com.mapconductor.core.controller.OverlayKind.Marker), + ) + ensureGeoJsonSource(style, renderer.dragLayer.sourceId) + MapLibreOverlayStacking.add(holder.map, style, renderer.dragLayer.layer, MapLibreOverlayStacking.MARKER_DRAG) + renderer.redraw() + renderer.drawDragLayer() + } + } + + fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface { + val groupId = UUID.randomUUID().toString() + val markerLayer = + MarkerLayer( + sourceId = "markers-source-$groupId", + layerId = "markers-layer-$groupId", + ) + val dragLayer = + MarkerDragLayer( + sourceId = "marker-drag-source-$groupId", + layerId = "marker-drag-layer-$groupId", + ) + return MapLibreMarkerOverlayRenderer( + holder = holder, + markerManager = strategy.markerManager, + markerLayer = markerLayer, + dragLayer = dragLayer, + ) + } + + fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = DefaultMarkerEventController(controller) + + fun registerMarkerEventController(controller: MarkerEventControllerInterface) { + @Suppress("UNCHECKED_CAST") + val typed = controller as? DefaultMarkerEventController ?: return + registerMarkerEventController(typed) + } +} diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt index a1bc241..2c818e9 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt @@ -1,9 +1,12 @@ package com.mapconductor.maplibre +import android.view.View import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.map.CameraPaddingEmulation import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapPaddings import com.mapconductor.core.map.buildVisibleRegion import com.mapconductor.maplibre.zoom.ZoomAltitudeConverter import org.maplibre.android.camera.CameraUpdateFactory @@ -17,10 +20,65 @@ import kotlinx.coroutines.launch * 1 段ずれる。読むときも書くときも [ZoomAltitudeConverter] を通す。 */ internal fun MapLibreViewController.readLogicalCameraPosition(): MapCameraPosition = - MapLibreCameraStateSnapshot( - cameraPosition = holder.map.cameraPosition, - logicalTiltHint = lastLogicalCameraPosition?.tilt, - ).toMapCameraPosition() + CameraPaddingEmulation.fromNativeCamera( + MapLibreCameraStateSnapshot( + cameraPosition = holder.map.cameraPosition, + logicalTiltHint = lastLogicalCameraPosition?.tilt, + density = density(), + ).toMapCameraPosition(), + cameraPaddings, + MAPLIBRE_VERTICAL_FOV, + viewportHeightDp(), + ) + +/** 地図の高さ(dp)。パディングのエミュレーションで遠近を解くのに使う。 */ +internal fun MapLibreViewController.viewportHeightDp(): Double = holder.mapView.height / density().toDouble() + +/** + * パディングを中心のずらしに置き換えたカメラ。MapLibre のパディングは常に 0 にする。 + * + * MapLibre のネイティブのパディングは視錐台を上へ広げる。tilt があると上のパディングが + * 大きいほど画面の上端が地平線の先まで届き、そこまでタイルを読みに行って固まる + * (パディング中は tilt も 56° 前後に下げられる)。中心をずらせば、パディング無しと + * 同じ視錐台のまま target をパディング中央に置ける。paddings が null なら今のパディングを使い続ける。 + */ +internal fun MapLibreViewController.toNativeCamera(position: MapCameraPosition): MapCameraPosition { + position.paddings?.let { cameraPaddings = it } + return CameraPaddingEmulation + .toNativeCamera(position.copy(paddings = cameraPaddings), MAPLIBRE_VERTICAL_FOV, viewportHeightDp()) + .copy(paddings = MapPaddings.Zeros) +} + +/** 地図がまだ大きさを持たない(レイアウト前)ときは、パディングを解けないので最初のレイアウトまで待つ。 */ +private fun MapLibreViewController.needsLayoutFor(position: MapCameraPosition): Boolean { + val paddings = position.paddings ?: cameraPaddings + val padded = paddings.top != 0.0 || paddings.left != 0.0 || paddings.bottom != 0.0 || paddings.right != 0.0 + return padded && holder.mapView.height <= 0 +} + +private fun MapLibreViewController.afterFirstLayout(action: () -> Unit) { + holder.mapView.addOnLayoutChangeListener( + object : View.OnLayoutChangeListener { + override fun onLayoutChange( + v: View, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int, + ) { + if (bottom - top <= 0) return + v.removeOnLayoutChangeListener(this) + action() + } + }, + ) +} + +internal fun MapLibreViewController.density(): Float = holder.mapView.resources.displayMetrics.density internal fun MapLibreViewController.getMapCameraPosition(camera: MapCameraPositionInterface): MapCameraPosition? { // 4 隅の逆投影は全プロバイダ共通なのでコアの buildVisibleRegion を使う。 @@ -31,7 +89,11 @@ internal fun MapLibreViewController.getMapCameraPosition(camera: MapCameraPositi internal fun MapLibreViewController.handleMoveCamera(position: MapCameraPosition) { lastLogicalCameraPosition = position mainCoroutine.launch { - val cameraPos = position.toCameraPosition() + if (needsLayoutFor(position)) { + afterFirstLayout { handleMoveCamera(position) } + return@launch + } + val cameraPos = toNativeCamera(position).toCameraPosition(density()) val cameraUpdate = CameraUpdateFactory .newCameraPosition(cameraPos) @@ -46,7 +108,11 @@ internal fun MapLibreViewController.handleAnimateCamera( ) { lastLogicalCameraPosition = position mainCoroutine.launch { - val cameraPos = position.toCameraPosition() + if (needsLayoutFor(position)) { + afterFirstLayout { handleAnimateCamera(position, duration) } + return@launch + } + val cameraPos = toNativeCamera(position).toCameraPosition(density()) val cameraUpdate = CameraUpdateFactory .newCameraPosition(cameraPos) diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt index 8f6c758..025fcc9 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt @@ -1,258 +1,222 @@ -package com.mapconductor.maplibre - -import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer -import org.maplibre.android.maps.Style -import org.maplibre.android.style.expressions.Expression -import org.maplibre.android.style.layers.FillLayer -import org.maplibre.android.style.layers.Layer -import org.maplibre.android.style.layers.LineLayer -import org.maplibre.android.style.layers.Property -import org.maplibre.android.style.layers.PropertyFactory -import org.maplibre.android.style.sources.GeoJsonSource -import android.util.Log -import kotlinx.coroutines.launch - -/** - * スタイル(レイヤーとソース)の組み立て。 - * - * MapLibre はスタイルが読み込み直されるたびにレイヤーが全部消えるため、ここは - * 何度呼ばれても同じ状態になるように書いてある(`ensureGeoJsonSource` / - * `addLayerSafely` はいずれも既存があれば作らない)。 - * - * ポリゴンの z 順は**レイヤーを z 値ごとに用意して**表現する。MapLibre には - * フィーチャー単位の z 指定が無く、レイヤーの並び順だけが順序を決めるため。 - */ -internal fun MapLibreViewController.ensureGeoJsonSource( - style: Style, - sourceId: String, -) { - if (style.getSource(sourceId) != null) return - try { - style.addSource(GeoJsonSource(sourceId)) - } catch (e: Exception) { - Log.w("MapLibre", "Failed to add source: $sourceId (${e.message})") - } -} - -internal fun MapLibreViewController.addLayerSafely( - style: Style, - layer: Layer, - layerId: String, -) { - if (style.getLayer(layerId) != null) return - try { - style.addLayer(layer) - } catch (e: Exception) { - if (style.getLayer(layerId) == null) { - Log.w("MapLibre", "Failed to add layer: $layerId (${e.message})") - } - } -} - -internal fun MapLibreViewController.addLayerAboveSafely( - style: Style, - layer: Layer, - layerId: String, - aboveId: String, -) { - if (style.getLayer(layerId) != null) return - try { - style.addLayerAbove(layer, aboveId) - } catch (_: Exception) { - if (style.getLayer(layerId) != null) return - try { - style.addLayer(layer) - } catch (e2: Exception) { - if (style.getLayer(layerId) == null) { - Log.w("MapLibre", "Failed to add layer: $layerId (${e2.message})") - } - } - } -} - -internal fun MapLibreViewController.setupStyle(style: Style) { - // Store the style instance for future use - styleInstance = style - - // Log existing layers - // val topLayerId = style.layers.lastOrNull()?.id - - // Ensure default icon image exists on this style - (markerController.renderer as MapLibreMarkerOverlayRenderer).ensureDefaultIcon(style) - - // Polygon sources only (layers will be added per zIndex) - ensureGeoJsonSource(style, polygonController.polylineOverlay.layer.sourceId) - ensureGeoJsonSource(style, polygonController.polygonOverlay.layer.sourceId) - - // Circle cts as anchor above polygons - ensureGeoJsonSource(style, circleController.renderer.layer.sourceId) - addLayerSafely( - style = style, - layer = circleController.renderer.layer.layer, - layerId = circleController.renderer.layer.layerId, - ) - // Circle stroke (LineLayer) directly above the circle fill - addLayerAboveSafely( - style = style, - layer = circleController.renderer.layer.strokeLayer, - layerId = circleController.renderer.layer.strokeLayerId, - aboveId = circleController.renderer.layer.layerId, - ) - - // Polyline (general) acts as anchor above circles - ensureGeoJsonSource(style, polylineController.renderer.layer.sourceId) - addLayerSafely( - style = style, - layer = polylineController.renderer.layer.layer, - layerId = polylineController.renderer.layer.layerId, - ) - - // Add z-indexed polygon layers below general polylines - ensurePolygonZLayers(style) - - // Marker - add source and layer at the top - ensureGeoJsonSource(style, (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layer, - layerId = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layerId, - aboveId = polylineController.renderer.layer.layerId, - ) - (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() - - // Drag layer above marker layer - ensureGeoJsonSource(style, (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.layer, - layerId = (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.layerId, - aboveId = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layerId, - ) - (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() - - markerEventControllers - .map { it.renderer as MapLibreMarkerOverlayRenderer } - .filter { it != markerController.renderer } - .forEach { renderer -> - renderer.ensureDefaultIcon(style) - ensureGeoJsonSource(style, renderer.markerLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.markerLayer.layer, - layerId = renderer.markerLayer.layerId, - aboveId = polylineController.renderer.layer.layerId, - ) - ensureGeoJsonSource(style, renderer.dragLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.dragLayer.layer, - layerId = renderer.dragLayer.layerId, - aboveId = renderer.markerLayer.layerId, - ) - renderer.redraw() - renderer.drawDragLayer() - } - - // Force redraw after adding layers - (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() - circleController.renderer.redraw() - polylineController.renderer.redraw() -// polygonController.polygonOverlay.onPostProcess() - mainCoroutine.launch { - groundImageController.reapplyStyle() - rasterLayerController.reapplyStyle() - } -} - -internal fun MapLibreViewController.ensurePolygonZLayers(style: Style) { - val fillSourceId = polygonController.polygonOverlay.layer.sourceId - val outlineSourceId = polygonController.polylineOverlay.layer.sourceId - val anchorId = polylineController.renderer.layer.layerId - - val zSet = - polygonController.polygonOverlay.polygonManager - .allEntities() - .map { it.state.zIndex } - .toSet() - - // Remove stale z-indexed layers we previously created - val toRemove = polygonZLayers.subtract(zSet) - toRemove.forEach { z -> - val fillId = "polygon-fill-layer-$z" - val outlineId = "polygon-outline-layer-$z" - try { - style.removeLayer(outlineId) - } catch (_: Exception) { - } - try { - style.removeLayer(fillId) - } catch (_: Exception) { - } - } - - val zList = zSet.toList().sorted() - zList.forEach { z -> - val fillId = "polygon-fill-layer-$z" - val outlineId = "polygon-outline-layer-$z" - - if (style.getLayer(fillId) == null) { - val fill = - FillLayer(fillId, fillSourceId).apply { - setFilter( - Expression.eq( - Expression - .get("zIndex"), - Expression - .literal(z), - ), - ) - setProperties( - PropertyFactory.fillColor( - Expression - .get("fillColor"), - ), - ) - } - try { - style.addLayerBelow(fill, anchorId) - } catch (_: Exception) { - style.addLayer(fill) - } - } - - if (style.getLayer(outlineId) == null) { - val outline = - LineLayer(outlineId, outlineSourceId).apply { - setFilter( - Expression.eq( - Expression - .get("zIndex"), - Expression - .literal(z), - ), - ) - setProperties( - PropertyFactory - .lineJoin(Property.LINE_JOIN_ROUND), - PropertyFactory - .lineCap(Property.LINE_CAP_ROUND), - PropertyFactory.lineColor( - Expression - .get("strokeColor"), - ), - PropertyFactory.lineWidth( - Expression - .get("strokeWidth"), - ), - ) - } - try { - style.addLayerAbove(outline, fillId) - } catch (_: Exception) { - style.addLayer(outline) - } - } - } - polygonZLayers.clear() - polygonZLayers.addAll(zSet) -} +package com.mapconductor.maplibre + +import com.mapconductor.core.controller.OverlayKind +import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.Layer +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.Property +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.sources.GeoJsonSource +import android.util.Log +import kotlinx.coroutines.launch + +/** + * スタイル(レイヤーとソース)の組み立て。 + * + * MapLibre はスタイルが読み込み直されるたびにレイヤーが全部消えるため、ここは + * 何度呼ばれても同じ状態になるように書いてある(`ensureGeoJsonSource` / + * [MapLibreOverlayStacking.add] はいずれも既存があれば作らない)。層の位置は + * [MapLibreOverlayStacking] が帯の順(ラスター < GroundImage < ポリゴン < サークル < + * ポリライン < マーカー)で決める。 + * + * ポリゴンの z 順は**レイヤーを z 値ごとに用意して**表現する。MapLibre には + * フィーチャー単位の z 指定が無く、レイヤーの並び順だけが順序を決めるため。 + */ +internal fun MapLibreViewController.ensureGeoJsonSource( + style: Style, + sourceId: String, +) { + if (style.getSource(sourceId) != null) return + try { + style.addSource(GeoJsonSource(sourceId)) + } catch (e: Exception) { + Log.w("MapLibre", "Failed to add source: $sourceId (${e.message})") + } +} + +// 層の位置は MapLibreOverlayStacking が決める。以下 2 つは公開 API の互換のために残している(SDK 内では未使用)。 +internal fun MapLibreViewController.addLayerSafely( + style: Style, + layer: Layer, + layerId: String, +) { + if (style.getLayer(layerId) != null) return + try { + style.addLayer(layer) + } catch (e: Exception) { + if (style.getLayer(layerId) == null) { + Log.w("MapLibre", "Failed to add layer: $layerId (${e.message})") + } + } +} + +internal fun MapLibreViewController.addLayerAboveSafely( + style: Style, + layer: Layer, + layerId: String, + aboveId: String, +) { + if (style.getLayer(layerId) != null) return + try { + style.addLayerAbove(layer, aboveId) + } catch (_: Exception) { + if (style.getLayer(layerId) != null) return + try { + style.addLayer(layer) + } catch (e2: Exception) { + if (style.getLayer(layerId) == null) { + Log.w("MapLibre", "Failed to add layer: $layerId (${e2.message})") + } + } + } +} + +internal fun MapLibreViewController.setupStyle(style: Style) { + // Store the style instance for future use + styleInstance = style + + // Log existing layers + // val topLayerId = style.layers.lastOrNull()?.id + + // Ensure default icon image exists on this style + (markerController.renderer as MapLibreMarkerOverlayRenderer).ensureDefaultIcon(style) + + // Polygon sources only (layers will be added per zIndex) + ensureGeoJsonSource(style, polygonController.renderer.outline.layer.sourceId) + ensureGeoJsonSource(style, polygonController.renderer.fill.layer.sourceId) + + // 層の位置は MapLibreOverlayStacking が帯の順で決める(追加の順に依らない)。 + val stacking = MapLibreOverlayStacking + val map = holder.map + val markerRenderer = markerController.renderer as MapLibreMarkerOverlayRenderer + + ensureGeoJsonSource(style, circleController.renderer.layer.sourceId) + stacking.add(map, style, circleController.renderer.layer.layer, stacking.key(OverlayKind.Circle)) + // 線は塗りの直後(同じ鍵なら後から入れたものが上)。 + stacking.add(map, style, circleController.renderer.layer.strokeLayer, stacking.key(OverlayKind.Circle)) + + ensureGeoJsonSource(style, polylineController.renderer.layer.sourceId) + stacking.add(map, style, polylineController.renderer.layer.layer, stacking.key(OverlayKind.Polyline)) + + ensurePolygonZLayers(style) + + ensureGeoJsonSource(style, markerRenderer.markerLayer.sourceId) + stacking.add(map, style, markerRenderer.markerLayer.layer, stacking.key(OverlayKind.Marker)) + markerRenderer.redraw() + + ensureGeoJsonSource(style, markerRenderer.dragLayer.sourceId) + stacking.add(map, style, markerRenderer.dragLayer.layer, stacking.MARKER_DRAG) + markerRenderer.redraw() + + markerEventControllers + .map { it.renderer as MapLibreMarkerOverlayRenderer } + .filter { it != markerController.renderer } + .forEach { renderer -> + renderer.ensureDefaultIcon(style) + ensureGeoJsonSource(style, renderer.markerLayer.sourceId) + stacking.add(map, style, renderer.markerLayer.layer, stacking.key(OverlayKind.Marker)) + ensureGeoJsonSource(style, renderer.dragLayer.sourceId) + stacking.add(map, style, renderer.dragLayer.layer, stacking.MARKER_DRAG) + renderer.redraw() + renderer.drawDragLayer() + } + + // Force redraw after adding layers + (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() + circleController.renderer.redraw() + polylineController.renderer.redraw() + // The polygon sources are new and empty on every style; the data has to + // be written into them again, like the markers' and the polylines'. + polygonController.renderer.fill.redraw() + polygonController.renderer.outline.redraw() + mainCoroutine.launch { + groundImageController.reapplyStyle() + rasterLayerController.reapplyStyle() + } + // Last, and after the overlays: anything a vector style adjustment put + // on the previous style went with it, and this is the only moment the + // new one is known to be complete. + styleLoadedListener?.invoke() +} + +internal fun MapLibreViewController.ensurePolygonZLayers(style: Style) { + val fillSourceId = polygonController.renderer.fill.layer.sourceId + val outlineSourceId = polygonController.renderer.outline.layer.sourceId + + val zSet = + polygonController.polygonManager + .allEntities() + .map { it.state.zIndex } + .toSet() + + // Remove stale z-indexed layers we previously created + val toRemove = polygonZLayers.subtract(zSet) + toRemove.forEach { z -> + val fillId = "polygon-fill-layer-$z" + val outlineId = "polygon-outline-layer-$z" + MapLibreOverlayStacking.remove(holder.map, style, outlineId) + MapLibreOverlayStacking.remove(holder.map, style, fillId) + } + + val zList = zSet.toList().sorted() + zList.forEach { z -> + val fillId = "polygon-fill-layer-$z" + val outlineId = "polygon-outline-layer-$z" + + if (style.getLayer(fillId) == null) { + val fill = + FillLayer(fillId, fillSourceId).apply { + setFilter( + Expression.eq( + Expression + .get("zIndex"), + Expression + .literal(z), + ), + ) + setProperties( + PropertyFactory.fillColor( + Expression + .get("fillColor"), + ), + ) + } + MapLibreOverlayStacking.add(holder.map, style, fill, MapLibreOverlayStacking.key(OverlayKind.Polygon, z)) + } + + if (style.getLayer(outlineId) == null) { + val outline = + LineLayer(outlineId, outlineSourceId).apply { + setFilter( + Expression.eq( + Expression + .get("zIndex"), + Expression + .literal(z), + ), + ) + setProperties( + PropertyFactory + .lineJoin(Property.LINE_JOIN_ROUND), + PropertyFactory + .lineCap(Property.LINE_CAP_ROUND), + PropertyFactory.lineColor( + Expression + .get("strokeColor"), + ), + PropertyFactory.lineWidth( + Expression + .get("strokeWidth"), + ), + ) + } + // 塗りの直後(同じ鍵なら後から入れたものが上)。 + MapLibreOverlayStacking.add(holder.map, style, outline, MapLibreOverlayStacking.key(OverlayKind.Polygon, z)) + } + } + polygonZLayers.clear() + polygonZLayers.addAll(zSet) +} diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt index 10fd586..1485310 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt @@ -8,6 +8,7 @@ import com.mapconductor.compose.map.BaseMapViewSaver import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapViewState +import com.mapconductor.core.map.MapViewStateBundle import com.mapconductor.core.map.MapViewStateInterface import java.util.UUID import android.os.Bundle @@ -20,7 +21,10 @@ class MapLibreViewState( cameraPosition: MapCameraPosition = MapCameraPosition.Default, ) : MapViewState(cameraPosition), MapLibreViewStateInterface { - private var controller: MapLibreViewControllerInterface? = null + // Read through the base so that letting go of the controller there (MapViewBase + // does it when the map is torn down) leaves nothing behind here. + private val controller: MapLibreViewControllerInterface? + get() = attachedMapController as? MapLibreViewControllerInterface private var _mapDesignType: MapLibreMapDesignTypeInterface = mapDesignType override var mapDesignType: MapLibreMapDesignTypeInterface @@ -31,7 +35,6 @@ class MapLibreViewState( get() = _mapDesignType internal fun setController(controller: MapLibreViewControllerInterface) { - this.controller = controller attachController(controller) } @@ -53,7 +56,10 @@ class MapLibreMapViewSaver : BaseMapViewSaver() { state: MapLibreViewState, bundle: Bundle, ) { - bundle.putString("styleJsonURL", state.mapDesignType.styleJsonURL) + val design = state.mapDesignType + bundle.putString("id", design.id) + bundle.putString("styleJsonURL", design.styleJsonURL) + MapViewStateBundle.putAttributionRules(bundle, design.attributionRules) } override fun createState( @@ -71,6 +77,7 @@ class MapLibreMapViewSaver : BaseMapViewSaver() { styleJsonURL = mapDesignBundle?.getString("styleJsonURL") ?: MapLibreDesign.OsmBright.styleJsonURL, + attributionRules = MapViewStateBundle.getAttributionRules(mapDesignBundle), ), cameraPosition = cameraPosition, ) diff --git a/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt index 91f179c..586a11e 100644 --- a/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt @@ -43,24 +43,32 @@ class MapLibreCircleLayer( ) /** 塗りレイヤ。レイヤ順序の互換のため id は従来の layerId を引き継ぐ。 */ - val layer: FillLayer = - FillLayer(layerId, sourceId).apply { - setProperties( - fillColor(Expression.get(Prop.FILL_COLOR)), - fillSortKey(Expression.get(Prop.Z_INDEX)), - ) - } + // A fresh native layer on every read. MapLibre releases a Layer's core + // object into the style it is added to, and the Java object can never be + // added again ("Cannot add layer twice") -- so the one built for the + // first style was silently refused by every style loaded after it, and + // a design change lost the overlays. Only `setupStyle` reads this, once + // per style load. + val layer: FillLayer + get() = + FillLayer(layerId, sourceId).apply { + setProperties( + fillColor(Expression.get(Prop.FILL_COLOR)), + fillSortKey(Expression.get(Prop.Z_INDEX)), + ) + } - val strokeLayer: LineLayer = - LineLayer(strokeLayerId, sourceId).apply { - setProperties( - lineJoin(Property.LINE_JOIN_ROUND), - lineCap(Property.LINE_CAP_ROUND), - lineColor(Expression.get(Prop.STROKE_COLOR)), - lineWidth(Expression.get(Prop.STROKE_WIDTH)), - lineSortKey(Expression.get(Prop.Z_INDEX)), - ) - } + val strokeLayer: LineLayer + get() = + LineLayer(strokeLayerId, sourceId).apply { + setProperties( + lineJoin(Property.LINE_JOIN_ROUND), + lineCap(Property.LINE_CAP_ROUND), + lineColor(Expression.get(Prop.STROKE_COLOR)), + lineWidth(Expression.get(Prop.STROKE_WIDTH)), + lineSortKey(Expression.get(Prop.Z_INDEX)), + ) + } fun draw( entities: List>, diff --git a/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt index 6315989..b36ed33 100644 --- a/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt @@ -42,7 +42,7 @@ class MapLibreCircleOverlayRenderer( /** Reapply the current composition after a MapLibre style is created or replaced. */ fun redraw() { - val circles = circleManager.allEntities() + val circles = circleManager.nativeEntities().filter { (it.circle as MapLibreActualCircle?) != null } holder.map.style?.let { style -> coroutine.launch { layer.draw(circles, style) } } diff --git a/src/main/java/com/mapconductor/maplibre/groundimage/MapLibreGroundImageOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/groundimage/MapLibreGroundImageOverlayRenderer.kt index 439c175..45ee6a2 100644 --- a/src/main/java/com/mapconductor/maplibre/groundimage/MapLibreGroundImageOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/groundimage/MapLibreGroundImageOverlayRenderer.kt @@ -1,5 +1,6 @@ package com.mapconductor.maplibre.groundimage +import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.features.GeoRectBounds import com.mapconductor.core.groundimage.AbstractGroundImageOverlayRenderer import com.mapconductor.core.groundimage.GroundImageEntityInterface @@ -7,6 +8,7 @@ import com.mapconductor.core.groundimage.GroundImageFingerPrint import com.mapconductor.core.groundimage.GroundImageState import com.mapconductor.maplibre.MapLibreActualGroundImage import com.mapconductor.maplibre.MapLibreMapViewHolderInterface +import com.mapconductor.maplibre.MapLibreOverlayStacking import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngQuad import org.maplibre.android.maps.Style @@ -104,25 +106,20 @@ class MapLibreGroundImageOverlayRenderer( Log.w("MapLibre", "Failed to add ground image source: ${e.message}") } - try { - style.addLayerBelow(layer, BELOW_LAYER_ID) - } catch (_: Exception) { - try { - style.addLayer(layer) - } catch (e: Exception) { - Log.w("MapLibre", "Failed to add ground image layer: ${e.message}") - } - } + // GroundImage の帯(ラスターの上、ポリゴンの下)。送り直しても同じ位置。 + MapLibreOverlayStacking.add( + holder.map, + style, + layer, + MapLibreOverlayStacking.key(OverlayKind.GroundImage), + ) } private fun removeSourceAndLayerIfExists( style: Style, handle: MapLibreGroundImageHandle, ) { - try { - style.removeLayer(handle.layerId) - } catch (_: Exception) { - } + MapLibreOverlayStacking.remove(holder.map, style, handle.layerId) try { style.removeSource(handle.sourceId) } catch (_: Exception) { @@ -185,7 +182,6 @@ class MapLibreGroundImageOverlayRenderer( opacity = opacity, ) - companion object { - private const val BELOW_LAYER_ID = "polyline-layer" - } + // 公開 API(Companion)の互換のために残している。 + companion object } diff --git a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt index 206c8fd..2f16d05 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt @@ -1,318 +1,358 @@ -package com.mapconductor.maplibre.marker - -import com.mapconductor.core.controller.OnCameraChangeReceiverInterface -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.marker.AbstractMarkerController -import com.mapconductor.core.marker.BitmapIcon -import com.mapconductor.core.marker.DefaultMarkerIcon -import com.mapconductor.core.marker.MarkerEntity -import com.mapconductor.core.marker.MarkerEntityInterface -import com.mapconductor.core.marker.MarkerIngestionEngine -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerState -import com.mapconductor.core.marker.MarkerTileRasterLayerCallback -import com.mapconductor.core.marker.MarkerTileRenderer -import com.mapconductor.core.marker.MarkerTilingOptions -import com.mapconductor.core.marker.MarkerViewportSwitch -import com.mapconductor.core.raster.RasterLayerSource -import com.mapconductor.core.raster.RasterLayerState -import com.mapconductor.core.raster.TileScheme -import com.mapconductor.core.tileserver.TileServerRegistry -import com.mapconductor.maplibre.MapLibreActualMarker -import java.util.UUID -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.withPermit - -class MapLibreMarkerController( - renderer: MapLibreMarkerOverlayRenderer, - private val markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, - private val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : AbstractMarkerController( - markerManager = renderer.markerManager, - renderer = renderer, - ), - OnCameraChangeReceiverInterface { - private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() - private val tiledMarkerIds = LinkedHashSet() - - private lateinit var lastCameraPosition: MapCameraPosition - - private val tileServer = TileServerRegistry.get() - private var markerTileRenderer: MarkerTileRenderer? = null - private var markerTileGroupId: String? = null - private var markerTileRasterLayerState: RasterLayerState? = null - private var rasterLayerCallback: MarkerTileRasterLayerCallback? = null - private var cacheVersion: Int = 0 - - /** - * ビューポート内が少ないときだけタイルをやめてネイティブマーカーで描く切り替え器。 - * - * レンダラ/マネージャ/semaphore を共有するので、コントローラと同じ排他の下で動く。 - */ - private val viewportSwitch = - MarkerViewportSwitch( - markerManager = markerManager, - renderer = renderer, - defaultMarkerIcon = defaultMarkerIcon, - semaphore = semaphore, - policy = markerTiling.viewport, - setTileLayerVisible = ::setTileLayerVisible, - invalidateTiles = ::updateRasterLayerSource, - ) - - fun setRasterLayerCallback(callback: MarkerTileRasterLayerCallback?) { - rasterLayerCallback = callback - } - - override suspend fun add(data: List) { - // ingest はタイル担当 entity を marker = null で登録し直すので、先に昇格を戻す。 - // semaphore は再入不可なので withPermit の外で呼ぶこと。 - viewportSwitch.retract() - semaphore.withPermit { - val tilingEnabled = - markerTiling.enabled && data.size >= markerManager.minMarkerCount - val result = - MarkerIngestionEngine.ingest( - data = data, - markerManager = markerManager, - renderer = renderer, - defaultMarkerIcon = defaultMarkerIcon, - tilingEnabled = tilingEnabled, - tiledMarkerIds = tiledMarkerIds, - shouldTile = { state -> !state.draggable && state.getAnimation() == null }, - ) - - if (result.tiledDataChanged) { - syncTiledOverlay() - } else if (result.hasTiledMarkers) { - if (markerTileRenderer == null || markerTileRasterLayerState == null) { - syncTiledOverlay() - } - } else { - removeTileOverlay() - } - } - viewportSwitch.requestReapply() - } - - override suspend fun update(state: MarkerState) { - if (!markerManager.hasEntity(state.id)) return - - // 昇格中の 1 件なら先に取り下げる(下で marker = null 登録があるため)。 - // 昇格していないマーカー(ドラッグ中など)では何もしないので、ドラッグは素通りする。 - if (viewportSwitch.isPromoted(state.id)) viewportSwitch.release(state.id) - - val prevEntity = markerManager.getEntity(state.id) ?: return - val currentFinger = state.fingerPrint() - val prevFinger = prevEntity.fingerPrint - if (currentFinger == prevFinger) return - - semaphore.withPermit { - val tilingEnabled = - markerTiling.enabled && markerManager.allEntities().size >= markerManager.minMarkerCount - val wantsTiled = tilingEnabled && !state.draggable && state.getAnimation() == null - val wasTiled = tiledMarkerIds.contains(state.id) - val markerIcon = state.icon?.toBitmapIcon() ?: defaultMarkerIcon - - if (wantsTiled) { - if (!wasTiled) { - prevEntity.marker?.let { renderer.onRemove(listOf(prevEntity)) } - tiledMarkerIds.add(state.id) - } - markerManager.updateEntity( - MarkerEntity( - marker = null, - state = state, - visible = prevEntity.visible, - isRendered = true, - // tiling を立てないと MarkerTileRenderer の絞り込みから漏れ、 - // タイル昇格したのにタイルへ描かれないマーカーになる。 - tiling = true, - ), - ) - renderer.onPostProcess() - syncTiledOverlay() - return@withPermit - } - - if (wasTiled) { - tiledMarkerIds.remove(state.id) - } - - val params = - object : MarkerOverlayRendererInterface.ChangeParamsInterface { - override val current: MarkerEntityInterface = - MarkerEntity( - marker = prevEntity.marker, - state = state, - visible = prevEntity.visible, - isRendered = true, - ) - override val bitmapIcon: BitmapIcon = markerIcon - override val prev: MarkerEntityInterface = prevEntity - } - val markers = renderer.onChange(listOf(params)) - markers.firstOrNull()?.let { actual -> - markerManager.updateEntity( - MarkerEntity( - marker = actual, - state = state, - visible = prevEntity.visible, - isRendered = true, - ), - ) - if (prevFinger.animation != currentFinger.animation) { - state.getAnimation()?.let { renderer.onAnimate(markerManager.getEntity(state.id)!!) } - } - } - renderer.onPostProcess() - - if (tiledMarkerIds.isNotEmpty()) { - syncTiledOverlay() - } else { - removeTileOverlay() - } - } - viewportSwitch.requestReapply() - } - - override suspend fun clear() { - viewportSwitch.destroy() - semaphore.withPermit { - val entities = markerManager.allEntities() - val toRemove = entities.filter { it.marker != null } - if (toRemove.isNotEmpty()) { - renderer.onRemove(toRemove) - } - markerManager.clear() - tiledMarkerIds.clear() - removeTileOverlay() - } - } - - override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { - lastCameraPosition = mapCameraPosition - // 判定と昇格は debounce したうえで切り替え器の中で走る(パン中は動かない)。 - viewportSwitch.onCameraChanged(mapCameraPosition) - } - - /** - * マーカータイルのラスターレイヤの表示だけを切り替える。 - * - * source(URL)には触らない。触るとタイルを取り直すことになり、切り替えのたびに - * タイルキャッシュを捨てるのと同じになる。 - */ - private suspend fun setTileLayerVisible(visible: Boolean) { - val current = markerTileRasterLayerState ?: return - if (current.visible == visible) return - val newState = current.copy(visible = visible) - markerTileRasterLayerState = newState - rasterLayerCallback?.onRasterLayerUpdate(newState) - } - - override fun destroy() { - viewportSwitch.destroy() - // Clean up tile server registration - // Unregister this map's route only. Never stop the server here: it is - // a process-wide singleton shared by all map controllers and overlay - // modules; stopping it breaks tile loading for every other live map. - markerTileGroupId?.let { groupId -> - tileServer.unregister(groupId) - } - markerTileGroupId = null - markerTileRenderer = null - - coroutine.launch { - rasterLayerCallback?.onRasterLayerUpdate(null) - } - markerTileRasterLayerState = null - super.destroy() - } - - private suspend fun updateRasterLayerSource() { - val groupId = markerTileGroupId ?: return - val tileRenderer = markerTileRenderer ?: return - val oldState = markerTileRasterLayerState ?: return - cacheVersion = (cacheVersion + 1) and 0x7fffffff - tileRenderer.invalidate() - - val newState = - oldState.copy( - source = - RasterLayerSource.UrlTemplate( - template = "${tileServer.urlTemplate(groupId, tileRenderer.tileSize)}?v=$cacheVersion", - tileSize = tileRenderer.tileSize, - maxZoom = 22, - scheme = TileScheme.XYZ, - ), - id = oldState.id, - ) - markerTileRasterLayerState = newState - rasterLayerCallback?.onRasterLayerUpdate(newState) - } - - private suspend fun syncTiledOverlay() { - if (tiledMarkerIds.isEmpty()) { - removeTileOverlay() - return - } - if (!markerTiling.enabled) { - removeTileOverlay() - tiledMarkerIds.clear() - return - } - - getOrCreateTileRenderer() - updateRasterLayerSource() - } - - private fun getOrCreateTileRenderer(): MarkerTileRenderer { - synchronized(this) { - markerTileRenderer?.let { return it } - - val groupId = UUID.randomUUID().toString() - markerTileGroupId = groupId - - val tileRenderer = - MarkerTileRenderer( - markerManager = markerManager, - tileSize = 256, - cacheSizeBytes = markerTiling.cacheSize, - debugTileOverlay = markerTiling.debugTileOverlay, - iconScaleCallback = markerTiling.iconScaleCallback, - ) - markerTileRenderer = tileRenderer - - tileServer.register(groupId, tileRenderer) - - markerTileRasterLayerState = - RasterLayerState( - id = "marker-tile-$groupId", - source = - RasterLayerSource.UrlTemplate( - template = tileServer.urlTemplate(groupId, tileRenderer.tileSize), - tileSize = tileRenderer.tileSize, - maxZoom = 22, - scheme = TileScheme.XYZ, - ), - opacity = 1.0f, - visible = true, - ) - - return tileRenderer - } - } - - private suspend fun removeTileOverlay() { - markerTileGroupId?.let { groupId -> - tileServer.unregister(groupId) - } - markerTileGroupId = null - markerTileRenderer = null - - rasterLayerCallback?.onRasterLayerUpdate(null) - markerTileRasterLayerState = null - } -} +package com.mapconductor.maplibre.marker + +import com.mapconductor.core.controller.OnCameraChangeReceiverInterface +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.marker.AbstractMarkerController +import com.mapconductor.core.marker.BitmapIcon +import com.mapconductor.core.marker.DefaultMarkerIcon +import com.mapconductor.core.marker.MarkerEntity +import com.mapconductor.core.marker.MarkerEntityInterface +import com.mapconductor.core.marker.MarkerIngestionEngine +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.marker.MarkerTileRasterLayerCallback +import com.mapconductor.core.marker.MarkerTileRenderer +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.MarkerViewportSwitch +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.raster.TileScheme +import com.mapconductor.core.tileserver.TileServerRegistry +import com.mapconductor.maplibre.MapLibreActualMarker +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.sync.withPermit + +class MapLibreMarkerController( + renderer: MapLibreMarkerOverlayRenderer, + private val markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, + private val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractMarkerController( + markerManager = renderer.markerManager, + renderer = renderer, + ), + OnCameraChangeReceiverInterface { + private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() + private val tiledMarkerIds = LinkedHashSet() + + private lateinit var lastCameraPosition: MapCameraPosition + + private val tileServer = TileServerRegistry.get() + private var markerTileRenderer: MarkerTileRenderer? = null + private var markerTileGroupId: String? = null + private var markerTileRasterLayerState: RasterLayerState? = null + private var rasterLayerCallback: MarkerTileRasterLayerCallback? = null + private var cacheVersion: Int = 0 + + /** + * ビューポート内が少ないときだけタイルをやめてネイティブマーカーで描く切り替え器。 + * + * レンダラ/マネージャ/semaphore を共有するので、コントローラと同じ排他の下で動く。 + */ + private val viewportSwitch = + MarkerViewportSwitch( + markerManager = markerManager, + renderer = renderer, + defaultMarkerIcon = defaultMarkerIcon, + semaphore = semaphore, + policy = markerTiling.viewport, + setTileLayerVisible = ::setTileLayerVisible, + invalidateTiles = ::updateRasterLayerSource, + ) + + fun setRasterLayerCallback(callback: MarkerTileRasterLayerCallback?) { + rasterLayerCallback = callback + } + + override suspend fun add(data: List) { + // ingest はタイル担当 entity を marker = null で登録し直すので、先に昇格を戻す。 + // semaphore は再入不可なので withPermit の外で呼ぶこと。 + viewportSwitch.retract() + semaphore.withPermit { + val tilingEnabled = + markerTiling.enabled && data.size >= markerManager.minMarkerCount + val result = + MarkerIngestionEngine.ingest( + data = data, + markerManager = markerManager, + renderer = renderer, + defaultMarkerIcon = defaultMarkerIcon, + tilingEnabled = tilingEnabled, + tiledMarkerIds = tiledMarkerIds, + shouldTile = { state -> !state.draggable && state.getAnimation() == null }, + ) + + if (result.tiledDataChanged) { + syncTiledOverlay() + } else if (result.hasTiledMarkers) { + if (markerTileRenderer == null || markerTileRasterLayerState == null) { + syncTiledOverlay() + } + } else { + removeTileOverlay() + } + } + viewportSwitch.requestReapply() + } + + override suspend fun update(state: MarkerState) { + if (!markerManager.hasEntity(state.id)) return + + // 昇格中の 1 件なら先に取り下げる(下で marker = null 登録があるため)。 + // 昇格していないマーカー(ドラッグ中など)では何もしないので、ドラッグは素通りする。 + if (viewportSwitch.isPromoted(state.id)) viewportSwitch.release(state.id) + + val prevEntity = markerManager.getEntity(state.id) ?: return + val currentFinger = state.fingerPrint() + val prevFinger = prevEntity.fingerPrint + if (currentFinger == prevFinger) return + + semaphore.withPermit { + // 件数だけが要る。allEntities() は全件の写しを作るので、ラベルの付け替えのように + // 数十本が続けて変わると、そのたびに 14 万件を写すことになる。 + val tilingEnabled = + markerTiling.enabled && markerManager.getMemoryStats().entityCount >= markerManager.minMarkerCount + val wantsTiled = tilingEnabled && !state.draggable && state.getAnimation() == null + val wasTiled = tiledMarkerIds.contains(state.id) + + if (wantsTiled) { + if (!wasTiled) { + prevEntity.marker?.let { renderer.onRemove(listOf(prevEntity)) } + tiledMarkerIds.add(state.id) + } + markerManager.updateEntity( + MarkerEntity( + marker = null, + state = state, + visible = prevEntity.visible, + isRendered = true, + // tiling を立てないと MarkerTileRenderer の絞り込みから漏れ、 + // タイル昇格したのにタイルへ描かれないマーカーになる。 + tiling = true, + ), + ) + // タイル担当のままなら、ネイティブのマーカーには何も起きていない。onPostProcess は + // 全件を写してネイティブ分を描き直すので(14 万件で 1 回 50ms)、ネイティブから + // タイルへ移ったときだけ呼ぶ。 + if (!wasTiled) renderer.onPostProcess() + scheduleTileRefresh() + return@withPermit + } + + if (wasTiled) { + tiledMarkerIds.remove(state.id) + } + // ネイティブマーカーとして描くときだけ焼き込む。タイル担当なら要らない。 + val markerIcon = state.icon?.toBitmapIcon() ?: defaultMarkerIcon + + val params = + object : MarkerOverlayRendererInterface.ChangeParamsInterface { + override val current: MarkerEntityInterface = + MarkerEntity( + marker = prevEntity.marker, + state = state, + visible = prevEntity.visible, + isRendered = true, + ) + override val bitmapIcon: BitmapIcon = markerIcon + override val prev: MarkerEntityInterface = prevEntity + } + val markers = renderer.onChange(listOf(params)) + markers.firstOrNull()?.let { actual -> + markerManager.updateEntity( + MarkerEntity( + marker = actual, + state = state, + visible = prevEntity.visible, + isRendered = true, + ), + ) + if (prevFinger.animation != currentFinger.animation) { + state.getAnimation()?.let { renderer.onAnimate(markerManager.getEntity(state.id)!!) } + } + } + renderer.onPostProcess() + + if (tiledMarkerIds.isNotEmpty()) { + scheduleTileRefresh() + } else { + removeTileOverlay() + } + } + viewportSwitch.requestReapply() + } + + private var tileRefreshJob: Job? = null + + /** + * 1 本ずつの変更からの描き直しを 1 回にまとめる。 + * + * ラベルの付け替えのように数十本が続けて変わると、1 本ごとにラスターソースを差し替え、 + * そのたびに地図が見えているタイルを全部取り直していた(Pixel 5a で 45 回、 + * 描き直したタイル 494 枚、最後のタイルまで 6 秒)。続けて来た変更は + * [TILE_REFRESH_COALESCE] の間に受けて、まとめて 1 回だけ差し替える。 + */ + private fun scheduleTileRefresh() { + if (tileRefreshJob?.isActive == true) return + tileRefreshJob = + coroutine.launch { + delay(TILE_REFRESH_COALESCE) + semaphore.withPermit { syncTiledOverlay() } + } + } + + private companion object { + /** 続けて来る変更を 1 回の描き直しにまとめる窓。1 フレーム分。 */ + val TILE_REFRESH_COALESCE = 16.milliseconds + } + + override suspend fun clear() { + viewportSwitch.destroy() + semaphore.withPermit { + val entities = markerManager.allEntities() + val toRemove = entities.filter { it.marker != null } + if (toRemove.isNotEmpty()) { + renderer.onRemove(toRemove) + } + markerManager.clear() + tiledMarkerIds.clear() + removeTileOverlay() + // onRemove はアイコンの参照数を減らすだけで、地図のソースは onPostProcess が + // manager の中身から描き直すまで変わらない。空にした後で呼ばないと、 + // clearOverlays() の後もマーカーが地図に残る。 + renderer.onPostProcess() + } + } + + override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { + lastCameraPosition = mapCameraPosition + // 判定と昇格は debounce したうえで切り替え器の中で走る(パン中は動かない)。 + viewportSwitch.onCameraChanged(mapCameraPosition) + } + + /** + * マーカータイルのラスターレイヤの表示だけを切り替える。 + * + * source(URL)には触らない。触るとタイルを取り直すことになり、切り替えのたびに + * タイルキャッシュを捨てるのと同じになる。 + */ + private suspend fun setTileLayerVisible(visible: Boolean) { + val current = markerTileRasterLayerState ?: return + if (current.visible == visible) return + val newState = current.copy(visible = visible) + markerTileRasterLayerState = newState + rasterLayerCallback?.onRasterLayerUpdate(newState) + } + + override fun destroy() { + viewportSwitch.destroy() + // 待っている描き直しが後から走ると、外したタイル経路をまた登録してしまう。 + tileRefreshJob?.cancel() + // Clean up tile server registration + // Unregister this map's route only. Never stop the server here: it is + // a process-wide singleton shared by all map controllers and overlay + // modules; stopping it breaks tile loading for every other live map. + markerTileGroupId?.let { groupId -> + tileServer.unregister(groupId) + } + markerTileGroupId = null + markerTileRenderer = null + + coroutine.launch { + rasterLayerCallback?.onRasterLayerUpdate(null) + } + markerTileRasterLayerState = null + super.destroy() + } + + private suspend fun updateRasterLayerSource() { + val groupId = markerTileGroupId ?: return + val tileRenderer = markerTileRenderer ?: return + val oldState = markerTileRasterLayerState ?: return + cacheVersion = (cacheVersion + 1) and 0x7fffffff + tileRenderer.invalidate() + + val newState = + oldState.copy( + source = + RasterLayerSource.UrlTemplate( + template = "${tileServer.urlTemplate(groupId, tileRenderer.tileSize)}?v=$cacheVersion", + tileSize = tileRenderer.tileSize, + maxZoom = 22, + scheme = TileScheme.XYZ, + ), + id = oldState.id, + ) + markerTileRasterLayerState = newState + rasterLayerCallback?.onRasterLayerUpdate(newState) + } + + private suspend fun syncTiledOverlay() { + if (tiledMarkerIds.isEmpty()) { + removeTileOverlay() + return + } + if (!markerTiling.enabled) { + removeTileOverlay() + tiledMarkerIds.clear() + return + } + + getOrCreateTileRenderer() + updateRasterLayerSource() + } + + private fun getOrCreateTileRenderer(): MarkerTileRenderer { + synchronized(this) { + markerTileRenderer?.let { return it } + + val groupId = UUID.randomUUID().toString() + markerTileGroupId = groupId + + val tileRenderer = + MarkerTileRenderer( + markerManager = markerManager, + tileSize = 256, + cacheSizeBytes = markerTiling.cacheSize, + debugTileOverlay = markerTiling.debugTileOverlay, + iconScaleCallback = markerTiling.iconScaleCallback, + declutterPx = markerTiling.declutterPx, + ) + markerTileRenderer = tileRenderer + + tileServer.register(groupId, tileRenderer) + + markerTileRasterLayerState = + RasterLayerState( + id = "marker-tile-$groupId", + source = + RasterLayerSource.UrlTemplate( + template = tileServer.urlTemplate(groupId, tileRenderer.tileSize), + tileSize = tileRenderer.tileSize, + maxZoom = 22, + scheme = TileScheme.XYZ, + ), + opacity = 1.0f, + visible = true, + ) + + return tileRenderer + } + } + + private suspend fun removeTileOverlay() { + markerTileGroupId?.let { groupId -> + tileServer.unregister(groupId) + } + markerTileGroupId = null + markerTileRenderer = null + + rasterLayerCallback?.onRasterLayerUpdate(null) + markerTileRasterLayerState = null + } +} diff --git a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt index 275802b..9367222 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt @@ -1,437 +1,417 @@ -package com.mapconductor.maplibre.marker - -import com.google.gson.JsonArray -import com.google.gson.JsonObject -import com.mapconductor.core.ResourceProvider -import com.mapconductor.core.calculateZIndex -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.marker.AbstractMarkerOverlayRenderer -import com.mapconductor.core.marker.BitmapIcon -import com.mapconductor.core.marker.DefaultMarkerIcon -import com.mapconductor.core.marker.MarkerEntityInterface -import com.mapconductor.core.marker.MarkerIconInterface -import com.mapconductor.core.marker.MarkerManager -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.maplibre.MapLibreActualMarker -import com.mapconductor.maplibre.MapLibreMapViewHolderInterface -import com.mapconductor.maplibre.toPoint -import org.maplibre.geojson.Feature -import org.maplibre.geojson.FeatureCollection -import android.graphics.Bitmap -import android.os.SystemClock -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield - -class MapLibreMarkerOverlayRenderer( - holder: MapLibreMapViewHolderInterface, - val markerManager: MarkerManager, - val markerLayer: MarkerLayer, - val dragLayer: MarkerDragLayer, - coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : AbstractMarkerOverlayRenderer( - holder = holder, - coroutine = coroutine, - ) { - override val supportsAnimationOverlay: Boolean = true - - private val iconRefCounter: MutableMap = mutableMapOf() - private val pendingStyleImageRemovals: MutableMap = mutableMapOf() - private val iconBitmapCache: MutableMap = mutableMapOf() - private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() - - object Prop { - const val ICON_ID = "icon_id" - const val DEFAULT_MARKER_ID = "default" - const val SCALE = "scale" - const val ICON_ANCHOR = "icon-offset" - const val Z_INDEX = "zIndex" - } - - object IconAnchor { - const val CENTER = "center" - const val LEFT = "left" - const val RIGHT = "right" - const val BOTTOM = "bottom" - const val TOP_LEFT = "top-left" - const val TOP_RIGHT = "top-right" - const val BOTTOM_LEFT = "bottom-left" - const val BOTTOM_RIGHT = "bottom-right" - } - - object IconTranslateAnchor { - const val MAP = "map" - const val VIEWPORT = "viewport" - } - - init { - val style = holder.map.style - if (style != null) { - style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) - } else { - holder.map.getStyle { style -> - style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) - } - } - } - - // Ensure marker images exist on the given style (used after style reload). - fun ensureDefaultIcon(style: org.maplibre.android.maps.Style) { - ensureStyleImages(style) - } - - fun ensureStyleImages(style: org.maplibre.android.maps.Style) { - val startedAt = SystemClock.elapsedRealtime() - try { - style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) - } catch (_: Exception) { - } - - val entities = markerManager.allEntities() - val styleIcons = linkedMapOf() - var nativeMarkerCount = 0 - entities.forEach { entity -> - // Tiled markers have no MapLibre Feature and are drawn by MarkerTileRenderer. - // Registering their images with the style is both unnecessary and extremely - // expensive for large data sets (the same bitmap may otherwise be added tens of - // thousands of times during a style reload). - if (entity.marker == null) return@forEach - nativeMarkerCount++ - val icon = entity.state.icon ?: return@forEach - val iconKey = icon.hashCode().toString() - if (!styleIcons.containsKey(iconKey)) { - styleIcons[iconKey] = iconBitmapCache[iconKey] ?: icon.toBitmapIcon().bitmap - } - } - - styleIcons.forEach { (iconKey, bitmap) -> - try { - style.addImage(iconKey, bitmap, false) - } catch (_: Exception) { - } - iconBitmapCache[iconKey] = bitmap - } - } - - private fun decrementIconRef(iconKey: String) { - if (iconKey == Prop.DEFAULT_MARKER_ID) return - val next = (iconRefCounter[iconKey] ?: 0) - 1 - if (next <= 0) { - iconRefCounter.remove(iconKey) - // MapLibre can render one frame behind GeoJSON source updates during marker animation. - // Keep images briefly after last use so icon-image references never point at a missing image. - pendingStyleImageRemovals[iconKey] = System.currentTimeMillis() + STYLE_IMAGE_REMOVAL_GRACE_MS - } else { - iconRefCounter[iconKey] = next - } - } - - private fun incrementIconRef(iconKey: String) { - if (iconKey == Prop.DEFAULT_MARKER_ID) return - pendingStyleImageRemovals.remove(iconKey) - iconRefCounter[iconKey] = (iconRefCounter[iconKey] ?: 0) + 1 - } - - override fun setMarkerVisible( - markerEntity: MarkerEntityInterface, - visible: Boolean, - ) { - markerEntity.visible = visible - redraw() - } - - override fun setMarkerPosition( - markerEntity: MarkerEntityInterface, - position: GeoPoint, - ) { - val entities = markerManager.allEntities() - val props = (markerEntity.marker?.properties() ?: JsonObject()).deepCopy() - val previousZIndex = - props - .get(Prop.Z_INDEX) - ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } - ?.asInt - props.addProperty( - Prop.Z_INDEX, - markerEntity.state.zIndex - ?: previousZIndex - ?: calculateZIndex(position), - ) - val feature = - Feature.fromGeometry( - position.toPoint(), - props, - "marker-${markerEntity.state.id}", - ) - markerEntity.marker = feature - val features = - entities.map { - if (it.state.id == markerEntity.state.id) { - feature - } else { - it.marker - } - } - // Execute directly instead of launching a new coroutine - holder.map.style?.let { style -> - val styleSource = style.getSourceAs(markerLayer.sourceId) - styleSource?.setGeoJson(FeatureCollection.fromFeatures(features)) - } - } - - override suspend fun onAdd( - data: List, - ): List = - withContext(Dispatchers.Main) { - val style = holder.map.style ?: return@withContext emptyList() - - data.forEach { - it.state.icon?.let { icon -> - val iconKey = icon.hashCode().toString() - try { - style.addImage(iconKey, it.bitmapIcon.bitmap, false) - } catch (_: Exception) { - } - iconBitmapCache[iconKey] = it.bitmapIcon.bitmap - if (!iconRefCounter.contains(iconKey)) iconRefCounter[iconKey] = 0 - } - } - - data.map { - val featureId = "marker-${it.state.id}" - val position = GeoPoint.from(it.state.position).toPoint() - val properties = - JsonObject().apply { - if (it.state.icon != null) { - it.state.icon?.let { icon -> - val iconKey = icon.hashCode().toString() - incrementIconRef(iconKey) - addProperty(Prop.ICON_ID, iconKey) - // icon offset property - add(Prop.ICON_ANCHOR, createIconOffset(icon)) - } - } else { - addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) - add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) - } - // We don't use the MapLibre SDK's scaling system - // addProperty(Prop.SCALE, 1.0) - addProperty(Prop.Z_INDEX, it.state.zIndex ?: calculateZIndex(it.state.position)) - } - Feature.fromGeometry(position, properties, featureId) - } - } - - private fun getDefaultIconOffsetProperty(): JsonArray = createIconOffset(defaultMarkerIcon) - - private fun createIconOffset(icon: BitmapIcon): JsonArray = - JsonArray().apply { - add(-(icon.size.width * icon.anchor.x) / ResourceProvider.getDensity()) - add(-(icon.size.height * icon.anchor.y) / ResourceProvider.getDensity()) - } - - private fun createIconOffset(icon: MarkerIconInterface): JsonArray = createIconOffset(icon.toBitmapIcon()) - - override suspend fun onRemove(data: List>) { - data.forEach { entity -> - val iconKey = - entity.marker - ?.properties() - ?.get(Prop.ICON_ID) - ?.asString - ?: entity.state.icon - ?.hashCode() - ?.toString() - if (iconKey != null) { - decrementIconRef(iconKey) - } - } - } - - fun drawDragLayer() { - holder.map.style?.let { - coroutine.launch { - dragLayer.draw(it) - } - } - } - - fun redraw() { - val entities = markerManager.allEntities() - // Get style from controller to use the same instance - holder.map.style?.let { - coroutine.launch { - markerLayer.draw(entities, it) - } - } - } - - override suspend fun onPostProcess() { - val target = markerManager.allEntities().filter { !it.tiling } - - // withContext(Dispatchers.Main) below is a real cross-thread hop when onPostProcess() - // is invoked from a background ingest dispatcher (as the React Native wrapper does for - // large marker batches) rather than already running on Main (as the native Compose - // sample does). That hop, immediately following a large tiled-marker ingest, lines up - // with severe GC stalls on-device even though the Main-side work below is a few ms at - // most - so skip the hop entirely when there's nothing for it to do. - if (markerLayer.wouldSkipDraw(target) && pendingStyleImageRemovals.isEmpty()) { - return - } - - return withContext(Dispatchers.Main) { - val style = holder.map.style ?: return@withContext - markerLayer.draw(target, style) - yield() - - val now = System.currentTimeMillis() - val expired = - pendingStyleImageRemovals - .asSequence() - .filter { (_, deadline) -> deadline <= now } - .map { (key, _) -> key } - .toList() - - expired.forEach { iconKey -> - if (iconRefCounter.containsKey(iconKey)) { - pendingStyleImageRemovals.remove(iconKey) - return@forEach - } - try { - style.removeImage(iconKey) - } catch (_: Exception) { - } finally { - pendingStyleImageRemovals.remove(iconKey) - iconBitmapCache.remove(iconKey) - } - } - } - } - - override suspend fun onChange( - data: List>, - ): List = - withContext(Dispatchers.Main) { - val style = holder.map.style ?: return@withContext emptyList() - - data.map { params -> - val prevFinger = params.prev.fingerPrint - val currFinger = params.current.fingerPrint - val prevProperties = params.prev.marker?.properties() - - val properties = - JsonObject().apply { - // No additional scaling needed - bitmap is created with device density - // and Bitmap.density is set to prevent MapLibre's automatic scaling - // addProperty(Prop.SCALE, 1.0) - if (currFinger.icon == prevFinger.icon) { - addProperty( - Prop.ICON_ID, - prevProperties?.get(Prop.ICON_ID)?.asString ?: Prop.DEFAULT_MARKER_ID, - ) - - add( - Prop.ICON_ANCHOR, - prevProperties?.get(Prop.ICON_ANCHOR) ?: getDefaultIconOffsetProperty(), - ) - } else { - val prevIconKey = - prevProperties - ?.get(Prop.ICON_ID) - ?.asString - ?: params.prev.state.icon - ?.hashCode() - ?.toString() - ?: Prop.DEFAULT_MARKER_ID - decrementIconRef(prevIconKey) - - if (currFinger.icon == null) { - addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) - add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) - } else { - params.current.state.icon?.let { icon -> - // icon id - val iconKey = icon.hashCode().toString() - style.addImage(iconKey, params.bitmapIcon.bitmap, false) - iconBitmapCache[iconKey] = params.bitmapIcon.bitmap - if (!iconRefCounter.contains(iconKey)) iconRefCounter[iconKey] = 0 - incrementIconRef(iconKey) - addProperty(Prop.ICON_ID, iconKey) - add(Prop.ICON_ANCHOR, createIconOffset(icon)) - } - } - } - addProperty( - Prop.Z_INDEX, - resolveZIndexForChange( - current = params.current, - prev = params.prev, - prevProperties = prevProperties, - ), - ) - } - - val position = - GeoPoint.from(params.current.state.position).toPoint() - val featureId = "marker-${params.current.state.id}" - Feature.fromGeometry(position, properties, featureId) - } - } - - private fun resolveZIndexForChange( - current: MarkerEntityInterface, - prev: MarkerEntityInterface, - prevProperties: JsonObject?, - ): Int { - current.state.zIndex?.let { return it } - - if (current.fingerPrint.zIndex == prev.fingerPrint.zIndex) { - prevProperties - ?.get(Prop.Z_INDEX) - ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } - ?.asInt - ?.let { return it } - } - - return calculateZIndex(current.state.position) - } - - /** - * ドラッグ対象の入れ替え。 - * - * ドラッグ中のマーカーは通常のレイヤ([markerLayer])から外して専用の - * [dragLayer] へ移す。そうしないと GeoJSON ソースを丸ごと描き直すたびに - * 指の位置と食い違う。以前は各プロバイダのマーカーコントローラと - * ストラテジ用イベントコントローラに同じものが 2 本ずつあった。 - */ - override fun onDragSelectionChanged( - previous: MarkerEntityInterface?, - current: MarkerEntityInterface?, - ) { - if (current == null) { - previous?.let { - dragLayer.updatePosition(GeoPoint.from(it.state.position)) - dragLayer.selected = null - drawDragLayer() - markerManager.registerEntity(it) - redraw() - } - return - } - markerManager.removeEntity(current.state.id) - dragLayer.selected = current - dragLayer.updatePosition(GeoPoint.from(current.state.position)) - redraw() - drawDragLayer() - } - - override fun onDragPositionChanged(position: GeoPointInterface) { - dragLayer.updatePosition(GeoPoint.from(position)) - drawDragLayer() - } -} - -private const val STYLE_IMAGE_REMOVAL_GRACE_MS: Long = 1500L +package com.mapconductor.maplibre.marker + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.marker.AbstractMarkerOverlayRenderer +import com.mapconductor.core.marker.BitmapIcon +import com.mapconductor.core.marker.DefaultMarkerIcon +import com.mapconductor.core.marker.MarkerDrawOrder +import com.mapconductor.core.marker.MarkerEntityInterface +import com.mapconductor.core.marker.MarkerIconInterface +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.maplibre.MapLibreActualMarker +import com.mapconductor.maplibre.MapLibreMapViewHolderInterface +import com.mapconductor.maplibre.toPoint +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import android.graphics.Bitmap +import android.os.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield + +class MapLibreMarkerOverlayRenderer( + holder: MapLibreMapViewHolderInterface, + val markerManager: MarkerManager, + val markerLayer: MarkerLayer, + val dragLayer: MarkerDragLayer, + coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractMarkerOverlayRenderer( + holder = holder, + coroutine = coroutine, + ) { + override val supportsAnimationOverlay: Boolean = true + + private val iconRefCounter: MutableMap = mutableMapOf() + private val pendingStyleImageRemovals: MutableMap = mutableMapOf() + private val iconBitmapCache: MutableMap = mutableMapOf() + private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() + + object Prop { + const val ICON_ID = "icon_id" + const val DEFAULT_MARKER_ID = "default" + const val SCALE = "scale" + const val ICON_ANCHOR = "icon-offset" + const val Z_INDEX = "zIndex" + } + + object IconAnchor { + const val CENTER = "center" + const val LEFT = "left" + const val RIGHT = "right" + const val BOTTOM = "bottom" + const val TOP_LEFT = "top-left" + const val TOP_RIGHT = "top-right" + const val BOTTOM_LEFT = "bottom-left" + const val BOTTOM_RIGHT = "bottom-right" + } + + object IconTranslateAnchor { + const val MAP = "map" + const val VIEWPORT = "viewport" + } + + init { + val style = holder.map.style + if (style != null) { + style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) + } else { + holder.map.getStyle { style -> + style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) + } + } + } + + // Ensure marker images exist on the given style (used after style reload). + fun ensureDefaultIcon(style: org.maplibre.android.maps.Style) { + ensureStyleImages(style) + } + + fun ensureStyleImages(style: org.maplibre.android.maps.Style) { + val startedAt = SystemClock.elapsedRealtime() + try { + style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) + } catch (_: Exception) { + } + + val entities = markerManager.allEntities() + val styleIcons = linkedMapOf() + var nativeMarkerCount = 0 + entities.forEach { entity -> + // Tiled markers have no MapLibre Feature and are drawn by MarkerTileRenderer. + // Registering their images with the style is both unnecessary and extremely + // expensive for large data sets (the same bitmap may otherwise be added tens of + // thousands of times during a style reload). + if (entity.marker == null) return@forEach + nativeMarkerCount++ + val icon = entity.state.icon ?: return@forEach + val iconKey = icon.hashCode().toString() + if (!styleIcons.containsKey(iconKey)) { + styleIcons[iconKey] = iconBitmapCache[iconKey] ?: icon.toBitmapIcon().bitmap + } + } + + styleIcons.forEach { (iconKey, bitmap) -> + try { + style.addImage(iconKey, bitmap, false) + } catch (_: Exception) { + } + iconBitmapCache[iconKey] = bitmap + } + } + + private fun decrementIconRef(iconKey: String) { + if (iconKey == Prop.DEFAULT_MARKER_ID) return + val next = (iconRefCounter[iconKey] ?: 0) - 1 + if (next <= 0) { + iconRefCounter.remove(iconKey) + // MapLibre can render one frame behind GeoJSON source updates during marker animation. + // Keep images briefly after last use so icon-image references never point at a missing image. + pendingStyleImageRemovals[iconKey] = System.currentTimeMillis() + STYLE_IMAGE_REMOVAL_GRACE_MS + } else { + iconRefCounter[iconKey] = next + } + } + + private fun incrementIconRef(iconKey: String) { + if (iconKey == Prop.DEFAULT_MARKER_ID) return + pendingStyleImageRemovals.remove(iconKey) + iconRefCounter[iconKey] = (iconRefCounter[iconKey] ?: 0) + 1 + } + + override fun setMarkerVisible( + markerEntity: MarkerEntityInterface, + visible: Boolean, + ) { + markerEntity.visible = visible + redraw() + } + + override fun setMarkerPosition( + markerEntity: MarkerEntityInterface, + position: GeoPoint, + ) { + val entities = markerManager.allEntities() + val props = (markerEntity.marker?.properties() ?: JsonObject()).deepCopy() + props.addProperty(Prop.Z_INDEX, MarkerDrawOrder.of(markerEntity.state.zIndex, position, drawOrderFrame)) + val feature = + Feature.fromGeometry( + position.toPoint(), + props, + "marker-${markerEntity.state.id}", + ) + markerEntity.marker = feature + val features = + entities.map { + if (it.state.id == markerEntity.state.id) { + feature + } else { + it.marker + } + } + // Execute directly instead of launching a new coroutine + holder.map.style?.let { style -> + val styleSource = style.getSourceAs(markerLayer.sourceId) + styleSource?.setGeoJson(FeatureCollection.fromFeatures(features)) + } + } + + override suspend fun onAdd( + data: List, + ): List = + withContext(Dispatchers.Main) { + val style = holder.map.style ?: return@withContext emptyList() + + data.forEach { + it.state.icon?.let { icon -> + val iconKey = icon.hashCode().toString() + try { + style.addImage(iconKey, it.bitmapIcon.bitmap, false) + } catch (_: Exception) { + } + iconBitmapCache[iconKey] = it.bitmapIcon.bitmap + if (!iconRefCounter.contains(iconKey)) iconRefCounter[iconKey] = 0 + } + } + + data.map { + val featureId = "marker-${it.state.id}" + val position = GeoPoint.from(it.state.position).toPoint() + val properties = + JsonObject().apply { + if (it.state.icon != null) { + it.state.icon?.let { icon -> + val iconKey = icon.hashCode().toString() + incrementIconRef(iconKey) + addProperty(Prop.ICON_ID, iconKey) + // icon offset property + add(Prop.ICON_ANCHOR, createIconOffset(icon)) + } + } else { + addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) + add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) + } + // We don't use the MapLibre SDK's scaling system + // addProperty(Prop.SCALE, 1.0) + addProperty(Prop.Z_INDEX, drawOrderOf(it.state)) + } + Feature.fromGeometry(position, properties, featureId) + } + } + + private fun getDefaultIconOffsetProperty(): JsonArray = createIconOffset(defaultMarkerIcon) + + private fun createIconOffset(icon: BitmapIcon): JsonArray = + JsonArray().apply { + add(-(icon.size.width * icon.anchor.x) / ResourceProvider.getDensity()) + add(-(icon.size.height * icon.anchor.y) / ResourceProvider.getDensity()) + } + + private fun createIconOffset(icon: MarkerIconInterface): JsonArray = createIconOffset(icon.toBitmapIcon()) + + override suspend fun onRemove(data: List>) { + data.forEach { entity -> + val iconKey = + entity.marker + ?.properties() + ?.get(Prop.ICON_ID) + ?.asString + ?: entity.state.icon + ?.hashCode() + ?.toString() + if (iconKey != null) { + decrementIconRef(iconKey) + } + } + } + + fun drawDragLayer() { + holder.map.style?.let { + coroutine.launch { + dragLayer.draw(it) + } + } + } + + fun redraw() { + val entities = markerManager.allEntities() + // Get style from controller to use the same instance + holder.map.style?.let { + coroutine.launch { + markerLayer.draw(entities, it) + } + } + } + + // 地図が回ったら、各フィーチャーの並び順キー(symbol-sort-key)を付け直して流し直す。 + override suspend fun applyDrawOrder(entities: List>) { + val target = entities.filter { !it.tiling } + withContext(Dispatchers.Main) { + target.forEach { entity -> + entity.marker?.properties()?.addProperty(Prop.Z_INDEX, drawOrderOf(entity.state)) + } + val style = holder.map.style ?: return@withContext + markerLayer.draw(target, style) + } + } + + override suspend fun onPostProcess() { + val target = markerManager.allEntities().filter { !it.tiling } + + // withContext(Dispatchers.Main) below is a real cross-thread hop when onPostProcess() + // is invoked from a background ingest dispatcher (as the React Native wrapper does for + // large marker batches) rather than already running on Main (as the native Compose + // sample does). That hop, immediately following a large tiled-marker ingest, lines up + // with severe GC stalls on-device even though the Main-side work below is a few ms at + // most - so skip the hop entirely when there's nothing for it to do. + if (markerLayer.wouldSkipDraw(target) && pendingStyleImageRemovals.isEmpty()) { + return + } + + return withContext(Dispatchers.Main) { + val style = holder.map.style ?: return@withContext + markerLayer.draw(target, style) + yield() + + val now = System.currentTimeMillis() + val expired = + pendingStyleImageRemovals + .asSequence() + .filter { (_, deadline) -> deadline <= now } + .map { (key, _) -> key } + .toList() + + expired.forEach { iconKey -> + if (iconRefCounter.containsKey(iconKey)) { + pendingStyleImageRemovals.remove(iconKey) + return@forEach + } + try { + style.removeImage(iconKey) + } catch (_: Exception) { + } finally { + pendingStyleImageRemovals.remove(iconKey) + iconBitmapCache.remove(iconKey) + } + } + } + } + + override suspend fun onChange( + data: List>, + ): List = + withContext(Dispatchers.Main) { + val style = holder.map.style ?: return@withContext emptyList() + + data.map { params -> + val prevFinger = params.prev.fingerPrint + val currFinger = params.current.fingerPrint + val prevProperties = params.prev.marker?.properties() + + val properties = + JsonObject().apply { + // No additional scaling needed - bitmap is created with device density + // and Bitmap.density is set to prevent MapLibre's automatic scaling + // addProperty(Prop.SCALE, 1.0) + if (currFinger.icon == prevFinger.icon) { + addProperty( + Prop.ICON_ID, + prevProperties?.get(Prop.ICON_ID)?.asString ?: Prop.DEFAULT_MARKER_ID, + ) + + add( + Prop.ICON_ANCHOR, + prevProperties?.get(Prop.ICON_ANCHOR) ?: getDefaultIconOffsetProperty(), + ) + } else { + val prevIconKey = + prevProperties + ?.get(Prop.ICON_ID) + ?.asString + ?: params.prev.state.icon + ?.hashCode() + ?.toString() + ?: Prop.DEFAULT_MARKER_ID + decrementIconRef(prevIconKey) + + if (currFinger.icon == null) { + addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) + add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) + } else { + params.current.state.icon?.let { icon -> + // icon id + val iconKey = icon.hashCode().toString() + style.addImage(iconKey, params.bitmapIcon.bitmap, false) + iconBitmapCache[iconKey] = params.bitmapIcon.bitmap + if (!iconRefCounter.contains(iconKey)) iconRefCounter[iconKey] = 0 + incrementIconRef(iconKey) + addProperty(Prop.ICON_ID, iconKey) + add(Prop.ICON_ANCHOR, createIconOffset(icon)) + } + } + } + addProperty( + Prop.Z_INDEX, + drawOrderOf(params.current.state), + ) + } + + val position = + GeoPoint.from(params.current.state.position).toPoint() + val featureId = "marker-${params.current.state.id}" + Feature.fromGeometry(position, properties, featureId) + } + } + + /** + * ドラッグ対象の入れ替え。 + * + * ドラッグ中のマーカーは通常のレイヤ([markerLayer])から外して専用の + * [dragLayer] へ移す。そうしないと GeoJSON ソースを丸ごと描き直すたびに + * 指の位置と食い違う。以前は各プロバイダのマーカーコントローラと + * ストラテジ用イベントコントローラに同じものが 2 本ずつあった。 + */ + override fun onDragSelectionChanged( + previous: MarkerEntityInterface?, + current: MarkerEntityInterface?, + ) { + if (current == null) { + previous?.let { + dragLayer.updatePosition(GeoPoint.from(it.state.position)) + dragLayer.selected = null + drawDragLayer() + markerManager.registerEntity(it) + redraw() + } + return + } + markerManager.removeEntity(current.state.id) + dragLayer.selected = current + dragLayer.updatePosition(GeoPoint.from(current.state.position)) + redraw() + drawDragLayer() + } + + override fun onDragPositionChanged(position: GeoPointInterface) { + dragLayer.updatePosition(GeoPoint.from(position)) + drawDragLayer() + } +} + +private const val STYLE_IMAGE_REMOVAL_GRACE_MS: Long = 1500L diff --git a/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt b/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt index fce53de..f0fc551 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt @@ -19,20 +19,27 @@ open class MarkerLayer( open val sourceId: String, open val layerId: String, ) { - val layer = - SymbolLayer(layerId, sourceId).apply { - setProperties( - iconImage(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ID)), - // iconSize(get(MapLibreMarkerOverlayRenderer.Prop.SCALE)), - iconAllowOverlap(true), - iconIgnorePlacement(true), - PropertyFactory.symbolSortKey(get(MapLibreMarkerOverlayRenderer.Prop.Z_INDEX)), - iconAnchor(MapLibreMarkerOverlayRenderer.IconAnchor.TOP_LEFT), - iconTranslateAnchor(MapLibreMarkerOverlayRenderer.IconTranslateAnchor.MAP), - // Each feature always carries icon-offset in properties; use it directly - iconOffset(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ANCHOR)), - ) - } + // A fresh native layer on every read. MapLibre releases a Layer's core + // object into the style it is added to, and the Java object can never be + // added again ("Cannot add layer twice") -- so the one built for the + // first style was silently refused by every style loaded after it, and + // a design change lost the overlays. Only `setupStyle` reads this, once + // per style load. + val layer: SymbolLayer + get() = + SymbolLayer(layerId, sourceId).apply { + setProperties( + iconImage(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ID)), + // iconSize(get(MapLibreMarkerOverlayRenderer.Prop.SCALE)), + iconAllowOverlap(true), + iconIgnorePlacement(true), + PropertyFactory.symbolSortKey(get(MapLibreMarkerOverlayRenderer.Prop.Z_INDEX)), + iconAnchor(MapLibreMarkerOverlayRenderer.IconAnchor.TOP_LEFT), + iconTranslateAnchor(MapLibreMarkerOverlayRenderer.IconTranslateAnchor.MAP), + // Each feature always carries icon-offset in properties; use it directly + iconOffset(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ANCHOR)), + ) + } val source: GeoJsonSource = GeoJsonSource( diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonCompositeRenderer.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonCompositeRenderer.kt new file mode 100644 index 0000000..f9c4f8b --- /dev/null +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonCompositeRenderer.kt @@ -0,0 +1,78 @@ +package com.mapconductor.maplibre.polygon + +import com.mapconductor.core.polygon.PolygonEntityInterface +import com.mapconductor.core.polygon.PolygonOverlayRendererInterface +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.PolylineEntity +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.maplibre.MapLibreActualPolygon +import com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer + +/** + * 塗りと輪郭を 1 つのレンダラとして [com.mapconductor.core.polygon.PolygonController] に渡すための合成レンダラ。 + * + * fill レイヤーは幅 1 px の輪郭しか描けないので、`strokeWidth` を守るために輪郭は + * 別のポリライン(`outline-`)として [outline] で描く。差分計算はコアの + * PolygonController に任せ、ここは「塗りへの指示を輪郭にも写す」ことだけをする。 + * + * 塗りのエンティティはコントローラと同じ [fill] の polygonManager に載る。輪郭は + * [outline] の polylineManager に載り、コントローラからは見えない。 + */ +class MapLibrePolygonCompositeRenderer( + val fill: MapLibrePolygonOverlayRenderer, + val outline: MapLibrePolylineOverlayRenderer, +) : PolygonOverlayRendererInterface { + override suspend fun onAdd(data: List): List { + val polygons = fill.onAdd(data) + data.forEachIndexed { index, params -> + if (polygons[index] != null) putOutline(params.state) + } + return polygons + } + + override suspend fun onChange( + data: List>, + ): List { + val polygons = fill.onChange(data) + data.forEachIndexed { index, params -> + if (polygons[index] != null) putOutline(params.current.state) + } + return polygons + } + + override suspend fun onRemove(data: List>) { + data.forEach { outline.polylineManager.removeEntity(outlineId(it.state.id)) } + // clear() は onPostProcess を呼ばないので、消した結果はここで描き直す。 + fill.onRemove(data) + outline.onPostProcess() + } + + override suspend fun onPostProcess() { + fill.onPostProcess() + outline.onPostProcess() + } + + private suspend fun putOutline(state: PolygonState) { + val outlineState = state.toOutlineState() ?: return + outline.createPolyline(outlineState)?.let { polyline -> + outline.polylineManager.registerEntity(PolylineEntity(polyline = polyline, state = outlineState)) + } + } +} + +private fun outlineId(polygonId: String): String = "outline-$polygonId" + +/** 外周だけを閉じたポリラインにする(穴の輪郭は描かない)。 */ +private fun PolygonState.toOutlineState(): PolylineState? { + if (points.isEmpty()) return null + val closedPoints = if (points.first() != points.last()) points + points.first() else points + return PolylineState( + points = closedPoints, + id = outlineId(id), + strokeColor = strokeColor, + strokeWidth = strokeWidth, + geodesic = geodesic, + zIndex = zIndex, + extra = extra, + ) +} diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt deleted file mode 100644 index f0150eb..0000000 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.mapconductor.maplibre.polygon - -import com.mapconductor.core.controller.OverlayHit -import com.mapconductor.core.controller.OverlayKind -import com.mapconductor.core.controller.SlottedOverlayController -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.polygon.PolygonEntity -import com.mapconductor.core.polygon.PolygonEntityInterface -import com.mapconductor.core.polygon.PolygonEvent -import com.mapconductor.core.polygon.PolygonState -import com.mapconductor.core.polyline.PolylineEntity -import com.mapconductor.core.polyline.PolylineState -import com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer - -class MapLibrePolygonConductor( - val polygonOverlay: MapLibrePolygonOverlayRenderer, - val polylineOverlay: MapLibrePolylineOverlayRenderer, -) : SlottedOverlayController< - PolygonState, - PolygonEntityInterface, - > { - override val zIndex: Int = 2 - - var clickListener: ((PolygonEvent) -> Unit)? = null - - override suspend fun add(data: List) { - val nextIds = data.asSequence().map { it.id }.toSet() - val prevIds = - polygonOverlay.polygonManager - .allEntities() - .asSequence() - .map { it.state.id } - .toSet() - val removeIds = prevIds - nextIds - - removeIds.forEach { id -> - polygonOverlay.polygonManager.removeEntity(id) - polylineOverlay.polylineManager.removeEntity("outline-$id") - } - - data.forEach { polygonState -> - - polygonOverlay.createPolygon(polygonState)?.let { polygon -> - val polygonEntity = - PolygonEntity( - polygon = polygon, - state = polygonState, - ) - polygonOverlay.polygonManager.registerEntity(polygonEntity) - } - - val polylineState = polygonState.toPolylineState() - polylineOverlay.createPolyline(polylineState)?.let { polyline -> - val polylineEntity = - PolylineEntity( - polyline = polyline, - state = polylineState, - ) - polylineOverlay.polylineManager.registerEntity(polylineEntity) - } - } - polygonOverlay.onPostProcess() - polylineOverlay.onPostProcess() - } - - override suspend fun update(state: PolygonState) { - polygonOverlay.createPolygon(state)?.let { polygon -> - val polygonEntity = - PolygonEntity( - polygon = polygon, - state = state, - ) - polygonOverlay.polygonManager.registerEntity(polygonEntity) - } - - val polylineState = state.toPolylineState() - polylineOverlay.createPolyline(polylineState)?.let { polyline -> - val polylineEntity = - PolylineEntity( - polyline = polyline, - state = polylineState, - ) - polylineOverlay.polylineManager.registerEntity(polylineEntity) - } - polygonOverlay.onPostProcess() - polylineOverlay.onPostProcess() - } - - fun dispatchClick(event: PolygonEvent) { - // 配送座標の wrap は PolygonEvent の生成時に一元化済み。 - event.state.onClick?.invoke(event) - clickListener?.invoke(event) - } - - @Suppress("UNCHECKED_CAST") - override fun find(position: GeoPointInterface): PolygonEntityInterface? = - polygonOverlay.polygonManager.find(position) as? PolygonEntityInterface - - override suspend fun clear() { - polygonOverlay.polygonManager.clear() - polylineOverlay.polylineManager.clear() - polygonOverlay.onPostProcess() - polylineOverlay.onPostProcess() - } - - // ── Capable ファサードのスロットとクリックカスケードへの参加 ────────── - // - // このコンダクタはコアの PolygonController を継承していない(ポリゴンの輪郭を - // ポリラインとして描くために polygon と polyline の 2 レンダラを束ねている)。 - // それでも扱いは他プロバイダのポリゴンと同じでなければならないので、 - // スロットの契約はここで自前で満たす。 - // - // 実際、Step 4 でこれを実装し忘れて `hasPolygon` が常に false になり、 - // ポリゴン単体の状態更新が黙って捨てられていた。 - - override val kind: OverlayKind = OverlayKind.Polygon - - override fun has(id: String): Boolean = polygonOverlay.polygonManager.hasEntity(id) - - @Suppress("UNCHECKED_CAST") - override fun setClickListenerAny(listener: Any?) { - clickListener = listener as? ((PolygonEvent) -> Unit) - } - - override fun resolveTap(position: GeoPointInterface): OverlayHit? = - find(position)?.let { entity -> - OverlayHit(OverlayKind.Polygon, position) { - dispatchClick(PolygonEvent(entity.state, position)) - } - } - - override fun destroy() { - // No native resources to clean up for polygons - } -} - -private fun PolygonState.toPolylineState(): PolylineState { - val closedPoints = - if (points.first() != points.last()) { - points + points.first() - } else { - points - } - return PolylineState( - points = closedPoints, - id = "outline-${this.id}", - strokeColor = this.strokeColor, - strokeWidth = this.strokeWidth, - geodesic = this.geodesic, - zIndex = this.zIndex, - extra = this.extra, - ) -} diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonController.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonController.kt new file mode 100644 index 0000000..73d0771 --- /dev/null +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonController.kt @@ -0,0 +1,24 @@ +package com.mapconductor.maplibre.polygon + +import com.mapconductor.core.polygon.PolygonController +import com.mapconductor.maplibre.MapLibreActualPolygon + +/** + * MapLibre 用のポリゴンコントローラ。 + * + * 追加・更新・削除の差分計算、`has`、クリックカスケードはコアの [PolygonController] が担う。 + * 塗りと輪郭(幅のある LineLayer)の 2 系統の描画は [MapLibrePolygonCompositeRenderer] が束ねる。 + */ +class MapLibrePolygonController( + override val renderer: MapLibrePolygonCompositeRenderer, +) : PolygonController(renderer.fill.polygonManager, renderer) { + // 旧 MapLibrePolygonConductor と同じ値を保つ。 + override val zIndex: Int = 2 + + override fun destroy() { + super.destroy() + // The outlines live in the outline renderer's manager, which core does not + // know about; a destroyed controller that is still referenced keeps none. + renderer.outline.polylineManager.clear() + } +} diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt index 1457327..55c4761 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt @@ -24,12 +24,19 @@ class MapLibrePolygonLayer( FeatureCollection.fromFeatures(emptyList()), ) - val layer: FillLayer = - FillLayer(layerId, sourceId).apply { - setProperties( - fillColor(get(Prop.FILL_COLOR)), - ) - } + // A fresh native layer on every read. MapLibre releases a Layer's core + // object into the style it is added to, and the Java object can never be + // added again ("Cannot add layer twice") -- so the one built for the + // first style was silently refused by every style loaded after it, and + // a design change lost the overlays. Only `setupStyle` reads this, once + // per style load. + val layer: FillLayer + get() = + FillLayer(layerId, sourceId).apply { + setProperties( + fillColor(get(Prop.FILL_COLOR)), + ) + } fun draw( entities: List>, diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt index 9fc668a..143df04 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt @@ -1,82 +1,103 @@ -package com.mapconductor.maplibre.polygon - -import com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer -import com.mapconductor.core.polygon.PolygonEntityInterface -import com.mapconductor.core.polygon.PolygonManagerInterface -import com.mapconductor.core.polygon.PolygonState -import com.mapconductor.core.polygon.unionHoles -import com.mapconductor.maplibre.MapLibreActualPolygon -import com.mapconductor.maplibre.MapLibreMapViewHolderInterface -import com.mapconductor.maplibre.createMapLibrePolygons -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -/** - * MapLibre Polygon Overlay Renderer - * - * 使用 GeoJSON hole polygons(与 React SDK 相同的方式)而不是 tile-based masking - */ -class MapLibrePolygonOverlayRenderer( - val layer: MapLibrePolygonLayer, - val polygonManager: PolygonManagerInterface, - override val holder: MapLibreMapViewHolderInterface, - override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : AbstractPolygonOverlayRenderer() { - override suspend fun onRemove(data: List>) { - coroutine.launch { - holder.map.style?.let { - layer.draw(getAllPolygonEntities(), it) - } - } - } - - override suspend fun onPostProcess() { - val polygons = getAllPolygonEntities() - - holder.map.style?.let { - coroutine.launch { - layer.draw(polygons, it) - } - } - } - - override suspend fun removePolygon(entity: PolygonEntityInterface) { - // 不单独删除,通过 onPostProcess 重绘所有多边形 - } - - override suspend fun createPolygon(state: PolygonState): MapLibreActualPolygon? { - val resolved = if (state.holes.size > 1) state.unionHoles() else state - val features = - createMapLibrePolygons( - id = resolved.id, - points = resolved.points, - holes = resolved.holes, - geodesic = resolved.geodesic, - fillColor = resolved.fillColor, - zIndex = resolved.zIndex, - ) - - if (features.isEmpty()) { - return null - } - return features // MapLibreActualPolygon = List - } - - override suspend fun updatePolygonProperties( - polygon: MapLibreActualPolygon, - current: PolygonEntityInterface, - prev: PolygonEntityInterface, - ): MapLibreActualPolygon? { - val finger = current.fingerPrint - val prevFinger = prev.fingerPrint - - if (finger != prevFinger) { - return createPolygon(current.state) - } - return polygon - } - - private fun getAllPolygonEntities(): List> = - polygonManager.allEntities() -} +package com.mapconductor.maplibre.polygon + +import com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer +import com.mapconductor.core.polygon.PolygonEntityInterface +import com.mapconductor.core.polygon.PolygonManagerInterface +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polygon.unionHoles +import com.mapconductor.maplibre.MapLibreActualPolygon +import com.mapconductor.maplibre.MapLibreMapViewHolderInterface +import com.mapconductor.maplibre.createMapLibrePolygons +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * MapLibre Polygon Overlay Renderer + * + * 使用 GeoJSON hole polygons(与 React SDK 相同的方式)而不是 tile-based masking + */ +class MapLibrePolygonOverlayRenderer( + val layer: MapLibrePolygonLayer, + val polygonManager: PolygonManagerInterface, + override val holder: MapLibreMapViewHolderInterface, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractPolygonOverlayRenderer() { + // 消えた分を除いた残りで描き直す。コントローラはこの呼び出しの後で polygonManager から + // 外すので(clear() では onPostProcess も来ない)、ここで除外しておかないと消えずに残る。 + override suspend fun onRemove(data: List>) { + val polygons = getRemainingPolygonEntities(data) + holder.map.style?.let { + coroutine.launch { + layer.draw(polygons, it) + } + } + } + + /** Draws every polygon into the current style again; for after a style load. */ + fun redraw() { + val polygons = getAllPolygonEntities() + holder.map.style?.let { style -> + coroutine.launch { layer.draw(polygons, style) } + } + } + + override suspend fun onPostProcess() { + val polygons = getAllPolygonEntities() + + holder.map.style?.let { + coroutine.launch { + layer.draw(polygons, it) + } + } + } + + override suspend fun removePolygon(entity: PolygonEntityInterface) { + // 不单独删除,通过 onPostProcess 重绘所有多边形 + } + + override suspend fun createPolygon(state: PolygonState): MapLibreActualPolygon? { + val resolved = if (state.holes.size > 1) state.unionHoles() else state + val features = + createMapLibrePolygons( + id = resolved.id, + points = resolved.points, + holes = resolved.holes, + geodesic = resolved.geodesic, + fillColor = resolved.fillColor, + zIndex = resolved.zIndex, + ) + + if (features.isEmpty()) { + return null + } + return features // MapLibreActualPolygon = List + } + + override suspend fun updatePolygonProperties( + polygon: MapLibreActualPolygon, + current: PolygonEntityInterface, + prev: PolygonEntityInterface, + ): MapLibreActualPolygon? { + val finger = current.fingerPrint + val prevFinger = prev.fingerPrint + + if (finger != prevFinger) { + return createPolygon(current.state) + } + return polygon + } + + // Polygons drawn as tiles (PolygonController.tiler) stay in the manager, for + // taps, with no features: their entity's polygon is null. + @Suppress("USELESS_CAST") + private fun getAllPolygonEntities(): List> = + polygonManager.allEntities().filter { (it.polygon as MapLibreActualPolygon?) != null } + + private fun getRemainingPolygonEntities( + removed: List>, + ): List> { + val removedIds = removed.mapTo(HashSet()) { it.state.id } + return getAllPolygonEntities().filter { it.state.id !in removedIds } + } +} diff --git a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt index 3961e08..2ce28fd 100644 --- a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt @@ -29,15 +29,22 @@ class MapLibrePolylineLayer( FeatureCollection.fromFeatures(emptyList()), ) - val layer: LineLayer = - LineLayer(layerId, sourceId).apply { - setProperties( - lineJoin(Property.LINE_JOIN_ROUND), - lineCap(Property.LINE_CAP_ROUND), - lineColor(get(Prop.STROKE_COLOR)), - lineWidth(get(Prop.STROKE_WIDTH)), - ) - } + // A fresh native layer on every read. MapLibre releases a Layer's core + // object into the style it is added to, and the Java object can never be + // added again ("Cannot add layer twice") -- so the one built for the + // first style was silently refused by every style loaded after it, and + // a design change lost the overlays. Only `setupStyle` reads this, once + // per style load. + val layer: LineLayer + get() = + LineLayer(layerId, sourceId).apply { + setProperties( + lineJoin(Property.LINE_JOIN_ROUND), + lineCap(Property.LINE_CAP_ROUND), + lineColor(get(Prop.STROKE_COLOR)), + lineWidth(get(Prop.STROKE_WIDTH)), + ) + } fun draw( entities: List>, diff --git a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt index 0c576a3..c356138 100644 --- a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt @@ -67,11 +67,11 @@ class MapLibrePolylineOverlayRenderer( private fun getAllPolylineEntities(): List> { // This would need access to the polyline manager // For now, we'll implement a simple workaround - return polylineManager.allEntities() + return polylineManager.allEntities().filter { (it.polyline as MapLibreActualPolyline?) != null } } fun redraw() { - val entities = polylineManager.allEntities() + val entities = polylineManager.allEntities().filter { (it.polyline as MapLibreActualPolyline?) != null } holder.map.style?.let { coroutine.launch { layer.draw(entities, it) diff --git a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerController.kt b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerController.kt index d45e4b4..14dc737 100644 --- a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerController.kt +++ b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerController.kt @@ -1,10 +1,8 @@ package com.mapconductor.maplibre.raster import com.mapconductor.core.raster.RasterLayerController -import com.mapconductor.core.raster.RasterLayerEntity import com.mapconductor.core.raster.RasterLayerManager import com.mapconductor.core.raster.RasterLayerManagerInterface -import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface import com.mapconductor.core.raster.RasterLayerState class MapLibreRasterLayerController( @@ -35,27 +33,4 @@ class MapLibreRasterLayerController( MapLibreRasterHeaderInjector.remove(this) super.destroy() } - - suspend fun reapplyStyle() { - val states = rasterLayerManager.allEntities().map { it.state } - if (states.isEmpty()) return - val addParams = - states.map { state -> - object : RasterLayerOverlayRendererInterface.AddParamsInterface { - override val state: RasterLayerState = state - } - } - val layers = renderer.onAdd(addParams) - layers.forEachIndexed { index, layer -> - layer?.let { - rasterLayerManager.registerEntity( - RasterLayerEntity( - layer = it, - state = states[index], - ), - ) - } - } - renderer.onPostProcess() - } } diff --git a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt index dffde3c..6f16208 100644 --- a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt @@ -1,11 +1,16 @@ package com.mapconductor.maplibre.raster +import com.mapconductor.core.circle.CIRCLE_TILE_LAYER_ID +import com.mapconductor.core.controller.OverlayKind +import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID +import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID import com.mapconductor.core.raster.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface import com.mapconductor.core.raster.RasterLayerSource import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.core.raster.TileScheme import com.mapconductor.maplibre.MapLibreMapViewHolderInterface +import com.mapconductor.maplibre.MapLibreOverlayStacking import org.maplibre.android.style.layers.Property import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.RasterLayer @@ -24,6 +29,12 @@ class MapLibreRasterLayerOverlayRenderer( private fun isMarkerTileRaster(state: RasterLayerState): Boolean = state.id.startsWith(MARKER_TILE_RASTER_ID_PREFIX) + private fun isPolygonTileRaster(state: RasterLayerState): Boolean = state.id == POLYGON_TILE_LAYER_ID + + private fun isPolylineTileRaster(state: RasterLayerState): Boolean = state.id == POLYLINE_TILE_LAYER_ID + + private fun isCircleTileRaster(state: RasterLayerState): Boolean = state.id == CIRCLE_TILE_LAYER_ID + override suspend fun onAdd( data: List, ): List = @@ -93,38 +104,33 @@ class MapLibreRasterLayerOverlayRenderer( } catch (e: Exception) { Log.w("MapLibre", "Failed to add raster source: ${e.message}") } - try { - if (isMarkerTileRaster(state)) { - addLayerForMarkerTile(style, layer) - } else { - style.addLayer(layer) - } - } catch (e: Exception) { - Log.w("MapLibre", "Failed to add raster layer: ${e.message}") - } + addToStack(style, layer, state) return handle } - private fun addLayerForMarkerTile( + /** + * 帯の順([MapLibreOverlayStacking])で足す。アプリのラスターはラスターの帯 + * (一番下、ベースマップのラベルの下)、マーカーのタイルはマーカーの帯の一番下、 + * ポリゴンのタイルはポリゴンの帯の一番下(画像になったポリゴンであって、地図の画像ではない)。 + */ + private fun addToStack( style: org.maplibre.android.maps.Style, layer: RasterLayer, + state: RasterLayerState, ) { - // Insert the raster tiles below the marker symbol layer so they don't cover markers, - // but remain above vector overlays that are anchored below markers (polyline/circle/etc). - try { - style.addLayerBelow(layer, MARKERS_LAYER_ID) - return - } catch (_: Exception) { - } - - // Best-effort fallback: place above polylines if marker layer isn't present yet. - try { - style.addLayerAbove(layer, POLYLINE_LAYER_ID) - return - } catch (_: Exception) { - } - - style.addLayer(layer) + val key = + if (isMarkerTileRaster(state)) { + MapLibreOverlayStacking.MARKER_TILE + } else if (isPolygonTileRaster(state)) { + MapLibreOverlayStacking.POLYGON_TILE + } else if (isCircleTileRaster(state)) { + MapLibreOverlayStacking.CIRCLE_TILE + } else if (isPolylineTileRaster(state)) { + MapLibreOverlayStacking.POLYLINE_TILE + } else { + MapLibreOverlayStacking.key(OverlayKind.RasterLayer, state.zIndex) + } + MapLibreOverlayStacking.add(holder.map, style, layer, key) } private fun updateLayer( @@ -145,10 +151,7 @@ class MapLibreRasterLayerOverlayRenderer( private fun removeLayer(entity: RasterLayerEntityInterface) { val style = holder.map.style ?: return val handle = entity.layer - try { - style.removeLayer(handle.layerId) - } catch (_: Exception) { - } + MapLibreOverlayStacking.remove(holder.map, style, handle.layerId) try { style.removeSource(handle.sourceId) } catch (_: Exception) { @@ -159,16 +162,15 @@ class MapLibreRasterLayerOverlayRenderer( val ordered = stateById.values .asSequence() - .filter { !isMarkerTileRaster(it) } + .filter { !isMarkerTileRaster(it) && !isPolygonTileRaster(it) && !isPolylineTileRaster(it) && !isCircleTileRaster(it) } .sortedBy { it.zIndex } .mapNotNull { state -> handleById[state.id]?.let { handle -> state to handle } } .toList() + // zIndex が変わったときのために、いったん外して zIndex の順に入れ直す + // (同じ zIndex なら渡された順)。位置は帯の順で決まる。 ordered.forEach { (_, handle) -> - try { - style.removeLayer(handle.layerId) - } catch (_: Exception) { - } + MapLibreOverlayStacking.remove(holder.map, style, handle.layerId) } ordered.forEach { (state, handle) -> @@ -180,10 +182,7 @@ class MapLibreRasterLayerOverlayRenderer( if (state.visible) Property.VISIBLE else Property.NONE, ), ) - try { - style.addLayer(layer) - } catch (_: Exception) { - } + addToStack(style, layer, state) } } @@ -214,8 +213,6 @@ class MapLibreRasterLayerOverlayRenderer( private companion object { private const val MARKER_TILE_RASTER_ID_PREFIX = "marker-tile-" - private const val MARKERS_LAYER_ID = "markers-layer" - private const val POLYLINE_LAYER_ID = "polyline-layer" } }