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..275fd89 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 @@ -46,7 +48,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 @@ -316,7 +321,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 +336,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..71cf0a6 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DControllerCamera.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DControllerCamera.kt @@ -58,8 +58,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 @@ -118,6 +121,9 @@ internal fun ArcGISMapView2DController.handleMoveCamera(position: MapCameraPosit mainCoroutine.launch { if (!holder.mapView.isAttachedToWindow) return@launch holder.map.setViewpoint(viewpoint) + // tilt だけが変わったときは Viewpoint が同じなので ArcGIS は変化を通知しない。 + // そのままだと state のカメラが古い tilt のまま残るので、ここで配る。 + if (tiltChanged) onViewpointChange() } Unit } diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt index 9df02f5..07598c7 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt @@ -118,8 +118,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) 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, ) +}