From 12c89e6f0591cf1cc7eff6f5da93258230384cf2 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 12 Sep 2026 14:34:30 +0900 Subject: [PATCH 01/34] Make bearing a clockwise map rotation (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make bearing a clockwise map rotation bearing を「値を増やすと地図が時計回りに回る」向きに統一し、ネイティブ SDK との変換をコアの CameraBearing に集約する。 * ci: build the core module from the matching branch コアの未マージ/未リリースな変更に追随する PR を、コアが先に取り込まれるのを待たずに検証できるようにする。同名ブランチが無ければ従来どおり既定のコアを使う。 * fix: point material3 at its own version line material3 は compose-ui とは別のバージョン系列で、composeLibs(1.7.8)を流用すると存在しない material3:1.7.8 を要求して sample-app のクラスパスごと解決に失敗する。集約ビルド(android-sdk)の catalog と同じ 1.4.0 に合わせる。 * fix: declare material-icons-core for the sample app sample-app が Icons.AutoMirrored を使うが、material3 は material-icons を推移的に持ってこないため単体ビルドで Unresolved reference 'Icons' になる。版は compose BOM 任せで、集約ビルドの catalog と同じ形にする。 --- .github/workflows/build.yml | 25 +++++++++++++++++-- gradle/libs.versions.toml | 8 +++++- sample-app/build.gradle.kts | 1 + .../mapconductor/mapbox/MapCameraPosition.kt | 13 +++++----- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 79f872f..f0d3bb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 .. @@ -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 .. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9d0e75..beb199c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,8 @@ [versions] composeLibs = "1.7.8" +# material3 は compose-ui とは別のバージョン系列。composeLibs(ui 系)を流用すると +# 存在しない material3:1.7.8 を要求してクラスパスごと解決に失敗する。 +material3 = "1.4.0" agp = "9.2.1" kotlin = "2.0.21" @@ -29,7 +32,10 @@ mapbox-android = { module = "com.mapbox.maps:android-ndk27", version.ref = "mapb # 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" } +# sample-app が Icons.AutoMirrored を使う。material3 は material-icons を推移的に持ってこないので +# 明示的に足す。版は compose BOM 任せ(集約ビルドの catalog と同じ形)。 +androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "composeLibs" } # Test diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts index 8f2c78e..23993c2 100644 --- a/sample-app/build.gradle.kts +++ b/sample-app/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.core) debugImplementation(platform(libs.androidx.compose.bom)) debugImplementation(libs.androidx.ui.tooling) } diff --git a/src/main/java/com/mapconductor/mapbox/MapCameraPosition.kt b/src/main/java/com/mapconductor/mapbox/MapCameraPosition.kt index 629e4a9..8a7e729 100644 --- a/src/main/java/com/mapconductor/mapbox/MapCameraPosition.kt +++ b/src/main/java/com/mapconductor/mapbox/MapCameraPosition.kt @@ -6,6 +6,7 @@ import com.mapbox.maps.CameraState import com.mapbox.maps.EdgeInsets import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.map.CameraBearing import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapPaddingsInterface @@ -30,7 +31,7 @@ fun MapCameraPosition.toCameraOptions(): CameraOptions { .center(GeoPoint.from(position).toPoint()) .zoom(ZoomAltitudeConverter.googleZoomToMapboxZoom(zoom)) .pitch(tilt) - .bearing(bearing) + .bearing(CameraBearing.toNativeHeading(bearing)) // TODO: // .padding(paddings?.toEdgeInsects()) .build() @@ -43,7 +44,7 @@ fun MapCameraPosition.toCameraOptions(): CameraOptions { val mapboxZoomForAltitude = ZoomAltitudeConverter.googleZoomToMapboxZoom(zoom) val altitude = converter.zoomLevelToAltitude(mapboxZoomForAltitude, position.latitude, 0.0) val distanceForward = altitude * tan(tiltAbsRad) - val target = Spherical.computeOffset(position, distanceForward, bearing) + val target = Spherical.computeOffset(position, distanceForward, CameraBearing.toNativeHeading(bearing)) val mapboxZoom = converter.altitudeToZoomLevel(altitude / cos(tiltAbsRad), target.latitude, 0.0) return CameraOptions @@ -51,7 +52,7 @@ fun MapCameraPosition.toCameraOptions(): CameraOptions { .center(target.toPoint()) .zoom(mapboxZoom) .pitch(tiltAbsDeg) - .bearing(bearing) + .bearing(CameraBearing.toNativeHeading(bearing)) // TODO: // .padding(paddings?.toEdgeInsects()) .build() @@ -63,7 +64,7 @@ fun MapCameraPosition.toCameraState(): CameraState = GeoPoint.from(position).toPoint(), EdgeInsets(0.0, 0.0, 0.0, 0.0), ZoomAltitudeConverter.googleZoomToMapboxZoom(zoom), - bearing, + CameraBearing.toNativeHeading(bearing), tilt, ) @@ -130,7 +131,7 @@ private fun toMapCameraPosition( return MapCameraPosition( position = center, zoom = ZoomAltitudeConverter.mapboxZoomToGoogleZoom(mapboxZoom), - bearing = bearing, + bearing = CameraBearing.bearingFromNativeHeading(bearing), tilt = pitch, paddings = paddings, visibleRegion = visibleRegion, @@ -148,7 +149,7 @@ private fun toMapCameraPosition( return MapCameraPosition( position = originalCenter, zoom = ZoomAltitudeConverter.mapboxZoomToGoogleZoom(originalMapboxZoom), - bearing = bearing, + bearing = CameraBearing.bearingFromNativeHeading(bearing), tilt = -pitchAbsDeg, paddings = paddings, visibleRegion = visibleRegion, From 50b620afa58b8e16b81cd4ea3d7f54cb69038a70 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 21 Sep 2026 23:44:25 +0900 Subject: [PATCH 02/34] Pass declutterPx through to the marker tile renderer MarkerTilingOptions.declutterPx existed and samples set it, but every controller dropped it on the floor when constructing MarkerTileRenderer, so the renderer ran with 0 and drew every marker on every tile. On a slow device that is the difference between an overview tile costing 538ms and 79ms (SO-02L, 144k markers, z10, MarkerTileRenderBenchmark) -- and the drawn result now actually matches what iOS and react show for the same options. --- .../mapbox/marker/MapboxMarkerController.kt | 687 +++++++++--------- 1 file changed, 344 insertions(+), 343 deletions(-) diff --git a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt index f1b766d..874cfe4 100644 --- a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt +++ b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt @@ -1,343 +1,344 @@ -package com.mapconductor.mapbox.marker - -import com.mapconductor.core.controller.OnCameraChangeReceiverInterface -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.marker.AbstractMarkerController -import com.mapconductor.core.marker.BitmapIcon -import com.mapconductor.core.marker.DefaultMarkerIcon -import com.mapconductor.core.marker.MarkerEntity -import com.mapconductor.core.marker.MarkerEntityInterface -import com.mapconductor.core.marker.MarkerIngestionEngine -import com.mapconductor.core.marker.MarkerManager -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerState -import com.mapconductor.core.marker.MarkerTileRasterLayerCallback -import com.mapconductor.core.marker.MarkerTileRenderer -import com.mapconductor.core.marker.MarkerTilingOptions -import com.mapconductor.core.marker.MarkerViewportSwitch -import com.mapconductor.core.raster.RasterLayerSource -import com.mapconductor.core.raster.RasterLayerState -import com.mapconductor.core.raster.TileScheme -import com.mapconductor.core.tileserver.TileServerRegistry -import com.mapconductor.mapbox.MapboxActualMarker -import com.mapconductor.mapbox.MapboxMapViewHolder -import com.mapconductor.mapbox.toMapCameraPosition -import java.util.UUID -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.withPermit - -class MapboxMarkerController private constructor( - renderer: MapboxMarkerOverlayRenderer, - markerManager: MarkerManager, - private val markerTiling: MarkerTilingOptions, -) : AbstractMarkerController( - markerManager = markerManager, - renderer = renderer, - ), - OnCameraChangeReceiverInterface { - private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() - private val tiledMarkerIds = LinkedHashSet() - private lateinit var lastCameraPosition: MapCameraPosition - - // Tile rendering via RasterLayer - private val tileServer = TileServerRegistry.get() - private var markerTileRenderer: MarkerTileRenderer? = null - private var markerTileGroupId: String? = null - private var markerTileRasterLayerState: RasterLayerState? = null - private var rasterLayerCallback: MarkerTileRasterLayerCallback? = null - private var cacheVersion: Int = 0 - - /** - * ビューポート内が少ないときだけタイルをやめてネイティブマーカーで描く切り替え器。 - * - * レンダラ/マネージャ/semaphore を共有するので、コントローラと同じ排他の下で動く。 - */ - private val viewportSwitch = - MarkerViewportSwitch( - markerManager = markerManager, - renderer = renderer, - defaultMarkerIcon = defaultMarkerIcon, - semaphore = semaphore, - policy = markerTiling.viewport, - setTileLayerVisible = ::setTileLayerVisible, - invalidateTiles = ::updateRasterLayerSource, - ) - - fun setRasterLayerCallback(callback: MarkerTileRasterLayerCallback?) { - rasterLayerCallback = callback - } - - override suspend fun add(data: List) { - // ingest はタイル担当 entity を marker = null で登録し直すので、先に昇格を戻す。 - // semaphore は再入不可なので withPermit の外で呼ぶこと。 - viewportSwitch.retract() - semaphore.withPermit { - val tilingEnabled = - markerTiling.enabled && data.size >= markerManager.minMarkerCount - val result = - MarkerIngestionEngine.ingest( - data = data, - markerManager = markerManager, - renderer = renderer, - defaultMarkerIcon = defaultMarkerIcon, - tilingEnabled = tilingEnabled, - tiledMarkerIds = tiledMarkerIds, - shouldTile = { state -> !state.draggable && state.getAnimation() == null }, - ) - - if (result.tiledDataChanged) { - syncTiledOverlay() - } else if (result.hasTiledMarkers) { - if (markerTileRenderer == null || markerTileRasterLayerState == null) { - syncTiledOverlay() - } - } else { - removeTileOverlay() - } - } - viewportSwitch.requestReapply() - } - - override suspend fun clear() { - viewportSwitch.destroy() - semaphore.withPermit { - val entities = markerManager.allEntities() - val toRemove = entities.filter { it.marker != null } - if (toRemove.isNotEmpty()) { - renderer.onRemove(toRemove) - } - markerManager.clear() - tiledMarkerIds.clear() - removeTileOverlay() - } - viewportSwitch.requestReapply() - } - - override suspend fun update(state: MarkerState) { - if (!markerManager.hasEntity(state.id)) return - - // 昇格中の 1 件なら先に取り下げる(下で marker = null 登録があるため)。 - // 昇格していないマーカー(ドラッグ中など)では何もしないので、ドラッグは素通りする。 - if (viewportSwitch.isPromoted(state.id)) viewportSwitch.release(state.id) - - val prevEntity = markerManager.getEntity(state.id) ?: return - val currentFinger = state.fingerPrint() - val prevFinger = prevEntity.fingerPrint - if (currentFinger == prevFinger) return - - semaphore.withPermit { - val tilingEnabled = - markerTiling.enabled && markerManager.allEntities().size >= markerManager.minMarkerCount - val wantsTiled = tilingEnabled && !state.draggable && state.getAnimation() == null - val wasTiled = tiledMarkerIds.contains(state.id) - val markerIcon = state.icon?.toBitmapIcon() ?: defaultMarkerIcon - - if (wantsTiled) { - if (!wasTiled) { - prevEntity.marker?.let { renderer.onRemove(listOf(prevEntity)) } - tiledMarkerIds.add(state.id) - } - markerManager.updateEntity( - MarkerEntity( - marker = null, - state = state, - visible = prevEntity.visible, - isRendered = true, - // tiling を立てないと MarkerTileRenderer の絞り込みから漏れ、 - // タイル昇格したのにタイルへ描かれないマーカーになる。 - tiling = true, - ), - ) - renderer.onPostProcess() - syncTiledOverlay() - return@withPermit - } - - if (wasTiled) { - tiledMarkerIds.remove(state.id) - } - - val params = - object : MarkerOverlayRendererInterface.ChangeParamsInterface { - override val current: MarkerEntityInterface = - MarkerEntity( - marker = prevEntity.marker, - state = state, - visible = prevEntity.visible, - isRendered = true, - ) - override val bitmapIcon: BitmapIcon = markerIcon - override val prev: MarkerEntityInterface = prevEntity - } - val markers = renderer.onChange(listOf(params)) - markers.firstOrNull()?.let { actual -> - markerManager.updateEntity( - MarkerEntity( - marker = actual, - state = state, - visible = prevEntity.visible, - isRendered = true, - ), - ) - if (prevFinger.animation != currentFinger.animation) { - state.getAnimation()?.let { renderer.onAnimate(markerManager.getEntity(state.id)!!) } - } - } - renderer.onPostProcess() - - if (tiledMarkerIds.isNotEmpty()) { - syncTiledOverlay() - } else { - removeTileOverlay() - } - } - } - - override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { - // 判定と昇格は debounce したうえで切り替え器の中で走る(パン中は動かない)。 - viewportSwitch.onCameraChanged(mapCameraPosition) - lastCameraPosition = mapCameraPosition - } - - /** - * マーカータイルのラスターレイヤの表示だけを切り替える。 - * - * source(URL)には触らない。触るとタイルを取り直すことになり、切り替えのたびに - * タイルキャッシュを捨てるのと同じになる。 - */ - private suspend fun setTileLayerVisible(visible: Boolean) { - val current = markerTileRasterLayerState ?: return - if (current.visible == visible) return - val newState = current.copy(visible = visible) - markerTileRasterLayerState = newState - rasterLayerCallback?.onRasterLayerUpdate(newState) - } - - override fun destroy() { - viewportSwitch.destroy() - // Clean up tile server registration - // Unregister this map's route only. Never stop the server here: it is - // a process-wide singleton shared by all map controllers and overlay - // modules; stopping it breaks tile loading for every other live map. - markerTileGroupId?.let { groupId -> - tileServer.unregister(groupId) - } - markerTileGroupId = null - markerTileRenderer = null - - (renderer as MapboxMarkerOverlayRenderer).coroutine.launch { - rasterLayerCallback?.onRasterLayerUpdate(null) - } - markerTileRasterLayerState = null - super.destroy() - } - - private suspend fun updateRasterLayerSource() { - val groupId = markerTileGroupId ?: return - val tileRenderer = markerTileRenderer ?: return - val oldState = markerTileRasterLayerState ?: return - cacheVersion = (cacheVersion + 1) and 0x7fffffff - tileRenderer.invalidate() - - val newState = - oldState.copy( - source = - RasterLayerSource.UrlTemplate( - template = "${tileServer.urlTemplate(groupId, tileRenderer.tileSize)}?v=$cacheVersion", - tileSize = tileRenderer.tileSize, - maxZoom = 22, - scheme = TileScheme.XYZ, - ), - id = oldState.id, - ) - markerTileRasterLayerState = newState - rasterLayerCallback?.onRasterLayerUpdate(newState) - } - - private suspend fun syncTiledOverlay() { - if (tiledMarkerIds.isEmpty()) { - removeTileOverlay() - return - } - if (!markerTiling.enabled) { - removeTileOverlay() - tiledMarkerIds.clear() - return - } - - getOrCreateTileRenderer() - updateRasterLayerSource() - } - - private fun getOrCreateTileRenderer(): MarkerTileRenderer { - markerTileRenderer?.let { return it } - - val groupId = UUID.randomUUID().toString() - markerTileGroupId = groupId - - val tileRenderer = - MarkerTileRenderer( - markerManager = markerManager, - tileSize = 256, - cacheSizeBytes = markerTiling.cacheSize, - debugTileOverlay = markerTiling.debugTileOverlay, - iconScaleCallback = markerTiling.iconScaleCallback, - ) - markerTileRenderer = tileRenderer - - tileServer.register(groupId, tileRenderer) - - markerTileRasterLayerState = - RasterLayerState( - id = "marker-tile-$groupId", - source = - RasterLayerSource.UrlTemplate( - template = tileServer.urlTemplate(groupId, tileRenderer.tileSize), - tileSize = tileRenderer.tileSize, - maxZoom = 22, - scheme = TileScheme.XYZ, - ), - opacity = 1.0f, - visible = true, - ) - - return tileRenderer - } - - private suspend fun removeTileOverlay() { - markerTileGroupId?.let { groupId -> - tileServer.unregister(groupId) - } - markerTileGroupId = null - markerTileRenderer = null - - rasterLayerCallback?.onRasterLayerUpdate(null) - markerTileRasterLayerState = null - } - - companion object { - fun create( - holder: MapboxMapViewHolder, - markerManager: MarkerManager, - markerLayer: MarkerLayer, - dragLayer: MarkerDragLayer, - markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, - ): MapboxMarkerController { - val renderer = - MapboxMarkerOverlayRenderer( - holder = holder, - markerManager = markerManager, - markerLayer = markerLayer, - dragLayer = dragLayer, - ) - val controller = - MapboxMarkerController( - renderer = renderer, - markerManager = markerManager, - markerTiling = markerTiling, - ) - controller.lastCameraPosition = holder.map.cameraState.toMapCameraPosition() - return controller - } - } -} +package com.mapconductor.mapbox.marker + +import com.mapconductor.core.controller.OnCameraChangeReceiverInterface +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.marker.AbstractMarkerController +import com.mapconductor.core.marker.BitmapIcon +import com.mapconductor.core.marker.DefaultMarkerIcon +import com.mapconductor.core.marker.MarkerEntity +import com.mapconductor.core.marker.MarkerEntityInterface +import com.mapconductor.core.marker.MarkerIngestionEngine +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.marker.MarkerTileRasterLayerCallback +import com.mapconductor.core.marker.MarkerTileRenderer +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.MarkerViewportSwitch +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.raster.TileScheme +import com.mapconductor.core.tileserver.TileServerRegistry +import com.mapconductor.mapbox.MapboxActualMarker +import com.mapconductor.mapbox.MapboxMapViewHolder +import com.mapconductor.mapbox.toMapCameraPosition +import java.util.UUID +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withPermit + +class MapboxMarkerController private constructor( + renderer: MapboxMarkerOverlayRenderer, + markerManager: MarkerManager, + private val markerTiling: MarkerTilingOptions, +) : AbstractMarkerController( + markerManager = markerManager, + renderer = renderer, + ), + OnCameraChangeReceiverInterface { + private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() + private val tiledMarkerIds = LinkedHashSet() + private lateinit var lastCameraPosition: MapCameraPosition + + // Tile rendering via RasterLayer + private val tileServer = TileServerRegistry.get() + private var markerTileRenderer: MarkerTileRenderer? = null + private var markerTileGroupId: String? = null + private var markerTileRasterLayerState: RasterLayerState? = null + private var rasterLayerCallback: MarkerTileRasterLayerCallback? = null + private var cacheVersion: Int = 0 + + /** + * ビューポート内が少ないときだけタイルをやめてネイティブマーカーで描く切り替え器。 + * + * レンダラ/マネージャ/semaphore を共有するので、コントローラと同じ排他の下で動く。 + */ + private val viewportSwitch = + MarkerViewportSwitch( + markerManager = markerManager, + renderer = renderer, + defaultMarkerIcon = defaultMarkerIcon, + semaphore = semaphore, + policy = markerTiling.viewport, + setTileLayerVisible = ::setTileLayerVisible, + invalidateTiles = ::updateRasterLayerSource, + ) + + fun setRasterLayerCallback(callback: MarkerTileRasterLayerCallback?) { + rasterLayerCallback = callback + } + + override suspend fun add(data: List) { + // ingest はタイル担当 entity を marker = null で登録し直すので、先に昇格を戻す。 + // semaphore は再入不可なので withPermit の外で呼ぶこと。 + viewportSwitch.retract() + semaphore.withPermit { + val tilingEnabled = + markerTiling.enabled && data.size >= markerManager.minMarkerCount + val result = + MarkerIngestionEngine.ingest( + data = data, + markerManager = markerManager, + renderer = renderer, + defaultMarkerIcon = defaultMarkerIcon, + tilingEnabled = tilingEnabled, + tiledMarkerIds = tiledMarkerIds, + shouldTile = { state -> !state.draggable && state.getAnimation() == null }, + ) + + if (result.tiledDataChanged) { + syncTiledOverlay() + } else if (result.hasTiledMarkers) { + if (markerTileRenderer == null || markerTileRasterLayerState == null) { + syncTiledOverlay() + } + } else { + removeTileOverlay() + } + } + viewportSwitch.requestReapply() + } + + override suspend fun clear() { + viewportSwitch.destroy() + semaphore.withPermit { + val entities = markerManager.allEntities() + val toRemove = entities.filter { it.marker != null } + if (toRemove.isNotEmpty()) { + renderer.onRemove(toRemove) + } + markerManager.clear() + tiledMarkerIds.clear() + removeTileOverlay() + } + viewportSwitch.requestReapply() + } + + override suspend fun update(state: MarkerState) { + if (!markerManager.hasEntity(state.id)) return + + // 昇格中の 1 件なら先に取り下げる(下で marker = null 登録があるため)。 + // 昇格していないマーカー(ドラッグ中など)では何もしないので、ドラッグは素通りする。 + if (viewportSwitch.isPromoted(state.id)) viewportSwitch.release(state.id) + + val prevEntity = markerManager.getEntity(state.id) ?: return + val currentFinger = state.fingerPrint() + val prevFinger = prevEntity.fingerPrint + if (currentFinger == prevFinger) return + + semaphore.withPermit { + val tilingEnabled = + markerTiling.enabled && markerManager.allEntities().size >= markerManager.minMarkerCount + val wantsTiled = tilingEnabled && !state.draggable && state.getAnimation() == null + val wasTiled = tiledMarkerIds.contains(state.id) + val markerIcon = state.icon?.toBitmapIcon() ?: defaultMarkerIcon + + if (wantsTiled) { + if (!wasTiled) { + prevEntity.marker?.let { renderer.onRemove(listOf(prevEntity)) } + tiledMarkerIds.add(state.id) + } + markerManager.updateEntity( + MarkerEntity( + marker = null, + state = state, + visible = prevEntity.visible, + isRendered = true, + // tiling を立てないと MarkerTileRenderer の絞り込みから漏れ、 + // タイル昇格したのにタイルへ描かれないマーカーになる。 + tiling = true, + ), + ) + renderer.onPostProcess() + syncTiledOverlay() + return@withPermit + } + + if (wasTiled) { + tiledMarkerIds.remove(state.id) + } + + val params = + object : MarkerOverlayRendererInterface.ChangeParamsInterface { + override val current: MarkerEntityInterface = + MarkerEntity( + marker = prevEntity.marker, + state = state, + visible = prevEntity.visible, + isRendered = true, + ) + override val bitmapIcon: BitmapIcon = markerIcon + override val prev: MarkerEntityInterface = prevEntity + } + val markers = renderer.onChange(listOf(params)) + markers.firstOrNull()?.let { actual -> + markerManager.updateEntity( + MarkerEntity( + marker = actual, + state = state, + visible = prevEntity.visible, + isRendered = true, + ), + ) + if (prevFinger.animation != currentFinger.animation) { + state.getAnimation()?.let { renderer.onAnimate(markerManager.getEntity(state.id)!!) } + } + } + renderer.onPostProcess() + + if (tiledMarkerIds.isNotEmpty()) { + syncTiledOverlay() + } else { + removeTileOverlay() + } + } + } + + override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { + // 判定と昇格は debounce したうえで切り替え器の中で走る(パン中は動かない)。 + viewportSwitch.onCameraChanged(mapCameraPosition) + lastCameraPosition = mapCameraPosition + } + + /** + * マーカータイルのラスターレイヤの表示だけを切り替える。 + * + * source(URL)には触らない。触るとタイルを取り直すことになり、切り替えのたびに + * タイルキャッシュを捨てるのと同じになる。 + */ + private suspend fun setTileLayerVisible(visible: Boolean) { + val current = markerTileRasterLayerState ?: return + if (current.visible == visible) return + val newState = current.copy(visible = visible) + markerTileRasterLayerState = newState + rasterLayerCallback?.onRasterLayerUpdate(newState) + } + + override fun destroy() { + viewportSwitch.destroy() + // Clean up tile server registration + // Unregister this map's route only. Never stop the server here: it is + // a process-wide singleton shared by all map controllers and overlay + // modules; stopping it breaks tile loading for every other live map. + markerTileGroupId?.let { groupId -> + tileServer.unregister(groupId) + } + markerTileGroupId = null + markerTileRenderer = null + + (renderer as MapboxMarkerOverlayRenderer).coroutine.launch { + rasterLayerCallback?.onRasterLayerUpdate(null) + } + markerTileRasterLayerState = null + super.destroy() + } + + private suspend fun updateRasterLayerSource() { + val groupId = markerTileGroupId ?: return + val tileRenderer = markerTileRenderer ?: return + val oldState = markerTileRasterLayerState ?: return + cacheVersion = (cacheVersion + 1) and 0x7fffffff + tileRenderer.invalidate() + + val newState = + oldState.copy( + source = + RasterLayerSource.UrlTemplate( + template = "${tileServer.urlTemplate(groupId, tileRenderer.tileSize)}?v=$cacheVersion", + tileSize = tileRenderer.tileSize, + maxZoom = 22, + scheme = TileScheme.XYZ, + ), + id = oldState.id, + ) + markerTileRasterLayerState = newState + rasterLayerCallback?.onRasterLayerUpdate(newState) + } + + private suspend fun syncTiledOverlay() { + if (tiledMarkerIds.isEmpty()) { + removeTileOverlay() + return + } + if (!markerTiling.enabled) { + removeTileOverlay() + tiledMarkerIds.clear() + return + } + + getOrCreateTileRenderer() + updateRasterLayerSource() + } + + private fun getOrCreateTileRenderer(): MarkerTileRenderer { + markerTileRenderer?.let { return it } + + val groupId = UUID.randomUUID().toString() + markerTileGroupId = groupId + + val tileRenderer = + MarkerTileRenderer( + markerManager = markerManager, + tileSize = 256, + cacheSizeBytes = markerTiling.cacheSize, + debugTileOverlay = markerTiling.debugTileOverlay, + iconScaleCallback = markerTiling.iconScaleCallback, + declutterPx = markerTiling.declutterPx, + ) + markerTileRenderer = tileRenderer + + tileServer.register(groupId, tileRenderer) + + markerTileRasterLayerState = + RasterLayerState( + id = "marker-tile-$groupId", + source = + RasterLayerSource.UrlTemplate( + template = tileServer.urlTemplate(groupId, tileRenderer.tileSize), + tileSize = tileRenderer.tileSize, + maxZoom = 22, + scheme = TileScheme.XYZ, + ), + opacity = 1.0f, + visible = true, + ) + + return tileRenderer + } + + private suspend fun removeTileOverlay() { + markerTileGroupId?.let { groupId -> + tileServer.unregister(groupId) + } + markerTileGroupId = null + markerTileRenderer = null + + rasterLayerCallback?.onRasterLayerUpdate(null) + markerTileRasterLayerState = null + } + + companion object { + fun create( + holder: MapboxMapViewHolder, + markerManager: MarkerManager, + markerLayer: MarkerLayer, + dragLayer: MarkerDragLayer, + markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, + ): MapboxMarkerController { + val renderer = + MapboxMarkerOverlayRenderer( + holder = holder, + markerManager = markerManager, + markerLayer = markerLayer, + dragLayer = dragLayer, + ) + val controller = + MapboxMarkerController( + renderer = renderer, + markerManager = markerManager, + markerTiling = markerTiling, + ) + controller.lastCameraPosition = holder.map.cameraState.toMapCameraPosition() + return controller + } + } +} From aef61833a2b7be66aa26698ed2ad34a73d3028de Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 26 Sep 2026 22:26:14 -0700 Subject: [PATCH 03/34] Put raster overlays under the basemap's labels A raster overlay was appended at the top of the style, above the place names, road names and shields the backend draws. The vector tile sample showed what that costs: our own roads run straight through the district names, which reads as "the labels are drawn under the lines". A heatmap or a satellite layer hid them outright. Anchoring to the style's first symbol layer puts the overlay above the basemap's geometry and below its labels, which is where a tile overlay belongs. Inserting successive layers below the same anchor preserves their order, so the z-order among our own raster layers is unchanged. Our own layers are skipped when looking for the anchor: markers are a symbol layer too, and anchoring to them would put the raster back above the labels -- and only once a marker existed. Marker tiles keep their own rule (below the marker symbol layer), so native markers still draw above everything. --- .../MapboxRasterLayerOverlayRenderer.kt | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt index 850b20e..54c73a1 100644 --- a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt @@ -103,7 +103,7 @@ class MapboxRasterLayerOverlayRenderer( if (isMarkerTileRaster(state)) { addLayerForMarkerTile(style, layer) } else { - style.addLayer(layer) + addBelowBasemapLabels(style, layer) } } catch (e: Exception) { Log.w("Mapbox", "Failed to add raster layer: ${e.message}") @@ -134,7 +134,7 @@ class MapboxRasterLayerOverlayRenderer( if (isMarkerTileRaster(state)) { addLayerForMarkerTile(style, layer) } else { - style.addLayer(layer) + addBelowBasemapLabels(style, layer) } } catch (_: Exception) { } @@ -233,12 +233,54 @@ class MapboxRasterLayerOverlayRenderer( rasterOpacity(opacity) } try { - style.addLayer(layer) + addBelowBasemapLabels(style, layer) } catch (_: Exception) { } } } + /** + * Puts a raster layer above the basemap's geometry but **below its labels**. + * + * Appended at the top of the style instead, a raster overlay covers the + * place names, road names and shields the backend draws -- a vector tile + * layer's own roads run straight through them, which is what "the labels + * are under the lines" looks like. Every raster overlay we add has the + * same problem, so the rule lives here rather than in each of them. + * + * Inserting successive layers below the same anchor keeps their order: + * each one lands directly below the anchor, which is directly above the + * one inserted before it. + * + * Anything of ours is skipped when looking for the anchor. Markers are a + * symbol layer too, and anchoring to them would put the raster back above + * the labels -- with the added twist of only doing so once a marker exists. + */ + private fun addBelowBasemapLabels( + style: com.mapbox.maps.Style, + layer: com.mapbox.maps.extension.style.layers.Layer, + ) { + val anchor = + style.styleLayers.firstOrNull { it.type == "symbol" && !isOursById(it.id) } + if (anchor == null) { + style.addLayer(layer) + return + } + try { + style.addLayerBelow(layer, anchor.id) + } catch (_: Exception) { + style.addLayer(layer) + } + } + + /** True for layers this SDK adds, as opposed to the design's own. */ + private fun isOursById(id: String): Boolean = + id.startsWith("raster-layer-") || + id.startsWith(MARKERS_LAYER_ID) || + id.startsWith("marker-drag-layer") || + id.startsWith(POLYLINE_LAYER_ID) || + id.startsWith("circle-layer") + private companion object { private const val MARKER_TILE_RASTER_ID_PREFIX = "marker-tile-" private const val MARKERS_LAYER_ID = "markers-layer" From 2d624740322285a90f609c39c19853ceefc3cfd6 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Tue, 29 Sep 2026 10:13:27 -0700 Subject: [PATCH 04/34] Add MapboxMapDesign.None getValue() hands the blank style JSON itself; loadStyle and MapInitOptions.styleUri both take a URI or JSON string. --- api/android-for-mapbox.api | 5 +++++ .../java/com/mapconductor/mapbox/MapboxMapDesign.kt | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 1ab909d..38d87f8 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -75,6 +75,11 @@ public final class com.mapconductor.mapbox.MapboxMapDesign$NavigationDay extends public final class com.mapconductor.mapbox.MapboxMapDesign$NavigationNight extends com.mapconductor.mapbox.MapboxMapDesign { public static final com.mapconductor.mapbox.MapboxMapDesign$NavigationNight INSTANCE; } +public final class com.mapconductor.mapbox.MapboxMapDesign$None extends com.mapconductor.mapbox.MapboxMapDesign { + public static final com.mapconductor.mapbox.MapboxMapDesign$None INSTANCE; + public java.lang.String getValue(); + public java.lang.Object getValue(); +} public final class com.mapconductor.mapbox.MapboxMapDesign$Outdoors extends com.mapconductor.mapbox.MapboxMapDesign { public static final com.mapconductor.mapbox.MapboxMapDesign$Outdoors INSTANCE; } diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapDesign.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapDesign.kt index a79ad2c..4032a33 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapDesign.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapDesign.kt @@ -2,6 +2,7 @@ package com.mapconductor.mapbox import com.mapbox.maps.Style import com.mapconductor.core.map.AttributionRule +import com.mapconductor.core.map.BlankMapStyle import com.mapconductor.core.map.MapDesignTypeInterface import com.mapconductor.mapbox.MapboxMapDesign.Companion.MAPBOX_URL @@ -11,6 +12,16 @@ sealed class MapboxMapDesign( override val id: String, override val attributionRules: List = emptyList(), ) : MapboxDesignType { + /** + * ベースマップ無し。背景色だけのスタイルで、タイルは何も取得しない。 + * + * `getValue()` は URI ではなくスタイル JSON そのもの。Mapbox の `loadStyle` と + * `MapInitOptions.styleUri` はどちらも URI か JSON の文字列を受ける。 + */ + object None : MapboxMapDesign("none") { + override fun getValue(): String = BlankMapStyle.JSON + } + object Standard : MapboxMapDesign("standard") object StandardSatellite : MapboxMapDesign("standard-satellite") @@ -43,6 +54,7 @@ sealed class MapboxMapDesign( fun create(layerId: String): MapboxMapDesign = when (layerId) { + None.id -> None Standard.id -> Standard StandardSatellite.id -> StandardSatellite Streets.id -> Streets From 1bf95d1c392a799bee2af6b8bb74acee3fb5b11b Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 30 Sep 2026 15:29:52 -0700 Subject: [PATCH 05/34] Register VectorStyleSupport; add a URI-carrying design --- api/android-for-mapbox.api | 5 + .../mapconductor/mapbox/MapboxMapDesign.kt | 24 +- .../com/mapconductor/mapbox/MapboxMapView.kt | 786 +++++++++--------- 3 files changed, 424 insertions(+), 391 deletions(-) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 38d87f8..35c9a95 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -98,6 +98,11 @@ public final class com.mapconductor.mapbox.MapboxMapDesign$StandardSatellite ext public final class com.mapconductor.mapbox.MapboxMapDesign$Streets extends com.mapconductor.mapbox.MapboxMapDesign { public static final com.mapconductor.mapbox.MapboxMapDesign$Streets INSTANCE; } +public final class com.mapconductor.mapbox.MapboxMapDesign$StyleUri extends com.mapconductor.mapbox.MapboxMapDesign { + public final java.lang.String getUri(); + public java.lang.String getValue(); + public java.lang.Object getValue(); +} public final class com.mapconductor.mapbox.MapboxMapDesignKt { public static final com.mapconductor.core.map.MapDesignTypeInterface toMapDesignType(com.mapbox.maps.Style); } diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapDesign.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapDesign.kt index 4032a33..7d4e69d 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapDesign.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapDesign.kt @@ -47,6 +47,20 @@ sealed class MapboxMapDesign( attributionRules: List = emptyList(), ) : MapboxMapDesign(layerId, attributionRules) + /** + * A style at an arbitrary URI -- `http(s)://`, `asset://`, `file://` -- + * rather than one of Mapbox's own. [Custom] cannot say this: its value is + * always prefixed with [MAPBOX_URL]. The id is the URI itself, so the + * design a loaded style reports back as (see [toMapDesignType]) compares + * equal to the one that was set. + */ + class StyleUri( + val uri: String, + attributionRules: List = emptyList(), + ) : MapboxMapDesign(uri, attributionRules) { + override fun getValue(): String = uri + } + override fun getValue(): String = "${MAPBOX_URL}/${this.id}" companion object { @@ -83,7 +97,11 @@ fun Style.toMapDesignType(): MapboxDesignType = "$MAPBOX_URL/${MapboxMapDesign.NavigationDay.id}" -> MapboxMapDesign.NavigationDay "$MAPBOX_URL/${MapboxMapDesign.NavigationNight.id}" -> MapboxMapDesign.NavigationNight else -> - MapboxMapDesign.Custom( - layerId = this.styleURI.replaceFirst("${MAPBOX_URL}/", ""), - ) + if (this.styleURI.startsWith("$MAPBOX_URL/")) { + MapboxMapDesign.Custom( + layerId = this.styleURI.replaceFirst("${MAPBOX_URL}/", ""), + ) + } else { + MapboxMapDesign.StyleUri(this.styleURI) + } } diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt index 935c2e8..4ef7cee 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt @@ -1,388 +1,398 @@ -package com.mapconductor.mapbox - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.node.Ref -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.mapbox.maps.MapInitOptions -import com.mapbox.maps.MapView -import com.mapconductor.compose.map.MapViewBase -import com.mapconductor.core.OnCameraMoveHandler -import com.mapconductor.core.OnMapEventHandler -import com.mapconductor.core.OnMapLoadedHandler -import com.mapconductor.core.circle.CircleManager -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPositionInterface -import com.mapconductor.core.map.MapProjection -import com.mapconductor.core.map.MutableMapServiceRegistry -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerManager -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -import com.mapconductor.core.marker.MarkerRenderingSupport -import com.mapconductor.core.marker.MarkerRenderingSupportKey -import com.mapconductor.core.marker.MarkerTilingOptions -import com.mapconductor.core.marker.StrategyMarkerController -import com.mapconductor.core.polygon.PolygonManager -import com.mapconductor.core.polyline.PolylineManager -import com.mapconductor.mapbox.circle.MapboxCircleController -import com.mapconductor.mapbox.circle.MapboxCircleLayer -import com.mapconductor.mapbox.circle.MapboxCircleOverlayRenderer -import com.mapconductor.mapbox.groundimage.MapboxGroundImageController -import com.mapconductor.mapbox.groundimage.MapboxGroundImageOverlayRenderer -import com.mapconductor.mapbox.marker.MapboxMarkerController -import com.mapconductor.mapbox.marker.MarkerDragLayer -import com.mapconductor.mapbox.marker.MarkerLayer -import com.mapconductor.mapbox.polygon.MapboxPolygonConductor -import com.mapconductor.mapbox.polygon.MapboxPolygonLayer -import com.mapconductor.mapbox.polygon.MapboxPolygonOverlayRenderer -import com.mapconductor.mapbox.polyline.MapboxPolylineController -import com.mapconductor.mapbox.polyline.MapboxPolylineLayer -import com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer -import com.mapconductor.mapbox.raster.MapboxRasterLayerController -import com.mapconductor.mapbox.raster.MapboxRasterLayerOverlayRenderer -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.view.ViewGroup - -@SuppressLint("Lifecycle") -@Composable -fun MapboxMapView( - state: MapboxViewState, - modifier: Modifier = Modifier, - markerTiling: MarkerTilingOptions? = null, - cameraRestriction: CameraRestriction? = null, - sdkInitialize: (suspend (Context) -> Boolean)? = null, - onMapLoaded: OnMapLoadedHandler? = null, - onMapClick: OnMapEventHandler? = null, - onMapLongClick: OnMapEventHandler? = null, - onCameraMoveStart: OnCameraMoveHandler? = null, - onCameraMove: OnCameraMoveHandler? = null, - onCameraMoveEnd: OnCameraMoveHandler? = null, - projection: MapProjection = MapProjection.Mercator, - content: (@Composable MapboxMapViewScope.() -> Unit)? = null, -) { - val holderRef = remember { Ref() } - val context = LocalContext.current - val controllerRef = remember { Ref() } - val scope = remember { MapboxMapViewScope() } - val registry = remember { scope.buildRegistry() } - val lifecycle = LocalLifecycleOwner.current.lifecycle - val cameraState = remember { mutableStateOf(state.cameraPosition) } - - SideEffect { - controllerRef.value?.setProjection(projection) - } - - MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = { - val cameraOptions = - state.cameraPosition.toCameraOptions() - - val styleUri = state.mapDesignType.getValue() - - val mapOptions = - MapInitOptions( - context = context, - textureView = true, - styleUri = styleUri, - cameraOptions = cameraOptions, - ) - - MapView(context, mapOptions).also { - it.onStart() - } - }, - holderProvider = { mapView -> MapboxMapViewHolder(mapView, mapView.mapboxMap) }, - controllerProvider = { holder -> - // **組み立ては `createMapboxViewController` に一本化してある。** - // React Native のような非 Compose ホストも同じ関数を通るので、 - // ここでコントローラを直接組み直さないこと(片方だけ配線が増えて食い違う)。 - createMapboxViewController( - holder = holder, - markerTiling = markerTiling ?: MarkerTilingOptions.Default, - projection = projection, - serviceRegistry = state.serviceRegistry, - ).also { mapController -> - mapController.setCameraMoveStartListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveStart?.invoke(it) - } - mapController.setCameraMoveListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMove?.invoke(it) - } - mapController.setCameraMoveEndListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveEnd?.invoke(it) - } - mapController.setMapClickListener(onMapClick) - mapController.setMapLongClickListener(onMapLongClick) - mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - cameraRestriction?.let { mapController.setCameraRestriction(it) } - state.setController(mapController) - - holderRef.value = holder - controllerRef.value = mapController - - // Post an initial camera update once the MapView is laid out and style is ready - holder.mapView.post { mapController.sendInitialCameraUpdate() } - } - }, - scope = scope, - registry = registry, - sdkInitialize = { - if (sdkInitialize != null) { - sdkInitialize(context) - } else { - MapboxInitSDK(context) - true - } - }, - onMapLoaded = onMapLoaded, - // Pass content if it needs to be rendered within the overlay providers in MapViewBase, - // or handle it here if it's specific to MapboxMapView structure before calling MapViewBase. - // For now, assuming content relates to overlay definitions. - content = content, // This might need adjustment based on how overlays are handled - customDisposableEffect = { _, holderRef -> - - // HERE specific DisposableEffect logic - DisposableEffect(lifecycle) { - val observer = - object : DefaultLifecycleObserver { - override fun onResume(owner: LifecycleOwner) { - holderRef.value?.mapView?.onResume() - } - - override fun onPause(owner: LifecycleOwner) { - // Do not call here to keep the MapView instance - // holderRef.value?.mapView?.onPause() - } - - @SuppressLint("Lifecycle") - override fun onDestroy(owner: LifecycleOwner) { - val currentHolder = holderRef.value - if (currentHolder != null) { - val activity = context.findActivity() - if (activity?.isChangingConfigurations == true) { - (currentHolder.mapView.parent as? ViewGroup)?.removeView(currentHolder.mapView) - } else { - // Ensure these calls are safe if mapView might be null or already destroyed - currentHolder.mapView.onDestroy() - } - } - } - } - lifecycle.addObserver(observer) - onDispose { - lifecycle.removeObserver(observer) - } - } - }, - ) -} - -/** - * 命令的なコントローラ一式を組み立てる。Compose の [MapboxMapView] と、 - * **Compose を通さないホスト(React Native / Cordova)の両方**から使う。 - * - * ここに置くことで、UI 統合ごとにプロバイダ固有のレイヤ設定を書き写さずに済む。 - * android-for-maplibre の `createMapLibreViewController()` と同じ役割・同じ形。 - * - * @param serviceRegistry 登録先のサービスレジストリ。Compose からは `state.serviceRegistry` を渡す - * (react-sdk / ios-sdk と同じく持ち主は state)。React Native のような非 Compose ホストは - * state を持たないので、自前のレジストリを渡す。 - * **null にすると `MarkerRenderingSupportKey` が登録されず、クラスタリングが - * エラーも出さずに何も描かなくなる。** - */ -fun createMapboxViewController( - holder: MapboxMapViewHolder, - markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, - projection: MapProjection = MapProjection.Mercator, - serviceRegistry: MutableMapServiceRegistry? = null, -): MapboxMapViewController { - val mapController = - MapboxMapViewController( - holder = holder, - projection = projection, - markerController = getMarkerController(holder = holder, markerTiling = markerTiling), - polylineController = getPolylineController(holder), - polygonController = getPolygonController(holder), - groundImageController = getGroundImageController(holder), - circleController = getCircleController(holder), - rasterLayerController = getRasterLayerController(holder), - ) - - serviceRegistry?.put( - MarkerRenderingSupportKey, - object : MarkerRenderingSupport { - override fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer(strategy) - - override fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = - mapController.createMarkerEventController(controller = controller, renderer = renderer) - - override fun registerMarkerEventController( - controller: MarkerEventControllerInterface, - ) { - mapController.registerMarkerEventController(controller) - } - - override fun onMarkerRenderingReady() { - mapController.sendInitialCameraUpdate() - } - }, - ) - - return mapController -} - -internal fun getPolygonController(holder: MapboxMapViewHolder): MapboxPolygonConductor { - val polylineLayer = - MapboxPolylineLayer( - sourceId = "polygon-outline-source", - layerId = "polygon-outline-layer", - ) - val polylineManager = PolylineManager() - val polylineOverlayRenderer = - MapboxPolylineOverlayRenderer( - layer = polylineLayer, - polylineManager = polylineManager, - holder = holder, - ) - - val polygonManager = PolygonManager() - val polygonLayer = - MapboxPolygonLayer( - sourceId = "polygon-fill-source", - layerId = "polygon-fill-layer", - ) - val polygonOverlayRenderer = - MapboxPolygonOverlayRenderer( - layer = polygonLayer, - polygonManager = polygonManager, - holder = holder, - ) - - val conductor = - MapboxPolygonConductor( - polygonOverlay = polygonOverlayRenderer, - polylineOverlay = polylineOverlayRenderer, - ) - return conductor -} - -internal fun getCircleController(holder: MapboxMapViewHolder): MapboxCircleController { - val circleLayer = - MapboxCircleLayer( - sourceId = "circle-source", - layerId = "circle-layer", - ) - val circleManager = CircleManager() - - val renderer = - MapboxCircleOverlayRenderer( - layer = circleLayer, - circleManager = circleManager, - holder = holder, - ) - - val controller = - MapboxCircleController( - renderer = renderer, - ) - return controller -} - -internal fun getPolylineController(holder: MapboxMapViewHolder): MapboxPolylineController { - val polylineLayer = - MapboxPolylineLayer( - sourceId = "polyline-source", - layerId = "polyline-layer", - ) - val polylineManager = PolylineManager() - - val renderer = - MapboxPolylineOverlayRenderer( - layer = polylineLayer, - polylineManager = polylineManager, - holder = holder, - ) - - val controller = - MapboxPolylineController( - renderer = renderer, - ) - return controller -} - -internal fun getMarkerController( - holder: MapboxMapViewHolder, - markerTiling: MarkerTilingOptions, -): MapboxMarkerController { - val manager = - MarkerManager.defaultManager( - minMarkerCount = markerTiling.minMarkerCount, - ) - val markerLayer = - MarkerLayer( - sourceId = "markers-source", - layerId = "markers-layer", - ) - val dragLayer = - MarkerDragLayer( - sourceId = "marker-drag-source", - layerId = "marker-drag-layer", - ) - return MapboxMarkerController.create( - holder = holder, - markerManager = manager, - markerLayer = markerLayer, - dragLayer = dragLayer, - markerTiling = markerTiling, - ) -} - -internal fun getRasterLayerController(holder: MapboxMapViewHolder): MapboxRasterLayerController { - val renderer = - MapboxRasterLayerOverlayRenderer( - holder = holder, - ) - return MapboxRasterLayerController( - renderer = renderer, - ) -} - -internal fun getGroundImageController(holder: MapboxMapViewHolder): MapboxGroundImageController { - val renderer = - MapboxGroundImageOverlayRenderer( - holder = holder, - ) - return MapboxGroundImageController(renderer = renderer) -} - -internal fun Context.findActivity(): Activity? = - when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null - } +package com.mapconductor.mapbox + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.MapView +import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.core.OnCameraMoveHandler +import com.mapconductor.core.OnMapEventHandler +import com.mapconductor.core.OnMapLoadedHandler +import com.mapconductor.core.circle.CircleManager +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapProjection +import com.mapconductor.core.map.MutableMapServiceRegistry +import com.mapconductor.core.map.VectorStyleAsDesign +import com.mapconductor.core.map.VectorStyleSupportKey +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +import com.mapconductor.core.marker.MarkerRenderingSupport +import com.mapconductor.core.marker.MarkerRenderingSupportKey +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.StrategyMarkerController +import com.mapconductor.core.polygon.PolygonManager +import com.mapconductor.core.polyline.PolylineManager +import com.mapconductor.mapbox.circle.MapboxCircleController +import com.mapconductor.mapbox.circle.MapboxCircleLayer +import com.mapconductor.mapbox.circle.MapboxCircleOverlayRenderer +import com.mapconductor.mapbox.groundimage.MapboxGroundImageController +import com.mapconductor.mapbox.groundimage.MapboxGroundImageOverlayRenderer +import com.mapconductor.mapbox.marker.MapboxMarkerController +import com.mapconductor.mapbox.marker.MarkerDragLayer +import com.mapconductor.mapbox.marker.MarkerLayer +import com.mapconductor.mapbox.polygon.MapboxPolygonConductor +import com.mapconductor.mapbox.polygon.MapboxPolygonLayer +import com.mapconductor.mapbox.polygon.MapboxPolygonOverlayRenderer +import com.mapconductor.mapbox.polyline.MapboxPolylineController +import com.mapconductor.mapbox.polyline.MapboxPolylineLayer +import com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer +import com.mapconductor.mapbox.raster.MapboxRasterLayerController +import com.mapconductor.mapbox.raster.MapboxRasterLayerOverlayRenderer +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.ViewGroup + +@SuppressLint("Lifecycle") +@Composable +fun MapboxMapView( + state: MapboxViewState, + modifier: Modifier = Modifier, + markerTiling: MarkerTilingOptions? = null, + cameraRestriction: CameraRestriction? = null, + sdkInitialize: (suspend (Context) -> Boolean)? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMapLongClick: OnMapEventHandler? = null, + onCameraMoveStart: OnCameraMoveHandler? = null, + onCameraMove: OnCameraMoveHandler? = null, + onCameraMoveEnd: OnCameraMoveHandler? = null, + projection: MapProjection = MapProjection.Mercator, + content: (@Composable MapboxMapViewScope.() -> Unit)? = null, +) { + val holderRef = remember { Ref() } + val context = LocalContext.current + val controllerRef = remember { Ref() } + val scope = remember { MapboxMapViewScope() } + val registry = remember { scope.buildRegistry() } + val lifecycle = LocalLifecycleOwner.current.lifecycle + val cameraState = remember { mutableStateOf(state.cameraPosition) } + + SideEffect { + controllerRef.value?.setProjection(projection) + } + + MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = { + val cameraOptions = + state.cameraPosition.toCameraOptions() + + val styleUri = state.mapDesignType.getValue() + + val mapOptions = + MapInitOptions( + context = context, + textureView = true, + styleUri = styleUri, + cameraOptions = cameraOptions, + ) + + MapView(context, mapOptions).also { + it.onStart() + } + }, + holderProvider = { mapView -> MapboxMapViewHolder(mapView, mapView.mapboxMap) }, + controllerProvider = { holder -> + // **組み立ては `createMapboxViewController` に一本化してある。** + // React Native のような非 Compose ホストも同じ関数を通るので、 + // ここでコントローラを直接組み直さないこと(片方だけ配線が増えて食い違う)。 + createMapboxViewController( + holder = holder, + markerTiling = markerTiling ?: MarkerTilingOptions.Default, + projection = projection, + serviceRegistry = state.serviceRegistry, + ).also { mapController -> + mapController.setCameraMoveStartListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveStart?.invoke(it) + } + mapController.setCameraMoveListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMove?.invoke(it) + } + mapController.setCameraMoveEndListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveEnd?.invoke(it) + } + mapController.setMapClickListener(onMapClick) + mapController.setMapLongClickListener(onMapLongClick) + mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + cameraRestriction?.let { mapController.setCameraRestriction(it) } + state.setController(mapController) + // Mapbox draws vector styles natively: a layer with a style to + // show hands it over instead of rasterising it. On the state, + // not in `createMapboxViewController` -- it acts on the + // state's design, which non-Compose hosts do not have. + state.serviceRegistry.put( + VectorStyleSupportKey, + VectorStyleAsDesign(state) { url, rules -> MapboxMapDesign.StyleUri(url, rules) }, + ) + + holderRef.value = holder + controllerRef.value = mapController + + // Post an initial camera update once the MapView is laid out and style is ready + holder.mapView.post { mapController.sendInitialCameraUpdate() } + } + }, + scope = scope, + registry = registry, + sdkInitialize = { + if (sdkInitialize != null) { + sdkInitialize(context) + } else { + MapboxInitSDK(context) + true + } + }, + onMapLoaded = onMapLoaded, + // Pass content if it needs to be rendered within the overlay providers in MapViewBase, + // or handle it here if it's specific to MapboxMapView structure before calling MapViewBase. + // For now, assuming content relates to overlay definitions. + content = content, // This might need adjustment based on how overlays are handled + customDisposableEffect = { _, holderRef -> + + // HERE specific DisposableEffect logic + DisposableEffect(lifecycle) { + val observer = + object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + holderRef.value?.mapView?.onResume() + } + + override fun onPause(owner: LifecycleOwner) { + // Do not call here to keep the MapView instance + // holderRef.value?.mapView?.onPause() + } + + @SuppressLint("Lifecycle") + override fun onDestroy(owner: LifecycleOwner) { + val currentHolder = holderRef.value + if (currentHolder != null) { + val activity = context.findActivity() + if (activity?.isChangingConfigurations == true) { + (currentHolder.mapView.parent as? ViewGroup)?.removeView(currentHolder.mapView) + } else { + // Ensure these calls are safe if mapView might be null or already destroyed + currentHolder.mapView.onDestroy() + } + } + } + } + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } + }, + ) +} + +/** + * 命令的なコントローラ一式を組み立てる。Compose の [MapboxMapView] と、 + * **Compose を通さないホスト(React Native / Cordova)の両方**から使う。 + * + * ここに置くことで、UI 統合ごとにプロバイダ固有のレイヤ設定を書き写さずに済む。 + * android-for-maplibre の `createMapLibreViewController()` と同じ役割・同じ形。 + * + * @param serviceRegistry 登録先のサービスレジストリ。Compose からは `state.serviceRegistry` を渡す + * (react-sdk / ios-sdk と同じく持ち主は state)。React Native のような非 Compose ホストは + * state を持たないので、自前のレジストリを渡す。 + * **null にすると `MarkerRenderingSupportKey` が登録されず、クラスタリングが + * エラーも出さずに何も描かなくなる。** + */ +fun createMapboxViewController( + holder: MapboxMapViewHolder, + markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, + projection: MapProjection = MapProjection.Mercator, + serviceRegistry: MutableMapServiceRegistry? = null, +): MapboxMapViewController { + val mapController = + MapboxMapViewController( + holder = holder, + projection = projection, + markerController = getMarkerController(holder = holder, markerTiling = markerTiling), + polylineController = getPolylineController(holder), + polygonController = getPolygonController(holder), + groundImageController = getGroundImageController(holder), + circleController = getCircleController(holder), + rasterLayerController = getRasterLayerController(holder), + ) + + serviceRegistry?.put( + MarkerRenderingSupportKey, + object : MarkerRenderingSupport { + override fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer(strategy) + + override fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = + mapController.createMarkerEventController(controller = controller, renderer = renderer) + + override fun registerMarkerEventController( + controller: MarkerEventControllerInterface, + ) { + mapController.registerMarkerEventController(controller) + } + + override fun onMarkerRenderingReady() { + mapController.sendInitialCameraUpdate() + } + }, + ) + + return mapController +} + +internal fun getPolygonController(holder: MapboxMapViewHolder): MapboxPolygonConductor { + val polylineLayer = + MapboxPolylineLayer( + sourceId = "polygon-outline-source", + layerId = "polygon-outline-layer", + ) + val polylineManager = PolylineManager() + val polylineOverlayRenderer = + MapboxPolylineOverlayRenderer( + layer = polylineLayer, + polylineManager = polylineManager, + holder = holder, + ) + + val polygonManager = PolygonManager() + val polygonLayer = + MapboxPolygonLayer( + sourceId = "polygon-fill-source", + layerId = "polygon-fill-layer", + ) + val polygonOverlayRenderer = + MapboxPolygonOverlayRenderer( + layer = polygonLayer, + polygonManager = polygonManager, + holder = holder, + ) + + val conductor = + MapboxPolygonConductor( + polygonOverlay = polygonOverlayRenderer, + polylineOverlay = polylineOverlayRenderer, + ) + return conductor +} + +internal fun getCircleController(holder: MapboxMapViewHolder): MapboxCircleController { + val circleLayer = + MapboxCircleLayer( + sourceId = "circle-source", + layerId = "circle-layer", + ) + val circleManager = CircleManager() + + val renderer = + MapboxCircleOverlayRenderer( + layer = circleLayer, + circleManager = circleManager, + holder = holder, + ) + + val controller = + MapboxCircleController( + renderer = renderer, + ) + return controller +} + +internal fun getPolylineController(holder: MapboxMapViewHolder): MapboxPolylineController { + val polylineLayer = + MapboxPolylineLayer( + sourceId = "polyline-source", + layerId = "polyline-layer", + ) + val polylineManager = PolylineManager() + + val renderer = + MapboxPolylineOverlayRenderer( + layer = polylineLayer, + polylineManager = polylineManager, + holder = holder, + ) + + val controller = + MapboxPolylineController( + renderer = renderer, + ) + return controller +} + +internal fun getMarkerController( + holder: MapboxMapViewHolder, + markerTiling: MarkerTilingOptions, +): MapboxMarkerController { + val manager = + MarkerManager.defaultManager( + minMarkerCount = markerTiling.minMarkerCount, + ) + val markerLayer = + MarkerLayer( + sourceId = "markers-source", + layerId = "markers-layer", + ) + val dragLayer = + MarkerDragLayer( + sourceId = "marker-drag-source", + layerId = "marker-drag-layer", + ) + return MapboxMarkerController.create( + holder = holder, + markerManager = manager, + markerLayer = markerLayer, + dragLayer = dragLayer, + markerTiling = markerTiling, + ) +} + +internal fun getRasterLayerController(holder: MapboxMapViewHolder): MapboxRasterLayerController { + val renderer = + MapboxRasterLayerOverlayRenderer( + holder = holder, + ) + return MapboxRasterLayerController( + renderer = renderer, + ) +} + +internal fun getGroundImageController(holder: MapboxMapViewHolder): MapboxGroundImageController { + val renderer = + MapboxGroundImageOverlayRenderer( + holder = holder, + ) + return MapboxGroundImageController(renderer = renderer) +} + +internal fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } From df1071f6e9ef6cff89d9c93307a9d629c3fb0422 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 5 Oct 2026 14:53:06 -0700 Subject: [PATCH 06/34] update dependencies (#12) --- gradle.properties | 2 +- gradle/libs.versions.toml | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gradle.properties b/gradle.properties index 26d9949..c018eb9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true -compileSdk=35 +compileSdk=37 minSdk=26 javaVersion=17 jvmTarget=17 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index beb199c..228daa7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -composeLibs = "1.7.8" +composeLibs = "1.12.1" # material3 は compose-ui とは別のバージョン系列。composeLibs(ui 系)を流用すると # 存在しない material3:1.7.8 を要求してクラスパスごと解決に失敗する。 material3 = "1.4.0" -agp = "9.2.1" -kotlin = "2.0.21" +agp = "9.4.1" +kotlin = "2.4.20" -composeBom = "2025.02.00" -lifecycleRuntimeKtx = "2.8.7" -mapboxAndroid = "11.24.3" -activityCompose = "1.9.3" +composeBom = "2026.09.00" +lifecycleRuntimeKtx = "2.11.0" +mapboxAndroid = "11.32.0" +activityCompose = "1.13.0" ktLint = "14.2.0" junit = "4.13.2" From 163889a62909c68b1a2a94e1ad6b8c2689ab9bfd Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 01:58:30 -0700 Subject: [PATCH 07/34] Update Mapbox Maps to 11.33.0 (#13) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 228daa7..fed587d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ kotlin = "2.4.20" composeBom = "2026.09.00" lifecycleRuntimeKtx = "2.11.0" -mapboxAndroid = "11.32.0" +mapboxAndroid = "11.33.0" activityCompose = "1.13.0" ktLint = "14.2.0" From b27d7fc8d5df252f5e55bd2d124f1953f805d1af Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 10:51:57 -0700 Subject: [PATCH 08/34] Vector style support with in-place style mutations (#14) * feat: integrate vector style support and pending map fixes * 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. --- api/android-for-mapbox.api | 2 +- .../com/mapconductor/mapbox/MapboxMapView.kt | 38 +++++ .../mapbox/MapboxMapViewController.kt | 37 ++++- .../mapbox/MapboxStyleMutationTarget.kt | 142 ++++++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/mapconductor/mapbox/MapboxStyleMutationTarget.kt diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 35c9a95..a3bce19 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -189,7 +189,7 @@ public final class com.mapconductor.mapbox.MapboxMapViewHolder implements com.ma public java.lang.Object getMap(); } public final class com.mapconductor.mapbox.MapboxMapViewKt { - public static final void MapboxMapView(com.mapconductor.mapbox.MapboxViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, 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.MapProjection, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void MapboxMapView(com.mapconductor.mapbox.MapboxViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, 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.MapProjection, 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 com.mapconductor.mapbox.MapboxMapViewController createMapboxViewController(com.mapconductor.mapbox.MapboxMapViewHolder, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.MapProjection, com.mapconductor.core.map.MutableMapServiceRegistry); public static final com.mapconductor.mapbox.polygon.MapboxPolygonConductor getPolygonController(com.mapconductor.mapbox.MapboxMapViewHolder); public static final com.mapconductor.mapbox.circle.MapboxCircleController getCircleController(com.mapconductor.mapbox.MapboxMapViewHolder); diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt index 4ef7cee..f2a0f8d 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt @@ -21,9 +21,13 @@ import com.mapconductor.core.circle.CircleManager import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapProjection +import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.map.MutableMapServiceRegistry +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.MarkerEventControllerInterface import com.mapconductor.core.marker.MarkerManager import com.mapconductor.core.marker.MarkerOverlayRendererInterface @@ -71,6 +75,15 @@ fun MapboxMapView( onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, projection: MapProjection = MapProjection.Mercator, + /** + * 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 MapboxMapViewScope.() -> Unit)? = null, ) { val holderRef = remember { Ref() } @@ -146,6 +159,29 @@ fun MapboxMapView( VectorStyleSupportKey, VectorStyleAsDesign(state) { url, rules -> MapboxMapDesign.StyleUri(url, rules) }, ) + // Mapbox can also be told to change a loaded style in place, + // so adjusting one need not hand it a new document -- which + // would drop its tiles and rebuild every overlay the app + // added. `VectorStyle` looks this up and takes the cheap road + // when it is here. + // + // The style is read through a lambda rather than captured: + // every design change replaces it, and a target holding the + // old one would patch a style the map has already dropped. + val mutations = + StyleMutationStore { list -> + applyStyleMutations( + MapboxStyleMutationTarget( + style = { holder.map.style }, + ready = { mapController.isShowingRequestedStyle() }, + ), + list, + ) + } + state.serviceRegistry.put(VectorStyleMutationSupportKey, mutations) + // Whatever was applied is lost the moment the map loads a + // style again; this is where it goes back on. + mapController.styleLoadedListener = { mutations.onStyleLoaded() } holderRef.value = holder controllerRef.value = mapController @@ -168,6 +204,8 @@ fun MapboxMapView( // Pass content if it needs to be rendered within the overlay providers in MapViewBase, // or handle it here if it's specific to MapboxMapView structure before calling MapViewBase. // For now, assuming content relates to overlay definitions. + style = style, + onStyleDiagnostics = onStyleDiagnostics, content = content, // This might need adjustment based on how overlays are handled customDisposableEffect = { _, holderRef -> diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt index 3542951..e10bc14 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt @@ -208,6 +208,11 @@ class MapboxMapViewController internal constructor( this@MapboxMapViewController.mapDesignType = mapDesign mapDesignTypeChangeListener?.invoke(mapDesign) } + + // Last, once the overlays are back on: a vector style + // adjustment lost everything it set with the old style, and + // this is the moment the new one is known to be complete. + styleLoadedListener?.invoke() } } @@ -358,9 +363,39 @@ class MapboxMapViewController internal constructor( internal var mapDesignTypeChangeListener: MapboxMapDesignTypeChangeHandler? = null + /** + * Called once the map has a style up and the overlays are back on it. + * + * How a vector style adjustment survives a design change. Kept in step + * with `MapLibreViewController.styleLoadedListener`. + */ + internal var styleLoadedListener: (() -> Unit)? = null + + /** The document the map was last asked for, and the one it reported loading. */ + private var requestedStyle: String? = null + private var loadedStyleValue: String? = null + + /** + * Whether the style on screen is the document the map was last asked + * for. + * + * What [MapboxStyleMutationTarget] answers `isReady` with, and not the + * same question as "is a style loaded". A vector style hands the map a + * new document and then applies the deltas compiled for it; in between, + * the map still has the document being replaced, whose layers have + * other names, so every delta came back as a property this map "cannot + * set by name". Mirrors `MapLibreViewController.isShowingRequestedStyle`. + */ + internal fun isShowingRequestedStyle(): Boolean = + holder.map.style != null && loadedStyleValue == requestedStyle + override fun setMapDesignType(value: MapboxDesignType) { + val document = value.getValue() + requestedStyle = document mainCoroutine.launch { - holder.mapView.mapboxMap.loadStyle(value.getValue()) + holder.mapView.mapboxMap.loadStyle(document) { + loadedStyleValue = document + } } } diff --git a/src/main/java/com/mapconductor/mapbox/MapboxStyleMutationTarget.kt b/src/main/java/com/mapconductor/mapbox/MapboxStyleMutationTarget.kt new file mode 100644 index 0000000..43cbe09 --- /dev/null +++ b/src/main/java/com/mapconductor/mapbox/MapboxStyleMutationTarget.kt @@ -0,0 +1,142 @@ +package com.mapconductor.mapbox + +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.None +import com.mapbox.bindgen.Value +import com.mapbox.maps.LayerPosition +import com.mapbox.maps.Style +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.map.StyleMutationTarget + +/** + * Changes a loaded Mapbox style in place. + * + * The seven calls [StyleMutationTarget] asks for, against Mapbox'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 this is shorter than the MapLibre one + * + * Mapbox v11 keeps a generic door open: `setStyleLayerProperty` takes the + * spec's own property name and a `Value`, and `Value.fromJson` builds one + * straight from text. Paint keys, layout keys, `filter`, `minzoom` and + * `maxzoom` all go through that one call, and a whole layer document goes + * through `addStyleLayer`. There is no typed-constructor detour and no + * expression builder, so the compiler's JSON reaches the renderer unread. + * + * MapLibre on Android has no such door, which is why + * `MapLibreStyleMutationTarget` is three times the length for the same seven + * calls. The adapters differ; the decisions above them do not. + */ +@InternalMapConductorApi +internal class MapboxStyleMutationTarget( + private val style: () -> Style?, + /** + * Whether the style on screen is the document these deltas are for. + * + * Not `style() != null`: a vector style hands the map a new document + * and applies the deltas compiled for it straight after, and in between + * the map still has the one being replaced. See + * `MapboxMapViewController.isShowingRequestedStyle`. + */ + private val ready: () -> Boolean, +) : StyleMutationTarget { + override val isReady: Boolean get() = ready() + + override fun hasLayer(layerId: String): Boolean = style()?.styleLayerExists(layerId) == true + + override fun setPaint( + layerId: String, + key: String, + valueJson: String, + ) = setProperty(layerId, key, valueJson) + + // Mapbox does not split paint from layout on this call: both are the + // spec's property name on the layer. + override fun setLayout( + layerId: String, + key: String, + valueJson: String, + ) = setProperty(layerId, key, valueJson) + + override fun setFilter( + layerId: String, + filterJson: String, + ) = setProperty(layerId, "filter", filterJson) + + override fun setZoomRange( + layerId: String, + minZoom: Float, + maxZoom: Float, + ) { + val target = style() ?: return + // Both ends, because the caller always sends both -- a mutation that + // moves one has to restate the other to be undoable. + orThrow("minzoom", target.setStyleLayerProperty(layerId, "minzoom", Value(minZoom.toDouble()))) + orThrow("maxzoom", target.setStyleLayerProperty(layerId, "maxzoom", Value(maxZoom.toDouble()))) + } + + override fun addLayer( + layerId: String, + layerJson: String, + beforeId: String?, + ) { + val target = style() ?: return + // Already there: the style was reloaded and the whole set is going + // back on. + if (target.styleLayerExists(layerId)) return + val layer = valueOf(layerJson) + // `beforeId` is the layer to go *under*, matching the style + // document's own order -- earlier in the list is drawn first. + val position = + beforeId + ?.takeIf { target.styleLayerExists(it) } + ?.let { LayerPosition(null, it, null) } + orThrow("addLayer", target.addStyleLayer(layer, position)) + } + + override fun removeLayer(layerId: String) { + val target = style() ?: return + // Mapbox calls removing an absent layer an error; the contract says + // it is not one. + if (!target.styleLayerExists(layerId)) return + orThrow("removeLayer", target.removeStyleLayer(layerId)) + } + + private fun setProperty( + layerId: String, + key: String, + valueJson: String, + ) { + val target = style() ?: return + orThrow(key, target.setStyleLayerProperty(layerId, key, valueOf(valueJson))) + } + + /** + * A JSON text as the `Value` Mapbox wants. + * + * Literals, expression arrays and whole layer objects all arrive the same + * way, so there is nothing here to decide — unlike MapLibre, which needs + * the value's shape chosen for it. + */ + private fun valueOf(json: String): Value { + val parsed = Value.fromJson(json) + if (parsed.isError) throw IllegalArgumentException("not a style value: ${parsed.error}") + return parsed.value ?: Value.nullValue() + } + + /** + * Mapbox answers with an error rather than throwing, and a refused + * property that went unnoticed would leave the map looking unlike what + * the rules asked for with nothing said. Throwing puts it back on the + * path core already has for this: the mutation comes back unapplied and + * the caller reports it. + */ + private fun orThrow( + what: String, + result: Expected, + ) { + if (result.isError) throw UnsupportedOperationException("$what: ${result.error}") + } +} From fbc5cb11e11769bccfbdcf1f98fdaf394f821d21 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 26 Oct 2026 18:02:54 -0700 Subject: [PATCH 09/34] Test that a ground image survives a map design change Instrumented: a real MapView loads two local style documents through setMapDesignType, and the ground image is checked in the new style (unchanged image, same design set again, removal after the change). The styles have no sources, so a placeholder access token is enough. Android is not affected by the iOS bug (ios-for-maplibre#12): the style-loaded subscription calls groundImageController.reapplyStyle(). Removing that call makes all three fail. --- build.gradle.kts | 6 + .../MapboxGroundImageStyleChangeTest.kt | 142 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 src/androidTest/java/com/mapconductor/mapbox/MapboxGroundImageStyleChangeTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 7b581a1..8071159 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -93,6 +93,12 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + // A real MapView loading real (local) styles: what a design change does to + // the overlays only shows on a map that actually swaps its style. + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.test.manifest) } // Set project version for NMCP plugin diff --git a/src/androidTest/java/com/mapconductor/mapbox/MapboxGroundImageStyleChangeTest.kt b/src/androidTest/java/com/mapconductor/mapbox/MapboxGroundImageStyleChangeTest.kt new file mode 100644 index 0000000..3ad3192 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/mapbox/MapboxGroundImageStyleChangeTest.kt @@ -0,0 +1,142 @@ +package com.mapconductor.mapbox + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.mapbox.common.MapboxOptions +import com.mapbox.maps.Style +import com.mapconductor.compose.groundimage.GroundImage +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.map.AttributionRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A ground image survives the map design changing under it. + * + * Replacing the style throws away every source and layer added at runtime, so + * the SDK has to put the image back into the new style. iOS kept the handle it + * had made for the old style and skipped an unchanged image (ios-for-maplibre + * #12); this checks the Android path — `setMapDesignType` → `loadStyle` → + * `subscribeStyleLoaded` — on a real MapView loading two local style documents. + * + * The styles have no sources, so nothing is fetched and no real access token + * is needed; a placeholder satisfies the SDK's check. + */ +@RunWith(AndroidJUnit4::class) +class MapboxGroundImageStyleChangeTest { + @get:Rule + val compose = createComposeRule() + + private class LocalStyle(override val id: String) : MapboxDesignType { + override val attributionRules: List = emptyList() + + override fun getValue(): String = + """{"version": 8, "name": "$id", "sources": {}, + "layers": [{"id": "background-$id", "type": "background"}]}""" + } + + private val first = LocalStyle("first") + private val second = LocalStyle("second") + + private fun groundImage(): GroundImageState { + val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) } + val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources + return GroundImageState( + bounds = GeoRectBounds(GeoPoint(35.6, 139.6), GeoPoint(35.8, 139.9)), + image = BitmapDrawable(resources, bitmap), + id = "tokyo", + ) + } + + /** The loaded style, when it is the one the given design describes. */ + private fun styleOf(state: MapboxViewState, design: LocalStyle): Style? { + var style: Style? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + style = state.getMapViewHolder()?.map?.style + ?.takeIf { it.isStyleLoaded() && it.styleLayerExists("background-${design.id}") } + } + return style + } + + private fun hasGroundImage(style: Style?): Boolean { + var found = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + found = style != null && + style.styleSourceExists("mc-gimg-src-tokyo") && + style.styleLayerExists("mc-gimg-lyr-tokyo") + } + return found + } + + private var shown by mutableStateOf(true) + + private fun show(state: MapboxViewState, image: GroundImageState) { + compose.setContent { + MapboxMapView( + state = state, + modifier = Modifier.fillMaxSize(), + sdkInitialize = { + MapboxOptions.accessToken = "pk.placeholder-for-local-styles" + true + }, + ) { + if (shown) GroundImage(image) + } + } + } + + @Test + fun anUnchangedGroundImageIsInTheNewStyle() { + val state = MapboxViewState(mapDesignType = first, id = "unchanged") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, first)) } + + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(TIMEOUT) { styleOf(state, second) != null } + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, second)) } + } + + @Test + fun theSameDesignSetAgainKeepsTheImage() { + val state = MapboxViewState(mapDesignType = first, id = "same") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, first)) } + + compose.runOnUiThread { state.mapDesignType = first } + Thread.sleep(SETTLE_MS) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, first)) } + } + + @Test + fun aGroundImageRemovedAfterTheChangeLeavesTheNewStyle() { + val state = MapboxViewState(mapDesignType = first, id = "removed") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, first)) } + + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, second)) } + + compose.runOnUiThread { shown = false } + compose.waitUntil(TIMEOUT) { + val style = styleOf(state, second) + style != null && !hasGroundImage(style) + } + } + + private companion object { + const val TIMEOUT = 20_000L + const val SETTLE_MS = 1_500L + } +} From 97ca8154647ba29b932ccf0d784d940f571bdf3e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 26 Oct 2026 19:12:46 -0700 Subject: [PATCH 10/34] Declare the test libraries in this repo's own catalog CI builds the repository on its own, against gradle/libs.versions.toml here, which had no androidx-runner or ui-test entries; they resolved only in the android-sdk monorepo's catalog the tests were first built with. Compose test artifacts follow this repo's composeLibs (1.12.1). --- gradle/libs.versions.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fed587d..65aa5f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ ktLint = "14.2.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +runner = "1.7.0" [libraries] # Jetpack Compose (with BOM) @@ -42,6 +43,9 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", vers junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "composeLibs" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "composeLibs" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 673cad55aab8f3fa39af6374a67315919656ff8d Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 26 Oct 2026 22:09:15 -0700 Subject: [PATCH 11/34] Expect this library's own test package in ExampleInstrumentedTest The template was copied from android-for-googlemaps and still expected com.mapconductor.googlemaps, so connectedAndroidTest always failed here. A library's instrumented tests run in its test APK, whose package is com.mapconductor.mapbox.test. --- .../java/com/mapconductor/mapbox/ExampleInstrumentedTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/androidTest/java/com/mapconductor/mapbox/ExampleInstrumentedTest.kt b/src/androidTest/java/com/mapconductor/mapbox/ExampleInstrumentedTest.kt index dc43fd6..f6941f2 100644 --- a/src/androidTest/java/com/mapconductor/mapbox/ExampleInstrumentedTest.kt +++ b/src/androidTest/java/com/mapconductor/mapbox/ExampleInstrumentedTest.kt @@ -17,6 +17,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.mapconductor.googlemaps", appContext.packageName) + assertEquals("com.mapconductor.mapbox.test", appContext.packageName) } } From d998d5509a39e80f367be65bce03b6d4ccc7ca43 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 28 Oct 2026 13:47:12 -0700 Subject: [PATCH 12/34] Add .claude/settings.json --- .claude/settings.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..671a084 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "attribution": { + "commit": "", + "pr": "" + } +} From 938a62042864b455f0f291644c1f46273f7f6f0e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 29 Oct 2026 01:37:50 -0700 Subject: [PATCH 13/34] Use the core reapplyStyle() for raster layers MapboxRasterLayerController.reapplyStyle() read the manager outside the controller lock and registered what it read again. Drop it and use the one in android-sdk-core's RasterLayerController (MapConductor/android-sdk-core#17), which runs under the lock. --- api/android-for-mapbox.api | 1 - .../raster/MapboxRasterLayerController.kt | 28 +------------------ 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index a3bce19..3527d83 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -487,7 +487,6 @@ public final class com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRendere public final class com.mapconductor.mapbox.raster.MapboxRasterLayerController extends com.mapconductor.core.raster.RasterLayerController { public com.mapconductor.mapbox.raster.MapboxRasterLayerController(com.mapconductor.core.raster.RasterLayerManagerInterface, com.mapconductor.mapbox.raster.MapboxRasterLayerOverlayRenderer); public com.mapconductor.mapbox.raster.MapboxRasterLayerController(com.mapconductor.core.raster.RasterLayerManagerInterface, com.mapconductor.mapbox.raster.MapboxRasterLayerOverlayRenderer, int, kotlin.jvm.internal.DefaultConstructorMarker); - public final java.lang.Object reapplyStyle(kotlin.coroutines.Continuation); } public final class com.mapconductor.mapbox.raster.MapboxRasterLayerHandle { public com.mapconductor.mapbox.raster.MapboxRasterLayerHandle(java.lang.String, java.lang.String); diff --git a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerController.kt b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerController.kt index 7e65960..df62068 100644 --- a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerController.kt +++ b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerController.kt @@ -1,36 +1,10 @@ package com.mapconductor.mapbox.raster import com.mapconductor.core.raster.RasterLayerController -import com.mapconductor.core.raster.RasterLayerEntity import com.mapconductor.core.raster.RasterLayerManager import com.mapconductor.core.raster.RasterLayerManagerInterface -import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface -import com.mapconductor.core.raster.RasterLayerState class MapboxRasterLayerController( rasterLayerManager: RasterLayerManagerInterface = RasterLayerManager(), renderer: MapboxRasterLayerOverlayRenderer, -) : RasterLayerController(rasterLayerManager, renderer) { - suspend fun reapplyStyle() { - val states = rasterLayerManager.allEntities().map { it.state } - if (states.isEmpty()) return - val addParams = - states.map { state -> - object : RasterLayerOverlayRendererInterface.AddParamsInterface { - override val state: RasterLayerState = state - } - } - val layers = renderer.onAdd(addParams) - layers.forEachIndexed { index, layer -> - layer?.let { - rasterLayerManager.registerEntity( - RasterLayerEntity( - layer = it, - state = states[index], - ), - ) - } - } - renderer.onPostProcess() - } -} +) : RasterLayerController(rasterLayerManager, renderer) From 7f8234dc51a1686707a09780fa688d02bf04a90a Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 14:58:13 -0800 Subject: [PATCH 14/34] Redraw tiles once per burst of tiled-marker changes Same change as android-for-maplibre: relabelling street trees changes a few dozen tiled markers at once, and each update() replaced the raster source, so the map fetched every visible tile again per marker (on MapLibre, Pixel 5a: 45 replacements, 494 tile renders, last tile 6.1 s after the change). - Changes arriving within a frame share one tile refresh; a pending refresh is cancelled on destroy() so it cannot register the tile route again. - The tiling check reads the entity count without copying all entities. - The icon is baked only when the marker is drawn natively. - onPostProcess runs only when a native marker became tiled (it copies every entity to redraw the native ones). --- .../mapbox/marker/MapboxMarkerController.kt | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt index 874cfe4..ee2ba1a 100644 --- a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt +++ b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt @@ -23,6 +23,9 @@ import com.mapconductor.mapbox.MapboxActualMarker import com.mapconductor.mapbox.MapboxMapViewHolder import com.mapconductor.mapbox.toMapCameraPosition import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withPermit @@ -126,11 +129,12 @@ class MapboxMarkerController private constructor( if (currentFinger == prevFinger) return semaphore.withPermit { + // 件数だけが要る。allEntities() は全件の写しを作るので、ラベルの付け替えのように + // 数十本が続けて変わると、そのたびに 14 万件を写すことになる。 val tilingEnabled = - markerTiling.enabled && markerManager.allEntities().size >= markerManager.minMarkerCount + markerTiling.enabled && markerManager.getMemoryStats().entityCount >= markerManager.minMarkerCount val wantsTiled = tilingEnabled && !state.draggable && state.getAnimation() == null val wasTiled = tiledMarkerIds.contains(state.id) - val markerIcon = state.icon?.toBitmapIcon() ?: defaultMarkerIcon if (wantsTiled) { if (!wasTiled) { @@ -148,14 +152,19 @@ class MapboxMarkerController private constructor( tiling = true, ), ) - renderer.onPostProcess() - syncTiledOverlay() + // タイル担当のままなら、ネイティブのマーカーには何も起きていない。onPostProcess は + // 全件を写してネイティブ分を描き直すので(14 万件で 1 回 50ms)、ネイティブから + // タイルへ移ったときだけ呼ぶ。 + if (!wasTiled) renderer.onPostProcess() + scheduleTileRefresh() return@withPermit } if (wasTiled) { tiledMarkerIds.remove(state.id) } + // ネイティブマーカーとして描くときだけ焼き込む。タイル担当なら要らない。 + val markerIcon = state.icon?.toBitmapIcon() ?: defaultMarkerIcon val params = object : MarkerOverlayRendererInterface.ChangeParamsInterface { @@ -186,13 +195,32 @@ class MapboxMarkerController private constructor( renderer.onPostProcess() if (tiledMarkerIds.isNotEmpty()) { - syncTiledOverlay() + scheduleTileRefresh() } else { removeTileOverlay() } } } + private var tileRefreshJob: Job? = null + + /** + * 1 本ずつの変更からの描き直しを 1 回にまとめる。 + * + * ラベルの付け替えのように数十本が続けて変わると、1 本ごとにラスターソースを差し替え、 + * そのたびに地図が見えているタイルを全部取り直していた(MapLibre・Pixel 5a で 45 回、 + * 描き直したタイル 494 枚、最後のタイルまで 6 秒)。続けて来た変更は + * [TILE_REFRESH_COALESCE] の間に受けて、まとめて 1 回だけ差し替える。 + */ + private fun scheduleTileRefresh() { + if (tileRefreshJob?.isActive == true) return + tileRefreshJob = + (renderer as MapboxMarkerOverlayRenderer).coroutine.launch { + delay(TILE_REFRESH_COALESCE) + semaphore.withPermit { syncTiledOverlay() } + } + } + override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { // 判定と昇格は debounce したうえで切り替え器の中で走る(パン中は動かない)。 viewportSwitch.onCameraChanged(mapCameraPosition) @@ -215,6 +243,8 @@ class MapboxMarkerController private constructor( override fun destroy() { viewportSwitch.destroy() + // 待っている描き直しが後から走ると、外したタイル経路をまた登録してしまう。 + tileRefreshJob?.cancel() // Clean up tile server registration // Unregister this map's route only. Never stop the server here: it is // a process-wide singleton shared by all map controllers and overlay @@ -317,6 +347,9 @@ class MapboxMarkerController private constructor( } companion object { + /** 続けて来る変更を 1 回の描き直しにまとめる窓。1 フレーム分。 */ + private val TILE_REFRESH_COALESCE = 16.milliseconds + fun create( holder: MapboxMapViewHolder, markerManager: MarkerManager, From ff50d396fe7acae0212d1fbd3b73245b056b08a5 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 19:25:13 -0800 Subject: [PATCH 15/34] Keep the map design across configuration changes - The saver kept only the id: StyleUri came back as a mapbox://styles/mapbox Custom and attribution rules were lost. It now saves the kind and rules. - On style load the state was replaced by a design rebuilt from the style URI, dropping attribution rules even without a rotation. A report with the same style value now keeps the current design. - Device round-trip test. --- .../mapbox/MapboxMapViewSaverTest.kt | 113 ++++++++++++++++++ .../mapbox/MapboxViewStateImpl.kt | 53 +++++++- 2 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/mapbox/MapboxMapViewSaverTest.kt diff --git a/src/androidTest/java/com/mapconductor/mapbox/MapboxMapViewSaverTest.kt b/src/androidTest/java/com/mapconductor/mapbox/MapboxMapViewSaverTest.kt new file mode 100644 index 0000000..471cb44 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/mapbox/MapboxMapViewSaverTest.kt @@ -0,0 +1,113 @@ +package com.mapconductor.mapbox + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.AttributionRule +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapDesignTypeInterface +import com.mapconductor.core.map.MapUISettings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import android.os.Bundle +import android.os.Parcel + +/** + * 回転などの構成変更で state が [MapboxMapViewSaver] を通って作り直されても、デザイン(id・ + * attributionRules ほか)と uiSettings・カメラが落ちないこと。Bundle は実機の Parcel を一度通す。 + */ +@RunWith(AndroidJUnit4::class) +class MapboxMapViewSaverTest { + private val rules = + listOf(AttributionRule("© test"), AttributionRule("© ranged", minZoom = 3, maxZoom = 15)) + + private val camera = + MapCameraPosition( + position = GeoPoint(21.38, -157.93), + zoom = 12.0, + bearing = 20.0, + tilt = 10.0, + ) + + @Test + fun keepsCustomDesignWithAttributionRules() { + val design = MapboxMapDesign.Custom("my-account/my-style", rules) + val state = MapboxViewState(mapDesignType = design, id = "mapbox-1", cameraPosition = camera) + state.uiSettings = MapUISettings(rotateGesture = false, tiltGesture = false) + + val restored = roundTrip(MapboxMapViewSaver().createSaver(), state) + + val actual = restored.mapDesignType + assertTrue(actual is MapboxMapDesign.Custom) + assertEquals("my-account/my-style", actual.id) + assertEquals(rules, actual.attributionRules) + assertCommon(restored, "mapbox-1") + } + + @Test + fun keepsStyleUriAsStyleUri() { + // 以前は id だけを保存して create(id) で戻しており、URI が mapbox://styles/mapbox/ の + // Custom になっていた。 + val design = MapboxMapDesign.StyleUri("https://example.com/style.json", rules) + val restored = roundTrip(MapboxMapViewSaver().createSaver(), MapboxViewState(design, "mapbox-2")) + + val actual = restored.mapDesignType + assertTrue(actual is MapboxMapDesign.StyleUri) + assertEquals("https://example.com/style.json", actual.getValue()) + assertEquals(rules, actual.attributionRules) + } + + @Test + fun keepsPresetDesignAsTheSameObject() { + val restored = roundTrip(MapboxMapViewSaver().createSaver(), MapboxViewState(MapboxMapDesign.Dark, "mapbox-3")) + assertSame(MapboxMapDesign.Dark, restored.mapDesignType) + } + + @Test + fun appImplementationComesBackWithItsStyle() { + val design = + object : MapDesignTypeInterface { + override val id: String = "app-design" + override val attributionRules: List = rules + + override fun getValue(): String = "${MapboxMapDesign.MAPBOX_URL}/streets-v12" + } + val restored = roundTrip(MapboxMapViewSaver().createSaver(), MapboxViewState(design, "mapbox-4")) + + assertEquals(design.getValue(), restored.mapDesignType.getValue()) + assertEquals(rules, restored.mapDesignType.attributionRules) + } + + private fun assertCommon( + restored: com.mapconductor.core.map.MapViewStateInterface<*>, + id: String, + ) { + assertEquals(id, restored.id) + assertEquals(false, restored.uiSettings.rotateGesture) + assertEquals(false, restored.uiSettings.tiltGesture) + assertEquals(true, restored.uiSettings.scrollGesture) + assertEquals(12.0, restored.cameraPosition.zoom, 1e-9) + assertEquals(20.0, restored.cameraPosition.bearing, 1e-9) + assertEquals(21.38, restored.cameraPosition.position.latitude, 1e-9) + } + + private fun roundTrip( + saver: Saver, + value: T, + ): T { + val saved = with(saver) { SaverScope { true }.save(value) }!! + // 一度 Parcel に書いて読み戻す(プロセスが落ちたあとの復元と同じ経路)。 + val parcel = Parcel.obtain() + try { + parcel.writeBundle(saved) + parcel.setDataPosition(0) + return saver.restore(parcel.readBundle(javaClass.classLoader)!!)!! + } finally { + parcel.recycle() + } + } +} diff --git a/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt b/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt index b2ab6c7..1e0adea 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt @@ -9,6 +9,7 @@ import com.mapconductor.compose.map.BaseMapViewSaver import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapViewState +import com.mapconductor.core.map.MapViewStateBundle import com.mapconductor.core.map.MapViewStateInterface import com.mapconductor.mapbox.MapboxMapDesign.Standard import java.util.UUID @@ -39,6 +40,9 @@ class MapboxViewState( } internal fun onMapDesignTypeChange(value: MapboxDesignType) { + // 地図から読み戻した値はスタイル URI から作ったデザインで、attributionRules を持たない。 + // 同じスタイルなら、アプリが渡した(あるいは復元した)デザインを残す。 + if (value.getValue() == _mapDesignType.getValue()) return _mapDesignType = value } @@ -55,7 +59,20 @@ class MapboxMapViewSaver : BaseMapViewSaver() { state: MapboxViewState, bundle: Bundle, ) { - bundle.putString("id", state.mapDesignType.id) + val design = state.mapDesignType + bundle.putString("id", design.id) + // id だけでは Custom / StyleUri / 定義済みの区別がつかず、StyleUri(URI そのもの)が + // mapbox://styles/mapbox/ の Custom として戻ってしまう。 + val kind = + when (design) { + is MapboxMapDesign.StyleUri -> KIND_STYLE_URI + is MapboxMapDesign.Custom -> KIND_CUSTOM + is MapboxMapDesign -> if (design === MapboxMapDesign.create(design.id)) KIND_PRESET else KIND_VALUE + else -> KIND_VALUE + } + bundle.putString(KEY_KIND, kind) + bundle.putString(KEY_VALUE, design.getValue()) + MapViewStateBundle.putAttributionRules(bundle, design.attributionRules) } override fun createState( @@ -65,16 +82,42 @@ class MapboxMapViewSaver : BaseMapViewSaver() { ): MapboxViewState = MapboxViewState( id = stateId, - mapDesignType = - MapboxMapDesign.create( - layerId = mapDesignBundle?.getString("id") ?: Standard.id, - ), + mapDesignType = restoreDesign(mapDesignBundle), cameraPosition = cameraPosition, ) + private fun restoreDesign(bundle: Bundle?): MapboxMapDesign { + val id = bundle?.getString("id") ?: return Standard + val rules = MapViewStateBundle.getAttributionRules(bundle) + return when (bundle.getString(KEY_KIND)) { + KIND_STYLE_URI -> MapboxMapDesign.StyleUri(uri = id, attributionRules = rules) + KIND_CUSTOM -> MapboxMapDesign.Custom(layerId = id, attributionRules = rules) + KIND_VALUE -> { + // アプリ独自の実装。地図に渡していた値(スタイル URI)から作り直す。 + val value = bundle.getString(KEY_VALUE) ?: id + val prefix = "${MapboxMapDesign.MAPBOX_URL}/" + if (value.startsWith(prefix)) { + MapboxMapDesign.Custom(layerId = value.removePrefix(prefix), attributionRules = rules) + } else { + MapboxMapDesign.StyleUri(uri = value, attributionRules = rules) + } + } + // KIND_PRESET と、kind を持たない以前の形式 + else -> MapboxMapDesign.create(layerId = id) + } + } + override fun getStateId(state: MapboxViewState): String = state.id } +// 公開 API の門番(javap)にコンパニオンの定数が載らないよう、ファイル private にする。 +private const val KEY_KIND = "kind" +private const val KEY_VALUE = "value" +private const val KIND_PRESET = "preset" +private const val KIND_CUSTOM = "custom" +private const val KIND_STYLE_URI = "styleUri" +private const val KIND_VALUE = "value" + @Composable fun rememberMapboxMapViewState( mapDesign: MapboxDesignType = Standard, From 5e594cd07e4531217a91fd3138d3221671a536fb Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 05:40:54 -0800 Subject: [PATCH 16/34] Draw large polygon sets as raster tiles (PolygonTiler) The polygon controller gets a PolygonTiler, whose tiles are shown through the raster layer path the marker tiles use (upsert / removeById, outside the app's RasterLayer composition). Tiled polygons stay registered for taps and give the map SDK nothing. The polygon tile layer goes where the SDK's polygon layers do: over the basemap, under polylines and markers. --- api/android-for-mapbox.api | 2 ++ .../mapbox/MapboxMapViewController.kt | 16 +++++++++ .../mapbox/polygon/MapboxPolygonConductor.kt | 33 +++++++++++++++++++ .../MapboxRasterLayerOverlayRenderer.kt | 33 ++++++++++++++++++- 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 3527d83..fe46994 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -405,6 +405,8 @@ public final class com.mapconductor.mapbox.polygon.MapboxPolygonConductor implem public final com.mapconductor.mapbox.polygon.MapboxPolygonOverlayRenderer getPolygonOverlay(); public final com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer getPolylineOverlay(); public int getZIndex(); + public final com.mapconductor.core.polygon.PolygonTiler getTiler(); + public final void setTiler(com.mapconductor.core.polygon.PolygonTiler); public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); public java.lang.Object update(com.mapconductor.core.polygon.PolygonState, kotlin.coroutines.Continuation); public final void dispatchClick(com.mapconductor.core.polygon.PolygonEvent); diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt index e10bc14..075c5fa 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt @@ -1,5 +1,8 @@ package com.mapconductor.mapbox +import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID +import com.mapconductor.core.polygon.PolygonTiler +import com.mapconductor.core.polygon.polygonTileRasterLayerState import MapboxMapViewControllerInterface import com.mapbox.android.gestures.MoveGestureDetector import com.mapbox.geojson.Point @@ -116,6 +119,19 @@ class MapboxMapViewController internal constructor( markerTileLayers.forEach { entity -> rasterLayerController.removeById(entity.state.id) } } } + + // Many polygons are drawn as raster tiles, shown through the same RasterLayer + // controller as the marker tiles (upsert / removeById, never the app's layers). + polygonController.tiler = + PolygonTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(polygonTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(POLYGON_TILE_LAYER_ID) + } + } + } } override fun moveCamera(position: MapCameraPosition) = handleMoveCamera(position) diff --git a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt index d797101..8038ca5 100644 --- a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt +++ b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt @@ -8,9 +8,12 @@ import com.mapconductor.core.polygon.PolygonEntity import com.mapconductor.core.polygon.PolygonEntityInterface import com.mapconductor.core.polygon.PolygonEvent import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polygon.PolygonTiler import com.mapconductor.core.polyline.PolylineEntity import com.mapconductor.core.polyline.PolylineState import com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class MapboxPolygonConductor( val polygonOverlay: MapboxPolygonOverlayRenderer, @@ -21,6 +24,13 @@ class MapboxPolygonConductor( > { override val zIndex: Int = 2 + /** + * Set by the view controller to draw many polygons as tiles (see + * [PolygonTiler]). Tiled polygons stay in the manager, for taps, and give + * the GeoJSON layers nothing. + */ + var tiler: PolygonTiler? = null + override suspend fun add(data: List) { val nextIds = data.asSequence().map { it.id }.toSet() val prevIds = @@ -36,7 +46,19 @@ class MapboxPolygonConductor( polylineOverlay.polylineManager.removeEntity("outline-$id") } + val tiled = tiler?.route(data) ?: emptySet() + if (tiled.isNotEmpty()) { + // Still registered, so taps resolve against the original shape. + withContext(Dispatchers.Default) { + for (polygonState in data) { + if (polygonState.id !in tiled) continue + polygonOverlay.polygonManager.registerEntity(PolygonEntity(polygon = IN_TILES, state = polygonState)) + polylineOverlay.polylineManager.removeEntity("outline-${polygonState.id}") + } + } + } data.forEach { polygonState -> + if (polygonState.id in tiled) return@forEach polygonOverlay.createPolygon(polygonState)?.let { polygon -> val polygonEntity = PolygonEntity( @@ -61,6 +83,13 @@ class MapboxPolygonConductor( } override suspend fun update(state: PolygonState) { + tiler?.let { tiler -> + if (tiler.isTiled(state.id)) { + polygonOverlay.polygonManager.registerEntity(PolygonEntity(polygon = IN_TILES, state = state)) + tiler.update(state) + return + } + } polygonOverlay.createPolygon(state)?.let { polygon -> val polygonEntity = PolygonEntity( @@ -96,6 +125,7 @@ class MapboxPolygonConductor( polygonOverlay.polygonManager.find(position) as? PolygonEntityInterface override suspend fun clear() { + tiler?.clear() polygonOverlay.polygonManager.clear() polylineOverlay.polylineManager.clear() polygonOverlay.onPostProcess() @@ -133,6 +163,9 @@ class MapboxPolygonConductor( } } +/** What a tiled polygon hands the GeoJSON layers: nothing. It is in the tiles. */ +private val IN_TILES: List = emptyList() + private fun PolygonState.toPolylineState(): PolylineState { val closedPoints = if (points.first() != points.last()) { diff --git a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt index 54c73a1..1509a07 100644 --- a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt @@ -6,6 +6,7 @@ import com.mapbox.maps.extension.style.layers.addLayerBelow import com.mapbox.maps.extension.style.layers.generated.rasterLayer import com.mapbox.maps.extension.style.sources.addSource import com.mapbox.maps.extension.style.sources.generated.rasterSource +import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID import com.mapconductor.core.raster.RasterHeaderRuleSet import com.mapconductor.core.raster.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface @@ -26,6 +27,8 @@ class MapboxRasterLayerOverlayRenderer( private fun isMarkerTileRaster(state: RasterLayerState): Boolean = state.id.startsWith(MARKER_TILE_RASTER_ID_PREFIX) + private fun isPolygonTileRaster(state: RasterLayerState): Boolean = state.id == POLYGON_TILE_LAYER_ID + /** Mapbox Android はタイル要求を書き換える公開 API を持たない。 */ override suspend fun onAdd( data: List, @@ -102,6 +105,8 @@ class MapboxRasterLayerOverlayRenderer( try { if (isMarkerTileRaster(state)) { addLayerForMarkerTile(style, layer) + } else if (isPolygonTileRaster(state)) { + addLayerForPolygonTile(style, layer) } else { addBelowBasemapLabels(style, layer) } @@ -133,6 +138,8 @@ class MapboxRasterLayerOverlayRenderer( try { if (isMarkerTileRaster(state)) { addLayerForMarkerTile(style, layer) + } else if (isPolygonTileRaster(state)) { + addLayerForPolygonTile(style, layer) } else { addBelowBasemapLabels(style, layer) } @@ -182,6 +189,26 @@ class MapboxRasterLayerOverlayRenderer( } } + /** + * The polygon tiles are polygons drawn as images, not imagery: they go + * where the SDK's own polygon layers do, over the basemap and under + * polylines and markers -- below the first of those in the style. + */ + private fun addLayerForPolygonTile( + style: com.mapbox.maps.Style, + layer: com.mapbox.maps.extension.style.layers.Layer, + ) { + val anchor = style.styleLayers.map { it.id }.firstOrNull { id -> POLYGON_TILE_ANCHOR_PREFIXES.any { id.startsWith(it) } } + if (anchor != null) { + try { + style.addLayerBelow(layer, anchor) + return + } catch (_: Exception) { + } + } + style.addLayer(layer) + } + private fun addLayerForMarkerTile( style: com.mapbox.maps.Style, layer: com.mapbox.maps.extension.style.layers.Layer, @@ -208,7 +235,7 @@ class MapboxRasterLayerOverlayRenderer( val ordered = stateById.values .asSequence() - .filter { !isMarkerTileRaster(it) } + .filter { !isMarkerTileRaster(it) && !isPolygonTileRaster(it) } .sortedBy { it.zIndex } .mapNotNull { state -> handleById[state.id]?.let { handle -> state to handle } } .toList() @@ -285,6 +312,10 @@ class MapboxRasterLayerOverlayRenderer( private const val MARKER_TILE_RASTER_ID_PREFIX = "marker-tile-" private const val MARKERS_LAYER_ID = "markers-layer" private const val POLYLINE_LAYER_ID = "polyline-layer" + + /** The SDK's own layers the polygon tiles go below, whichever comes first in the style. */ + private val POLYGON_TILE_ANCHOR_PREFIXES = + listOf("polygon-fill-layer", "polygon-outline-layer", POLYLINE_LAYER_ID, "raster-layer-marker-tile-", MARKERS_LAYER_ID) } } From 4b44c2cc835f5c586efe4a355515baa9b26d1c60 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 13:16:50 -0800 Subject: [PATCH 17/34] Stop and destroy the MapView when the map leaves the composition Only the Activity's real ON_DESTROY destroyed the MapView, so a provider switch left it alive with the controller it reaches. It is now stopped and destroyed through MapViewBase's onDestroyMapView, once. --- .../com/mapconductor/mapbox/MapboxMapView.kt | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt index f2a0f8d..bf8b3cb 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt @@ -58,7 +58,6 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.view.ViewGroup @SuppressLint("Lifecycle") @Composable @@ -221,20 +220,6 @@ fun MapboxMapView( // Do not call here to keep the MapView instance // holderRef.value?.mapView?.onPause() } - - @SuppressLint("Lifecycle") - override fun onDestroy(owner: LifecycleOwner) { - val currentHolder = holderRef.value - if (currentHolder != null) { - val activity = context.findActivity() - if (activity?.isChangingConfigurations == true) { - (currentHolder.mapView.parent as? ViewGroup)?.removeView(currentHolder.mapView) - } else { - // Ensure these calls are safe if mapView might be null or already destroyed - currentHolder.mapView.onDestroy() - } - } - } } lifecycle.addObserver(observer) onDispose { @@ -242,6 +227,13 @@ fun MapboxMapView( } } }, + // Started in viewProvider, so stopped before it is destroyed. A MapView + // left alive keeps its renderer and, through its listeners, the + // controller with every overlay. + onDestroyMapView = { mapView -> + mapView.onStop() + mapView.onDestroy() + }, ) } From 41c525962ac2a368a470130ea9099e8276c4a517 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 15:19:03 -0800 Subject: [PATCH 18/34] Give the MapView its own lifecycle and end it when the map leaves Mapbox's lifecycle plugin registers component callbacks on the Activity and observes the view tree's lifecycle, and lets go only when that lifecycle is destroyed; MapView.onDestroy() does not undo it. Under the Activity's lifecycle every MapView the user switched away from (and through its click listeners the controller) stayed until the Activity went. The MapView now gets a MapViewLifecycleOwner, ended in onDestroyMapView: the plugin then stops and destroys it and unregisters what it registered. --- .../com/mapconductor/mapbox/MapboxMapView.kt | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt index bf8b3cb..847e722 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt @@ -11,9 +11,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner import com.mapbox.maps.MapInitOptions import com.mapbox.maps.MapView import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.compose.map.MapViewLifecycleOwner import com.mapconductor.core.OnCameraMoveHandler import com.mapconductor.core.OnMapEventHandler import com.mapconductor.core.OnMapLoadedHandler @@ -116,6 +119,12 @@ fun MapboxMapView( ) MapView(context, mapOptions).also { + // Mapbox's lifecycle plugin follows the view tree's lifecycle and + // only lets go of the MapView (component callbacks, observers) + // when that lifecycle is destroyed. With the Activity's, every + // MapView the user switched away from would stay until the + // Activity goes, so it gets one of its own; see onDestroyMapView. + it.setViewTreeLifecycleOwner(MapViewLifecycleOwner(lifecycle)) it.onStart() } }, @@ -227,12 +236,18 @@ fun MapboxMapView( } } }, - // Started in viewProvider, so stopped before it is destroyed. A MapView - // left alive keeps its renderer and, through its listeners, the - // controller with every overlay. + // Ending the MapView's own lifecycle has the lifecycle plugin stop and + // destroy it and unregister what it registered. A MapView left alive + // keeps its renderer and, through its listeners, the controller with + // every overlay. onDestroyMapView = { mapView -> - mapView.onStop() - mapView.onDestroy() + val owner = mapView.findViewTreeLifecycleOwner() as? MapViewLifecycleOwner + if (owner != null) { + owner.destroy() + } else { + mapView.onStop() + mapView.onDestroy() + } }, ) } From c8abaee18b57a2654eba694cd58bf8f76b5a8733 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 13:33:12 -0800 Subject: [PATCH 19/34] Dispose the polygon tiler when the conductor is destroyed core's PolygonController.destroy() releases its tiler (02369fa), but this provider's own polygon conductor does not extend it and its destroy() was empty, so the tile server kept every destroyed map's tiled polygons. --- .../mapconductor/mapbox/polygon/MapboxPolygonConductor.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt index 8038ca5..eb6f713 100644 --- a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt +++ b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt @@ -159,7 +159,10 @@ class MapboxPolygonConductor( } override fun destroy() { - // No native resources to clean up for polygons + // The tile server outlives the map; without this its route keeps every + // tiled polygon (and the renderer's copies of them) alive. Same as + // core's PolygonController.destroy(), which this conductor does not extend. + tiler?.dispose() } } From ce827fa51ad421c9379524184675cf2b3c88e3c9 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 16:20:26 -0800 Subject: [PATCH 20/34] Read the controller through the state base, which MapViewBase clears The state kept its own controller field, set on attach and never cleared, so a state the app keeps held the destroyed controller and every overlay of the old map. It now reads the base's attached controller, which MapViewBase detaches when the map is torn down. The polygon conductor's destroy() clears its polygon and outline managers, as core's PolygonController now does. --- .../java/com/mapconductor/mapbox/MapboxViewStateImpl.kt | 6 ++++-- .../mapconductor/mapbox/polygon/MapboxPolygonConductor.kt | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt b/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt index 1e0adea..57fa0f0 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt @@ -23,7 +23,10 @@ class MapboxViewState( cameraPosition: MapCameraPosition = MapCameraPosition.Default, ) : MapViewState(cameraPosition), MapboxViewStateInterface { - private var controller: MapboxMapViewControllerInterface? = null + // Read through the base so that letting go of the controller there (MapViewBase + // does it when the map is torn down) leaves nothing behind here. + private val controller: MapboxMapViewControllerInterface? + get() = attachedMapController as? MapboxMapViewControllerInterface private var _mapDesignType: MapboxDesignType = mapDesignType @@ -35,7 +38,6 @@ class MapboxViewState( get() = _mapDesignType internal fun setController(controller: MapboxMapViewControllerInterface) { - this.controller = controller attachController(controller) } diff --git a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt index d797101..9599813 100644 --- a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt +++ b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt @@ -129,7 +129,10 @@ class MapboxPolygonConductor( } override fun destroy() { - // No native resources to clean up for polygons + // A destroyed conductor can still be referenced (by its map controller, + // or a state that has not let go yet); without this it keeps every entity. + polygonOverlay.polygonManager.clear() + polylineOverlay.polylineManager.clear() } } From d8c0942cac0488a40ebd3b3cf2ddda525b723dd0 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 17:05:15 -0800 Subject: [PATCH 21/34] Extend core PolygonController for Mapbox polygons MapboxPolygonConductor implemented SlottedOverlayController by hand because it drives two renderers (fill + a polyline per outline, since a fill layer cannot draw a stroke wider than 1 px). Every change to core's PolygonController had to be copied over, and it was missed twice. Replace it with MapboxPolygonController, which extends core's PolygonController, and MapboxPolygonCompositeRenderer, which presents the existing fill and outline renderers as one renderer. The fill renderer's onRemove now redraws without the removed polygons, since core's clear() does not call onPostProcess. --- api/android-for-mapbox.api | 41 ++-- .../MapboxPolygonCompositeRenderer.kt.md | 37 +++ docs/polygon/MapboxPolygonConductor.kt.md | 221 ------------------ docs/polygon/MapboxPolygonController.kt.md | 30 +++ .../com/mapconductor/mapbox/MapboxMapView.kt | 18 +- .../mapbox/MapboxMapViewController.kt | 6 +- .../mapbox/MapboxMapViewControllerStyle.kt | 10 +- .../polygon/MapboxPolygonCompositeRenderer.kt | 78 +++++++ .../mapbox/polygon/MapboxPolygonConductor.kt | 152 ------------ .../mapbox/polygon/MapboxPolygonController.kt | 17 ++ .../polygon/MapboxPolygonOverlayRenderer.kt | 10 + 11 files changed, 209 insertions(+), 411 deletions(-) create mode 100644 docs/polygon/MapboxPolygonCompositeRenderer.kt.md delete mode 100644 docs/polygon/MapboxPolygonConductor.kt.md create mode 100644 docs/polygon/MapboxPolygonController.kt.md create mode 100644 src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonCompositeRenderer.kt delete mode 100644 src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt create mode 100644 src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonController.kt diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 3527d83..7e1e793 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -107,8 +107,8 @@ public final class com.mapconductor.mapbox.MapboxMapDesignKt { public static final com.mapconductor.core.map.MapDesignTypeInterface toMapDesignType(com.mapbox.maps.Style); } public final class com.mapconductor.mapbox.MapboxMapViewController extends com.mapconductor.core.controller.BaseMapViewController implements MapboxMapViewControllerInterface,com.mapbox.maps.plugin.gestures.OnMapClickListener,com.mapbox.maps.plugin.gestures.OnMapLongClickListener,com.mapbox.maps.plugin.gestures.OnMoveListener { - public com.mapconductor.mapbox.MapboxMapViewController(com.mapconductor.mapbox.MapboxMapViewHolder, com.mapconductor.core.map.MapProjection, com.mapconductor.mapbox.marker.MapboxMarkerController, com.mapconductor.mapbox.polyline.MapboxPolylineController, com.mapconductor.mapbox.polygon.MapboxPolygonConductor, com.mapconductor.mapbox.groundimage.MapboxGroundImageController, com.mapconductor.mapbox.circle.MapboxCircleController, com.mapconductor.mapbox.raster.MapboxRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope); - public com.mapconductor.mapbox.MapboxMapViewController(com.mapconductor.mapbox.MapboxMapViewHolder, com.mapconductor.core.map.MapProjection, com.mapconductor.mapbox.marker.MapboxMarkerController, com.mapconductor.mapbox.polyline.MapboxPolylineController, com.mapconductor.mapbox.polygon.MapboxPolygonConductor, com.mapconductor.mapbox.groundimage.MapboxGroundImageController, com.mapconductor.mapbox.circle.MapboxCircleController, com.mapconductor.mapbox.raster.MapboxRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.mapbox.MapboxMapViewController(com.mapconductor.mapbox.MapboxMapViewHolder, com.mapconductor.core.map.MapProjection, com.mapconductor.mapbox.marker.MapboxMarkerController, com.mapconductor.mapbox.polyline.MapboxPolylineController, com.mapconductor.mapbox.polygon.MapboxPolygonController, com.mapconductor.mapbox.groundimage.MapboxGroundImageController, com.mapconductor.mapbox.circle.MapboxCircleController, com.mapconductor.mapbox.raster.MapboxRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope); + public com.mapconductor.mapbox.MapboxMapViewController(com.mapconductor.mapbox.MapboxMapViewHolder, com.mapconductor.core.map.MapProjection, com.mapconductor.mapbox.marker.MapboxMarkerController, com.mapconductor.mapbox.polyline.MapboxPolylineController, com.mapconductor.mapbox.polygon.MapboxPolygonController, com.mapconductor.mapbox.groundimage.MapboxGroundImageController, com.mapconductor.mapbox.circle.MapboxCircleController, com.mapconductor.mapbox.raster.MapboxRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.mapbox.MapboxMapViewHolder getHolder(); public kotlinx.coroutines.CoroutineScope getMainCoroutine(); public kotlinx.coroutines.CoroutineScope getDefaultCoroutine(); @@ -191,7 +191,7 @@ public final class com.mapconductor.mapbox.MapboxMapViewHolder implements com.ma public final class com.mapconductor.mapbox.MapboxMapViewKt { public static final void MapboxMapView(com.mapconductor.mapbox.MapboxViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, 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.MapProjection, 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 com.mapconductor.mapbox.MapboxMapViewController createMapboxViewController(com.mapconductor.mapbox.MapboxMapViewHolder, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.MapProjection, com.mapconductor.core.map.MutableMapServiceRegistry); - public static final com.mapconductor.mapbox.polygon.MapboxPolygonConductor getPolygonController(com.mapconductor.mapbox.MapboxMapViewHolder); + public static final com.mapconductor.mapbox.polygon.MapboxPolygonController getPolygonController(com.mapconductor.mapbox.MapboxMapViewHolder); public static final com.mapconductor.mapbox.circle.MapboxCircleController getCircleController(com.mapconductor.mapbox.MapboxMapViewHolder); public static final com.mapconductor.mapbox.polyline.MapboxPolylineController getPolylineController(com.mapconductor.mapbox.MapboxMapViewHolder); public static final com.mapconductor.mapbox.marker.MapboxMarkerController getMarkerController(com.mapconductor.mapbox.MapboxMapViewHolder, com.mapconductor.core.marker.MarkerTilingOptions); @@ -400,27 +400,22 @@ public class com.mapconductor.mapbox.marker.MarkerLayer { public final boolean wouldSkipDraw(java.util.List>); public final void draw(java.util.List>); } -public final class com.mapconductor.mapbox.polygon.MapboxPolygonConductor implements com.mapconductor.core.controller.SlottedOverlayController> { - public com.mapconductor.mapbox.polygon.MapboxPolygonConductor(com.mapconductor.mapbox.polygon.MapboxPolygonOverlayRenderer, com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer); - public final com.mapconductor.mapbox.polygon.MapboxPolygonOverlayRenderer getPolygonOverlay(); - public final com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer getPolylineOverlay(); - public int getZIndex(); - public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); - public java.lang.Object update(com.mapconductor.core.polygon.PolygonState, kotlin.coroutines.Continuation); - public final void dispatchClick(com.mapconductor.core.polygon.PolygonEvent); - public final kotlin.jvm.functions.Function1 getClickListener(); - public final void setClickListener(kotlin.jvm.functions.Function1); - public com.mapconductor.core.polygon.PolygonEntityInterface find(com.mapconductor.core.features.GeoPointInterface); - public java.lang.Object clear(kotlin.coroutines.Continuation); - public com.mapconductor.core.controller.OverlayKind getKind(); - public boolean has(java.lang.String); - public void setClickListenerAny(java.lang.Object); - public com.mapconductor.core.controller.OverlayHit resolveTap(com.mapconductor.core.features.GeoPointInterface); - public void destroy(); - public java.lang.Object update(java.lang.Object, kotlin.coroutines.Continuation); - public java.lang.Object find(com.mapconductor.core.features.GeoPointInterface); +public final class com.mapconductor.mapbox.polygon.MapboxPolygonCompositeRenderer implements com.mapconductor.core.polygon.PolygonOverlayRendererInterface> { + public com.mapconductor.mapbox.polygon.MapboxPolygonCompositeRenderer(com.mapconductor.mapbox.polygon.MapboxPolygonOverlayRenderer, com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer); + public final com.mapconductor.mapbox.polygon.MapboxPolygonOverlayRenderer getFill(); + public final com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer getOutline(); + public java.lang.Object onAdd(java.util.List, kotlin.coroutines.Continuation>>); + public java.lang.Object onChange(java.util.List>>, kotlin.coroutines.Continuation>>); + public java.lang.Object onRemove(java.util.List>>, kotlin.coroutines.Continuation); + public java.lang.Object onPostProcess(kotlin.coroutines.Continuation); } -public final class com.mapconductor.mapbox.polygon.MapboxPolygonConductorKt { +public final class com.mapconductor.mapbox.polygon.MapboxPolygonCompositeRendererKt { +} +public final class com.mapconductor.mapbox.polygon.MapboxPolygonController extends com.mapconductor.core.polygon.PolygonController> { + public com.mapconductor.mapbox.polygon.MapboxPolygonController(com.mapconductor.mapbox.polygon.MapboxPolygonCompositeRenderer); + public com.mapconductor.mapbox.polygon.MapboxPolygonCompositeRenderer getRenderer(); + public int getZIndex(); + public com.mapconductor.core.polygon.PolygonOverlayRendererInterface getRenderer(); } public final class com.mapconductor.mapbox.polygon.MapboxPolygonLayer { public com.mapconductor.mapbox.polygon.MapboxPolygonLayer(java.lang.String, java.lang.String); diff --git a/docs/polygon/MapboxPolygonCompositeRenderer.kt.md b/docs/polygon/MapboxPolygonCompositeRenderer.kt.md new file mode 100644 index 0000000..4542f73 --- /dev/null +++ b/docs/polygon/MapboxPolygonCompositeRenderer.kt.md @@ -0,0 +1,37 @@ +# MapboxPolygonCompositeRenderer + +## Signature + +```kotlin +class MapboxPolygonCompositeRenderer( + val fill: MapboxPolygonOverlayRenderer, + val outline: MapboxPolylineOverlayRenderer, +) : PolygonOverlayRendererInterface +``` + +## Description + +Presents the fill renderer and the outline renderer to the core `PolygonController` as a single +renderer. + +A Mapbox fill layer can only draw a 1 px outline with no width, so to honour `strokeWidth` each +polygon's outer ring is also drawn as a closed polyline with the id `outline-` through +`outline`. Every call the controller makes on this renderer is forwarded to `fill` and mirrored on +`outline`: + +- `onAdd` / `onChange`: the fill is created or rebuilt; when that succeeds, the outline polyline is + (re)created with the polygon's `strokeColor`, `strokeWidth`, `geodesic`, `zIndex` and `extra`. +- `onRemove`: the outline polylines are dropped and both sources are redrawn without the removed + polygons (the controller's `clear()` does not call `onPostProcess`). +- `onPostProcess`: both sources are redrawn. + +Holes are not outlined. + +## Constructor Parameters + +- `fill` + - Type: `MapboxPolygonOverlayRenderer` + - Description: Draws the polygon fill (with holes) into the fill source. +- `outline` + - Type: `MapboxPolylineOverlayRenderer` + - Description: Draws the outlines into the polygon outline source. diff --git a/docs/polygon/MapboxPolygonConductor.kt.md b/docs/polygon/MapboxPolygonConductor.kt.md deleted file mode 100644 index 3002a71..0000000 --- a/docs/polygon/MapboxPolygonConductor.kt.md +++ /dev/null @@ -1,221 +0,0 @@ -# MapboxPolygonConductor - -## Class: `MapboxPolygonConductor` - -### Description - -The `MapboxPolygonConductor` class is a controller responsible for managing and rendering polygon -overlays on a Mapbox map. It implements the `OverlayControllerInterface` to handle the lifecycle of -polygon features, including their filled areas and outlines. - -This conductor uses two distinct renderers: one for the polygon fills -(`MapboxPolygonOverlayRenderer`) and another for the polyline outlines -(`MapboxPolylineOverlayRenderer`). It orchestrates these renderers to add, update, clear, and find -polygons, as well as to handle user interactions like clicks. - -### Signature - -```kotlin -class MapboxPolygonConductor( - val polygonOverlay: MapboxPolygonOverlayRenderer, - val polylineOverlay: MapboxPolylineOverlayRenderer, -) : OverlayControllerInterface< - PolygonState, - PolygonEntityInterface, - PolygonEvent, - > -``` - -### Parameters - -- `polygonOverlay` - - Type: `MapboxPolygonOverlayRenderer` - - Description: The renderer responsible for drawing the filled area of the polygons. -- `polylineOverlay` - - Type: `MapboxPolylineOverlayRenderer` - - Description: The renderer responsible for drawing the outlines (strokes) of the polygons. - -## Properties - -### zIndex - -The stacking order of this overlay controller on the map. Overlays with higher z-indices are drawn -on top of those with lower indices. - -**Signature** -```kotlin -override val zIndex: Int = 2 -``` - -### clickListener - -A callback function that is invoked when any polygon managed by this conductor is clicked. This -provides a centralized way to handle click events for all polygons. - -**Signature** -```kotlin -override var clickListener: ((PolygonEvent) -> Unit)? = null -``` - -## Methods - -### add - -Adds a list of polygons to the map. This method performs a diffing operation: it removes any -previously added polygons that are not in the new `data` list and then adds or updates the polygons -specified in the list. For each polygon, it also creates a corresponding closed polyline to serve as -its outline. - -**Signature** -```kotlin -override suspend fun add(data: List) -``` - -**Parameters** - -- `data` - - Type: `List` - - Description: A list of `PolygonState` objects representing the polygons to be displayed on the - map. - -### update - -Adds or updates a single polygon on the map. If a polygon with the same ID already exists, it will -be replaced with the new state. A corresponding outline is also created or updated. - -**Signature** -```kotlin -override suspend fun update(state: PolygonState) -``` - -**Parameters** - -- `state` - - Type: `PolygonState` - - Description: The state of the single polygon to add or update. - -### dispatchClick - -Dispatches a click event. This method is typically called by the underlying map framework when a tap -is detected on a polygon. It triggers two callbacks in order: -1. The `onClick` handler defined within the specific `PolygonState` of the clicked polygon. -2. The global `clickListener` set on this `MapboxPolygonConductor` instance. - -**Signature** -```kotlin -fun dispatchClick(event: PolygonEvent) -``` - -**Parameters** - -- `event` - - Type: `PolygonEvent` - - Description: The event object containing details about the click, including the state of the - clicked polygon. - -### find - -Finds the topmost polygon entity at a given geographic coordinate. - -**Signature** -```kotlin -override fun find(position: GeoPointInterface): PolygonEntityInterface? -``` - -**Parameters** - -- `position` - - Type: `GeoPointInterface` - - Description: The geographic coordinates (latitude and longitude) to search at. - -**Returns** - -`PolygonEntityInterface?` — The found polygon entity, or `null` if no polygon exists -at the specified position. - -### clear - -Removes all polygons and their associated outlines from the map that are managed by this conductor. - -**Signature** -```kotlin -override suspend fun clear() -``` - -### onCameraChanged - -A lifecycle method called when the map's camera position changes. This implementation is currently -empty as no action is required for polygons on camera change. - -**Signature** -```kotlin -override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) -``` - -**Parameters** - -- `mapCameraPosition` - - Type: `MapCameraPosition` - - Description: The new camera position of the map. - -### destroy - -Cleans up resources used by the controller. This implementation is empty as there are no specific -native resources to release for polygons. - -**Signature** -```kotlin -override fun destroy() -``` - -## Example - -Here is an example of how to instantiate `MapboxPolygonConductor` and use it to manage polygons on a -map. - -```kotlin -// Assume polygonOverlay and polylineOverlay are already initialized -val polygonConductor = MapboxPolygonConductor(polygonOverlay, polylineOverlay) - -// Set a global click listener for all polygons -polygonConductor.clickListener = { event -> - println("Polygon with ID ${event.state.id} was clicked!") -} - -// Define the state for two polygons -val polygon1State = PolygonState( - id = "p1", - points = listOf( - GeoPoint(40.7128, -74.0060), // New York - GeoPoint(34.0522, -118.2437), // Los Angeles - GeoPoint(29.7604, -95.3698) // Houston - ), - fillColor = Color.argb(100, 0, 0, 255), // Semi-transparent blue - strokeColor = Color.BLUE, - strokeWidth = 2f, - onClick = { event -> - println("Specific onClick for polygon ${event.state.id}") - } -) - -val polygon2State = PolygonState( - id = "p2", - points = listOf( - GeoPoint(41.8781, -87.6298), // Chicago - GeoPoint(39.9526, -75.1652), // Philadelphia - GeoPoint(33.4484, -112.0740) // Phoenix - ), - fillColor = Color.argb(100, 255, 0, 0) // Semi-transparent red -) - -// Add the polygons to the map -// This must be called from a coroutine scope -coroutineScope.launch { - polygonConductor.add(listOf(polygon1State, polygon2State)) -} - -// ... later, to remove all polygons -coroutineScope.launch { - polygonConductor.clear() -} -``` diff --git a/docs/polygon/MapboxPolygonController.kt.md b/docs/polygon/MapboxPolygonController.kt.md new file mode 100644 index 0000000..5327ef3 --- /dev/null +++ b/docs/polygon/MapboxPolygonController.kt.md @@ -0,0 +1,30 @@ +# MapboxPolygonController + +## Signature + +```kotlin +class MapboxPolygonController( + override val renderer: MapboxPolygonCompositeRenderer, +) : PolygonController(renderer.fill.polygonManager, renderer) +``` + +## Description + +The polygon controller of the Mapbox provider. It extends the core `PolygonController`, so diffing +(add / change / remove), `has`, `find`, the click cascade (`resolveTap`, `dispatchClick`), +`clear` and `destroy` are the core implementation shared with every other provider. + +The only provider-specific part is the renderer: a fill layer can only draw a 1 px outline, so the +outline is drawn as a separate polyline by [`MapboxPolygonCompositeRenderer`](./MapboxPolygonCompositeRenderer.kt.md). + +## Constructor Parameters + +- `renderer` + - Type: `MapboxPolygonCompositeRenderer` + - Description: Draws both the fill and the outline. Its `fill.polygonManager` is used as the + controller's polygon manager, so the controller and the fill renderer always see the same + entities. + +## Properties + +- `zIndex`: `2` (unchanged from the former `MapboxPolygonConductor`). diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt index f2a0f8d..dd8ef22 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt @@ -46,7 +46,8 @@ import com.mapconductor.mapbox.groundimage.MapboxGroundImageOverlayRenderer import com.mapconductor.mapbox.marker.MapboxMarkerController import com.mapconductor.mapbox.marker.MarkerDragLayer import com.mapconductor.mapbox.marker.MarkerLayer -import com.mapconductor.mapbox.polygon.MapboxPolygonConductor +import com.mapconductor.mapbox.polygon.MapboxPolygonCompositeRenderer +import com.mapconductor.mapbox.polygon.MapboxPolygonController import com.mapconductor.mapbox.polygon.MapboxPolygonLayer import com.mapconductor.mapbox.polygon.MapboxPolygonOverlayRenderer import com.mapconductor.mapbox.polyline.MapboxPolylineController @@ -304,7 +305,7 @@ fun createMapboxViewController( return mapController } -internal fun getPolygonController(holder: MapboxMapViewHolder): MapboxPolygonConductor { +internal fun getPolygonController(holder: MapboxMapViewHolder): MapboxPolygonController { val polylineLayer = MapboxPolylineLayer( sourceId = "polygon-outline-source", @@ -331,12 +332,15 @@ internal fun getPolygonController(holder: MapboxMapViewHolder): MapboxPolygonCon holder = holder, ) - val conductor = - MapboxPolygonConductor( - polygonOverlay = polygonOverlayRenderer, - polylineOverlay = polylineOverlayRenderer, + val controller = + MapboxPolygonController( + renderer = + MapboxPolygonCompositeRenderer( + fill = polygonOverlayRenderer, + outline = polylineOverlayRenderer, + ), ) - return conductor + return controller } internal fun getCircleController(holder: MapboxMapViewHolder): MapboxCircleController { diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt index e10bc14..e305c46 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt @@ -43,7 +43,7 @@ import com.mapconductor.mapbox.marker.MapboxMarkerController import com.mapconductor.mapbox.marker.MapboxMarkerOverlayRenderer import com.mapconductor.mapbox.marker.MarkerDragLayer import com.mapconductor.mapbox.marker.MarkerLayer -import com.mapconductor.mapbox.polygon.MapboxPolygonConductor +import com.mapconductor.mapbox.polygon.MapboxPolygonController import com.mapconductor.mapbox.polyline.MapboxPolylineController import com.mapconductor.mapbox.raster.MapboxRasterLayerController import java.util.UUID @@ -68,7 +68,7 @@ class MapboxMapViewController internal constructor( internal var projection: MapProjection, internal val markerController: MapboxMarkerController, internal val polylineController: MapboxPolylineController, - internal val polygonController: MapboxPolygonConductor, + internal val polygonController: MapboxPolygonController, internal val groundImageController: MapboxGroundImageController, internal val circleController: MapboxCircleController, internal val rasterLayerController: MapboxRasterLayerController, @@ -298,7 +298,7 @@ class MapboxMapViewController internal constructor( .hasEntity(state.id) override fun hasPolygon(state: PolygonState): Boolean = - this.polygonController.polygonOverlay.polygonManager + this.polygonController.polygonManager .hasEntity(state.id) override fun applyUISettings(settings: MapUISettings) { diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerStyle.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerStyle.kt index 5137b98..cbbc2d0 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerStyle.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerStyle.kt @@ -37,8 +37,8 @@ internal fun MapboxMapViewController.attachMarkerLayers( internal fun MapboxMapViewController.attachOverlaySourcesAndLayers(style: com.mapbox.maps.Style) { // Polygon sources only (z-indexed layers added below) - style.addSource(polygonController.polylineOverlay.layer.source) - style.addSource(polygonController.polygonOverlay.layer.source) + style.addSource(polygonController.renderer.outline.layer.source) + style.addSource(polygonController.renderer.fill.layer.source) // Circle fill (fill pass) then stroke (line pass) — both below general polyline style.addSource(circleController.renderer.layer.source) @@ -61,12 +61,12 @@ internal fun MapboxMapViewController.attachOverlaySourcesAndLayers(style: com.ma } internal fun MapboxMapViewController.ensurePolygonZLayers(style: com.mapbox.maps.Style) { - val fillSourceId = polygonController.polygonOverlay.layer.sourceId - val outlineSourceId = polygonController.polylineOverlay.layer.sourceId + val fillSourceId = polygonController.renderer.fill.layer.sourceId + val outlineSourceId = polygonController.renderer.outline.layer.sourceId val anchorId = polylineController.renderer.layer.layerId val zSet = - polygonController.polygonOverlay.polygonManager + polygonController.polygonManager .allEntities() .map { it.state.zIndex } .toSet() diff --git a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonCompositeRenderer.kt b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonCompositeRenderer.kt new file mode 100644 index 0000000..6bf3565 --- /dev/null +++ b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonCompositeRenderer.kt @@ -0,0 +1,78 @@ +package com.mapconductor.mapbox.polygon + +import com.mapconductor.core.polygon.PolygonEntityInterface +import com.mapconductor.core.polygon.PolygonOverlayRendererInterface +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.PolylineEntity +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.mapbox.MapboxActualPolygon +import com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer + +/** + * 塗りと輪郭を 1 つのレンダラとして [com.mapconductor.core.polygon.PolygonController] に渡すための合成レンダラ。 + * + * fill レイヤーは幅 1 px の輪郭しか描けないので、`strokeWidth` を守るために輪郭は + * 別のポリライン(`outline-`)として [outline] で描く。差分計算はコアの + * PolygonController に任せ、ここは「塗りへの指示を輪郭にも写す」ことだけをする。 + * + * 塗りのエンティティはコントローラと同じ [fill] の polygonManager に載る。輪郭は + * [outline] の polylineManager に載り、コントローラからは見えない。 + */ +class MapboxPolygonCompositeRenderer( + val fill: MapboxPolygonOverlayRenderer, + val outline: MapboxPolylineOverlayRenderer, +) : PolygonOverlayRendererInterface { + override suspend fun onAdd(data: List): List { + val polygons = fill.onAdd(data) + data.forEachIndexed { index, params -> + if (polygons[index] != null) putOutline(params.state) + } + return polygons + } + + override suspend fun onChange( + data: List>, + ): List { + val polygons = fill.onChange(data) + data.forEachIndexed { index, params -> + if (polygons[index] != null) putOutline(params.current.state) + } + return polygons + } + + override suspend fun onRemove(data: List>) { + data.forEach { outline.polylineManager.removeEntity(outlineId(it.state.id)) } + // clear() は onPostProcess を呼ばないので、消した結果はここで描き直す。 + fill.onRemove(data) + outline.onPostProcess() + } + + override suspend fun onPostProcess() { + fill.onPostProcess() + outline.onPostProcess() + } + + private suspend fun putOutline(state: PolygonState) { + val outlineState = state.toOutlineState() ?: return + outline.createPolyline(outlineState)?.let { polyline -> + outline.polylineManager.registerEntity(PolylineEntity(polyline = polyline, state = outlineState)) + } + } +} + +private fun outlineId(polygonId: String): String = "outline-$polygonId" + +/** 外周だけを閉じたポリラインにする(穴の輪郭は描かない)。 */ +private fun PolygonState.toOutlineState(): PolylineState? { + if (points.isEmpty()) return null + val closedPoints = if (points.first() != points.last()) points + points.first() else points + return PolylineState( + points = closedPoints, + id = outlineId(id), + strokeColor = strokeColor, + strokeWidth = strokeWidth, + geodesic = geodesic, + zIndex = zIndex, + extra = extra, + ) +} diff --git a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt deleted file mode 100644 index d797101..0000000 --- a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.mapconductor.mapbox.polygon - -import com.mapconductor.core.controller.OverlayHit -import com.mapconductor.core.controller.OverlayKind -import com.mapconductor.core.controller.SlottedOverlayController -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.polygon.PolygonEntity -import com.mapconductor.core.polygon.PolygonEntityInterface -import com.mapconductor.core.polygon.PolygonEvent -import com.mapconductor.core.polygon.PolygonState -import com.mapconductor.core.polyline.PolylineEntity -import com.mapconductor.core.polyline.PolylineState -import com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer - -class MapboxPolygonConductor( - val polygonOverlay: MapboxPolygonOverlayRenderer, - val polylineOverlay: MapboxPolylineOverlayRenderer, -) : SlottedOverlayController< - PolygonState, - PolygonEntityInterface, - > { - override val zIndex: Int = 2 - - override suspend fun add(data: List) { - val nextIds = data.asSequence().map { it.id }.toSet() - val prevIds = - polygonOverlay.polygonManager - .allEntities() - .asSequence() - .map { it.state.id } - .toSet() - val removeIds = prevIds - nextIds - - removeIds.forEach { id -> - polygonOverlay.polygonManager.removeEntity(id) - polylineOverlay.polylineManager.removeEntity("outline-$id") - } - - data.forEach { polygonState -> - polygonOverlay.createPolygon(polygonState)?.let { polygon -> - val polygonEntity = - PolygonEntity( - polygon = polygon, - state = polygonState, - ) - polygonOverlay.polygonManager.registerEntity(polygonEntity) - } - - val polylineState = polygonState.toPolylineState() - polylineOverlay.createPolyline(polylineState)?.let { polyline -> - val polylineEntity = - PolylineEntity( - polyline = polyline, - state = polylineState, - ) - polylineOverlay.polylineManager.registerEntity(polylineEntity) - } - } - polygonOverlay.onPostProcess() - polylineOverlay.onPostProcess() - } - - override suspend fun update(state: PolygonState) { - polygonOverlay.createPolygon(state)?.let { polygon -> - val polygonEntity = - PolygonEntity( - polygon = polygon, - state = state, - ) - polygonOverlay.polygonManager.registerEntity(polygonEntity) - } - - val polylineState = state.toPolylineState() - polylineOverlay.createPolyline(polylineState)?.let { polyline -> - val polylineEntity = - PolylineEntity( - polyline = polyline, - state = polylineState, - ) - polylineOverlay.polylineManager.registerEntity(polylineEntity) - } - polygonOverlay.onPostProcess() - polylineOverlay.onPostProcess() - } - - fun dispatchClick(event: PolygonEvent) { - // 配送座標の wrap は PolygonEvent の生成時に一元化済み。 - event.state.onClick?.invoke(event) - clickListener?.invoke(event) - } - - var clickListener: ((PolygonEvent) -> Unit)? = null - - @Suppress("UNCHECKED_CAST") - override fun find(position: GeoPointInterface): PolygonEntityInterface? = - polygonOverlay.polygonManager.find(position) as? PolygonEntityInterface - - override suspend fun clear() { - polygonOverlay.polygonManager.clear() - polylineOverlay.polylineManager.clear() - polygonOverlay.onPostProcess() - polylineOverlay.onPostProcess() - } - - // ── Capable ファサードのスロットとクリックカスケードへの参加 ────────── - // - // このコンダクタはコアの PolygonController を継承していない(ポリゴンの輪郭を - // ポリラインとして描くために polygon と polyline の 2 レンダラを束ねている)。 - // それでも扱いは他プロバイダのポリゴンと同じでなければならないので、 - // スロットの契約はここで自前で満たす。 - // - // 実際、Step 4 でこれを実装し忘れて `hasPolygon` が常に false になり、 - // ポリゴン単体の状態更新が黙って捨てられていた。 - - override val kind: OverlayKind = OverlayKind.Polygon - - override fun has(id: String): Boolean = polygonOverlay.polygonManager.hasEntity(id) - - @Suppress("UNCHECKED_CAST") - override fun setClickListenerAny(listener: Any?) { - clickListener = listener as? ((PolygonEvent) -> Unit) - } - - override fun resolveTap(position: GeoPointInterface): OverlayHit? = - find(position)?.let { entity -> - OverlayHit(OverlayKind.Polygon, position) { - dispatchClick(PolygonEvent(entity.state, position)) - } - } - - override fun destroy() { - // No native resources to clean up for polygons - } -} - -private fun PolygonState.toPolylineState(): PolylineState { - val closedPoints = - if (points.first() != points.last()) { - points + points.first() - } else { - points - } - return PolylineState( - points = closedPoints, - id = "outline-${this.id}", - strokeColor = this.strokeColor, - strokeWidth = this.strokeWidth, - geodesic = this.geodesic, - zIndex = this.zIndex, - extra = this.extra, - ) -} diff --git a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonController.kt b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonController.kt new file mode 100644 index 0000000..e67ac2c --- /dev/null +++ b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonController.kt @@ -0,0 +1,17 @@ +package com.mapconductor.mapbox.polygon + +import com.mapconductor.core.polygon.PolygonController +import com.mapconductor.mapbox.MapboxActualPolygon + +/** + * Mapbox 用のポリゴンコントローラ。 + * + * 追加・更新・削除の差分計算、`has`、クリックカスケードはコアの [PolygonController] が担う。 + * 塗りと輪郭(幅のある LineLayer)の 2 系統の描画は [MapboxPolygonCompositeRenderer] が束ねる。 + */ +class MapboxPolygonController( + override val renderer: MapboxPolygonCompositeRenderer, +) : PolygonController(renderer.fill.polygonManager, renderer) { + // 旧 MapboxPolygonConductor と同じ値を保つ。 + override val zIndex: Int = 2 +} diff --git a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonOverlayRenderer.kt index 51a5bc8..853670c 100644 --- a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonOverlayRenderer.kt @@ -17,7 +17,10 @@ class MapboxPolygonOverlayRenderer( override val holder: MapboxMapViewHolder, override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), ) : AbstractPolygonOverlayRenderer() { + // 消えた分を除いた残りで描き直す。コントローラはこの呼び出しの後で polygonManager から + // 外すので(clear() では onPostProcess も来ない)、ここで除外しておかないと消えずに残る。 override suspend fun onRemove(data: List>) { + layer.draw(getRemainingPolygonEntities(data)) } override suspend fun onPostProcess() { @@ -61,4 +64,11 @@ class MapboxPolygonOverlayRenderer( private fun getAllPolygonEntities(): List> = polygonManager.allEntities() + + private fun getRemainingPolygonEntities( + removed: List>, + ): List> { + val removedIds = removed.mapTo(HashSet()) { it.state.id } + return polygonManager.allEntities().filter { it.state.id !in removedIds } + } } From 6aacb1b71ad1c85735eed0471eb0fcd3efff6935 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:07:32 -0800 Subject: [PATCH 22/34] Polygon conductor: move a tiled polygon to the GeoJSON layers when a change makes it untileable Setting geodesic on a tiled polygon in place, or giving it points that span half the world or fewer than three, kept it in the tiles. update() now checks canTile and, when it fails, takes the polygon out of the tiles and draws it natively. It stays on the map, so this is not an add. --- .../mapbox/polygon/MapboxPolygonConductor.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt index eb6f713..5858c51 100644 --- a/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt +++ b/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt @@ -8,6 +8,7 @@ import com.mapconductor.core.polygon.PolygonEntity import com.mapconductor.core.polygon.PolygonEntityInterface import com.mapconductor.core.polygon.PolygonEvent import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polygon.PolygonTileSet import com.mapconductor.core.polygon.PolygonTiler import com.mapconductor.core.polyline.PolylineEntity import com.mapconductor.core.polyline.PolylineState @@ -85,9 +86,15 @@ class MapboxPolygonConductor( override suspend fun update(state: PolygonState) { tiler?.let { tiler -> if (tiler.isTiled(state.id)) { - polygonOverlay.polygonManager.registerEntity(PolygonEntity(polygon = IN_TILES, state = state)) - tiler.update(state) - return + if (PolygonTileSet.canTile(state)) { + polygonOverlay.polygonManager.registerEntity(PolygonEntity(polygon = IN_TILES, state = state)) + tiler.update(state) + return + } + // No longer drawable in a tile (geodesic, half the world wide, or + // under 3 points): the GeoJSON layers draw it from now on. Still + // on the map, so this is not an add. + tiler.remove(state.id) } } polygonOverlay.createPolygon(state)?.let { polygon -> From a4a389eb0b75dd36ff3c0d14f255e768a300f616 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:52:54 -0800 Subject: [PATCH 23/34] Clear circles in clearOverlays() too clearOverlays() cleared every overlay controller except circleController, so circles stayed on the map. The other providers already clear it. --- src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt index e10bc14..7218dfa 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt @@ -266,6 +266,7 @@ class MapboxMapViewController internal constructor( polylineController.clear() polygonController.clear() groundImageController.clear() + circleController.clear() rasterLayerController.clear() } From 544220f8d803f0027fa1213eaa37652a88f00f6a Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:52:54 -0800 Subject: [PATCH 24/34] Redraw markers after clearing them The marker controller's clear() emptied the manager, but onRemove only drops icon references; the GeoJSON source is rewritten from the manager in onPostProcess, which clear() never called. The markers stayed on the map after clearOverlays(). Call it once the manager is empty, the same order the core controllers use. --- .../com/mapconductor/mapbox/marker/MapboxMarkerController.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt index ee2ba1a..ee04202 100644 --- a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt +++ b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt @@ -112,6 +112,10 @@ class MapboxMarkerController private constructor( markerManager.clear() tiledMarkerIds.clear() removeTileOverlay() + // onRemove はアイコンの参照数を減らすだけで、地図のソースは onPostProcess が + // manager の中身から描き直すまで変わらない。空にした後で呼ばないと、 + // clearOverlays() の後もマーカーが地図に残る。 + renderer.onPostProcess() } viewportSwitch.requestReapply() } From 55d5811a21e22b007d425c327ef16202570bec44 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:10:01 -0800 Subject: [PATCH 25/34] Clear strategy markers in clearOverlays() Markers drawn through a StrategyMarkerController (clustering and other extension modules register these with registerOverlayController) were left on the map by clearOverlays(), which only cleared this provider's own controllers. Call BaseMapViewController.clearStrategyMarkerControllers(). Needs android-sdk-core with clearStrategyMarkerControllers() (fix/clear-strategy). --- .../java/com/mapconductor/mapbox/MapboxMapViewController.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt index d866205..a3d6324 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt @@ -268,6 +268,8 @@ class MapboxMapViewController internal constructor( groundImageController.clear() circleController.clear() rasterLayerController.clear() + // 拡張モジュール(クラスタリングなど)が登録した Strategy のマーカーも消す。 + clearStrategyMarkerControllers() } override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { From 81525c40ad0c331b80c748fab57f47400b5a37e0 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:55:27 -0800 Subject: [PATCH 26/34] Stack overlays by band, independent of add order and style reloads Before: circles, then polylines were appended, polygon z-layers went below the polyline layer (above circles) and ground images directly below the polyline layer (above polygons). With the Standard style the basemap is an import and has no label layer in styleLayers, so rasters were appended on top of every overlay. MapboxOverlayStacking records the band and zIndex of every layer the SDK adds and inserts each one below the lowest SDK layer with a higher key. Rasters stay below the basemap's first label layer when the style has one. Stacking order (all providers, all platforms), bottom to top: raster layers < ground images < polygons < circles < polylines < markers. Needs android-sdk-core with OverlayStacking (fix/overlay-stacking-order). --- api/android-for-mapbox.api | 9 ++ .../mapbox/MapboxMapViewControllerStyle.kt | 53 +++----- .../mapbox/MapboxOverlayStacking.kt | 86 +++++++++++++ .../MapboxGroundImageOverlayRenderer.kt | 25 ++-- .../MapboxRasterLayerOverlayRenderer.kt | 118 ++++-------------- 5 files changed, 143 insertions(+), 148 deletions(-) create mode 100644 src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 7e1e793..86164e0 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -211,6 +211,15 @@ public final class com.mapconductor.mapbox.MapboxMapViewSaver extends com.mapcon public final class com.mapconductor.mapbox.MapboxMapViewScope extends com.mapconductor.compose.MapViewScope { public com.mapconductor.mapbox.MapboxMapViewScope(); } +public final class com.mapconductor.mapbox.MapboxOverlayStacking { + public static final com.mapconductor.mapbox.MapboxOverlayStacking INSTANCE; + public final com.mapconductor.core.controller.OverlayLayerStack stackOf(com.mapbox.maps.MapboxMap); + public final com.mapconductor.core.controller.OverlayStacking$Key key(com.mapconductor.core.controller.OverlayKind, int); + public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_DRAG(); + public final void add(com.mapbox.maps.MapboxMap, com.mapbox.maps.Style, com.mapbox.maps.extension.style.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); + public final void remove(com.mapbox.maps.MapboxMap, com.mapbox.maps.Style, java.lang.String); +} public final class com.mapconductor.mapbox.MapboxPaddings extends com.mapconductor.core.map.MapPaddings implements com.mapconductor.mapbox.IMapboxPaddingsInterface { public static final com.mapconductor.mapbox.MapboxPaddings$Companion Companion; public com.mapconductor.mapbox.MapboxPaddings(double, double, double, double); diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerStyle.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerStyle.kt index cbbc2d0..3f08602 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerStyle.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerStyle.kt @@ -1,9 +1,7 @@ package com.mapconductor.mapbox -import com.mapbox.maps.extension.style.layers.addLayer -import com.mapbox.maps.extension.style.layers.addLayerAbove -import com.mapbox.maps.extension.style.layers.addLayerBelow import com.mapbox.maps.extension.style.sources.addSource +import com.mapconductor.core.controller.OverlayKind import com.mapconductor.mapbox.marker.MapboxMarkerOverlayRenderer /** @@ -21,18 +19,12 @@ internal fun MapboxMapViewController.attachMarkerLayers( style.addSource(renderer.markerLayer.source) } catch (_: Exception) { } - try { - style.addLayer(renderer.markerLayer.layer) - } catch (_: Exception) { - } + MapboxOverlayStacking.add(holder.map, style, renderer.markerLayer.layer, MapboxOverlayStacking.key(OverlayKind.Marker)) try { style.addSource(renderer.dragLayer.source) } catch (_: Exception) { } - try { - style.addLayer(renderer.dragLayer.layer) - } catch (_: Exception) { - } + MapboxOverlayStacking.add(holder.map, style, renderer.dragLayer.layer, MapboxOverlayStacking.MARKER_DRAG) } internal fun MapboxMapViewController.attachOverlaySourcesAndLayers(style: com.mapbox.maps.Style) { @@ -42,14 +34,22 @@ internal fun MapboxMapViewController.attachOverlaySourcesAndLayers(style: com.ma // Circle fill (fill pass) then stroke (line pass) — both below general polyline style.addSource(circleController.renderer.layer.source) - style.addLayer(circleController.renderer.layer.fillLayer) - style.addLayer(circleController.renderer.layer.strokeLayer) + // 層の位置は MapboxOverlayStacking が帯の順で決める(追加の順に依らない)。 + // 線は塗りの直後(同じ鍵なら後から入れたものが上)。 + val circleKey = MapboxOverlayStacking.key(OverlayKind.Circle) + MapboxOverlayStacking.add(holder.map, style, circleController.renderer.layer.fillLayer, circleKey) + MapboxOverlayStacking.add(holder.map, style, circleController.renderer.layer.strokeLayer, circleKey) // Polyline (general) style.addSource(polylineController.renderer.layer.source) - style.addLayer(polylineController.renderer.layer.layer) + MapboxOverlayStacking.add( + holder.map, + style, + polylineController.renderer.layer.layer, + MapboxOverlayStacking.key(OverlayKind.Polyline), + ) - // Add z-indexed polygon layers below general polylines + // Polygon z-layers (polygon band: above ground images, below circles) ensurePolygonZLayers(style) // Marker + drag layers @@ -76,14 +76,8 @@ internal fun MapboxMapViewController.ensurePolygonZLayers(style: com.mapbox.maps toRemove.forEach { z -> val fillId = "polygon-fill-layer-$z" val outlineId = "polygon-outline-layer-$z" - try { - style.removeStyleLayer(outlineId) - } catch (_: Exception) { - } - try { - style.removeStyleLayer(fillId) - } catch (_: Exception) { - } + MapboxOverlayStacking.remove(holder.map, style, outlineId) + MapboxOverlayStacking.remove(holder.map, style, fillId) } val zList = zSet.toList().sorted() @@ -108,11 +102,7 @@ internal fun MapboxMapViewController.ensurePolygonZLayers(style: com.mapbox.maps .get("fillColor"), ) } - try { - style.addLayerBelow(layer, anchorId) - } catch (_: Exception) { - style.addLayer(layer) - } + MapboxOverlayStacking.add(holder.map, style, layer, MapboxOverlayStacking.key(OverlayKind.Polygon, z)) } // Outline layer above its fill @@ -138,11 +128,8 @@ internal fun MapboxMapViewController.ensurePolygonZLayers(style: com.mapbox.maps .get("strokeWidth"), ) } - try { - style.addLayerAbove(layer, fillId) - } catch (_: Exception) { - style.addLayer(layer) - } + // 塗りの直後(同じ鍵なら後から入れたものが上)。 + MapboxOverlayStacking.add(holder.map, style, layer, MapboxOverlayStacking.key(OverlayKind.Polygon, z)) } } // Update tracked set diff --git a/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt b/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt new file mode 100644 index 0000000..34757f5 --- /dev/null +++ b/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt @@ -0,0 +1,86 @@ +package com.mapconductor.mapbox + +import android.util.Log +import com.mapbox.maps.MapboxMap +import com.mapbox.maps.Style +import com.mapbox.maps.extension.style.layers.Layer +import com.mapbox.maps.extension.style.layers.addLayer +import com.mapbox.maps.extension.style.layers.addLayerBelow +import com.mapconductor.core.controller.OverlayKind +import com.mapconductor.core.controller.OverlayLayerStack +import com.mapconductor.core.controller.OverlayStacking +import java.util.WeakHashMap + +/** + * オーバーレイの層をスタイルのどこへ入れるか([OverlayStacking] の帯の順)。 + * + * 下から: ラスター < GroundImage < ポリゴン < サークル < ポリライン < マーカー + * (マーカーのタイルはマーカーの帯の一番下、ドラッグ中の層は一番上)。 + * + * 位置は毎回スタイルの今の並びから決めるので、追加した順・送り直し・ + * スタイルの読み直し(デザイン変更)に左右されない。 + * + * ラスターだけはベースマップのラベル(SDK の層でない最初の symbol 層)の下に入れる。 + * それ以外の帯はラベルの上に乗る(従来どおり)。Standard スタイルのように + * ベースマップが import で、その層が `styleLayers` に出てこない場合は、 + * ラスターも含めてベースマップ全体の上に乗る。 + */ +internal object MapboxOverlayStacking { + private val stacks = WeakHashMap() + + fun stackOf(map: MapboxMap): OverlayLayerStack = synchronized(stacks) { stacks.getOrPut(map) { OverlayLayerStack() } } + + fun key( + kind: OverlayKind, + zIndex: Int = 0, + ) = OverlayStacking.Key(kind, zIndex) + + /** マーカーのタイル(ラスター): マーカーの帯の一番下。 */ + val MARKER_TILE = key(OverlayKind.Marker, Int.MIN_VALUE) + + /** ドラッグ中のマーカーの層: マーカーの帯の一番上。 */ + val MARKER_DRAG = key(OverlayKind.Marker, Int.MAX_VALUE) + + /** [layer] を [key] の位置へ足す。同じ id の層が既にあれば何もしない。 */ + fun add( + map: MapboxMap, + style: Style, + layer: Layer, + key: OverlayStacking.Key, + ) { + val stack = stackOf(map) + stack.put(layer.layerId, key) + if (style.styleLayerExists(layer.layerId)) return + val layers = style.styleLayers + val ids = layers.map { it.id } + var before = stack.beforeId(ids, key) + if (key.kind == OverlayKind.RasterLayer) { + val label = layers.firstOrNull { it.type == "symbol" && stack.keyOf(it.id) == null }?.id + if (label != null && (before == null || ids.indexOf(label) < ids.indexOf(before))) { + before = label + } + } + try { + if (before != null) style.addLayerBelow(layer, before) else style.addLayer(layer) + } catch (e: Exception) { + if (style.styleLayerExists(layer.layerId)) return + try { + style.addLayer(layer) + } catch (e2: Exception) { + Log.w("Mapbox", "Failed to add layer: ${layer.layerId} (${e2.message})") + } + } + } + + fun remove( + map: MapboxMap, + style: Style?, + layerId: String, + ) { + stackOf(map).remove(layerId) + try { + style?.removeStyleLayer(layerId) + } catch (_: Exception) { + } + } +} diff --git a/src/main/java/com/mapconductor/mapbox/groundimage/MapboxGroundImageOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/groundimage/MapboxGroundImageOverlayRenderer.kt index 34b8679..2a5c186 100644 --- a/src/main/java/com/mapconductor/mapbox/groundimage/MapboxGroundImageOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/groundimage/MapboxGroundImageOverlayRenderer.kt @@ -3,13 +3,12 @@ package com.mapconductor.mapbox.groundimage import com.mapbox.bindgen.DataRef import com.mapbox.maps.Image import com.mapbox.maps.Style -import com.mapbox.maps.extension.style.layers.addLayer -import com.mapbox.maps.extension.style.layers.addLayerBelow import com.mapbox.maps.extension.style.layers.generated.rasterLayer import com.mapbox.maps.extension.style.layers.properties.generated.Visibility import com.mapbox.maps.extension.style.sources.addSource import com.mapbox.maps.extension.style.sources.generated.imageSource import com.mapbox.maps.extension.style.utils.TypeUtils +import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.features.GeoRectBounds import com.mapconductor.core.groundimage.AbstractGroundImageOverlayRenderer import com.mapconductor.core.groundimage.GroundImageEntityInterface @@ -17,6 +16,7 @@ import com.mapconductor.core.groundimage.GroundImageFingerPrint import com.mapconductor.core.groundimage.GroundImageState import com.mapconductor.mapbox.MapboxActualGroundImage import com.mapconductor.mapbox.MapboxMapViewHolder +import com.mapconductor.mapbox.MapboxOverlayStacking import java.nio.ByteBuffer import android.annotation.SuppressLint import android.graphics.Bitmap @@ -134,25 +134,15 @@ class MapboxGroundImageOverlayRenderer( Log.w("Mapbox", "Failed to add ground image source: ${e.message}") } - try { - style.addLayerBelow(layer, BELOW_LAYER_ID) - } catch (_: Exception) { - try { - style.addLayer(layer) - } catch (e: Exception) { - Log.w("Mapbox", "Failed to add ground image layer: ${e.message}") - } - } + // GroundImage の帯(ラスターの上、ポリゴンの下)。送り直しても同じ位置。 + MapboxOverlayStacking.add(holder.map, style, layer, MapboxOverlayStacking.key(OverlayKind.GroundImage)) } private fun removeSourceAndLayerIfExists( style: Style, handle: MapboxGroundImageHandle, ) { - try { - style.removeStyleLayer(handle.layerId) - } catch (_: Exception) { - } + MapboxOverlayStacking.remove(holder.map, style, handle.layerId) try { style.removeStyleSource(handle.sourceId) } catch (_: Exception) { @@ -224,7 +214,6 @@ class MapboxGroundImageOverlayRenderer( opacity = opacity, ) - companion object { - private const val BELOW_LAYER_ID = "polyline-layer" - } + // 公開 API(Companion)の互換のために残している。 + companion object } diff --git a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt index 54c73a1..9c36fdc 100644 --- a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt @@ -1,11 +1,9 @@ package com.mapconductor.mapbox.raster -import com.mapbox.maps.extension.style.layers.addLayer -import com.mapbox.maps.extension.style.layers.addLayerAbove -import com.mapbox.maps.extension.style.layers.addLayerBelow import com.mapbox.maps.extension.style.layers.generated.rasterLayer import com.mapbox.maps.extension.style.sources.addSource import com.mapbox.maps.extension.style.sources.generated.rasterSource +import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.raster.RasterHeaderRuleSet import com.mapconductor.core.raster.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface @@ -13,6 +11,7 @@ import com.mapconductor.core.raster.RasterLayerSource import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.core.raster.TileScheme import com.mapconductor.mapbox.MapboxMapViewHolder +import com.mapconductor.mapbox.MapboxOverlayStacking import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -99,15 +98,7 @@ class MapboxRasterLayerOverlayRenderer( } catch (e: Exception) { Log.w("Mapbox", "Failed to add raster source: ${e.message}") } - try { - if (isMarkerTileRaster(state)) { - addLayerForMarkerTile(style, layer) - } else { - addBelowBasemapLabels(style, layer) - } - } catch (e: Exception) { - Log.w("Mapbox", "Failed to add raster layer: ${e.message}") - } + addToStack(style, layer, state) return handle } @@ -116,10 +107,7 @@ class MapboxRasterLayerOverlayRenderer( state: RasterLayerState, ) { val style = holder.map.style ?: return - try { - style.removeStyleLayer(handle.layerId) - } catch (_: Exception) { - } + MapboxOverlayStacking.remove(holder.map, style, handle.layerId) val opacity = if (state.visible) { state.opacity.coerceIn(0.0f, 1.0f).toDouble() @@ -130,23 +118,13 @@ class MapboxRasterLayerOverlayRenderer( rasterLayer(handle.layerId, handle.sourceId) { rasterOpacity(opacity) } - try { - if (isMarkerTileRaster(state)) { - addLayerForMarkerTile(style, layer) - } else { - addBelowBasemapLabels(style, layer) - } - } catch (_: Exception) { - } + addToStack(style, layer, state) } private fun removeLayer(entity: RasterLayerEntityInterface) { val style = holder.map.style ?: return val handle = entity.layer - try { - style.removeStyleLayer(handle.layerId) - } catch (_: Exception) { - } + MapboxOverlayStacking.remove(holder.map, style, handle.layerId) try { style.removeStyleSource(handle.sourceId) } catch (_: Exception) { @@ -182,26 +160,22 @@ class MapboxRasterLayerOverlayRenderer( } } - private fun addLayerForMarkerTile( + /** + * 帯の順([MapboxOverlayStacking])で足す。アプリのラスターはラスターの帯 + * (一番下、ベースマップのラベルの下)、マーカーのタイルはマーカーの帯の一番下。 + */ + private fun addToStack( style: com.mapbox.maps.Style, layer: com.mapbox.maps.extension.style.layers.Layer, + state: RasterLayerState, ) { - // Insert the raster tiles below the marker symbol layer so they don't cover markers, - // but remain above vector overlays that are anchored below markers (polyline/circle/etc). - try { - style.addLayerBelow(layer, MARKERS_LAYER_ID) - return - } catch (_: Exception) { - } - - // Best-effort fallback: place above polylines if marker layer isn't present yet. - try { - style.addLayerAbove(layer, POLYLINE_LAYER_ID) - return - } catch (_: Exception) { - } - - style.addLayer(layer) + val key = + if (isMarkerTileRaster(state)) { + MapboxOverlayStacking.MARKER_TILE + } else { + MapboxOverlayStacking.key(OverlayKind.RasterLayer, state.zIndex) + } + MapboxOverlayStacking.add(holder.map, style, layer, key) } private fun rebuildNonMarkerRasterLayers(style: com.mapbox.maps.Style) { @@ -215,10 +189,7 @@ class MapboxRasterLayerOverlayRenderer( // Remove and re-add non-marker raster layers in zIndex order to ensure deterministic stacking. ordered.forEach { (_, handle) -> - try { - style.removeStyleLayer(handle.layerId) - } catch (_: Exception) { - } + MapboxOverlayStacking.remove(holder.map, style, handle.layerId) } ordered.forEach { (state, handle) -> @@ -232,59 +203,12 @@ class MapboxRasterLayerOverlayRenderer( rasterLayer(handle.layerId, handle.sourceId) { rasterOpacity(opacity) } - try { - addBelowBasemapLabels(style, layer) - } catch (_: Exception) { - } + addToStack(style, layer, state) } } - /** - * Puts a raster layer above the basemap's geometry but **below its labels**. - * - * Appended at the top of the style instead, a raster overlay covers the - * place names, road names and shields the backend draws -- a vector tile - * layer's own roads run straight through them, which is what "the labels - * are under the lines" looks like. Every raster overlay we add has the - * same problem, so the rule lives here rather than in each of them. - * - * Inserting successive layers below the same anchor keeps their order: - * each one lands directly below the anchor, which is directly above the - * one inserted before it. - * - * Anything of ours is skipped when looking for the anchor. Markers are a - * symbol layer too, and anchoring to them would put the raster back above - * the labels -- with the added twist of only doing so once a marker exists. - */ - private fun addBelowBasemapLabels( - style: com.mapbox.maps.Style, - layer: com.mapbox.maps.extension.style.layers.Layer, - ) { - val anchor = - style.styleLayers.firstOrNull { it.type == "symbol" && !isOursById(it.id) } - if (anchor == null) { - style.addLayer(layer) - return - } - try { - style.addLayerBelow(layer, anchor.id) - } catch (_: Exception) { - style.addLayer(layer) - } - } - - /** True for layers this SDK adds, as opposed to the design's own. */ - private fun isOursById(id: String): Boolean = - id.startsWith("raster-layer-") || - id.startsWith(MARKERS_LAYER_ID) || - id.startsWith("marker-drag-layer") || - id.startsWith(POLYLINE_LAYER_ID) || - id.startsWith("circle-layer") - private companion object { private const val MARKER_TILE_RASTER_ID_PREFIX = "marker-tile-" - private const val MARKERS_LAYER_ID = "markers-layer" - private const val POLYLINE_LAYER_ID = "polyline-layer" } } From 99ef8c3cc2d4b0f4c071247613ac1ce891b3b68c Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 06:55:03 -0800 Subject: [PATCH 27/34] Update the API baseline Additions only: the polygon tiles' stacking key, and declarations develop added without running apiDump (TILE_REFRESH_COALESCE, and on ArcGIS the marker controller's constructor that takes a boolean). --- api/android-for-mapbox.api | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 86164e0..9092445 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -216,6 +216,7 @@ public final class com.mapconductor.mapbox.MapboxOverlayStacking { public final com.mapconductor.core.controller.OverlayLayerStack stackOf(com.mapbox.maps.MapboxMap); public final com.mapconductor.core.controller.OverlayStacking$Key key(com.mapconductor.core.controller.OverlayKind, int); public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYGON_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_DRAG(); public final void add(com.mapbox.maps.MapboxMap, com.mapbox.maps.Style, com.mapbox.maps.extension.style.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); public final void remove(com.mapbox.maps.MapboxMap, com.mapbox.maps.Style, java.lang.String); @@ -424,6 +425,7 @@ public final class com.mapconductor.mapbox.polygon.MapboxPolygonController exten public com.mapconductor.mapbox.polygon.MapboxPolygonController(com.mapconductor.mapbox.polygon.MapboxPolygonCompositeRenderer); public com.mapconductor.mapbox.polygon.MapboxPolygonCompositeRenderer getRenderer(); public int getZIndex(); + public void destroy(); public com.mapconductor.core.polygon.PolygonOverlayRendererInterface getRenderer(); } public final class com.mapconductor.mapbox.polygon.MapboxPolygonLayer { From a5d212192aff08f4909674e60345caece118c1cc Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 09:38:06 -0800 Subject: [PATCH 28/34] Draw large polyline sets as raster tiles (PolylineTiler) The polyline controller gets a PolylineTiler, shown through the same raster layer path as the polygon tiles. Stacking key (Polyline, Int.MIN_VALUE): over circles, under native polylines and markers. Whatever redraws from the polyline manager skips tiled entities (no native line). --- api/android-for-mapbox.api | 1 + .../mapbox/MapboxMapViewController.kt | 15 +++++++++++++++ .../mapconductor/mapbox/MapboxOverlayStacking.kt | 3 +++ .../polyline/MapboxPolylineOverlayRenderer.kt | 2 +- .../raster/MapboxRasterLayerOverlayRenderer.kt | 7 ++++++- 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 9092445..bb0b2d8 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -217,6 +217,7 @@ public final class com.mapconductor.mapbox.MapboxOverlayStacking { public final com.mapconductor.core.controller.OverlayStacking$Key key(com.mapconductor.core.controller.OverlayKind, int); public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYGON_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYLINE_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_DRAG(); public final void add(com.mapbox.maps.MapboxMap, com.mapbox.maps.Style, com.mapbox.maps.extension.style.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); public final void remove(com.mapbox.maps.MapboxMap, com.mapbox.maps.Style, java.lang.String); diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt index 665a509..02e37c8 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt @@ -39,7 +39,10 @@ import com.mapconductor.core.marker.dispatchGeoMarkerClick import com.mapconductor.core.polygon.OnPolygonEventHandler import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTiler +import com.mapconductor.core.polyline.polylineTileRasterLayerState import com.mapconductor.mapbox.circle.MapboxCircleController import com.mapconductor.mapbox.groundimage.MapboxGroundImageController import com.mapconductor.mapbox.marker.MapboxMarkerController @@ -132,6 +135,18 @@ class MapboxMapViewController internal constructor( } } } + + // Many polylines too: the same raster layer path, in the polyline band. + polylineController.tiler = + PolylineTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(polylineTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(POLYLINE_TILE_LAYER_ID) + } + } + } } override fun moveCamera(position: MapCameraPosition) = handleMoveCamera(position) diff --git a/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt b/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt index 855290f..a850f33 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt @@ -42,6 +42,9 @@ internal object MapboxOverlayStacking { /** ポリゴンのタイル(ラスター): ポリゴンの帯の一番下。 */ val POLYGON_TILE = key(OverlayKind.Polygon, Int.MIN_VALUE) + /** ポリラインのタイル(ラスター): ポリラインの帯の一番下(サークルの上、ネイティブのポリラインの下)。 */ + val POLYLINE_TILE = key(OverlayKind.Polyline, Int.MIN_VALUE) + /** ドラッグ中のマーカーの層: マーカーの帯の一番上。 */ val MARKER_DRAG = key(OverlayKind.Marker, Int.MAX_VALUE) diff --git a/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineOverlayRenderer.kt index d942083..b299838 100644 --- a/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineOverlayRenderer.kt @@ -65,6 +65,6 @@ class MapboxPolylineOverlayRenderer( private fun getAllPolylineEntities(): List> { // This would need access to the polyline manager // For now, we'll implement a simple workaround - return polylineManager.allEntities() + return polylineManager.allEntities().filter { (it.polyline as MapboxActualPolyline?) != null } } } diff --git a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt index 3700979..e391bc7 100644 --- a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt @@ -5,6 +5,7 @@ import com.mapbox.maps.extension.style.sources.addSource import com.mapbox.maps.extension.style.sources.generated.rasterSource import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID +import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID import com.mapconductor.core.raster.RasterHeaderRuleSet import com.mapconductor.core.raster.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface @@ -28,6 +29,8 @@ class MapboxRasterLayerOverlayRenderer( private fun isPolygonTileRaster(state: RasterLayerState): Boolean = state.id == POLYGON_TILE_LAYER_ID + private fun isPolylineTileRaster(state: RasterLayerState): Boolean = state.id == POLYLINE_TILE_LAYER_ID + /** Mapbox Android はタイル要求を書き換える公開 API を持たない。 */ override suspend fun onAdd( data: List, @@ -178,6 +181,8 @@ class MapboxRasterLayerOverlayRenderer( MapboxOverlayStacking.MARKER_TILE } else if (isPolygonTileRaster(state)) { MapboxOverlayStacking.POLYGON_TILE + } else if (isPolylineTileRaster(state)) { + MapboxOverlayStacking.POLYLINE_TILE } else { MapboxOverlayStacking.key(OverlayKind.RasterLayer, state.zIndex) } @@ -188,7 +193,7 @@ class MapboxRasterLayerOverlayRenderer( val ordered = stateById.values .asSequence() - .filter { !isMarkerTileRaster(it) && !isPolygonTileRaster(it) } + .filter { !isMarkerTileRaster(it) && !isPolygonTileRaster(it) && !isPolylineTileRaster(it) } .sortedBy { it.zIndex } .mapNotNull { state -> handleById[state.id]?.let { handle -> state to handle } } .toList() From e60d0328988d737a3e5d8cde075f8491578f031e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Tue, 10 Nov 2026 16:16:50 -0800 Subject: [PATCH 29/34] Support bulk Circles with raster tiles --- .../mapbox/MapboxMapViewController.kt | 15 +++++++++++++++ .../mapconductor/mapbox/MapboxOverlayStacking.kt | 3 +++ .../mapbox/circle/MapboxCircleOverlayRenderer.kt | 2 +- .../raster/MapboxRasterLayerOverlayRenderer.kt | 7 ++++++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt index 02e37c8..aedd292 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt @@ -1,5 +1,8 @@ package com.mapconductor.mapbox +import com.mapconductor.core.circle.CIRCLE_TILE_LAYER_ID +import com.mapconductor.core.circle.CircleTiler +import com.mapconductor.core.circle.circleTileRasterLayerState import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID import com.mapconductor.core.polygon.PolygonTiler import com.mapconductor.core.polygon.polygonTileRasterLayerState @@ -136,6 +139,18 @@ class MapboxMapViewController internal constructor( } } + // Many circles too: the same raster layer path, in the circle band. + circleController.tiler = + CircleTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(circleTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(CIRCLE_TILE_LAYER_ID) + } + } + } + // Many polylines too: the same raster layer path, in the polyline band. polylineController.tiler = PolylineTiler().apply { diff --git a/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt b/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt index a850f33..06c1281 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxOverlayStacking.kt @@ -42,6 +42,9 @@ internal object MapboxOverlayStacking { /** ポリゴンのタイル(ラスター): ポリゴンの帯の一番下。 */ val POLYGON_TILE = key(OverlayKind.Polygon, Int.MIN_VALUE) + /** 円のタイル(ラスター): サークルの帯の一番下(ポリゴンの上、ネイティブの円とポリラインのタイルの下)。 */ + val CIRCLE_TILE = key(OverlayKind.Circle, Int.MIN_VALUE) + /** ポリラインのタイル(ラスター): ポリラインの帯の一番下(サークルの上、ネイティブのポリラインの下)。 */ val POLYLINE_TILE = key(OverlayKind.Polyline, Int.MIN_VALUE) diff --git a/src/main/java/com/mapconductor/mapbox/circle/MapboxCircleOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/circle/MapboxCircleOverlayRenderer.kt index f0a7e86..2149b04 100644 --- a/src/main/java/com/mapconductor/mapbox/circle/MapboxCircleOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/circle/MapboxCircleOverlayRenderer.kt @@ -65,7 +65,7 @@ class MapboxCircleOverlayRenderer( } override suspend fun onPostProcess() { - val circles = circleManager.allEntities() + val circles = circleManager.nativeEntities().filter { (it.circle as MapboxActualCircle?) != null } coroutine.launch { layer.draw(circles) } diff --git a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt index e391bc7..348f341 100644 --- a/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/raster/MapboxRasterLayerOverlayRenderer.kt @@ -3,6 +3,7 @@ package com.mapconductor.mapbox.raster import com.mapbox.maps.extension.style.layers.generated.rasterLayer import com.mapbox.maps.extension.style.sources.addSource import com.mapbox.maps.extension.style.sources.generated.rasterSource +import com.mapconductor.core.circle.CIRCLE_TILE_LAYER_ID import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID @@ -31,6 +32,8 @@ class MapboxRasterLayerOverlayRenderer( private fun isPolylineTileRaster(state: RasterLayerState): Boolean = state.id == POLYLINE_TILE_LAYER_ID + private fun isCircleTileRaster(state: RasterLayerState): Boolean = state.id == CIRCLE_TILE_LAYER_ID + /** Mapbox Android はタイル要求を書き換える公開 API を持たない。 */ override suspend fun onAdd( data: List, @@ -181,6 +184,8 @@ class MapboxRasterLayerOverlayRenderer( MapboxOverlayStacking.MARKER_TILE } else if (isPolygonTileRaster(state)) { MapboxOverlayStacking.POLYGON_TILE + } else if (isCircleTileRaster(state)) { + MapboxOverlayStacking.CIRCLE_TILE } else if (isPolylineTileRaster(state)) { MapboxOverlayStacking.POLYLINE_TILE } else { @@ -193,7 +198,7 @@ class MapboxRasterLayerOverlayRenderer( val ordered = stateById.values .asSequence() - .filter { !isMarkerTileRaster(it) && !isPolygonTileRaster(it) && !isPolylineTileRaster(it) } + .filter { !isMarkerTileRaster(it) && !isPolygonTileRaster(it) && !isPolylineTileRaster(it) && !isCircleTileRaster(it) } .sortedBy { it.zIndex } .mapNotNull { state -> handleById[state.id]?.let { handle -> state to handle } } .toList() From 36c733bd41f411a722024355f019fbf614cff07f Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 11 Nov 2026 11:41:05 -0800 Subject: [PATCH 30/34] Order markers with MarkerDrawOrder The symbol sort key is now zIndex first, then south and west, recomputed from the current state on every change. Unspecified markers in the southern hemisphere no longer outrank an explicit zIndex. --- .../mapbox/marker/MapboxMarkerOverlayRenderer.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt index e057e2e..294f551 100644 --- a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt @@ -5,12 +5,12 @@ import com.google.gson.JsonObject import com.mapbox.geojson.Feature import com.mapbox.geojson.FeatureCollection import com.mapconductor.core.ResourceProvider -import com.mapconductor.core.calculateZIndex import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.marker.AbstractMarkerOverlayRenderer import com.mapconductor.core.marker.BitmapIcon import com.mapconductor.core.marker.DefaultMarkerIcon +import com.mapconductor.core.marker.MarkerDrawOrder import com.mapconductor.core.marker.MarkerEntityInterface import com.mapconductor.core.marker.MarkerIconInterface import com.mapconductor.core.marker.MarkerManager @@ -184,7 +184,7 @@ class MapboxMarkerOverlayRenderer( val props = (markerEntity.marker?.properties() ?: JsonObject()).deepCopy() props.addProperty( Prop.Z_INDEX, - markerEntity.state.zIndex ?: calculateZIndex(position), + MarkerDrawOrder.of(markerEntity.state.zIndex, position), ) val feature = Feature.fromGeometry( @@ -246,7 +246,7 @@ class MapboxMarkerOverlayRenderer( add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) } addProperty(Prop.SCALE, it.state.icon?.scale ?: 1.0) - addProperty(Prop.Z_INDEX, it.state.zIndex ?: calculateZIndex(it.state.position)) + addProperty(Prop.Z_INDEX, MarkerDrawOrder.of(it.state.zIndex, it.state.position)) } Feature.fromGeometry(position, properties, featureId) } @@ -335,7 +335,7 @@ class MapboxMarkerOverlayRenderer( ) addProperty( Prop.Z_INDEX, - params.current.state.zIndex ?: calculateZIndex(params.current.state.position), + MarkerDrawOrder.of(params.current.state.zIndex, params.current.state.position), ) if (currFinger.icon == prevFinger.icon) { addProperty( From 32a75e247fa4bbf83248aac6e8a947bf4b7cd534 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 11 Nov 2026 15:00:56 -0800 Subject: [PATCH 31/34] Reorder markers when the map rotates Same-zIndex markers follow the screen position (lower in front) and are re-sorted after rotation settles. --- api/android-for-mapbox.api | 1 + .../marker/MapboxMarkerOverlayRenderer.kt | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index bb0b2d8..8865c91 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -380,6 +380,7 @@ public final class com.mapconductor.mapbox.marker.MapboxMarkerOverlayRenderer ex public void setMarkerPosition(com.mapconductor.core.marker.MarkerEntityInterface, com.mapconductor.core.features.GeoPoint); public java.lang.Object onAdd(java.util.List, kotlin.coroutines.Continuation>); public java.lang.Object onRemove(java.util.List>, kotlin.coroutines.Continuation); + protected java.lang.Object applyDrawOrder(java.util.List>, kotlin.coroutines.Continuation); public java.lang.Object onPostProcess(kotlin.coroutines.Continuation); public java.lang.Object onChange(java.util.List>, kotlin.coroutines.Continuation>); public void onDragSelectionChanged(com.mapconductor.core.marker.MarkerEntityInterface, com.mapconductor.core.marker.MarkerEntityInterface); diff --git a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt index 294f551..e73dde4 100644 --- a/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt @@ -184,7 +184,7 @@ class MapboxMarkerOverlayRenderer( val props = (markerEntity.marker?.properties() ?: JsonObject()).deepCopy() props.addProperty( Prop.Z_INDEX, - MarkerDrawOrder.of(markerEntity.state.zIndex, position), + MarkerDrawOrder.of(markerEntity.state.zIndex, position, drawOrderFrame), ) val feature = Feature.fromGeometry( @@ -246,7 +246,7 @@ class MapboxMarkerOverlayRenderer( add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) } addProperty(Prop.SCALE, it.state.icon?.scale ?: 1.0) - addProperty(Prop.Z_INDEX, MarkerDrawOrder.of(it.state.zIndex, it.state.position)) + addProperty(Prop.Z_INDEX, drawOrderOf(it.state)) } Feature.fromGeometry(position, properties, featureId) } @@ -271,6 +271,17 @@ class MapboxMarkerOverlayRenderer( } } + // 地図が回ったら、各フィーチャーの並び順キー(symbol-sort-key)を付け直して流し直す。 + override suspend fun applyDrawOrder(entities: List>) { + val target = entities.filter { !it.tiling } + withContext(Dispatchers.Main) { + target.forEach { entity -> + entity.marker?.properties()?.addProperty(Prop.Z_INDEX, drawOrderOf(entity.state)) + } + markerLayer.draw(target) + } + } + override suspend fun onPostProcess() { val target = markerManager.allEntities().filter { !it.tiling } @@ -335,7 +346,7 @@ class MapboxMarkerOverlayRenderer( ) addProperty( Prop.Z_INDEX, - MarkerDrawOrder.of(params.current.state.zIndex, params.current.state.position), + drawOrderOf(params.current.state), ) if (currFinger.icon == prevFinger.icon) { addProperty( From 463cc88f2a30170321f927c3c1e52b18f14bb7c3 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 11 Nov 2026 22:29:04 -0800 Subject: [PATCH 32/34] Apply camera paddings and report fov on Mapbox The padding TODO is enabled: paddings (dp) go to CameraOptions.padding as px on move, animate and the initial camera, and are read back from CameraState.padding. fov is 36.87 (measured 36.9 on TB520FU). --- api/android-for-mapbox.api | 14 ++++- .../mapconductor/mapbox/MapCameraPosition.kt | 56 ++++++++++++++----- .../com/mapconductor/mapbox/MapboxMapView.kt | 2 +- .../mapbox/MapboxMapViewControllerCamera.kt | 7 ++- 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index bb0b2d8..917d767 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -20,22 +20,29 @@ public interface com.mapconductor.mapbox.IMapboxPaddingsInterface extends com.ma public abstract com.mapbox.maps.EdgeInsets toEdgeInsects(); } public final class com.mapconductor.mapbox.MapCameraPositionKt { + public static final double MAPBOX_VERTICAL_FOV; + public static final com.mapbox.maps.EdgeInsets toMapboxEdgeInsets(com.mapconductor.core.map.MapPaddingsInterface, float); + public static final com.mapconductor.core.map.MapPaddings toMapPaddings(com.mapbox.maps.EdgeInsets, float); public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(com.mapbox.maps.CameraChanged); public static final com.mapbox.maps.CameraOptions toCameraOptions(com.mapconductor.core.map.MapCameraPosition); + public static final com.mapbox.maps.CameraOptions toCameraOptions(com.mapconductor.core.map.MapCameraPosition, java.lang.Float); public static final com.mapbox.maps.CameraState toCameraState(com.mapconductor.core.map.MapCameraPosition); public static final com.mapconductor.core.map.MapCameraPosition from(com.mapconductor.core.map.MapCameraPosition$Companion, com.mapconductor.core.map.MapCameraPositionInterface); public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(com.mapbox.maps.CameraOptions); public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(com.mapbox.maps.CameraState); - public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(com.mapbox.maps.CameraState, java.lang.Double); + public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(com.mapbox.maps.CameraState, java.lang.Double, java.lang.Float); } public final class com.mapconductor.mapbox.MapboxCameraStateSnapshot { - public com.mapconductor.mapbox.MapboxCameraStateSnapshot(com.mapbox.maps.CameraState, java.lang.Double); + public com.mapconductor.mapbox.MapboxCameraStateSnapshot(com.mapbox.maps.CameraState, java.lang.Double, java.lang.Float); + public com.mapconductor.mapbox.MapboxCameraStateSnapshot(com.mapbox.maps.CameraState, java.lang.Double, java.lang.Float, int, kotlin.jvm.internal.DefaultConstructorMarker); public final com.mapbox.maps.CameraState getCameraState(); public final java.lang.Double getLogicalTiltHint(); + public final java.lang.Float getDensity(); public final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(); public final com.mapbox.maps.CameraState component1(); public final java.lang.Double component2(); - public final com.mapconductor.mapbox.MapboxCameraStateSnapshot copy(com.mapbox.maps.CameraState, java.lang.Double); + public final java.lang.Float component3(); + public final com.mapconductor.mapbox.MapboxCameraStateSnapshot copy(com.mapbox.maps.CameraState, java.lang.Double, java.lang.Float); public java.lang.String toString(); public int hashCode(); public boolean equals(java.lang.Object); @@ -151,6 +158,7 @@ public final class com.mapconductor.mapbox.MapboxMapViewController extends com.m public final class com.mapconductor.mapbox.MapboxMapViewControllerCameraKt { public static final com.mapconductor.core.map.MapCameraPosition getMapCameraPosition(com.mapconductor.mapbox.MapboxMapViewController); public static final com.mapconductor.core.map.MapCameraPosition readLogicalCameraPosition(com.mapconductor.mapbox.MapboxMapViewController); + public static final float density(com.mapconductor.mapbox.MapboxMapViewController); public static final java.lang.Object getMapCameraPositionAsync(com.mapconductor.mapbox.MapboxMapViewController, kotlin.coroutines.Continuation); public static final void handleMoveCamera(com.mapconductor.mapbox.MapboxMapViewController, com.mapconductor.core.map.MapCameraPosition); public static final void handleAnimateCamera(com.mapconductor.mapbox.MapboxMapViewController, com.mapconductor.core.map.MapCameraPosition, long); diff --git a/src/main/java/com/mapconductor/mapbox/MapCameraPosition.kt b/src/main/java/com/mapconductor/mapbox/MapCameraPosition.kt index 8a7e729..58cefb5 100644 --- a/src/main/java/com/mapconductor/mapbox/MapCameraPosition.kt +++ b/src/main/java/com/mapconductor/mapbox/MapCameraPosition.kt @@ -9,6 +9,7 @@ import com.mapconductor.core.features.GeoPointInterface 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.map.VisibleRegion import com.mapconductor.core.spherical.Spherical @@ -19,12 +20,34 @@ import kotlin.math.tan private val converter = ZoomAltitudeConverter() +/** + * Mapbox のカメラの縦方向の視野角(度)。Mapbox Maps の既定値で、地図の大きさに + * よらず一定(焦点距離 = 地図の高さ × 1.5)。TB520FU で 36.9° を実測。 + */ +internal const val MAPBOX_VERTICAL_FOV = 36.87 + +/** dp のパディング → Mapbox の px のパディング。 */ +internal fun MapPaddingsInterface.toMapboxEdgeInsets(density: Float): EdgeInsets = + EdgeInsets(top * density, left * density, bottom * density, right * density) + +/** Mapbox の px のパディング → dp のパディング。 */ +internal fun EdgeInsets.toMapPaddings(density: Float): MapPaddings = + MapPaddings(top = top / density, left = left / density, bottom = bottom / density, right = right / density) + fun CameraChanged.toMapCameraPosition() = cameraState .toMapCameraPosition() .copy(paddings = cameraState.padding.toPaddings()) -fun MapCameraPosition.toCameraOptions(): CameraOptions { +fun MapCameraPosition.toCameraOptions(): CameraOptions = toCameraOptions(density = null) + +/** + * [density] があれば [MapCameraPosition.paddings] を Mapbox のパディングとして渡す。 + * Mapbox はパディングを除いた領域の中央にカメラの中心を置き、tilt の遠近もエンジンが解く。 + * パディングは次に変えるまで残るので、null(未指定)のときは渡さない。 + */ +internal fun MapCameraPosition.toCameraOptions(density: Float?): CameraOptions { + val padding = if (density != null) paddings?.toMapboxEdgeInsets(density) else null if (this.tilt >= 0) { return CameraOptions .Builder() @@ -32,8 +55,7 @@ fun MapCameraPosition.toCameraOptions(): CameraOptions { .zoom(ZoomAltitudeConverter.googleZoomToMapboxZoom(zoom)) .pitch(tilt) .bearing(CameraBearing.toNativeHeading(bearing)) - // TODO: - // .padding(paddings?.toEdgeInsects()) + .padding(padding) .build() } else { // tilt < 0: Mapbox cannot represent an upward pitch directly. @@ -53,8 +75,7 @@ fun MapCameraPosition.toCameraOptions(): CameraOptions { .zoom(mapboxZoom) .pitch(tiltAbsDeg) .bearing(CameraBearing.toNativeHeading(bearing)) - // TODO: - // .padding(paddings?.toEdgeInsects()) + .padding(padding) .build() } } @@ -79,6 +100,7 @@ fun MapCameraPosition.Companion.from(cameraPosition: MapCameraPositionInterface) tilt = cameraPosition.tilt, paddings = MapboxPaddings.from(cameraPosition.paddings), visibleRegion = cameraPosition.visibleRegion, + fov = cameraPosition.fov, ) } @@ -101,20 +123,24 @@ fun CameraState.toMapCameraPosition() = logicalTiltHint = null, ) -internal fun CameraState.toMapCameraPosition(logicalTiltHint: Double?) = - toMapCameraPosition( - center = center.toGeoPoint(), - mapboxZoom = zoom, - bearing = bearing, - pitch = pitch, - logicalTiltHint = logicalTiltHint, - ) +internal fun CameraState.toMapCameraPosition( + logicalTiltHint: Double?, + density: Float? = null, +) = toMapCameraPosition( + center = center.toGeoPoint(), + mapboxZoom = zoom, + bearing = bearing, + pitch = pitch, + paddings = density?.let { padding.toMapPaddings(it) }, + logicalTiltHint = logicalTiltHint, +) internal data class MapboxCameraStateSnapshot( val cameraState: CameraState, val logicalTiltHint: Double?, + val density: Float? = null, ) { - fun toMapCameraPosition(): MapCameraPosition = cameraState.toMapCameraPosition(logicalTiltHint) + fun toMapCameraPosition(): MapCameraPosition = cameraState.toMapCameraPosition(logicalTiltHint, density) } private fun toMapCameraPosition( @@ -135,6 +161,7 @@ private fun toMapCameraPosition( tilt = pitch, paddings = paddings, visibleRegion = visibleRegion, + fov = MAPBOX_VERTICAL_FOV, ) } @@ -153,5 +180,6 @@ private fun toMapCameraPosition( tilt = -pitchAbsDeg, paddings = paddings, visibleRegion = visibleRegion, + fov = MAPBOX_VERTICAL_FOV, ) } diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt index 4211aea..2b54385 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt @@ -107,7 +107,7 @@ fun MapboxMapView( modifier = modifier, viewProvider = { val cameraOptions = - state.cameraPosition.toCameraOptions() + state.cameraPosition.toCameraOptions(context.resources.displayMetrics.density) val styleUri = state.mapDesignType.getValue() diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerCamera.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerCamera.kt index fd3d37f..157b233 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerCamera.kt @@ -31,8 +31,11 @@ internal fun MapboxMapViewController.readLogicalCameraPosition(): MapCameraPosit MapboxCameraStateSnapshot( cameraState = holder.map.cameraState, logicalTiltHint = lastLogicalCameraPosition?.tilt, + density = density(), ).toMapCameraPosition() +internal fun MapboxMapViewController.density(): Float = holder.mapView.resources.displayMetrics.density + /** * Async version of getMapCameraPosition that can be called from background threads. * Uses withContext to ensure Mapbox SDK calls run on the main thread. @@ -43,7 +46,7 @@ internal suspend fun MapboxMapViewController.getMapCameraPositionAsync(): MapCam } internal fun MapboxMapViewController.handleMoveCamera(position: MapCameraPosition) { - val cameraOptions = position.toCameraOptions() + val cameraOptions = position.toCameraOptions(density()) lastLogicalCameraPosition = position mainCoroutine.launch { holder.map.setCamera(cameraOptions) @@ -54,7 +57,7 @@ internal fun MapboxMapViewController.handleAnimateCamera( position: MapCameraPosition, duration: Long, ) { - val targetCamera = position.toCameraOptions() + val targetCamera = position.toCameraOptions(density()) lastLogicalCameraPosition = position val animationOptions = From be2ffafa8d9ba752de46a1028e56db41a369ba5b Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 12 Nov 2026 00:55:18 -0800 Subject: [PATCH 33/34] Emulate camera paddings by shifting the centre Native padding widens the frustum upward. Under tilt a large top padding reaches past the horizon: the engine loads tiles out to it and stalls, and MapLibre also lowers the pitch (60 -> 55.4 deg) and clamps the padding. The target is now placed at the padded centre by moving the camera centre with CameraPaddingEmulation and the provider's fov; native padding is always zero. Read-back undoes the shift, and padding on the initial camera is applied once the map has a size. --- api/android-for-mapbox.api | 2 + .../com/mapconductor/mapbox/MapboxMapView.kt | 4 +- .../mapbox/MapboxMapViewController.kt | 1015 +++++++++-------- .../mapbox/MapboxMapViewControllerCamera.kt | 78 +- 4 files changed, 585 insertions(+), 514 deletions(-) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 496c34f..67b49fb 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -158,6 +158,8 @@ public final class com.mapconductor.mapbox.MapboxMapViewController extends com.m public final class com.mapconductor.mapbox.MapboxMapViewControllerCameraKt { public static final com.mapconductor.core.map.MapCameraPosition getMapCameraPosition(com.mapconductor.mapbox.MapboxMapViewController); public static final com.mapconductor.core.map.MapCameraPosition readLogicalCameraPosition(com.mapconductor.mapbox.MapboxMapViewController); + public static final double viewportHeightDp(com.mapconductor.mapbox.MapboxMapViewController); + public static final com.mapconductor.core.map.MapCameraPosition toNativeCamera(com.mapconductor.mapbox.MapboxMapViewController, com.mapconductor.core.map.MapCameraPosition); public static final float density(com.mapconductor.mapbox.MapboxMapViewController); public static final java.lang.Object getMapCameraPositionAsync(com.mapconductor.mapbox.MapboxMapViewController, kotlin.coroutines.Continuation); public static final void handleMoveCamera(com.mapconductor.mapbox.MapboxMapViewController, com.mapconductor.core.map.MapCameraPosition); diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt index 2b54385..0f60a3b 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt @@ -23,6 +23,7 @@ import com.mapconductor.core.OnMapLoadedHandler import com.mapconductor.core.circle.CircleManager import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapPaddings import com.mapconductor.core.map.MapProjection import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.map.MutableMapServiceRegistry @@ -107,7 +108,8 @@ fun MapboxMapView( modifier = modifier, viewProvider = { val cameraOptions = - state.cameraPosition.toCameraOptions(context.resources.displayMetrics.density) + // パディングはネイティブに渡さない。接続時の moveCamera が中心のずらしで適用する。 + state.cameraPosition.copy(paddings = MapPaddings.Zeros).toCameraOptions(context.resources.displayMetrics.density) val styleUri = state.mapDesignType.getValue() diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt index aedd292..2b044f4 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt @@ -1,505 +1,510 @@ -package com.mapconductor.mapbox - -import com.mapconductor.core.circle.CIRCLE_TILE_LAYER_ID -import com.mapconductor.core.circle.CircleTiler -import com.mapconductor.core.circle.circleTileRasterLayerState -import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID -import com.mapconductor.core.polygon.PolygonTiler -import com.mapconductor.core.polygon.polygonTileRasterLayerState -import MapboxMapViewControllerInterface -import com.mapbox.android.gestures.MoveGestureDetector -import com.mapbox.geojson.Point -import com.mapbox.maps.extension.style.layers.properties.generated.ProjectionName -import com.mapbox.maps.extension.style.projection.generated.Projection -import com.mapbox.maps.extension.style.projection.generated.setProjection -import com.mapbox.maps.plugin.gestures.OnMapClickListener -import com.mapbox.maps.plugin.gestures.OnMapLongClickListener -import com.mapbox.maps.plugin.gestures.OnMoveListener -import com.mapbox.maps.plugin.gestures.addOnMapClickListener -import com.mapbox.maps.plugin.gestures.addOnMapLongClickListener -import com.mapbox.maps.plugin.gestures.addOnMoveListener -import com.mapbox.maps.plugin.gestures.gestures -import com.mapbox.maps.plugin.gestures.removeOnMapClickListener -import com.mapbox.maps.plugin.gestures.removeOnMapLongClickListener -import com.mapbox.maps.plugin.gestures.removeOnMoveListener -import com.mapconductor.core.circle.OnCircleEventHandler -import com.mapconductor.core.controller.BaseMapViewController -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.features.GeoRectBounds -import com.mapconductor.core.groundimage.OnGroundImageEventHandler -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapProjection -import com.mapconductor.core.map.MapUISettings -import com.mapconductor.core.marker.DefaultMarkerEventController -import com.mapconductor.core.marker.MarkerAnimationOverlayHost -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -import com.mapconductor.core.marker.OnMarkerEventHandler -import com.mapconductor.core.marker.StrategyMarkerController -import com.mapconductor.core.marker.dispatchGeoMarkerClick -import com.mapconductor.core.polygon.OnPolygonEventHandler -import com.mapconductor.core.polygon.PolygonState -import com.mapconductor.core.polyline.OnPolylineEventHandler -import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID -import com.mapconductor.core.polyline.PolylineState -import com.mapconductor.core.polyline.PolylineTiler -import com.mapconductor.core.polyline.polylineTileRasterLayerState -import com.mapconductor.mapbox.circle.MapboxCircleController -import com.mapconductor.mapbox.groundimage.MapboxGroundImageController -import com.mapconductor.mapbox.marker.MapboxMarkerController -import com.mapconductor.mapbox.marker.MapboxMarkerOverlayRenderer -import com.mapconductor.mapbox.marker.MarkerDragLayer -import com.mapconductor.mapbox.marker.MarkerLayer -import com.mapconductor.mapbox.polygon.MapboxPolygonController -import com.mapconductor.mapbox.polyline.MapboxPolylineController -import com.mapconductor.mapbox.raster.MapboxRasterLayerController -import java.util.UUID -import java.util.concurrent.atomic.AtomicInteger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -typealias MapboxMapDesignTypeChangeHandler = (MapboxDesignType) -> Unit - -/** - * Mapbox のマップコントローラ。 - * - * **型は公開、構築は非公開。** `createMapboxViewController()` の戻り型として、また - * React Native のような非 Compose ホストが `BaseMapViewController` として受けるために - * 型は見える必要がある(LongdoMapViewController / MapTilerMapViewController も公開)。 - * 一方コンストラクタ引数は internal なコントローラ群なので、`internal constructor` に - * して**組み立てはファクトリ 1 か所に閉じる**。2 経路で組むと配線が片方だけ増える。 - */ -class MapboxMapViewController internal constructor( - override val holder: MapboxMapViewHolder, - internal var projection: MapProjection, - internal val markerController: MapboxMarkerController, - internal val polylineController: MapboxPolylineController, - internal val polygonController: MapboxPolygonController, - internal val groundImageController: MapboxGroundImageController, - internal val circleController: MapboxCircleController, - internal val rasterLayerController: MapboxRasterLayerController, - override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), - override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), -) : BaseMapViewController(), - MapboxMapViewControllerInterface, - OnMapClickListener, - OnMapLongClickListener, - OnMoveListener { - // Track created z-indexed polygon layers to manage add/remove without enumerating style layers - internal val polygonZLayers: MutableSet = mutableSetOf() - internal val markerEventControllers = mutableListOf>() - internal var activeDragController: DefaultMarkerEventController? = null - - internal val cameraUpdateToken = AtomicInteger(0) - internal var lastLogicalCameraPosition: MapCameraPosition? = null - - internal var markerClickListener: OnMarkerEventHandler? = null - internal var markerDragStartListener: OnMarkerEventHandler? = null - internal var markerDragListener: OnMarkerEventHandler? = null - internal var markerDragEndListener: OnMarkerEventHandler? = null - internal var markerAnimateStartListener: OnMarkerEventHandler? = null - internal var markerAnimateEndListener: OnMarkerEventHandler? = null - internal var involvedMapInitializedCallback: Boolean = false - - init { - setupListeners() - registerOverlayController(markerController) - registerOverlayController(polylineController) - registerOverlayController(polygonController) - registerOverlayController(groundImageController) - registerOverlayController(circleController) - registerOverlayController(rasterLayerController) - registerMarkerEventController(DefaultMarkerEventController(markerController)) - - markerController.setRasterLayerCallback { state -> - if (state != null) { - rasterLayerController.upsert(state) - } else { - val markerTileLayers = - rasterLayerController.rasterLayerManager - .allEntities() - .filter { it.state.id.startsWith("marker-tile-") } - markerTileLayers.forEach { entity -> rasterLayerController.removeById(entity.state.id) } - } - } - - // Many polygons are drawn as raster tiles, shown through the same RasterLayer - // controller as the marker tiles (upsert / removeById, never the app's layers). - polygonController.tiler = - PolygonTiler().apply { - onTemplate = { template, tileSize -> - if (template != null) { - rasterLayerController.upsert(polygonTileRasterLayerState(template, tileSize)) - } else { - rasterLayerController.removeById(POLYGON_TILE_LAYER_ID) - } - } - } - - // Many circles too: the same raster layer path, in the circle band. - circleController.tiler = - CircleTiler().apply { - onTemplate = { template, tileSize -> - if (template != null) { - rasterLayerController.upsert(circleTileRasterLayerState(template, tileSize)) - } else { - rasterLayerController.removeById(CIRCLE_TILE_LAYER_ID) - } - } - } - - // Many polylines too: the same raster layer path, in the polyline band. - polylineController.tiler = - PolylineTiler().apply { - onTemplate = { template, tileSize -> - if (template != null) { - rasterLayerController.upsert(polylineTileRasterLayerState(template, tileSize)) - } else { - rasterLayerController.removeById(POLYLINE_TILE_LAYER_ID) - } - } - } - } - - override fun moveCamera(position: MapCameraPosition) = handleMoveCamera(position) - - override fun animateCamera( - position: MapCameraPosition, - duration: Long, - ) = handleAnimateCamera(position, duration) - - override fun setCameraRestriction(restriction: CameraRestriction?) = handleCameraRestriction(restriction) - - override fun fitBounds( - bounds: GeoRectBounds, - padding: Int, - ) = handleFitBounds(bounds, padding) - - override fun onMapLongClick(point: Point): Boolean = handleMapLongClick(point) - - override fun onMapClick(point: Point): Boolean = handleMapClick(point) - - override fun onMove(detector: MoveGestureDetector): Boolean = handleMove(detector) - - override fun onMoveBegin(detector: MoveGestureDetector) = handleMoveBegin(detector) - - override fun onMoveEnd(detector: MoveGestureDetector) = handleMoveEnd(detector) - - /** - * マーカーのヒットテスト。クリックカスケードの先頭。 - * - * Mapbox は地図クリックの座標からそのまま引けるので、コアの - * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 - */ - override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = - markerEventControllers.dispatchGeoMarkerClick(position) - - // 拡張ファイル(Style / Camera / Gestures)からは基底クラスの protected へ - // 触れないため、ここで internal の入口を用意しておく。 - - internal fun emitMapInitialized() { - mapInitializedCallback?.invoke() - } - - internal fun emitCameraMoveStart(position: MapCameraPosition) { - cameraMoveStartCallback?.invoke(position) - } - - internal fun emitCameraMove(position: MapCameraPosition) { - cameraMoveCallback?.invoke(position) - } - - internal fun emitCameraMoveEnd(position: MapCameraPosition) { - cameraMoveEndCallback?.invoke(position) - } - - internal suspend fun emitCameraPosition(position: MapCameraPosition) { - notifyMapCameraPosition(position) - } - - fun setupListeners() { - holder.map.subscribeCameraChanged { - val token = cameraUpdateToken.incrementAndGet() - defaultCoroutine.launch { - if (token != cameraUpdateToken.get()) return@launch - // Calculate camera position on background thread - val mapCameraPosition = getMapCameraPositionAsync() ?: return@launch - notifyMapCameraPosition(mapCameraPosition) - cameraMoveCallback?.invoke(mapCameraPosition) - } - } - holder.map.subscribeStyleLoaded { - holder.map.style?.let { style -> - applyProjection() - // When style reloads, our runtime sources/layers/images are dropped. - // Reattach overlays and ensure marker images exist, then redraw. - attachOverlaySourcesAndLayers(style) - markerEventControllers.forEach { controller -> - val renderer = controller.renderer as MapboxMarkerOverlayRenderer - renderer.ensureStyleImages(style) - renderer.redraw() - } - mainCoroutine.launch { - groundImageController.reapplyStyle() - rasterLayerController.reapplyStyle() - } - - // After style is ready, trigger an initial camera update - sendInitialCameraUpdate() - - style.toMapDesignType().let { mapDesign -> - this@MapboxMapViewController.mapDesignType = mapDesign - mapDesignTypeChangeListener?.invoke(mapDesign) - } - - // Last, once the overlays are back on: a vector style - // adjustment lost everything it set with the old style, and - // this is the moment the new one is known to be complete. - styleLoadedListener?.invoke() - } - } - - holder.map.subscribeStyleImageMissing { evt -> - val missingId = evt.imageId - markerEventControllers.forEach { controller -> - (controller.renderer as MapboxMarkerOverlayRenderer).onStyleImageMissing(missingId) - } - } - holder.map.subscribeMapIdle { - mainCoroutine.launch { - getMapCameraPosition()?.let { mapCameraPosition -> - defaultCoroutine.launch { - notifyMapCameraPosition(mapCameraPosition) - cameraMoveEndCallback?.invoke(mapCameraPosition) - } - } - } - } - - holder.map.removeOnMapClickListener(this) - holder.map.addOnMapClickListener(this) - - holder.map.removeOnMapLongClickListener(this) - holder.map.addOnMapLongClickListener(this) - - holder.map.removeOnMoveListener(this) - holder.map.addOnMoveListener(this) - } - - fun setProjection(value: MapProjection) { - if (projection == value) return - projection = value - mainCoroutine.launch { - applyProjection() - } - } - - private fun applyProjection() { - if (holder.map.style == null) return - val name = - when (projection) { - MapProjection.Mercator -> ProjectionName.MERCATOR - MapProjection.Globe -> ProjectionName.GLOBE - } - holder.map.setProjection(Projection(name)) - } - - override suspend fun clearOverlays() { - markerController.clear() - polylineController.clear() - polygonController.clear() - groundImageController.clear() - circleController.clear() - rasterLayerController.clear() - // 拡張モジュール(クラスタリングなど)が登録した Strategy のマーカーも消す。 - clearStrategyMarkerControllers() - } - - override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { - (markerController.renderer as MapboxMarkerOverlayRenderer).animationOverlayHost = host - } - - override suspend fun compositionPolygons(data: List) { - polygonController.add(data) - holder.map.getStyle { ensurePolygonZLayers(it) } - } - - override suspend fun updatePolygon(state: PolygonState) { - polygonController.update(state) - holder.map.getStyle { ensurePolygonZLayers(it) } - } - - @Deprecated("Use CircleState.onClick instead.") - override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { - this.circleController.clickListener = listener - } - - @Deprecated("Use GroundImageState.onClick instead.") - override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { - this.groundImageController.clickListener = listener - } - - override fun hasPolyline(state: PolylineState): Boolean = - this.polylineController.polylineManager - .hasEntity(state.id) - - override fun hasPolygon(state: PolygonState): Boolean = - this.polygonController.polygonManager - .hasEntity(state.id) - - override fun applyUISettings(settings: MapUISettings) { - holder.mapView.gestures.apply { - scrollEnabled = settings.scrollGesture - pinchToZoomEnabled = settings.zoomGesture - doubleTapToZoomInEnabled = settings.zoomGesture - doubleTouchToZoomOutEnabled = settings.zoomGesture - quickZoomEnabled = settings.zoomGesture - rotateEnabled = settings.rotateGesture - pitchEnabled = settings.tiltGesture - } - } - - @Deprecated("Use MarkerState.onDragStart instead.") - override fun setOnMarkerDragStart(listener: OnMarkerEventHandler?) { - markerDragStartListener = listener - markerEventControllers.forEach { it.setDragStartListener(listener) } - } - - @Deprecated("Use MarkerState.onDrag instead.") - override fun setOnMarkerDrag(listener: OnMarkerEventHandler?) { - markerDragListener = listener - markerEventControllers.forEach { it.setDragListener(listener) } - } - - @Deprecated("Use MarkerState.onDragEnd instead.") - override fun setOnMarkerDragEnd(listener: OnMarkerEventHandler?) { - markerDragEndListener = listener - markerEventControllers.forEach { it.setDragEndListener(listener) } - } - - @Deprecated("Use MarkerState.onAnimateStart instead.") - override fun setOnMarkerAnimateStart(listener: OnMarkerEventHandler?) { - markerAnimateStartListener = listener - markerEventControllers.forEach { it.setAnimateStartListener(listener) } - } - - @Deprecated("Use MarkerState.onAnimateEnd instead.") - override fun setOnMarkerAnimateEnd(listener: OnMarkerEventHandler?) { - markerAnimateEndListener = listener - markerEventControllers.forEach { it.setAnimateEndListener(listener) } - } - - @Deprecated("Use MarkerState.onClick instead.") - override fun setOnMarkerClickListener(listener: OnMarkerEventHandler?) { - markerClickListener = listener - markerEventControllers.forEach { it.setClickListener(listener) } - } - - @Deprecated("Use PolylineState.onClick instead.") - override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { - polylineController.clickListener = listener - } - - @Deprecated("Use PolygonState.onClick instead.") - override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { - polygonController.clickListener = listener - } - - internal var mapDesignType: MapboxDesignType = MapboxMapDesign.Standard - - internal var mapDesignTypeChangeListener: MapboxMapDesignTypeChangeHandler? = null - - /** - * Called once the map has a style up and the overlays are back on it. - * - * How a vector style adjustment survives a design change. Kept in step - * with `MapLibreViewController.styleLoadedListener`. - */ - internal var styleLoadedListener: (() -> Unit)? = null - - /** The document the map was last asked for, and the one it reported loading. */ - private var requestedStyle: String? = null - private var loadedStyleValue: String? = null - - /** - * Whether the style on screen is the document the map was last asked - * for. - * - * What [MapboxStyleMutationTarget] answers `isReady` with, and not the - * same question as "is a style loaded". A vector style hands the map a - * new document and then applies the deltas compiled for it; in between, - * the map still has the document being replaced, whose layers have - * other names, so every delta came back as a property this map "cannot - * set by name". Mirrors `MapLibreViewController.isShowingRequestedStyle`. - */ - internal fun isShowingRequestedStyle(): Boolean = - holder.map.style != null && loadedStyleValue == requestedStyle - - override fun setMapDesignType(value: MapboxDesignType) { - val document = value.getValue() - requestedStyle = document - mainCoroutine.launch { - holder.mapView.mapboxMap.loadStyle(document) { - loadedStyleValue = document - } - } - } - - override fun setMapDesignTypeChangeListener(listener: MapboxMapDesignTypeChangeHandler) { - mapDesignTypeChangeListener = listener - } - - internal fun registerMarkerEventController(controller: DefaultMarkerEventController) { - if (markerEventControllers.contains(controller)) return - markerEventControllers.add(controller) - controller.setClickListener(markerClickListener) - controller.setDragStartListener(markerDragStartListener) - controller.setDragListener(markerDragListener) - controller.setDragEndListener(markerDragEndListener) - controller.setAnimateStartListener(markerAnimateStartListener) - controller.setAnimateEndListener(markerAnimateEndListener) - - holder.map.style?.let { style -> - val renderer = controller.renderer as MapboxMarkerOverlayRenderer - attachMarkerLayers(style, renderer) - renderer.ensureStyleImages(style) - renderer.redraw() - } - } - - fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface { - val groupId = UUID.randomUUID().toString() - val markerLayer = - MarkerLayer( - sourceId = "markers-source-$groupId", - layerId = "markers-layer-$groupId", - ) - val dragLayer = - MarkerDragLayer( - sourceId = "marker-drag-source-$groupId", - layerId = "marker-drag-layer-$groupId", - ) - return MapboxMarkerOverlayRenderer( - holder = holder, - markerManager = strategy.markerManager, - markerLayer = markerLayer, - dragLayer = dragLayer, - ) - } - - fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = DefaultMarkerEventController(controller) - - fun registerMarkerEventController(controller: MarkerEventControllerInterface) { - @Suppress("UNCHECKED_CAST") - val typed = controller as? DefaultMarkerEventController ?: return - registerMarkerEventController(typed) - } -} +package com.mapconductor.mapbox + +import com.mapconductor.core.circle.CIRCLE_TILE_LAYER_ID +import com.mapconductor.core.circle.CircleTiler +import com.mapconductor.core.circle.circleTileRasterLayerState +import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID +import com.mapconductor.core.polygon.PolygonTiler +import com.mapconductor.core.polygon.polygonTileRasterLayerState +import MapboxMapViewControllerInterface +import com.mapbox.android.gestures.MoveGestureDetector +import com.mapbox.geojson.Point +import com.mapbox.maps.extension.style.layers.properties.generated.ProjectionName +import com.mapbox.maps.extension.style.projection.generated.Projection +import com.mapbox.maps.extension.style.projection.generated.setProjection +import com.mapbox.maps.plugin.gestures.OnMapClickListener +import com.mapbox.maps.plugin.gestures.OnMapLongClickListener +import com.mapbox.maps.plugin.gestures.OnMoveListener +import com.mapbox.maps.plugin.gestures.addOnMapClickListener +import com.mapbox.maps.plugin.gestures.addOnMapLongClickListener +import com.mapbox.maps.plugin.gestures.addOnMoveListener +import com.mapbox.maps.plugin.gestures.gestures +import com.mapbox.maps.plugin.gestures.removeOnMapClickListener +import com.mapbox.maps.plugin.gestures.removeOnMapLongClickListener +import com.mapbox.maps.plugin.gestures.removeOnMoveListener +import com.mapconductor.core.circle.OnCircleEventHandler +import com.mapconductor.core.controller.BaseMapViewController +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.OnGroundImageEventHandler +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapPaddingsInterface +import com.mapconductor.core.map.MapPaddings +import com.mapconductor.core.map.MapProjection +import com.mapconductor.core.map.MapUISettings +import com.mapconductor.core.marker.DefaultMarkerEventController +import com.mapconductor.core.marker.MarkerAnimationOverlayHost +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +import com.mapconductor.core.marker.OnMarkerEventHandler +import com.mapconductor.core.marker.StrategyMarkerController +import com.mapconductor.core.marker.dispatchGeoMarkerClick +import com.mapconductor.core.polygon.OnPolygonEventHandler +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTiler +import com.mapconductor.core.polyline.polylineTileRasterLayerState +import com.mapconductor.mapbox.circle.MapboxCircleController +import com.mapconductor.mapbox.groundimage.MapboxGroundImageController +import com.mapconductor.mapbox.marker.MapboxMarkerController +import com.mapconductor.mapbox.marker.MapboxMarkerOverlayRenderer +import com.mapconductor.mapbox.marker.MarkerDragLayer +import com.mapconductor.mapbox.marker.MarkerLayer +import com.mapconductor.mapbox.polygon.MapboxPolygonController +import com.mapconductor.mapbox.polyline.MapboxPolylineController +import com.mapconductor.mapbox.raster.MapboxRasterLayerController +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +typealias MapboxMapDesignTypeChangeHandler = (MapboxDesignType) -> Unit + +/** + * Mapbox のマップコントローラ。 + * + * **型は公開、構築は非公開。** `createMapboxViewController()` の戻り型として、また + * React Native のような非 Compose ホストが `BaseMapViewController` として受けるために + * 型は見える必要がある(LongdoMapViewController / MapTilerMapViewController も公開)。 + * 一方コンストラクタ引数は internal なコントローラ群なので、`internal constructor` に + * して**組み立てはファクトリ 1 か所に閉じる**。2 経路で組むと配線が片方だけ増える。 + */ +class MapboxMapViewController internal constructor( + override val holder: MapboxMapViewHolder, + internal var projection: MapProjection, + internal val markerController: MapboxMarkerController, + internal val polylineController: MapboxPolylineController, + internal val polygonController: MapboxPolygonController, + internal val groundImageController: MapboxGroundImageController, + internal val circleController: MapboxCircleController, + internal val rasterLayerController: MapboxRasterLayerController, + override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), + override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), +) : BaseMapViewController(), + MapboxMapViewControllerInterface, + OnMapClickListener, + OnMapLongClickListener, + OnMoveListener { + // Track created z-indexed polygon layers to manage add/remove without enumerating style layers + internal val polygonZLayers: MutableSet = mutableSetOf() + internal val markerEventControllers = mutableListOf>() + internal var activeDragController: DefaultMarkerEventController? = null + + internal val cameraUpdateToken = AtomicInteger(0) + internal var lastLogicalCameraPosition: MapCameraPosition? = null + + /** アプリが指定したパディング(dp)。ネイティブには渡さず、中心のずらしで再現する。 */ + internal var cameraPaddings: MapPaddingsInterface = MapPaddings.Zeros + + internal var markerClickListener: OnMarkerEventHandler? = null + internal var markerDragStartListener: OnMarkerEventHandler? = null + internal var markerDragListener: OnMarkerEventHandler? = null + internal var markerDragEndListener: OnMarkerEventHandler? = null + internal var markerAnimateStartListener: OnMarkerEventHandler? = null + internal var markerAnimateEndListener: OnMarkerEventHandler? = null + internal var involvedMapInitializedCallback: Boolean = false + + init { + setupListeners() + registerOverlayController(markerController) + registerOverlayController(polylineController) + registerOverlayController(polygonController) + registerOverlayController(groundImageController) + registerOverlayController(circleController) + registerOverlayController(rasterLayerController) + registerMarkerEventController(DefaultMarkerEventController(markerController)) + + markerController.setRasterLayerCallback { state -> + if (state != null) { + rasterLayerController.upsert(state) + } else { + val markerTileLayers = + rasterLayerController.rasterLayerManager + .allEntities() + .filter { it.state.id.startsWith("marker-tile-") } + markerTileLayers.forEach { entity -> rasterLayerController.removeById(entity.state.id) } + } + } + + // Many polygons are drawn as raster tiles, shown through the same RasterLayer + // controller as the marker tiles (upsert / removeById, never the app's layers). + polygonController.tiler = + PolygonTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(polygonTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(POLYGON_TILE_LAYER_ID) + } + } + } + + // Many circles too: the same raster layer path, in the circle band. + circleController.tiler = + CircleTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(circleTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(CIRCLE_TILE_LAYER_ID) + } + } + } + + // Many polylines too: the same raster layer path, in the polyline band. + polylineController.tiler = + PolylineTiler().apply { + onTemplate = { template, tileSize -> + if (template != null) { + rasterLayerController.upsert(polylineTileRasterLayerState(template, tileSize)) + } else { + rasterLayerController.removeById(POLYLINE_TILE_LAYER_ID) + } + } + } + } + + override fun moveCamera(position: MapCameraPosition) = handleMoveCamera(position) + + override fun animateCamera( + position: MapCameraPosition, + duration: Long, + ) = handleAnimateCamera(position, duration) + + override fun setCameraRestriction(restriction: CameraRestriction?) = handleCameraRestriction(restriction) + + override fun fitBounds( + bounds: GeoRectBounds, + padding: Int, + ) = handleFitBounds(bounds, padding) + + override fun onMapLongClick(point: Point): Boolean = handleMapLongClick(point) + + override fun onMapClick(point: Point): Boolean = handleMapClick(point) + + override fun onMove(detector: MoveGestureDetector): Boolean = handleMove(detector) + + override fun onMoveBegin(detector: MoveGestureDetector) = handleMoveBegin(detector) + + override fun onMoveEnd(detector: MoveGestureDetector) = handleMoveEnd(detector) + + /** + * マーカーのヒットテスト。クリックカスケードの先頭。 + * + * Mapbox は地図クリックの座標からそのまま引けるので、コアの + * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 + */ + override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = + markerEventControllers.dispatchGeoMarkerClick(position) + + // 拡張ファイル(Style / Camera / Gestures)からは基底クラスの protected へ + // 触れないため、ここで internal の入口を用意しておく。 + + internal fun emitMapInitialized() { + mapInitializedCallback?.invoke() + } + + internal fun emitCameraMoveStart(position: MapCameraPosition) { + cameraMoveStartCallback?.invoke(position) + } + + internal fun emitCameraMove(position: MapCameraPosition) { + cameraMoveCallback?.invoke(position) + } + + internal fun emitCameraMoveEnd(position: MapCameraPosition) { + cameraMoveEndCallback?.invoke(position) + } + + internal suspend fun emitCameraPosition(position: MapCameraPosition) { + notifyMapCameraPosition(position) + } + + fun setupListeners() { + holder.map.subscribeCameraChanged { + val token = cameraUpdateToken.incrementAndGet() + defaultCoroutine.launch { + if (token != cameraUpdateToken.get()) return@launch + // Calculate camera position on background thread + val mapCameraPosition = getMapCameraPositionAsync() ?: return@launch + notifyMapCameraPosition(mapCameraPosition) + cameraMoveCallback?.invoke(mapCameraPosition) + } + } + holder.map.subscribeStyleLoaded { + holder.map.style?.let { style -> + applyProjection() + // When style reloads, our runtime sources/layers/images are dropped. + // Reattach overlays and ensure marker images exist, then redraw. + attachOverlaySourcesAndLayers(style) + markerEventControllers.forEach { controller -> + val renderer = controller.renderer as MapboxMarkerOverlayRenderer + renderer.ensureStyleImages(style) + renderer.redraw() + } + mainCoroutine.launch { + groundImageController.reapplyStyle() + rasterLayerController.reapplyStyle() + } + + // After style is ready, trigger an initial camera update + sendInitialCameraUpdate() + + style.toMapDesignType().let { mapDesign -> + this@MapboxMapViewController.mapDesignType = mapDesign + mapDesignTypeChangeListener?.invoke(mapDesign) + } + + // Last, once the overlays are back on: a vector style + // adjustment lost everything it set with the old style, and + // this is the moment the new one is known to be complete. + styleLoadedListener?.invoke() + } + } + + holder.map.subscribeStyleImageMissing { evt -> + val missingId = evt.imageId + markerEventControllers.forEach { controller -> + (controller.renderer as MapboxMarkerOverlayRenderer).onStyleImageMissing(missingId) + } + } + holder.map.subscribeMapIdle { + mainCoroutine.launch { + getMapCameraPosition()?.let { mapCameraPosition -> + defaultCoroutine.launch { + notifyMapCameraPosition(mapCameraPosition) + cameraMoveEndCallback?.invoke(mapCameraPosition) + } + } + } + } + + holder.map.removeOnMapClickListener(this) + holder.map.addOnMapClickListener(this) + + holder.map.removeOnMapLongClickListener(this) + holder.map.addOnMapLongClickListener(this) + + holder.map.removeOnMoveListener(this) + holder.map.addOnMoveListener(this) + } + + fun setProjection(value: MapProjection) { + if (projection == value) return + projection = value + mainCoroutine.launch { + applyProjection() + } + } + + private fun applyProjection() { + if (holder.map.style == null) return + val name = + when (projection) { + MapProjection.Mercator -> ProjectionName.MERCATOR + MapProjection.Globe -> ProjectionName.GLOBE + } + holder.map.setProjection(Projection(name)) + } + + override suspend fun clearOverlays() { + markerController.clear() + polylineController.clear() + polygonController.clear() + groundImageController.clear() + circleController.clear() + rasterLayerController.clear() + // 拡張モジュール(クラスタリングなど)が登録した Strategy のマーカーも消す。 + clearStrategyMarkerControllers() + } + + override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { + (markerController.renderer as MapboxMarkerOverlayRenderer).animationOverlayHost = host + } + + override suspend fun compositionPolygons(data: List) { + polygonController.add(data) + holder.map.getStyle { ensurePolygonZLayers(it) } + } + + override suspend fun updatePolygon(state: PolygonState) { + polygonController.update(state) + holder.map.getStyle { ensurePolygonZLayers(it) } + } + + @Deprecated("Use CircleState.onClick instead.") + override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { + this.circleController.clickListener = listener + } + + @Deprecated("Use GroundImageState.onClick instead.") + override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { + this.groundImageController.clickListener = listener + } + + override fun hasPolyline(state: PolylineState): Boolean = + this.polylineController.polylineManager + .hasEntity(state.id) + + override fun hasPolygon(state: PolygonState): Boolean = + this.polygonController.polygonManager + .hasEntity(state.id) + + override fun applyUISettings(settings: MapUISettings) { + holder.mapView.gestures.apply { + scrollEnabled = settings.scrollGesture + pinchToZoomEnabled = settings.zoomGesture + doubleTapToZoomInEnabled = settings.zoomGesture + doubleTouchToZoomOutEnabled = settings.zoomGesture + quickZoomEnabled = settings.zoomGesture + rotateEnabled = settings.rotateGesture + pitchEnabled = settings.tiltGesture + } + } + + @Deprecated("Use MarkerState.onDragStart instead.") + override fun setOnMarkerDragStart(listener: OnMarkerEventHandler?) { + markerDragStartListener = listener + markerEventControllers.forEach { it.setDragStartListener(listener) } + } + + @Deprecated("Use MarkerState.onDrag instead.") + override fun setOnMarkerDrag(listener: OnMarkerEventHandler?) { + markerDragListener = listener + markerEventControllers.forEach { it.setDragListener(listener) } + } + + @Deprecated("Use MarkerState.onDragEnd instead.") + override fun setOnMarkerDragEnd(listener: OnMarkerEventHandler?) { + markerDragEndListener = listener + markerEventControllers.forEach { it.setDragEndListener(listener) } + } + + @Deprecated("Use MarkerState.onAnimateStart instead.") + override fun setOnMarkerAnimateStart(listener: OnMarkerEventHandler?) { + markerAnimateStartListener = listener + markerEventControllers.forEach { it.setAnimateStartListener(listener) } + } + + @Deprecated("Use MarkerState.onAnimateEnd instead.") + override fun setOnMarkerAnimateEnd(listener: OnMarkerEventHandler?) { + markerAnimateEndListener = listener + markerEventControllers.forEach { it.setAnimateEndListener(listener) } + } + + @Deprecated("Use MarkerState.onClick instead.") + override fun setOnMarkerClickListener(listener: OnMarkerEventHandler?) { + markerClickListener = listener + markerEventControllers.forEach { it.setClickListener(listener) } + } + + @Deprecated("Use PolylineState.onClick instead.") + override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { + polylineController.clickListener = listener + } + + @Deprecated("Use PolygonState.onClick instead.") + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { + polygonController.clickListener = listener + } + + internal var mapDesignType: MapboxDesignType = MapboxMapDesign.Standard + + internal var mapDesignTypeChangeListener: MapboxMapDesignTypeChangeHandler? = null + + /** + * Called once the map has a style up and the overlays are back on it. + * + * How a vector style adjustment survives a design change. Kept in step + * with `MapLibreViewController.styleLoadedListener`. + */ + internal var styleLoadedListener: (() -> Unit)? = null + + /** The document the map was last asked for, and the one it reported loading. */ + private var requestedStyle: String? = null + private var loadedStyleValue: String? = null + + /** + * Whether the style on screen is the document the map was last asked + * for. + * + * What [MapboxStyleMutationTarget] answers `isReady` with, and not the + * same question as "is a style loaded". A vector style hands the map a + * new document and then applies the deltas compiled for it; in between, + * the map still has the document being replaced, whose layers have + * other names, so every delta came back as a property this map "cannot + * set by name". Mirrors `MapLibreViewController.isShowingRequestedStyle`. + */ + internal fun isShowingRequestedStyle(): Boolean = + holder.map.style != null && loadedStyleValue == requestedStyle + + override fun setMapDesignType(value: MapboxDesignType) { + val document = value.getValue() + requestedStyle = document + mainCoroutine.launch { + holder.mapView.mapboxMap.loadStyle(document) { + loadedStyleValue = document + } + } + } + + override fun setMapDesignTypeChangeListener(listener: MapboxMapDesignTypeChangeHandler) { + mapDesignTypeChangeListener = listener + } + + internal fun registerMarkerEventController(controller: DefaultMarkerEventController) { + if (markerEventControllers.contains(controller)) return + markerEventControllers.add(controller) + controller.setClickListener(markerClickListener) + controller.setDragStartListener(markerDragStartListener) + controller.setDragListener(markerDragListener) + controller.setDragEndListener(markerDragEndListener) + controller.setAnimateStartListener(markerAnimateStartListener) + controller.setAnimateEndListener(markerAnimateEndListener) + + holder.map.style?.let { style -> + val renderer = controller.renderer as MapboxMarkerOverlayRenderer + attachMarkerLayers(style, renderer) + renderer.ensureStyleImages(style) + renderer.redraw() + } + } + + fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface { + val groupId = UUID.randomUUID().toString() + val markerLayer = + MarkerLayer( + sourceId = "markers-source-$groupId", + layerId = "markers-layer-$groupId", + ) + val dragLayer = + MarkerDragLayer( + sourceId = "marker-drag-source-$groupId", + layerId = "marker-drag-layer-$groupId", + ) + return MapboxMarkerOverlayRenderer( + holder = holder, + markerManager = strategy.markerManager, + markerLayer = markerLayer, + dragLayer = dragLayer, + ) + } + + fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = DefaultMarkerEventController(controller) + + fun registerMarkerEventController(controller: MarkerEventControllerInterface) { + @Suppress("UNCHECKED_CAST") + val typed = controller as? DefaultMarkerEventController ?: return + registerMarkerEventController(typed) + } +} diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerCamera.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerCamera.kt index 157b233..e574209 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerCamera.kt @@ -3,9 +3,12 @@ package com.mapconductor.mapbox import com.mapbox.maps.CameraBoundsOptions import com.mapbox.maps.plugin.animation.MapAnimationOptions import com.mapbox.maps.plugin.animation.flyTo +import android.view.View import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.map.CameraPaddingEmulation import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapPaddings import com.mapconductor.core.map.buildVisibleRegion import com.mapconductor.mapbox.zoom.ZoomAltitudeConverter import android.animation.Animator @@ -28,11 +31,63 @@ internal fun MapboxMapViewController.getMapCameraPosition(): MapCameraPosition? } internal fun MapboxMapViewController.readLogicalCameraPosition(): MapCameraPosition = - MapboxCameraStateSnapshot( - cameraState = holder.map.cameraState, - logicalTiltHint = lastLogicalCameraPosition?.tilt, - density = density(), - ).toMapCameraPosition() + CameraPaddingEmulation.fromNativeCamera( + MapboxCameraStateSnapshot( + cameraState = holder.map.cameraState, + logicalTiltHint = lastLogicalCameraPosition?.tilt, + density = density(), + ).toMapCameraPosition(), + cameraPaddings, + MAPBOX_VERTICAL_FOV, + viewportHeightDp(), + ) + +/** 地図の高さ(dp)。パディングのエミュレーションで遠近を解くのに使う。 */ +internal fun MapboxMapViewController.viewportHeightDp(): Double = holder.mapView.height / density().toDouble() + +/** + * パディングを中心のずらしに置き換えたカメラ。Mapbox のパディングは常に 0 にする。 + * + * ネイティブのパディングは視錐台を上へ広げる。tilt があると上のパディングが + * 大きいほど画面の上端が地平線の先まで届き、そこまでタイルを読みに行って固まる。 + * 中心をずらせば、パディング無しと同じ視錐台のまま target をパディング中央に置ける。 + * paddings が null なら今のパディングを使い続ける。 + */ +internal fun MapboxMapViewController.toNativeCamera(position: MapCameraPosition): MapCameraPosition { + position.paddings?.let { cameraPaddings = it } + return CameraPaddingEmulation + .toNativeCamera(position.copy(paddings = cameraPaddings), MAPBOX_VERTICAL_FOV, viewportHeightDp()) + .copy(paddings = MapPaddings.Zeros) +} + +/** 地図がまだ大きさを持たない(レイアウト前)ときは、パディングを解けないので最初のレイアウトまで待つ。 */ +private fun MapboxMapViewController.needsLayoutFor(position: MapCameraPosition): Boolean { + val paddings = position.paddings ?: cameraPaddings + val padded = paddings.top != 0.0 || paddings.left != 0.0 || paddings.bottom != 0.0 || paddings.right != 0.0 + return padded && holder.mapView.height <= 0 +} + +private fun MapboxMapViewController.afterFirstLayout(action: () -> Unit) { + holder.mapView.addOnLayoutChangeListener( + object : View.OnLayoutChangeListener { + override fun onLayoutChange( + v: View, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int, + ) { + if (bottom - top <= 0) return + v.removeOnLayoutChangeListener(this) + action() + } + }, + ) +} internal fun MapboxMapViewController.density(): Float = holder.mapView.resources.displayMetrics.density @@ -46,10 +101,13 @@ internal suspend fun MapboxMapViewController.getMapCameraPositionAsync(): MapCam } internal fun MapboxMapViewController.handleMoveCamera(position: MapCameraPosition) { - val cameraOptions = position.toCameraOptions(density()) lastLogicalCameraPosition = position mainCoroutine.launch { - holder.map.setCamera(cameraOptions) + if (needsLayoutFor(position)) { + afterFirstLayout { handleMoveCamera(position) } + return@launch + } + holder.map.setCamera(toNativeCamera(position).toCameraOptions(density())) } } @@ -57,7 +115,11 @@ internal fun MapboxMapViewController.handleAnimateCamera( position: MapCameraPosition, duration: Long, ) { - val targetCamera = position.toCameraOptions(density()) + if (needsLayoutFor(position)) { + afterFirstLayout { handleAnimateCamera(position, duration) } + return + } + val targetCamera = toNativeCamera(position).toCameraOptions(density()) lastLogicalCameraPosition = position val animationOptions = From 158babfea98be64bd776ab17937cafbd1d2eb777 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 13 Nov 2026 03:40:21 -0800 Subject: [PATCH 34/34] Release 1.4.0 --- api/android-for-mapbox.api | 4 ++++ gradle.properties | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 67b49fb..0e55423 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -194,6 +194,9 @@ public final class com.mapconductor.mapbox.MapboxMapViewHolder implements com.ma public void onLowMemory(); public void onStart(); public void onStop(); + public com.mapconductor.core.projection.ProjectedPoint toProjectedPoint(com.mapconductor.core.features.GeoPointInterface); + public com.mapconductor.core.features.GeoPoint fromProjectedPoint(com.mapconductor.core.projection.ProjectedPoint); + public com.mapconductor.core.projection.MapCoordinateSystem getCoordinateSystem(); public void onResume(); public java.lang.Object getMapView(); public java.lang.Object getMap(); @@ -227,6 +230,7 @@ public final class com.mapconductor.mapbox.MapboxOverlayStacking { public final com.mapconductor.core.controller.OverlayStacking$Key key(com.mapconductor.core.controller.OverlayKind, int); public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYGON_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getCIRCLE_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYLINE_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_DRAG(); public final void add(com.mapbox.maps.MapboxMap, com.mapbox.maps.Style, com.mapbox.maps.extension.style.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); diff --git a/gradle.properties b/gradle.properties index c018eb9..8b1a332 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,5 +8,5 @@ minSdk=26 javaVersion=17 jvmTarget=17 -libraryVersion=1.3.1 +libraryVersion=1.4.0 android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false