From 3a47b126d5ca39e5f49a97c53e6fb3eb97308721 Mon Sep 17 00:00:00 2001 From: masashi Date: Tue, 1 Sep 2026 11:40:28 +0900 Subject: [PATCH 1/2] Make bearing a clockwise map rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bearing を「値を増やすと地図が時計回りに回る」向きに統一し、ネイティブ SDK との変換をコアの CameraBearing に集約する。 --- .../arcgis/ArcGIS2DTiltEmulation.kt | 5 +- .../arcgis/ArcGISMapView2DController.kt | 814 +++++++++--------- .../arcgis/ArcGISMapView2DControllerCamera.kt | 3 +- .../arcgis/ArcGISMapViewControllerCamera.kt | 429 ++++----- .../mapconductor/arcgis/MapCameraPosition.kt | 3 +- .../arcgis/ArcGIS2DTiltEmulationTest.kt | 13 +- 6 files changed, 640 insertions(+), 627 deletions(-) diff --git a/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt b/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt index 35415e3..46a0557 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt @@ -3,6 +3,7 @@ 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 @@ -56,7 +57,7 @@ internal object ArcGIS2DTiltEmulation { 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, position.bearing) + val target = Spherical.computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) return target to zoom } @@ -81,7 +82,7 @@ internal object ArcGIS2DTiltEmulation { 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, bearing + 180.0) + 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/ArcGISMapView2DController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt index 9b6db38..db27097 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt @@ -1,404 +1,410 @@ -package com.mapconductor.arcgis - -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.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), - rotation = 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 baseMap = Basemap(ArcGISDesign.toBasemapStyle(value)) - holder.map.map?.setBasemap(baseMap) - } - - 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.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 baseMap = Basemap(ArcGISDesign.toBasemapStyle(value)) + holder.map.map?.setBasemap(baseMap) + } + + 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/ArcGISMapView2DControllerCamera.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DControllerCamera.kt index adc5cef..72de329 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DControllerCamera.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DControllerCamera.kt @@ -2,6 +2,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.MapCameraPosition import com.mapconductor.core.map.MapPaddings import com.mapconductor.core.map.VisibleRegion @@ -82,7 +83,7 @@ internal suspend fun ArcGISMapView2DController.getMapCameraPosition(): MapCamera // 2D はカメラピッチを持てないため tilt は擬似表現([ArcGIS2DTiltEmulation])。 // 直近に要求した論理 tilt を手掛かりに、前進させた中心とズームを巻き戻して返す。 - val bearing = ((holder.map.mapRotation.value % 360) + 360) % 360 + val bearing = CameraBearing.bearingFromNativeHeading(holder.map.mapRotation.value) val logicalTilt = lastLogicalCameraPosition?.tilt ?: 0.0 val (restoredCenter, restoredZoom) = ArcGIS2DTiltEmulation.restoreLogicalCamera( diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt index 9015113..9df02f5 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerCamera.kt @@ -1,214 +1,215 @@ -package com.mapconductor.arcgis - -import androidx.compose.ui.geometry.Offset -import com.arcgismaps.mapping.Viewpoint -import com.arcgismaps.mapping.view.Camera -import com.mapconductor.arcgis.zoom.ZoomAltitudeConverter -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoRectBounds -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapPaddings -import com.mapconductor.core.map.VisibleRegion -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -// カメラの読み取りと通知。 -// ArcGIS は連続的な viewpoint 変化しか通知しないので、「止まった」は -// デバウンス(cameraMoveEndDebounceMs)で作っている。範囲制限もネイティブ -// API が無く、停止時に矩形内へクランプして再適用する。 -internal fun ArcGISMapViewController.getFastMapCameraPosition(): MapCameraPosition? = - try { - holder.map.getCurrentViewpointCamera()?.toMapCameraPosition() - } catch (_: Exception) { - null - } - -internal fun ArcGISMapViewController.invokeCameraMoveStartCallback() { - cameraMoveStartHandler()?.let { cb -> - getFastMapCameraPosition()?.let { mapCameraPosition -> - cb(mapCameraPosition) - } - } -} - -internal fun ArcGISMapViewController.invokeCameraMoveCallback() { - cameraMoveHandler()?.let { cb -> - getFastMapCameraPosition()?.let { mapCameraPosition -> - cb(mapCameraPosition) - } - } - scheduleCameraMoveEndCallback() -} - -internal suspend fun ArcGISMapViewController.invokeCameraMoveEndCallback() { - val mapCameraPosition = getMapCameraPosition() ?: return - // 範囲・ズーム制限に違反していれば矩形内へ引き戻す(ArcGIS はネイティブの範囲制限 API が無いため)。 - // 再適用すると viewpointChanged が再発火し、そこでは補正不要になり通常フローへ進む。 - correctForCameraRestriction(mapCameraPosition)?.let { corrected -> - moveCamera(corrected) - return - } - emitCameraMoveEnd(mapCameraPosition) -} - -internal fun ArcGISMapViewController.scheduleCameraMoveEndCallback() { - if (!needsCameraMoveEndWork()) return - cameraMoveEndJob?.cancel() - cameraMoveEndJob = - defaultCoroutine.launch { - delay(cameraMoveEndDebounceMs.milliseconds) - invokeCameraMoveEndCallback() - } -} - -internal fun ArcGISMapViewController.currentViewportSizeInDp(): Pair { - val density = - holder.mapView.resources.displayMetrics.density - .coerceAtLeast(0.1f) - val widthDp = (holder.map.width / density).toInt().coerceAtLeast(1) - val heightDp = (holder.map.height / density).toInt().coerceAtLeast(1) - return Pair(widthDp, heightDp) -} - -internal suspend fun ArcGISMapViewController.onViewpointChange() { - emitMapInitialized() - - getFastMapCameraPosition()?.let { mapCameraPosition -> - emitCameraPosition(mapCameraPosition) - scheduleCameraMoveEndCallback() - } -} - -internal suspend fun ArcGISMapViewController.getMapCameraPosition(): MapCameraPosition? { - val mapWidth = holder.map.width.toFloat() - 1.0f - val mapHeight = holder.map.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 - val farRight = - holder.fromScreenOffsetSync( - Offset(mapWidth, 1.0f), - ) ?: return null - - val bounds = GeoRectBounds() - bounds.extend(nearLeft) - bounds.extend(nearRight) - bounds.extend(farLeft) - bounds.extend(farRight) - - val visibleRegion = - VisibleRegion( - bounds = bounds, - nearLeft = nearLeft, - nearRight = nearRight, - farLeft = farLeft, - farRight = farRight, - ) - - val arcCamera = holder.map.getCurrentViewpointCamera() ?: return null - val lat = arcCamera.location.y - val lon = arcCamera.location.x - val alt = arcCamera.location.z ?: 0.0 - val tilt = arcCamera.pitch - val bearing = ((arcCamera.heading % 360) + 360) % 360 - - val conv = ZoomAltitudeConverter() - val (viewportWidthDp, viewportHeightDp) = currentViewportSizeInDp() - val zoom = - conv.altitudeToZoomLevel( - altitude = alt, - latitude = lat, - tilt = tilt, - viewportWidthPx = viewportWidthDp, - viewportHeightPx = viewportHeightDp, - ) - - val camera = - MapCameraPosition( - position = - GeoPoint - .fromLongLat(lon, lat, alt), - zoom = zoom, - bearing = bearing, - tilt = tilt, - paddings = MapPaddings.Zeros, - visibleRegion = visibleRegion, - ) - return camera -} - -internal fun ArcGISMapViewController.handleMoveCamera(position: MapCameraPosition) { - val dstCameraPosition = toCameraWithView(position) - - mainCoroutine.launch { - if (!holder.mapView.isAttachedToWindow) return@launch - holder.map.setViewpointCamera(camera = dstCameraPosition) - } -} - -internal fun ArcGISMapViewController.handleAnimateCamera( - position: MapCameraPosition, - duration: Long, -) { - val dstCameraPosition = toCameraWithView(position) - - defaultCoroutine.launch { - invokeCameraMoveStartCallback() - mainCoroutine.launch { - if (!holder.mapView.isAttachedToWindow) return@launch - holder.map.setViewpointCameraAnimated( - camera = dstCameraPosition, - duration = duration.toFloat() / 1000.0f, - ) - } - scheduleCameraMoveEndCallback() - } -} - -internal fun ArcGISMapViewController.handleFitBounds( - bounds: GeoRectBounds, - padding: Int, -) { - val envelope = bounds.toEnvelope() ?: return - - mainCoroutine.launch { - if (!holder.mapView.isAttachedToWindow) return@launch - // NOTE: 3D の SceneView は setViewpointGeometry(geometry, padding) を持たず、 - // GeoView.setViewpoint(Viewpoint) には padding の概念が無いため padding は反映できない。 - // (padding 対応は 2D の ArcGISMapView2DController のみ。) - holder.map.setViewpoint(Viewpoint(envelope)) - } -} - -internal fun ArcGISMapViewController.toCameraWithView(position: MapCameraPosition): Camera { - val targetPoint = - GeoPoint - .from(position.position) - .toPoint() - val conv = ZoomAltitudeConverter() - val (viewportWidthDp, viewportHeightDp) = currentViewportSizeInDp() - val distance = - conv.zoomLevelToDistance( - zoomLevel = position.zoom, - latitude = position.position.latitude, - viewportWidthPx = viewportWidthDp, - viewportHeightPx = viewportHeightDp, - ) - return calculateCameraForOrbitParameters( - targetPoint = targetPoint, - distance = distance, - cameraHeadingOffset = position.bearing + 180, - cameraPitchOffset = position.tilt, - ) -} +package com.mapconductor.arcgis + +import androidx.compose.ui.geometry.Offset +import com.arcgismaps.mapping.Viewpoint +import com.arcgismaps.mapping.view.Camera +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.MapCameraPosition +import com.mapconductor.core.map.MapPaddings +import com.mapconductor.core.map.VisibleRegion +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// カメラの読み取りと通知。 +// ArcGIS は連続的な viewpoint 変化しか通知しないので、「止まった」は +// デバウンス(cameraMoveEndDebounceMs)で作っている。範囲制限もネイティブ +// API が無く、停止時に矩形内へクランプして再適用する。 +internal fun ArcGISMapViewController.getFastMapCameraPosition(): MapCameraPosition? = + try { + holder.map.getCurrentViewpointCamera()?.toMapCameraPosition() + } catch (_: Exception) { + null + } + +internal fun ArcGISMapViewController.invokeCameraMoveStartCallback() { + cameraMoveStartHandler()?.let { cb -> + getFastMapCameraPosition()?.let { mapCameraPosition -> + cb(mapCameraPosition) + } + } +} + +internal fun ArcGISMapViewController.invokeCameraMoveCallback() { + cameraMoveHandler()?.let { cb -> + getFastMapCameraPosition()?.let { mapCameraPosition -> + cb(mapCameraPosition) + } + } + scheduleCameraMoveEndCallback() +} + +internal suspend fun ArcGISMapViewController.invokeCameraMoveEndCallback() { + val mapCameraPosition = getMapCameraPosition() ?: return + // 範囲・ズーム制限に違反していれば矩形内へ引き戻す(ArcGIS はネイティブの範囲制限 API が無いため)。 + // 再適用すると viewpointChanged が再発火し、そこでは補正不要になり通常フローへ進む。 + correctForCameraRestriction(mapCameraPosition)?.let { corrected -> + moveCamera(corrected) + return + } + emitCameraMoveEnd(mapCameraPosition) +} + +internal fun ArcGISMapViewController.scheduleCameraMoveEndCallback() { + if (!needsCameraMoveEndWork()) return + cameraMoveEndJob?.cancel() + cameraMoveEndJob = + defaultCoroutine.launch { + delay(cameraMoveEndDebounceMs.milliseconds) + invokeCameraMoveEndCallback() + } +} + +internal fun ArcGISMapViewController.currentViewportSizeInDp(): Pair { + val density = + holder.mapView.resources.displayMetrics.density + .coerceAtLeast(0.1f) + val widthDp = (holder.map.width / density).toInt().coerceAtLeast(1) + val heightDp = (holder.map.height / density).toInt().coerceAtLeast(1) + return Pair(widthDp, heightDp) +} + +internal suspend fun ArcGISMapViewController.onViewpointChange() { + emitMapInitialized() + + getFastMapCameraPosition()?.let { mapCameraPosition -> + emitCameraPosition(mapCameraPosition) + scheduleCameraMoveEndCallback() + } +} + +internal suspend fun ArcGISMapViewController.getMapCameraPosition(): MapCameraPosition? { + val mapWidth = holder.map.width.toFloat() - 1.0f + val mapHeight = holder.map.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 + val farRight = + holder.fromScreenOffsetSync( + Offset(mapWidth, 1.0f), + ) ?: return null + + val bounds = GeoRectBounds() + bounds.extend(nearLeft) + bounds.extend(nearRight) + bounds.extend(farLeft) + bounds.extend(farRight) + + val visibleRegion = + VisibleRegion( + bounds = bounds, + nearLeft = nearLeft, + nearRight = nearRight, + farLeft = farLeft, + farRight = farRight, + ) + + val arcCamera = holder.map.getCurrentViewpointCamera() ?: return null + val lat = arcCamera.location.y + val lon = arcCamera.location.x + val alt = arcCamera.location.z ?: 0.0 + val tilt = arcCamera.pitch + val bearing = CameraBearing.bearingFromNativeHeading(arcCamera.heading) + + val conv = ZoomAltitudeConverter() + val (viewportWidthDp, viewportHeightDp) = currentViewportSizeInDp() + val zoom = + conv.altitudeToZoomLevel( + altitude = alt, + latitude = lat, + tilt = tilt, + viewportWidthPx = viewportWidthDp, + viewportHeightPx = viewportHeightDp, + ) + + val camera = + MapCameraPosition( + position = + GeoPoint + .fromLongLat(lon, lat, alt), + zoom = zoom, + bearing = bearing, + tilt = tilt, + paddings = MapPaddings.Zeros, + visibleRegion = visibleRegion, + ) + return camera +} + +internal fun ArcGISMapViewController.handleMoveCamera(position: MapCameraPosition) { + val dstCameraPosition = toCameraWithView(position) + + mainCoroutine.launch { + if (!holder.mapView.isAttachedToWindow) return@launch + holder.map.setViewpointCamera(camera = dstCameraPosition) + } +} + +internal fun ArcGISMapViewController.handleAnimateCamera( + position: MapCameraPosition, + duration: Long, +) { + val dstCameraPosition = toCameraWithView(position) + + defaultCoroutine.launch { + invokeCameraMoveStartCallback() + mainCoroutine.launch { + if (!holder.mapView.isAttachedToWindow) return@launch + holder.map.setViewpointCameraAnimated( + camera = dstCameraPosition, + duration = duration.toFloat() / 1000.0f, + ) + } + scheduleCameraMoveEndCallback() + } +} + +internal fun ArcGISMapViewController.handleFitBounds( + bounds: GeoRectBounds, + padding: Int, +) { + val envelope = bounds.toEnvelope() ?: return + + mainCoroutine.launch { + if (!holder.mapView.isAttachedToWindow) return@launch + // NOTE: 3D の SceneView は setViewpointGeometry(geometry, padding) を持たず、 + // GeoView.setViewpoint(Viewpoint) には padding の概念が無いため padding は反映できない。 + // (padding 対応は 2D の ArcGISMapView2DController のみ。) + holder.map.setViewpoint(Viewpoint(envelope)) + } +} + +internal fun ArcGISMapViewController.toCameraWithView(position: MapCameraPosition): Camera { + val targetPoint = + GeoPoint + .from(position.position) + .toPoint() + val conv = ZoomAltitudeConverter() + val (viewportWidthDp, viewportHeightDp) = currentViewportSizeInDp() + val distance = + conv.zoomLevelToDistance( + zoomLevel = position.zoom, + latitude = position.position.latitude, + viewportWidthPx = viewportWidthDp, + viewportHeightPx = viewportHeightDp, + ) + return calculateCameraForOrbitParameters( + targetPoint = targetPoint, + distance = distance, + cameraHeadingOffset = CameraBearing.toNativeHeading(position.bearing) + 180, + cameraPitchOffset = position.tilt, + ) +} diff --git a/src/main/java/com/mapconductor/arcgis/MapCameraPosition.kt b/src/main/java/com/mapconductor/arcgis/MapCameraPosition.kt index 727100a..029f1ca 100644 --- a/src/main/java/com/mapconductor/arcgis/MapCameraPosition.kt +++ b/src/main/java/com/mapconductor/arcgis/MapCameraPosition.kt @@ -3,6 +3,7 @@ package com.mapconductor.arcgis import com.arcgismaps.mapping.view.Camera import com.mapconductor.arcgis.zoom.ZoomAltitudeConverter import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.CameraBearing import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapPaddings @@ -161,7 +162,7 @@ fun Camera.toMapCameraPosition(): MapCameraPosition = latitude = this.location.y, tilt = this.pitch, ), - bearing = ((this.heading % 360) + 360) % 360, + bearing = CameraBearing.bearingFromNativeHeading(this.heading), tilt = this.pitch, paddings = MapPaddings.Zeros, visibleRegion = null, diff --git a/src/test/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulationTest.kt b/src/test/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulationTest.kt index 0b63157..b0b0f5b 100644 --- a/src/test/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulationTest.kt +++ b/src/test/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulationTest.kt @@ -63,12 +63,15 @@ class ArcGIS2DTiltEmulationTest { } @Test - fun shiftDirection_followsBearing() { - val (east, _) = ArcGIS2DTiltEmulation.shiftedCamera(camera(-45.0, bearing = 90.0)) - assertTrue(east.longitude > tokyo.longitude) - // 大円に沿って東進するため緯度はわずかに下がる - assertTrue(abs(east.latitude - tokyo.latitude) < 1e-3) + fun shiftDirection_followsCameraHeading() { + // bearing は「地図を時計回りに回す量」なので、カメラが向く方位はその符号反転。 + // bearing 90(地図が右へ 90 度)のとき画面の上=カメラの向きは西。 + val (west, _) = ArcGIS2DTiltEmulation.shiftedCamera(camera(-45.0, bearing = 90.0)) + assertTrue(west.longitude < tokyo.longitude) + // 大円に沿って西進するため緯度はわずかに下がる + assertTrue(abs(west.latitude - tokyo.latitude) < 1e-3) + // bearing 180 は符号反転しても 180(真南のまま)。 val (south, _) = ArcGIS2DTiltEmulation.shiftedCamera(camera(-45.0, bearing = 180.0)) assertTrue(south.latitude < tokyo.latitude) } From 3437f075c0d1fa7da536eb9e4902bc8d846da9d5 Mon Sep 17 00:00:00 2001 From: masashi Date: Thu, 3 Sep 2026 17:12:11 +0900 Subject: [PATCH 2/2] ci: build the core module from the matching branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コアの未マージ/未リリースな変更に追随する PR を、コアが先に取り込まれるのを待たずに検証できるようにする。同名ブランチが無ければ従来どおり既定のコアを使う。 --- .github/workflows/build.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 79f872f..f0d3bb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,8 +38,29 @@ jobs: cache: 'gradle' - name: Build core and compose locally + env: + HEAD_REF: ${{ github.head_ref }} run: | - git clone --depth 1 https://github.com/MapConductor/android-sdk-core.git _core + # コア側に PR と同名のブランチがあるなら、そちらを組む。 + # + # ここを default ブランチ固定にしていると、**コアの未マージな変更に追随する + # PR は永久に赤いまま**になる。実際 CameraBearing をコアへ足したとき、 + # プロバイダ側の PR が 10 本まとめて Unresolved reference で落ちた。 + # コアが先にマージされるまで待つ、という運用は取り込み順の事故を呼ぶので、 + # 同名ブランチがあれば拾う形にしてある(無ければ従来どおり default)。 + clone_matching() { + repo="$1"; dir="$2" + url="https://github.com/MapConductor/$repo.git" + if [ -n "$HEAD_REF" ] && git ls-remote --exit-code --heads "$url" "$HEAD_REF" >/dev/null 2>&1; then + echo "$repo: matching branch $HEAD_REF" + git clone --depth 1 --branch "$HEAD_REF" "$url" "$dir" + else + echo "$repo: default branch" + git clone --depth 1 "$url" "$dir" + fi + } + + clone_matching android-sdk-core _core cd _core ./gradlew --no-daemon publishToMavenLocal cd .. @@ -53,7 +74,7 @@ jobs: } EOF - git clone --depth 1 https://github.com/MapConductor/android-sdk-compose.git _compose + clone_matching android-sdk-compose _compose cd _compose ./gradlew --no-daemon publishToMavenLocal cd ..