From 64906644cf36495b729fe25f18ced10061a91c00 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 10 Oct 2026 14:18:53 -0700 Subject: [PATCH 1/2] feat: integrate vector style support and pending map fixes --- api/android-for-maptiler.api | 4 +- gradle/libs.versions.toml | 2 +- .../mapconductor/maptiler/MapTilerDesign.kt | 132 +++--- .../mapconductor/maptiler/MapTilerMapView.kt | 81 +++- .../MapTilerMapViewControllerCamera.kt | 420 +++++++++--------- .../maptiler/MapTilerStyleMutationTarget.kt | 166 +++++++ 6 files changed, 527 insertions(+), 278 deletions(-) create mode 100644 src/main/java/com/mapconductor/maptiler/MapTilerStyleMutationTarget.kt diff --git a/api/android-for-maptiler.api b/api/android-for-maptiler.api index 3ba82ef..2e328d8 100644 --- a/api/android-for-maptiler.api +++ b/api/android-for-maptiler.api @@ -147,8 +147,8 @@ public final class com.mapconductor.maptiler.MapTilerMapViewHolder implements co public java.lang.Object getMap(); } public final class com.mapconductor.maptiler.MapTilerMapViewKt { - public static final void MapTilerMapView(com.mapconductor.maptiler.MapTilerViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); - public static final void MapTilerMapSurface(com.mapconductor.maptiler.MapTilerViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void MapTilerMapView(com.mapconductor.maptiler.MapTilerViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void MapTilerMapSurface(com.mapconductor.maptiler.MapTilerViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.maptiler.MapTilerMapViewSaver extends com.mapconductor.compose.map.BaseMapViewSaver { public static final java.lang.String KEY_DESIGN_ID; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc06c27..ba3f17c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ kotlin = "2.0.21" composeBom = "2025.02.00" lifecycleRuntimeKtx = "2.8.7" -maptilerSdk = "2.1.0" +maptilerSdk = "2.2.1" activityCompose = "1.9.3" ktLint = "14.2.0" junit = "4.13.2" diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt b/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt index 9abe5fa..00af684 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt @@ -1,66 +1,66 @@ -package com.mapconductor.maptiler - -import java.net.URL -import com.mapconductor.core.map.AttributionRule -import com.mapconductor.core.map.BlankMapStyle -import com.mapconductor.core.map.MapDesignTypeInterface -import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle -import com.maptiler.maptilersdk.map.style.MTMapStyleVariant - -/** - * MapTiler の地図デザイン(リファレンススタイル + バリアント)を表すインターフェース。 - * - * 他プロバイダの `*MapDesignTypeInterface` と同じく [MapDesignTypeInterface] を実装する。 - * [id] はデザインを一意に識別する文字列で、状態の保存・復元にも用いる。 - */ -interface MapTilerMapDesignTypeInterface : MapDesignTypeInterface { - val referenceStyle: MTMapReferenceStyle - val variant: MTMapStyleVariant? -} - -/** - * MapTiler Cloud のリファレンススタイルを指す地図デザイン。 - * - * @property id デザイン識別子。 - * @property referenceStyle MapTiler のリファレンススタイル(STREETS / SATELLITE など)。 - * @property variant スタイルのバリアント(DARK / LIGHT など)。null で既定バリアント。 - */ -data class MapTilerDesign( - override val id: String, - override val referenceStyle: MTMapReferenceStyle, - override val variant: MTMapStyleVariant? = null, - override val attributionRules: List = emptyList(), -) : MapTilerMapDesignTypeInterface { - override fun getValue(): String = id - - companion object { - /** ベースマップ無し。背景色だけのスタイル(core の同梱アセット)を CUSTOM で読む。 */ - val None = MapTilerDesign("None", MTMapReferenceStyle.CUSTOM(URL(BlankMapStyle.ASSET_FILE_URL))) - - val Streets = MapTilerDesign("Streets", MTMapReferenceStyle.STREETS) - val StreetsDark = MapTilerDesign("StreetsDark", MTMapReferenceStyle.STREETS, MTMapStyleVariant.DARK) - val StreetsLight = MapTilerDesign("StreetsLight", MTMapReferenceStyle.STREETS, MTMapStyleVariant.LIGHT) - val Basic = MapTilerDesign("Basic", MTMapReferenceStyle.BASE) - val Bright = MapTilerDesign("Bright", MTMapReferenceStyle.BRIGHT) - val Satellite = MapTilerDesign("Satellite", MTMapReferenceStyle.SATELLITE) - val Outdoor = MapTilerDesign("Outdoor", MTMapReferenceStyle.OUTDOOR) - val Winter = MapTilerDesign("Winter", MTMapReferenceStyle.WINTER) - val Topo = MapTilerDesign("Topo", MTMapReferenceStyle.TOPO) - val Toner = MapTilerDesign("Toner", MTMapReferenceStyle.TONER) - val Dataviz = MapTilerDesign("Dataviz", MTMapReferenceStyle.DATAVIZ) - val Backdrop = MapTilerDesign("Backdrop", MTMapReferenceStyle.BACKDROP) - val Ocean = MapTilerDesign("Ocean", MTMapReferenceStyle.OCEAN) - val Landscape = MapTilerDesign("Landscape", MTMapReferenceStyle.LANDSCAPE) - val Aquarelle = MapTilerDesign("Aquarelle", MTMapReferenceStyle.AQUARELLE) - val OpenStreetMap = MapTilerDesign("OpenStreetMap", MTMapReferenceStyle.OPENSTREETMAP) - - private val all: List = - listOf( - None, Streets, StreetsDark, StreetsLight, Basic, Bright, Satellite, Outdoor, Winter, - Topo, Toner, Dataviz, Backdrop, Ocean, Landscape, Aquarelle, OpenStreetMap, - ) - - /** 保存済みの [id] からデザインを復元する。未知の場合は [Streets] を返す。 */ - fun fromId(id: String?): MapTilerDesign = all.firstOrNull { it.id == id } ?: Streets - } -} +package com.mapconductor.maptiler + +import com.mapconductor.core.map.AttributionRule +import com.mapconductor.core.map.BlankMapStyle +import com.mapconductor.core.map.MapDesignTypeInterface +import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle +import com.maptiler.maptilersdk.map.style.MTMapStyleVariant +import java.net.URL + +/** + * MapTiler の地図デザイン(リファレンススタイル + バリアント)を表すインターフェース。 + * + * 他プロバイダの `*MapDesignTypeInterface` と同じく [MapDesignTypeInterface] を実装する。 + * [id] はデザインを一意に識別する文字列で、状態の保存・復元にも用いる。 + */ +interface MapTilerMapDesignTypeInterface : MapDesignTypeInterface { + val referenceStyle: MTMapReferenceStyle + val variant: MTMapStyleVariant? +} + +/** + * MapTiler Cloud のリファレンススタイルを指す地図デザイン。 + * + * @property id デザイン識別子。 + * @property referenceStyle MapTiler のリファレンススタイル(STREETS / SATELLITE など)。 + * @property variant スタイルのバリアント(DARK / LIGHT など)。null で既定バリアント。 + */ +data class MapTilerDesign( + override val id: String, + override val referenceStyle: MTMapReferenceStyle, + override val variant: MTMapStyleVariant? = null, + override val attributionRules: List = emptyList(), +) : MapTilerMapDesignTypeInterface { + override fun getValue(): String = id + + companion object { + /** ベースマップ無し。背景色だけのスタイル(core の同梱アセット)を CUSTOM で読む。 */ + val None = MapTilerDesign("None", MTMapReferenceStyle.CUSTOM(URL(BlankMapStyle.ASSET_FILE_URL))) + + val Streets = MapTilerDesign("Streets", MTMapReferenceStyle.STREETS) + val StreetsDark = MapTilerDesign("StreetsDark", MTMapReferenceStyle.STREETS, MTMapStyleVariant.DARK) + val StreetsLight = MapTilerDesign("StreetsLight", MTMapReferenceStyle.STREETS, MTMapStyleVariant.LIGHT) + val Basic = MapTilerDesign("Basic", MTMapReferenceStyle.BASE) + val Bright = MapTilerDesign("Bright", MTMapReferenceStyle.BRIGHT) + val Satellite = MapTilerDesign("Satellite", MTMapReferenceStyle.SATELLITE) + val Outdoor = MapTilerDesign("Outdoor", MTMapReferenceStyle.OUTDOOR) + val Winter = MapTilerDesign("Winter", MTMapReferenceStyle.WINTER) + val Topo = MapTilerDesign("Topo", MTMapReferenceStyle.TOPO) + val Toner = MapTilerDesign("Toner", MTMapReferenceStyle.TONER) + val Dataviz = MapTilerDesign("Dataviz", MTMapReferenceStyle.DATAVIZ) + val Backdrop = MapTilerDesign("Backdrop", MTMapReferenceStyle.BACKDROP) + val Ocean = MapTilerDesign("Ocean", MTMapReferenceStyle.OCEAN) + val Landscape = MapTilerDesign("Landscape", MTMapReferenceStyle.LANDSCAPE) + val Aquarelle = MapTilerDesign("Aquarelle", MTMapReferenceStyle.AQUARELLE) + val OpenStreetMap = MapTilerDesign("OpenStreetMap", MTMapReferenceStyle.OPENSTREETMAP) + + private val all: List = + listOf( + None, Streets, StreetsDark, StreetsLight, Basic, Bright, Satellite, Outdoor, Winter, + Topo, Toner, Dataviz, Backdrop, Ocean, Landscape, Aquarelle, OpenStreetMap, + ) + + /** 保存済みの [id] からデザインを復元する。未知の場合は [Streets] を返す。 */ + fun fromId(id: String?): MapTilerDesign = all.firstOrNull { it.id == id } ?: Streets + } +} diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerMapView.kt b/src/main/java/com/mapconductor/maptiler/MapTilerMapView.kt index b066bc6..5c634ab 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerMapView.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerMapView.kt @@ -37,8 +37,13 @@ import com.mapconductor.core.map.LocalMapOverlayRegistry import com.mapconductor.core.map.LocalMapServiceRegistry import com.mapconductor.core.map.LocalMapViewController import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapViewStyle +import com.mapconductor.core.map.MapViewStyleEffect +import com.mapconductor.core.map.StyleMutationStore import com.mapconductor.core.map.VectorStyleAsDesign +import com.mapconductor.core.map.VectorStyleMutationSupportKey import com.mapconductor.core.map.VectorStyleSupportKey +import com.mapconductor.core.map.applyStyleMutations import com.mapconductor.core.marker.MarkerRenderingSupportKey import com.mapconductor.core.marker.MarkerTilingOptions import com.maptiler.maptilersdk.events.MTEvent @@ -78,6 +83,15 @@ fun MapTilerMapView( onCameraMoveStart: OnCameraMoveHandler? = null, onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable MapViewScope.() -> Unit)? = null, ) { MapTilerMapSurface( @@ -91,6 +105,8 @@ fun MapTilerMapView( onCameraMoveStart = onCameraMoveStart, onCameraMove = onCameraMove, onCameraMoveEnd = onCameraMoveEnd, + style = style, + onStyleDiagnostics = onStyleDiagnostics, content = content, ) } @@ -121,6 +137,15 @@ fun MapTilerMapSurface( onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, onControllerReady: ((MapTilerMapViewController) -> Unit)? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable MapViewScope.() -> Unit)? = null, ) { val context = LocalContext.current @@ -136,10 +161,36 @@ fun MapTilerMapSurface( // `key(design.id)` の**外**に置く: 中に置くと、デザイン切替のたびに // 「新しい合成の put → 古い合成の onDispose で remove」の順で走り、 // 登録が消える。スタイル切替はまさにこの capability が起こす。 + // 同じ理由で、スタイルを「その場で」書き換える capability も `key(design.id)` の外。 + // こちらが登録されていれば `VectorStyle` はデザイン差し替え(= WebView 作り直し)を + // せずルールだけ送る。コントローラは `key` の中で作り直されるので、スタイルは箱越しに + // 読む — 古い `MTStyle` を掴んだままだと、地図が捨てたスタイルに書き込むことになる。 + val liveController = remember(state) { mutableStateOf(null) } + // コントローラも同じ理由で箱越し。スタイルの設置を `key` の中で呼ぶと、 + // デザイン切替ごとに dispose → install が走り、設置がデザインを変えるので止まらない。 + val liveMapController = remember(state) { mutableStateOf(null) } + val mutations = + remember(state) { + StyleMutationStore { list -> + applyStyleMutations( + MapTilerStyleMutationTarget { liveController.value?.style }, + list, + ) + } + } + DisposableEffect(state) { state.serviceRegistry.put( VectorStyleSupportKey, - VectorStyleAsDesign(state) { url, rules -> + VectorStyleAsDesign( + state, + // CUSTOM(URL) 以外は MapTiler クラウドの名前付きスタイルで、 + // キーが要るので取りに行けない。null を返して「調整するものが + // 無い」と言わせる。 + urlOf = { design -> + (design.referenceStyle as? MTMapReferenceStyle.CUSTOM)?.url?.toString() + }, + ) { url, rules -> MapTilerDesign( id = "vector-style:$url", referenceStyle = MTMapReferenceStyle.CUSTOM(URL(url)), @@ -147,9 +198,22 @@ fun MapTilerMapSurface( ) }, ) - onDispose { state.serviceRegistry.remove(VectorStyleSupportKey) } + state.serviceRegistry.put(VectorStyleMutationSupportKey, mutations) + onDispose { + state.serviceRegistry.remove(VectorStyleSupportKey) + state.serviceRegistry.remove(VectorStyleMutationSupportKey) + } } + // スタイルの設置も `key(design.id)` の外。何が起きるかはこのバックエンドが + // 宣言した能力で決まる(MapTiler は文書もその場書き換えも受けられる)。 + MapViewStyleEffect( + state = state, + style = style, + controller = liveMapController.value, + onDiagnostics = onStyleDiagnostics, + ) + // デザイン(スタイル)切替時はコントローラと WebView を作り直し、確実に新スタイルを反映する。 key(design.id) { val mtController = remember { MTMapViewController(context) } @@ -165,6 +229,7 @@ fun MapTilerMapSurface( state.serviceRegistry.put(MarkerRenderingSupportKey, it.markerRenderingSupport) // RN のホストはここでコントローラを受け取り、共通基底へ渡す。 // `remember` の中なのでスタイル切替でコントローラを作り直したときだけ鳴る。 + liveMapController.value = it onControllerReady?.invoke(it) } } @@ -225,6 +290,18 @@ fun MapTilerMapSurface( object : MTMapViewDelegate { override fun onMapViewInitialized() { mapReady = true + liveController.value = mtController + // 適用済みのスタイル調整はスタイルを読み込み直した時点で消える。 + // ここが戻し場所。 + // + // **このプロバイダにはスタイル読み込みの通知がこれしかない。** + // ブリッジが中継するイベントのうち `ready` は MapLibre の + // `load` の中で 1 度だけ鳴り、`styledata` はプロパティを 1 つ + // 変えるたびに鳴る(= 再適用が自分を呼び続ける)。Compose は + // デザイン切替でコントローラごと作り直すのでここで足りるが、 + // 非 Compose ホストが `setMapDesignType` を直接呼んだ場合は + // 調整が落ちたままになる。 + mutations.onStyleLoaded() controller.reapplyRasterLayers() controller.reapplyPolylines() controller.reapplyPolygons() diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt index e736409..02162f9 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt @@ -1,207 +1,213 @@ -package com.mapconductor.maptiler - -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.MapGesture -import com.mapconductor.core.map.MapUISettings -import com.mapconductor.core.map.MapUISettingsDiagnostics -import com.mapconductor.core.map.VisibleRegion -import com.mapconductor.core.spherical.Spherical -import com.mapconductor.maptiler.zoom.ZoomAltitudeConverter -import com.maptiler.maptilersdk.map.gestures.MTGestureType -import com.maptiler.maptilersdk.map.options.MTCameraOptions -import com.maptiler.maptilersdk.map.options.MTFitBoundsOptions -import com.maptiler.maptilersdk.map.options.MTFlyToOptions -import com.maptiler.maptilersdk.map.options.MTPaddingOptions -import kotlin.math.abs -import kotlin.math.cos -import kotlin.math.tan - -// カメラの読み書きとジェスチャ設定。 -// MapTiler のズームは MapConductor の論理ズームと 1 段ずれるため -// ZoomAltitudeConverter を通す。範囲制限はネイティブ API が無いので -// カメラ停止時にクランプして再適用する。 - -/** - * MapTiler から現在のカメラ状態を取得し、可視領域([VisibleRegion])を付与した [MapCameraPosition] を返す。 - * クラスタリングは `visibleRegion.bounds` を必要とするため、[com.maptiler.maptilersdk.map.MTMapViewController.getBounds] を用いる。 - */ -internal suspend fun MapTilerMapViewController.currentCameraWithRegion(): MapCameraPosition { - val center = mtController.getCenter() - val zoom = mtController.getZoom() - val bearing = mtController.getBearing() - val pitch = mtController.getPitch() - val region = - runCatching { mtController.getBounds() }.getOrNull()?.let { bounds -> - VisibleRegion( - bounds = - GeoRectBounds( - southWest = GeoPoint(bounds.southwest.lat, bounds.southwest.lng), - northEast = GeoPoint(bounds.northeast.lat, bounds.northeast.lng), - ), - nearLeft = null, - nearRight = null, - farLeft = null, - farRight = null, - ) - } - val raw = - MapCameraPosition( - position = center.toGeoPoint(), - zoom = MapTilerMapViewController.mapTilerZoomToCore(zoom), - bearing = CameraBearing.bearingFromNativeHeading(bearing), - tilt = pitch, - visibleRegion = region, - ) - return recoverLogicalCameraPosition(raw) -} - -/** - * 生のカメラ状態(MapTiler の正ピッチ・統一ズーム換算済み)から、直近に要求した論理 tilt が - * 負のときに元の位置・ズーム・負tilt を復元した [MapCameraPosition] を返す。 - * tilt < 0 の擬似表現([toCameraOptions])の逆変換で、MapLibre 実装と同一ロジック。 - */ -internal fun MapTilerMapViewController.recoverLogicalCameraPosition(raw: MapCameraPosition): MapCameraPosition { - val logicalTiltHint = lastLogicalCameraPosition?.tilt - val pitchAbsDeg = abs(raw.tilt).coerceIn(0.0, 60.0) - if (logicalTiltHint == null || logicalTiltHint >= 0.0 || pitchAbsDeg == 0.0) return raw - - val pitchAbsRad = Math.toRadians(pitchAbsDeg) - val shiftedCenter = raw.position - val originalGoogleZoom = - raw.zoom - MapTilerMapViewController.NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (pitchAbsDeg / 60.0) - val altitude = - zoomConverter.zoomLevelToAltitude( - ZoomAltitudeConverter.googleZoomToMaptilerZoom(originalGoogleZoom), - shiftedCenter.latitude, - 0.0, - ) - val distanceBackward = - altitude * cos(pitchAbsRad) * tan(pitchAbsRad) * MapTilerMapViewController.NEGATIVE_TILT_TARGET_DISTANCE_SCALE - val originalPosition = Spherical.computeOffset(shiftedCenter, distanceBackward, CameraBearing.toNativeHeading(raw.bearing) + 180.0) - - return raw.copy( - position = originalPosition, - zoom = originalGoogleZoom, - tilt = -pitchAbsDeg, - ) -} - -internal fun MapTilerMapViewController.handleMoveCamera(position: MapCameraPosition) { - lastLogicalCameraPosition = position - mtController.jumpTo(toCameraOptions(position)) -} - -internal fun MapTilerMapViewController.handleAnimateCamera( - position: MapCameraPosition, - duration: Long, -) { - lastLogicalCameraPosition = position - // easeTo は線形補間で、しかも MapTiler SDK ラッパーが duration を渡せないため MapLibre GL JS - // 既定の約 300ms 固定で動く。ズーム差が大きい移動(例: 世界 z0 → 都市 z10)では一気にズームして - // タイル読み込みが追いつかず激しくカクつく。flyTo(van Wijk のズームアーク)に切り替え、指定 - // duration を maxDuration として渡すことで、タイル追従しつつ滑らかにアニメーションさせる。 - val flyToOptions = - MTFlyToOptions(null, null, null, null, null).apply { - maxDuration = duration.toDouble() - } - mtController.flyTo(toCameraOptions(position), flyToOptions) -} - -internal fun MapTilerMapViewController.handleFitBounds( - bounds: GeoRectBounds, - padding: Int, -) { - val mtBounds = bounds.toMTBounds() ?: return - val options = - MTFitBoundsOptions( - padding = - MTPaddingOptions( - left = padding.toDouble(), - top = padding.toDouble(), - right = padding.toDouble(), - bottom = padding.toDouble(), - ), - ) - mtController.fitBounds(mtBounds, options) -} - -internal fun MapTilerMapViewController.applyGestureSettings(settings: MapUISettings) { - val gestures = mtController.gestureService ?: return - - if (settings.scrollGesture) { - gestures.enableDragPanGesture() - } else { - gestures.disableGesture(MTGestureType.DRAG_PAN) - } - - if (settings.tiltGesture) { - gestures.enableTwoFingerDragPitchGesture() - } else { - gestures.disableGesture(MTGestureType.TWO_FINGERS_DRAG_PITCH) - } - - if (settings.zoomGesture) { - gestures.enableDoubleTapZoomInGesture() - } else { - gestures.disableGesture(MTGestureType.DOUBLE_TAP_ZOOM_IN) - } - - // MapTiler bundles pinch-zoom and rotation into one PINCH_ROTATE_AND_ZOOM - // gesture, so neither can be switched off alone; only drop it when both are - // off. The discrete double-tap zoom above still follows zoomGesture. - if (settings.zoomGesture || settings.rotateGesture) { - gestures.enablePinchRotateAndZoomGesture() - } else { - gestures.disableGesture(MTGestureType.PINCH_ROTATE_AND_ZOOM) - } - - if (settings.zoomGesture != settings.rotateGesture) { - MapUISettingsDiagnostics.warnIfRequested( - false, - gesture = if (settings.zoomGesture) MapGesture.Rotate else MapGesture.Zoom, - provider = "MapTiler", - reason = "pinch zoom and rotation share one gesture, so they can only be disabled together", - ) - } -} - -internal fun MapTilerMapViewController.toCameraOptions(camera: MapCameraPosition): MTCameraOptions { - if (camera.tilt >= 0) { - return MTCameraOptions( - center = camera.position.toLngLat(), - zoom = MapTilerMapViewController.coreZoomToMapTiler(camera.zoom), - bearing = CameraBearing.toNativeHeading(camera.bearing), - pitch = camera.tilt.coerceIn(0.0, 60.0), - ) - } - - // camera.tilt < 0: MapTiler(MapLibre GL JS)は上向きピッチを直接表現できない。 - // MapLibre 実装と同じ方式で、地上ターゲットを進行方向へ前進させ abs(camera.tilt) の下向き - // ピッチで描画することで、擬似的に上向き(負tilt)視点を再現する。 - val tiltAbsDeg = abs(camera.tilt).coerceIn(0.0, 60.0) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = - zoomConverter.zoomLevelToAltitude( - ZoomAltitudeConverter.googleZoomToMaptilerZoom(camera.zoom), - camera.position.latitude, - 0.0, - ) - val distanceForward = - altitude * - cos(tiltAbsRad) * - tan(tiltAbsRad) * - MapTilerMapViewController.NEGATIVE_TILT_TARGET_DISTANCE_SCALE - val target = Spherical.computeOffset(camera.position, distanceForward, CameraBearing.toNativeHeading(camera.bearing)) - val adjustedZoom = - camera.zoom + MapTilerMapViewController.NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) - - return MTCameraOptions( - center = target.toLngLat(), - zoom = MapTilerMapViewController.coreZoomToMapTiler(adjustedZoom), - bearing = CameraBearing.toNativeHeading(camera.bearing), - pitch = tiltAbsDeg, - ) -} +package com.mapconductor.maptiler + +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.MapGesture +import com.mapconductor.core.map.MapUISettings +import com.mapconductor.core.map.MapUISettingsDiagnostics +import com.mapconductor.core.map.VisibleRegion +import com.mapconductor.core.spherical.Spherical +import com.mapconductor.maptiler.zoom.ZoomAltitudeConverter +import com.maptiler.maptilersdk.map.gestures.MTGestureType +import com.maptiler.maptilersdk.map.options.MTCameraOptions +import com.maptiler.maptilersdk.map.options.MTFitBoundsOptions +import com.maptiler.maptilersdk.map.options.MTFlyToOptions +import com.maptiler.maptilersdk.map.options.MTPaddingOptions +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.tan + +// カメラの読み書きとジェスチャ設定。 +// MapTiler のズームは MapConductor の論理ズームと 1 段ずれるため +// ZoomAltitudeConverter を通す。範囲制限はネイティブ API が無いので +// カメラ停止時にクランプして再適用する。 + +/** + * MapTiler から現在のカメラ状態を取得し、可視領域([VisibleRegion])を付与した [MapCameraPosition] を返す。 + * クラスタリングは `visibleRegion.bounds` を必要とするため、[com.maptiler.maptilersdk.map.MTMapViewController.getBounds] を用いる。 + */ +internal suspend fun MapTilerMapViewController.currentCameraWithRegion(): MapCameraPosition { + val center = mtController.getCenter() + val zoom = mtController.getZoom() + val bearing = mtController.getBearing() + val pitch = mtController.getPitch() + val region = + runCatching { mtController.getBounds() }.getOrNull()?.let { bounds -> + VisibleRegion( + bounds = + GeoRectBounds( + southWest = GeoPoint(bounds.southwest.lat, bounds.southwest.lng), + northEast = GeoPoint(bounds.northeast.lat, bounds.northeast.lng), + ), + nearLeft = null, + nearRight = null, + farLeft = null, + farRight = null, + ) + } + val raw = + MapCameraPosition( + position = center.toGeoPoint(), + zoom = MapTilerMapViewController.mapTilerZoomToCore(zoom), + bearing = CameraBearing.bearingFromNativeHeading(bearing), + tilt = pitch, + visibleRegion = region, + ) + return recoverLogicalCameraPosition(raw) +} + +/** + * 生のカメラ状態(MapTiler の正ピッチ・統一ズーム換算済み)から、直近に要求した論理 tilt が + * 負のときに元の位置・ズーム・負tilt を復元した [MapCameraPosition] を返す。 + * tilt < 0 の擬似表現([toCameraOptions])の逆変換で、MapLibre 実装と同一ロジック。 + */ +internal fun MapTilerMapViewController.recoverLogicalCameraPosition(raw: MapCameraPosition): MapCameraPosition { + val logicalTiltHint = lastLogicalCameraPosition?.tilt + val pitchAbsDeg = abs(raw.tilt).coerceIn(0.0, 60.0) + if (logicalTiltHint == null || logicalTiltHint >= 0.0 || pitchAbsDeg == 0.0) return raw + + val pitchAbsRad = Math.toRadians(pitchAbsDeg) + val shiftedCenter = raw.position + val originalGoogleZoom = + raw.zoom - MapTilerMapViewController.NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (pitchAbsDeg / 60.0) + val altitude = + zoomConverter.zoomLevelToAltitude( + ZoomAltitudeConverter.googleZoomToMaptilerZoom(originalGoogleZoom), + shiftedCenter.latitude, + 0.0, + ) + val distanceBackward = + altitude * cos(pitchAbsRad) * tan(pitchAbsRad) * MapTilerMapViewController.NEGATIVE_TILT_TARGET_DISTANCE_SCALE + val originalPosition = + Spherical.computeOffset( + shiftedCenter, distanceBackward, + CameraBearing.toNativeHeading(raw.bearing) + 180.0, + ) + + return raw.copy( + position = originalPosition, + zoom = originalGoogleZoom, + tilt = -pitchAbsDeg, + ) +} + +internal fun MapTilerMapViewController.handleMoveCamera(position: MapCameraPosition) { + lastLogicalCameraPosition = position + mtController.jumpTo(toCameraOptions(position)) +} + +internal fun MapTilerMapViewController.handleAnimateCamera( + position: MapCameraPosition, + duration: Long, +) { + lastLogicalCameraPosition = position + // easeTo は線形補間で、しかも MapTiler SDK ラッパーが duration を渡せないため MapLibre GL JS + // 既定の約 300ms 固定で動く。ズーム差が大きい移動(例: 世界 z0 → 都市 z10)では一気にズームして + // タイル読み込みが追いつかず激しくカクつく。flyTo(van Wijk のズームアーク)に切り替え、指定 + // duration を maxDuration として渡すことで、タイル追従しつつ滑らかにアニメーションさせる。 + val flyToOptions = + MTFlyToOptions(null, null, null, null, null).apply { + maxDuration = duration.toDouble() + } + mtController.flyTo(toCameraOptions(position), flyToOptions) +} + +internal fun MapTilerMapViewController.handleFitBounds( + bounds: GeoRectBounds, + padding: Int, +) { + val mtBounds = bounds.toMTBounds() ?: return + val options = + MTFitBoundsOptions( + padding = + MTPaddingOptions( + left = padding.toDouble(), + top = padding.toDouble(), + right = padding.toDouble(), + bottom = padding.toDouble(), + ), + ) + mtController.fitBounds(mtBounds, options) +} + +internal fun MapTilerMapViewController.applyGestureSettings(settings: MapUISettings) { + val gestures = mtController.gestureService ?: return + + if (settings.scrollGesture) { + gestures.enableDragPanGesture() + } else { + gestures.disableGesture(MTGestureType.DRAG_PAN) + } + + if (settings.tiltGesture) { + gestures.enableTwoFingerDragPitchGesture() + } else { + gestures.disableGesture(MTGestureType.TWO_FINGERS_DRAG_PITCH) + } + + if (settings.zoomGesture) { + gestures.enableDoubleTapZoomInGesture() + } else { + gestures.disableGesture(MTGestureType.DOUBLE_TAP_ZOOM_IN) + } + + // MapTiler bundles pinch-zoom and rotation into one PINCH_ROTATE_AND_ZOOM + // gesture, so neither can be switched off alone; only drop it when both are + // off. The discrete double-tap zoom above still follows zoomGesture. + if (settings.zoomGesture || settings.rotateGesture) { + gestures.enablePinchRotateAndZoomGesture() + } else { + gestures.disableGesture(MTGestureType.PINCH_ROTATE_AND_ZOOM) + } + + if (settings.zoomGesture != settings.rotateGesture) { + MapUISettingsDiagnostics.warnIfRequested( + false, + gesture = if (settings.zoomGesture) MapGesture.Rotate else MapGesture.Zoom, + provider = "MapTiler", + reason = "pinch zoom and rotation share one gesture, so they can only be disabled together", + ) + } +} + +internal fun MapTilerMapViewController.toCameraOptions(camera: MapCameraPosition): MTCameraOptions { + if (camera.tilt >= 0) { + return MTCameraOptions( + center = camera.position.toLngLat(), + zoom = MapTilerMapViewController.coreZoomToMapTiler(camera.zoom), + bearing = CameraBearing.toNativeHeading(camera.bearing), + pitch = camera.tilt.coerceIn(0.0, 60.0), + ) + } + + // camera.tilt < 0: MapTiler(MapLibre GL JS)は上向きピッチを直接表現できない。 + // MapLibre 実装と同じ方式で、地上ターゲットを進行方向へ前進させ abs(camera.tilt) の下向き + // ピッチで描画することで、擬似的に上向き(負tilt)視点を再現する。 + val tiltAbsDeg = abs(camera.tilt).coerceIn(0.0, 60.0) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = + zoomConverter.zoomLevelToAltitude( + ZoomAltitudeConverter.googleZoomToMaptilerZoom(camera.zoom), + camera.position.latitude, + 0.0, + ) + val distanceForward = + altitude * + cos(tiltAbsRad) * + tan(tiltAbsRad) * + MapTilerMapViewController.NEGATIVE_TILT_TARGET_DISTANCE_SCALE + val target = + Spherical + .computeOffset(camera.position, distanceForward, CameraBearing.toNativeHeading(camera.bearing)) + val adjustedZoom = + camera.zoom + MapTilerMapViewController.NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) + + return MTCameraOptions( + center = target.toLngLat(), + zoom = MapTilerMapViewController.coreZoomToMapTiler(adjustedZoom), + bearing = CameraBearing.toNativeHeading(camera.bearing), + pitch = tiltAbsDeg, + ) +} diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerStyleMutationTarget.kt b/src/main/java/com/mapconductor/maptiler/MapTilerStyleMutationTarget.kt new file mode 100644 index 0000000..06b6d51 --- /dev/null +++ b/src/main/java/com/mapconductor/maptiler/MapTilerStyleMutationTarget.kt @@ -0,0 +1,166 @@ +package com.mapconductor.maptiler + +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.map.StyleMutationTarget +import com.maptiler.maptilersdk.map.style.MTStyle +import com.maptiler.maptilersdk.map.style.dsl.PropertyValue +import com.maptiler.maptilersdk.map.style.layer.background.MTBackgroundLayer +import org.json.JSONObject + +/** + * Changes a loaded MapTiler style in place. + * + * The seven calls [StyleMutationTarget] asks for, against the MapTiler + * SDK's style API. Everything above them — what a mutation means, which can + * be skipped, keeping one bad property from costing the rest — is + * `applyStyleMutations` in core, shared with every other provider. + * + * ## Why the compiler's JSON goes straight through + * + * The MapTiler Android SDK is a bridge onto MapTiler SDK JS in a WebView, + * and its `PropertyValue` has a `RawJs` case that reaches the page + * unchanged. Every JSON value is also a JavaScript literal, so the text the + * compiler produced is handed over as written — no literal/expression + * decision, no colour parsing, no typed builders. The same shape as the + * Mapbox adapter and for a different reason. + * + * ## What it cannot do + * + * **There is no way to ask whether a layer exists.** The bridge exposes + * `sourceExists` but nothing for layers, and `MTStyle` only remembers the + * ones it was told to add. So [hasLayer] says yes to everything — the + * mutation is attempted, and a layer the document does not have fails inside + * the page where nothing can see it. Unlike every other provider, a missing + * layer here is not reported. Adding and removing are unaffected: those + * concern layers this adapter owns, and it keeps its own list. + * + * Saying yes to everything is about the document's contents only. Whether + * there is a document at all is [isReady], and it has to be answered + * separately: when the two were the same call, every adjustment made before + * the style finished loading was reported as one this map could not make. + * + * **A layer is added as a typed object, not a document.** `MTStyle.addLayer` + * takes an `MTLayer`, so [addLayer] builds the one kind the compiler ever + * adds — a background under a style that has none — and then replays its + * paint and layout through the property calls, which is how the colour gets + * across without being parsed here. + */ +@InternalMapConductorApi +internal class MapTilerStyleMutationTarget( + private val style: () -> MTStyle?, +) : StyleMutationTarget { + /** + * The ids this adapter added to the style it added them to. + * + * `MTStyle` throws `LayerAlreadyExists` on a duplicate id, and a style + * reload replaces the `MTStyle` with an empty one -- so the identity of + * the style has to be part of the record, or putting the set back on a + * reloaded map would be skipped. + */ + private var added = mutableSetOf() + private var addedTo: MTStyle? = null + + /** Always true: see the class comment. */ + override val isReady: Boolean get() = style() != null + + override fun hasLayer(layerId: String): Boolean = true + + override fun setPaint( + layerId: String, + key: String, + valueJson: String, + ) { + style()?.setPaintProperty(layerId, key, PropertyValue.RawJs(valueJson)) + } + + override fun setLayout( + layerId: String, + key: String, + valueJson: String, + ) { + style()?.setLayoutProperty(layerId, key, PropertyValue.RawJs(valueJson)) + } + + override fun setFilter( + layerId: String, + filterJson: String, + ) { + style()?.setFilter(layerId, PropertyValue.RawJs(filterJson)) + } + + override fun setZoomRange( + layerId: String, + minZoom: Float, + maxZoom: Float, + ) { + style()?.setLayerZoomRange(layerId, minZoom.toDouble(), maxZoom.toDouble()) + } + + override fun addLayer( + layerId: String, + layerJson: String, + beforeId: String?, + ) { + val target = style() ?: return + if (layerId in own(target)) return + val document = JSONObject(layerJson) + if (document.optString("type") != "background") { + throw UnsupportedOperationException("only a background layer can be added here") + } + target.addLayer(MTBackgroundLayer(layerId)) + own(target) += layerId + // Position, then paint: `addLayer` appends, and the compiler asks for + // the background to go under the layer the document starts with. + if (beforeId != null) target.moveLayer(layerId, beforeId) + replay(layerId, document.optJSONObject("paint"), ::setPaint) + replay(layerId, document.optJSONObject("layout"), ::setLayout) + } + + override fun removeLayer(layerId: String) { + val target = style() ?: return + // `removeLayerById` reaches the page without being checked against + // anything, and removing an absent layer there is a no-op. + target.removeLayerById(layerId) + own(target) -= layerId + } + + /** The ids we added to [target], forgetting what belonged to an older style. */ + private fun own(target: MTStyle): MutableSet { + if (addedTo !== target) { + addedTo = target + added = mutableSetOf() + } + return added + } + + /** + * A new layer's own properties, sent the same way an adjustment to an + * existing layer's would be. + * + * `MTBackgroundLayer` could take a colour directly, but only as an ARGB + * int -- which would mean parsing `#rrggbb` and `rgba(...)` here, a + * fourth place for the same bug. This way the compiler's text is the + * only thing that ever states a colour. + */ + private fun replay( + layerId: String, + section: JSONObject?, + set: (String, String, String) -> Unit, + ) { + val properties = section ?: return + for (key in properties.keys()) { + set(layerId, key, jsonTextOf(properties.get(key))) + } + } + + /** + * A parsed JSON node back as JSON text, which is what the property calls + * take -- so a string keeps its quotes and a null stays `null`. + */ + private fun jsonTextOf(value: Any?): String = + when { + value == null || value === JSONObject.NULL -> "null" + value is String -> JSONObject.quote(value) + else -> value.toString() + } +} From d5b721bf55b9bbdd54a609a1086f308f54156a33 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:50:15 -0700 Subject: [PATCH 2/2] Keep each file's line endings as they are on develop The checkpoint commit rewrote whole files between LF and CRLF, which buried its real changes under line-ending noise. Content is unchanged. --- .../mapconductor/maptiler/MapTilerDesign.kt | 132 +++--- .../MapTilerMapViewControllerCamera.kt | 426 +++++++++--------- 2 files changed, 279 insertions(+), 279 deletions(-) diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt b/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt index 00af684..8281be7 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt @@ -1,66 +1,66 @@ -package com.mapconductor.maptiler - -import com.mapconductor.core.map.AttributionRule -import com.mapconductor.core.map.BlankMapStyle -import com.mapconductor.core.map.MapDesignTypeInterface -import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle -import com.maptiler.maptilersdk.map.style.MTMapStyleVariant -import java.net.URL - -/** - * MapTiler の地図デザイン(リファレンススタイル + バリアント)を表すインターフェース。 - * - * 他プロバイダの `*MapDesignTypeInterface` と同じく [MapDesignTypeInterface] を実装する。 - * [id] はデザインを一意に識別する文字列で、状態の保存・復元にも用いる。 - */ -interface MapTilerMapDesignTypeInterface : MapDesignTypeInterface { - val referenceStyle: MTMapReferenceStyle - val variant: MTMapStyleVariant? -} - -/** - * MapTiler Cloud のリファレンススタイルを指す地図デザイン。 - * - * @property id デザイン識別子。 - * @property referenceStyle MapTiler のリファレンススタイル(STREETS / SATELLITE など)。 - * @property variant スタイルのバリアント(DARK / LIGHT など)。null で既定バリアント。 - */ -data class MapTilerDesign( - override val id: String, - override val referenceStyle: MTMapReferenceStyle, - override val variant: MTMapStyleVariant? = null, - override val attributionRules: List = emptyList(), -) : MapTilerMapDesignTypeInterface { - override fun getValue(): String = id - - companion object { - /** ベースマップ無し。背景色だけのスタイル(core の同梱アセット)を CUSTOM で読む。 */ - val None = MapTilerDesign("None", MTMapReferenceStyle.CUSTOM(URL(BlankMapStyle.ASSET_FILE_URL))) - - val Streets = MapTilerDesign("Streets", MTMapReferenceStyle.STREETS) - val StreetsDark = MapTilerDesign("StreetsDark", MTMapReferenceStyle.STREETS, MTMapStyleVariant.DARK) - val StreetsLight = MapTilerDesign("StreetsLight", MTMapReferenceStyle.STREETS, MTMapStyleVariant.LIGHT) - val Basic = MapTilerDesign("Basic", MTMapReferenceStyle.BASE) - val Bright = MapTilerDesign("Bright", MTMapReferenceStyle.BRIGHT) - val Satellite = MapTilerDesign("Satellite", MTMapReferenceStyle.SATELLITE) - val Outdoor = MapTilerDesign("Outdoor", MTMapReferenceStyle.OUTDOOR) - val Winter = MapTilerDesign("Winter", MTMapReferenceStyle.WINTER) - val Topo = MapTilerDesign("Topo", MTMapReferenceStyle.TOPO) - val Toner = MapTilerDesign("Toner", MTMapReferenceStyle.TONER) - val Dataviz = MapTilerDesign("Dataviz", MTMapReferenceStyle.DATAVIZ) - val Backdrop = MapTilerDesign("Backdrop", MTMapReferenceStyle.BACKDROP) - val Ocean = MapTilerDesign("Ocean", MTMapReferenceStyle.OCEAN) - val Landscape = MapTilerDesign("Landscape", MTMapReferenceStyle.LANDSCAPE) - val Aquarelle = MapTilerDesign("Aquarelle", MTMapReferenceStyle.AQUARELLE) - val OpenStreetMap = MapTilerDesign("OpenStreetMap", MTMapReferenceStyle.OPENSTREETMAP) - - private val all: List = - listOf( - None, Streets, StreetsDark, StreetsLight, Basic, Bright, Satellite, Outdoor, Winter, - Topo, Toner, Dataviz, Backdrop, Ocean, Landscape, Aquarelle, OpenStreetMap, - ) - - /** 保存済みの [id] からデザインを復元する。未知の場合は [Streets] を返す。 */ - fun fromId(id: String?): MapTilerDesign = all.firstOrNull { it.id == id } ?: Streets - } -} +package com.mapconductor.maptiler + +import com.mapconductor.core.map.AttributionRule +import com.mapconductor.core.map.BlankMapStyle +import com.mapconductor.core.map.MapDesignTypeInterface +import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle +import com.maptiler.maptilersdk.map.style.MTMapStyleVariant +import java.net.URL + +/** + * MapTiler の地図デザイン(リファレンススタイル + バリアント)を表すインターフェース。 + * + * 他プロバイダの `*MapDesignTypeInterface` と同じく [MapDesignTypeInterface] を実装する。 + * [id] はデザインを一意に識別する文字列で、状態の保存・復元にも用いる。 + */ +interface MapTilerMapDesignTypeInterface : MapDesignTypeInterface { + val referenceStyle: MTMapReferenceStyle + val variant: MTMapStyleVariant? +} + +/** + * MapTiler Cloud のリファレンススタイルを指す地図デザイン。 + * + * @property id デザイン識別子。 + * @property referenceStyle MapTiler のリファレンススタイル(STREETS / SATELLITE など)。 + * @property variant スタイルのバリアント(DARK / LIGHT など)。null で既定バリアント。 + */ +data class MapTilerDesign( + override val id: String, + override val referenceStyle: MTMapReferenceStyle, + override val variant: MTMapStyleVariant? = null, + override val attributionRules: List = emptyList(), +) : MapTilerMapDesignTypeInterface { + override fun getValue(): String = id + + companion object { + /** ベースマップ無し。背景色だけのスタイル(core の同梱アセット)を CUSTOM で読む。 */ + val None = MapTilerDesign("None", MTMapReferenceStyle.CUSTOM(URL(BlankMapStyle.ASSET_FILE_URL))) + + val Streets = MapTilerDesign("Streets", MTMapReferenceStyle.STREETS) + val StreetsDark = MapTilerDesign("StreetsDark", MTMapReferenceStyle.STREETS, MTMapStyleVariant.DARK) + val StreetsLight = MapTilerDesign("StreetsLight", MTMapReferenceStyle.STREETS, MTMapStyleVariant.LIGHT) + val Basic = MapTilerDesign("Basic", MTMapReferenceStyle.BASE) + val Bright = MapTilerDesign("Bright", MTMapReferenceStyle.BRIGHT) + val Satellite = MapTilerDesign("Satellite", MTMapReferenceStyle.SATELLITE) + val Outdoor = MapTilerDesign("Outdoor", MTMapReferenceStyle.OUTDOOR) + val Winter = MapTilerDesign("Winter", MTMapReferenceStyle.WINTER) + val Topo = MapTilerDesign("Topo", MTMapReferenceStyle.TOPO) + val Toner = MapTilerDesign("Toner", MTMapReferenceStyle.TONER) + val Dataviz = MapTilerDesign("Dataviz", MTMapReferenceStyle.DATAVIZ) + val Backdrop = MapTilerDesign("Backdrop", MTMapReferenceStyle.BACKDROP) + val Ocean = MapTilerDesign("Ocean", MTMapReferenceStyle.OCEAN) + val Landscape = MapTilerDesign("Landscape", MTMapReferenceStyle.LANDSCAPE) + val Aquarelle = MapTilerDesign("Aquarelle", MTMapReferenceStyle.AQUARELLE) + val OpenStreetMap = MapTilerDesign("OpenStreetMap", MTMapReferenceStyle.OPENSTREETMAP) + + private val all: List = + listOf( + None, Streets, StreetsDark, StreetsLight, Basic, Bright, Satellite, Outdoor, Winter, + Topo, Toner, Dataviz, Backdrop, Ocean, Landscape, Aquarelle, OpenStreetMap, + ) + + /** 保存済みの [id] からデザインを復元する。未知の場合は [Streets] を返す。 */ + fun fromId(id: String?): MapTilerDesign = all.firstOrNull { it.id == id } ?: Streets + } +} diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt index 02162f9..bd77086 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt @@ -1,213 +1,213 @@ -package com.mapconductor.maptiler - -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.MapGesture -import com.mapconductor.core.map.MapUISettings -import com.mapconductor.core.map.MapUISettingsDiagnostics -import com.mapconductor.core.map.VisibleRegion -import com.mapconductor.core.spherical.Spherical -import com.mapconductor.maptiler.zoom.ZoomAltitudeConverter -import com.maptiler.maptilersdk.map.gestures.MTGestureType -import com.maptiler.maptilersdk.map.options.MTCameraOptions -import com.maptiler.maptilersdk.map.options.MTFitBoundsOptions -import com.maptiler.maptilersdk.map.options.MTFlyToOptions -import com.maptiler.maptilersdk.map.options.MTPaddingOptions -import kotlin.math.abs -import kotlin.math.cos -import kotlin.math.tan - -// カメラの読み書きとジェスチャ設定。 -// MapTiler のズームは MapConductor の論理ズームと 1 段ずれるため -// ZoomAltitudeConverter を通す。範囲制限はネイティブ API が無いので -// カメラ停止時にクランプして再適用する。 - -/** - * MapTiler から現在のカメラ状態を取得し、可視領域([VisibleRegion])を付与した [MapCameraPosition] を返す。 - * クラスタリングは `visibleRegion.bounds` を必要とするため、[com.maptiler.maptilersdk.map.MTMapViewController.getBounds] を用いる。 - */ -internal suspend fun MapTilerMapViewController.currentCameraWithRegion(): MapCameraPosition { - val center = mtController.getCenter() - val zoom = mtController.getZoom() - val bearing = mtController.getBearing() - val pitch = mtController.getPitch() - val region = - runCatching { mtController.getBounds() }.getOrNull()?.let { bounds -> - VisibleRegion( - bounds = - GeoRectBounds( - southWest = GeoPoint(bounds.southwest.lat, bounds.southwest.lng), - northEast = GeoPoint(bounds.northeast.lat, bounds.northeast.lng), - ), - nearLeft = null, - nearRight = null, - farLeft = null, - farRight = null, - ) - } - val raw = - MapCameraPosition( - position = center.toGeoPoint(), - zoom = MapTilerMapViewController.mapTilerZoomToCore(zoom), - bearing = CameraBearing.bearingFromNativeHeading(bearing), - tilt = pitch, - visibleRegion = region, - ) - return recoverLogicalCameraPosition(raw) -} - -/** - * 生のカメラ状態(MapTiler の正ピッチ・統一ズーム換算済み)から、直近に要求した論理 tilt が - * 負のときに元の位置・ズーム・負tilt を復元した [MapCameraPosition] を返す。 - * tilt < 0 の擬似表現([toCameraOptions])の逆変換で、MapLibre 実装と同一ロジック。 - */ -internal fun MapTilerMapViewController.recoverLogicalCameraPosition(raw: MapCameraPosition): MapCameraPosition { - val logicalTiltHint = lastLogicalCameraPosition?.tilt - val pitchAbsDeg = abs(raw.tilt).coerceIn(0.0, 60.0) - if (logicalTiltHint == null || logicalTiltHint >= 0.0 || pitchAbsDeg == 0.0) return raw - - val pitchAbsRad = Math.toRadians(pitchAbsDeg) - val shiftedCenter = raw.position - val originalGoogleZoom = - raw.zoom - MapTilerMapViewController.NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (pitchAbsDeg / 60.0) - val altitude = - zoomConverter.zoomLevelToAltitude( - ZoomAltitudeConverter.googleZoomToMaptilerZoom(originalGoogleZoom), - shiftedCenter.latitude, - 0.0, - ) - val distanceBackward = - altitude * cos(pitchAbsRad) * tan(pitchAbsRad) * MapTilerMapViewController.NEGATIVE_TILT_TARGET_DISTANCE_SCALE - val originalPosition = - Spherical.computeOffset( - shiftedCenter, distanceBackward, - CameraBearing.toNativeHeading(raw.bearing) + 180.0, - ) - - return raw.copy( - position = originalPosition, - zoom = originalGoogleZoom, - tilt = -pitchAbsDeg, - ) -} - -internal fun MapTilerMapViewController.handleMoveCamera(position: MapCameraPosition) { - lastLogicalCameraPosition = position - mtController.jumpTo(toCameraOptions(position)) -} - -internal fun MapTilerMapViewController.handleAnimateCamera( - position: MapCameraPosition, - duration: Long, -) { - lastLogicalCameraPosition = position - // easeTo は線形補間で、しかも MapTiler SDK ラッパーが duration を渡せないため MapLibre GL JS - // 既定の約 300ms 固定で動く。ズーム差が大きい移動(例: 世界 z0 → 都市 z10)では一気にズームして - // タイル読み込みが追いつかず激しくカクつく。flyTo(van Wijk のズームアーク)に切り替え、指定 - // duration を maxDuration として渡すことで、タイル追従しつつ滑らかにアニメーションさせる。 - val flyToOptions = - MTFlyToOptions(null, null, null, null, null).apply { - maxDuration = duration.toDouble() - } - mtController.flyTo(toCameraOptions(position), flyToOptions) -} - -internal fun MapTilerMapViewController.handleFitBounds( - bounds: GeoRectBounds, - padding: Int, -) { - val mtBounds = bounds.toMTBounds() ?: return - val options = - MTFitBoundsOptions( - padding = - MTPaddingOptions( - left = padding.toDouble(), - top = padding.toDouble(), - right = padding.toDouble(), - bottom = padding.toDouble(), - ), - ) - mtController.fitBounds(mtBounds, options) -} - -internal fun MapTilerMapViewController.applyGestureSettings(settings: MapUISettings) { - val gestures = mtController.gestureService ?: return - - if (settings.scrollGesture) { - gestures.enableDragPanGesture() - } else { - gestures.disableGesture(MTGestureType.DRAG_PAN) - } - - if (settings.tiltGesture) { - gestures.enableTwoFingerDragPitchGesture() - } else { - gestures.disableGesture(MTGestureType.TWO_FINGERS_DRAG_PITCH) - } - - if (settings.zoomGesture) { - gestures.enableDoubleTapZoomInGesture() - } else { - gestures.disableGesture(MTGestureType.DOUBLE_TAP_ZOOM_IN) - } - - // MapTiler bundles pinch-zoom and rotation into one PINCH_ROTATE_AND_ZOOM - // gesture, so neither can be switched off alone; only drop it when both are - // off. The discrete double-tap zoom above still follows zoomGesture. - if (settings.zoomGesture || settings.rotateGesture) { - gestures.enablePinchRotateAndZoomGesture() - } else { - gestures.disableGesture(MTGestureType.PINCH_ROTATE_AND_ZOOM) - } - - if (settings.zoomGesture != settings.rotateGesture) { - MapUISettingsDiagnostics.warnIfRequested( - false, - gesture = if (settings.zoomGesture) MapGesture.Rotate else MapGesture.Zoom, - provider = "MapTiler", - reason = "pinch zoom and rotation share one gesture, so they can only be disabled together", - ) - } -} - -internal fun MapTilerMapViewController.toCameraOptions(camera: MapCameraPosition): MTCameraOptions { - if (camera.tilt >= 0) { - return MTCameraOptions( - center = camera.position.toLngLat(), - zoom = MapTilerMapViewController.coreZoomToMapTiler(camera.zoom), - bearing = CameraBearing.toNativeHeading(camera.bearing), - pitch = camera.tilt.coerceIn(0.0, 60.0), - ) - } - - // camera.tilt < 0: MapTiler(MapLibre GL JS)は上向きピッチを直接表現できない。 - // MapLibre 実装と同じ方式で、地上ターゲットを進行方向へ前進させ abs(camera.tilt) の下向き - // ピッチで描画することで、擬似的に上向き(負tilt)視点を再現する。 - val tiltAbsDeg = abs(camera.tilt).coerceIn(0.0, 60.0) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = - zoomConverter.zoomLevelToAltitude( - ZoomAltitudeConverter.googleZoomToMaptilerZoom(camera.zoom), - camera.position.latitude, - 0.0, - ) - val distanceForward = - altitude * - cos(tiltAbsRad) * - tan(tiltAbsRad) * - MapTilerMapViewController.NEGATIVE_TILT_TARGET_DISTANCE_SCALE - val target = - Spherical - .computeOffset(camera.position, distanceForward, CameraBearing.toNativeHeading(camera.bearing)) - val adjustedZoom = - camera.zoom + MapTilerMapViewController.NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) - - return MTCameraOptions( - center = target.toLngLat(), - zoom = MapTilerMapViewController.coreZoomToMapTiler(adjustedZoom), - bearing = CameraBearing.toNativeHeading(camera.bearing), - pitch = tiltAbsDeg, - ) -} +package com.mapconductor.maptiler + +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.MapGesture +import com.mapconductor.core.map.MapUISettings +import com.mapconductor.core.map.MapUISettingsDiagnostics +import com.mapconductor.core.map.VisibleRegion +import com.mapconductor.core.spherical.Spherical +import com.mapconductor.maptiler.zoom.ZoomAltitudeConverter +import com.maptiler.maptilersdk.map.gestures.MTGestureType +import com.maptiler.maptilersdk.map.options.MTCameraOptions +import com.maptiler.maptilersdk.map.options.MTFitBoundsOptions +import com.maptiler.maptilersdk.map.options.MTFlyToOptions +import com.maptiler.maptilersdk.map.options.MTPaddingOptions +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.tan + +// カメラの読み書きとジェスチャ設定。 +// MapTiler のズームは MapConductor の論理ズームと 1 段ずれるため +// ZoomAltitudeConverter を通す。範囲制限はネイティブ API が無いので +// カメラ停止時にクランプして再適用する。 + +/** + * MapTiler から現在のカメラ状態を取得し、可視領域([VisibleRegion])を付与した [MapCameraPosition] を返す。 + * クラスタリングは `visibleRegion.bounds` を必要とするため、[com.maptiler.maptilersdk.map.MTMapViewController.getBounds] を用いる。 + */ +internal suspend fun MapTilerMapViewController.currentCameraWithRegion(): MapCameraPosition { + val center = mtController.getCenter() + val zoom = mtController.getZoom() + val bearing = mtController.getBearing() + val pitch = mtController.getPitch() + val region = + runCatching { mtController.getBounds() }.getOrNull()?.let { bounds -> + VisibleRegion( + bounds = + GeoRectBounds( + southWest = GeoPoint(bounds.southwest.lat, bounds.southwest.lng), + northEast = GeoPoint(bounds.northeast.lat, bounds.northeast.lng), + ), + nearLeft = null, + nearRight = null, + farLeft = null, + farRight = null, + ) + } + val raw = + MapCameraPosition( + position = center.toGeoPoint(), + zoom = MapTilerMapViewController.mapTilerZoomToCore(zoom), + bearing = CameraBearing.bearingFromNativeHeading(bearing), + tilt = pitch, + visibleRegion = region, + ) + return recoverLogicalCameraPosition(raw) +} + +/** + * 生のカメラ状態(MapTiler の正ピッチ・統一ズーム換算済み)から、直近に要求した論理 tilt が + * 負のときに元の位置・ズーム・負tilt を復元した [MapCameraPosition] を返す。 + * tilt < 0 の擬似表現([toCameraOptions])の逆変換で、MapLibre 実装と同一ロジック。 + */ +internal fun MapTilerMapViewController.recoverLogicalCameraPosition(raw: MapCameraPosition): MapCameraPosition { + val logicalTiltHint = lastLogicalCameraPosition?.tilt + val pitchAbsDeg = abs(raw.tilt).coerceIn(0.0, 60.0) + if (logicalTiltHint == null || logicalTiltHint >= 0.0 || pitchAbsDeg == 0.0) return raw + + val pitchAbsRad = Math.toRadians(pitchAbsDeg) + val shiftedCenter = raw.position + val originalGoogleZoom = + raw.zoom - MapTilerMapViewController.NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (pitchAbsDeg / 60.0) + val altitude = + zoomConverter.zoomLevelToAltitude( + ZoomAltitudeConverter.googleZoomToMaptilerZoom(originalGoogleZoom), + shiftedCenter.latitude, + 0.0, + ) + val distanceBackward = + altitude * cos(pitchAbsRad) * tan(pitchAbsRad) * MapTilerMapViewController.NEGATIVE_TILT_TARGET_DISTANCE_SCALE + val originalPosition = + Spherical.computeOffset( + shiftedCenter, distanceBackward, + CameraBearing.toNativeHeading(raw.bearing) + 180.0, + ) + + return raw.copy( + position = originalPosition, + zoom = originalGoogleZoom, + tilt = -pitchAbsDeg, + ) +} + +internal fun MapTilerMapViewController.handleMoveCamera(position: MapCameraPosition) { + lastLogicalCameraPosition = position + mtController.jumpTo(toCameraOptions(position)) +} + +internal fun MapTilerMapViewController.handleAnimateCamera( + position: MapCameraPosition, + duration: Long, +) { + lastLogicalCameraPosition = position + // easeTo は線形補間で、しかも MapTiler SDK ラッパーが duration を渡せないため MapLibre GL JS + // 既定の約 300ms 固定で動く。ズーム差が大きい移動(例: 世界 z0 → 都市 z10)では一気にズームして + // タイル読み込みが追いつかず激しくカクつく。flyTo(van Wijk のズームアーク)に切り替え、指定 + // duration を maxDuration として渡すことで、タイル追従しつつ滑らかにアニメーションさせる。 + val flyToOptions = + MTFlyToOptions(null, null, null, null, null).apply { + maxDuration = duration.toDouble() + } + mtController.flyTo(toCameraOptions(position), flyToOptions) +} + +internal fun MapTilerMapViewController.handleFitBounds( + bounds: GeoRectBounds, + padding: Int, +) { + val mtBounds = bounds.toMTBounds() ?: return + val options = + MTFitBoundsOptions( + padding = + MTPaddingOptions( + left = padding.toDouble(), + top = padding.toDouble(), + right = padding.toDouble(), + bottom = padding.toDouble(), + ), + ) + mtController.fitBounds(mtBounds, options) +} + +internal fun MapTilerMapViewController.applyGestureSettings(settings: MapUISettings) { + val gestures = mtController.gestureService ?: return + + if (settings.scrollGesture) { + gestures.enableDragPanGesture() + } else { + gestures.disableGesture(MTGestureType.DRAG_PAN) + } + + if (settings.tiltGesture) { + gestures.enableTwoFingerDragPitchGesture() + } else { + gestures.disableGesture(MTGestureType.TWO_FINGERS_DRAG_PITCH) + } + + if (settings.zoomGesture) { + gestures.enableDoubleTapZoomInGesture() + } else { + gestures.disableGesture(MTGestureType.DOUBLE_TAP_ZOOM_IN) + } + + // MapTiler bundles pinch-zoom and rotation into one PINCH_ROTATE_AND_ZOOM + // gesture, so neither can be switched off alone; only drop it when both are + // off. The discrete double-tap zoom above still follows zoomGesture. + if (settings.zoomGesture || settings.rotateGesture) { + gestures.enablePinchRotateAndZoomGesture() + } else { + gestures.disableGesture(MTGestureType.PINCH_ROTATE_AND_ZOOM) + } + + if (settings.zoomGesture != settings.rotateGesture) { + MapUISettingsDiagnostics.warnIfRequested( + false, + gesture = if (settings.zoomGesture) MapGesture.Rotate else MapGesture.Zoom, + provider = "MapTiler", + reason = "pinch zoom and rotation share one gesture, so they can only be disabled together", + ) + } +} + +internal fun MapTilerMapViewController.toCameraOptions(camera: MapCameraPosition): MTCameraOptions { + if (camera.tilt >= 0) { + return MTCameraOptions( + center = camera.position.toLngLat(), + zoom = MapTilerMapViewController.coreZoomToMapTiler(camera.zoom), + bearing = CameraBearing.toNativeHeading(camera.bearing), + pitch = camera.tilt.coerceIn(0.0, 60.0), + ) + } + + // camera.tilt < 0: MapTiler(MapLibre GL JS)は上向きピッチを直接表現できない。 + // MapLibre 実装と同じ方式で、地上ターゲットを進行方向へ前進させ abs(camera.tilt) の下向き + // ピッチで描画することで、擬似的に上向き(負tilt)視点を再現する。 + val tiltAbsDeg = abs(camera.tilt).coerceIn(0.0, 60.0) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = + zoomConverter.zoomLevelToAltitude( + ZoomAltitudeConverter.googleZoomToMaptilerZoom(camera.zoom), + camera.position.latitude, + 0.0, + ) + val distanceForward = + altitude * + cos(tiltAbsRad) * + tan(tiltAbsRad) * + MapTilerMapViewController.NEGATIVE_TILT_TARGET_DISTANCE_SCALE + val target = + Spherical + .computeOffset(camera.position, distanceForward, CameraBearing.toNativeHeading(camera.bearing)) + val adjustedZoom = + camera.zoom + MapTilerMapViewController.NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) + + return MTCameraOptions( + center = target.toLngLat(), + zoom = MapTilerMapViewController.coreZoomToMapTiler(adjustedZoom), + bearing = CameraBearing.toNativeHeading(camera.bearing), + pitch = tiltAbsDeg, + ) +}