Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ..
Expand All @@ -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 ..
Expand Down
5 changes: 4 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
[versions]
composeLibs = "1.7.8"
# material3 は compose-ui とは別のバージョン系列。composeLibs(ui 系)を流用すると
# 存在しない material3:1.7.8 を要求してクラスパスごと解決に失敗する。
material3 = "1.4.0"
coroutines = "1.9.0"
agp = "9.2.1"
kotlin = "2.0.21"
Expand Down Expand Up @@ -33,7 +36,7 @@ tomtom-map-display = { group = "com.tomtom.sdk.maps", name = "map-display-standa

# Sample app
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "composeLibs" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "composeLibs" }

# Test
Expand Down
273 changes: 137 additions & 136 deletions src/main/java/com/mapconductor/tomtom/MapCameraPosition.kt
Original file line number Diff line number Diff line change
@@ -1,136 +1,137 @@
package com.mapconductor.tomtom

import com.mapconductor.core.features.GeoPoint
import com.mapconductor.core.map.MapCameraPosition
import com.mapconductor.core.map.MapCameraPositionInterface
import com.mapconductor.core.map.MapPaddings
import com.mapconductor.core.map.MapPaddingsInterface
import com.mapconductor.core.spherical.Spherical
import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter
import com.mapconductor.tomtom.zoom.ZoomAltitudeConverter
import com.tomtom.sdk.map.display.camera.CameraOptions
import com.tomtom.sdk.map.display.camera.CameraPosition as TomTomCameraPosition
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.tan

private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE)

// tilt < 0(上向きピッチ)の擬似表現に用いる定数。MapLibre 実装(android-for-maplibre の
// MapCameraPosition.kt)と同一値を用いることで、プロバイダ間で挙動を揃える。
private const val NEGATIVE_TILT_TARGET_DISTANCE_SCALE = 1.83
private const val NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT = -0.9

/**
* 統一 bearing を TomTom の [CameraOptions.rotation] が要求する [0.0, 360.0] へ正規化する。
*
* 他プロバイダ(MapTiler/MapLibre GL JS 等)は方位角を (-180, 180] で報告するため、地図を回転させた
* 状態で TomTom へ切り替えると負の rotation が渡り、`IllegalArgumentException: rotation must be in
* range [0.0,360.0]` でクラッシュしていた。ここで [0, 360) に丸めて防止する(NaN は 0 とみなす)。
*/
private fun Double.toTomTomRotation(): Double = if (isNaN()) 0.0 else ((this % 360.0) + 360.0) % 360.0

/**
* MapConductor の MapCameraPosition → TomTom の [CameraOptions]。
*
* rotation は方位角(bearing)に対応。tilt は 0.0〜60.0 にクランプする。
* 統一ズームは Google Maps 換算のため、緯度を使って TomTom ネイティブズームへ変換する。
*/
fun MapCameraPosition.toCameraOptions(): CameraOptions {
if (tilt >= 0) {
return CameraOptions(
position = GeoPoint.from(position).toTomTomGeoPoint(),
zoom = ZoomAltitudeConverter.googleZoomToTomTomZoom(zoom, position.latitude),
tilt = tilt.coerceIn(0.0, 60.0),
rotation = bearing.toTomTomRotation(),
)
}

// tilt < 0: TomTom(Mapbox GL 由来のベクターエンジン)は上向きピッチを直接表現できない。
// MapLibre 実装と同じ方式で、地上ターゲットを進行方向(bearing)へ前進させ、abs(tilt) の
// 下向きピッチで描画することで、擬似的に上向き(負tilt)視点を再現する。
val tiltAbsDeg = abs(tilt).coerceIn(0.0, 60.0)
val tiltAbsRad = Math.toRadians(tiltAbsDeg)
val tomtomZoomForAltitude = ZoomAltitudeConverter.googleZoomToTomTomZoom(zoom, position.latitude)
val altitude = converter.zoomLevelToAltitude(tomtomZoomForAltitude, position.latitude, 0.0)
val distanceForward =
altitude *
cos(tiltAbsRad) *
tan(tiltAbsRad) *
NEGATIVE_TILT_TARGET_DISTANCE_SCALE
val target = Spherical.computeOffset(position, distanceForward, bearing)
val adjustedZoom = zoom + NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0)

return CameraOptions(
position = target.toTomTomGeoPoint(),
zoom = ZoomAltitudeConverter.googleZoomToTomTomZoom(adjustedZoom, target.latitude),
tilt = tiltAbsDeg,
rotation = bearing.toTomTomRotation(),
)
}

fun MapCameraPosition.Companion.from(position: MapCameraPositionInterface): MapCameraPosition =
when (position) {
is MapCameraPosition -> position
else ->
MapCameraPosition(
position = position.position,
zoom = position.zoom,
bearing = position.bearing,
tilt = position.tilt,
paddings = position.paddings,
visibleRegion = position.visibleRegion,
)
}

