diff --git a/api/android-for-maptiler.api b/api/android-for-maptiler.api index 35e2827..090f6a9 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/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt b/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt index 9abe5fa..8281be7 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerDesign.kt @@ -1,11 +1,11 @@ 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 +import java.net.URL /** * MapTiler の地図デザイン(リファレンススタイル + バリアント)を表すインターフェース。 diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerMapView.kt b/src/main/java/com/mapconductor/maptiler/MapTilerMapView.kt index 9e8ec3b..7c54322 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..bd77086 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewControllerCamera.kt @@ -80,7 +80,11 @@ internal fun MapTilerMapViewController.recoverLogicalCameraPosition(raw: MapCame ) 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) + val originalPosition = + Spherical.computeOffset( + shiftedCenter, distanceBackward, + CameraBearing.toNativeHeading(raw.bearing) + 180.0, + ) return raw.copy( position = originalPosition, @@ -194,7 +198,9 @@ internal fun MapTilerMapViewController.toCameraOptions(camera: MapCameraPosition cos(tiltAbsRad) * tan(tiltAbsRad) * MapTilerMapViewController.NEGATIVE_TILT_TARGET_DISTANCE_SCALE - val target = Spherical.computeOffset(camera.position, distanceForward, CameraBearing.toNativeHeading(camera.bearing)) + 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) 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() + } +}