From d243a1786c3e1daf442050026a8faee5cee1274b Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 1 Oct 2026 16:54:00 -0700 Subject: [PATCH 1/3] Support offline basemapless ArcGIS maps --- .../com/mapconductor/arcgis/ArcGISMapView.kt | 31 ++++++++++++++++--- .../mapconductor/arcgis/ArcGISMapView2D.kt | 31 +++++++++++++++++-- .../arcgis/ArcGISMapView2DController.kt | 15 +++++++-- .../arcgis/ArcGISMapViewController.kt | 5 +++ 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt index e0a8c3e..c62569f 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt @@ -13,6 +13,7 @@ import com.arcgismaps.ArcGISEnvironment import com.arcgismaps.LoadStatus import com.arcgismaps.mapping.ArcGISScene import com.arcgismaps.mapping.ArcGISTiledElevationSource +import com.arcgismaps.mapping.Basemap import com.arcgismaps.mapping.view.GraphicsOverlay import com.arcgismaps.mapping.view.GraphicsRenderingMode import com.arcgismaps.mapping.view.SceneView @@ -76,7 +77,9 @@ fun ArcGISMapView( val context = LocalContext.current // Context will be available from MapViewBase too if needed val registry = remember { scope.buildRegistry() } val owner = LocalLifecycleOwner.current - val basemapStyle = remember { ArcGISDesign.toBasemapStyleOrNull(state.mapDesignType) } + // The design the scene was built with; a design set on the state before + // the controller existed is applied once it does. + val createdDesign = remember { Ref() } val cameraState = remember { mutableStateOf(state.cameraPosition) } val controllerRef = remember { Ref() } val controllerGeneration = remember { AtomicLong(0L) } @@ -101,14 +104,20 @@ fun ArcGISMapView( scope = scope, registry = registry, holderProvider = { wrapView -> + val design = state.mapDesignType + createdDesign.value = design.getValue() val options = ArcGISMapViewInitOptions( - basemapStyle = basemapStyle, - elevationSources = state.mapDesignType.elevationSources, + basemapStyle = ArcGISDesign.toBasemapStyleOrNull(design), + elevationSources = design.elevationSources, ) - // No basemap is a scene with only what the app puts on it. - val scene = options.basemapStyle?.let { ArcGISScene(it) } ?: ArcGISScene() + // No basemap is a scene with only what the app puts on it. The + // basemap is set on an empty scene rather than built into it, so + // a scene whose basemap is out of reach is still a scene (a + // global one is always WGS84) that can draw its own layers. + val scene = ArcGISScene() + options.basemapStyle?.let { scene.setBasemap(Basemap(it)) } options.elevationSources.forEach { val source = ArcGISTiledElevationSource(it) @@ -116,6 +125,7 @@ fun ArcGISMapView( } wrapView.sceneView.scene = scene + var retried = false val coroutine = CoroutineScope(Dispatchers.Default) @@ -133,6 +143,16 @@ fun ArcGISMapView( cont.resume(holder) { _, _, _ -> } } is LoadStatus.FailedToLoad -> { + Log.w("ArcGISMapView", "scene failed to load: ${it.error.message}") + // The basemap was taken away while it was being + // fetched (the app went basemap-less, say for an + // offline raster): without one the scene needs no + // network, so it gets one more go. + if (scene.basemap.value == null && !retried) { + retried = true + scene.retryLoad() + return@collect + } // Offline or network error: resume with a holder anyway so // the scene view is displayed (possibly with cached tiles) // without crashing. The ArcGIS view may be blank but usable. @@ -238,6 +258,7 @@ fun ArcGISMapView( mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) cameraRestriction?.let { mapController.setCameraRestriction(it) } state.setController(mapController) + if (state.mapDesignType.getValue() != createdDesign.value) mapController.setMapDesignType(state.mapDesignType) // Set camera listeners immediately so they are ready to receive // camera updates from external sources (e.g. camera sync scenarios). diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt index a7c88b4..0c1f527 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt @@ -9,7 +9,9 @@ import androidx.compose.ui.node.Ref import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import com.arcgismaps.LoadStatus +import com.arcgismaps.geometry.SpatialReference import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.Basemap import com.arcgismaps.mapping.view.GraphicsOverlay import com.arcgismaps.mapping.view.GraphicsRenderingMode import com.arcgismaps.mapping.view.MapView @@ -29,6 +31,7 @@ import com.mapconductor.core.marker.MarkerTilingOptions import com.mapconductor.core.marker.StrategyMarkerController import java.util.concurrent.atomic.AtomicLong import android.content.Context +import android.util.Log import android.widget.FrameLayout import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -55,7 +58,9 @@ fun ArcGISMapView2D( val context = LocalContext.current val registry = remember { scope.buildRegistry() } val owner = LocalLifecycleOwner.current - val basemapStyle = remember { ArcGISDesign.toBasemapStyleOrNull(state.mapDesignType) } + // The design the map was built with; a design set on the state before the + // controller existed is applied once it does. + val createdDesign = remember { Ref() } val cameraState = remember { mutableStateOf(state.cameraPosition) } val controllerRef = remember { Ref() } val controllerGeneration = remember { AtomicLong(0L) } @@ -78,8 +83,19 @@ fun ArcGISMapView2D( scope = scope, registry = registry, holderProvider = { wrapView -> - val map = basemapStyle?.let { ArcGISMap(it) } ?: ArcGISMap() + // The map is told its spatial reference up front instead of + // learning it from the basemap: a basemap style lives on + // arcgis.com, and a map that waited for it to find out where it + // is can draw nothing while the network is away. Told, it loads + // at once with no basemap, and with one it keeps drawing its own + // layers (a raster layer served on the device, say) when the + // basemap is out of reach. + val design = state.mapDesignType + createdDesign.value = design.getValue() + val map = ArcGISMap(SpatialReference.webMercator()) + ArcGISDesign.toBasemapStyleOrNull(design)?.let { map.setBasemap(Basemap(it)) } wrapView.arcGISMapView.map = map + var retried = false val coroutine = CoroutineScope(Dispatchers.Default) suspendCancellableCoroutine { cont -> @@ -96,6 +112,16 @@ fun ArcGISMapView2D( ) { _, _, _ -> } } is LoadStatus.FailedToLoad -> { + Log.w("ArcGISMapView2D", "map failed to load: ${it.error.message}") + // The basemap was taken away while it was being + // fetched (the app went basemap-less, say for an + // offline raster): without one the map needs no + // network, so it gets one more go. + if (map.basemap.value == null && !retried) { + retried = true + map.retryLoad() + return@collect + } if (cont.isActive) { cont.resume( ArcGISMapView2DHolder( @@ -170,6 +196,7 @@ fun ArcGISMapView2D( mapController.setMapLongClickListener(onMapLongClick) mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) state.setController(mapController) + if (state.mapDesignType.getValue() != createdDesign.value) mapController.setMapDesignType(state.mapDesignType) // 他プロバイダの *MapView と同じく、コントローラ生成直後に適用する。 cameraRestriction?.let { mapController.setCameraRestriction(it) } diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt index 9c57dee..091c1a8 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt @@ -1,5 +1,7 @@ package com.mapconductor.arcgis +import android.util.Log +import com.arcgismaps.LoadStatus import com.arcgismaps.geometry.SpatialReference import com.arcgismaps.mapping.Basemap import com.arcgismaps.mapping.Viewpoint @@ -308,8 +310,17 @@ class ArcGISMapView2DController( } override fun setMapDesignType(value: ArcGISDesignTypeInterface) { - val baseMap = ArcGISDesign.toBasemap(value) - holder.map.map?.setBasemap(baseMap) + val map = holder.map.map ?: return + map.setBasemap(ArcGISDesign.toBasemap(value)) + // A map that failed to load -- its basemap was out of reach -- gets + // another go with the new basemap, or with none, which needs no + // network at all. + if (map.loadStatus.value is LoadStatus.FailedToLoad) { + defaultCoroutine.launch { + val result = map.retryLoad() + Log.d("ArcGISMapView2D", "map reloaded with basemap=${value.getValue()}: ${result.isSuccess} ${result.exceptionOrNull()?.message ?: ""}") + } + } } override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt index d5cfee4..9b42a8f 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt @@ -1,5 +1,6 @@ package com.mapconductor.arcgis +import com.arcgismaps.LoadStatus import com.arcgismaps.mapping.Basemap import com.arcgismaps.mapping.view.GraphicsOverlay import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController @@ -259,6 +260,10 @@ class ArcGISMapViewController( val baseMap = ArcGISDesign.toBasemap(value) defaultCoroutine.launch { scene.setBasemap(baseMap) + // A scene that failed to load -- its basemap was out of + // reach -- gets another go with the new basemap, or with + // none, which needs no network at all. + if (scene.loadStatus.value is LoadStatus.FailedToLoad) scene.retryLoad() // Basemap changes can reset the viewpoint; mark the current request as pending so that // the next viewpointChanged can restore the last requested camera if needed. // pendingCameraRestoreRequest = cameraRequestGeneration.get() From 6fa825652b87a40d4444c8d9f9688a41642b9f66 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 10 Oct 2026 14:18:52 -0700 Subject: [PATCH 2/3] feat: integrate vector style support and pending map fixes --- api/android-for-arcgis.api | 4 +- .../arcgis/ArcGIS2DTiltEmulation.kt | 182 ++-- .../com/mapconductor/arcgis/ArcGISMapView.kt | 974 +++++++++--------- .../mapconductor/arcgis/ArcGISMapView2D.kt | 532 +++++----- .../arcgis/ArcGISMapView2DController.kt | 842 +++++++-------- .../arcgis/ArcGISMapViewController.kt | 689 +++++++------ .../raster/ArcGISRasterLayerController.kt | 67 +- .../ArcGISRasterLayerOverlayRenderer.kt | 729 ++++++------- 8 files changed, 2029 insertions(+), 1990 deletions(-) diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index d366041..4624a96 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -188,7 +188,7 @@ public final class com.mapconductor.arcgis.ArcGISMapView2DHolder implements com. public java.lang.Object getMap(); } public final class com.mapconductor.arcgis.ArcGISMapView2DKt { - public static final void ArcGISMapView2D(com.mapconductor.arcgis.ArcGISMapViewState, 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 ArcGISMapView2D(com.mapconductor.arcgis.ArcGISMapViewState, 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 final class com.mapconductor.arcgis.ArcGISMapViewController extends com.mapconductor.core.controller.BaseMapViewController implements com.mapconductor.arcgis.ArcGISMapViewControllerInterface { public com.mapconductor.arcgis.ArcGISMapViewController(com.mapconductor.arcgis.ArcGISMapViewHolder, com.mapconductor.arcgis.marker.ArcGISMarkerController, com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController, com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController, com.mapconductor.arcgis.circle.ArcGISCircleOverlayController, com.mapconductor.arcgis.groundimage.ArcGISGroundImageController, com.mapconductor.arcgis.raster.ArcGISRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope); @@ -287,7 +287,7 @@ public final class com.mapconductor.arcgis.ArcGISMapViewInitOptions { public boolean equals(java.lang.Object); } public final class com.mapconductor.arcgis.ArcGISMapViewKt { - public static final void ArcGISMapView(com.mapconductor.arcgis.ArcGISMapViewState, 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 ArcGISMapView(com.mapconductor.arcgis.ArcGISMapViewState, 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.arcgis.circle.ArcGISCircleOverlayController getCircleController(com.mapconductor.arcgis.ArcGISGeoViewHolder, boolean); public static final com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController getPolylineController(com.mapconductor.arcgis.ArcGISGeoViewHolder, boolean); public static final com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController getPolygonController(com.mapconductor.arcgis.ArcGISGeoViewHolder, boolean); diff --git a/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt b/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt index 46a0557..1db316a 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt @@ -1,88 +1,94 @@ -package com.mapconductor.arcgis - -import com.mapconductor.arcgis.zoom.ZoomAltitudeConverter -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.map.CameraBearing -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.spherical.Spherical -import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter -import kotlin.math.abs -import kotlin.math.cos -import kotlin.math.tan - -/** - * ArcGIS の 2D [com.arcgismaps.mapping.view.MapView] 向けの tilt 擬似表現。 - * - * 2D は Viewpoint(中心 + 縮尺 + 回転)ベースでカメラピッチを一切持てない。3D の SceneView - * ([ArcGISMapViewController])は calculateCameraForOrbitParameters で実際にピッチできるが、 - * 2D では遠近感を作れないため、**カメラが見ている地表範囲**を幾何的に近似する。 - * - * 見た目の傾き(遠近感)は ArcGISMapView2D が MapView 自体を graphicsLayer で傾けて作る - * (react-for-leaflet が CSS rotateX で行うのと同じ方式)。ここが受け持つのは - * **カメラ位置の付け替え**だけ: - * - * - tilt >= 0: 指定位置は**ターゲット**(画面中心)でカメラが後方へ下がるだけなので、 - * 中心もズームも変えない。傾きは見た目の変換だけで表現される。 - * - tilt < 0: 指定位置は**カメラ位置**で、ターゲットが進行方向(bearing)へ前進する。 - * 前進量とズームオフセットは MapLibre / TomTom / Leaflet と同一の式・同一定数。 - * - * ios-for-arcgis の `ArcGIS2DTiltEmulation.swift` と同一ロジック。 - */ -internal object ArcGIS2DTiltEmulation { - /** MapLibre / TomTom / Leaflet と同一値。プロバイダ間で挙動を揃えるため変えないこと。 */ - private const val TARGET_DISTANCE_SCALE = 1.83 - private const val ZOOM_OFFSET_AT_MAX_TILT = -0.9 - - /** - * 高度の算出にはプラットフォーム非依存の既定値(Google Maps 較正)を使う。 - * - * [ZoomAltitudeConverter] の既定値(Android 136_500_000 / iOS 141_600_000)は - * **3D SceneView の実効画角**を合わせるための較正値で、画角を持たない 2D MapView には - * 当てはまらない。既定値を使うことでシフト量が iOS と厳密に一致する。 - */ - private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE) - - /** - * 論理カメラ → 実際に Viewpoint へ渡す中心・ズーム。 - * - * tilt < 0 のときだけ中心を進行方向へ前進させ、ズームを引く。tilt >= 0 は入力のまま - * (position が**ターゲット**=画面中心で、カメラが後方へ下がるだけのため)。 - */ - fun shiftedCamera(position: MapCameraPosition): Pair { - if (position.tilt >= 0) return position.position to position.zoom - - val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, 60.0) - val zoom = position.zoom + ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = converter.zoomLevelToAltitude(position.zoom, position.position.latitude, 0.0) - val distanceForward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val target = Spherical.computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) - return target to zoom - } - - /** - * Viewpoint から読み戻した中心・ズームを論理カメラへ戻す。 - * - * tilt < 0 のときだけ中心とズームを巻き戻す。 - * - * @param logicalTilt 直近に要求した論理 tilt。 - * @param bearing 前進に使った方位。 - */ - fun restoreLogicalCamera( - center: GeoPointInterface, - zoom: Double, - bearing: Double, - logicalTilt: Double, - ): Pair { - val tiltAbsDeg = abs(logicalTilt).coerceIn(0.0, 60.0) - if (logicalTilt >= 0 || tiltAbsDeg == 0.0) return center to zoom - - val originalZoom = zoom - ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = converter.zoomLevelToAltitude(originalZoom, center.latitude, 0.0) - val distanceBackward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val originalPosition: GeoPoint = Spherical.computeOffset(center, distanceBackward, CameraBearing.toNativeHeading(bearing) + 180.0) - return originalPosition to originalZoom - } -} +package com.mapconductor.arcgis + +import com.mapconductor.arcgis.zoom.ZoomAltitudeConverter +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.spherical.Spherical +import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.tan + +/** + * ArcGIS の 2D [com.arcgismaps.mapping.view.MapView] 向けの tilt 擬似表現。 + * + * 2D は Viewpoint(中心 + 縮尺 + 回転)ベースでカメラピッチを一切持てない。3D の SceneView + * ([ArcGISMapViewController])は calculateCameraForOrbitParameters で実際にピッチできるが、 + * 2D では遠近感を作れないため、**カメラが見ている地表範囲**を幾何的に近似する。 + * + * 見た目の傾き(遠近感)は ArcGISMapView2D が MapView 自体を graphicsLayer で傾けて作る + * (react-for-leaflet が CSS rotateX で行うのと同じ方式)。ここが受け持つのは + * **カメラ位置の付け替え**だけ: + * + * - tilt >= 0: 指定位置は**ターゲット**(画面中心)でカメラが後方へ下がるだけなので、 + * 中心もズームも変えない。傾きは見た目の変換だけで表現される。 + * - tilt < 0: 指定位置は**カメラ位置**で、ターゲットが進行方向(bearing)へ前進する。 + * 前進量とズームオフセットは MapLibre / TomTom / Leaflet と同一の式・同一定数。 + * + * ios-for-arcgis の `ArcGIS2DTiltEmulation.swift` と同一ロジック。 + */ +internal object ArcGIS2DTiltEmulation { + /** MapLibre / TomTom / Leaflet と同一値。プロバイダ間で挙動を揃えるため変えないこと。 */ + private const val TARGET_DISTANCE_SCALE = 1.83 + private const val ZOOM_OFFSET_AT_MAX_TILT = -0.9 + + /** + * 高度の算出にはプラットフォーム非依存の既定値(Google Maps 較正)を使う。 + * + * [ZoomAltitudeConverter] の既定値(Android 136_500_000 / iOS 141_600_000)は + * **3D SceneView の実効画角**を合わせるための較正値で、画角を持たない 2D MapView には + * 当てはまらない。既定値を使うことでシフト量が iOS と厳密に一致する。 + */ + private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE) + + /** + * 論理カメラ → 実際に Viewpoint へ渡す中心・ズーム。 + * + * tilt < 0 のときだけ中心を進行方向へ前進させ、ズームを引く。tilt >= 0 は入力のまま + * (position が**ターゲット**=画面中心で、カメラが後方へ下がるだけのため)。 + */ + fun shiftedCamera(position: MapCameraPosition): Pair { + if (position.tilt >= 0) return position.position to position.zoom + + val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, 60.0) + val zoom = position.zoom + ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = converter.zoomLevelToAltitude(position.zoom, position.position.latitude, 0.0) + val distanceForward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE + val target = + Spherical + .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) + return target to zoom + } + + /** + * Viewpoint から読み戻した中心・ズームを論理カメラへ戻す。 + * + * tilt < 0 のときだけ中心とズームを巻き戻す。 + * + * @param logicalTilt 直近に要求した論理 tilt。 + * @param bearing 前進に使った方位。 + */ + fun restoreLogicalCamera( + center: GeoPointInterface, + zoom: Double, + bearing: Double, + logicalTilt: Double, + ): Pair { + val tiltAbsDeg = abs(logicalTilt).coerceIn(0.0, 60.0) + if (logicalTilt >= 0 || tiltAbsDeg == 0.0) return center to zoom + + val originalZoom = zoom - ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = converter.zoomLevelToAltitude(originalZoom, center.latitude, 0.0) + val distanceBackward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE + val originalPosition: GeoPoint = + Spherical.computeOffset( + center, distanceBackward, + CameraBearing.toNativeHeading(bearing) + 180.0, + ) + return originalPosition to originalZoom + } +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt index c62569f..f31d8c9 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt @@ -1,479 +1,495 @@ -package com.mapconductor.arcgis - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.node.Ref -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.arcgismaps.ApiKey -import com.arcgismaps.ArcGISEnvironment -import com.arcgismaps.LoadStatus -import com.arcgismaps.mapping.ArcGISScene -import com.arcgismaps.mapping.ArcGISTiledElevationSource -import com.arcgismaps.mapping.Basemap -import com.arcgismaps.mapping.view.GraphicsOverlay -import com.arcgismaps.mapping.view.GraphicsRenderingMode -import com.arcgismaps.mapping.view.SceneView -import com.arcgismaps.mapping.view.SurfacePlacement -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer -import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController -import com.mapconductor.arcgis.groundimage.ArcGISGroundImageOverlayRenderer -import com.mapconductor.arcgis.marker.ArcGISMarkerController -import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer -import com.mapconductor.arcgis.raster.ArcGISRasterLayerController -import com.mapconductor.arcgis.raster.ArcGISRasterLayerOverlayRenderer -import com.mapconductor.compose.map.MapViewBase -import com.mapconductor.core.OnCameraMoveHandler -import com.mapconductor.core.OnMapEventHandler -import com.mapconductor.core.OnMapLoadedHandler -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapCameraPositionInterface -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerManager -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -import com.mapconductor.core.marker.MarkerRenderingSupport -import com.mapconductor.core.marker.MarkerRenderingSupportKey -import com.mapconductor.core.marker.MarkerTilingOptions -import com.mapconductor.core.raster.RasterTilePreference -import com.mapconductor.core.raster.RasterTilePreferenceKey -import com.mapconductor.core.marker.StrategyMarkerController -import com.mapconductor.core.tileserver.TileServerRegistry -import java.util.concurrent.atomic.AtomicLong -import android.content.Context -import android.util.Log -import android.widget.FrameLayout -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine - -@Composable -fun ArcGISMapView( - state: ArcGISMapViewState, - modifier: Modifier = Modifier, - markerTiling: MarkerTilingOptions? = null, - cameraRestriction: CameraRestriction? = null, - sdkInitialize: (suspend (Context) -> Boolean)? = null, - onMapLoaded: OnMapLoadedHandler? = null, - onCameraMoveStart: OnCameraMoveHandler? = null, - onCameraMove: OnCameraMoveHandler? = null, - onCameraMoveEnd: OnCameraMoveHandler? = null, - onMapClick: OnMapEventHandler? = null, - onMapLongClick: OnMapEventHandler? = null, - content: (@Composable ArcGISMapViewScope.() -> Unit)? = null, -) { - val scope = remember { ArcGISMapViewScope() } // Use specific scope - val context = LocalContext.current // Context will be available from MapViewBase too if needed - val registry = remember { scope.buildRegistry() } - val owner = LocalLifecycleOwner.current - // The design the scene was built with; a design set on the state before - // the controller existed is applied once it does. - val createdDesign = remember { Ref() } - val cameraState = remember { mutableStateOf(state.cameraPosition) } - val controllerRef = remember { Ref() } - val controllerGeneration = remember { AtomicLong(0L) } - - MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = { - val sceneView = SceneView(context) - val wrapView = - WrapSceneView(context).apply { - addView(sceneView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - } - wrapView.sceneView = sceneView - // Ensure lifecycle owner is set before the view is attached/drawn - // to avoid GeoView.lifeCycleOwner UninitializedPropertyAccessException - sceneView.onCreate(owner) - sceneView.onResume(owner) - wrapView - }, - scope = scope, - registry = registry, - holderProvider = { wrapView -> - val design = state.mapDesignType - createdDesign.value = design.getValue() - val options = - ArcGISMapViewInitOptions( - basemapStyle = ArcGISDesign.toBasemapStyleOrNull(design), - elevationSources = design.elevationSources, - ) - - // No basemap is a scene with only what the app puts on it. The - // basemap is set on an empty scene rather than built into it, so - // a scene whose basemap is out of reach is still a scene (a - // global one is always WGS84) that can draw its own layers. - val scene = ArcGISScene() - options.basemapStyle?.let { scene.setBasemap(Basemap(it)) } - - options.elevationSources.forEach { - val source = ArcGISTiledElevationSource(it) - scene.baseSurface.elevationSources.add(source) - } - - wrapView.sceneView.scene = scene - var retried = false - - val coroutine = CoroutineScope(Dispatchers.Default) - - suspendCancellableCoroutine { cont -> - cont.invokeOnCancellation { coroutine.cancel() } - coroutine.launch { - scene.loadStatus.collect { - when (it) { - is LoadStatus.Loaded -> { - val holder = - ArcGISMapViewHolder( - mapView = wrapView, - map = wrapView.sceneView, - ) - cont.resume(holder) { _, _, _ -> } - } - is LoadStatus.FailedToLoad -> { - Log.w("ArcGISMapView", "scene failed to load: ${it.error.message}") - // The basemap was taken away while it was being - // fetched (the app went basemap-less, say for an - // offline raster): without one the scene needs no - // network, so it gets one more go. - if (scene.basemap.value == null && !retried) { - retried = true - scene.retryLoad() - return@collect - } - // Offline or network error: resume with a holder anyway so - // the scene view is displayed (possibly with cached tiles) - // without crashing. The ArcGIS view may be blank but usable. - if (cont.isActive) { - cont.resume( - ArcGISMapViewHolder( - mapView = wrapView, - map = wrapView.sceneView, - ), - ) { _, _, _ -> } - } - } - else -> { - // Do nothing here - } - } - } - } - } - }, - controllerProvider = { holder -> - - val markerLayer: GraphicsOverlay = - GraphicsOverlay().apply { - renderingMode = GraphicsRenderingMode.Dynamic - sceneProperties.surfacePlacement = SurfacePlacement.Relative - } - val markerController = - getMarkerController( - holder = holder, - markerLayer = markerLayer, - markerTiling = markerTiling ?: MarkerTilingOptions.Default, - ) - val polylineController = getPolylineController(holder) - val rasterLayerController = getRasterLayerController(holder) - val polygonController = getPolygonController(holder) - val circleController = getCircleController(holder) - val groundImageController = getGroundImageController(holder) - - // Defer initial camera update until controller is created and view is laid out - - ArcGISMapViewController( - holder = holder, - markerController = markerController, - polylineController = polylineController, - polygonController = polygonController, - circleController = circleController, - groundImageController = groundImageController, - rasterLayerController = rasterLayerController, - ).also { mapController -> - state.serviceRegistry.put( - MarkerRenderingSupportKey, - object : MarkerRenderingSupport { - override fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() - - override fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = - mapController - .createMarkerEventController(controller) - - override fun registerMarkerEventController( - controller: MarkerEventControllerInterface, - ) { - mapController.registerMarkerEventController(controller) - } - - override fun onMarkerRenderingReady() { - mapController.sendInitialCameraUpdate() - } - }, - ) - - // 3D SceneView は「タイルは 256px」という前提でレベルを選ぶ。 - // 512px のタイルを渡すと 1 段深いレベルを 4 倍の枚数で引く - // (実測: 統一ズーム 12 で 2D は z=11、3D は z=12)。絵は正しい - // ので気づきにくいが、タイル 1 枚あたりの固定費がそのぶん効く。 - // 2D は好みが無いので宣言しない — 供給側の既定に任せる。 - // 3D SceneView は「タイルは 256px」という自前の梯子で level を - // 選び、こちらが申告する縮尺では動かせない(実機で確認)。同じ - // カメラで 2D より 1 段深い level を引くので、512dp のつもりで - // 描いたタイルは半分の枠に入って文字も線も半分になる。 - // - // 256 を要求すると、選ばれる level と描画の前提が噛み合い、 - // 文字の大きさは MapLibre と一致する(実測)。残るのは元データの - // 違い — 1 段深いソースタイルには大きな地名が入っていないことが - // あり、低ズームで「新潟市」のような表示が落ちる。 - if (holder.usesSceneView) { - state.serviceRegistry.put( - RasterTilePreferenceKey, - object : RasterTilePreference { - override val preferredTileSize: Int = 256 - }, - ) - } - - controllerRef.value = mapController - mapController.setMapClickListener(onMapClick) - mapController.setMapLongClickListener(onMapLongClick) - mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - cameraRestriction?.let { mapController.setCameraRestriction(it) } - state.setController(mapController) - if (state.mapDesignType.getValue() != createdDesign.value) mapController.setMapDesignType(state.mapDesignType) - - // Set camera listeners immediately so they are ready to receive - // camera updates from external sources (e.g. camera sync scenarios). - mapController.setCameraMoveStartListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveStart?.invoke(it) - } - mapController.setCameraMoveListener { - cameraState.value = it - state.updateCameraPosition(it) - // The tile server's ancestor gate needs to know which level - // the camera looks at. - mapController.rasterLayerController.renderer.cameraMoved(it.zoom) - onCameraMove?.invoke(it) - } - mapController.setCameraMoveEndListener { - cameraState.value = it - state.updateCameraPosition(it) - mapController.rasterLayerController.renderer.cameraMoved(it.zoom) - mapController.mainCoroutine.launch { - mapController.rasterLayerController.rebuildLocalLayersIfNeeded() - } - onCameraMoveEnd?.invoke(it) - } - - // Avoid early ArcGIS viewpoint updates overwriting the desired initial camera. - // Apply the initial camera after layout. - val initialCameraPosition = state.cameraPosition - val generation = controllerGeneration.incrementAndGet() - holder.mapView.post { - if (controllerGeneration.get() != generation) return@post - mapController.moveCamera(MapCameraPosition.from(initialCameraPosition)) - mapController.sendInitialCameraUpdate() - // viewpointChanged の初回発火に頼らず、レイアウト確定のこの時点で - // 「準備完了」を通知する(取り逃すとオーバーレイが一切描画されない)。 - mapController.markMapInitialized() - } - } - }, - sdkInitialize = { - sdkInitialize?.invoke(context) ?: defaultArcGISInitialize(context) - }, - onMapLoaded = onMapLoaded, - customDisposableEffect = { _, holderRef -> - DisposableEffect(state.id) { - onDispose { - // Invalidate any pending `post { ... }` work that captured older controllers. - controllerGeneration.incrementAndGet() - // Detach callbacks so a disposed controller cannot keep overwriting state.cameraPosition - // during rapid ArcGIS<->other provider switching. - controllerRef.value?.apply { - setCameraMoveStartListener(null) - setCameraMoveListener(null) - setCameraMoveEndListener(null) - setMapClickListener(null) - setMapLongClickListener(null) - // Free overlay controllers (tile-server routes, marker - // managers) and cancel the controller scope. - destroy() - } - controllerRef.value = null - state.clearController() - holderRef.value?.mapView?.apply { - onPause(owner) - onStop(owner) - onDestroy(owner) - } - } - } - }, - content = content, - ) -} - -internal fun getCircleController( - holder: ArcGISGeoViewHolder<*, *>, - useScenePlacement: Boolean = true, -): ArcGISCircleOverlayController { - val circleLayer: GraphicsOverlay = - GraphicsOverlay().apply { - if (useScenePlacement) { - sceneProperties.surfacePlacement = SurfacePlacement.DrapedFlat - } - } - holder.geoView.graphicsOverlays.add(circleLayer) - - val renderer = - ArcGISCircleOverlayRenderer( - circleLayer = circleLayer, - holder = holder, - ) - - val controller = - ArcGISCircleOverlayController( - renderer = renderer, - ) - return controller -} - -internal fun getPolylineController( - holder: ArcGISGeoViewHolder<*, *>, - useScenePlacement: Boolean = true, -): ArcGISPolylineOverlayController { - val polylineLayer: GraphicsOverlay = - GraphicsOverlay().apply { - if (useScenePlacement) { - sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded - } - } - holder.geoView.graphicsOverlays.add(polylineLayer) - - val renderer = - ArcGISPolylineOverlayRenderer( - polylineLayer = polylineLayer, - holder = holder, - ) - - val controller = - ArcGISPolylineOverlayController( - renderer = renderer, - ) - return controller -} - -internal fun getPolygonController( - holder: ArcGISGeoViewHolder<*, *>, - useScenePlacement: Boolean = true, -): ArcGISPolygonOverlayController { - val polygonLayer: GraphicsOverlay = - GraphicsOverlay().apply { - if (useScenePlacement) { - sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded - } - } - holder.geoView.graphicsOverlays.add(polygonLayer) - - val renderer = - ArcGISPolygonOverlayRenderer( - polygonLayer = polygonLayer, - holder = holder, - ) - - val controller = - ArcGISPolygonOverlayController( - renderer = renderer, - ) - return controller -} - -internal fun getMarkerController( - holder: ArcGISGeoViewHolder<*, *>, - markerLayer: GraphicsOverlay, - markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, -): ArcGISMarkerController { - val renderer = - ArcGISMarkerRenderer( - markerLayer = markerLayer, - holder = holder, - ) - - val markerManager = - MarkerManager.defaultManager( - minMarkerCount = markerTiling.minMarkerCount, - ) - - val controller = - ArcGISMarkerController( - markerManager = markerManager, - renderer = renderer, - markerTiling = markerTiling, - ) - return controller -} - -internal fun getRasterLayerController(holder: ArcGISGeoViewHolder<*, *>): ArcGISRasterLayerController { - val renderer = - ArcGISRasterLayerOverlayRenderer( - holder = holder, - ) - return ArcGISRasterLayerController( - renderer = renderer, - ) -} - -internal fun getGroundImageController(holder: ArcGISGeoViewHolder<*, *>): ArcGISGroundImageController { - val tileServer = TileServerRegistry.get() - val renderer = - ArcGISGroundImageOverlayRenderer( - holder = holder, - tileServer = tileServer, - ) - return ArcGISGroundImageController(renderer = renderer) -} - -/** - * Default ArcGIS SDK initialization using API Key authentication. - * - * This function is used when no custom sdkInitialize parameter is provided to ArcGISMapView. - * It reads the API Key from AndroidManifest.xml metadata and configures ArcGISEnvironment. - * - * @param context Application context - * @return true if initialization succeeded, false otherwise - */ -internal fun defaultArcGISInitialize(context: Context): Boolean { - if (ArcGISEnvironment.authenticationManager.arcGISCredentialStore - .getCredentials() - .isEmpty() - ) { - val apiKey = context.applicationContext.getArcGisApiKey() - if (apiKey == null) { - Log.e("ArcGISMapView", " is required") - return false - } - ArcGISEnvironment.apiKey = ApiKey.create(apiKey) - } - return true -} +package com.mapconductor.arcgis + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.arcgismaps.ApiKey +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.LoadStatus +import com.arcgismaps.mapping.ArcGISScene +import com.arcgismaps.mapping.ArcGISTiledElevationSource +import com.arcgismaps.mapping.Basemap +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.arcgismaps.mapping.view.GraphicsRenderingMode +import com.arcgismaps.mapping.view.SceneView +import com.arcgismaps.mapping.view.SurfacePlacement +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer +import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController +import com.mapconductor.arcgis.groundimage.ArcGISGroundImageOverlayRenderer +import com.mapconductor.arcgis.marker.ArcGISMarkerController +import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer +import com.mapconductor.arcgis.raster.ArcGISRasterLayerController +import com.mapconductor.arcgis.raster.ArcGISRasterLayerOverlayRenderer +import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.core.OnCameraMoveHandler +import com.mapconductor.core.OnMapEventHandler +import com.mapconductor.core.OnMapLoadedHandler +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapViewStyle +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +import com.mapconductor.core.marker.MarkerRenderingSupport +import com.mapconductor.core.marker.MarkerRenderingSupportKey +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.StrategyMarkerController +import com.mapconductor.core.raster.RasterTilePreference +import com.mapconductor.core.raster.RasterTilePreferenceKey +import com.mapconductor.core.tileserver.TileServerRegistry +import java.util.concurrent.atomic.AtomicLong +import android.content.Context +import android.util.Log +import android.widget.FrameLayout +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine + +@Composable +fun ArcGISMapView( + state: ArcGISMapViewState, + modifier: Modifier = Modifier, + markerTiling: MarkerTilingOptions? = null, + cameraRestriction: CameraRestriction? = null, + sdkInitialize: (suspend (Context) -> Boolean)? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onCameraMoveStart: OnCameraMoveHandler? = null, + onCameraMove: OnCameraMoveHandler? = null, + onCameraMoveEnd: OnCameraMoveHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMapLongClick: OnMapEventHandler? = 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 ArcGISMapViewScope.() -> Unit)? = null, +) { + val scope = remember { ArcGISMapViewScope() } // Use specific scope + val context = LocalContext.current // Context will be available from MapViewBase too if needed + val registry = remember { scope.buildRegistry() } + val owner = LocalLifecycleOwner.current + // The design the scene was built with; a design set on the state before + // the controller existed is applied once it does. + val createdDesign = remember { Ref() } + val cameraState = remember { mutableStateOf(state.cameraPosition) } + val controllerRef = remember { Ref() } + val controllerGeneration = remember { AtomicLong(0L) } + + MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = { + val sceneView = SceneView(context) + val wrapView = + WrapSceneView(context).apply { + addView(sceneView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + } + wrapView.sceneView = sceneView + // Ensure lifecycle owner is set before the view is attached/drawn + // to avoid GeoView.lifeCycleOwner UninitializedPropertyAccessException + sceneView.onCreate(owner) + sceneView.onResume(owner) + wrapView + }, + scope = scope, + registry = registry, + holderProvider = { wrapView -> + val design = state.mapDesignType + createdDesign.value = design.getValue() + val options = + ArcGISMapViewInitOptions( + basemapStyle = ArcGISDesign.toBasemapStyleOrNull(design), + elevationSources = design.elevationSources, + ) + + // No basemap is a scene with only what the app puts on it. The + // basemap is set on an empty scene rather than built into it, so + // a scene whose basemap is out of reach is still a scene (a + // global one is always WGS84) that can draw its own layers. + val scene = ArcGISScene() + options.basemapStyle?.let { scene.setBasemap(Basemap(it)) } + + options.elevationSources.forEach { + val source = ArcGISTiledElevationSource(it) + scene.baseSurface.elevationSources.add(source) + } + + wrapView.sceneView.scene = scene + var retried = false + + val coroutine = CoroutineScope(Dispatchers.Default) + + suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { coroutine.cancel() } + coroutine.launch { + scene.loadStatus.collect { + when (it) { + is LoadStatus.Loaded -> { + val holder = + ArcGISMapViewHolder( + mapView = wrapView, + map = wrapView.sceneView, + ) + cont.resume(holder) { _, _, _ -> } + } + is LoadStatus.FailedToLoad -> { + Log.w("ArcGISMapView", "scene failed to load: ${it.error.message}") + // The basemap was taken away while it was being + // fetched (the app went basemap-less, say for an + // offline raster): without one the scene needs no + // network, so it gets one more go. + if (scene.basemap.value == null && !retried) { + retried = true + scene.retryLoad() + return@collect + } + // Offline or network error: resume with a holder anyway so + // the scene view is displayed (possibly with cached tiles) + // without crashing. The ArcGIS view may be blank but usable. + if (cont.isActive) { + cont.resume( + ArcGISMapViewHolder( + mapView = wrapView, + map = wrapView.sceneView, + ), + ) { _, _, _ -> } + } + } + else -> { + // Do nothing here + } + } + } + } + } + }, + controllerProvider = { holder -> + + val markerLayer: GraphicsOverlay = + GraphicsOverlay().apply { + renderingMode = GraphicsRenderingMode.Dynamic + sceneProperties.surfacePlacement = SurfacePlacement.Relative + } + val markerController = + getMarkerController( + holder = holder, + markerLayer = markerLayer, + markerTiling = markerTiling ?: MarkerTilingOptions.Default, + ) + val polylineController = getPolylineController(holder) + val rasterLayerController = getRasterLayerController(holder) + val polygonController = getPolygonController(holder) + val circleController = getCircleController(holder) + val groundImageController = getGroundImageController(holder) + + // Defer initial camera update until controller is created and view is laid out + + ArcGISMapViewController( + holder = holder, + markerController = markerController, + polylineController = polylineController, + polygonController = polygonController, + circleController = circleController, + groundImageController = groundImageController, + rasterLayerController = rasterLayerController, + ).also { mapController -> + state.serviceRegistry.put( + MarkerRenderingSupportKey, + object : MarkerRenderingSupport { + override fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() + + override fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = + mapController + .createMarkerEventController(controller) + + override fun registerMarkerEventController( + controller: MarkerEventControllerInterface, + ) { + mapController.registerMarkerEventController(controller) + } + + override fun onMarkerRenderingReady() { + mapController.sendInitialCameraUpdate() + } + }, + ) + + // 3D SceneView は「タイルは 256px」という前提でレベルを選ぶ。 + // 512px のタイルを渡すと 1 段深いレベルを 4 倍の枚数で引く + // (実測: 統一ズーム 12 で 2D は z=11、3D は z=12)。絵は正しい + // ので気づきにくいが、タイル 1 枚あたりの固定費がそのぶん効く。 + // 2D は好みが無いので宣言しない — 供給側の既定に任せる。 + // 3D SceneView は「タイルは 256px」という自前の梯子で level を + // 選び、こちらが申告する縮尺では動かせない(実機で確認)。同じ + // カメラで 2D より 1 段深い level を引くので、512dp のつもりで + // 描いたタイルは半分の枠に入って文字も線も半分になる。 + // + // 256 を要求すると、選ばれる level と描画の前提が噛み合い、 + // 文字の大きさは MapLibre と一致する(実測)。残るのは元データの + // 違い — 1 段深いソースタイルには大きな地名が入っていないことが + // あり、低ズームで「新潟市」のような表示が落ちる。 + if (holder.usesSceneView) { + state.serviceRegistry.put( + RasterTilePreferenceKey, + object : RasterTilePreference { + override val preferredTileSize: Int = 256 + }, + ) + } + + controllerRef.value = mapController + mapController.setMapClickListener(onMapClick) + mapController.setMapLongClickListener(onMapLongClick) + mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + cameraRestriction?.let { mapController.setCameraRestriction(it) } + state.setController(mapController) + if (state.mapDesignType.getValue() != + createdDesign.value + ) { + mapController.setMapDesignType(state.mapDesignType) + } + + // Set camera listeners immediately so they are ready to receive + // camera updates from external sources (e.g. camera sync scenarios). + mapController.setCameraMoveStartListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveStart?.invoke(it) + } + mapController.setCameraMoveListener { + cameraState.value = it + state.updateCameraPosition(it) + // The tile server's ancestor gate needs to know which level + // the camera looks at. + mapController.rasterLayerController.renderer.cameraMoved(it.zoom) + onCameraMove?.invoke(it) + } + mapController.setCameraMoveEndListener { + cameraState.value = it + state.updateCameraPosition(it) + mapController.rasterLayerController.renderer.cameraMoved(it.zoom) + mapController.mainCoroutine.launch { + mapController.rasterLayerController.rebuildLocalLayersIfNeeded() + } + onCameraMoveEnd?.invoke(it) + } + + // Avoid early ArcGIS viewpoint updates overwriting the desired initial camera. + // Apply the initial camera after layout. + val initialCameraPosition = state.cameraPosition + val generation = controllerGeneration.incrementAndGet() + holder.mapView.post { + if (controllerGeneration.get() != generation) return@post + mapController.moveCamera(MapCameraPosition.from(initialCameraPosition)) + mapController.sendInitialCameraUpdate() + // viewpointChanged の初回発火に頼らず、レイアウト確定のこの時点で + // 「準備完了」を通知する(取り逃すとオーバーレイが一切描画されない)。 + mapController.markMapInitialized() + } + } + }, + sdkInitialize = { + sdkInitialize?.invoke(context) ?: defaultArcGISInitialize(context) + }, + onMapLoaded = onMapLoaded, + customDisposableEffect = { _, holderRef -> + DisposableEffect(state.id) { + onDispose { + // Invalidate any pending `post { ... }` work that captured older controllers. + controllerGeneration.incrementAndGet() + // Detach callbacks so a disposed controller cannot keep overwriting state.cameraPosition + // during rapid ArcGIS<->other provider switching. + controllerRef.value?.apply { + setCameraMoveStartListener(null) + setCameraMoveListener(null) + setCameraMoveEndListener(null) + setMapClickListener(null) + setMapLongClickListener(null) + // Free overlay controllers (tile-server routes, marker + // managers) and cancel the controller scope. + destroy() + } + controllerRef.value = null + state.clearController() + holderRef.value?.mapView?.apply { + onPause(owner) + onStop(owner) + onDestroy(owner) + } + } + } + }, + style = style, + onStyleDiagnostics = onStyleDiagnostics, + content = content, + ) +} + +internal fun getCircleController( + holder: ArcGISGeoViewHolder<*, *>, + useScenePlacement: Boolean = true, +): ArcGISCircleOverlayController { + val circleLayer: GraphicsOverlay = + GraphicsOverlay().apply { + if (useScenePlacement) { + sceneProperties.surfacePlacement = SurfacePlacement.DrapedFlat + } + } + holder.geoView.graphicsOverlays.add(circleLayer) + + val renderer = + ArcGISCircleOverlayRenderer( + circleLayer = circleLayer, + holder = holder, + ) + + val controller = + ArcGISCircleOverlayController( + renderer = renderer, + ) + return controller +} + +internal fun getPolylineController( + holder: ArcGISGeoViewHolder<*, *>, + useScenePlacement: Boolean = true, +): ArcGISPolylineOverlayController { + val polylineLayer: GraphicsOverlay = + GraphicsOverlay().apply { + if (useScenePlacement) { + sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded + } + } + holder.geoView.graphicsOverlays.add(polylineLayer) + + val renderer = + ArcGISPolylineOverlayRenderer( + polylineLayer = polylineLayer, + holder = holder, + ) + + val controller = + ArcGISPolylineOverlayController( + renderer = renderer, + ) + return controller +} + +internal fun getPolygonController( + holder: ArcGISGeoViewHolder<*, *>, + useScenePlacement: Boolean = true, +): ArcGISPolygonOverlayController { + val polygonLayer: GraphicsOverlay = + GraphicsOverlay().apply { + if (useScenePlacement) { + sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded + } + } + holder.geoView.graphicsOverlays.add(polygonLayer) + + val renderer = + ArcGISPolygonOverlayRenderer( + polygonLayer = polygonLayer, + holder = holder, + ) + + val controller = + ArcGISPolygonOverlayController( + renderer = renderer, + ) + return controller +} + +internal fun getMarkerController( + holder: ArcGISGeoViewHolder<*, *>, + markerLayer: GraphicsOverlay, + markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, +): ArcGISMarkerController { + val renderer = + ArcGISMarkerRenderer( + markerLayer = markerLayer, + holder = holder, + ) + + val markerManager = + MarkerManager.defaultManager( + minMarkerCount = markerTiling.minMarkerCount, + ) + + val controller = + ArcGISMarkerController( + markerManager = markerManager, + renderer = renderer, + markerTiling = markerTiling, + ) + return controller +} + +internal fun getRasterLayerController(holder: ArcGISGeoViewHolder<*, *>): ArcGISRasterLayerController { + val renderer = + ArcGISRasterLayerOverlayRenderer( + holder = holder, + ) + return ArcGISRasterLayerController( + renderer = renderer, + ) +} + +internal fun getGroundImageController(holder: ArcGISGeoViewHolder<*, *>): ArcGISGroundImageController { + val tileServer = TileServerRegistry.get() + val renderer = + ArcGISGroundImageOverlayRenderer( + holder = holder, + tileServer = tileServer, + ) + return ArcGISGroundImageController(renderer = renderer) +} + +/** + * Default ArcGIS SDK initialization using API Key authentication. + * + * This function is used when no custom sdkInitialize parameter is provided to ArcGISMapView. + * It reads the API Key from AndroidManifest.xml metadata and configures ArcGISEnvironment. + * + * @param context Application context + * @return true if initialization succeeded, false otherwise + */ +internal fun defaultArcGISInitialize(context: Context): Boolean { + if (ArcGISEnvironment.authenticationManager.arcGISCredentialStore + .getCredentials() + .isEmpty() + ) { + val apiKey = context.applicationContext.getArcGisApiKey() + if (apiKey == null) { + Log.e("ArcGISMapView", " is required") + return false + } + ArcGISEnvironment.apiKey = ApiKey.create(apiKey) + } + return true +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt index 0c1f527..a39a437 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt @@ -1,258 +1,274 @@ -package com.mapconductor.arcgis - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.node.Ref -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.arcgismaps.LoadStatus -import com.arcgismaps.geometry.SpatialReference -import com.arcgismaps.mapping.ArcGISMap -import com.arcgismaps.mapping.Basemap -import com.arcgismaps.mapping.view.GraphicsOverlay -import com.arcgismaps.mapping.view.GraphicsRenderingMode -import com.arcgismaps.mapping.view.MapView -import com.mapconductor.compose.map.MapViewBase -import com.mapconductor.core.OnCameraMoveHandler -import com.mapconductor.core.OnMapEventHandler -import com.mapconductor.core.OnMapLoadedHandler -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapCameraPositionInterface -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -import com.mapconductor.core.marker.MarkerRenderingSupport -import com.mapconductor.core.marker.MarkerRenderingSupportKey -import com.mapconductor.core.marker.MarkerTilingOptions -import com.mapconductor.core.marker.StrategyMarkerController -import java.util.concurrent.atomic.AtomicLong -import android.content.Context -import android.util.Log -import android.widget.FrameLayout -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine - -@Composable -fun ArcGISMapView2D( - state: ArcGISMapViewState, - modifier: Modifier = Modifier, - markerTiling: MarkerTilingOptions? = null, - cameraRestriction: CameraRestriction? = null, - sdkInitialize: (suspend (Context) -> Boolean)? = null, - onMapLoaded: OnMapLoadedHandler? = null, - onCameraMoveStart: OnCameraMoveHandler? = null, - onCameraMove: OnCameraMoveHandler? = null, - onCameraMoveEnd: OnCameraMoveHandler? = null, - onMapClick: OnMapEventHandler? = null, - onMapLongClick: OnMapEventHandler? = null, - content: (@Composable ArcGISMapViewScope.() -> Unit)? = null, -) { - val scope = remember { ArcGISMapViewScope() } - val context = LocalContext.current - val registry = remember { scope.buildRegistry() } - val owner = LocalLifecycleOwner.current - // The design the map was built with; a design set on the state before the - // controller existed is applied once it does. - val createdDesign = remember { Ref() } - val cameraState = remember { mutableStateOf(state.cameraPosition) } - val controllerRef = remember { Ref() } - val controllerGeneration = remember { AtomicLong(0L) } - - MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = { - val mapView = MapView(context) - val wrapView = - WrapMapView(context).apply { - addView(mapView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - } - wrapView.arcGISMapView = mapView - mapView.onCreate(owner) - mapView.onResume(owner) - wrapView - }, - scope = scope, - registry = registry, - holderProvider = { wrapView -> - // The map is told its spatial reference up front instead of - // learning it from the basemap: a basemap style lives on - // arcgis.com, and a map that waited for it to find out where it - // is can draw nothing while the network is away. Told, it loads - // at once with no basemap, and with one it keeps drawing its own - // layers (a raster layer served on the device, say) when the - // basemap is out of reach. - val design = state.mapDesignType - createdDesign.value = design.getValue() - val map = ArcGISMap(SpatialReference.webMercator()) - ArcGISDesign.toBasemapStyleOrNull(design)?.let { map.setBasemap(Basemap(it)) } - wrapView.arcGISMapView.map = map - var retried = false - - val coroutine = CoroutineScope(Dispatchers.Default) - suspendCancellableCoroutine { cont -> - cont.invokeOnCancellation { coroutine.cancel() } - coroutine.launch { - map.loadStatus.collect { - when (it) { - is LoadStatus.Loaded -> { - cont.resume( - ArcGISMapView2DHolder( - mapView = wrapView, - map = wrapView.arcGISMapView, - ), - ) { _, _, _ -> } - } - is LoadStatus.FailedToLoad -> { - Log.w("ArcGISMapView2D", "map failed to load: ${it.error.message}") - // The basemap was taken away while it was being - // fetched (the app went basemap-less, say for an - // offline raster): without one the map needs no - // network, so it gets one more go. - if (map.basemap.value == null && !retried) { - retried = true - map.retryLoad() - return@collect - } - if (cont.isActive) { - cont.resume( - ArcGISMapView2DHolder( - mapView = wrapView, - map = wrapView.arcGISMapView, - ), - ) { _, _, _ -> } - } - } - else -> { - // Do nothing here - } - } - } - } - } - }, - controllerProvider = { holder -> - val markerLayer: GraphicsOverlay = - GraphicsOverlay().apply { - renderingMode = GraphicsRenderingMode.Dynamic - } - val markerController = - getMarkerController( - holder = holder, - markerLayer = markerLayer, - markerTiling = markerTiling ?: MarkerTilingOptions.Default, - ) - val polylineController = getPolylineController(holder, useScenePlacement = false) - val rasterLayerController = getRasterLayerController(holder) - val polygonController = getPolygonController(holder, useScenePlacement = false) - val circleController = getCircleController(holder, useScenePlacement = false) - val groundImageController = getGroundImageController(holder) - - ArcGISMapView2DController( - holder = holder, - markerController = markerController, - polylineController = polylineController, - polygonController = polygonController, - circleController = circleController, - groundImageController = groundImageController, - rasterLayerController = rasterLayerController, - ).also { mapController -> - state.serviceRegistry.put( - MarkerRenderingSupportKey, - object : MarkerRenderingSupport { - override fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() - - override fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = - mapController - .createMarkerEventController(controller) - - override fun registerMarkerEventController( - controller: MarkerEventControllerInterface, - ) { - mapController.registerMarkerEventController(controller) - } - - override fun onMarkerRenderingReady() { - mapController.sendInitialCameraUpdate() - } - }, - ) - - controllerRef.value = mapController - mapController.setMapClickListener(onMapClick) - mapController.setMapLongClickListener(onMapLongClick) - mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - state.setController(mapController) - if (state.mapDesignType.getValue() != createdDesign.value) mapController.setMapDesignType(state.mapDesignType) - // 他プロバイダの *MapView と同じく、コントローラ生成直後に適用する。 - cameraRestriction?.let { mapController.setCameraRestriction(it) } - - mapController.setCameraMoveStartListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveStart?.invoke(it) - } - mapController.setCameraMoveListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMove?.invoke(it) - } - mapController.setCameraMoveEndListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveEnd?.invoke(it) - } - - val initialCameraPosition = state.cameraPosition - val generation = controllerGeneration.incrementAndGet() - holder.mapView.post { - if (controllerGeneration.get() != generation) return@post - mapController.moveCamera(MapCameraPosition.from(initialCameraPosition)) - mapController.sendInitialCameraUpdate() - // viewpointChanged の初回発火に頼らず、レイアウト確定のこの時点で - // 「準備完了」を通知する(取り逃すとオーバーレイが一切描画されない)。 - mapController.markMapInitialized() - } - } - }, - sdkInitialize = { - sdkInitialize?.invoke(context) ?: defaultArcGISInitialize(context) - }, - onMapLoaded = onMapLoaded, - customDisposableEffect = { _, holderRef -> - DisposableEffect(state.id) { - onDispose { - controllerGeneration.incrementAndGet() - controllerRef.value?.apply { - setCameraMoveStartListener(null) - setCameraMoveListener(null) - setCameraMoveEndListener(null) - setMapClickListener(null) - setMapLongClickListener(null) - } - controllerRef.value = null - state.clearController() - holderRef.value?.mapView?.apply { - onPause(owner) - onStop(owner) - onDestroy(owner) - } - } - } - }, - content = content, - ) -} +package com.mapconductor.arcgis + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.arcgismaps.LoadStatus +import com.arcgismaps.geometry.SpatialReference +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.Basemap +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.arcgismaps.mapping.view.GraphicsRenderingMode +import com.arcgismaps.mapping.view.MapView +import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.core.OnCameraMoveHandler +import com.mapconductor.core.OnMapEventHandler +import com.mapconductor.core.OnMapLoadedHandler +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapViewStyle +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +import com.mapconductor.core.marker.MarkerRenderingSupport +import com.mapconductor.core.marker.MarkerRenderingSupportKey +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.StrategyMarkerController +import java.util.concurrent.atomic.AtomicLong +import android.content.Context +import android.util.Log +import android.widget.FrameLayout +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine + +@Composable +fun ArcGISMapView2D( + state: ArcGISMapViewState, + modifier: Modifier = Modifier, + markerTiling: MarkerTilingOptions? = null, + cameraRestriction: CameraRestriction? = null, + sdkInitialize: (suspend (Context) -> Boolean)? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onCameraMoveStart: OnCameraMoveHandler? = null, + onCameraMove: OnCameraMoveHandler? = null, + onCameraMoveEnd: OnCameraMoveHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMapLongClick: OnMapEventHandler? = 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 ArcGISMapViewScope.() -> Unit)? = null, +) { + val scope = remember { ArcGISMapViewScope() } + val context = LocalContext.current + val registry = remember { scope.buildRegistry() } + val owner = LocalLifecycleOwner.current + // The design the map was built with; a design set on the state before the + // controller existed is applied once it does. + val createdDesign = remember { Ref() } + val cameraState = remember { mutableStateOf(state.cameraPosition) } + val controllerRef = remember { Ref() } + val controllerGeneration = remember { AtomicLong(0L) } + + MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = { + val mapView = MapView(context) + val wrapView = + WrapMapView(context).apply { + addView(mapView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + } + wrapView.arcGISMapView = mapView + mapView.onCreate(owner) + mapView.onResume(owner) + wrapView + }, + scope = scope, + registry = registry, + holderProvider = { wrapView -> + // The map is told its spatial reference up front instead of + // learning it from the basemap: a basemap style lives on + // arcgis.com, and a map that waited for it to find out where it + // is can draw nothing while the network is away. Told, it loads + // at once with no basemap, and with one it keeps drawing its own + // layers (a raster layer served on the device, say) when the + // basemap is out of reach. + val design = state.mapDesignType + createdDesign.value = design.getValue() + val map = ArcGISMap(SpatialReference.webMercator()) + ArcGISDesign.toBasemapStyleOrNull(design)?.let { map.setBasemap(Basemap(it)) } + wrapView.arcGISMapView.map = map + var retried = false + + val coroutine = CoroutineScope(Dispatchers.Default) + suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { coroutine.cancel() } + coroutine.launch { + map.loadStatus.collect { + when (it) { + is LoadStatus.Loaded -> { + cont.resume( + ArcGISMapView2DHolder( + mapView = wrapView, + map = wrapView.arcGISMapView, + ), + ) { _, _, _ -> } + } + is LoadStatus.FailedToLoad -> { + Log.w("ArcGISMapView2D", "map failed to load: ${it.error.message}") + // The basemap was taken away while it was being + // fetched (the app went basemap-less, say for an + // offline raster): without one the map needs no + // network, so it gets one more go. + if (map.basemap.value == null && !retried) { + retried = true + map.retryLoad() + return@collect + } + if (cont.isActive) { + cont.resume( + ArcGISMapView2DHolder( + mapView = wrapView, + map = wrapView.arcGISMapView, + ), + ) { _, _, _ -> } + } + } + else -> { + // Do nothing here + } + } + } + } + } + }, + controllerProvider = { holder -> + val markerLayer: GraphicsOverlay = + GraphicsOverlay().apply { + renderingMode = GraphicsRenderingMode.Dynamic + } + val markerController = + getMarkerController( + holder = holder, + markerLayer = markerLayer, + markerTiling = markerTiling ?: MarkerTilingOptions.Default, + ) + val polylineController = getPolylineController(holder, useScenePlacement = false) + val rasterLayerController = getRasterLayerController(holder) + val polygonController = getPolygonController(holder, useScenePlacement = false) + val circleController = getCircleController(holder, useScenePlacement = false) + val groundImageController = getGroundImageController(holder) + + ArcGISMapView2DController( + holder = holder, + markerController = markerController, + polylineController = polylineController, + polygonController = polygonController, + circleController = circleController, + groundImageController = groundImageController, + rasterLayerController = rasterLayerController, + ).also { mapController -> + state.serviceRegistry.put( + MarkerRenderingSupportKey, + object : MarkerRenderingSupport { + override fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() + + override fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = + mapController + .createMarkerEventController(controller) + + override fun registerMarkerEventController( + controller: MarkerEventControllerInterface, + ) { + mapController.registerMarkerEventController(controller) + } + + override fun onMarkerRenderingReady() { + mapController.sendInitialCameraUpdate() + } + }, + ) + + controllerRef.value = mapController + mapController.setMapClickListener(onMapClick) + mapController.setMapLongClickListener(onMapLongClick) + mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + state.setController(mapController) + if (state.mapDesignType.getValue() != + createdDesign.value + ) { + mapController.setMapDesignType(state.mapDesignType) + } + // 他プロバイダの *MapView と同じく、コントローラ生成直後に適用する。 + cameraRestriction?.let { mapController.setCameraRestriction(it) } + + mapController.setCameraMoveStartListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveStart?.invoke(it) + } + mapController.setCameraMoveListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMove?.invoke(it) + } + mapController.setCameraMoveEndListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveEnd?.invoke(it) + } + + val initialCameraPosition = state.cameraPosition + val generation = controllerGeneration.incrementAndGet() + holder.mapView.post { + if (controllerGeneration.get() != generation) return@post + mapController.moveCamera(MapCameraPosition.from(initialCameraPosition)) + mapController.sendInitialCameraUpdate() + // viewpointChanged の初回発火に頼らず、レイアウト確定のこの時点で + // 「準備完了」を通知する(取り逃すとオーバーレイが一切描画されない)。 + mapController.markMapInitialized() + } + } + }, + sdkInitialize = { + sdkInitialize?.invoke(context) ?: defaultArcGISInitialize(context) + }, + onMapLoaded = onMapLoaded, + customDisposableEffect = { _, holderRef -> + DisposableEffect(state.id) { + onDispose { + controllerGeneration.incrementAndGet() + controllerRef.value?.apply { + setCameraMoveStartListener(null) + setCameraMoveListener(null) + setCameraMoveEndListener(null) + setMapClickListener(null) + setMapLongClickListener(null) + } + controllerRef.value = null + state.clearController() + holderRef.value?.mapView?.apply { + onPause(owner) + onStop(owner) + onDestroy(owner) + } + } + } + }, + style = style, + onStyleDiagnostics = onStyleDiagnostics, + content = content, + ) +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt index 091c1a8..dc27259 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt @@ -1,421 +1,421 @@ -package com.mapconductor.arcgis - -import android.util.Log -import com.arcgismaps.LoadStatus -import com.arcgismaps.geometry.SpatialReference -import com.arcgismaps.mapping.Basemap -import com.arcgismaps.mapping.Viewpoint -import com.arcgismaps.mapping.view.GraphicsOverlay -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController -import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController -import com.mapconductor.arcgis.marker.ArcGISMarkerController -import com.mapconductor.arcgis.marker.ArcGISMarkerEventController -import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController -import com.mapconductor.arcgis.raster.ArcGISRasterLayerController -import com.mapconductor.core.circle.CircleState -import com.mapconductor.core.circle.OnCircleEventHandler -import com.mapconductor.core.controller.BaseMapViewController -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.features.GeoRectBounds -import com.mapconductor.core.groundimage.GroundImageState -import com.mapconductor.core.groundimage.OnGroundImageEventHandler -import com.mapconductor.core.map.CameraBearing -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapGesture -import com.mapconductor.core.map.MapUISettings -import com.mapconductor.core.map.MapUISettingsDiagnostics -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerState -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.core.polyline.PolylineState -import com.mapconductor.core.projection.Earth -import com.mapconductor.core.raster.RasterLayerState -import kotlin.math.ln -import kotlin.math.pow -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -class ArcGISMapView2DController( - override val holder: ArcGISMapView2DHolder, - internal val markerController: ArcGISMarkerController, - internal val polylineController: ArcGISPolylineOverlayController, - internal val polygonController: ArcGISPolygonOverlayController, - internal val circleController: ArcGISCircleOverlayController, - internal val groundImageController: ArcGISGroundImageController, - internal val rasterLayerController: ArcGISRasterLayerController, - override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), - override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : BaseMapViewController(), - ArcGISMapViewControllerInterface { - internal val markerEventControllers = mutableListOf() - internal var activeDragController: ArcGISMarkerEventController? = 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 cameraMoveEndJob: Job? = null - - // 直近に要求した論理カメラ位置。2D はカメラピッチを持てず tilt を擬似表現しているため、 - // カメラ状態の読み戻し時に元の tilt・位置・ズームを復元するヒントとして保持する - // (android-for-tomtom / ios-for-arcgis と同方針)。 - internal var lastLogicalCameraPosition: MapCameraPosition? = null - internal val cameraMoveEndDebounceMs = 180L - internal var mapDesignType: ArcGISDesignTypeInterface = ArcGISDesign.Streets - internal var mapDesignTypeChangeListener: ArcGISDesignTypeChangeHandler? = null - - init { - holder.map.graphicsOverlays.clear() - holder.map.graphicsOverlays.add(circleController.renderer.circleLayer) - holder.map.graphicsOverlays.add(polygonController.renderer.polygonLayer) - holder.map.graphicsOverlays.add(polylineController.renderer.polylineLayer) - holder.map.graphicsOverlays.add((markerController.renderer as ArcGISMarkerRenderer).markerLayer) - setupListeners() - registerOverlayController(markerController) - registerOverlayController(polygonController) - registerOverlayController(polylineController) - registerOverlayController(circleController) - registerOverlayController(groundImageController) - registerOverlayController(rasterLayerController) - registerMarkerEventController(ArcGISMarkerEventController(markerController)) - - markerController.setRasterLayerCallback { state -> - if (state != null) { - rasterLayerController.upsert(state) - } else { - rasterLayerController.rasterLayerManager - .allEntities() - .filter { it.state.id.startsWith("marker-tile-") } - .forEach { entity -> rasterLayerController.removeById(entity.state.id) } - } - } - } - - 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) - - /** - * マーカーのヒットテスト。クリックカスケードの先頭。 - * - * ArcGIS は地図タップの座標からそのまま引けるので、コアの - * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 - * 長押し(ドラッグ開始)だけは identifyGraphicsOverlay を使うので Gestures 側に残る。 - */ - override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = - markerEventControllers.dispatchGeoMarkerClick(position) - - // 拡張ファイル(Camera / Gestures)からは基底クラスの protected へ触れないため、 - // ここで internal の入口を用意しておく。 - internal fun cameraMoveStartHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveStartCallback - - internal fun cameraMoveHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveCallback - - internal fun needsCameraMoveEndWork(): Boolean = cameraMoveEndCallback != null || hasCameraRestriction() - - internal fun emitCameraMoveEnd(position: MapCameraPosition) { - cameraMoveEndCallback?.invoke(position) - } - - internal fun emitMapInitialized() { - notifyMapInitialized() - } - - internal suspend fun emitCameraPosition(position: MapCameraPosition) { - notifyMapCameraPosition(position) - } - - internal fun correctForCameraRestriction(current: MapCameraPosition): MapCameraPosition? = - cameraRestrictionCorrection(current) - - fun setupListeners() { - mainCoroutine.launch { holder.map.onSingleTapConfirmed.collect { onMapTap(it) } } - defaultCoroutine.launch { holder.map.viewpointChanged.collect { onViewpointChange() } } - defaultCoroutine.launch { holder.map.onInteractiveZooming.collect { invokeCameraMoveCallback() } } - defaultCoroutine.launch { holder.map.onRotate.collect { invokeCameraMoveCallback() } } - mainCoroutine.launch { holder.map.onLongPress.collect { onMapLongPress(it) } } - mainCoroutine.launch { holder.map.onUp.collect { onMapUp(it) } } - mainCoroutine.launch { holder.map.onPan.collect { onMapPan(it) } } - } - - override fun hasMarker(state: MarkerState): Boolean = markerController.markerManager.hasEntity(state.id) - - override fun hasPolyline(state: PolylineState): Boolean = polylineController.polylineManager.hasEntity(state.id) - - override fun hasPolygon(state: PolygonState): Boolean = polygonController.polygonManager.hasEntity(state.id) - - override fun hasCircle(state: CircleState): Boolean = circleController.circleManager.hasEntity(state.id) - - override fun hasGroundImage(state: GroundImageState): Boolean = - groundImageController.groundImageManager.hasEntity(state.id) - - override fun hasRasterLayer(state: RasterLayerState): Boolean = - rasterLayerController.rasterLayerManager.hasEntity(state.id) - - /** - * カメラの可動範囲を制限する。 - * - * ズームは ArcGIS のネイティブなスケール制限([com.arcgismaps.mapping.ArcGISMap.minScale] / - * [com.arcgismaps.mapping.ArcGISMap.maxScale])へ変換して適用する。ArcGIS の縮尺は分母(1:N)で - * 表され、`minScale` が「最も引いた側」= N が大きい方、`maxScale` が「最も寄せた側」= N が - * 小さい方に対応するため、統一ズームとは大小が逆になる(`minZoom → minScale` / - * `maxZoom → maxScale`)。 - * - * パン範囲(矩形)は ArcGIS に中心基準の制限 API が無いため、これまでどおりカメラ停止時の - * クランプで制限する([invokeCameraMoveEndCallback])。 - * - * 縮尺は投影座標系上の公称縮尺で緯度に依存しないため([zoomToScale])、`minScale` / - * `maxScale` のような緯度に依らない定数へそのまま変換できる。 - */ - override fun setCameraRestriction(restriction: CameraRestriction?) { - super.setCameraRestriction(restriction) - // ビュー破棄後はネイティブアクセサが例外を投げるため runCatching で保護する - // (このホルダの他アクセサと同じ扱い)。 - runCatching { - holder.map.map?.let { arcGISMap -> - arcGISMap.minScale = restriction?.minZoom?.let { zoomToScale(it) } - arcGISMap.maxScale = restriction?.maxZoom?.let { zoomToScale(it) } - } - } - } - - internal var appliedUISettings: MapUISettings = MapUISettings.Default - - /** - * 3D([ArcGISMapViewController])と同じ実装。2D の `MapView` も同じ - * `interactionOptions` を持つため、有効/無効の対応は完全に一致する。 - */ - override fun applyUISettings(settings: MapUISettings) { - appliedUISettings = settings - MapUISettingsDiagnostics.warnIfRequested( - settings.tiltGesture, - gesture = MapGesture.Tilt, - provider = "ArcGIS", - reason = "InteractionOptions has no separate tilt toggle", - ) - // ビュー破棄後はネイティブアクセサが例外を投げるため runCatching で保護する。 - runCatching { - with(holder.map) { - interactionOptions.isPanEnabled = settings.scrollGesture - interactionOptions.isRotateEnabled = settings.rotateGesture - interactionOptions.isZoomEnabled = settings.zoomGesture - } - } - } - - override suspend fun clearOverlays() { - markerController.clear() - groundImageController.clear() - polylineController.clear() - polygonController.clear() - circleController.clear() - rasterLayerController.clear() - } - - @Deprecated("Use CircleState.onClick instead.") - override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { - circleController.clickListener = listener - } - - @Deprecated("Use GroundImageState.onClick instead.") - override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { - groundImageController.clickListener = listener - } - - /** - * 2D の MapView はカメラピッチを持てないため、tilt < 0(上向き)は中心の前進と - * ズーム補正で近似する([ArcGIS2DTiltEmulation])。tilt >= 0 は他プロバイダと同じく - * 指定位置がそのまま画面中心に来る。 - */ - internal fun toViewpoint(position: MapCameraPosition): Viewpoint { - val (center, zoom) = ArcGIS2DTiltEmulation.shiftedCamera(position) - val point = GeoPoint.from(center).toPoint(SpatialReference.wgs84()) - return Viewpoint( - center = point, - scale = zoomToScale(zoom), - // ArcGIS Maps SDK for Kotlin の Viewpoint.rotation は反時計回り、つまり - // カメラの heading と同じ向きである(**JS の MapView.rotation とは逆**。 - // @arcgis/core の型定義は "due north is rotated 90 degrees, pointing to the - // right side of the view" と書いており、そちらは rotation 系)。 - // 実機(TB520FU / Tilt ページ)で MapLibre と並べて確認した結果がこれ。 - rotation = CameraBearing.toNativeHeading(position.bearing), - ) - } - - @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 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 PolylineState.onClick instead.") - override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { - polylineController.clickListener = listener - } - - @Deprecated("Use PolygonState.onClick instead.") - override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { - polygonController.clickListener = listener - } - - override fun setMapDesignType(value: ArcGISDesignTypeInterface) { - val map = holder.map.map ?: return - map.setBasemap(ArcGISDesign.toBasemap(value)) - // A map that failed to load -- its basemap was out of reach -- gets - // another go with the new basemap, or with none, which needs no - // network at all. - if (map.loadStatus.value is LoadStatus.FailedToLoad) { - defaultCoroutine.launch { - val result = map.retryLoad() - Log.d("ArcGISMapView2D", "map reloaded with basemap=${value.getValue()}: ${result.isSuccess} ${result.exceptionOrNull()?.message ?: ""}") - } - } - } - - override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { - mapDesignTypeChangeListener = listener - listener(mapDesignType) - } - - /** - * 「マップの準備ができた」ことをコンポーズ側へ通知する。 - * - * これまで ArcGIS は基底の [notifyMapInitialized](sticky。リスナー未登録でも記録して後で - * 配送する)を使わず、[onViewpointChange] で `mapInitializedCallback` を直接呼んでいた。 - * `MapViewBase` は `InitState.MapLoaded` になるまで `CollectAndRenderOverlays` を - * コンポーズしないため、この 1 回きりのイベントを取り逃すとマーカー・ポリゴン・InfoBubble が - * 一切描画されない。`viewpointChanged` は replay を持たないホットフローで、購読は - * `setupListeners()` がコルーチンで非同期に始めるので、初期ビューポート確定がその購読より - * 早いと通知が失われる。2D は初期ビューポートが即座に確定して以後動かないため、 - * 起動のたびにマーカーが出たり出なかったりする形で表面化した。 - * - * そこでレイアウト確定後に呼び出し側から明示的に通知する。基底の実装が sticky かつ - * 一度しか配送しないので、[onViewpointChange] 側と重なっても二重通知にはならない。 - * Compose の状態を書き換えるためメインディスパッチャで呼ぶ。 - */ - fun markMapInitialized() { - mainCoroutine.launch { notifyMapInitialized() } - } - - fun sendInitialCameraUpdate() { - defaultCoroutine.launch { - if (holder.map.width <= 0 || holder.map.height <= 0) return@launch - getMapCameraPosition()?.let { notifyMapCameraPosition(it) } - } - } - - internal fun registerMarkerEventController(controller: ArcGISMarkerEventController) { - 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) - } - - fun createMarkerRenderer(): MarkerOverlayRendererInterface { - val markerLayer = GraphicsOverlay() - registerMarkerOverlayLayer(markerLayer) - return ArcGISMarkerRenderer( - markerLayer = markerLayer, - holder = holder, - ) - } - - fun createMarkerEventController( - controller: StrategyMarkerController, - ): MarkerEventControllerInterface = ArcGISMarkerEventController(controller) - - fun registerMarkerEventController(controller: MarkerEventControllerInterface) { - val typed = controller as? ArcGISMarkerEventController ?: return - registerMarkerEventController(typed) - } - - internal fun registerMarkerOverlayLayer(layer: GraphicsOverlay) { - if (holder.map.graphicsOverlays.contains(layer)) return - holder.map.graphicsOverlays.add(layer) - } - - /** - * 統一ズーム(Google 準拠)→ ArcGIS の縮尺分母。 - * - * ArcGIS が扱う縮尺は Web メルカトルの **投影座標系上** の縮尺(公称縮尺)で、緯度による - * 補正は含まない。Google のズームも同じく投影座標系(256px タイルで世界一周)で定義されて - * いるため、両者は緯度に依存しない 1 対 1 の対応になる。 - * - * 以前はここに `cos(latitude)` を掛けて「地表の実距離」に直していたため、高緯度ほど縮尺が - * 小さく(=寄りすぎに)なっていた。緯度 65 度で Google Maps 比 2.4 倍ほど拡大されており、 - * 同じ `MapCameraPosition` を渡しても表示範囲が一致しなかった。 - */ - private fun zoomToScale(zoom: Double): Double { - val resolution = WEB_MERCATOR_CIRCUMFERENCE_METERS / (TILE_SIZE * 2.0.pow(zoom)) - return resolution * PIXELS_PER_METER_AT_96_DPI - } - - /** [zoomToScale] の逆変換。 */ - internal fun scaleToZoom(scale: Double): Double { - val resolution = scale / PIXELS_PER_METER_AT_96_DPI - return ln(WEB_MERCATOR_CIRCUMFERENCE_METERS / (TILE_SIZE * resolution)) / ln(2.0) - } - - companion object { - private const val WEB_MERCATOR_CIRCUMFERENCE_METERS = Earth.CIRCUMFERENCE_METERS - private const val TILE_SIZE = 256.0 - - /** 96 DPI(ESRI が縮尺計算に用いる標準値)における 1 メートルあたりのピクセル数。 */ - private const val PIXELS_PER_METER_AT_96_DPI = 96.0 / 0.0254 - } -} +package com.mapconductor.arcgis + +import com.arcgismaps.LoadStatus +import com.arcgismaps.geometry.SpatialReference +import com.arcgismaps.mapping.Viewpoint +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController +import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController +import com.mapconductor.arcgis.marker.ArcGISMarkerController +import com.mapconductor.arcgis.marker.ArcGISMarkerEventController +import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController +import com.mapconductor.arcgis.raster.ArcGISRasterLayerController +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.circle.OnCircleEventHandler +import com.mapconductor.core.controller.BaseMapViewController +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.groundimage.OnGroundImageEventHandler +import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapGesture +import com.mapconductor.core.map.MapUISettings +import com.mapconductor.core.map.MapUISettingsDiagnostics +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerState +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.core.polyline.PolylineState +import com.mapconductor.core.projection.Earth +import com.mapconductor.core.raster.RasterLayerState +import kotlin.math.ln +import kotlin.math.pow +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class ArcGISMapView2DController( + override val holder: ArcGISMapView2DHolder, + internal val markerController: ArcGISMarkerController, + internal val polylineController: ArcGISPolylineOverlayController, + internal val polygonController: ArcGISPolygonOverlayController, + internal val circleController: ArcGISCircleOverlayController, + internal val groundImageController: ArcGISGroundImageController, + internal val rasterLayerController: ArcGISRasterLayerController, + override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), + override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : BaseMapViewController(), + ArcGISMapViewControllerInterface { + internal val markerEventControllers = mutableListOf() + internal var activeDragController: ArcGISMarkerEventController? = 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 cameraMoveEndJob: Job? = null + + // 直近に要求した論理カメラ位置。2D はカメラピッチを持てず tilt を擬似表現しているため、 + // カメラ状態の読み戻し時に元の tilt・位置・ズームを復元するヒントとして保持する + // (android-for-tomtom / ios-for-arcgis と同方針)。 + internal var lastLogicalCameraPosition: MapCameraPosition? = null + internal val cameraMoveEndDebounceMs = 180L + internal var mapDesignType: ArcGISDesignTypeInterface = ArcGISDesign.Streets + internal var mapDesignTypeChangeListener: ArcGISDesignTypeChangeHandler? = null + + init { + holder.map.graphicsOverlays.clear() + holder.map.graphicsOverlays.add(circleController.renderer.circleLayer) + holder.map.graphicsOverlays.add(polygonController.renderer.polygonLayer) + holder.map.graphicsOverlays.add(polylineController.renderer.polylineLayer) + holder.map.graphicsOverlays.add((markerController.renderer as ArcGISMarkerRenderer).markerLayer) + setupListeners() + registerOverlayController(markerController) + registerOverlayController(polygonController) + registerOverlayController(polylineController) + registerOverlayController(circleController) + registerOverlayController(groundImageController) + registerOverlayController(rasterLayerController) + registerMarkerEventController(ArcGISMarkerEventController(markerController)) + + markerController.setRasterLayerCallback { state -> + if (state != null) { + rasterLayerController.upsert(state) + } else { + rasterLayerController.rasterLayerManager + .allEntities() + .filter { it.state.id.startsWith("marker-tile-") } + .forEach { entity -> rasterLayerController.removeById(entity.state.id) } + } + } + } + + 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) + + /** + * マーカーのヒットテスト。クリックカスケードの先頭。 + * + * ArcGIS は地図タップの座標からそのまま引けるので、コアの + * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 + * 長押し(ドラッグ開始)だけは identifyGraphicsOverlay を使うので Gestures 側に残る。 + */ + override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = + markerEventControllers.dispatchGeoMarkerClick(position) + + // 拡張ファイル(Camera / Gestures)からは基底クラスの protected へ触れないため、 + // ここで internal の入口を用意しておく。 + internal fun cameraMoveStartHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveStartCallback + + internal fun cameraMoveHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveCallback + + internal fun needsCameraMoveEndWork(): Boolean = cameraMoveEndCallback != null || hasCameraRestriction() + + internal fun emitCameraMoveEnd(position: MapCameraPosition) { + cameraMoveEndCallback?.invoke(position) + } + + internal fun emitMapInitialized() { + notifyMapInitialized() + } + + internal suspend fun emitCameraPosition(position: MapCameraPosition) { + notifyMapCameraPosition(position) + } + + internal fun correctForCameraRestriction(current: MapCameraPosition): MapCameraPosition? = + cameraRestrictionCorrection(current) + + fun setupListeners() { + mainCoroutine.launch { holder.map.onSingleTapConfirmed.collect { onMapTap(it) } } + defaultCoroutine.launch { holder.map.viewpointChanged.collect { onViewpointChange() } } + defaultCoroutine.launch { holder.map.onInteractiveZooming.collect { invokeCameraMoveCallback() } } + defaultCoroutine.launch { holder.map.onRotate.collect { invokeCameraMoveCallback() } } + mainCoroutine.launch { holder.map.onLongPress.collect { onMapLongPress(it) } } + mainCoroutine.launch { holder.map.onUp.collect { onMapUp(it) } } + mainCoroutine.launch { holder.map.onPan.collect { onMapPan(it) } } + } + + override fun hasMarker(state: MarkerState): Boolean = markerController.markerManager.hasEntity(state.id) + + override fun hasPolyline(state: PolylineState): Boolean = polylineController.polylineManager.hasEntity(state.id) + + override fun hasPolygon(state: PolygonState): Boolean = polygonController.polygonManager.hasEntity(state.id) + + override fun hasCircle(state: CircleState): Boolean = circleController.circleManager.hasEntity(state.id) + + override fun hasGroundImage(state: GroundImageState): Boolean = + groundImageController.groundImageManager.hasEntity(state.id) + + override fun hasRasterLayer(state: RasterLayerState): Boolean = + rasterLayerController.rasterLayerManager.hasEntity(state.id) + + /** + * カメラの可動範囲を制限する。 + * + * ズームは ArcGIS のネイティブなスケール制限([com.arcgismaps.mapping.ArcGISMap.minScale] / + * [com.arcgismaps.mapping.ArcGISMap.maxScale])へ変換して適用する。ArcGIS の縮尺は分母(1:N)で + * 表され、`minScale` が「最も引いた側」= N が大きい方、`maxScale` が「最も寄せた側」= N が + * 小さい方に対応するため、統一ズームとは大小が逆になる(`minZoom → minScale` / + * `maxZoom → maxScale`)。 + * + * パン範囲(矩形)は ArcGIS に中心基準の制限 API が無いため、これまでどおりカメラ停止時の + * クランプで制限する([invokeCameraMoveEndCallback])。 + * + * 縮尺は投影座標系上の公称縮尺で緯度に依存しないため([zoomToScale])、`minScale` / + * `maxScale` のような緯度に依らない定数へそのまま変換できる。 + */ + override fun setCameraRestriction(restriction: CameraRestriction?) { + super.setCameraRestriction(restriction) + // ビュー破棄後はネイティブアクセサが例外を投げるため runCatching で保護する + // (このホルダの他アクセサと同じ扱い)。 + runCatching { + holder.map.map?.let { arcGISMap -> + arcGISMap.minScale = restriction?.minZoom?.let { zoomToScale(it) } + arcGISMap.maxScale = restriction?.maxZoom?.let { zoomToScale(it) } + } + } + } + + internal var appliedUISettings: MapUISettings = MapUISettings.Default + + /** + * 3D([ArcGISMapViewController])と同じ実装。2D の `MapView` も同じ + * `interactionOptions` を持つため、有効/無効の対応は完全に一致する。 + */ + override fun applyUISettings(settings: MapUISettings) { + appliedUISettings = settings + MapUISettingsDiagnostics.warnIfRequested( + settings.tiltGesture, + gesture = MapGesture.Tilt, + provider = "ArcGIS", + reason = "InteractionOptions has no separate tilt toggle", + ) + // ビュー破棄後はネイティブアクセサが例外を投げるため runCatching で保護する。 + runCatching { + with(holder.map) { + interactionOptions.isPanEnabled = settings.scrollGesture + interactionOptions.isRotateEnabled = settings.rotateGesture + interactionOptions.isZoomEnabled = settings.zoomGesture + } + } + } + + override suspend fun clearOverlays() { + markerController.clear() + groundImageController.clear() + polylineController.clear() + polygonController.clear() + circleController.clear() + rasterLayerController.clear() + } + + @Deprecated("Use CircleState.onClick instead.") + override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { + circleController.clickListener = listener + } + + @Deprecated("Use GroundImageState.onClick instead.") + override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { + groundImageController.clickListener = listener + } + + /** + * 2D の MapView はカメラピッチを持てないため、tilt < 0(上向き)は中心の前進と + * ズーム補正で近似する([ArcGIS2DTiltEmulation])。tilt >= 0 は他プロバイダと同じく + * 指定位置がそのまま画面中心に来る。 + */ + internal fun toViewpoint(position: MapCameraPosition): Viewpoint { + val (center, zoom) = ArcGIS2DTiltEmulation.shiftedCamera(position) + val point = GeoPoint.from(center).toPoint(SpatialReference.wgs84()) + return Viewpoint( + center = point, + scale = zoomToScale(zoom), + // ArcGIS Maps SDK for Kotlin の Viewpoint.rotation は反時計回り、つまり + // カメラの heading と同じ向きである(**JS の MapView.rotation とは逆**。 + // @arcgis/core の型定義は "due north is rotated 90 degrees, pointing to the + // right side of the view" と書いており、そちらは rotation 系)。 + // 実機(TB520FU / Tilt ページ)で MapLibre と並べて確認した結果がこれ。 + rotation = CameraBearing.toNativeHeading(position.bearing), + ) + } + + @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 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 PolylineState.onClick instead.") + override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { + polylineController.clickListener = listener + } + + @Deprecated("Use PolygonState.onClick instead.") + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { + polygonController.clickListener = listener + } + + override fun setMapDesignType(value: ArcGISDesignTypeInterface) { + val map = holder.map.map ?: return + map.setBasemap(ArcGISDesign.toBasemap(value)) + // A map that failed to load -- its basemap was out of reach -- gets + // another go with the new basemap, or with none, which needs no + // network at all. + if (map.loadStatus.value is LoadStatus.FailedToLoad) { + defaultCoroutine.launch { + val result = map.retryLoad() + Log + .d("ArcGISMapView2D", "map reloaded with basemap=${value.getValue()}: ${result.isSuccess} ${result.exceptionOrNull()?.message ?: ""}") + } + } + } + + override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { + mapDesignTypeChangeListener = listener + listener(mapDesignType) + } + + /** + * 「マップの準備ができた」ことをコンポーズ側へ通知する。 + * + * これまで ArcGIS は基底の [notifyMapInitialized](sticky。リスナー未登録でも記録して後で + * 配送する)を使わず、[onViewpointChange] で `mapInitializedCallback` を直接呼んでいた。 + * `MapViewBase` は `InitState.MapLoaded` になるまで `CollectAndRenderOverlays` を + * コンポーズしないため、この 1 回きりのイベントを取り逃すとマーカー・ポリゴン・InfoBubble が + * 一切描画されない。`viewpointChanged` は replay を持たないホットフローで、購読は + * `setupListeners()` がコルーチンで非同期に始めるので、初期ビューポート確定がその購読より + * 早いと通知が失われる。2D は初期ビューポートが即座に確定して以後動かないため、 + * 起動のたびにマーカーが出たり出なかったりする形で表面化した。 + * + * そこでレイアウト確定後に呼び出し側から明示的に通知する。基底の実装が sticky かつ + * 一度しか配送しないので、[onViewpointChange] 側と重なっても二重通知にはならない。 + * Compose の状態を書き換えるためメインディスパッチャで呼ぶ。 + */ + fun markMapInitialized() { + mainCoroutine.launch { notifyMapInitialized() } + } + + fun sendInitialCameraUpdate() { + defaultCoroutine.launch { + if (holder.map.width <= 0 || holder.map.height <= 0) return@launch + getMapCameraPosition()?.let { notifyMapCameraPosition(it) } + } + } + + internal fun registerMarkerEventController(controller: ArcGISMarkerEventController) { + 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) + } + + fun createMarkerRenderer(): MarkerOverlayRendererInterface { + val markerLayer = GraphicsOverlay() + registerMarkerOverlayLayer(markerLayer) + return ArcGISMarkerRenderer( + markerLayer = markerLayer, + holder = holder, + ) + } + + fun createMarkerEventController( + controller: StrategyMarkerController, + ): MarkerEventControllerInterface = ArcGISMarkerEventController(controller) + + fun registerMarkerEventController(controller: MarkerEventControllerInterface) { + val typed = controller as? ArcGISMarkerEventController ?: return + registerMarkerEventController(typed) + } + + internal fun registerMarkerOverlayLayer(layer: GraphicsOverlay) { + if (holder.map.graphicsOverlays.contains(layer)) return + holder.map.graphicsOverlays.add(layer) + } + + /** + * 統一ズーム(Google 準拠)→ ArcGIS の縮尺分母。 + * + * ArcGIS が扱う縮尺は Web メルカトルの **投影座標系上** の縮尺(公称縮尺)で、緯度による + * 補正は含まない。Google のズームも同じく投影座標系(256px タイルで世界一周)で定義されて + * いるため、両者は緯度に依存しない 1 対 1 の対応になる。 + * + * 以前はここに `cos(latitude)` を掛けて「地表の実距離」に直していたため、高緯度ほど縮尺が + * 小さく(=寄りすぎに)なっていた。緯度 65 度で Google Maps 比 2.4 倍ほど拡大されており、 + * 同じ `MapCameraPosition` を渡しても表示範囲が一致しなかった。 + */ + private fun zoomToScale(zoom: Double): Double { + val resolution = WEB_MERCATOR_CIRCUMFERENCE_METERS / (TILE_SIZE * 2.0.pow(zoom)) + return resolution * PIXELS_PER_METER_AT_96_DPI + } + + /** [zoomToScale] の逆変換。 */ + internal fun scaleToZoom(scale: Double): Double { + val resolution = scale / PIXELS_PER_METER_AT_96_DPI + return ln(WEB_MERCATOR_CIRCUMFERENCE_METERS / (TILE_SIZE * resolution)) / ln(2.0) + } + + companion object { + private const val WEB_MERCATOR_CIRCUMFERENCE_METERS = Earth.CIRCUMFERENCE_METERS + private const val TILE_SIZE = 256.0 + + /** 96 DPI(ESRI が縮尺計算に用いる標準値)における 1 メートルあたりのピクセル数。 */ + private const val PIXELS_PER_METER_AT_96_DPI = 96.0 / 0.0254 + } +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt index 9b42a8f..93eab0d 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt @@ -1,345 +1,344 @@ -package com.mapconductor.arcgis - -import com.arcgismaps.LoadStatus -import com.arcgismaps.mapping.Basemap -import com.arcgismaps.mapping.view.GraphicsOverlay -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController -import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController -import com.mapconductor.arcgis.marker.ArcGISMarkerController -import com.mapconductor.arcgis.marker.ArcGISMarkerEventController -import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController -import com.mapconductor.arcgis.raster.ArcGISRasterLayerController -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.GroundImageState -import com.mapconductor.core.groundimage.OnGroundImageEventHandler -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapGesture -import com.mapconductor.core.map.MapUISettings -import com.mapconductor.core.map.MapUISettingsDiagnostics -import com.mapconductor.core.marker.MarkerAnimationOverlayHost -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -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.polyline.OnPolylineEventHandler -import com.mapconductor.core.polyline.PolylineState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -class ArcGISMapViewController( - override val holder: ArcGISMapViewHolder, - internal val markerController: ArcGISMarkerController, - internal val polylineController: ArcGISPolylineOverlayController, - internal val polygonController: ArcGISPolygonOverlayController, - internal val circleController: ArcGISCircleOverlayController, - internal val groundImageController: ArcGISGroundImageController, - internal val rasterLayerController: ArcGISRasterLayerController, - override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), - override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : BaseMapViewController(), - ArcGISMapViewControllerInterface { - internal val markerEventControllers = mutableListOf() - internal var activeDragController: ArcGISMarkerEventController? = 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 - - // ArcGIS updates the viewpoint asynchronously; firing "move end" immediately after setViewpointCamera() - // can read a stale camera and cause feedback loops in camera sync scenarios. - internal var cameraMoveEndJob: Job? = null - internal val cameraMoveEndDebounceMs = 180L - - init { - holder.map.graphicsOverlays.clear() - holder.map.graphicsOverlays.add(circleController.renderer.circleLayer) - holder.map.graphicsOverlays.add(polygonController.renderer.polygonLayer) - holder.map.graphicsOverlays.add(polylineController.renderer.polylineLayer) - holder.map.graphicsOverlays.add((markerController.renderer as ArcGISMarkerRenderer).markerLayer) - setupListeners() - registerOverlayController(markerController) - registerOverlayController(polygonController) - registerOverlayController(polylineController) - registerOverlayController(circleController) - registerOverlayController(groundImageController) - registerOverlayController(rasterLayerController) - registerMarkerEventController(ArcGISMarkerEventController(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) } - } - } - } - - 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) - - /** - * マーカーのヒットテスト。クリックカスケードの先頭。 - * - * ArcGIS は地図タップの座標からそのまま引けるので、コアの - * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 - * 長押し(ドラッグ開始)だけは identifyGraphicsOverlay を使うので Gestures 側に残る。 - */ - override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = - markerEventControllers.dispatchGeoMarkerClick(position) - - // 拡張ファイル(Camera / Gestures)からは基底クラスの protected へ触れないため、 - // ここで internal の入口を用意しておく。 - internal fun cameraMoveStartHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveStartCallback - - internal fun cameraMoveHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveCallback - - internal fun needsCameraMoveEndWork(): Boolean = cameraMoveEndCallback != null || hasCameraRestriction() - - internal fun emitCameraMoveEnd(position: MapCameraPosition) { - cameraMoveEndCallback?.invoke(position) - } - - internal fun emitMapInitialized() { - notifyMapInitialized() - } - - internal suspend fun emitCameraPosition(position: MapCameraPosition) { - notifyMapCameraPosition(position) - } - - internal fun correctForCameraRestriction(current: MapCameraPosition): MapCameraPosition? = - cameraRestrictionCorrection(current) - - fun setupListeners() { - mainCoroutine.launch { - holder.map.onSingleTapConfirmed.collect { onMapTap(it) } - } - defaultCoroutine.launch { - holder.map.viewpointChanged.collect { onViewpointChange() } - } - defaultCoroutine.launch { - holder.map.onInteractiveZooming.collect { invokeCameraMoveCallback() } - } - defaultCoroutine.launch { - holder.map.onRotate.collect { invokeCameraMoveCallback() } - } - mainCoroutine.launch { - holder.map.onLongPress.collect { onMapLongPress(it) } - } - mainCoroutine.launch { - holder.map.onUp.collect { onMapUp(it) } - } - mainCoroutine.launch { - holder.map.onPan.collect { onMapPan(it) } - } - } - - override fun hasPolyline(state: PolylineState): Boolean = - this.polylineController.polylineManager - .hasEntity(state.id) - - override fun hasGroundImage(state: GroundImageState): Boolean = - this.groundImageController.groundImageManager - .hasEntity(state.id) - - internal var appliedUISettings: MapUISettings = MapUISettings.Default - - override fun applyUISettings(settings: MapUISettings) { - appliedUISettings = settings - MapUISettingsDiagnostics.warnIfRequested( - settings.tiltGesture, - gesture = MapGesture.Tilt, - provider = "ArcGIS", - reason = "InteractionOptions has no separate tilt toggle", - ) - with(holder.map) { - interactionOptions.isPanEnabled = settings.scrollGesture - interactionOptions.isRotateEnabled = settings.rotateGesture - interactionOptions.isZoomEnabled = settings.zoomGesture - } - } - - override suspend fun clearOverlays() { - markerController.clear() - groundImageController.clear() - polylineController.clear() - polygonController.clear() - circleController.clear() - rasterLayerController.clear() - } - - override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { - (markerController.renderer as ArcGISMarkerRenderer).animationOverlayHost = host - } - - @Deprecated("Use CircleState.onClick instead.") - override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { - this.circleController.clickListener = listener - } - - @Deprecated("Use GroundImageState.onClick instead.") - override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { - this.groundImageController.clickListener = listener - } - - @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 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 PolylineState.onClick instead.") - override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { - this.polylineController.clickListener = listener - } - - @Deprecated("Use PolygonState.onClick instead.") - override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { - this.polygonController.clickListener = listener - } - - internal var mapDesignType: ArcGISDesignTypeInterface = ArcGISDesign.Streets - internal var mapDesignTypeChangeListener: ArcGISDesignTypeChangeHandler? = null - - override fun setMapDesignType(value: ArcGISDesignTypeInterface) { - holder.map.scene?.let { scene -> - val baseMap = ArcGISDesign.toBasemap(value) - defaultCoroutine.launch { - scene.setBasemap(baseMap) - // A scene that failed to load -- its basemap was out of - // reach -- gets another go with the new basemap, or with - // none, which needs no network at all. - if (scene.loadStatus.value is LoadStatus.FailedToLoad) scene.retryLoad() - // Basemap changes can reset the viewpoint; mark the current request as pending so that - // the next viewpointChanged can restore the last requested camera if needed. -// pendingCameraRestoreRequest = cameraRequestGeneration.get() - } - } - } - - override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { - mapDesignTypeChangeListener = listener - listener(mapDesignType) - } - - /** - * 「マップの準備ができた」ことをコンポーズ側へ通知する。 - * - * これまで ArcGIS は基底の [notifyMapInitialized](sticky。リスナー未登録でも記録して後で - * 配送する)を使わず、[onViewpointChange] で `mapInitializedCallback` を直接呼んでいた。 - * `MapViewBase` は `InitState.MapLoaded` になるまで `CollectAndRenderOverlays` を - * コンポーズしないため、この 1 回きりのイベントを取り逃すとマーカー・ポリゴン・InfoBubble が - * 一切描画されない。`viewpointChanged` は replay を持たないホットフローで、購読は - * `setupListeners()` がコルーチンで非同期に始めるので、初期ビューポート確定がその購読より - * 早いと通知が失われる。2D は初期ビューポートが即座に確定して以後動かないため、 - * 起動のたびにマーカーが出たり出なかったりする形で表面化した。 - * - * そこでレイアウト確定後に呼び出し側から明示的に通知する。基底の実装が sticky かつ - * 一度しか配送しないので、[onViewpointChange] 側と重なっても二重通知にはならない。 - * Compose の状態を書き換えるためメインディスパッチャで呼ぶ。 - */ - fun markMapInitialized() { - mainCoroutine.launch { notifyMapInitialized() } - } - - // Trigger an initial camera update after the view and scene are ready - fun sendInitialCameraUpdate() { - defaultCoroutine.launch { - val mapWidth = holder.map.width - val mapHeight = holder.map.height - if (mapWidth <= 0 || mapHeight <= 0) return@launch - getMapCameraPosition()?.let { mapCameraPosition -> - notifyMapCameraPosition(mapCameraPosition) - } - } - } - - internal fun registerMarkerEventController(controller: ArcGISMarkerEventController) { - 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) - } - - fun createMarkerRenderer(): MarkerOverlayRendererInterface { - val markerLayer = - GraphicsOverlay() - registerMarkerOverlayLayer(markerLayer) - return ArcGISMarkerRenderer( - markerLayer = markerLayer, - holder = holder, - ) - } - - fun createMarkerEventController( - controller: StrategyMarkerController, - ): MarkerEventControllerInterface = ArcGISMarkerEventController(controller) - - fun registerMarkerEventController(controller: MarkerEventControllerInterface) { - val typed = controller as? ArcGISMarkerEventController ?: return - registerMarkerEventController(typed) - } - - internal fun registerMarkerOverlayLayer(layer: GraphicsOverlay) { - if (holder.map.graphicsOverlays.contains(layer)) return - holder.map.graphicsOverlays.add(layer) - } -} +package com.mapconductor.arcgis + +import com.arcgismaps.LoadStatus +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController +import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController +import com.mapconductor.arcgis.marker.ArcGISMarkerController +import com.mapconductor.arcgis.marker.ArcGISMarkerEventController +import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController +import com.mapconductor.arcgis.raster.ArcGISRasterLayerController +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.GroundImageState +import com.mapconductor.core.groundimage.OnGroundImageEventHandler +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapGesture +import com.mapconductor.core.map.MapUISettings +import com.mapconductor.core.map.MapUISettingsDiagnostics +import com.mapconductor.core.marker.MarkerAnimationOverlayHost +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +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.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.PolylineState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class ArcGISMapViewController( + override val holder: ArcGISMapViewHolder, + internal val markerController: ArcGISMarkerController, + internal val polylineController: ArcGISPolylineOverlayController, + internal val polygonController: ArcGISPolygonOverlayController, + internal val circleController: ArcGISCircleOverlayController, + internal val groundImageController: ArcGISGroundImageController, + internal val rasterLayerController: ArcGISRasterLayerController, + override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), + override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : BaseMapViewController(), + ArcGISMapViewControllerInterface { + internal val markerEventControllers = mutableListOf() + internal var activeDragController: ArcGISMarkerEventController? = 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 + + // ArcGIS updates the viewpoint asynchronously; firing "move end" immediately after setViewpointCamera() + // can read a stale camera and cause feedback loops in camera sync scenarios. + internal var cameraMoveEndJob: Job? = null + internal val cameraMoveEndDebounceMs = 180L + + init { + holder.map.graphicsOverlays.clear() + holder.map.graphicsOverlays.add(circleController.renderer.circleLayer) + holder.map.graphicsOverlays.add(polygonController.renderer.polygonLayer) + holder.map.graphicsOverlays.add(polylineController.renderer.polylineLayer) + holder.map.graphicsOverlays.add((markerController.renderer as ArcGISMarkerRenderer).markerLayer) + setupListeners() + registerOverlayController(markerController) + registerOverlayController(polygonController) + registerOverlayController(polylineController) + registerOverlayController(circleController) + registerOverlayController(groundImageController) + registerOverlayController(rasterLayerController) + registerMarkerEventController(ArcGISMarkerEventController(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) } + } + } + } + + 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) + + /** + * マーカーのヒットテスト。クリックカスケードの先頭。 + * + * ArcGIS は地図タップの座標からそのまま引けるので、コアの + * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 + * 長押し(ドラッグ開始)だけは identifyGraphicsOverlay を使うので Gestures 側に残る。 + */ + override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = + markerEventControllers.dispatchGeoMarkerClick(position) + + // 拡張ファイル(Camera / Gestures)からは基底クラスの protected へ触れないため、 + // ここで internal の入口を用意しておく。 + internal fun cameraMoveStartHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveStartCallback + + internal fun cameraMoveHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveCallback + + internal fun needsCameraMoveEndWork(): Boolean = cameraMoveEndCallback != null || hasCameraRestriction() + + internal fun emitCameraMoveEnd(position: MapCameraPosition) { + cameraMoveEndCallback?.invoke(position) + } + + internal fun emitMapInitialized() { + notifyMapInitialized() + } + + internal suspend fun emitCameraPosition(position: MapCameraPosition) { + notifyMapCameraPosition(position) + } + + internal fun correctForCameraRestriction(current: MapCameraPosition): MapCameraPosition? = + cameraRestrictionCorrection(current) + + fun setupListeners() { + mainCoroutine.launch { + holder.map.onSingleTapConfirmed.collect { onMapTap(it) } + } + defaultCoroutine.launch { + holder.map.viewpointChanged.collect { onViewpointChange() } + } + defaultCoroutine.launch { + holder.map.onInteractiveZooming.collect { invokeCameraMoveCallback() } + } + defaultCoroutine.launch { + holder.map.onRotate.collect { invokeCameraMoveCallback() } + } + mainCoroutine.launch { + holder.map.onLongPress.collect { onMapLongPress(it) } + } + mainCoroutine.launch { + holder.map.onUp.collect { onMapUp(it) } + } + mainCoroutine.launch { + holder.map.onPan.collect { onMapPan(it) } + } + } + + override fun hasPolyline(state: PolylineState): Boolean = + this.polylineController.polylineManager + .hasEntity(state.id) + + override fun hasGroundImage(state: GroundImageState): Boolean = + this.groundImageController.groundImageManager + .hasEntity(state.id) + + internal var appliedUISettings: MapUISettings = MapUISettings.Default + + override fun applyUISettings(settings: MapUISettings) { + appliedUISettings = settings + MapUISettingsDiagnostics.warnIfRequested( + settings.tiltGesture, + gesture = MapGesture.Tilt, + provider = "ArcGIS", + reason = "InteractionOptions has no separate tilt toggle", + ) + with(holder.map) { + interactionOptions.isPanEnabled = settings.scrollGesture + interactionOptions.isRotateEnabled = settings.rotateGesture + interactionOptions.isZoomEnabled = settings.zoomGesture + } + } + + override suspend fun clearOverlays() { + markerController.clear() + groundImageController.clear() + polylineController.clear() + polygonController.clear() + circleController.clear() + rasterLayerController.clear() + } + + override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { + (markerController.renderer as ArcGISMarkerRenderer).animationOverlayHost = host + } + + @Deprecated("Use CircleState.onClick instead.") + override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { + this.circleController.clickListener = listener + } + + @Deprecated("Use GroundImageState.onClick instead.") + override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { + this.groundImageController.clickListener = listener + } + + @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 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 PolylineState.onClick instead.") + override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { + this.polylineController.clickListener = listener + } + + @Deprecated("Use PolygonState.onClick instead.") + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { + this.polygonController.clickListener = listener + } + + internal var mapDesignType: ArcGISDesignTypeInterface = ArcGISDesign.Streets + internal var mapDesignTypeChangeListener: ArcGISDesignTypeChangeHandler? = null + + override fun setMapDesignType(value: ArcGISDesignTypeInterface) { + holder.map.scene?.let { scene -> + val baseMap = ArcGISDesign.toBasemap(value) + defaultCoroutine.launch { + scene.setBasemap(baseMap) + // A scene that failed to load -- its basemap was out of + // reach -- gets another go with the new basemap, or with + // none, which needs no network at all. + if (scene.loadStatus.value is LoadStatus.FailedToLoad) scene.retryLoad() + // Basemap changes can reset the viewpoint; mark the current request as pending so that + // the next viewpointChanged can restore the last requested camera if needed. +// pendingCameraRestoreRequest = cameraRequestGeneration.get() + } + } + } + + override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { + mapDesignTypeChangeListener = listener + listener(mapDesignType) + } + + /** + * 「マップの準備ができた」ことをコンポーズ側へ通知する。 + * + * これまで ArcGIS は基底の [notifyMapInitialized](sticky。リスナー未登録でも記録して後で + * 配送する)を使わず、[onViewpointChange] で `mapInitializedCallback` を直接呼んでいた。 + * `MapViewBase` は `InitState.MapLoaded` になるまで `CollectAndRenderOverlays` を + * コンポーズしないため、この 1 回きりのイベントを取り逃すとマーカー・ポリゴン・InfoBubble が + * 一切描画されない。`viewpointChanged` は replay を持たないホットフローで、購読は + * `setupListeners()` がコルーチンで非同期に始めるので、初期ビューポート確定がその購読より + * 早いと通知が失われる。2D は初期ビューポートが即座に確定して以後動かないため、 + * 起動のたびにマーカーが出たり出なかったりする形で表面化した。 + * + * そこでレイアウト確定後に呼び出し側から明示的に通知する。基底の実装が sticky かつ + * 一度しか配送しないので、[onViewpointChange] 側と重なっても二重通知にはならない。 + * Compose の状態を書き換えるためメインディスパッチャで呼ぶ。 + */ + fun markMapInitialized() { + mainCoroutine.launch { notifyMapInitialized() } + } + + // Trigger an initial camera update after the view and scene are ready + fun sendInitialCameraUpdate() { + defaultCoroutine.launch { + val mapWidth = holder.map.width + val mapHeight = holder.map.height + if (mapWidth <= 0 || mapHeight <= 0) return@launch + getMapCameraPosition()?.let { mapCameraPosition -> + notifyMapCameraPosition(mapCameraPosition) + } + } + } + + internal fun registerMarkerEventController(controller: ArcGISMarkerEventController) { + 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) + } + + fun createMarkerRenderer(): MarkerOverlayRendererInterface { + val markerLayer = + GraphicsOverlay() + registerMarkerOverlayLayer(markerLayer) + return ArcGISMarkerRenderer( + markerLayer = markerLayer, + holder = holder, + ) + } + + fun createMarkerEventController( + controller: StrategyMarkerController, + ): MarkerEventControllerInterface = ArcGISMarkerEventController(controller) + + fun registerMarkerEventController(controller: MarkerEventControllerInterface) { + val typed = controller as? ArcGISMarkerEventController ?: return + registerMarkerEventController(typed) + } + + internal fun registerMarkerOverlayLayer(layer: GraphicsOverlay) { + if (holder.map.graphicsOverlays.contains(layer)) return + holder.map.graphicsOverlays.add(layer) + } +} diff --git a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt index 27d9797..eae707a 100644 --- a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt +++ b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt @@ -1,33 +1,34 @@ -package com.mapconductor.arcgis.raster - -import com.arcgismaps.mapping.layers.Layer -import com.mapconductor.core.raster.RasterLayerController -import com.mapconductor.core.raster.RasterLayerEntity -import com.mapconductor.core.raster.RasterLayerManager -import com.mapconductor.core.raster.RasterLayerManagerInterface - -class ArcGISRasterLayerController( - rasterLayerManager: RasterLayerManagerInterface = RasterLayerManager(), - override val renderer: ArcGISRasterLayerOverlayRenderer, -) : RasterLayerController(rasterLayerManager, renderer) { - /** - * Builds the in-process layers again, so ArcGIS drops the tiles it holds - * for them. Wanted after the camera settles on a level that was answered - * transparent while it was an ancestor (see the renderer's gate). - */ - suspend fun rebuildLocalLayersIfNeeded() { - if (!renderer.localLayersNeedRebuild()) return - val entities = - rasterLayerManager.allEntities() - .filter { renderer.isLocalLayer(it.state) } - .sortedBy { it.state.zIndex } - for (entity in entities) { - // In place, over the old one; the renderer removes the old layer - // once the new one has had time to draw. - val layer = renderer.rebuildLayer(entity) ?: continue - rasterLayerManager.removeEntity(entity.state.id) - rasterLayerManager.registerEntity(RasterLayerEntity(layer, entity.state)) - } - renderer.localLayersRebuilt() - } -} +package com.mapconductor.arcgis.raster + +import com.arcgismaps.mapping.layers.Layer +import com.mapconductor.core.raster.RasterLayerController +import com.mapconductor.core.raster.RasterLayerEntity +import com.mapconductor.core.raster.RasterLayerManager +import com.mapconductor.core.raster.RasterLayerManagerInterface + +class ArcGISRasterLayerController( + rasterLayerManager: RasterLayerManagerInterface = RasterLayerManager(), + override val renderer: ArcGISRasterLayerOverlayRenderer, +) : RasterLayerController(rasterLayerManager, renderer) { + /** + * Builds the in-process layers again, so ArcGIS drops the tiles it holds + * for them. Wanted after the camera settles on a level that was answered + * transparent while it was an ancestor (see the renderer's gate). + */ + suspend fun rebuildLocalLayersIfNeeded() { + if (!renderer.localLayersNeedRebuild()) return + val entities = + rasterLayerManager + .allEntities() + .filter { renderer.isLocalLayer(it.state) } + .sortedBy { it.state.zIndex } + for (entity in entities) { + // In place, over the old one; the renderer removes the old layer + // once the new one has had time to draw. + val layer = renderer.rebuildLayer(entity) ?: continue + rasterLayerManager.removeEntity(entity.state.id) + rasterLayerManager.registerEntity(RasterLayerEntity(layer, entity.state)) + } + renderer.localLayersRebuilt() + } +} diff --git a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt index 4aea178..4a973f8 100644 --- a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt @@ -1,364 +1,365 @@ -package com.mapconductor.arcgis.raster - -import kotlinx.coroutines.launch -import com.mapconductor.arcgis.ArcGISMapViewHolder -import com.mapconductor.core.tileserver.TileServerRegistry -import com.arcgismaps.arcgisservices.LevelOfDetail -import com.arcgismaps.geometry.Envelope -import com.arcgismaps.geometry.Point -import com.arcgismaps.geometry.SpatialReference -import com.arcgismaps.mapping.layers.ArcGISTiledLayer -import com.arcgismaps.mapping.layers.Layer -import com.arcgismaps.mapping.layers.TileImageFormat -import com.arcgismaps.mapping.layers.TileInfo -import com.arcgismaps.mapping.layers.WebTiledLayer -import com.mapconductor.arcgis.ArcGISGeoViewHolder -import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS -import com.mapconductor.core.raster.RasterHeaderRuleSet -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.core.zoom.AbstractZoomAltitudeConverter -import kotlin.math.log2 -import kotlin.math.pow -import android.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers - -class ArcGISRasterLayerOverlayRenderer( - private val holder: ArcGISGeoViewHolder<*, *>, - override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : RasterLayerOverlayRendererInterface { - /** - * Which level the camera looks at, and which levels were answered with a - * transparent tile since the local layers were last built. - * - * The 3D view asks for every level above the one on screen and draws none - * of them once that level is in, so a request two or more levels above the - * camera is answered transparent, without a render. ArcGIS keeps what it - * is given, so a level answered that way stays blank if the camera later - * lands on it -- unless the layer is rebuilt, which [takeRebuildWanted] - * asks for. The 3D view sizes its levels in dp, so the level is the - * unified zoom itself (ios-for-arcgis adds log2 of the screen scale). - */ - internal class AncestorGate { - private var level: Int? = null - private val stubbed = HashSet() - private var rebuildWanted = false - - @Synchronized - fun set(unifiedZoom: Double) { - val wanted = Math.round(unifiedZoom).toInt() - if (wanted != level) { - level = wanted - if (stubbed.contains(wanted) || stubbed.contains(wanted - 1) || stubbed.contains(wanted + 1)) { - rebuildWanted = true - } - } - } - - @Synchronized - fun isAncestor(requested: Int): Boolean { - val level = level ?: return false - return requested < level - 1 - } - - @Synchronized - fun markStubbed(requested: Int) { - stubbed.add(requested) - } - - @Synchronized - fun layerRebuilt() { - stubbed.clear() - rebuildWanted = false - } - - @Synchronized - fun takeRebuildWanted(): Boolean { - val wanted = rebuildWanted - rebuildWanted = false - return wanted - } - } - - private val ancestors = AncestorGate() - - /** How long a replaced layer stays under its replacement. */ - private val HANDOVER_MS = 1200L - - /** The tile server route each local layer was built from, for its gate. */ - private val localRoutes = java.util.IdentityHashMap() - - /** Only the 3D view loads the pyramid above the level on screen. */ - private val isSceneView: Boolean get() = holder is ArcGISMapViewHolder - - fun cameraMoved(unifiedZoom: Double) { - if (isSceneView) ancestors.set(unifiedZoom) - } - - /** Once per need: whether the camera settled on a level answered transparent. */ - fun localLayersNeedRebuild(): Boolean = ancestors.takeRebuildWanted() - - fun localLayersRebuilt() = ancestors.layerRebuilt() - - /** Whether this layer is served by the in-process tile server. */ - fun isLocalLayer(state: RasterLayerState): Boolean = localRouteOf(state) != null - - private fun localRouteOf(state: RasterLayerState): String? { - val template = (state.source as? RasterLayerSource.UrlTemplate)?.template ?: return null - val base = TileServerRegistry.get().baseUrl + "/tiles/" - if (!template.startsWith(base)) return null - return template.removePrefix(base).substringBefore('/') - } - override suspend fun onAdd(data: List): List { - val results = ArrayList(data.size) - for (params in data) { - results.add(addLayer(params.state)) - } - return results - } - - override suspend fun onChange( - data: List>, - ): List { - val results = ArrayList(data.size) - for (params in data) { - val prev = params.prev - val next = params.current.state - if (prev.state.source != next.source) { - // Add new layer first to avoid losing in-flight tile requests - val newLayer = addLayer(next) - results.add(newLayer) - // Remove old layer after new one is added - removeLayer(prev) - } else { - updateLayer(prev.layer, next) - results.add(prev.layer) - } - } - return results - } - - override suspend fun onRemove(data: List>) { - data.forEach { entity -> - removeLayer(entity) - } - } - - override suspend fun onPostProcess() {} - - private suspend fun addLayer(state: RasterLayerState): Layer? { - RasterHeaderRuleSet.warnUnsupported(provider = "ArcGIS", state = state) - val operationalLayers = holder.operationalLayers ?: return null - val layer = - when (val source = state.source) { - is RasterLayerSource.ArcGisService -> ArcGISTiledLayer(source.serviceUrl) - is RasterLayerSource.UrlTemplate -> buildWebTiledLayer(source, state.id) ?: return null - is RasterLayerSource.TileJson -> { - Log.w("ArcGIS", "ArcGIS raster layers do not support TileJson sources.") - return null - } - } - - // Load layer before adding to scene to ensure TileInfo is fully initialized - val loadResult = layer.load() - if (loadResult.isFailure) { - val error = loadResult.exceptionOrNull() - Log.e("ArcGIS", "Failed to load raster layer id=${state.id}: ${error?.message}", error) - return null - } - - // Add to scene only after successful initialization - updateLayer(layer, state) - operationalLayers.add(layer) - localRouteOf(state)?.let { routeId -> - if (isSceneView) { - localRoutes[layer] = routeId - TileServerRegistry.get().setLevelGate(routeId) { level -> - ancestors.isAncestor(level).also { if (it) ancestors.markStubbed(level) } - } - } - } - return layer - } - - private fun updateLayer( - layer: Layer, - state: RasterLayerState, - ) { - layer.opacity = state.opacity.coerceIn(0.0f, 1.0f) - layer.isVisible = state.visible - } - - private fun removeLayer(entity: RasterLayerEntityInterface) { - holder.operationalLayers?.remove(entity.layer) - forgetGate(entity.layer) - } - - private fun forgetGate(layer: Layer) { - val routeId = localRoutes.remove(layer) ?: return - // Another layer (its replacement) may still be on the same route. - if (routeId !in localRoutes.values) TileServerRegistry.get().setLevelGate(routeId, null) - } - - /** - * Builds this entity's layer again, in place: the new one goes directly - * above the old, which stays for [HANDOVER_MS] and is then removed. - * Replacing by remove-then-add left the map bare until the new tiles - * landed -- a flicker on every rebuild. - */ - suspend fun rebuildLayer(entity: RasterLayerEntityInterface): Layer? { - val operationalLayers = holder.operationalLayers ?: return null - val state = entity.state - val source = state.source as? RasterLayerSource.UrlTemplate ?: return null - val layer = buildWebTiledLayer(source, state.id) ?: return null - val loadResult = layer.load() - if (loadResult.isFailure) { - Log.e("ArcGIS", "Failed to load raster layer id=${state.id}: ${loadResult.exceptionOrNull()?.message}") - return null - } - updateLayer(layer, state) - val index = operationalLayers.indexOf(entity.layer) - if (index >= 0) operationalLayers.add(index + 1, layer) else operationalLayers.add(layer) - localRouteOf(state)?.let { routeId -> - if (isSceneView) { - localRoutes[layer] = routeId - TileServerRegistry.get().setLevelGate(routeId) { level -> - ancestors.isAncestor(level).also { if (it) ancestors.markStubbed(level) } - } - } - } - val old = entity.layer - coroutine.launch { - kotlinx.coroutines.delay(HANDOVER_MS) - operationalLayers.remove(old) - forgetGate(old) - } - return layer - } - - private fun buildWebTiledLayer( - source: RasterLayerSource.UrlTemplate, - id: String, - ): WebTiledLayer? { - if (source.scheme == TileScheme.TMS) { - Log.w("ArcGIS", "TMS scheme is not supported for WebTiledLayer.") - return null - } - val template = - source.template - .replace("{z}", "{level}") - .replace("{x}", "{col}") - .replace("{y}", "{row}") - val minZoom = source.minZoom ?: DEFAULT_MIN_ZOOM - val maxZoom = source.maxZoom ?: DEFAULT_MAX_ZOOM - val tileInfo = buildWebMercatorTileInfo(source.tileSize, minZoom, maxZoom) - val fullExtent = buildWebMercatorExtent() - return WebTiledLayer.create(template, emptyList(), tileInfo, fullExtent) - } - - private fun buildWebMercatorTileInfo( - tileSize: Int, - minZoom: Int, - maxZoom: Int, - ): TileInfo { - val spatialReference = SpatialReference(WEB_MERCATOR_WKID) - val origin = Point(WEB_MERCATOR_MIN, WEB_MERCATOR_MAX, spatialReference) - // LOD の解像度は **必ず tileSize 基準**で刻む。1 枚のタイルが覆う地面は - // `resolution * tileWidth` なので、ここを 256 基準にしたまま tileWidth に - // 512 を渡すと、レベル L のタイルが L-1 の広さを覆う。**番号だけ 1 段深い - // 格子**になり、ArcGIS は「z=13, x=3637」のような噛み合わない組を要求する - // (東京の z=13 は x=7276、x=3637 は z=12 の値)。実機の Pixel 5a では - // ベクタータイルのサンプルが大西洋のタイルを引いて真っ青になった。 - // - // 以前ここには「3D SceneView は 256 基準でないと何も要求しない」という - // 計測メモがあり、512 のときだけ 256 へ読み替えていた。ArcGIS 300 では - // 再現しない: 素直な tileSize 基準で 3D も 2D も 256/512 の両方を正しく - // 引く(z=11 を要求し、MapLibre と一致)。256 のときは読み替えが恒等だった - // ので、この分岐が壊していたのは 512 だけだった。 - val levels = buildWebMercatorLevels(tileSize, minZoom, maxZoom) - return TileInfo( - DEFAULT_DPI, - TileImageFormat.Png, - levels, - origin, - spatialReference, - tileSize, - tileSize, - ) - } - - /** - * レベルの梯子を**統一ズーム**(Google Maps 基準・256px タイル)から組む。 - * - * MapConductor のズームはどのプロバイダでも Google のズームなので、level `z` の - * タイルが持つべき解像度も 1 本の物差しから出す: - * [AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256] が統一ズーム 0 の - * 1px あたりメートル数(256px タイル基準)。 - * - * 512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、level `z` は統一ズーム - * `z + 1` に対応する(コアの `WebMercatorZoomAltitudeConverter` が言う - * `zoomOffset`。MapLibre / Mapbox / MapTiler が 1.0、Google / MapKit が 0.0)。 - * その対応を [unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も - * 「統一ズームいくつのときにこの level を引くか」から一意に決まる。 - * - * 自前で `2πR / tileSize` と DPI を掛け合わせても同じ数にはなるが、どの物差しに - * 合わせているのかがコードから消える。実際、以前ここには 512 のときだけ 256 基準へ - * 読み替える細工が入っていて、(z,x,y) が噛み合わずに地球の裏側のタイルを引いていた。 - */ - private fun buildWebMercatorLevels( - tileSize: Int, - minZoom: Int, - maxZoom: Int, - ): List { - val levels = mutableListOf() - for (level in minZoom..maxZoom) { - val unifiedZoom = unifiedZoomForLevel(level, tileSize) - val resolution = - AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 / - 2.0.pow(unifiedZoom) - // 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、 - // 解像度と同じ比率で刻んでいないと 1 段ずれる。 - // - // 3D の SceneView は**見ていない**。1 段浅い縮尺を申告して選択を - // 2D とそろえられないか実機で試したが、要求される level は変わらな - // かった(統一ズーム 6.5 で z=7 のまま)。SceneView は自前の - // 「タイルは 256px」という梯子で選ぶ。解像度のほうは見ているので、 - // タイルの位置は申告どおりに決まる。 - val scale = resolution * DEFAULT_DPI * INCHES_PER_METER - levels.add(LevelOfDetail(level, resolution, scale)) - } - return levels - } - - /** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */ - private fun unifiedZoomForLevel( - level: Int, - tileSize: Int, - ): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE) - - private fun buildWebMercatorExtent(): Envelope = - Envelope( - WEB_MERCATOR_MIN, - WEB_MERCATOR_MIN, - WEB_MERCATOR_MAX, - WEB_MERCATOR_MAX, - spatialReference = SpatialReference(WEB_MERCATOR_WKID), - ) - - companion object { - private const val WEB_MERCATOR_WKID = 3857 - private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS - private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX - private const val DEFAULT_DPI = 96 - private const val INCHES_PER_METER = 39.37 - - /** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */ - private const val UNIFIED_TILE_SIZE = 256.0 - private const val DEFAULT_MIN_ZOOM = 0 - private const val DEFAULT_MAX_ZOOM = 22 - } -} +package com.mapconductor.arcgis.raster + +import com.arcgismaps.arcgisservices.LevelOfDetail +import com.arcgismaps.geometry.Envelope +import com.arcgismaps.geometry.Point +import com.arcgismaps.geometry.SpatialReference +import com.arcgismaps.mapping.layers.ArcGISTiledLayer +import com.arcgismaps.mapping.layers.Layer +import com.arcgismaps.mapping.layers.TileImageFormat +import com.arcgismaps.mapping.layers.TileInfo +import com.arcgismaps.mapping.layers.WebTiledLayer +import com.mapconductor.arcgis.ArcGISGeoViewHolder +import com.mapconductor.arcgis.ArcGISMapViewHolder +import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS +import com.mapconductor.core.raster.RasterHeaderRuleSet +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.core.tileserver.TileServerRegistry +import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter +import kotlin.math.log2 +import kotlin.math.pow +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ArcGISRasterLayerOverlayRenderer( + private val holder: ArcGISGeoViewHolder<*, *>, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : RasterLayerOverlayRendererInterface { + /** + * Which level the camera looks at, and which levels were answered with a + * transparent tile since the local layers were last built. + * + * The 3D view asks for every level above the one on screen and draws none + * of them once that level is in, so a request two or more levels above the + * camera is answered transparent, without a render. ArcGIS keeps what it + * is given, so a level answered that way stays blank if the camera later + * lands on it -- unless the layer is rebuilt, which [takeRebuildWanted] + * asks for. The 3D view sizes its levels in dp, so the level is the + * unified zoom itself (ios-for-arcgis adds log2 of the screen scale). + */ + internal class AncestorGate { + private var level: Int? = null + private val stubbed = HashSet() + private var rebuildWanted = false + + @Synchronized + fun set(unifiedZoom: Double) { + val wanted = Math.round(unifiedZoom).toInt() + if (wanted != level) { + level = wanted + if (stubbed.contains(wanted) || stubbed.contains(wanted - 1) || stubbed.contains(wanted + 1)) { + rebuildWanted = true + } + } + } + + @Synchronized + fun isAncestor(requested: Int): Boolean { + val level = level ?: return false + return requested < level - 1 + } + + @Synchronized + fun markStubbed(requested: Int) { + stubbed.add(requested) + } + + @Synchronized + fun layerRebuilt() { + stubbed.clear() + rebuildWanted = false + } + + @Synchronized + fun takeRebuildWanted(): Boolean { + val wanted = rebuildWanted + rebuildWanted = false + return wanted + } + } + + private val ancestors = AncestorGate() + + /** How long a replaced layer stays under its replacement. */ + private val HANDOVER_MS = 1200L + + /** The tile server route each local layer was built from, for its gate. */ + private val localRoutes = java.util.IdentityHashMap() + + /** Only the 3D view loads the pyramid above the level on screen. */ + private val isSceneView: Boolean get() = holder is ArcGISMapViewHolder + + fun cameraMoved(unifiedZoom: Double) { + if (isSceneView) ancestors.set(unifiedZoom) + } + + /** Once per need: whether the camera settled on a level answered transparent. */ + fun localLayersNeedRebuild(): Boolean = ancestors.takeRebuildWanted() + + fun localLayersRebuilt() = ancestors.layerRebuilt() + + /** Whether this layer is served by the in-process tile server. */ + fun isLocalLayer(state: RasterLayerState): Boolean = localRouteOf(state) != null + + private fun localRouteOf(state: RasterLayerState): String? { + val template = (state.source as? RasterLayerSource.UrlTemplate)?.template ?: return null + val base = TileServerRegistry.get().baseUrl + "/tiles/" + if (!template.startsWith(base)) return null + return template.removePrefix(base).substringBefore('/') + } + + override suspend fun onAdd(data: List): List { + val results = ArrayList(data.size) + for (params in data) { + results.add(addLayer(params.state)) + } + return results + } + + override suspend fun onChange( + data: List>, + ): List { + val results = ArrayList(data.size) + for (params in data) { + val prev = params.prev + val next = params.current.state + if (prev.state.source != next.source) { + // Add new layer first to avoid losing in-flight tile requests + val newLayer = addLayer(next) + results.add(newLayer) + // Remove old layer after new one is added + removeLayer(prev) + } else { + updateLayer(prev.layer, next) + results.add(prev.layer) + } + } + return results + } + + override suspend fun onRemove(data: List>) { + data.forEach { entity -> + removeLayer(entity) + } + } + + override suspend fun onPostProcess() {} + + private suspend fun addLayer(state: RasterLayerState): Layer? { + RasterHeaderRuleSet.warnUnsupported(provider = "ArcGIS", state = state) + val operationalLayers = holder.operationalLayers ?: return null + val layer = + when (val source = state.source) { + is RasterLayerSource.ArcGisService -> ArcGISTiledLayer(source.serviceUrl) + is RasterLayerSource.UrlTemplate -> buildWebTiledLayer(source, state.id) ?: return null + is RasterLayerSource.TileJson -> { + Log.w("ArcGIS", "ArcGIS raster layers do not support TileJson sources.") + return null + } + } + + // Load layer before adding to scene to ensure TileInfo is fully initialized + val loadResult = layer.load() + if (loadResult.isFailure) { + val error = loadResult.exceptionOrNull() + Log.e("ArcGIS", "Failed to load raster layer id=${state.id}: ${error?.message}", error) + return null + } + + // Add to scene only after successful initialization + updateLayer(layer, state) + operationalLayers.add(layer) + localRouteOf(state)?.let { routeId -> + if (isSceneView) { + localRoutes[layer] = routeId + TileServerRegistry.get().setLevelGate(routeId) { level -> + ancestors.isAncestor(level).also { if (it) ancestors.markStubbed(level) } + } + } + } + return layer + } + + private fun updateLayer( + layer: Layer, + state: RasterLayerState, + ) { + layer.opacity = state.opacity.coerceIn(0.0f, 1.0f) + layer.isVisible = state.visible + } + + private fun removeLayer(entity: RasterLayerEntityInterface) { + holder.operationalLayers?.remove(entity.layer) + forgetGate(entity.layer) + } + + private fun forgetGate(layer: Layer) { + val routeId = localRoutes.remove(layer) ?: return + // Another layer (its replacement) may still be on the same route. + if (routeId !in localRoutes.values) TileServerRegistry.get().setLevelGate(routeId, null) + } + + /** + * Builds this entity's layer again, in place: the new one goes directly + * above the old, which stays for [HANDOVER_MS] and is then removed. + * Replacing by remove-then-add left the map bare until the new tiles + * landed -- a flicker on every rebuild. + */ + suspend fun rebuildLayer(entity: RasterLayerEntityInterface): Layer? { + val operationalLayers = holder.operationalLayers ?: return null + val state = entity.state + val source = state.source as? RasterLayerSource.UrlTemplate ?: return null + val layer = buildWebTiledLayer(source, state.id) ?: return null + val loadResult = layer.load() + if (loadResult.isFailure) { + Log.e("ArcGIS", "Failed to load raster layer id=${state.id}: ${loadResult.exceptionOrNull()?.message}") + return null + } + updateLayer(layer, state) + val index = operationalLayers.indexOf(entity.layer) + if (index >= 0) operationalLayers.add(index + 1, layer) else operationalLayers.add(layer) + localRouteOf(state)?.let { routeId -> + if (isSceneView) { + localRoutes[layer] = routeId + TileServerRegistry.get().setLevelGate(routeId) { level -> + ancestors.isAncestor(level).also { if (it) ancestors.markStubbed(level) } + } + } + } + val old = entity.layer + coroutine.launch { + kotlinx.coroutines.delay(HANDOVER_MS) + operationalLayers.remove(old) + forgetGate(old) + } + return layer + } + + private fun buildWebTiledLayer( + source: RasterLayerSource.UrlTemplate, + id: String, + ): WebTiledLayer? { + if (source.scheme == TileScheme.TMS) { + Log.w("ArcGIS", "TMS scheme is not supported for WebTiledLayer.") + return null + } + val template = + source.template + .replace("{z}", "{level}") + .replace("{x}", "{col}") + .replace("{y}", "{row}") + val minZoom = source.minZoom ?: DEFAULT_MIN_ZOOM + val maxZoom = source.maxZoom ?: DEFAULT_MAX_ZOOM + val tileInfo = buildWebMercatorTileInfo(source.tileSize, minZoom, maxZoom) + val fullExtent = buildWebMercatorExtent() + return WebTiledLayer.create(template, emptyList(), tileInfo, fullExtent) + } + + private fun buildWebMercatorTileInfo( + tileSize: Int, + minZoom: Int, + maxZoom: Int, + ): TileInfo { + val spatialReference = SpatialReference(WEB_MERCATOR_WKID) + val origin = Point(WEB_MERCATOR_MIN, WEB_MERCATOR_MAX, spatialReference) + // LOD の解像度は **必ず tileSize 基準**で刻む。1 枚のタイルが覆う地面は + // `resolution * tileWidth` なので、ここを 256 基準にしたまま tileWidth に + // 512 を渡すと、レベル L のタイルが L-1 の広さを覆う。**番号だけ 1 段深い + // 格子**になり、ArcGIS は「z=13, x=3637」のような噛み合わない組を要求する + // (東京の z=13 は x=7276、x=3637 は z=12 の値)。実機の Pixel 5a では + // ベクタータイルのサンプルが大西洋のタイルを引いて真っ青になった。 + // + // 以前ここには「3D SceneView は 256 基準でないと何も要求しない」という + // 計測メモがあり、512 のときだけ 256 へ読み替えていた。ArcGIS 300 では + // 再現しない: 素直な tileSize 基準で 3D も 2D も 256/512 の両方を正しく + // 引く(z=11 を要求し、MapLibre と一致)。256 のときは読み替えが恒等だった + // ので、この分岐が壊していたのは 512 だけだった。 + val levels = buildWebMercatorLevels(tileSize, minZoom, maxZoom) + return TileInfo( + DEFAULT_DPI, + TileImageFormat.Png, + levels, + origin, + spatialReference, + tileSize, + tileSize, + ) + } + + /** + * レベルの梯子を**統一ズーム**(Google Maps 基準・256px タイル)から組む。 + * + * MapConductor のズームはどのプロバイダでも Google のズームなので、level `z` の + * タイルが持つべき解像度も 1 本の物差しから出す: + * [AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256] が統一ズーム 0 の + * 1px あたりメートル数(256px タイル基準)。 + * + * 512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、level `z` は統一ズーム + * `z + 1` に対応する(コアの `WebMercatorZoomAltitudeConverter` が言う + * `zoomOffset`。MapLibre / Mapbox / MapTiler が 1.0、Google / MapKit が 0.0)。 + * その対応を [unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も + * 「統一ズームいくつのときにこの level を引くか」から一意に決まる。 + * + * 自前で `2πR / tileSize` と DPI を掛け合わせても同じ数にはなるが、どの物差しに + * 合わせているのかがコードから消える。実際、以前ここには 512 のときだけ 256 基準へ + * 読み替える細工が入っていて、(z,x,y) が噛み合わずに地球の裏側のタイルを引いていた。 + */ + private fun buildWebMercatorLevels( + tileSize: Int, + minZoom: Int, + maxZoom: Int, + ): List { + val levels = mutableListOf() + for (level in minZoom..maxZoom) { + val unifiedZoom = unifiedZoomForLevel(level, tileSize) + val resolution = + AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 / + 2.0.pow(unifiedZoom) + // 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、 + // 解像度と同じ比率で刻んでいないと 1 段ずれる。 + // + // 3D の SceneView は**見ていない**。1 段浅い縮尺を申告して選択を + // 2D とそろえられないか実機で試したが、要求される level は変わらな + // かった(統一ズーム 6.5 で z=7 のまま)。SceneView は自前の + // 「タイルは 256px」という梯子で選ぶ。解像度のほうは見ているので、 + // タイルの位置は申告どおりに決まる。 + val scale = resolution * DEFAULT_DPI * INCHES_PER_METER + levels.add(LevelOfDetail(level, resolution, scale)) + } + return levels + } + + /** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */ + private fun unifiedZoomForLevel( + level: Int, + tileSize: Int, + ): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE) + + private fun buildWebMercatorExtent(): Envelope = + Envelope( + WEB_MERCATOR_MIN, + WEB_MERCATOR_MIN, + WEB_MERCATOR_MAX, + WEB_MERCATOR_MAX, + spatialReference = SpatialReference(WEB_MERCATOR_WKID), + ) + + companion object { + private const val WEB_MERCATOR_WKID = 3857 + private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS + private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX + private const val DEFAULT_DPI = 96 + private const val INCHES_PER_METER = 39.37 + + /** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */ + private const val UNIFIED_TILE_SIZE = 256.0 + private const val DEFAULT_MIN_ZOOM = 0 + private const val DEFAULT_MAX_ZOOM = 22 + } +} From 116112bd0659ee7761eaff99b8c229b67459861a Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:50:02 -0700 Subject: [PATCH 3/3] Keep each file's line endings as they are on develop The checkpoint commit rewrote whole files between LF and CRLF, which buried its real changes under line-ending noise. Content is unchanged. --- .../arcgis/ArcGIS2DTiltEmulation.kt | 188 ++-- .../com/mapconductor/arcgis/ArcGISMapView.kt | 990 +++++++++--------- .../mapconductor/arcgis/ArcGISMapView2D.kt | 548 +++++----- .../arcgis/ArcGISMapView2DController.kt | 842 +++++++-------- .../arcgis/ArcGISMapViewController.kt | 688 ++++++------ .../raster/ArcGISRasterLayerController.kt | 68 +- .../ArcGISRasterLayerOverlayRenderer.kt | 730 ++++++------- 7 files changed, 2027 insertions(+), 2027 deletions(-) diff --git a/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt b/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt index 1db316a..9f684d4 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt @@ -1,94 +1,94 @@ -package com.mapconductor.arcgis - -import com.mapconductor.arcgis.zoom.ZoomAltitudeConverter -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.map.CameraBearing -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.spherical.Spherical -import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter -import kotlin.math.abs -import kotlin.math.cos -import kotlin.math.tan - -/** - * ArcGIS の 2D [com.arcgismaps.mapping.view.MapView] 向けの tilt 擬似表現。 - * - * 2D は Viewpoint(中心 + 縮尺 + 回転)ベースでカメラピッチを一切持てない。3D の SceneView - * ([ArcGISMapViewController])は calculateCameraForOrbitParameters で実際にピッチできるが、 - * 2D では遠近感を作れないため、**カメラが見ている地表範囲**を幾何的に近似する。 - * - * 見た目の傾き(遠近感)は ArcGISMapView2D が MapView 自体を graphicsLayer で傾けて作る - * (react-for-leaflet が CSS rotateX で行うのと同じ方式)。ここが受け持つのは - * **カメラ位置の付け替え**だけ: - * - * - tilt >= 0: 指定位置は**ターゲット**(画面中心)でカメラが後方へ下がるだけなので、 - * 中心もズームも変えない。傾きは見た目の変換だけで表現される。 - * - tilt < 0: 指定位置は**カメラ位置**で、ターゲットが進行方向(bearing)へ前進する。 - * 前進量とズームオフセットは MapLibre / TomTom / Leaflet と同一の式・同一定数。 - * - * ios-for-arcgis の `ArcGIS2DTiltEmulation.swift` と同一ロジック。 - */ -internal object ArcGIS2DTiltEmulation { - /** MapLibre / TomTom / Leaflet と同一値。プロバイダ間で挙動を揃えるため変えないこと。 */ - private const val TARGET_DISTANCE_SCALE = 1.83 - private const val ZOOM_OFFSET_AT_MAX_TILT = -0.9 - - /** - * 高度の算出にはプラットフォーム非依存の既定値(Google Maps 較正)を使う。 - * - * [ZoomAltitudeConverter] の既定値(Android 136_500_000 / iOS 141_600_000)は - * **3D SceneView の実効画角**を合わせるための較正値で、画角を持たない 2D MapView には - * 当てはまらない。既定値を使うことでシフト量が iOS と厳密に一致する。 - */ - private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE) - - /** - * 論理カメラ → 実際に Viewpoint へ渡す中心・ズーム。 - * - * tilt < 0 のときだけ中心を進行方向へ前進させ、ズームを引く。tilt >= 0 は入力のまま - * (position が**ターゲット**=画面中心で、カメラが後方へ下がるだけのため)。 - */ - fun shiftedCamera(position: MapCameraPosition): Pair { - if (position.tilt >= 0) return position.position to position.zoom - - val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, 60.0) - val zoom = position.zoom + ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = converter.zoomLevelToAltitude(position.zoom, position.position.latitude, 0.0) - val distanceForward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val target = - Spherical - .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) - return target to zoom - } - - /** - * Viewpoint から読み戻した中心・ズームを論理カメラへ戻す。 - * - * tilt < 0 のときだけ中心とズームを巻き戻す。 - * - * @param logicalTilt 直近に要求した論理 tilt。 - * @param bearing 前進に使った方位。 - */ - fun restoreLogicalCamera( - center: GeoPointInterface, - zoom: Double, - bearing: Double, - logicalTilt: Double, - ): Pair { - val tiltAbsDeg = abs(logicalTilt).coerceIn(0.0, 60.0) - if (logicalTilt >= 0 || tiltAbsDeg == 0.0) return center to zoom - - val originalZoom = zoom - ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = converter.zoomLevelToAltitude(originalZoom, center.latitude, 0.0) - val distanceBackward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val originalPosition: GeoPoint = - Spherical.computeOffset( - center, distanceBackward, - CameraBearing.toNativeHeading(bearing) + 180.0, - ) - return originalPosition to originalZoom - } -} +package com.mapconductor.arcgis + +import com.mapconductor.arcgis.zoom.ZoomAltitudeConverter +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.spherical.Spherical +import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.tan + +/** + * ArcGIS の 2D [com.arcgismaps.mapping.view.MapView] 向けの tilt 擬似表現。 + * + * 2D は Viewpoint(中心 + 縮尺 + 回転)ベースでカメラピッチを一切持てない。3D の SceneView + * ([ArcGISMapViewController])は calculateCameraForOrbitParameters で実際にピッチできるが、 + * 2D では遠近感を作れないため、**カメラが見ている地表範囲**を幾何的に近似する。 + * + * 見た目の傾き(遠近感)は ArcGISMapView2D が MapView 自体を graphicsLayer で傾けて作る + * (react-for-leaflet が CSS rotateX で行うのと同じ方式)。ここが受け持つのは + * **カメラ位置の付け替え**だけ: + * + * - tilt >= 0: 指定位置は**ターゲット**(画面中心)でカメラが後方へ下がるだけなので、 + * 中心もズームも変えない。傾きは見た目の変換だけで表現される。 + * - tilt < 0: 指定位置は**カメラ位置**で、ターゲットが進行方向(bearing)へ前進する。 + * 前進量とズームオフセットは MapLibre / TomTom / Leaflet と同一の式・同一定数。 + * + * ios-for-arcgis の `ArcGIS2DTiltEmulation.swift` と同一ロジック。 + */ +internal object ArcGIS2DTiltEmulation { + /** MapLibre / TomTom / Leaflet と同一値。プロバイダ間で挙動を揃えるため変えないこと。 */ + private const val TARGET_DISTANCE_SCALE = 1.83 + private const val ZOOM_OFFSET_AT_MAX_TILT = -0.9 + + /** + * 高度の算出にはプラットフォーム非依存の既定値(Google Maps 較正)を使う。 + * + * [ZoomAltitudeConverter] の既定値(Android 136_500_000 / iOS 141_600_000)は + * **3D SceneView の実効画角**を合わせるための較正値で、画角を持たない 2D MapView には + * 当てはまらない。既定値を使うことでシフト量が iOS と厳密に一致する。 + */ + private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE) + + /** + * 論理カメラ → 実際に Viewpoint へ渡す中心・ズーム。 + * + * tilt < 0 のときだけ中心を進行方向へ前進させ、ズームを引く。tilt >= 0 は入力のまま + * (position が**ターゲット**=画面中心で、カメラが後方へ下がるだけのため)。 + */ + fun shiftedCamera(position: MapCameraPosition): Pair { + if (position.tilt >= 0) return position.position to position.zoom + + val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, 60.0) + val zoom = position.zoom + ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = converter.zoomLevelToAltitude(position.zoom, position.position.latitude, 0.0) + val distanceForward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE + val target = + Spherical + .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) + return target to zoom + } + + /** + * Viewpoint から読み戻した中心・ズームを論理カメラへ戻す。 + * + * tilt < 0 のときだけ中心とズームを巻き戻す。 + * + * @param logicalTilt 直近に要求した論理 tilt。 + * @param bearing 前進に使った方位。 + */ + fun restoreLogicalCamera( + center: GeoPointInterface, + zoom: Double, + bearing: Double, + logicalTilt: Double, + ): Pair { + val tiltAbsDeg = abs(logicalTilt).coerceIn(0.0, 60.0) + if (logicalTilt >= 0 || tiltAbsDeg == 0.0) return center to zoom + + val originalZoom = zoom - ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = converter.zoomLevelToAltitude(originalZoom, center.latitude, 0.0) + val distanceBackward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE + val originalPosition: GeoPoint = + Spherical.computeOffset( + center, distanceBackward, + CameraBearing.toNativeHeading(bearing) + 180.0, + ) + return originalPosition to originalZoom + } +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt index f31d8c9..4e54992 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt @@ -1,495 +1,495 @@ -package com.mapconductor.arcgis - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.node.Ref -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.arcgismaps.ApiKey -import com.arcgismaps.ArcGISEnvironment -import com.arcgismaps.LoadStatus -import com.arcgismaps.mapping.ArcGISScene -import com.arcgismaps.mapping.ArcGISTiledElevationSource -import com.arcgismaps.mapping.Basemap -import com.arcgismaps.mapping.view.GraphicsOverlay -import com.arcgismaps.mapping.view.GraphicsRenderingMode -import com.arcgismaps.mapping.view.SceneView -import com.arcgismaps.mapping.view.SurfacePlacement -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer -import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController -import com.mapconductor.arcgis.groundimage.ArcGISGroundImageOverlayRenderer -import com.mapconductor.arcgis.marker.ArcGISMarkerController -import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer -import com.mapconductor.arcgis.raster.ArcGISRasterLayerController -import com.mapconductor.arcgis.raster.ArcGISRasterLayerOverlayRenderer -import com.mapconductor.compose.map.MapViewBase -import com.mapconductor.core.OnCameraMoveHandler -import com.mapconductor.core.OnMapEventHandler -import com.mapconductor.core.OnMapLoadedHandler -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapCameraPositionInterface -import com.mapconductor.core.map.MapViewStyle -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerManager -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -import com.mapconductor.core.marker.MarkerRenderingSupport -import com.mapconductor.core.marker.MarkerRenderingSupportKey -import com.mapconductor.core.marker.MarkerTilingOptions -import com.mapconductor.core.marker.StrategyMarkerController -import com.mapconductor.core.raster.RasterTilePreference -import com.mapconductor.core.raster.RasterTilePreferenceKey -import com.mapconductor.core.tileserver.TileServerRegistry -import java.util.concurrent.atomic.AtomicLong -import android.content.Context -import android.util.Log -import android.widget.FrameLayout -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine - -@Composable -fun ArcGISMapView( - state: ArcGISMapViewState, - modifier: Modifier = Modifier, - markerTiling: MarkerTilingOptions? = null, - cameraRestriction: CameraRestriction? = null, - sdkInitialize: (suspend (Context) -> Boolean)? = null, - onMapLoaded: OnMapLoadedHandler? = null, - onCameraMoveStart: OnCameraMoveHandler? = null, - onCameraMove: OnCameraMoveHandler? = null, - onCameraMoveEnd: OnCameraMoveHandler? = null, - onMapClick: OnMapEventHandler? = null, - onMapLongClick: OnMapEventHandler? = 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 ArcGISMapViewScope.() -> Unit)? = null, -) { - val scope = remember { ArcGISMapViewScope() } // Use specific scope - val context = LocalContext.current // Context will be available from MapViewBase too if needed - val registry = remember { scope.buildRegistry() } - val owner = LocalLifecycleOwner.current - // The design the scene was built with; a design set on the state before - // the controller existed is applied once it does. - val createdDesign = remember { Ref() } - val cameraState = remember { mutableStateOf(state.cameraPosition) } - val controllerRef = remember { Ref() } - val controllerGeneration = remember { AtomicLong(0L) } - - MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = { - val sceneView = SceneView(context) - val wrapView = - WrapSceneView(context).apply { - addView(sceneView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - } - wrapView.sceneView = sceneView - // Ensure lifecycle owner is set before the view is attached/drawn - // to avoid GeoView.lifeCycleOwner UninitializedPropertyAccessException - sceneView.onCreate(owner) - sceneView.onResume(owner) - wrapView - }, - scope = scope, - registry = registry, - holderProvider = { wrapView -> - val design = state.mapDesignType - createdDesign.value = design.getValue() - val options = - ArcGISMapViewInitOptions( - basemapStyle = ArcGISDesign.toBasemapStyleOrNull(design), - elevationSources = design.elevationSources, - ) - - // No basemap is a scene with only what the app puts on it. The - // basemap is set on an empty scene rather than built into it, so - // a scene whose basemap is out of reach is still a scene (a - // global one is always WGS84) that can draw its own layers. - val scene = ArcGISScene() - options.basemapStyle?.let { scene.setBasemap(Basemap(it)) } - - options.elevationSources.forEach { - val source = ArcGISTiledElevationSource(it) - scene.baseSurface.elevationSources.add(source) - } - - wrapView.sceneView.scene = scene - var retried = false - - val coroutine = CoroutineScope(Dispatchers.Default) - - suspendCancellableCoroutine { cont -> - cont.invokeOnCancellation { coroutine.cancel() } - coroutine.launch { - scene.loadStatus.collect { - when (it) { - is LoadStatus.Loaded -> { - val holder = - ArcGISMapViewHolder( - mapView = wrapView, - map = wrapView.sceneView, - ) - cont.resume(holder) { _, _, _ -> } - } - is LoadStatus.FailedToLoad -> { - Log.w("ArcGISMapView", "scene failed to load: ${it.error.message}") - // The basemap was taken away while it was being - // fetched (the app went basemap-less, say for an - // offline raster): without one the scene needs no - // network, so it gets one more go. - if (scene.basemap.value == null && !retried) { - retried = true - scene.retryLoad() - return@collect - } - // Offline or network error: resume with a holder anyway so - // the scene view is displayed (possibly with cached tiles) - // without crashing. The ArcGIS view may be blank but usable. - if (cont.isActive) { - cont.resume( - ArcGISMapViewHolder( - mapView = wrapView, - map = wrapView.sceneView, - ), - ) { _, _, _ -> } - } - } - else -> { - // Do nothing here - } - } - } - } - } - }, - controllerProvider = { holder -> - - val markerLayer: GraphicsOverlay = - GraphicsOverlay().apply { - renderingMode = GraphicsRenderingMode.Dynamic - sceneProperties.surfacePlacement = SurfacePlacement.Relative - } - val markerController = - getMarkerController( - holder = holder, - markerLayer = markerLayer, - markerTiling = markerTiling ?: MarkerTilingOptions.Default, - ) - val polylineController = getPolylineController(holder) - val rasterLayerController = getRasterLayerController(holder) - val polygonController = getPolygonController(holder) - val circleController = getCircleController(holder) - val groundImageController = getGroundImageController(holder) - - // Defer initial camera update until controller is created and view is laid out - - ArcGISMapViewController( - holder = holder, - markerController = markerController, - polylineController = polylineController, - polygonController = polygonController, - circleController = circleController, - groundImageController = groundImageController, - rasterLayerController = rasterLayerController, - ).also { mapController -> - state.serviceRegistry.put( - MarkerRenderingSupportKey, - object : MarkerRenderingSupport { - override fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() - - override fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = - mapController - .createMarkerEventController(controller) - - override fun registerMarkerEventController( - controller: MarkerEventControllerInterface, - ) { - mapController.registerMarkerEventController(controller) - } - - override fun onMarkerRenderingReady() { - mapController.sendInitialCameraUpdate() - } - }, - ) - - // 3D SceneView は「タイルは 256px」という前提でレベルを選ぶ。 - // 512px のタイルを渡すと 1 段深いレベルを 4 倍の枚数で引く - // (実測: 統一ズーム 12 で 2D は z=11、3D は z=12)。絵は正しい - // ので気づきにくいが、タイル 1 枚あたりの固定費がそのぶん効く。 - // 2D は好みが無いので宣言しない — 供給側の既定に任せる。 - // 3D SceneView は「タイルは 256px」という自前の梯子で level を - // 選び、こちらが申告する縮尺では動かせない(実機で確認)。同じ - // カメラで 2D より 1 段深い level を引くので、512dp のつもりで - // 描いたタイルは半分の枠に入って文字も線も半分になる。 - // - // 256 を要求すると、選ばれる level と描画の前提が噛み合い、 - // 文字の大きさは MapLibre と一致する(実測)。残るのは元データの - // 違い — 1 段深いソースタイルには大きな地名が入っていないことが - // あり、低ズームで「新潟市」のような表示が落ちる。 - if (holder.usesSceneView) { - state.serviceRegistry.put( - RasterTilePreferenceKey, - object : RasterTilePreference { - override val preferredTileSize: Int = 256 - }, - ) - } - - controllerRef.value = mapController - mapController.setMapClickListener(onMapClick) - mapController.setMapLongClickListener(onMapLongClick) - mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - cameraRestriction?.let { mapController.setCameraRestriction(it) } - state.setController(mapController) - if (state.mapDesignType.getValue() != - createdDesign.value - ) { - mapController.setMapDesignType(state.mapDesignType) - } - - // Set camera listeners immediately so they are ready to receive - // camera updates from external sources (e.g. camera sync scenarios). - mapController.setCameraMoveStartListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveStart?.invoke(it) - } - mapController.setCameraMoveListener { - cameraState.value = it - state.updateCameraPosition(it) - // The tile server's ancestor gate needs to know which level - // the camera looks at. - mapController.rasterLayerController.renderer.cameraMoved(it.zoom) - onCameraMove?.invoke(it) - } - mapController.setCameraMoveEndListener { - cameraState.value = it - state.updateCameraPosition(it) - mapController.rasterLayerController.renderer.cameraMoved(it.zoom) - mapController.mainCoroutine.launch { - mapController.rasterLayerController.rebuildLocalLayersIfNeeded() - } - onCameraMoveEnd?.invoke(it) - } - - // Avoid early ArcGIS viewpoint updates overwriting the desired initial camera. - // Apply the initial camera after layout. - val initialCameraPosition = state.cameraPosition - val generation = controllerGeneration.incrementAndGet() - holder.mapView.post { - if (controllerGeneration.get() != generation) return@post - mapController.moveCamera(MapCameraPosition.from(initialCameraPosition)) - mapController.sendInitialCameraUpdate() - // viewpointChanged の初回発火に頼らず、レイアウト確定のこの時点で - // 「準備完了」を通知する(取り逃すとオーバーレイが一切描画されない)。 - mapController.markMapInitialized() - } - } - }, - sdkInitialize = { - sdkInitialize?.invoke(context) ?: defaultArcGISInitialize(context) - }, - onMapLoaded = onMapLoaded, - customDisposableEffect = { _, holderRef -> - DisposableEffect(state.id) { - onDispose { - // Invalidate any pending `post { ... }` work that captured older controllers. - controllerGeneration.incrementAndGet() - // Detach callbacks so a disposed controller cannot keep overwriting state.cameraPosition - // during rapid ArcGIS<->other provider switching. - controllerRef.value?.apply { - setCameraMoveStartListener(null) - setCameraMoveListener(null) - setCameraMoveEndListener(null) - setMapClickListener(null) - setMapLongClickListener(null) - // Free overlay controllers (tile-server routes, marker - // managers) and cancel the controller scope. - destroy() - } - controllerRef.value = null - state.clearController() - holderRef.value?.mapView?.apply { - onPause(owner) - onStop(owner) - onDestroy(owner) - } - } - } - }, - style = style, - onStyleDiagnostics = onStyleDiagnostics, - content = content, - ) -} - -internal fun getCircleController( - holder: ArcGISGeoViewHolder<*, *>, - useScenePlacement: Boolean = true, -): ArcGISCircleOverlayController { - val circleLayer: GraphicsOverlay = - GraphicsOverlay().apply { - if (useScenePlacement) { - sceneProperties.surfacePlacement = SurfacePlacement.DrapedFlat - } - } - holder.geoView.graphicsOverlays.add(circleLayer) - - val renderer = - ArcGISCircleOverlayRenderer( - circleLayer = circleLayer, - holder = holder, - ) - - val controller = - ArcGISCircleOverlayController( - renderer = renderer, - ) - return controller -} - -internal fun getPolylineController( - holder: ArcGISGeoViewHolder<*, *>, - useScenePlacement: Boolean = true, -): ArcGISPolylineOverlayController { - val polylineLayer: GraphicsOverlay = - GraphicsOverlay().apply { - if (useScenePlacement) { - sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded - } - } - holder.geoView.graphicsOverlays.add(polylineLayer) - - val renderer = - ArcGISPolylineOverlayRenderer( - polylineLayer = polylineLayer, - holder = holder, - ) - - val controller = - ArcGISPolylineOverlayController( - renderer = renderer, - ) - return controller -} - -internal fun getPolygonController( - holder: ArcGISGeoViewHolder<*, *>, - useScenePlacement: Boolean = true, -): ArcGISPolygonOverlayController { - val polygonLayer: GraphicsOverlay = - GraphicsOverlay().apply { - if (useScenePlacement) { - sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded - } - } - holder.geoView.graphicsOverlays.add(polygonLayer) - - val renderer = - ArcGISPolygonOverlayRenderer( - polygonLayer = polygonLayer, - holder = holder, - ) - - val controller = - ArcGISPolygonOverlayController( - renderer = renderer, - ) - return controller -} - -internal fun getMarkerController( - holder: ArcGISGeoViewHolder<*, *>, - markerLayer: GraphicsOverlay, - markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, -): ArcGISMarkerController { - val renderer = - ArcGISMarkerRenderer( - markerLayer = markerLayer, - holder = holder, - ) - - val markerManager = - MarkerManager.defaultManager( - minMarkerCount = markerTiling.minMarkerCount, - ) - - val controller = - ArcGISMarkerController( - markerManager = markerManager, - renderer = renderer, - markerTiling = markerTiling, - ) - return controller -} - -internal fun getRasterLayerController(holder: ArcGISGeoViewHolder<*, *>): ArcGISRasterLayerController { - val renderer = - ArcGISRasterLayerOverlayRenderer( - holder = holder, - ) - return ArcGISRasterLayerController( - renderer = renderer, - ) -} - -internal fun getGroundImageController(holder: ArcGISGeoViewHolder<*, *>): ArcGISGroundImageController { - val tileServer = TileServerRegistry.get() - val renderer = - ArcGISGroundImageOverlayRenderer( - holder = holder, - tileServer = tileServer, - ) - return ArcGISGroundImageController(renderer = renderer) -} - -/** - * Default ArcGIS SDK initialization using API Key authentication. - * - * This function is used when no custom sdkInitialize parameter is provided to ArcGISMapView. - * It reads the API Key from AndroidManifest.xml metadata and configures ArcGISEnvironment. - * - * @param context Application context - * @return true if initialization succeeded, false otherwise - */ -internal fun defaultArcGISInitialize(context: Context): Boolean { - if (ArcGISEnvironment.authenticationManager.arcGISCredentialStore - .getCredentials() - .isEmpty() - ) { - val apiKey = context.applicationContext.getArcGisApiKey() - if (apiKey == null) { - Log.e("ArcGISMapView", " is required") - return false - } - ArcGISEnvironment.apiKey = ApiKey.create(apiKey) - } - return true -} +package com.mapconductor.arcgis + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.arcgismaps.ApiKey +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.LoadStatus +import com.arcgismaps.mapping.ArcGISScene +import com.arcgismaps.mapping.ArcGISTiledElevationSource +import com.arcgismaps.mapping.Basemap +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.arcgismaps.mapping.view.GraphicsRenderingMode +import com.arcgismaps.mapping.view.SceneView +import com.arcgismaps.mapping.view.SurfacePlacement +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer +import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController +import com.mapconductor.arcgis.groundimage.ArcGISGroundImageOverlayRenderer +import com.mapconductor.arcgis.marker.ArcGISMarkerController +import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer +import com.mapconductor.arcgis.raster.ArcGISRasterLayerController +import com.mapconductor.arcgis.raster.ArcGISRasterLayerOverlayRenderer +import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.core.OnCameraMoveHandler +import com.mapconductor.core.OnMapEventHandler +import com.mapconductor.core.OnMapLoadedHandler +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapViewStyle +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +import com.mapconductor.core.marker.MarkerRenderingSupport +import com.mapconductor.core.marker.MarkerRenderingSupportKey +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.StrategyMarkerController +import com.mapconductor.core.raster.RasterTilePreference +import com.mapconductor.core.raster.RasterTilePreferenceKey +import com.mapconductor.core.tileserver.TileServerRegistry +import java.util.concurrent.atomic.AtomicLong +import android.content.Context +import android.util.Log +import android.widget.FrameLayout +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine + +@Composable +fun ArcGISMapView( + state: ArcGISMapViewState, + modifier: Modifier = Modifier, + markerTiling: MarkerTilingOptions? = null, + cameraRestriction: CameraRestriction? = null, + sdkInitialize: (suspend (Context) -> Boolean)? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onCameraMoveStart: OnCameraMoveHandler? = null, + onCameraMove: OnCameraMoveHandler? = null, + onCameraMoveEnd: OnCameraMoveHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMapLongClick: OnMapEventHandler? = 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 ArcGISMapViewScope.() -> Unit)? = null, +) { + val scope = remember { ArcGISMapViewScope() } // Use specific scope + val context = LocalContext.current // Context will be available from MapViewBase too if needed + val registry = remember { scope.buildRegistry() } + val owner = LocalLifecycleOwner.current + // The design the scene was built with; a design set on the state before + // the controller existed is applied once it does. + val createdDesign = remember { Ref() } + val cameraState = remember { mutableStateOf(state.cameraPosition) } + val controllerRef = remember { Ref() } + val controllerGeneration = remember { AtomicLong(0L) } + + MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = { + val sceneView = SceneView(context) + val wrapView = + WrapSceneView(context).apply { + addView(sceneView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + } + wrapView.sceneView = sceneView + // Ensure lifecycle owner is set before the view is attached/drawn + // to avoid GeoView.lifeCycleOwner UninitializedPropertyAccessException + sceneView.onCreate(owner) + sceneView.onResume(owner) + wrapView + }, + scope = scope, + registry = registry, + holderProvider = { wrapView -> + val design = state.mapDesignType + createdDesign.value = design.getValue() + val options = + ArcGISMapViewInitOptions( + basemapStyle = ArcGISDesign.toBasemapStyleOrNull(design), + elevationSources = design.elevationSources, + ) + + // No basemap is a scene with only what the app puts on it. The + // basemap is set on an empty scene rather than built into it, so + // a scene whose basemap is out of reach is still a scene (a + // global one is always WGS84) that can draw its own layers. + val scene = ArcGISScene() + options.basemapStyle?.let { scene.setBasemap(Basemap(it)) } + + options.elevationSources.forEach { + val source = ArcGISTiledElevationSource(it) + scene.baseSurface.elevationSources.add(source) + } + + wrapView.sceneView.scene = scene + var retried = false + + val coroutine = CoroutineScope(Dispatchers.Default) + + suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { coroutine.cancel() } + coroutine.launch { + scene.loadStatus.collect { + when (it) { + is LoadStatus.Loaded -> { + val holder = + ArcGISMapViewHolder( + mapView = wrapView, + map = wrapView.sceneView, + ) + cont.resume(holder) { _, _, _ -> } + } + is LoadStatus.FailedToLoad -> { + Log.w("ArcGISMapView", "scene failed to load: ${it.error.message}") + // The basemap was taken away while it was being + // fetched (the app went basemap-less, say for an + // offline raster): without one the scene needs no + // network, so it gets one more go. + if (scene.basemap.value == null && !retried) { + retried = true + scene.retryLoad() + return@collect + } + // Offline or network error: resume with a holder anyway so + // the scene view is displayed (possibly with cached tiles) + // without crashing. The ArcGIS view may be blank but usable. + if (cont.isActive) { + cont.resume( + ArcGISMapViewHolder( + mapView = wrapView, + map = wrapView.sceneView, + ), + ) { _, _, _ -> } + } + } + else -> { + // Do nothing here + } + } + } + } + } + }, + controllerProvider = { holder -> + + val markerLayer: GraphicsOverlay = + GraphicsOverlay().apply { + renderingMode = GraphicsRenderingMode.Dynamic + sceneProperties.surfacePlacement = SurfacePlacement.Relative + } + val markerController = + getMarkerController( + holder = holder, + markerLayer = markerLayer, + markerTiling = markerTiling ?: MarkerTilingOptions.Default, + ) + val polylineController = getPolylineController(holder) + val rasterLayerController = getRasterLayerController(holder) + val polygonController = getPolygonController(holder) + val circleController = getCircleController(holder) + val groundImageController = getGroundImageController(holder) + + // Defer initial camera update until controller is created and view is laid out + + ArcGISMapViewController( + holder = holder, + markerController = markerController, + polylineController = polylineController, + polygonController = polygonController, + circleController = circleController, + groundImageController = groundImageController, + rasterLayerController = rasterLayerController, + ).also { mapController -> + state.serviceRegistry.put( + MarkerRenderingSupportKey, + object : MarkerRenderingSupport { + override fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() + + override fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = + mapController + .createMarkerEventController(controller) + + override fun registerMarkerEventController( + controller: MarkerEventControllerInterface, + ) { + mapController.registerMarkerEventController(controller) + } + + override fun onMarkerRenderingReady() { + mapController.sendInitialCameraUpdate() + } + }, + ) + + // 3D SceneView は「タイルは 256px」という前提でレベルを選ぶ。 + // 512px のタイルを渡すと 1 段深いレベルを 4 倍の枚数で引く + // (実測: 統一ズーム 12 で 2D は z=11、3D は z=12)。絵は正しい + // ので気づきにくいが、タイル 1 枚あたりの固定費がそのぶん効く。 + // 2D は好みが無いので宣言しない — 供給側の既定に任せる。 + // 3D SceneView は「タイルは 256px」という自前の梯子で level を + // 選び、こちらが申告する縮尺では動かせない(実機で確認)。同じ + // カメラで 2D より 1 段深い level を引くので、512dp のつもりで + // 描いたタイルは半分の枠に入って文字も線も半分になる。 + // + // 256 を要求すると、選ばれる level と描画の前提が噛み合い、 + // 文字の大きさは MapLibre と一致する(実測)。残るのは元データの + // 違い — 1 段深いソースタイルには大きな地名が入っていないことが + // あり、低ズームで「新潟市」のような表示が落ちる。 + if (holder.usesSceneView) { + state.serviceRegistry.put( + RasterTilePreferenceKey, + object : RasterTilePreference { + override val preferredTileSize: Int = 256 + }, + ) + } + + controllerRef.value = mapController + mapController.setMapClickListener(onMapClick) + mapController.setMapLongClickListener(onMapLongClick) + mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + cameraRestriction?.let { mapController.setCameraRestriction(it) } + state.setController(mapController) + if (state.mapDesignType.getValue() != + createdDesign.value + ) { + mapController.setMapDesignType(state.mapDesignType) + } + + // Set camera listeners immediately so they are ready to receive + // camera updates from external sources (e.g. camera sync scenarios). + mapController.setCameraMoveStartListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveStart?.invoke(it) + } + mapController.setCameraMoveListener { + cameraState.value = it + state.updateCameraPosition(it) + // The tile server's ancestor gate needs to know which level + // the camera looks at. + mapController.rasterLayerController.renderer.cameraMoved(it.zoom) + onCameraMove?.invoke(it) + } + mapController.setCameraMoveEndListener { + cameraState.value = it + state.updateCameraPosition(it) + mapController.rasterLayerController.renderer.cameraMoved(it.zoom) + mapController.mainCoroutine.launch { + mapController.rasterLayerController.rebuildLocalLayersIfNeeded() + } + onCameraMoveEnd?.invoke(it) + } + + // Avoid early ArcGIS viewpoint updates overwriting the desired initial camera. + // Apply the initial camera after layout. + val initialCameraPosition = state.cameraPosition + val generation = controllerGeneration.incrementAndGet() + holder.mapView.post { + if (controllerGeneration.get() != generation) return@post + mapController.moveCamera(MapCameraPosition.from(initialCameraPosition)) + mapController.sendInitialCameraUpdate() + // viewpointChanged の初回発火に頼らず、レイアウト確定のこの時点で + // 「準備完了」を通知する(取り逃すとオーバーレイが一切描画されない)。 + mapController.markMapInitialized() + } + } + }, + sdkInitialize = { + sdkInitialize?.invoke(context) ?: defaultArcGISInitialize(context) + }, + onMapLoaded = onMapLoaded, + customDisposableEffect = { _, holderRef -> + DisposableEffect(state.id) { + onDispose { + // Invalidate any pending `post { ... }` work that captured older controllers. + controllerGeneration.incrementAndGet() + // Detach callbacks so a disposed controller cannot keep overwriting state.cameraPosition + // during rapid ArcGIS<->other provider switching. + controllerRef.value?.apply { + setCameraMoveStartListener(null) + setCameraMoveListener(null) + setCameraMoveEndListener(null) + setMapClickListener(null) + setMapLongClickListener(null) + // Free overlay controllers (tile-server routes, marker + // managers) and cancel the controller scope. + destroy() + } + controllerRef.value = null + state.clearController() + holderRef.value?.mapView?.apply { + onPause(owner) + onStop(owner) + onDestroy(owner) + } + } + } + }, + style = style, + onStyleDiagnostics = onStyleDiagnostics, + content = content, + ) +} + +internal fun getCircleController( + holder: ArcGISGeoViewHolder<*, *>, + useScenePlacement: Boolean = true, +): ArcGISCircleOverlayController { + val circleLayer: GraphicsOverlay = + GraphicsOverlay().apply { + if (useScenePlacement) { + sceneProperties.surfacePlacement = SurfacePlacement.DrapedFlat + } + } + holder.geoView.graphicsOverlays.add(circleLayer) + + val renderer = + ArcGISCircleOverlayRenderer( + circleLayer = circleLayer, + holder = holder, + ) + + val controller = + ArcGISCircleOverlayController( + renderer = renderer, + ) + return controller +} + +internal fun getPolylineController( + holder: ArcGISGeoViewHolder<*, *>, + useScenePlacement: Boolean = true, +): ArcGISPolylineOverlayController { + val polylineLayer: GraphicsOverlay = + GraphicsOverlay().apply { + if (useScenePlacement) { + sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded + } + } + holder.geoView.graphicsOverlays.add(polylineLayer) + + val renderer = + ArcGISPolylineOverlayRenderer( + polylineLayer = polylineLayer, + holder = holder, + ) + + val controller = + ArcGISPolylineOverlayController( + renderer = renderer, + ) + return controller +} + +internal fun getPolygonController( + holder: ArcGISGeoViewHolder<*, *>, + useScenePlacement: Boolean = true, +): ArcGISPolygonOverlayController { + val polygonLayer: GraphicsOverlay = + GraphicsOverlay().apply { + if (useScenePlacement) { + sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded + } + } + holder.geoView.graphicsOverlays.add(polygonLayer) + + val renderer = + ArcGISPolygonOverlayRenderer( + polygonLayer = polygonLayer, + holder = holder, + ) + + val controller = + ArcGISPolygonOverlayController( + renderer = renderer, + ) + return controller +} + +internal fun getMarkerController( + holder: ArcGISGeoViewHolder<*, *>, + markerLayer: GraphicsOverlay, + markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, +): ArcGISMarkerController { + val renderer = + ArcGISMarkerRenderer( + markerLayer = markerLayer, + holder = holder, + ) + + val markerManager = + MarkerManager.defaultManager( + minMarkerCount = markerTiling.minMarkerCount, + ) + + val controller = + ArcGISMarkerController( + markerManager = markerManager, + renderer = renderer, + markerTiling = markerTiling, + ) + return controller +} + +internal fun getRasterLayerController(holder: ArcGISGeoViewHolder<*, *>): ArcGISRasterLayerController { + val renderer = + ArcGISRasterLayerOverlayRenderer( + holder = holder, + ) + return ArcGISRasterLayerController( + renderer = renderer, + ) +} + +internal fun getGroundImageController(holder: ArcGISGeoViewHolder<*, *>): ArcGISGroundImageController { + val tileServer = TileServerRegistry.get() + val renderer = + ArcGISGroundImageOverlayRenderer( + holder = holder, + tileServer = tileServer, + ) + return ArcGISGroundImageController(renderer = renderer) +} + +/** + * Default ArcGIS SDK initialization using API Key authentication. + * + * This function is used when no custom sdkInitialize parameter is provided to ArcGISMapView. + * It reads the API Key from AndroidManifest.xml metadata and configures ArcGISEnvironment. + * + * @param context Application context + * @return true if initialization succeeded, false otherwise + */ +internal fun defaultArcGISInitialize(context: Context): Boolean { + if (ArcGISEnvironment.authenticationManager.arcGISCredentialStore + .getCredentials() + .isEmpty() + ) { + val apiKey = context.applicationContext.getArcGisApiKey() + if (apiKey == null) { + Log.e("ArcGISMapView", " is required") + return false + } + ArcGISEnvironment.apiKey = ApiKey.create(apiKey) + } + return true +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt index a39a437..7f271f4 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt @@ -1,274 +1,274 @@ -package com.mapconductor.arcgis - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.node.Ref -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.arcgismaps.LoadStatus -import com.arcgismaps.geometry.SpatialReference -import com.arcgismaps.mapping.ArcGISMap -import com.arcgismaps.mapping.Basemap -import com.arcgismaps.mapping.view.GraphicsOverlay -import com.arcgismaps.mapping.view.GraphicsRenderingMode -import com.arcgismaps.mapping.view.MapView -import com.mapconductor.compose.map.MapViewBase -import com.mapconductor.core.OnCameraMoveHandler -import com.mapconductor.core.OnMapEventHandler -import com.mapconductor.core.OnMapLoadedHandler -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapCameraPositionInterface -import com.mapconductor.core.map.MapViewStyle -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -import com.mapconductor.core.marker.MarkerRenderingSupport -import com.mapconductor.core.marker.MarkerRenderingSupportKey -import com.mapconductor.core.marker.MarkerTilingOptions -import com.mapconductor.core.marker.StrategyMarkerController -import java.util.concurrent.atomic.AtomicLong -import android.content.Context -import android.util.Log -import android.widget.FrameLayout -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine - -@Composable -fun ArcGISMapView2D( - state: ArcGISMapViewState, - modifier: Modifier = Modifier, - markerTiling: MarkerTilingOptions? = null, - cameraRestriction: CameraRestriction? = null, - sdkInitialize: (suspend (Context) -> Boolean)? = null, - onMapLoaded: OnMapLoadedHandler? = null, - onCameraMoveStart: OnCameraMoveHandler? = null, - onCameraMove: OnCameraMoveHandler? = null, - onCameraMoveEnd: OnCameraMoveHandler? = null, - onMapClick: OnMapEventHandler? = null, - onMapLongClick: OnMapEventHandler? = 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 ArcGISMapViewScope.() -> Unit)? = null, -) { - val scope = remember { ArcGISMapViewScope() } - val context = LocalContext.current - val registry = remember { scope.buildRegistry() } - val owner = LocalLifecycleOwner.current - // The design the map was built with; a design set on the state before the - // controller existed is applied once it does. - val createdDesign = remember { Ref() } - val cameraState = remember { mutableStateOf(state.cameraPosition) } - val controllerRef = remember { Ref() } - val controllerGeneration = remember { AtomicLong(0L) } - - MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = { - val mapView = MapView(context) - val wrapView = - WrapMapView(context).apply { - addView(mapView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - } - wrapView.arcGISMapView = mapView - mapView.onCreate(owner) - mapView.onResume(owner) - wrapView - }, - scope = scope, - registry = registry, - holderProvider = { wrapView -> - // The map is told its spatial reference up front instead of - // learning it from the basemap: a basemap style lives on - // arcgis.com, and a map that waited for it to find out where it - // is can draw nothing while the network is away. Told, it loads - // at once with no basemap, and with one it keeps drawing its own - // layers (a raster layer served on the device, say) when the - // basemap is out of reach. - val design = state.mapDesignType - createdDesign.value = design.getValue() - val map = ArcGISMap(SpatialReference.webMercator()) - ArcGISDesign.toBasemapStyleOrNull(design)?.let { map.setBasemap(Basemap(it)) } - wrapView.arcGISMapView.map = map - var retried = false - - val coroutine = CoroutineScope(Dispatchers.Default) - suspendCancellableCoroutine { cont -> - cont.invokeOnCancellation { coroutine.cancel() } - coroutine.launch { - map.loadStatus.collect { - when (it) { - is LoadStatus.Loaded -> { - cont.resume( - ArcGISMapView2DHolder( - mapView = wrapView, - map = wrapView.arcGISMapView, - ), - ) { _, _, _ -> } - } - is LoadStatus.FailedToLoad -> { - Log.w("ArcGISMapView2D", "map failed to load: ${it.error.message}") - // The basemap was taken away while it was being - // fetched (the app went basemap-less, say for an - // offline raster): without one the map needs no - // network, so it gets one more go. - if (map.basemap.value == null && !retried) { - retried = true - map.retryLoad() - return@collect - } - if (cont.isActive) { - cont.resume( - ArcGISMapView2DHolder( - mapView = wrapView, - map = wrapView.arcGISMapView, - ), - ) { _, _, _ -> } - } - } - else -> { - // Do nothing here - } - } - } - } - } - }, - controllerProvider = { holder -> - val markerLayer: GraphicsOverlay = - GraphicsOverlay().apply { - renderingMode = GraphicsRenderingMode.Dynamic - } - val markerController = - getMarkerController( - holder = holder, - markerLayer = markerLayer, - markerTiling = markerTiling ?: MarkerTilingOptions.Default, - ) - val polylineController = getPolylineController(holder, useScenePlacement = false) - val rasterLayerController = getRasterLayerController(holder) - val polygonController = getPolygonController(holder, useScenePlacement = false) - val circleController = getCircleController(holder, useScenePlacement = false) - val groundImageController = getGroundImageController(holder) - - ArcGISMapView2DController( - holder = holder, - markerController = markerController, - polylineController = polylineController, - polygonController = polygonController, - circleController = circleController, - groundImageController = groundImageController, - rasterLayerController = rasterLayerController, - ).also { mapController -> - state.serviceRegistry.put( - MarkerRenderingSupportKey, - object : MarkerRenderingSupport { - override fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() - - override fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = - mapController - .createMarkerEventController(controller) - - override fun registerMarkerEventController( - controller: MarkerEventControllerInterface, - ) { - mapController.registerMarkerEventController(controller) - } - - override fun onMarkerRenderingReady() { - mapController.sendInitialCameraUpdate() - } - }, - ) - - controllerRef.value = mapController - mapController.setMapClickListener(onMapClick) - mapController.setMapLongClickListener(onMapLongClick) - mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - state.setController(mapController) - if (state.mapDesignType.getValue() != - createdDesign.value - ) { - mapController.setMapDesignType(state.mapDesignType) - } - // 他プロバイダの *MapView と同じく、コントローラ生成直後に適用する。 - cameraRestriction?.let { mapController.setCameraRestriction(it) } - - mapController.setCameraMoveStartListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveStart?.invoke(it) - } - mapController.setCameraMoveListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMove?.invoke(it) - } - mapController.setCameraMoveEndListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveEnd?.invoke(it) - } - - val initialCameraPosition = state.cameraPosition - val generation = controllerGeneration.incrementAndGet() - holder.mapView.post { - if (controllerGeneration.get() != generation) return@post - mapController.moveCamera(MapCameraPosition.from(initialCameraPosition)) - mapController.sendInitialCameraUpdate() - // viewpointChanged の初回発火に頼らず、レイアウト確定のこの時点で - // 「準備完了」を通知する(取り逃すとオーバーレイが一切描画されない)。 - mapController.markMapInitialized() - } - } - }, - sdkInitialize = { - sdkInitialize?.invoke(context) ?: defaultArcGISInitialize(context) - }, - onMapLoaded = onMapLoaded, - customDisposableEffect = { _, holderRef -> - DisposableEffect(state.id) { - onDispose { - controllerGeneration.incrementAndGet() - controllerRef.value?.apply { - setCameraMoveStartListener(null) - setCameraMoveListener(null) - setCameraMoveEndListener(null) - setMapClickListener(null) - setMapLongClickListener(null) - } - controllerRef.value = null - state.clearController() - holderRef.value?.mapView?.apply { - onPause(owner) - onStop(owner) - onDestroy(owner) - } - } - } - }, - style = style, - onStyleDiagnostics = onStyleDiagnostics, - content = content, - ) -} +package com.mapconductor.arcgis + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.arcgismaps.LoadStatus +import com.arcgismaps.geometry.SpatialReference +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.Basemap +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.arcgismaps.mapping.view.GraphicsRenderingMode +import com.arcgismaps.mapping.view.MapView +import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.core.OnCameraMoveHandler +import com.mapconductor.core.OnMapEventHandler +import com.mapconductor.core.OnMapLoadedHandler +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapViewStyle +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +import com.mapconductor.core.marker.MarkerRenderingSupport +import com.mapconductor.core.marker.MarkerRenderingSupportKey +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.StrategyMarkerController +import java.util.concurrent.atomic.AtomicLong +import android.content.Context +import android.util.Log +import android.widget.FrameLayout +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine + +@Composable +fun ArcGISMapView2D( + state: ArcGISMapViewState, + modifier: Modifier = Modifier, + markerTiling: MarkerTilingOptions? = null, + cameraRestriction: CameraRestriction? = null, + sdkInitialize: (suspend (Context) -> Boolean)? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onCameraMoveStart: OnCameraMoveHandler? = null, + onCameraMove: OnCameraMoveHandler? = null, + onCameraMoveEnd: OnCameraMoveHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMapLongClick: OnMapEventHandler? = 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 ArcGISMapViewScope.() -> Unit)? = null, +) { + val scope = remember { ArcGISMapViewScope() } + val context = LocalContext.current + val registry = remember { scope.buildRegistry() } + val owner = LocalLifecycleOwner.current + // The design the map was built with; a design set on the state before the + // controller existed is applied once it does. + val createdDesign = remember { Ref() } + val cameraState = remember { mutableStateOf(state.cameraPosition) } + val controllerRef = remember { Ref() } + val controllerGeneration = remember { AtomicLong(0L) } + + MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = { + val mapView = MapView(context) + val wrapView = + WrapMapView(context).apply { + addView(mapView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + } + wrapView.arcGISMapView = mapView + mapView.onCreate(owner) + mapView.onResume(owner) + wrapView + }, + scope = scope, + registry = registry, + holderProvider = { wrapView -> + // The map is told its spatial reference up front instead of + // learning it from the basemap: a basemap style lives on + // arcgis.com, and a map that waited for it to find out where it + // is can draw nothing while the network is away. Told, it loads + // at once with no basemap, and with one it keeps drawing its own + // layers (a raster layer served on the device, say) when the + // basemap is out of reach. + val design = state.mapDesignType + createdDesign.value = design.getValue() + val map = ArcGISMap(SpatialReference.webMercator()) + ArcGISDesign.toBasemapStyleOrNull(design)?.let { map.setBasemap(Basemap(it)) } + wrapView.arcGISMapView.map = map + var retried = false + + val coroutine = CoroutineScope(Dispatchers.Default) + suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { coroutine.cancel() } + coroutine.launch { + map.loadStatus.collect { + when (it) { + is LoadStatus.Loaded -> { + cont.resume( + ArcGISMapView2DHolder( + mapView = wrapView, + map = wrapView.arcGISMapView, + ), + ) { _, _, _ -> } + } + is LoadStatus.FailedToLoad -> { + Log.w("ArcGISMapView2D", "map failed to load: ${it.error.message}") + // The basemap was taken away while it was being + // fetched (the app went basemap-less, say for an + // offline raster): without one the map needs no + // network, so it gets one more go. + if (map.basemap.value == null && !retried) { + retried = true + map.retryLoad() + return@collect + } + if (cont.isActive) { + cont.resume( + ArcGISMapView2DHolder( + mapView = wrapView, + map = wrapView.arcGISMapView, + ), + ) { _, _, _ -> } + } + } + else -> { + // Do nothing here + } + } + } + } + } + }, + controllerProvider = { holder -> + val markerLayer: GraphicsOverlay = + GraphicsOverlay().apply { + renderingMode = GraphicsRenderingMode.Dynamic + } + val markerController = + getMarkerController( + holder = holder, + markerLayer = markerLayer, + markerTiling = markerTiling ?: MarkerTilingOptions.Default, + ) + val polylineController = getPolylineController(holder, useScenePlacement = false) + val rasterLayerController = getRasterLayerController(holder) + val polygonController = getPolygonController(holder, useScenePlacement = false) + val circleController = getCircleController(holder, useScenePlacement = false) + val groundImageController = getGroundImageController(holder) + + ArcGISMapView2DController( + holder = holder, + markerController = markerController, + polylineController = polylineController, + polygonController = polygonController, + circleController = circleController, + groundImageController = groundImageController, + rasterLayerController = rasterLayerController, + ).also { mapController -> + state.serviceRegistry.put( + MarkerRenderingSupportKey, + object : MarkerRenderingSupport { + override fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() + + override fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = + mapController + .createMarkerEventController(controller) + + override fun registerMarkerEventController( + controller: MarkerEventControllerInterface, + ) { + mapController.registerMarkerEventController(controller) + } + + override fun onMarkerRenderingReady() { + mapController.sendInitialCameraUpdate() + } + }, + ) + + controllerRef.value = mapController + mapController.setMapClickListener(onMapClick) + mapController.setMapLongClickListener(onMapLongClick) + mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + state.setController(mapController) + if (state.mapDesignType.getValue() != + createdDesign.value + ) { + mapController.setMapDesignType(state.mapDesignType) + } + // 他プロバイダの *MapView と同じく、コントローラ生成直後に適用する。 + cameraRestriction?.let { mapController.setCameraRestriction(it) } + + mapController.setCameraMoveStartListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveStart?.invoke(it) + } + mapController.setCameraMoveListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMove?.invoke(it) + } + mapController.setCameraMoveEndListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveEnd?.invoke(it) + } + + val initialCameraPosition = state.cameraPosition + val generation = controllerGeneration.incrementAndGet() + holder.mapView.post { + if (controllerGeneration.get() != generation) return@post + mapController.moveCamera(MapCameraPosition.from(initialCameraPosition)) + mapController.sendInitialCameraUpdate() + // viewpointChanged の初回発火に頼らず、レイアウト確定のこの時点で + // 「準備完了」を通知する(取り逃すとオーバーレイが一切描画されない)。 + mapController.markMapInitialized() + } + } + }, + sdkInitialize = { + sdkInitialize?.invoke(context) ?: defaultArcGISInitialize(context) + }, + onMapLoaded = onMapLoaded, + customDisposableEffect = { _, holderRef -> + DisposableEffect(state.id) { + onDispose { + controllerGeneration.incrementAndGet() + controllerRef.value?.apply { + setCameraMoveStartListener(null) + setCameraMoveListener(null) + setCameraMoveEndListener(null) + setMapClickListener(null) + setMapLongClickListener(null) + } + controllerRef.value = null + state.clearController() + holderRef.value?.mapView?.apply { + onPause(owner) + onStop(owner) + onDestroy(owner) + } + } + } + }, + style = style, + onStyleDiagnostics = onStyleDiagnostics, + content = content, + ) +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt index dc27259..16dd4db 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt @@ -1,421 +1,421 @@ -package com.mapconductor.arcgis - -import com.arcgismaps.LoadStatus -import com.arcgismaps.geometry.SpatialReference -import com.arcgismaps.mapping.Viewpoint -import com.arcgismaps.mapping.view.GraphicsOverlay -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController -import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController -import com.mapconductor.arcgis.marker.ArcGISMarkerController -import com.mapconductor.arcgis.marker.ArcGISMarkerEventController -import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController -import com.mapconductor.arcgis.raster.ArcGISRasterLayerController -import com.mapconductor.core.circle.CircleState -import com.mapconductor.core.circle.OnCircleEventHandler -import com.mapconductor.core.controller.BaseMapViewController -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.features.GeoRectBounds -import com.mapconductor.core.groundimage.GroundImageState -import com.mapconductor.core.groundimage.OnGroundImageEventHandler -import com.mapconductor.core.map.CameraBearing -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapGesture -import com.mapconductor.core.map.MapUISettings -import com.mapconductor.core.map.MapUISettingsDiagnostics -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerState -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.core.polyline.PolylineState -import com.mapconductor.core.projection.Earth -import com.mapconductor.core.raster.RasterLayerState -import kotlin.math.ln -import kotlin.math.pow -import android.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -class ArcGISMapView2DController( - override val holder: ArcGISMapView2DHolder, - internal val markerController: ArcGISMarkerController, - internal val polylineController: ArcGISPolylineOverlayController, - internal val polygonController: ArcGISPolygonOverlayController, - internal val circleController: ArcGISCircleOverlayController, - internal val groundImageController: ArcGISGroundImageController, - internal val rasterLayerController: ArcGISRasterLayerController, - override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), - override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : BaseMapViewController(), - ArcGISMapViewControllerInterface { - internal val markerEventControllers = mutableListOf() - internal var activeDragController: ArcGISMarkerEventController? = 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 cameraMoveEndJob: Job? = null - - // 直近に要求した論理カメラ位置。2D はカメラピッチを持てず tilt を擬似表現しているため、 - // カメラ状態の読み戻し時に元の tilt・位置・ズームを復元するヒントとして保持する - // (android-for-tomtom / ios-for-arcgis と同方針)。 - internal var lastLogicalCameraPosition: MapCameraPosition? = null - internal val cameraMoveEndDebounceMs = 180L - internal var mapDesignType: ArcGISDesignTypeInterface = ArcGISDesign.Streets - internal var mapDesignTypeChangeListener: ArcGISDesignTypeChangeHandler? = null - - init { - holder.map.graphicsOverlays.clear() - holder.map.graphicsOverlays.add(circleController.renderer.circleLayer) - holder.map.graphicsOverlays.add(polygonController.renderer.polygonLayer) - holder.map.graphicsOverlays.add(polylineController.renderer.polylineLayer) - holder.map.graphicsOverlays.add((markerController.renderer as ArcGISMarkerRenderer).markerLayer) - setupListeners() - registerOverlayController(markerController) - registerOverlayController(polygonController) - registerOverlayController(polylineController) - registerOverlayController(circleController) - registerOverlayController(groundImageController) - registerOverlayController(rasterLayerController) - registerMarkerEventController(ArcGISMarkerEventController(markerController)) - - markerController.setRasterLayerCallback { state -> - if (state != null) { - rasterLayerController.upsert(state) - } else { - rasterLayerController.rasterLayerManager - .allEntities() - .filter { it.state.id.startsWith("marker-tile-") } - .forEach { entity -> rasterLayerController.removeById(entity.state.id) } - } - } - } - - 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) - - /** - * マーカーのヒットテスト。クリックカスケードの先頭。 - * - * ArcGIS は地図タップの座標からそのまま引けるので、コアの - * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 - * 長押し(ドラッグ開始)だけは identifyGraphicsOverlay を使うので Gestures 側に残る。 - */ - override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = - markerEventControllers.dispatchGeoMarkerClick(position) - - // 拡張ファイル(Camera / Gestures)からは基底クラスの protected へ触れないため、 - // ここで internal の入口を用意しておく。 - internal fun cameraMoveStartHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveStartCallback - - internal fun cameraMoveHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveCallback - - internal fun needsCameraMoveEndWork(): Boolean = cameraMoveEndCallback != null || hasCameraRestriction() - - internal fun emitCameraMoveEnd(position: MapCameraPosition) { - cameraMoveEndCallback?.invoke(position) - } - - internal fun emitMapInitialized() { - notifyMapInitialized() - } - - internal suspend fun emitCameraPosition(position: MapCameraPosition) { - notifyMapCameraPosition(position) - } - - internal fun correctForCameraRestriction(current: MapCameraPosition): MapCameraPosition? = - cameraRestrictionCorrection(current) - - fun setupListeners() { - mainCoroutine.launch { holder.map.onSingleTapConfirmed.collect { onMapTap(it) } } - defaultCoroutine.launch { holder.map.viewpointChanged.collect { onViewpointChange() } } - defaultCoroutine.launch { holder.map.onInteractiveZooming.collect { invokeCameraMoveCallback() } } - defaultCoroutine.launch { holder.map.onRotate.collect { invokeCameraMoveCallback() } } - mainCoroutine.launch { holder.map.onLongPress.collect { onMapLongPress(it) } } - mainCoroutine.launch { holder.map.onUp.collect { onMapUp(it) } } - mainCoroutine.launch { holder.map.onPan.collect { onMapPan(it) } } - } - - override fun hasMarker(state: MarkerState): Boolean = markerController.markerManager.hasEntity(state.id) - - override fun hasPolyline(state: PolylineState): Boolean = polylineController.polylineManager.hasEntity(state.id) - - override fun hasPolygon(state: PolygonState): Boolean = polygonController.polygonManager.hasEntity(state.id) - - override fun hasCircle(state: CircleState): Boolean = circleController.circleManager.hasEntity(state.id) - - override fun hasGroundImage(state: GroundImageState): Boolean = - groundImageController.groundImageManager.hasEntity(state.id) - - override fun hasRasterLayer(state: RasterLayerState): Boolean = - rasterLayerController.rasterLayerManager.hasEntity(state.id) - - /** - * カメラの可動範囲を制限する。 - * - * ズームは ArcGIS のネイティブなスケール制限([com.arcgismaps.mapping.ArcGISMap.minScale] / - * [com.arcgismaps.mapping.ArcGISMap.maxScale])へ変換して適用する。ArcGIS の縮尺は分母(1:N)で - * 表され、`minScale` が「最も引いた側」= N が大きい方、`maxScale` が「最も寄せた側」= N が - * 小さい方に対応するため、統一ズームとは大小が逆になる(`minZoom → minScale` / - * `maxZoom → maxScale`)。 - * - * パン範囲(矩形)は ArcGIS に中心基準の制限 API が無いため、これまでどおりカメラ停止時の - * クランプで制限する([invokeCameraMoveEndCallback])。 - * - * 縮尺は投影座標系上の公称縮尺で緯度に依存しないため([zoomToScale])、`minScale` / - * `maxScale` のような緯度に依らない定数へそのまま変換できる。 - */ - override fun setCameraRestriction(restriction: CameraRestriction?) { - super.setCameraRestriction(restriction) - // ビュー破棄後はネイティブアクセサが例外を投げるため runCatching で保護する - // (このホルダの他アクセサと同じ扱い)。 - runCatching { - holder.map.map?.let { arcGISMap -> - arcGISMap.minScale = restriction?.minZoom?.let { zoomToScale(it) } - arcGISMap.maxScale = restriction?.maxZoom?.let { zoomToScale(it) } - } - } - } - - internal var appliedUISettings: MapUISettings = MapUISettings.Default - - /** - * 3D([ArcGISMapViewController])と同じ実装。2D の `MapView` も同じ - * `interactionOptions` を持つため、有効/無効の対応は完全に一致する。 - */ - override fun applyUISettings(settings: MapUISettings) { - appliedUISettings = settings - MapUISettingsDiagnostics.warnIfRequested( - settings.tiltGesture, - gesture = MapGesture.Tilt, - provider = "ArcGIS", - reason = "InteractionOptions has no separate tilt toggle", - ) - // ビュー破棄後はネイティブアクセサが例外を投げるため runCatching で保護する。 - runCatching { - with(holder.map) { - interactionOptions.isPanEnabled = settings.scrollGesture - interactionOptions.isRotateEnabled = settings.rotateGesture - interactionOptions.isZoomEnabled = settings.zoomGesture - } - } - } - - override suspend fun clearOverlays() { - markerController.clear() - groundImageController.clear() - polylineController.clear() - polygonController.clear() - circleController.clear() - rasterLayerController.clear() - } - - @Deprecated("Use CircleState.onClick instead.") - override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { - circleController.clickListener = listener - } - - @Deprecated("Use GroundImageState.onClick instead.") - override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { - groundImageController.clickListener = listener - } - - /** - * 2D の MapView はカメラピッチを持てないため、tilt < 0(上向き)は中心の前進と - * ズーム補正で近似する([ArcGIS2DTiltEmulation])。tilt >= 0 は他プロバイダと同じく - * 指定位置がそのまま画面中心に来る。 - */ - internal fun toViewpoint(position: MapCameraPosition): Viewpoint { - val (center, zoom) = ArcGIS2DTiltEmulation.shiftedCamera(position) - val point = GeoPoint.from(center).toPoint(SpatialReference.wgs84()) - return Viewpoint( - center = point, - scale = zoomToScale(zoom), - // ArcGIS Maps SDK for Kotlin の Viewpoint.rotation は反時計回り、つまり - // カメラの heading と同じ向きである(**JS の MapView.rotation とは逆**。 - // @arcgis/core の型定義は "due north is rotated 90 degrees, pointing to the - // right side of the view" と書いており、そちらは rotation 系)。 - // 実機(TB520FU / Tilt ページ)で MapLibre と並べて確認した結果がこれ。 - rotation = CameraBearing.toNativeHeading(position.bearing), - ) - } - - @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 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 PolylineState.onClick instead.") - override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { - polylineController.clickListener = listener - } - - @Deprecated("Use PolygonState.onClick instead.") - override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { - polygonController.clickListener = listener - } - - override fun setMapDesignType(value: ArcGISDesignTypeInterface) { - val map = holder.map.map ?: return - map.setBasemap(ArcGISDesign.toBasemap(value)) - // A map that failed to load -- its basemap was out of reach -- gets - // another go with the new basemap, or with none, which needs no - // network at all. - if (map.loadStatus.value is LoadStatus.FailedToLoad) { - defaultCoroutine.launch { - val result = map.retryLoad() - Log - .d("ArcGISMapView2D", "map reloaded with basemap=${value.getValue()}: ${result.isSuccess} ${result.exceptionOrNull()?.message ?: ""}") - } - } - } - - override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { - mapDesignTypeChangeListener = listener - listener(mapDesignType) - } - - /** - * 「マップの準備ができた」ことをコンポーズ側へ通知する。 - * - * これまで ArcGIS は基底の [notifyMapInitialized](sticky。リスナー未登録でも記録して後で - * 配送する)を使わず、[onViewpointChange] で `mapInitializedCallback` を直接呼んでいた。 - * `MapViewBase` は `InitState.MapLoaded` になるまで `CollectAndRenderOverlays` を - * コンポーズしないため、この 1 回きりのイベントを取り逃すとマーカー・ポリゴン・InfoBubble が - * 一切描画されない。`viewpointChanged` は replay を持たないホットフローで、購読は - * `setupListeners()` がコルーチンで非同期に始めるので、初期ビューポート確定がその購読より - * 早いと通知が失われる。2D は初期ビューポートが即座に確定して以後動かないため、 - * 起動のたびにマーカーが出たり出なかったりする形で表面化した。 - * - * そこでレイアウト確定後に呼び出し側から明示的に通知する。基底の実装が sticky かつ - * 一度しか配送しないので、[onViewpointChange] 側と重なっても二重通知にはならない。 - * Compose の状態を書き換えるためメインディスパッチャで呼ぶ。 - */ - fun markMapInitialized() { - mainCoroutine.launch { notifyMapInitialized() } - } - - fun sendInitialCameraUpdate() { - defaultCoroutine.launch { - if (holder.map.width <= 0 || holder.map.height <= 0) return@launch - getMapCameraPosition()?.let { notifyMapCameraPosition(it) } - } - } - - internal fun registerMarkerEventController(controller: ArcGISMarkerEventController) { - 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) - } - - fun createMarkerRenderer(): MarkerOverlayRendererInterface { - val markerLayer = GraphicsOverlay() - registerMarkerOverlayLayer(markerLayer) - return ArcGISMarkerRenderer( - markerLayer = markerLayer, - holder = holder, - ) - } - - fun createMarkerEventController( - controller: StrategyMarkerController, - ): MarkerEventControllerInterface = ArcGISMarkerEventController(controller) - - fun registerMarkerEventController(controller: MarkerEventControllerInterface) { - val typed = controller as? ArcGISMarkerEventController ?: return - registerMarkerEventController(typed) - } - - internal fun registerMarkerOverlayLayer(layer: GraphicsOverlay) { - if (holder.map.graphicsOverlays.contains(layer)) return - holder.map.graphicsOverlays.add(layer) - } - - /** - * 統一ズーム(Google 準拠)→ ArcGIS の縮尺分母。 - * - * ArcGIS が扱う縮尺は Web メルカトルの **投影座標系上** の縮尺(公称縮尺)で、緯度による - * 補正は含まない。Google のズームも同じく投影座標系(256px タイルで世界一周)で定義されて - * いるため、両者は緯度に依存しない 1 対 1 の対応になる。 - * - * 以前はここに `cos(latitude)` を掛けて「地表の実距離」に直していたため、高緯度ほど縮尺が - * 小さく(=寄りすぎに)なっていた。緯度 65 度で Google Maps 比 2.4 倍ほど拡大されており、 - * 同じ `MapCameraPosition` を渡しても表示範囲が一致しなかった。 - */ - private fun zoomToScale(zoom: Double): Double { - val resolution = WEB_MERCATOR_CIRCUMFERENCE_METERS / (TILE_SIZE * 2.0.pow(zoom)) - return resolution * PIXELS_PER_METER_AT_96_DPI - } - - /** [zoomToScale] の逆変換。 */ - internal fun scaleToZoom(scale: Double): Double { - val resolution = scale / PIXELS_PER_METER_AT_96_DPI - return ln(WEB_MERCATOR_CIRCUMFERENCE_METERS / (TILE_SIZE * resolution)) / ln(2.0) - } - - companion object { - private const val WEB_MERCATOR_CIRCUMFERENCE_METERS = Earth.CIRCUMFERENCE_METERS - private const val TILE_SIZE = 256.0 - - /** 96 DPI(ESRI が縮尺計算に用いる標準値)における 1 メートルあたりのピクセル数。 */ - private const val PIXELS_PER_METER_AT_96_DPI = 96.0 / 0.0254 - } -} +package com.mapconductor.arcgis + +import com.arcgismaps.LoadStatus +import com.arcgismaps.geometry.SpatialReference +import com.arcgismaps.mapping.Viewpoint +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController +import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController +import com.mapconductor.arcgis.marker.ArcGISMarkerController +import com.mapconductor.arcgis.marker.ArcGISMarkerEventController +import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController +import com.mapconductor.arcgis.raster.ArcGISRasterLayerController +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.circle.OnCircleEventHandler +import com.mapconductor.core.controller.BaseMapViewController +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.groundimage.OnGroundImageEventHandler +import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapGesture +import com.mapconductor.core.map.MapUISettings +import com.mapconductor.core.map.MapUISettingsDiagnostics +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerState +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.core.polyline.PolylineState +import com.mapconductor.core.projection.Earth +import com.mapconductor.core.raster.RasterLayerState +import kotlin.math.ln +import kotlin.math.pow +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class ArcGISMapView2DController( + override val holder: ArcGISMapView2DHolder, + internal val markerController: ArcGISMarkerController, + internal val polylineController: ArcGISPolylineOverlayController, + internal val polygonController: ArcGISPolygonOverlayController, + internal val circleController: ArcGISCircleOverlayController, + internal val groundImageController: ArcGISGroundImageController, + internal val rasterLayerController: ArcGISRasterLayerController, + override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), + override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : BaseMapViewController(), + ArcGISMapViewControllerInterface { + internal val markerEventControllers = mutableListOf() + internal var activeDragController: ArcGISMarkerEventController? = 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 cameraMoveEndJob: Job? = null + + // 直近に要求した論理カメラ位置。2D はカメラピッチを持てず tilt を擬似表現しているため、 + // カメラ状態の読み戻し時に元の tilt・位置・ズームを復元するヒントとして保持する + // (android-for-tomtom / ios-for-arcgis と同方針)。 + internal var lastLogicalCameraPosition: MapCameraPosition? = null + internal val cameraMoveEndDebounceMs = 180L + internal var mapDesignType: ArcGISDesignTypeInterface = ArcGISDesign.Streets + internal var mapDesignTypeChangeListener: ArcGISDesignTypeChangeHandler? = null + + init { + holder.map.graphicsOverlays.clear() + holder.map.graphicsOverlays.add(circleController.renderer.circleLayer) + holder.map.graphicsOverlays.add(polygonController.renderer.polygonLayer) + holder.map.graphicsOverlays.add(polylineController.renderer.polylineLayer) + holder.map.graphicsOverlays.add((markerController.renderer as ArcGISMarkerRenderer).markerLayer) + setupListeners() + registerOverlayController(markerController) + registerOverlayController(polygonController) + registerOverlayController(polylineController) + registerOverlayController(circleController) + registerOverlayController(groundImageController) + registerOverlayController(rasterLayerController) + registerMarkerEventController(ArcGISMarkerEventController(markerController)) + + markerController.setRasterLayerCallback { state -> + if (state != null) { + rasterLayerController.upsert(state) + } else { + rasterLayerController.rasterLayerManager + .allEntities() + .filter { it.state.id.startsWith("marker-tile-") } + .forEach { entity -> rasterLayerController.removeById(entity.state.id) } + } + } + } + + 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) + + /** + * マーカーのヒットテスト。クリックカスケードの先頭。 + * + * ArcGIS は地図タップの座標からそのまま引けるので、コアの + * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 + * 長押し(ドラッグ開始)だけは identifyGraphicsOverlay を使うので Gestures 側に残る。 + */ + override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = + markerEventControllers.dispatchGeoMarkerClick(position) + + // 拡張ファイル(Camera / Gestures)からは基底クラスの protected へ触れないため、 + // ここで internal の入口を用意しておく。 + internal fun cameraMoveStartHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveStartCallback + + internal fun cameraMoveHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveCallback + + internal fun needsCameraMoveEndWork(): Boolean = cameraMoveEndCallback != null || hasCameraRestriction() + + internal fun emitCameraMoveEnd(position: MapCameraPosition) { + cameraMoveEndCallback?.invoke(position) + } + + internal fun emitMapInitialized() { + notifyMapInitialized() + } + + internal suspend fun emitCameraPosition(position: MapCameraPosition) { + notifyMapCameraPosition(position) + } + + internal fun correctForCameraRestriction(current: MapCameraPosition): MapCameraPosition? = + cameraRestrictionCorrection(current) + + fun setupListeners() { + mainCoroutine.launch { holder.map.onSingleTapConfirmed.collect { onMapTap(it) } } + defaultCoroutine.launch { holder.map.viewpointChanged.collect { onViewpointChange() } } + defaultCoroutine.launch { holder.map.onInteractiveZooming.collect { invokeCameraMoveCallback() } } + defaultCoroutine.launch { holder.map.onRotate.collect { invokeCameraMoveCallback() } } + mainCoroutine.launch { holder.map.onLongPress.collect { onMapLongPress(it) } } + mainCoroutine.launch { holder.map.onUp.collect { onMapUp(it) } } + mainCoroutine.launch { holder.map.onPan.collect { onMapPan(it) } } + } + + override fun hasMarker(state: MarkerState): Boolean = markerController.markerManager.hasEntity(state.id) + + override fun hasPolyline(state: PolylineState): Boolean = polylineController.polylineManager.hasEntity(state.id) + + override fun hasPolygon(state: PolygonState): Boolean = polygonController.polygonManager.hasEntity(state.id) + + override fun hasCircle(state: CircleState): Boolean = circleController.circleManager.hasEntity(state.id) + + override fun hasGroundImage(state: GroundImageState): Boolean = + groundImageController.groundImageManager.hasEntity(state.id) + + override fun hasRasterLayer(state: RasterLayerState): Boolean = + rasterLayerController.rasterLayerManager.hasEntity(state.id) + + /** + * カメラの可動範囲を制限する。 + * + * ズームは ArcGIS のネイティブなスケール制限([com.arcgismaps.mapping.ArcGISMap.minScale] / + * [com.arcgismaps.mapping.ArcGISMap.maxScale])へ変換して適用する。ArcGIS の縮尺は分母(1:N)で + * 表され、`minScale` が「最も引いた側」= N が大きい方、`maxScale` が「最も寄せた側」= N が + * 小さい方に対応するため、統一ズームとは大小が逆になる(`minZoom → minScale` / + * `maxZoom → maxScale`)。 + * + * パン範囲(矩形)は ArcGIS に中心基準の制限 API が無いため、これまでどおりカメラ停止時の + * クランプで制限する([invokeCameraMoveEndCallback])。 + * + * 縮尺は投影座標系上の公称縮尺で緯度に依存しないため([zoomToScale])、`minScale` / + * `maxScale` のような緯度に依らない定数へそのまま変換できる。 + */ + override fun setCameraRestriction(restriction: CameraRestriction?) { + super.setCameraRestriction(restriction) + // ビュー破棄後はネイティブアクセサが例外を投げるため runCatching で保護する + // (このホルダの他アクセサと同じ扱い)。 + runCatching { + holder.map.map?.let { arcGISMap -> + arcGISMap.minScale = restriction?.minZoom?.let { zoomToScale(it) } + arcGISMap.maxScale = restriction?.maxZoom?.let { zoomToScale(it) } + } + } + } + + internal var appliedUISettings: MapUISettings = MapUISettings.Default + + /** + * 3D([ArcGISMapViewController])と同じ実装。2D の `MapView` も同じ + * `interactionOptions` を持つため、有効/無効の対応は完全に一致する。 + */ + override fun applyUISettings(settings: MapUISettings) { + appliedUISettings = settings + MapUISettingsDiagnostics.warnIfRequested( + settings.tiltGesture, + gesture = MapGesture.Tilt, + provider = "ArcGIS", + reason = "InteractionOptions has no separate tilt toggle", + ) + // ビュー破棄後はネイティブアクセサが例外を投げるため runCatching で保護する。 + runCatching { + with(holder.map) { + interactionOptions.isPanEnabled = settings.scrollGesture + interactionOptions.isRotateEnabled = settings.rotateGesture + interactionOptions.isZoomEnabled = settings.zoomGesture + } + } + } + + override suspend fun clearOverlays() { + markerController.clear() + groundImageController.clear() + polylineController.clear() + polygonController.clear() + circleController.clear() + rasterLayerController.clear() + } + + @Deprecated("Use CircleState.onClick instead.") + override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { + circleController.clickListener = listener + } + + @Deprecated("Use GroundImageState.onClick instead.") + override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { + groundImageController.clickListener = listener + } + + /** + * 2D の MapView はカメラピッチを持てないため、tilt < 0(上向き)は中心の前進と + * ズーム補正で近似する([ArcGIS2DTiltEmulation])。tilt >= 0 は他プロバイダと同じく + * 指定位置がそのまま画面中心に来る。 + */ + internal fun toViewpoint(position: MapCameraPosition): Viewpoint { + val (center, zoom) = ArcGIS2DTiltEmulation.shiftedCamera(position) + val point = GeoPoint.from(center).toPoint(SpatialReference.wgs84()) + return Viewpoint( + center = point, + scale = zoomToScale(zoom), + // ArcGIS Maps SDK for Kotlin の Viewpoint.rotation は反時計回り、つまり + // カメラの heading と同じ向きである(**JS の MapView.rotation とは逆**。 + // @arcgis/core の型定義は "due north is rotated 90 degrees, pointing to the + // right side of the view" と書いており、そちらは rotation 系)。 + // 実機(TB520FU / Tilt ページ)で MapLibre と並べて確認した結果がこれ。 + rotation = CameraBearing.toNativeHeading(position.bearing), + ) + } + + @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 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 PolylineState.onClick instead.") + override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { + polylineController.clickListener = listener + } + + @Deprecated("Use PolygonState.onClick instead.") + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { + polygonController.clickListener = listener + } + + override fun setMapDesignType(value: ArcGISDesignTypeInterface) { + val map = holder.map.map ?: return + map.setBasemap(ArcGISDesign.toBasemap(value)) + // A map that failed to load -- its basemap was out of reach -- gets + // another go with the new basemap, or with none, which needs no + // network at all. + if (map.loadStatus.value is LoadStatus.FailedToLoad) { + defaultCoroutine.launch { + val result = map.retryLoad() + Log + .d("ArcGISMapView2D", "map reloaded with basemap=${value.getValue()}: ${result.isSuccess} ${result.exceptionOrNull()?.message ?: ""}") + } + } + } + + override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { + mapDesignTypeChangeListener = listener + listener(mapDesignType) + } + + /** + * 「マップの準備ができた」ことをコンポーズ側へ通知する。 + * + * これまで ArcGIS は基底の [notifyMapInitialized](sticky。リスナー未登録でも記録して後で + * 配送する)を使わず、[onViewpointChange] で `mapInitializedCallback` を直接呼んでいた。 + * `MapViewBase` は `InitState.MapLoaded` になるまで `CollectAndRenderOverlays` を + * コンポーズしないため、この 1 回きりのイベントを取り逃すとマーカー・ポリゴン・InfoBubble が + * 一切描画されない。`viewpointChanged` は replay を持たないホットフローで、購読は + * `setupListeners()` がコルーチンで非同期に始めるので、初期ビューポート確定がその購読より + * 早いと通知が失われる。2D は初期ビューポートが即座に確定して以後動かないため、 + * 起動のたびにマーカーが出たり出なかったりする形で表面化した。 + * + * そこでレイアウト確定後に呼び出し側から明示的に通知する。基底の実装が sticky かつ + * 一度しか配送しないので、[onViewpointChange] 側と重なっても二重通知にはならない。 + * Compose の状態を書き換えるためメインディスパッチャで呼ぶ。 + */ + fun markMapInitialized() { + mainCoroutine.launch { notifyMapInitialized() } + } + + fun sendInitialCameraUpdate() { + defaultCoroutine.launch { + if (holder.map.width <= 0 || holder.map.height <= 0) return@launch + getMapCameraPosition()?.let { notifyMapCameraPosition(it) } + } + } + + internal fun registerMarkerEventController(controller: ArcGISMarkerEventController) { + 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) + } + + fun createMarkerRenderer(): MarkerOverlayRendererInterface { + val markerLayer = GraphicsOverlay() + registerMarkerOverlayLayer(markerLayer) + return ArcGISMarkerRenderer( + markerLayer = markerLayer, + holder = holder, + ) + } + + fun createMarkerEventController( + controller: StrategyMarkerController, + ): MarkerEventControllerInterface = ArcGISMarkerEventController(controller) + + fun registerMarkerEventController(controller: MarkerEventControllerInterface) { + val typed = controller as? ArcGISMarkerEventController ?: return + registerMarkerEventController(typed) + } + + internal fun registerMarkerOverlayLayer(layer: GraphicsOverlay) { + if (holder.map.graphicsOverlays.contains(layer)) return + holder.map.graphicsOverlays.add(layer) + } + + /** + * 統一ズーム(Google 準拠)→ ArcGIS の縮尺分母。 + * + * ArcGIS が扱う縮尺は Web メルカトルの **投影座標系上** の縮尺(公称縮尺)で、緯度による + * 補正は含まない。Google のズームも同じく投影座標系(256px タイルで世界一周)で定義されて + * いるため、両者は緯度に依存しない 1 対 1 の対応になる。 + * + * 以前はここに `cos(latitude)` を掛けて「地表の実距離」に直していたため、高緯度ほど縮尺が + * 小さく(=寄りすぎに)なっていた。緯度 65 度で Google Maps 比 2.4 倍ほど拡大されており、 + * 同じ `MapCameraPosition` を渡しても表示範囲が一致しなかった。 + */ + private fun zoomToScale(zoom: Double): Double { + val resolution = WEB_MERCATOR_CIRCUMFERENCE_METERS / (TILE_SIZE * 2.0.pow(zoom)) + return resolution * PIXELS_PER_METER_AT_96_DPI + } + + /** [zoomToScale] の逆変換。 */ + internal fun scaleToZoom(scale: Double): Double { + val resolution = scale / PIXELS_PER_METER_AT_96_DPI + return ln(WEB_MERCATOR_CIRCUMFERENCE_METERS / (TILE_SIZE * resolution)) / ln(2.0) + } + + companion object { + private const val WEB_MERCATOR_CIRCUMFERENCE_METERS = Earth.CIRCUMFERENCE_METERS + private const val TILE_SIZE = 256.0 + + /** 96 DPI(ESRI が縮尺計算に用いる標準値)における 1 メートルあたりのピクセル数。 */ + private const val PIXELS_PER_METER_AT_96_DPI = 96.0 / 0.0254 + } +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt index 93eab0d..5e6ac77 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt @@ -1,344 +1,344 @@ -package com.mapconductor.arcgis - -import com.arcgismaps.LoadStatus -import com.arcgismaps.mapping.view.GraphicsOverlay -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController -import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController -import com.mapconductor.arcgis.marker.ArcGISMarkerController -import com.mapconductor.arcgis.marker.ArcGISMarkerEventController -import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController -import com.mapconductor.arcgis.raster.ArcGISRasterLayerController -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.GroundImageState -import com.mapconductor.core.groundimage.OnGroundImageEventHandler -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapGesture -import com.mapconductor.core.map.MapUISettings -import com.mapconductor.core.map.MapUISettingsDiagnostics -import com.mapconductor.core.marker.MarkerAnimationOverlayHost -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -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.polyline.OnPolylineEventHandler -import com.mapconductor.core.polyline.PolylineState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -class ArcGISMapViewController( - override val holder: ArcGISMapViewHolder, - internal val markerController: ArcGISMarkerController, - internal val polylineController: ArcGISPolylineOverlayController, - internal val polygonController: ArcGISPolygonOverlayController, - internal val circleController: ArcGISCircleOverlayController, - internal val groundImageController: ArcGISGroundImageController, - internal val rasterLayerController: ArcGISRasterLayerController, - override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), - override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : BaseMapViewController(), - ArcGISMapViewControllerInterface { - internal val markerEventControllers = mutableListOf() - internal var activeDragController: ArcGISMarkerEventController? = 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 - - // ArcGIS updates the viewpoint asynchronously; firing "move end" immediately after setViewpointCamera() - // can read a stale camera and cause feedback loops in camera sync scenarios. - internal var cameraMoveEndJob: Job? = null - internal val cameraMoveEndDebounceMs = 180L - - init { - holder.map.graphicsOverlays.clear() - holder.map.graphicsOverlays.add(circleController.renderer.circleLayer) - holder.map.graphicsOverlays.add(polygonController.renderer.polygonLayer) - holder.map.graphicsOverlays.add(polylineController.renderer.polylineLayer) - holder.map.graphicsOverlays.add((markerController.renderer as ArcGISMarkerRenderer).markerLayer) - setupListeners() - registerOverlayController(markerController) - registerOverlayController(polygonController) - registerOverlayController(polylineController) - registerOverlayController(circleController) - registerOverlayController(groundImageController) - registerOverlayController(rasterLayerController) - registerMarkerEventController(ArcGISMarkerEventController(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) } - } - } - } - - 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) - - /** - * マーカーのヒットテスト。クリックカスケードの先頭。 - * - * ArcGIS は地図タップの座標からそのまま引けるので、コアの - * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 - * 長押し(ドラッグ開始)だけは identifyGraphicsOverlay を使うので Gestures 側に残る。 - */ - override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = - markerEventControllers.dispatchGeoMarkerClick(position) - - // 拡張ファイル(Camera / Gestures)からは基底クラスの protected へ触れないため、 - // ここで internal の入口を用意しておく。 - internal fun cameraMoveStartHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveStartCallback - - internal fun cameraMoveHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveCallback - - internal fun needsCameraMoveEndWork(): Boolean = cameraMoveEndCallback != null || hasCameraRestriction() - - internal fun emitCameraMoveEnd(position: MapCameraPosition) { - cameraMoveEndCallback?.invoke(position) - } - - internal fun emitMapInitialized() { - notifyMapInitialized() - } - - internal suspend fun emitCameraPosition(position: MapCameraPosition) { - notifyMapCameraPosition(position) - } - - internal fun correctForCameraRestriction(current: MapCameraPosition): MapCameraPosition? = - cameraRestrictionCorrection(current) - - fun setupListeners() { - mainCoroutine.launch { - holder.map.onSingleTapConfirmed.collect { onMapTap(it) } - } - defaultCoroutine.launch { - holder.map.viewpointChanged.collect { onViewpointChange() } - } - defaultCoroutine.launch { - holder.map.onInteractiveZooming.collect { invokeCameraMoveCallback() } - } - defaultCoroutine.launch { - holder.map.onRotate.collect { invokeCameraMoveCallback() } - } - mainCoroutine.launch { - holder.map.onLongPress.collect { onMapLongPress(it) } - } - mainCoroutine.launch { - holder.map.onUp.collect { onMapUp(it) } - } - mainCoroutine.launch { - holder.map.onPan.collect { onMapPan(it) } - } - } - - override fun hasPolyline(state: PolylineState): Boolean = - this.polylineController.polylineManager - .hasEntity(state.id) - - override fun hasGroundImage(state: GroundImageState): Boolean = - this.groundImageController.groundImageManager - .hasEntity(state.id) - - internal var appliedUISettings: MapUISettings = MapUISettings.Default - - override fun applyUISettings(settings: MapUISettings) { - appliedUISettings = settings - MapUISettingsDiagnostics.warnIfRequested( - settings.tiltGesture, - gesture = MapGesture.Tilt, - provider = "ArcGIS", - reason = "InteractionOptions has no separate tilt toggle", - ) - with(holder.map) { - interactionOptions.isPanEnabled = settings.scrollGesture - interactionOptions.isRotateEnabled = settings.rotateGesture - interactionOptions.isZoomEnabled = settings.zoomGesture - } - } - - override suspend fun clearOverlays() { - markerController.clear() - groundImageController.clear() - polylineController.clear() - polygonController.clear() - circleController.clear() - rasterLayerController.clear() - } - - override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { - (markerController.renderer as ArcGISMarkerRenderer).animationOverlayHost = host - } - - @Deprecated("Use CircleState.onClick instead.") - override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { - this.circleController.clickListener = listener - } - - @Deprecated("Use GroundImageState.onClick instead.") - override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { - this.groundImageController.clickListener = listener - } - - @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 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 PolylineState.onClick instead.") - override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { - this.polylineController.clickListener = listener - } - - @Deprecated("Use PolygonState.onClick instead.") - override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { - this.polygonController.clickListener = listener - } - - internal var mapDesignType: ArcGISDesignTypeInterface = ArcGISDesign.Streets - internal var mapDesignTypeChangeListener: ArcGISDesignTypeChangeHandler? = null - - override fun setMapDesignType(value: ArcGISDesignTypeInterface) { - holder.map.scene?.let { scene -> - val baseMap = ArcGISDesign.toBasemap(value) - defaultCoroutine.launch { - scene.setBasemap(baseMap) - // A scene that failed to load -- its basemap was out of - // reach -- gets another go with the new basemap, or with - // none, which needs no network at all. - if (scene.loadStatus.value is LoadStatus.FailedToLoad) scene.retryLoad() - // Basemap changes can reset the viewpoint; mark the current request as pending so that - // the next viewpointChanged can restore the last requested camera if needed. -// pendingCameraRestoreRequest = cameraRequestGeneration.get() - } - } - } - - override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { - mapDesignTypeChangeListener = listener - listener(mapDesignType) - } - - /** - * 「マップの準備ができた」ことをコンポーズ側へ通知する。 - * - * これまで ArcGIS は基底の [notifyMapInitialized](sticky。リスナー未登録でも記録して後で - * 配送する)を使わず、[onViewpointChange] で `mapInitializedCallback` を直接呼んでいた。 - * `MapViewBase` は `InitState.MapLoaded` になるまで `CollectAndRenderOverlays` を - * コンポーズしないため、この 1 回きりのイベントを取り逃すとマーカー・ポリゴン・InfoBubble が - * 一切描画されない。`viewpointChanged` は replay を持たないホットフローで、購読は - * `setupListeners()` がコルーチンで非同期に始めるので、初期ビューポート確定がその購読より - * 早いと通知が失われる。2D は初期ビューポートが即座に確定して以後動かないため、 - * 起動のたびにマーカーが出たり出なかったりする形で表面化した。 - * - * そこでレイアウト確定後に呼び出し側から明示的に通知する。基底の実装が sticky かつ - * 一度しか配送しないので、[onViewpointChange] 側と重なっても二重通知にはならない。 - * Compose の状態を書き換えるためメインディスパッチャで呼ぶ。 - */ - fun markMapInitialized() { - mainCoroutine.launch { notifyMapInitialized() } - } - - // Trigger an initial camera update after the view and scene are ready - fun sendInitialCameraUpdate() { - defaultCoroutine.launch { - val mapWidth = holder.map.width - val mapHeight = holder.map.height - if (mapWidth <= 0 || mapHeight <= 0) return@launch - getMapCameraPosition()?.let { mapCameraPosition -> - notifyMapCameraPosition(mapCameraPosition) - } - } - } - - internal fun registerMarkerEventController(controller: ArcGISMarkerEventController) { - 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) - } - - fun createMarkerRenderer(): MarkerOverlayRendererInterface { - val markerLayer = - GraphicsOverlay() - registerMarkerOverlayLayer(markerLayer) - return ArcGISMarkerRenderer( - markerLayer = markerLayer, - holder = holder, - ) - } - - fun createMarkerEventController( - controller: StrategyMarkerController, - ): MarkerEventControllerInterface = ArcGISMarkerEventController(controller) - - fun registerMarkerEventController(controller: MarkerEventControllerInterface) { - val typed = controller as? ArcGISMarkerEventController ?: return - registerMarkerEventController(typed) - } - - internal fun registerMarkerOverlayLayer(layer: GraphicsOverlay) { - if (holder.map.graphicsOverlays.contains(layer)) return - holder.map.graphicsOverlays.add(layer) - } -} +package com.mapconductor.arcgis + +import com.arcgismaps.LoadStatus +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController +import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController +import com.mapconductor.arcgis.marker.ArcGISMarkerController +import com.mapconductor.arcgis.marker.ArcGISMarkerEventController +import com.mapconductor.arcgis.marker.ArcGISMarkerRenderer +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController +import com.mapconductor.arcgis.raster.ArcGISRasterLayerController +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.GroundImageState +import com.mapconductor.core.groundimage.OnGroundImageEventHandler +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapGesture +import com.mapconductor.core.map.MapUISettings +import com.mapconductor.core.map.MapUISettingsDiagnostics +import com.mapconductor.core.marker.MarkerAnimationOverlayHost +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +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.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.PolylineState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class ArcGISMapViewController( + override val holder: ArcGISMapViewHolder, + internal val markerController: ArcGISMarkerController, + internal val polylineController: ArcGISPolylineOverlayController, + internal val polygonController: ArcGISPolygonOverlayController, + internal val circleController: ArcGISCircleOverlayController, + internal val groundImageController: ArcGISGroundImageController, + internal val rasterLayerController: ArcGISRasterLayerController, + override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), + override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : BaseMapViewController(), + ArcGISMapViewControllerInterface { + internal val markerEventControllers = mutableListOf() + internal var activeDragController: ArcGISMarkerEventController? = 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 + + // ArcGIS updates the viewpoint asynchronously; firing "move end" immediately after setViewpointCamera() + // can read a stale camera and cause feedback loops in camera sync scenarios. + internal var cameraMoveEndJob: Job? = null + internal val cameraMoveEndDebounceMs = 180L + + init { + holder.map.graphicsOverlays.clear() + holder.map.graphicsOverlays.add(circleController.renderer.circleLayer) + holder.map.graphicsOverlays.add(polygonController.renderer.polygonLayer) + holder.map.graphicsOverlays.add(polylineController.renderer.polylineLayer) + holder.map.graphicsOverlays.add((markerController.renderer as ArcGISMarkerRenderer).markerLayer) + setupListeners() + registerOverlayController(markerController) + registerOverlayController(polygonController) + registerOverlayController(polylineController) + registerOverlayController(circleController) + registerOverlayController(groundImageController) + registerOverlayController(rasterLayerController) + registerMarkerEventController(ArcGISMarkerEventController(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) } + } + } + } + + 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) + + /** + * マーカーのヒットテスト。クリックカスケードの先頭。 + * + * ArcGIS は地図タップの座標からそのまま引けるので、コアの + * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 + * 長押し(ドラッグ開始)だけは identifyGraphicsOverlay を使うので Gestures 側に残る。 + */ + override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = + markerEventControllers.dispatchGeoMarkerClick(position) + + // 拡張ファイル(Camera / Gestures)からは基底クラスの protected へ触れないため、 + // ここで internal の入口を用意しておく。 + internal fun cameraMoveStartHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveStartCallback + + internal fun cameraMoveHandler(): ((MapCameraPosition) -> Unit)? = cameraMoveCallback + + internal fun needsCameraMoveEndWork(): Boolean = cameraMoveEndCallback != null || hasCameraRestriction() + + internal fun emitCameraMoveEnd(position: MapCameraPosition) { + cameraMoveEndCallback?.invoke(position) + } + + internal fun emitMapInitialized() { + notifyMapInitialized() + } + + internal suspend fun emitCameraPosition(position: MapCameraPosition) { + notifyMapCameraPosition(position) + } + + internal fun correctForCameraRestriction(current: MapCameraPosition): MapCameraPosition? = + cameraRestrictionCorrection(current) + + fun setupListeners() { + mainCoroutine.launch { + holder.map.onSingleTapConfirmed.collect { onMapTap(it) } + } + defaultCoroutine.launch { + holder.map.viewpointChanged.collect { onViewpointChange() } + } + defaultCoroutine.launch { + holder.map.onInteractiveZooming.collect { invokeCameraMoveCallback() } + } + defaultCoroutine.launch { + holder.map.onRotate.collect { invokeCameraMoveCallback() } + } + mainCoroutine.launch { + holder.map.onLongPress.collect { onMapLongPress(it) } + } + mainCoroutine.launch { + holder.map.onUp.collect { onMapUp(it) } + } + mainCoroutine.launch { + holder.map.onPan.collect { onMapPan(it) } + } + } + + override fun hasPolyline(state: PolylineState): Boolean = + this.polylineController.polylineManager + .hasEntity(state.id) + + override fun hasGroundImage(state: GroundImageState): Boolean = + this.groundImageController.groundImageManager + .hasEntity(state.id) + + internal var appliedUISettings: MapUISettings = MapUISettings.Default + + override fun applyUISettings(settings: MapUISettings) { + appliedUISettings = settings + MapUISettingsDiagnostics.warnIfRequested( + settings.tiltGesture, + gesture = MapGesture.Tilt, + provider = "ArcGIS", + reason = "InteractionOptions has no separate tilt toggle", + ) + with(holder.map) { + interactionOptions.isPanEnabled = settings.scrollGesture + interactionOptions.isRotateEnabled = settings.rotateGesture + interactionOptions.isZoomEnabled = settings.zoomGesture + } + } + + override suspend fun clearOverlays() { + markerController.clear() + groundImageController.clear() + polylineController.clear() + polygonController.clear() + circleController.clear() + rasterLayerController.clear() + } + + override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { + (markerController.renderer as ArcGISMarkerRenderer).animationOverlayHost = host + } + + @Deprecated("Use CircleState.onClick instead.") + override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { + this.circleController.clickListener = listener + } + + @Deprecated("Use GroundImageState.onClick instead.") + override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { + this.groundImageController.clickListener = listener + } + + @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 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 PolylineState.onClick instead.") + override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { + this.polylineController.clickListener = listener + } + + @Deprecated("Use PolygonState.onClick instead.") + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { + this.polygonController.clickListener = listener + } + + internal var mapDesignType: ArcGISDesignTypeInterface = ArcGISDesign.Streets + internal var mapDesignTypeChangeListener: ArcGISDesignTypeChangeHandler? = null + + override fun setMapDesignType(value: ArcGISDesignTypeInterface) { + holder.map.scene?.let { scene -> + val baseMap = ArcGISDesign.toBasemap(value) + defaultCoroutine.launch { + scene.setBasemap(baseMap) + // A scene that failed to load -- its basemap was out of + // reach -- gets another go with the new basemap, or with + // none, which needs no network at all. + if (scene.loadStatus.value is LoadStatus.FailedToLoad) scene.retryLoad() + // Basemap changes can reset the viewpoint; mark the current request as pending so that + // the next viewpointChanged can restore the last requested camera if needed. +// pendingCameraRestoreRequest = cameraRequestGeneration.get() + } + } + } + + override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { + mapDesignTypeChangeListener = listener + listener(mapDesignType) + } + + /** + * 「マップの準備ができた」ことをコンポーズ側へ通知する。 + * + * これまで ArcGIS は基底の [notifyMapInitialized](sticky。リスナー未登録でも記録して後で + * 配送する)を使わず、[onViewpointChange] で `mapInitializedCallback` を直接呼んでいた。 + * `MapViewBase` は `InitState.MapLoaded` になるまで `CollectAndRenderOverlays` を + * コンポーズしないため、この 1 回きりのイベントを取り逃すとマーカー・ポリゴン・InfoBubble が + * 一切描画されない。`viewpointChanged` は replay を持たないホットフローで、購読は + * `setupListeners()` がコルーチンで非同期に始めるので、初期ビューポート確定がその購読より + * 早いと通知が失われる。2D は初期ビューポートが即座に確定して以後動かないため、 + * 起動のたびにマーカーが出たり出なかったりする形で表面化した。 + * + * そこでレイアウト確定後に呼び出し側から明示的に通知する。基底の実装が sticky かつ + * 一度しか配送しないので、[onViewpointChange] 側と重なっても二重通知にはならない。 + * Compose の状態を書き換えるためメインディスパッチャで呼ぶ。 + */ + fun markMapInitialized() { + mainCoroutine.launch { notifyMapInitialized() } + } + + // Trigger an initial camera update after the view and scene are ready + fun sendInitialCameraUpdate() { + defaultCoroutine.launch { + val mapWidth = holder.map.width + val mapHeight = holder.map.height + if (mapWidth <= 0 || mapHeight <= 0) return@launch + getMapCameraPosition()?.let { mapCameraPosition -> + notifyMapCameraPosition(mapCameraPosition) + } + } + } + + internal fun registerMarkerEventController(controller: ArcGISMarkerEventController) { + 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) + } + + fun createMarkerRenderer(): MarkerOverlayRendererInterface { + val markerLayer = + GraphicsOverlay() + registerMarkerOverlayLayer(markerLayer) + return ArcGISMarkerRenderer( + markerLayer = markerLayer, + holder = holder, + ) + } + + fun createMarkerEventController( + controller: StrategyMarkerController, + ): MarkerEventControllerInterface = ArcGISMarkerEventController(controller) + + fun registerMarkerEventController(controller: MarkerEventControllerInterface) { + val typed = controller as? ArcGISMarkerEventController ?: return + registerMarkerEventController(typed) + } + + internal fun registerMarkerOverlayLayer(layer: GraphicsOverlay) { + if (holder.map.graphicsOverlays.contains(layer)) return + holder.map.graphicsOverlays.add(layer) + } +} diff --git a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt index eae707a..51e06f3 100644 --- a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt +++ b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt @@ -1,34 +1,34 @@ -package com.mapconductor.arcgis.raster - -import com.arcgismaps.mapping.layers.Layer -import com.mapconductor.core.raster.RasterLayerController -import com.mapconductor.core.raster.RasterLayerEntity -import com.mapconductor.core.raster.RasterLayerManager -import com.mapconductor.core.raster.RasterLayerManagerInterface - -class ArcGISRasterLayerController( - rasterLayerManager: RasterLayerManagerInterface = RasterLayerManager(), - override val renderer: ArcGISRasterLayerOverlayRenderer, -) : RasterLayerController(rasterLayerManager, renderer) { - /** - * Builds the in-process layers again, so ArcGIS drops the tiles it holds - * for them. Wanted after the camera settles on a level that was answered - * transparent while it was an ancestor (see the renderer's gate). - */ - suspend fun rebuildLocalLayersIfNeeded() { - if (!renderer.localLayersNeedRebuild()) return - val entities = - rasterLayerManager - .allEntities() - .filter { renderer.isLocalLayer(it.state) } - .sortedBy { it.state.zIndex } - for (entity in entities) { - // In place, over the old one; the renderer removes the old layer - // once the new one has had time to draw. - val layer = renderer.rebuildLayer(entity) ?: continue - rasterLayerManager.removeEntity(entity.state.id) - rasterLayerManager.registerEntity(RasterLayerEntity(layer, entity.state)) - } - renderer.localLayersRebuilt() - } -} +package com.mapconductor.arcgis.raster + +import com.arcgismaps.mapping.layers.Layer +import com.mapconductor.core.raster.RasterLayerController +import com.mapconductor.core.raster.RasterLayerEntity +import com.mapconductor.core.raster.RasterLayerManager +import com.mapconductor.core.raster.RasterLayerManagerInterface + +class ArcGISRasterLayerController( + rasterLayerManager: RasterLayerManagerInterface = RasterLayerManager(), + override val renderer: ArcGISRasterLayerOverlayRenderer, +) : RasterLayerController(rasterLayerManager, renderer) { + /** + * Builds the in-process layers again, so ArcGIS drops the tiles it holds + * for them. Wanted after the camera settles on a level that was answered + * transparent while it was an ancestor (see the renderer's gate). + */ + suspend fun rebuildLocalLayersIfNeeded() { + if (!renderer.localLayersNeedRebuild()) return + val entities = + rasterLayerManager + .allEntities() + .filter { renderer.isLocalLayer(it.state) } + .sortedBy { it.state.zIndex } + for (entity in entities) { + // In place, over the old one; the renderer removes the old layer + // once the new one has had time to draw. + val layer = renderer.rebuildLayer(entity) ?: continue + rasterLayerManager.removeEntity(entity.state.id) + rasterLayerManager.registerEntity(RasterLayerEntity(layer, entity.state)) + } + renderer.localLayersRebuilt() + } +} diff --git a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt index 4a973f8..e1083db 100644 --- a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt @@ -1,365 +1,365 @@ -package com.mapconductor.arcgis.raster - -import com.arcgismaps.arcgisservices.LevelOfDetail -import com.arcgismaps.geometry.Envelope -import com.arcgismaps.geometry.Point -import com.arcgismaps.geometry.SpatialReference -import com.arcgismaps.mapping.layers.ArcGISTiledLayer -import com.arcgismaps.mapping.layers.Layer -import com.arcgismaps.mapping.layers.TileImageFormat -import com.arcgismaps.mapping.layers.TileInfo -import com.arcgismaps.mapping.layers.WebTiledLayer -import com.mapconductor.arcgis.ArcGISGeoViewHolder -import com.mapconductor.arcgis.ArcGISMapViewHolder -import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS -import com.mapconductor.core.raster.RasterHeaderRuleSet -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.core.tileserver.TileServerRegistry -import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter -import kotlin.math.log2 -import kotlin.math.pow -import android.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class ArcGISRasterLayerOverlayRenderer( - private val holder: ArcGISGeoViewHolder<*, *>, - override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : RasterLayerOverlayRendererInterface { - /** - * Which level the camera looks at, and which levels were answered with a - * transparent tile since the local layers were last built. - * - * The 3D view asks for every level above the one on screen and draws none - * of them once that level is in, so a request two or more levels above the - * camera is answered transparent, without a render. ArcGIS keeps what it - * is given, so a level answered that way stays blank if the camera later - * lands on it -- unless the layer is rebuilt, which [takeRebuildWanted] - * asks for. The 3D view sizes its levels in dp, so the level is the - * unified zoom itself (ios-for-arcgis adds log2 of the screen scale). - */ - internal class AncestorGate { - private var level: Int? = null - private val stubbed = HashSet() - private var rebuildWanted = false - - @Synchronized - fun set(unifiedZoom: Double) { - val wanted = Math.round(unifiedZoom).toInt() - if (wanted != level) { - level = wanted - if (stubbed.contains(wanted) || stubbed.contains(wanted - 1) || stubbed.contains(wanted + 1)) { - rebuildWanted = true - } - } - } - - @Synchronized - fun isAncestor(requested: Int): Boolean { - val level = level ?: return false - return requested < level - 1 - } - - @Synchronized - fun markStubbed(requested: Int) { - stubbed.add(requested) - } - - @Synchronized - fun layerRebuilt() { - stubbed.clear() - rebuildWanted = false - } - - @Synchronized - fun takeRebuildWanted(): Boolean { - val wanted = rebuildWanted - rebuildWanted = false - return wanted - } - } - - private val ancestors = AncestorGate() - - /** How long a replaced layer stays under its replacement. */ - private val HANDOVER_MS = 1200L - - /** The tile server route each local layer was built from, for its gate. */ - private val localRoutes = java.util.IdentityHashMap() - - /** Only the 3D view loads the pyramid above the level on screen. */ - private val isSceneView: Boolean get() = holder is ArcGISMapViewHolder - - fun cameraMoved(unifiedZoom: Double) { - if (isSceneView) ancestors.set(unifiedZoom) - } - - /** Once per need: whether the camera settled on a level answered transparent. */ - fun localLayersNeedRebuild(): Boolean = ancestors.takeRebuildWanted() - - fun localLayersRebuilt() = ancestors.layerRebuilt() - - /** Whether this layer is served by the in-process tile server. */ - fun isLocalLayer(state: RasterLayerState): Boolean = localRouteOf(state) != null - - private fun localRouteOf(state: RasterLayerState): String? { - val template = (state.source as? RasterLayerSource.UrlTemplate)?.template ?: return null - val base = TileServerRegistry.get().baseUrl + "/tiles/" - if (!template.startsWith(base)) return null - return template.removePrefix(base).substringBefore('/') - } - - override suspend fun onAdd(data: List): List { - val results = ArrayList(data.size) - for (params in data) { - results.add(addLayer(params.state)) - } - return results - } - - override suspend fun onChange( - data: List>, - ): List { - val results = ArrayList(data.size) - for (params in data) { - val prev = params.prev - val next = params.current.state - if (prev.state.source != next.source) { - // Add new layer first to avoid losing in-flight tile requests - val newLayer = addLayer(next) - results.add(newLayer) - // Remove old layer after new one is added - removeLayer(prev) - } else { - updateLayer(prev.layer, next) - results.add(prev.layer) - } - } - return results - } - - override suspend fun onRemove(data: List>) { - data.forEach { entity -> - removeLayer(entity) - } - } - - override suspend fun onPostProcess() {} - - private suspend fun addLayer(state: RasterLayerState): Layer? { - RasterHeaderRuleSet.warnUnsupported(provider = "ArcGIS", state = state) - val operationalLayers = holder.operationalLayers ?: return null - val layer = - when (val source = state.source) { - is RasterLayerSource.ArcGisService -> ArcGISTiledLayer(source.serviceUrl) - is RasterLayerSource.UrlTemplate -> buildWebTiledLayer(source, state.id) ?: return null - is RasterLayerSource.TileJson -> { - Log.w("ArcGIS", "ArcGIS raster layers do not support TileJson sources.") - return null - } - } - - // Load layer before adding to scene to ensure TileInfo is fully initialized - val loadResult = layer.load() - if (loadResult.isFailure) { - val error = loadResult.exceptionOrNull() - Log.e("ArcGIS", "Failed to load raster layer id=${state.id}: ${error?.message}", error) - return null - } - - // Add to scene only after successful initialization - updateLayer(layer, state) - operationalLayers.add(layer) - localRouteOf(state)?.let { routeId -> - if (isSceneView) { - localRoutes[layer] = routeId - TileServerRegistry.get().setLevelGate(routeId) { level -> - ancestors.isAncestor(level).also { if (it) ancestors.markStubbed(level) } - } - } - } - return layer - } - - private fun updateLayer( - layer: Layer, - state: RasterLayerState, - ) { - layer.opacity = state.opacity.coerceIn(0.0f, 1.0f) - layer.isVisible = state.visible - } - - private fun removeLayer(entity: RasterLayerEntityInterface) { - holder.operationalLayers?.remove(entity.layer) - forgetGate(entity.layer) - } - - private fun forgetGate(layer: Layer) { - val routeId = localRoutes.remove(layer) ?: return - // Another layer (its replacement) may still be on the same route. - if (routeId !in localRoutes.values) TileServerRegistry.get().setLevelGate(routeId, null) - } - - /** - * Builds this entity's layer again, in place: the new one goes directly - * above the old, which stays for [HANDOVER_MS] and is then removed. - * Replacing by remove-then-add left the map bare until the new tiles - * landed -- a flicker on every rebuild. - */ - suspend fun rebuildLayer(entity: RasterLayerEntityInterface): Layer? { - val operationalLayers = holder.operationalLayers ?: return null - val state = entity.state - val source = state.source as? RasterLayerSource.UrlTemplate ?: return null - val layer = buildWebTiledLayer(source, state.id) ?: return null - val loadResult = layer.load() - if (loadResult.isFailure) { - Log.e("ArcGIS", "Failed to load raster layer id=${state.id}: ${loadResult.exceptionOrNull()?.message}") - return null - } - updateLayer(layer, state) - val index = operationalLayers.indexOf(entity.layer) - if (index >= 0) operationalLayers.add(index + 1, layer) else operationalLayers.add(layer) - localRouteOf(state)?.let { routeId -> - if (isSceneView) { - localRoutes[layer] = routeId - TileServerRegistry.get().setLevelGate(routeId) { level -> - ancestors.isAncestor(level).also { if (it) ancestors.markStubbed(level) } - } - } - } - val old = entity.layer - coroutine.launch { - kotlinx.coroutines.delay(HANDOVER_MS) - operationalLayers.remove(old) - forgetGate(old) - } - return layer - } - - private fun buildWebTiledLayer( - source: RasterLayerSource.UrlTemplate, - id: String, - ): WebTiledLayer? { - if (source.scheme == TileScheme.TMS) { - Log.w("ArcGIS", "TMS scheme is not supported for WebTiledLayer.") - return null - } - val template = - source.template - .replace("{z}", "{level}") - .replace("{x}", "{col}") - .replace("{y}", "{row}") - val minZoom = source.minZoom ?: DEFAULT_MIN_ZOOM - val maxZoom = source.maxZoom ?: DEFAULT_MAX_ZOOM - val tileInfo = buildWebMercatorTileInfo(source.tileSize, minZoom, maxZoom) - val fullExtent = buildWebMercatorExtent() - return WebTiledLayer.create(template, emptyList(), tileInfo, fullExtent) - } - - private fun buildWebMercatorTileInfo( - tileSize: Int, - minZoom: Int, - maxZoom: Int, - ): TileInfo { - val spatialReference = SpatialReference(WEB_MERCATOR_WKID) - val origin = Point(WEB_MERCATOR_MIN, WEB_MERCATOR_MAX, spatialReference) - // LOD の解像度は **必ず tileSize 基準**で刻む。1 枚のタイルが覆う地面は - // `resolution * tileWidth` なので、ここを 256 基準にしたまま tileWidth に - // 512 を渡すと、レベル L のタイルが L-1 の広さを覆う。**番号だけ 1 段深い - // 格子**になり、ArcGIS は「z=13, x=3637」のような噛み合わない組を要求する - // (東京の z=13 は x=7276、x=3637 は z=12 の値)。実機の Pixel 5a では - // ベクタータイルのサンプルが大西洋のタイルを引いて真っ青になった。 - // - // 以前ここには「3D SceneView は 256 基準でないと何も要求しない」という - // 計測メモがあり、512 のときだけ 256 へ読み替えていた。ArcGIS 300 では - // 再現しない: 素直な tileSize 基準で 3D も 2D も 256/512 の両方を正しく - // 引く(z=11 を要求し、MapLibre と一致)。256 のときは読み替えが恒等だった - // ので、この分岐が壊していたのは 512 だけだった。 - val levels = buildWebMercatorLevels(tileSize, minZoom, maxZoom) - return TileInfo( - DEFAULT_DPI, - TileImageFormat.Png, - levels, - origin, - spatialReference, - tileSize, - tileSize, - ) - } - - /** - * レベルの梯子を**統一ズーム**(Google Maps 基準・256px タイル)から組む。 - * - * MapConductor のズームはどのプロバイダでも Google のズームなので、level `z` の - * タイルが持つべき解像度も 1 本の物差しから出す: - * [AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256] が統一ズーム 0 の - * 1px あたりメートル数(256px タイル基準)。 - * - * 512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、level `z` は統一ズーム - * `z + 1` に対応する(コアの `WebMercatorZoomAltitudeConverter` が言う - * `zoomOffset`。MapLibre / Mapbox / MapTiler が 1.0、Google / MapKit が 0.0)。 - * その対応を [unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も - * 「統一ズームいくつのときにこの level を引くか」から一意に決まる。 - * - * 自前で `2πR / tileSize` と DPI を掛け合わせても同じ数にはなるが、どの物差しに - * 合わせているのかがコードから消える。実際、以前ここには 512 のときだけ 256 基準へ - * 読み替える細工が入っていて、(z,x,y) が噛み合わずに地球の裏側のタイルを引いていた。 - */ - private fun buildWebMercatorLevels( - tileSize: Int, - minZoom: Int, - maxZoom: Int, - ): List { - val levels = mutableListOf() - for (level in minZoom..maxZoom) { - val unifiedZoom = unifiedZoomForLevel(level, tileSize) - val resolution = - AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 / - 2.0.pow(unifiedZoom) - // 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、 - // 解像度と同じ比率で刻んでいないと 1 段ずれる。 - // - // 3D の SceneView は**見ていない**。1 段浅い縮尺を申告して選択を - // 2D とそろえられないか実機で試したが、要求される level は変わらな - // かった(統一ズーム 6.5 で z=7 のまま)。SceneView は自前の - // 「タイルは 256px」という梯子で選ぶ。解像度のほうは見ているので、 - // タイルの位置は申告どおりに決まる。 - val scale = resolution * DEFAULT_DPI * INCHES_PER_METER - levels.add(LevelOfDetail(level, resolution, scale)) - } - return levels - } - - /** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */ - private fun unifiedZoomForLevel( - level: Int, - tileSize: Int, - ): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE) - - private fun buildWebMercatorExtent(): Envelope = - Envelope( - WEB_MERCATOR_MIN, - WEB_MERCATOR_MIN, - WEB_MERCATOR_MAX, - WEB_MERCATOR_MAX, - spatialReference = SpatialReference(WEB_MERCATOR_WKID), - ) - - companion object { - private const val WEB_MERCATOR_WKID = 3857 - private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS - private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX - private const val DEFAULT_DPI = 96 - private const val INCHES_PER_METER = 39.37 - - /** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */ - private const val UNIFIED_TILE_SIZE = 256.0 - private const val DEFAULT_MIN_ZOOM = 0 - private const val DEFAULT_MAX_ZOOM = 22 - } -} +package com.mapconductor.arcgis.raster + +import com.arcgismaps.arcgisservices.LevelOfDetail +import com.arcgismaps.geometry.Envelope +import com.arcgismaps.geometry.Point +import com.arcgismaps.geometry.SpatialReference +import com.arcgismaps.mapping.layers.ArcGISTiledLayer +import com.arcgismaps.mapping.layers.Layer +import com.arcgismaps.mapping.layers.TileImageFormat +import com.arcgismaps.mapping.layers.TileInfo +import com.arcgismaps.mapping.layers.WebTiledLayer +import com.mapconductor.arcgis.ArcGISGeoViewHolder +import com.mapconductor.arcgis.ArcGISMapViewHolder +import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS +import com.mapconductor.core.raster.RasterHeaderRuleSet +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.core.tileserver.TileServerRegistry +import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter +import kotlin.math.log2 +import kotlin.math.pow +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ArcGISRasterLayerOverlayRenderer( + private val holder: ArcGISGeoViewHolder<*, *>, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : RasterLayerOverlayRendererInterface { + /** + * Which level the camera looks at, and which levels were answered with a + * transparent tile since the local layers were last built. + * + * The 3D view asks for every level above the one on screen and draws none + * of them once that level is in, so a request two or more levels above the + * camera is answered transparent, without a render. ArcGIS keeps what it + * is given, so a level answered that way stays blank if the camera later + * lands on it -- unless the layer is rebuilt, which [takeRebuildWanted] + * asks for. The 3D view sizes its levels in dp, so the level is the + * unified zoom itself (ios-for-arcgis adds log2 of the screen scale). + */ + internal class AncestorGate { + private var level: Int? = null + private val stubbed = HashSet() + private var rebuildWanted = false + + @Synchronized + fun set(unifiedZoom: Double) { + val wanted = Math.round(unifiedZoom).toInt() + if (wanted != level) { + level = wanted + if (stubbed.contains(wanted) || stubbed.contains(wanted - 1) || stubbed.contains(wanted + 1)) { + rebuildWanted = true + } + } + } + + @Synchronized + fun isAncestor(requested: Int): Boolean { + val level = level ?: return false + return requested < level - 1 + } + + @Synchronized + fun markStubbed(requested: Int) { + stubbed.add(requested) + } + + @Synchronized + fun layerRebuilt() { + stubbed.clear() + rebuildWanted = false + } + + @Synchronized + fun takeRebuildWanted(): Boolean { + val wanted = rebuildWanted + rebuildWanted = false + return wanted + } + } + + private val ancestors = AncestorGate() + + /** How long a replaced layer stays under its replacement. */ + private val HANDOVER_MS = 1200L + + /** The tile server route each local layer was built from, for its gate. */ + private val localRoutes = java.util.IdentityHashMap() + + /** Only the 3D view loads the pyramid above the level on screen. */ + private val isSceneView: Boolean get() = holder is ArcGISMapViewHolder + + fun cameraMoved(unifiedZoom: Double) { + if (isSceneView) ancestors.set(unifiedZoom) + } + + /** Once per need: whether the camera settled on a level answered transparent. */ + fun localLayersNeedRebuild(): Boolean = ancestors.takeRebuildWanted() + + fun localLayersRebuilt() = ancestors.layerRebuilt() + + /** Whether this layer is served by the in-process tile server. */ + fun isLocalLayer(state: RasterLayerState): Boolean = localRouteOf(state) != null + + private fun localRouteOf(state: RasterLayerState): String? { + val template = (state.source as? RasterLayerSource.UrlTemplate)?.template ?: return null + val base = TileServerRegistry.get().baseUrl + "/tiles/" + if (!template.startsWith(base)) return null + return template.removePrefix(base).substringBefore('/') + } + + override suspend fun onAdd(data: List): List { + val results = ArrayList(data.size) + for (params in data) { + results.add(addLayer(params.state)) + } + return results + } + + override suspend fun onChange( + data: List>, + ): List { + val results = ArrayList(data.size) + for (params in data) { + val prev = params.prev + val next = params.current.state + if (prev.state.source != next.source) { + // Add new layer first to avoid losing in-flight tile requests + val newLayer = addLayer(next) + results.add(newLayer) + // Remove old layer after new one is added + removeLayer(prev) + } else { + updateLayer(prev.layer, next) + results.add(prev.layer) + } + } + return results + } + + override suspend fun onRemove(data: List>) { + data.forEach { entity -> + removeLayer(entity) + } + } + + override suspend fun onPostProcess() {} + + private suspend fun addLayer(state: RasterLayerState): Layer? { + RasterHeaderRuleSet.warnUnsupported(provider = "ArcGIS", state = state) + val operationalLayers = holder.operationalLayers ?: return null + val layer = + when (val source = state.source) { + is RasterLayerSource.ArcGisService -> ArcGISTiledLayer(source.serviceUrl) + is RasterLayerSource.UrlTemplate -> buildWebTiledLayer(source, state.id) ?: return null + is RasterLayerSource.TileJson -> { + Log.w("ArcGIS", "ArcGIS raster layers do not support TileJson sources.") + return null + } + } + + // Load layer before adding to scene to ensure TileInfo is fully initialized + val loadResult = layer.load() + if (loadResult.isFailure) { + val error = loadResult.exceptionOrNull() + Log.e("ArcGIS", "Failed to load raster layer id=${state.id}: ${error?.message}", error) + return null + } + + // Add to scene only after successful initialization + updateLayer(layer, state) + operationalLayers.add(layer) + localRouteOf(state)?.let { routeId -> + if (isSceneView) { + localRoutes[layer] = routeId + TileServerRegistry.get().setLevelGate(routeId) { level -> + ancestors.isAncestor(level).also { if (it) ancestors.markStubbed(level) } + } + } + } + return layer + } + + private fun updateLayer( + layer: Layer, + state: RasterLayerState, + ) { + layer.opacity = state.opacity.coerceIn(0.0f, 1.0f) + layer.isVisible = state.visible + } + + private fun removeLayer(entity: RasterLayerEntityInterface) { + holder.operationalLayers?.remove(entity.layer) + forgetGate(entity.layer) + } + + private fun forgetGate(layer: Layer) { + val routeId = localRoutes.remove(layer) ?: return + // Another layer (its replacement) may still be on the same route. + if (routeId !in localRoutes.values) TileServerRegistry.get().setLevelGate(routeId, null) + } + + /** + * Builds this entity's layer again, in place: the new one goes directly + * above the old, which stays for [HANDOVER_MS] and is then removed. + * Replacing by remove-then-add left the map bare until the new tiles + * landed -- a flicker on every rebuild. + */ + suspend fun rebuildLayer(entity: RasterLayerEntityInterface): Layer? { + val operationalLayers = holder.operationalLayers ?: return null + val state = entity.state + val source = state.source as? RasterLayerSource.UrlTemplate ?: return null + val layer = buildWebTiledLayer(source, state.id) ?: return null + val loadResult = layer.load() + if (loadResult.isFailure) { + Log.e("ArcGIS", "Failed to load raster layer id=${state.id}: ${loadResult.exceptionOrNull()?.message}") + return null + } + updateLayer(layer, state) + val index = operationalLayers.indexOf(entity.layer) + if (index >= 0) operationalLayers.add(index + 1, layer) else operationalLayers.add(layer) + localRouteOf(state)?.let { routeId -> + if (isSceneView) { + localRoutes[layer] = routeId + TileServerRegistry.get().setLevelGate(routeId) { level -> + ancestors.isAncestor(level).also { if (it) ancestors.markStubbed(level) } + } + } + } + val old = entity.layer + coroutine.launch { + kotlinx.coroutines.delay(HANDOVER_MS) + operationalLayers.remove(old) + forgetGate(old) + } + return layer + } + + private fun buildWebTiledLayer( + source: RasterLayerSource.UrlTemplate, + id: String, + ): WebTiledLayer? { + if (source.scheme == TileScheme.TMS) { + Log.w("ArcGIS", "TMS scheme is not supported for WebTiledLayer.") + return null + } + val template = + source.template + .replace("{z}", "{level}") + .replace("{x}", "{col}") + .replace("{y}", "{row}") + val minZoom = source.minZoom ?: DEFAULT_MIN_ZOOM + val maxZoom = source.maxZoom ?: DEFAULT_MAX_ZOOM + val tileInfo = buildWebMercatorTileInfo(source.tileSize, minZoom, maxZoom) + val fullExtent = buildWebMercatorExtent() + return WebTiledLayer.create(template, emptyList(), tileInfo, fullExtent) + } + + private fun buildWebMercatorTileInfo( + tileSize: Int, + minZoom: Int, + maxZoom: Int, + ): TileInfo { + val spatialReference = SpatialReference(WEB_MERCATOR_WKID) + val origin = Point(WEB_MERCATOR_MIN, WEB_MERCATOR_MAX, spatialReference) + // LOD の解像度は **必ず tileSize 基準**で刻む。1 枚のタイルが覆う地面は + // `resolution * tileWidth` なので、ここを 256 基準にしたまま tileWidth に + // 512 を渡すと、レベル L のタイルが L-1 の広さを覆う。**番号だけ 1 段深い + // 格子**になり、ArcGIS は「z=13, x=3637」のような噛み合わない組を要求する + // (東京の z=13 は x=7276、x=3637 は z=12 の値)。実機の Pixel 5a では + // ベクタータイルのサンプルが大西洋のタイルを引いて真っ青になった。 + // + // 以前ここには「3D SceneView は 256 基準でないと何も要求しない」という + // 計測メモがあり、512 のときだけ 256 へ読み替えていた。ArcGIS 300 では + // 再現しない: 素直な tileSize 基準で 3D も 2D も 256/512 の両方を正しく + // 引く(z=11 を要求し、MapLibre と一致)。256 のときは読み替えが恒等だった + // ので、この分岐が壊していたのは 512 だけだった。 + val levels = buildWebMercatorLevels(tileSize, minZoom, maxZoom) + return TileInfo( + DEFAULT_DPI, + TileImageFormat.Png, + levels, + origin, + spatialReference, + tileSize, + tileSize, + ) + } + + /** + * レベルの梯子を**統一ズーム**(Google Maps 基準・256px タイル)から組む。 + * + * MapConductor のズームはどのプロバイダでも Google のズームなので、level `z` の + * タイルが持つべき解像度も 1 本の物差しから出す: + * [AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256] が統一ズーム 0 の + * 1px あたりメートル数(256px タイル基準)。 + * + * 512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、level `z` は統一ズーム + * `z + 1` に対応する(コアの `WebMercatorZoomAltitudeConverter` が言う + * `zoomOffset`。MapLibre / Mapbox / MapTiler が 1.0、Google / MapKit が 0.0)。 + * その対応を [unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も + * 「統一ズームいくつのときにこの level を引くか」から一意に決まる。 + * + * 自前で `2πR / tileSize` と DPI を掛け合わせても同じ数にはなるが、どの物差しに + * 合わせているのかがコードから消える。実際、以前ここには 512 のときだけ 256 基準へ + * 読み替える細工が入っていて、(z,x,y) が噛み合わずに地球の裏側のタイルを引いていた。 + */ + private fun buildWebMercatorLevels( + tileSize: Int, + minZoom: Int, + maxZoom: Int, + ): List { + val levels = mutableListOf() + for (level in minZoom..maxZoom) { + val unifiedZoom = unifiedZoomForLevel(level, tileSize) + val resolution = + AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 / + 2.0.pow(unifiedZoom) + // 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、 + // 解像度と同じ比率で刻んでいないと 1 段ずれる。 + // + // 3D の SceneView は**見ていない**。1 段浅い縮尺を申告して選択を + // 2D とそろえられないか実機で試したが、要求される level は変わらな + // かった(統一ズーム 6.5 で z=7 のまま)。SceneView は自前の + // 「タイルは 256px」という梯子で選ぶ。解像度のほうは見ているので、 + // タイルの位置は申告どおりに決まる。 + val scale = resolution * DEFAULT_DPI * INCHES_PER_METER + levels.add(LevelOfDetail(level, resolution, scale)) + } + return levels + } + + /** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */ + private fun unifiedZoomForLevel( + level: Int, + tileSize: Int, + ): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE) + + private fun buildWebMercatorExtent(): Envelope = + Envelope( + WEB_MERCATOR_MIN, + WEB_MERCATOR_MIN, + WEB_MERCATOR_MAX, + WEB_MERCATOR_MAX, + spatialReference = SpatialReference(WEB_MERCATOR_WKID), + ) + + companion object { + private const val WEB_MERCATOR_WKID = 3857 + private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS + private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX + private const val DEFAULT_DPI = 96 + private const val INCHES_PER_METER = 39.37 + + /** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */ + private const val UNIFIED_TILE_SIZE = 256.0 + private const val DEFAULT_MIN_ZOOM = 0 + private const val DEFAULT_MAX_ZOOM = 22 + } +}