/**
* TomTom の [CameraPosition] → MapConductor の MapCameraPosition。
*
* @param logicalTiltHint 直近に要求した論理 tilt([MapCameraPosition.tilt])。これが負値のとき、
* シフト済みカメラ状態(前進ターゲット + 正ピッチ)から元の位置・ズーム・負tilt を復元する。
* null または 0 以上のときは通常変換。MapLibre 実装と同一ロジック。
*/
fun TomTomCameraPosition.toMapCameraPosition(
paddings: MapPaddingsInterface = MapPaddings.Zeros,
logicalTiltHint: Double? = null,
): MapCameraPosition {
val pitchAbsDeg = abs(tilt).coerceIn(0.0, 60.0)

if (logicalTiltHint == null || logicalTiltHint >= 0.0 || pitchAbsDeg == 0.0) {
// `zoom` は TomTom ネイティブズーム。統一ズーム(Google 換算)へ緯度依存で補正する。
val altitude =
converter.zoomLevelToAltitude(
zoomLevel = zoom,
latitude = position.latitude,
tilt = tilt,
)
val corePosition = position.toGeoPoint().copy(altitude = altitude)
return MapCameraPosition(
position = corePosition,
zoom = ZoomAltitudeConverter.tomtomZoomToGoogleZoom(zoom, position.latitude),
bearing = rotation,
tilt = tilt,
paddings = paddings,
visibleRegion = null,
)
}

// tilt < 0 の復元:前進させたターゲットとズームオフセットを逆算し、元の位置・ズーム・負tilt を返す。
val pitchAbsRad = Math.toRadians(pitchAbsDeg)
val shiftedCenter = position.toGeoPoint()
val googleZoom = ZoomAltitudeConverter.tomtomZoomToGoogleZoom(zoom, position.latitude)
val originalGoogleZoom = googleZoom - NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (pitchAbsDeg / 60.0)
val originalTomTomZoom = ZoomAltitudeConverter.googleZoomToTomTomZoom(originalGoogleZoom, shiftedCenter.latitude)
val altitude = converter.zoomLevelToAltitude(originalTomTomZoom, shiftedCenter.latitude, 0.0)
val distanceBackward = altitude * cos(pitchAbsRad) * tan(pitchAbsRad) * NEGATIVE_TILT_TARGET_DISTANCE_SCALE
val originalPosition = Spherical.computeOffset(shiftedCenter, distanceBackward, rotation + 180.0)

return MapCameraPosition(
position = originalPosition.copy(altitude = altitude),
zoom = originalGoogleZoom,
bearing = rotation,
tilt = -pitchAbsDeg,
paddings = paddings,
visibleRegion = null,
)
}
package com.mapconductor.tomtom

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
import com.mapconductor.core.map.MapPaddingsInterface
import com.mapconductor.core.spherical.Spherical
import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter
import com.mapconductor.tomtom.zoom.ZoomAltitudeConverter
import com.tomtom.sdk.map.display.camera.CameraOptions
import com.tomtom.sdk.map.display.camera.CameraPosition as TomTomCameraPosition
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.tan

private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE)

// tilt < 0(上向きピッチ)の擬似表現に用いる定数。MapLibre 実装(android-for-maplibre の
// MapCameraPosition.kt)と同一値を用いることで、プロバイダ間で挙動を揃える。
private const val NEGATIVE_TILT_TARGET_DISTANCE_SCALE = 1.83
private const val NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT = -0.9

/**
* 統一 bearing を TomTom の [CameraOptions.rotation] が要求する [0.0, 360.0] へ正規化する。
*
* 他プロバイダ(MapTiler/MapLibre GL JS 等)は方位角を (-180, 180] で報告するため、地図を回転させた
* 状態で TomTom へ切り替えると負の rotation が渡り、`IllegalArgumentException: rotation must be in
* range [0.0,360.0]` でクラッシュしていた。ここで [0, 360) に丸めて防止する(NaN は 0 とみなす)。
*/
private fun Double.toTomTomRotation(): Double = if (isNaN()) 0.0 else ((this % 360.0) + 360.0) % 360.0

