From b094855d2b8601489d45290e05cee3022ea23064 Mon Sep 17 00:00:00 2001 From: masashi Date: Tue, 1 Sep 2026 11:39:17 +0900 Subject: [PATCH] Make bearing a clockwise map rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bearing を「値を増やすと地図が時計回りに回る」向きに統一し、ネイティブ SDK との変換をコアの CameraBearing に集約する。 --- .../mapconductor/core/map/CameraBearing.kt | 47 +++++++++++++++++++ .../core/map/MapCameraPositionBase.kt | 8 ++++ .../projection/WebMercatorScreenProjection.kt | 8 ++-- .../WebMercatorScreenProjectionGoldenTest.kt | 13 +++-- 4 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/mapconductor/core/map/CameraBearing.kt diff --git a/src/main/java/com/mapconductor/core/map/CameraBearing.kt b/src/main/java/com/mapconductor/core/map/CameraBearing.kt new file mode 100644 index 0000000..baa6a33 --- /dev/null +++ b/src/main/java/com/mapconductor/core/map/CameraBearing.kt @@ -0,0 +1,47 @@ +package com.mapconductor.core.map + +import com.mapconductor.core.InternalMapConductorApi + +/** + * [com.mapconductor.core.features.MapCameraPosition.bearing] とネイティブ SDK の回転値の変換。 + * + * MapConductor の `bearing` は **「値を増やすと地図が時計回り(右)に回る」** 向きで定義する。 + * 0 が北で、90 なら地図全体が右へ 90 度回り、画面の上には西が来る。 + * + * ネイティブ SDK は大きく 2 系統に分かれる。 + * + * - **heading 系**(Google Maps / MapLibre / Mapbox / HERE / MapKit ほか) + * 「画面の上が指す方位を北から時計回りに測る」=カメラの向き。値を増やすと地図は + * 反時計回りに回るので、MapConductor とは符号が反転する。[toNativeHeading]。 + * - **rotation 系**(ArcGIS の `Viewpoint.rotation` など)「北を時計回りに回す量」= + * 地図の回転角。MapConductor と同じ向きなので変換は恒等になる。[toNativeRotation]。 + * + * 符号をプロバイダ各所に散らすと必ずどこかが漏れるので、変換はここだけに置く。 + */ +@InternalMapConductorApi +object CameraBearing { + + /** 角度を 0 以上 360 未満へ畳む。 */ + fun normalizeDegrees360(degrees: Double): Double { + if (!degrees.isFinite()) return 0.0 + val wrapped = degrees % 360.0 + return if (wrapped < 0.0) wrapped + 360.0 else wrapped + } + + /** + * MapConductor の bearing → heading 系ネイティブ SDK の値。 + * + * カメラが向いている方位でもあるので、負 tilt エミュレーションで + * 「カメラの前進方向」が要るときもこれを使う。 + */ + fun toNativeHeading(bearing: Double): Double = normalizeDegrees360(-bearing) + + /** heading 系ネイティブ SDK の値 → MapConductor の bearing。 */ + fun bearingFromNativeHeading(heading: Double): Double = normalizeDegrees360(-heading) + + /** MapConductor の bearing → rotation 系ネイティブ SDK の値(向きが同じなので恒等)。 */ + fun toNativeRotation(bearing: Double): Double = normalizeDegrees360(bearing) + + /** rotation 系ネイティブ SDK の値 → MapConductor の bearing(同上)。 */ + fun bearingFromNativeRotation(rotation: Double): Double = normalizeDegrees360(rotation) +} diff --git a/src/main/java/com/mapconductor/core/map/MapCameraPositionBase.kt b/src/main/java/com/mapconductor/core/map/MapCameraPositionBase.kt index 9500e2e..aad0a2f 100644 --- a/src/main/java/com/mapconductor/core/map/MapCameraPositionBase.kt +++ b/src/main/java/com/mapconductor/core/map/MapCameraPositionBase.kt @@ -16,6 +16,14 @@ data class VisibleRegion( interface MapCameraPositionInterface { val position: GeoPointInterface val zoom: Double + + /** + * 地図の回転角(度、0 が北)。 + * + * 値を増やすと地図は**時計回り(右)**に回る。90 なら地図が右へ 90 度回り、 + * 画面の上には西が来る。多くのネイティブ SDK の「カメラの向き(heading)」とは + * 符号が逆なので、変換は [CameraBearing] を通すこと。 + */ val bearing: Double val tilt: Double val paddings: MapPaddingsInterface? diff --git a/src/main/java/com/mapconductor/core/projection/WebMercatorScreenProjection.kt b/src/main/java/com/mapconductor/core/projection/WebMercatorScreenProjection.kt index f7066c1..761a36b 100644 --- a/src/main/java/com/mapconductor/core/projection/WebMercatorScreenProjection.kt +++ b/src/main/java/com/mapconductor/core/projection/WebMercatorScreenProjection.kt @@ -3,6 +3,7 @@ package com.mapconductor.core.projection import androidx.compose.ui.geometry.Offset 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 kotlin.math.cos import kotlin.math.pow @@ -66,8 +67,9 @@ object WebMercatorScreenProjection { var sx = dx * worldSize var sy = (target.y - center.y) * worldSize if (camera.bearing != 0.0) { - // bearing は「画面の上が指す方位(北から時計回り)」。世界を -bearing 回す。 - val angle = -camera.bearing * Math.PI / 180.0 + // 画面の上が指す方位はカメラの heading(= bearing の符号反転)。世界を + // -heading 回すと、その方位が画面の上(-y)に来る。 + val angle = -CameraBearing.toNativeHeading(camera.bearing) * Math.PI / 180.0 val rx = sx * cos(angle) - sy * sin(angle) val ry = sx * sin(angle) + sy * cos(angle) sx = rx @@ -90,7 +92,7 @@ object WebMercatorScreenProjection { var sx = offset.x - widthPx / 2.0 var sy = offset.y - heightPx / 2.0 if (camera.bearing != 0.0) { - val angle = camera.bearing * Math.PI / 180.0 + val angle = CameraBearing.toNativeHeading(camera.bearing) * Math.PI / 180.0 val rx = sx * cos(angle) - sy * sin(angle) val ry = sx * sin(angle) + sy * cos(angle) sx = rx diff --git a/src/test/java/com/mapconductor/core/projection/WebMercatorScreenProjectionGoldenTest.kt b/src/test/java/com/mapconductor/core/projection/WebMercatorScreenProjectionGoldenTest.kt index 61bfaac..c9f040e 100644 --- a/src/test/java/com/mapconductor/core/projection/WebMercatorScreenProjectionGoldenTest.kt +++ b/src/test/java/com/mapconductor/core/projection/WebMercatorScreenProjectionGoldenTest.kt @@ -94,12 +94,19 @@ class WebMercatorScreenProjectionGoldenTest { assertTrue("ratio=$ratio", abs(ratio - 2f) < 1e-4) } + /** + * bearing は「地図を時計回りに回す量」。90 なら地図が右へ 90 度回り、 + * 画面の上には**西**が来る(東は画面の下)。 + */ @Test - fun `bearing=90 では東が上に来る`() { + fun `bearing=90 では西が上に来る`() { val c = camera(bearing = 90.0) + val west = project(GeoPoint(c.position.latitude, c.position.longitude - 0.05), c)!! + assertTrue("west.y=${west.y}", west.y < 400f) + assertClose(200.0, west.x) + val east = project(GeoPoint(c.position.latitude, c.position.longitude + 0.05), c)!! - assertTrue("east.y=${east.y}", east.y < 400f) - assertClose(200.0, east.x) + assertTrue("east.y=${east.y}", east.y > 400f) } @Test