diff --git a/Sources/MapConductorCore/geocell/WebMercatorScreenProjection.swift b/Sources/MapConductorCore/geocell/WebMercatorScreenProjection.swift index de07b24..a18f85d 100644 --- a/Sources/MapConductorCore/geocell/WebMercatorScreenProjection.swift +++ b/Sources/MapConductorCore/geocell/WebMercatorScreenProjection.swift @@ -45,8 +45,9 @@ public enum WebMercatorScreenProjection { var sx = dx * worldSize var sy = (target.y - center.y) * worldSize if camera.bearing != 0 { - // bearing は「画面の上が指す方位(北から時計回り)」。世界を -bearing 回す。 - let angle = -camera.bearing * .pi / 180.0 + // 画面の上が指す方位はカメラの heading(= bearing の符号反転)。世界を + // -heading 回すと、その方位が画面の上(-y)に来る。 + let angle = -CameraBearing.toNativeHeading(camera.bearing) * .pi / 180.0 let rx = sx * cos(angle) - sy * sin(angle) let ry = sx * sin(angle) + sy * cos(angle) sx = rx @@ -66,7 +67,7 @@ public enum WebMercatorScreenProjection { var sx = offset.x - size.width / 2.0 var sy = offset.y - size.height / 2.0 if camera.bearing != 0 { - let angle = camera.bearing * .pi / 180.0 + let angle = CameraBearing.toNativeHeading(camera.bearing) * .pi / 180.0 let rx = sx * cos(angle) - sy * sin(angle) let ry = sx * sin(angle) + sy * cos(angle) sx = rx diff --git a/Sources/MapConductorCore/map/CameraBearing.swift b/Sources/MapConductorCore/map/CameraBearing.swift new file mode 100644 index 0000000..6f81318 --- /dev/null +++ b/Sources/MapConductorCore/map/CameraBearing.swift @@ -0,0 +1,52 @@ +import Foundation + +/// `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(_:)``。 +/// +/// 符号をプロバイダ各所に散らすと必ずどこかが漏れるので、変換はここだけに置く。 +/// +/// ドライバー実装点なので `@_spi(MapConductorDriver)`(android の +/// `@InternalMapConductorApi`、React の `/** @internal */` に対応)。 +@_spi(MapConductorDriver) +public enum CameraBearing { + + /// 角度を 0 以上 360 未満へ畳む。 + public static func normalizeDegrees360(_ degrees: Double) -> Double { + guard degrees.isFinite else { return 0 } + let wrapped = degrees.truncatingRemainder(dividingBy: 360.0) + return wrapped < 0 ? wrapped + 360.0 : wrapped + } + + /// MapConductor の bearing → heading 系ネイティブ SDK の値。 + /// + /// カメラが向いている方位でもあるので、負 tilt エミュレーションで + /// 「カメラの前進方向」が要るときもこれを使う。 + public static func toNativeHeading(_ bearing: Double) -> Double { + normalizeDegrees360(-bearing) + } + + /// heading 系ネイティブ SDK の値 → MapConductor の bearing。 + public static func bearingFromNativeHeading(_ heading: Double) -> Double { + normalizeDegrees360(-heading) + } + + /// MapConductor の bearing → rotation 系ネイティブ SDK の値(向きが同じなので恒等)。 + public static func toNativeRotation(_ bearing: Double) -> Double { + normalizeDegrees360(bearing) + } + + /// rotation 系ネイティブ SDK の値 → MapConductor の bearing(同上)。 + public static func bearingFromNativeRotation(_ rotation: Double) -> Double { + normalizeDegrees360(rotation) + } +} diff --git a/Sources/MapConductorCore/map/MapCameraPositionBase.swift b/Sources/MapConductorCore/map/MapCameraPositionBase.swift index d3f3efc..f3808ce 100644 --- a/Sources/MapConductorCore/map/MapCameraPositionBase.swift +++ b/Sources/MapConductorCore/map/MapCameraPositionBase.swift @@ -41,6 +41,11 @@ public struct VisibleRegion: Equatable, Hashable { public protocol MapCameraPositionProtocol { var position: GeoPointProtocol { get } var zoom: Double { get } + /// 地図の回転角(度、0 が北)。 + /// + /// 値を増やすと地図は**時計回り(右)**に回る。90 なら地図が右へ 90 度回り、 + /// 画面の上には西が来る。多くのネイティブ SDK の「カメラの向き(heading)」とは + /// 符号が逆なので、変換は ``CameraBearing`` を通すこと。 var bearing: Double { get } var tilt: Double { get } var paddings: MapPaddingsProtocol? { get } diff --git a/Tests/MapConductorCoreTests/WebMercatorScreenProjectionGoldenTests.swift b/Tests/MapConductorCoreTests/WebMercatorScreenProjectionGoldenTests.swift index 17e419c..a44d0b7 100644 --- a/Tests/MapConductorCoreTests/WebMercatorScreenProjectionGoldenTests.swift +++ b/Tests/MapConductorCoreTests/WebMercatorScreenProjectionGoldenTests.swift @@ -79,13 +79,20 @@ final class WebMercatorScreenProjectionGoldenTests: XCTestCase { XCTAssertEqual(ratio, 2, accuracy: 1e-9) } - func testBearing90PutsEastAtTheTop() throws { + /// bearing は「地図を時計回りに回す量」。90 なら地図が右へ 90 度回り、 + /// 画面の上には**西**が来る(東は画面の下)。 + func testBearing90PutsWestAtTheTop() throws { let c = camera(bearing: 90) + let west = try XCTUnwrap( + project(GeoPoint(latitude: c.position.latitude, longitude: c.position.longitude - 0.05), c) + ) + XCTAssertLessThan(west.y, 400) + XCTAssertEqual(west.x, 200, accuracy: 1e-6) + let east = try XCTUnwrap( project(GeoPoint(latitude: c.position.latitude, longitude: c.position.longitude + 0.05), c) ) - XCTAssertLessThan(east.y, 400) - XCTAssertEqual(east.x, 200, accuracy: 1e-6) + XCTAssertGreaterThan(east.y, 400) } func testDatelineWrapsTheShortWay() throws {