/**
* MapConductor の MapCameraPosition → TomTom の [CameraOptions]。
*
* rotation は方位角(bearing)に対応。tilt は 0.0〜60.0 にクランプする。
* 統一ズームは Google Maps 換算のため、緯度を使って TomTom ネイティブズームへ変換する。
*/
fun MapCameraPosition.toCameraOptions(): CameraOptions {
if (tilt >= 0) {
return CameraOptions(
position = GeoPoint.from(position).toTomTomGeoPoint(),
zoom = ZoomAltitudeConverter.googleZoomToTomTomZoom(zoom, position.latitude),
tilt = tilt.coerceIn(0.0, 60.0),
rotation = CameraBearing.toNativeHeading(bearing).toTomTomRotation(),
)
}

// tilt < 0: TomTom(Mapbox GL 由来のベクターエンジン)は上向きピッチを直接表現できない。
// MapLibre 実装と同じ方式で、地上ターゲットを進行方向(bearing)へ前進させ、abs(tilt) の
// 下向きピッチで描画することで、擬似的に上向き(負tilt)視点を再現する。
val tiltAbsDeg = abs(tilt).coerceIn(0.0, 60.0)
val tiltAbsRad = Math.toRadians(tiltAbsDeg)
val tomtomZoomForAltitude = ZoomAltitudeConverter.googleZoomToTomTomZoom(zoom, position.latitude)
val altitude = converter.zoomLevelToAltitude(tomtomZoomForAltitude, position.latitude, 0.0)
val distanceForward =
altitude *
cos(tiltAbsRad) *
tan(tiltAbsRad) *
NEGATIVE_TILT_TARGET_DISTANCE_SCALE
val target = Spherical.computeOffset(position, distanceForward, CameraBearing.toNativeHeading(bearing))
val adjustedZoom = zoom + NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0)

return CameraOptions(
position = target.toTomTomGeoPoint(),
zoom = ZoomAltitudeConverter.googleZoomToTomTomZoom(adjustedZoom, target.latitude),
tilt = tiltAbsDeg,
rotation = CameraBearing.toNativeHeading(bearing).toTomTomRotation(),
)
}

fun MapCameraPosition.Companion.from(position: MapCameraPositionInterface): MapCameraPosition =
when (position) {
is MapCameraPosition -> position
else ->
MapCameraPosition(
position = position.position,
zoom = position.zoom,
bearing = position.bearing,
tilt = position.tilt,
paddings = position.paddings,
visibleRegion = position.visibleRegion,
)
}

/**
* TomTom の [CameraPosition] → MapConductor の MapCameraPosition。
*
* @param logicalTiltHint 直近に要求した論理 tilt([MapCameraPosition.tilt])。これが負値のとき、
* シフト済みカメラ状態(前進ターゲット + 正ピッチ)から元の位置・ズーム・負tilt を復元する。
* null または 0 以上のときは通常変換。MapLibre 実装と同一ロジック。
*/
fun TomTomCameraPosition.toMapCameraPosition(
paddings: MapPaddingsInterface = MapPaddings.Zeros,
logicalTiltHint: Double? = null,
): MapCameraPosition {
val pitchAbsDeg = abs(tilt).coerceIn(0.0, 60.0)

if (logicalTiltHint == null || logicalTiltHint >= 0.0 || pitchAbsDeg == 0.0) {
// `zoom` は TomTom ネイティブズーム。統一ズーム(Google 換算)へ緯度依存で補正する。
val altitude =
converter.zoomLevelToAltitude(
zoomLevel = zoom,
latitude = position.latitude,
tilt = tilt,
)
val corePosition = position.toGeoPoint().copy(altitude = altitude)
return MapCameraPosition(
position = corePosition,
zoom = ZoomAltitudeConverter.tomtomZoomToGoogleZoom(zoom, position.latitude),
bearing = CameraBearing.bearingFromNativeHeading(rotation),
tilt = tilt,
paddings = paddings,
visibleRegion = null,
)
}

// tilt < 0 の復元:前進させたターゲットとズームオフセットを逆算し、元の位置・ズーム・負tilt を返す。
val pitchAbsRad = Math.toRadians(pitchAbsDeg)
val shiftedCenter = position.toGeoPoint()
val googleZoom = ZoomAltitudeConverter.tomtomZoomToGoogleZoom(zoom, position.latitude)
val originalGoogleZoom = googleZoom - NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (pitchAbsDeg / 60.0)
val originalTomTomZoom = ZoomAltitudeConverter.googleZoomToTomTomZoom(originalGoogleZoom, shiftedCenter.latitude)
val altitude = converter.zoomLevelToAltitude(originalTomTomZoom, shiftedCenter.latitude, 0.0)
val distanceBackward = altitude * cos(pitchAbsRad) * tan(pitchAbsRad) * NEGATIVE_TILT_TARGET_DISTANCE_SCALE
val originalPosition = Spherical.computeOffset(shiftedCenter, distanceBackward, rotation + 180.0)

return MapCameraPosition(
position = originalPosition.copy(altitude = altitude),
zoom = originalGoogleZoom,
bearing = CameraBearing.bearingFromNativeHeading(rotation),
tilt = -pitchAbsDeg,
paddings = paddings,
visibleRegion = null,
)
}
Loading