diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index 3820b40..e10fb5d 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -175,12 +175,15 @@ public final class com.mapconductor.arcgis.ArcGISMapView2DController extends com public final class com.mapconductor.arcgis.ArcGISMapView2DController$Companion { } public final class com.mapconductor.arcgis.ArcGISMapView2DControllerCameraKt { + public static final double ARCGIS_2D_VERTICAL_FOV; public static final java.lang.Object onViewpointChange(com.mapconductor.arcgis.ArcGISMapView2DController, kotlin.coroutines.Continuation); public static final java.lang.Object invokeCameraMoveStartCallback(com.mapconductor.arcgis.ArcGISMapView2DController, kotlin.coroutines.Continuation); public static final java.lang.Object invokeCameraMoveCallback(com.mapconductor.arcgis.ArcGISMapView2DController, kotlin.coroutines.Continuation); public static final java.lang.Object invokeCameraMoveEndCallback(com.mapconductor.arcgis.ArcGISMapView2DController, kotlin.coroutines.Continuation); public static final void scheduleCameraMoveEndCallback(com.mapconductor.arcgis.ArcGISMapView2DController); public static final java.lang.Object getMapCameraPosition(com.mapconductor.arcgis.ArcGISMapView2DController, kotlin.coroutines.Continuation); + public static final double viewportHeightDp(com.mapconductor.arcgis.ArcGISMapView2DController); + public static final com.mapconductor.core.map.MapCameraPosition toNativeCamera(com.mapconductor.arcgis.ArcGISMapView2DController, com.mapconductor.core.map.MapCameraPosition); public static final void handleMoveCamera(com.mapconductor.arcgis.ArcGISMapView2DController, com.mapconductor.core.map.MapCameraPosition); public static final void handleAnimateCamera(com.mapconductor.arcgis.ArcGISMapView2DController, com.mapconductor.core.map.MapCameraPosition, long); public static final void handleFitBounds(com.mapconductor.arcgis.ArcGISMapView2DController, com.mapconductor.core.features.GeoRectBounds, int); @@ -251,6 +254,9 @@ public final class com.mapconductor.arcgis.ArcGISMapViewController extends com.m } public final class com.mapconductor.arcgis.ArcGISMapViewControllerCameraKt { public static final com.mapconductor.core.map.MapCameraPosition getFastMapCameraPosition(com.mapconductor.arcgis.ArcGISMapViewController); + public static final java.lang.Double verticalFov(com.mapconductor.arcgis.ArcGISMapViewController); + public static final double viewportHeightDp(com.mapconductor.arcgis.ArcGISMapViewController); + public static final com.mapconductor.core.map.MapCameraPosition toNativeCamera(com.mapconductor.arcgis.ArcGISMapViewController, com.mapconductor.core.map.MapCameraPosition); public static final void invokeCameraMoveStartCallback(com.mapconductor.arcgis.ArcGISMapViewController); public static final void invokeCameraMoveCallback(com.mapconductor.arcgis.ArcGISMapViewController); public static final java.lang.Object invokeCameraMoveEndCallback(com.mapconductor.arcgis.ArcGISMapViewController, kotlin.coroutines.Continuation); diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISCameraTarget.kt b/src/main/java/com/mapconductor/arcgis/ArcGISCameraTarget.kt new file mode 100644 index 0000000..63fb9b0 --- /dev/null +++ b/src/main/java/com/mapconductor/arcgis/ArcGISCameraTarget.kt @@ -0,0 +1,32 @@ +package com.mapconductor.arcgis + +import com.arcgismaps.mapping.view.Camera +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.features.GeoPoint +import kotlin.math.tan + +// 公開 API のスナップショットから外すため、注釈付きの関数だけをこのファイルに置く +// (注釈はファイル単位のクラスごと除外されるので、他の公開関数と同居させない)。 + +/** + * カメラが見ている地上の点(画面中央)。 + * + * ArcGIS の [Camera] が持つのは**目の位置**で、傾けると見ている点は目の真下から向きの方へ + * `高さ × tan(pitch)` 先になる(カメラを置くときの [calculateCameraForOrbitParameters] の逆)。 + * 目の位置をそのまま MapCameraPosition.position にすると、tilt 60° では数 km 手前の点を + * 返してしまう。地形の高さは見ないので、起伏のある所では画面中央の逆投影の方が正確。 + */ +@InternalMapConductorApi +internal fun Camera.lookAtTarget(): GeoPoint { + val altitude = location.z ?: 0.0 + val pitchDeg = pitch.coerceIn(MIN_ANGLE, ARCGIS_MAX_PITCH - 1.0) + if (pitchDeg == 0.0 || altitude <= 0.0) { + return GeoPoint.fromLongLat(longitude = location.x, latitude = location.y) + } + return calculateDestinationPoint( + lat = location.y, + lon = location.x, + bearing = heading, + distance = altitude * tan(pitchDeg.toRadians()), + ) +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt index 6720cef..ee8744c 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt @@ -5,7 +5,9 @@ import com.mapconductor.core.circle.CircleTiler import com.mapconductor.core.circle.circleTileRasterLayerState import com.arcgismaps.LoadStatus import com.arcgismaps.geometry.SpatialReference +import com.arcgismaps.geometry.Point import com.arcgismaps.mapping.Viewpoint +import com.arcgismaps.mapping.ViewpointType import com.arcgismaps.mapping.view.GraphicsOverlay import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController @@ -26,6 +28,8 @@ 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.MapPaddings +import com.mapconductor.core.map.MapPaddingsInterface import com.mapconductor.core.map.MapGesture import com.mapconductor.core.map.MapUISettings import com.mapconductor.core.map.MapUISettingsDiagnostics @@ -46,7 +50,10 @@ import com.mapconductor.core.polyline.PolylineState import com.mapconductor.core.polyline.PolylineTiler import com.mapconductor.core.polyline.polylineTileRasterLayerState import com.mapconductor.core.projection.Earth +import com.mapconductor.core.spherical.Spherical import com.mapconductor.core.raster.RasterLayerState +import kotlin.math.abs +import kotlin.math.cos import kotlin.math.ln import kotlin.math.pow import android.util.Log @@ -81,6 +88,16 @@ class ArcGISMapView2DController( // カメラ状態の読み戻し時に元の tilt・位置・ズームを復元するヒントとして保持する // (android-for-tomtom / ios-for-arcgis と同方針)。 internal var lastLogicalCameraPosition: MapCameraPosition? = null + + /** + * 今カメラに付いているパディング(dp)。 + * + * `MapView.setViewInsets` は使わない。傾きのエミュレーションでは内側の MapView が + * 2 倍の大きさで回っているので、インセットが画面上のパディングと一致しないため。 + * 代わりに中心をずらして再現し([CameraPaddingEmulation])、読むときに元へ戻すために + * ここで覚えておく。 + */ + internal var cameraPaddings: MapPaddingsInterface = MapPaddings.Zeros internal val cameraMoveEndDebounceMs = 180L internal var mapDesignType: ArcGISDesignTypeInterface = ArcGISDesign.Streets internal var mapDesignTypeChangeListener: ArcGISDesignTypeChangeHandler? = null @@ -316,7 +333,8 @@ class ArcGISMapView2DController( * 指定位置がそのまま画面中心に来る。 */ internal fun toViewpoint(position: MapCameraPosition): Viewpoint { - val (center, zoom) = ArcGIS2DTiltEmulation.shiftedCamera(position) + val (shifted, zoom) = ArcGIS2DTiltEmulation.shiftedCamera(position) + val center = centeredOnView(shifted, zoom, position.bearing) val point = GeoPoint.from(center).toPoint(SpatialReference.wgs84()) return Viewpoint( center = point, @@ -330,6 +348,42 @@ class ArcGISMapView2DController( ) } + /** + * ArcGIS は Viewpoint の中心をビューの中央ではなく、帰属表示バーを除いた領域の中央に描く + * (TB520FU では 25.5px 上)。そのままでは position が画面中央に来ず、読み戻すと + * 45m ほどずれていた。ずれを今の地図から測り、その分だけ中心を画面の上方向 + * (カメラの向き)へずらして、position が画面中央に来るようにする。 + */ + private fun centeredOnView( + center: GeoPointInterface, + zoom: Double, + bearing: Double, + ): GeoPointInterface { + val offsetPx = viewpointCenterOffsetPx() ?: return center + if (abs(offsetPx) < 0.5) return center + val density = holder.mapView.resources.displayMetrics.density + val metersPerDp = + WEB_MERCATOR_CIRCUMFERENCE_METERS / (TILE_SIZE * 2.0.pow(zoom)) * + cos(Math.toRadians(center.latitude)) + return Spherical.computeOffset( + center, + -offsetPx / density * metersPerDp, + CameraBearing.toNativeHeading(bearing), + ) + } + + /** Viewpoint の中心が内側の MapView の中央から縦に何 px ずれて描かれているか。上が負。 */ + private fun viewpointCenterOffsetPx(): Double? = + runCatching { + val height = holder.map.height + if (height <= 0) return null + val center = + holder.map.getCurrentViewpoint(ViewpointType.CenterAndScale)?.targetGeometry as? Point + ?: return null + val screen = holder.map.locationToScreen(center) ?: return null + screen.y - height / 2.0 + }.getOrNull() + @Deprecated("Use MarkerState.onDragStart instead.") override fun setOnMarkerDragStart(listener: OnMarkerEventHandler?) { markerDragStartListener = listener diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DControllerCamera.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DControllerCamera.kt index 72de329..3b1714c 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DControllerCamera.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DControllerCamera.kt @@ -3,6 +3,7 @@ package com.mapconductor.arcgis import androidx.compose.ui.geometry.Offset import com.mapconductor.core.features.GeoRectBounds import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.CameraPaddingEmulation import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapPaddings import com.mapconductor.core.map.VisibleRegion @@ -58,8 +59,11 @@ internal fun ArcGISMapView2DController.scheduleCameraMoveEndCallback() { } internal suspend fun ArcGISMapView2DController.getMapCameraPosition(): MapCameraPosition? { - val mapWidth = holder.map.width.toFloat() - 1.0f - val mapHeight = holder.map.height.toFloat() - 1.0f + // holder の画面座標は外側のビュー(WrapMapView)のもの。傾けているときの内側の MapView は + // 2 倍の大きさで回っているので、その幅・高さで隅を取ると画面の外を逆投影してしまい、 + // 中心は数 km ずれ、隅は解けずにカメラごと読めなくなっていた。 + val mapWidth = holder.mapView.width.toFloat() - 1.0f + val mapHeight = holder.mapView.height.toFloat() - 1.0f val nearLeft = holder.fromScreenOffset(Offset(1.0f, mapHeight)) ?: return null val nearRight = holder.fromScreenOffsetSync(Offset(mapWidth, mapHeight)) ?: return null val farLeft = holder.fromScreenOffsetSync(Offset(1.0f, 1.0f)) ?: return null @@ -93,13 +97,41 @@ internal suspend fun ArcGISMapView2DController.getMapCameraPosition(): MapCamera logicalTilt = logicalTilt, ) - return MapCameraPosition( - position = restoredCenter, - zoom = restoredZoom, - bearing = bearing, - tilt = logicalTilt, - paddings = MapPaddings.Zeros, - visibleRegion = visibleRegion, + return CameraPaddingEmulation.fromNativeCamera( + MapCameraPosition( + position = restoredCenter, + zoom = restoredZoom, + bearing = bearing, + tilt = logicalTilt, + paddings = MapPaddings.Zeros, + visibleRegion = visibleRegion, + ), + cameraPaddings, + ARCGIS_2D_VERTICAL_FOV, + viewportHeightDp(), + ) +} + +/** + * 2D の MapView のカメラの縦方向の視野角(度)。2D はカメラを傾けられず、傾きはビューを + * X 軸まわりに回して見せている([WrapMapView]、遠近はほぼ付けない)。TB520FU で、傾けた + * ときの縦の間隔は cos(tilt) 倍と 0.3% 以内で一致した。遠近なしの傾きとして 0 と表す。 + */ +internal const val ARCGIS_2D_VERTICAL_FOV = 0.0 + +/** 地図の高さ(dp)。外側のビュー(画面に見えている大きさ)で測る。 */ +internal fun ArcGISMapView2DController.viewportHeightDp(): Double { + val density = holder.mapView.resources.displayMetrics.density + return holder.mapView.height / density.toDouble() +} + +/** パディングを中心のずらしに置き換えたカメラ。paddings が null なら今のパディングを使い続ける。 */ +internal fun ArcGISMapView2DController.toNativeCamera(position: MapCameraPosition): MapCameraPosition { + position.paddings?.let { cameraPaddings = it } + return CameraPaddingEmulation.toNativeCamera( + position.copy(paddings = cameraPaddings), + ARCGIS_2D_VERTICAL_FOV, + viewportHeightDp(), ) } @@ -113,11 +145,14 @@ internal fun ArcGISMapView2DController.handleMoveCamera(position: MapCameraPosit // ビューを傾けると地図の中身は縦に潰れる。マーカーだけは立って見えるよう、 // アイコンを先に縦へ引き伸ばす(他のオーバーレイは寝たままでよい)。 if (tiltChanged) markerController.refreshVerticalStretch() - val viewpoint = toViewpoint(position) + val viewpoint = toViewpoint(toNativeCamera(position)) val applyViewpoint = { mainCoroutine.launch { if (!holder.mapView.isAttachedToWindow) return@launch holder.map.setViewpoint(viewpoint) + // tilt だけが変わったときは Viewpoint が同じなので ArcGIS は変化を通知しない。 + // そのままだと state のカメラが古い tilt のまま残るので、ここで配る。 + if (tiltChanged) onViewpointChange() } Unit } @@ -132,7 +167,7 @@ internal fun ArcGISMapView2DController.handleAnimateCamera( val tiltChanged = holder.mapView.visualTilt != position.tilt holder.mapView.visualTilt = position.tilt if (tiltChanged) markerController.refreshVerticalStretch() - val viewpoint = toViewpoint(position) + val viewpoint = toViewpoint(toNativeCamera(position)) val animate = { defaultCoroutine.launch { invokeCameraMoveStartCallback() diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt index a341246..60dbada 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt @@ -20,6 +20,8 @@ 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.MapPaddings +import com.mapconductor.core.map.MapPaddingsInterface import com.mapconductor.core.map.MapGesture import com.mapconductor.core.map.MapUISettings import com.mapconductor.core.map.MapUISettingsDiagnostics @@ -67,6 +69,14 @@ class ArcGISMapViewController( // 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 + + /** + * 今カメラに付いているパディング(dp)。 + * + * 3D の SceneView にはパディング(インセット)の口が無いので、カメラが見る点を + * ずらして再現し([CameraPaddingEmulation])、読むときに元へ戻すためにここで覚えておく。 + */ + internal var cameraPaddings: MapPaddingsInterface = MapPaddings.Zeros internal val cameraMoveEndDebounceMs = 180L /** diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt index 9df02f5..55f7150 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt @@ -7,9 +7,12 @@ import com.mapconductor.arcgis.zoom.ZoomAltitudeConverter import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoRectBounds import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.CameraPaddingEmulation import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapPaddings import com.mapconductor.core.map.VisibleRegion +import kotlin.math.atan +import kotlin.math.tan import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -20,11 +23,45 @@ import kotlinx.coroutines.launch // API が無く、停止時に矩形内へクランプして再適用する。 internal fun ArcGISMapViewController.getFastMapCameraPosition(): MapCameraPosition? = try { - holder.map.getCurrentViewpointCamera()?.toMapCameraPosition() + holder.map.getCurrentViewpointCamera()?.toMapCameraPosition()?.let { camera -> + CameraPaddingEmulation.fromNativeCamera(camera, cameraPaddings, verticalFov(), viewportHeightDp()) + } } catch (_: Exception) { null } +/** + * SceneView のカメラの縦方向の視野角(度)。 + * + * [SceneView.getFieldOfView][com.arcgismaps.mapping.view.SceneView.fieldOfView] は**横方向**の + * 視野角(TB520FU の縦長の全画面で 29.3°、横長で 68.0°)。縦に直すと向きによらず 45° になり、 + * 実測(地図の大きさ・向きを変えて 45.2〜45.4°)と一致する。 + */ +internal fun ArcGISMapViewController.verticalFov(): Double? { + val width = holder.map.width + val height = holder.map.height + if (width <= 0 || height <= 0) return null + val horizontal = runCatching { holder.map.fieldOfView }.getOrNull() ?: return null + val halfVertical = atan(tan(Math.toRadians(horizontal) / 2.0) * height / width) + return Math.toDegrees(2.0 * halfVertical) +} + +/** 地図の高さ(dp)。パディングのエミュレーションで遠近を解くのに使う。 */ +internal fun ArcGISMapViewController.viewportHeightDp(): Double { + val density = holder.mapView.resources.displayMetrics.density + return holder.map.height / density.toDouble() +} + +/** パディングを見る点のずらしに置き換えたカメラ。paddings が null なら今のパディングを使い続ける。 */ +internal fun ArcGISMapViewController.toNativeCamera(position: MapCameraPosition): MapCameraPosition { + position.paddings?.let { cameraPaddings = it } + return CameraPaddingEmulation.toNativeCamera( + position.copy(paddings = cameraPaddings), + verticalFov(), + viewportHeightDp(), + ) +} + internal fun ArcGISMapViewController.invokeCameraMoveStartCallback() { cameraMoveStartHandler()?.let { cb -> getFastMapCameraPosition()?.let { mapCameraPosition -> @@ -118,8 +155,13 @@ internal suspend fun ArcGISMapViewController.getMapCameraPosition(): MapCameraPo ) val arcCamera = holder.map.getCurrentViewpointCamera() ?: return null - val lat = arcCamera.location.y - val lon = arcCamera.location.x + // position は見ている点(画面中央)。目の位置(arcCamera.location)ではない。 + // 地形を考慮するため画面中央の逆投影を優先し、取れなければ幾何で求める。 + val target = + holder.fromScreenOffsetSync(Offset(holder.map.width / 2f, holder.map.height / 2f)) + ?: arcCamera.lookAtTarget() + val lat = target.latitude + val lon = target.longitude val alt = arcCamera.location.z ?: 0.0 val tilt = arcCamera.pitch val bearing = CameraBearing.bearingFromNativeHeading(arcCamera.heading) @@ -146,11 +188,11 @@ internal suspend fun ArcGISMapViewController.getMapCameraPosition(): MapCameraPo paddings = MapPaddings.Zeros, visibleRegion = visibleRegion, ) - return camera + return CameraPaddingEmulation.fromNativeCamera(camera, cameraPaddings, verticalFov(), viewportHeightDp()) } internal fun ArcGISMapViewController.handleMoveCamera(position: MapCameraPosition) { - val dstCameraPosition = toCameraWithView(position) + val dstCameraPosition = toCameraWithView(toNativeCamera(position)) mainCoroutine.launch { if (!holder.mapView.isAttachedToWindow) return@launch @@ -162,7 +204,7 @@ internal fun ArcGISMapViewController.handleAnimateCamera( position: MapCameraPosition, duration: Long, ) { - val dstCameraPosition = toCameraWithView(position) + val dstCameraPosition = toCameraWithView(toNativeCamera(position)) defaultCoroutine.launch { invokeCameraMoveStartCallback() diff --git a/src/main/java/com/mapconductor/arcgis/MapCameraPosition.kt b/src/main/java/com/mapconductor/arcgis/MapCameraPosition.kt index 029f1ca..d3a522c 100644 --- a/src/main/java/com/mapconductor/arcgis/MapCameraPosition.kt +++ b/src/main/java/com/mapconductor/arcgis/MapCameraPosition.kt @@ -147,19 +147,20 @@ fun Camera.withZoomLevel(zoomLevel: Double): Camera { ) } -fun Camera.toMapCameraPosition(): MapCameraPosition = - MapCameraPosition( +fun Camera.toMapCameraPosition(): MapCameraPosition { + val target = lookAtTarget() + return MapCameraPosition( position = GeoPoint.fromLongLat( - longitude = this.location.x, - latitude = this.location.y, + longitude = target.longitude, + latitude = target.latitude, altitude = this.location.z ?: 0.0, ), zoom = converter .altitudeToZoomLevel( altitude = this.location.z ?: 0.0, - latitude = this.location.y, + latitude = target.latitude, tilt = this.pitch, ), bearing = CameraBearing.bearingFromNativeHeading(this.heading), @@ -167,3 +168,4 @@ fun Camera.toMapCameraPosition(): MapCameraPosition = paddings = MapPaddings.Zeros, visibleRegion = null, ) +}