From 09dbcd2628fe8c9495e6a2e55206285b63d9f4b1 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 4 Sep 2026 09:10:08 +0900 Subject: [PATCH 01/31] Make bearing a clockwise map rotation (#12) 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 と同じ形にする。 --------- Co-authored-by: masashi --- .github/workflows/build.yml | 25 +++++++++++++++++-- gradle/libs.versions.toml | 8 +++++- sample-app/build.gradle.kts | 1 + .../maplibre/MapCameraPosition.kt | 11 ++++---- 4 files changed, 37 insertions(+), 8 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 2cca6ba..5512f88 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" coroutines = "1.9.0" okhttp = "5.3.2" agp = "9.2.1" @@ -34,7 +37,10 @@ maplibre-annotation = { module = "org.maplibre.gl:android-plugin-annotation-v9", # 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 c3d7029..0862278 100644 --- a/sample-app/build.gradle.kts +++ b/sample-app/build.gradle.kts @@ -56,6 +56,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/maplibre/MapCameraPosition.kt b/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt index 449b359..84c1d99 100644 --- a/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt +++ b/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt @@ -1,6 +1,7 @@ package com.mapconductor.maplibre import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.CameraBearing import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.spherical.Spherical @@ -21,7 +22,7 @@ fun MapCameraPosition.toCameraPosition(): CameraPosition { .target(GeoPoint.from(position).toLatLng()) .zoom(ZoomAltitudeConverter.googleZoomToMaplibreZoom(zoom)) .tilt(tilt) - .bearing(bearing) + .bearing(CameraBearing.toNativeHeading(bearing)) // TODO: // .padding(paddings?.toEdgeInsects()) .build() @@ -37,7 +38,7 @@ fun MapCameraPosition.toCameraPosition(): CameraPosition { cos(tiltAbsRad) * tan(tiltAbsRad) * NEGATIVE_TILT_TARGET_DISTANCE_SCALE - val target = Spherical.computeOffset(position, distanceForward, bearing) + val target = Spherical.computeOffset(position, distanceForward, CameraBearing.toNativeHeading(bearing)) val adjustedZoom = zoom + NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / 60.0) return CameraPosition @@ -45,7 +46,7 @@ fun MapCameraPosition.toCameraPosition(): CameraPosition { .target(target.toLatLng()) .zoom(ZoomAltitudeConverter.googleZoomToMaplibreZoom(adjustedZoom)) .tilt(tiltAbsDeg) - .bearing(bearing) + .bearing(CameraBearing.toNativeHeading(bearing)) // TODO: // .padding(paddings?.toEdgeInsects()) .build() @@ -82,7 +83,7 @@ internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCa return MapCameraPosition( position = target?.toGeoPoint() ?: GeoPoint.fromLongLat(0.0, 0.0), zoom = ZoomAltitudeConverter.maplibreZoomToGoogleZoom(zoom), - bearing = bearing, + bearing = CameraBearing.bearingFromNativeHeading(bearing), tilt = pitch, visibleRegion = null, ) @@ -104,7 +105,7 @@ internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCa return MapCameraPosition( position = originalPosition, zoom = originalGoogleZoom, - bearing = bear, + bearing = CameraBearing.bearingFromNativeHeading(bear), tilt = -pitchAbsDeg, visibleRegion = null, ) From 684d91dfcf57b6a3fd9357e00d019ec2ba846875 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 21 Sep 2026 23:44:25 +0900 Subject: [PATCH 02/31] 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. --- .../marker/MapLibreMarkerController.kt | 637 +++++++++--------- 1 file changed, 319 insertions(+), 318 deletions(-) diff --git a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt index 206c8fd..9eef9e5 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt @@ -1,318 +1,319 @@ -package com.mapconductor.maplibre.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.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.maplibre.MapLibreActualMarker -import java.util.UUID -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.withPermit - -class MapLibreMarkerController( - renderer: MapLibreMarkerOverlayRenderer, - private val markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, - private val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : AbstractMarkerController( - markerManager = renderer.markerManager, - renderer = renderer, - ), - OnCameraChangeReceiverInterface { - private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() - private val tiledMarkerIds = LinkedHashSet() - - private lateinit var lastCameraPosition: MapCameraPosition - - 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 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() - } - } - 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() - } - } - - override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { - lastCameraPosition = mapCameraPosition - // 判定と昇格は debounce したうえで切り替え器の中で走る(パン中は動かない)。 - viewportSwitch.onCameraChanged(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 - - 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 { - synchronized(this) { - 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 - } -} +package com.mapconductor.maplibre.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.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.maplibre.MapLibreActualMarker +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withPermit + +class MapLibreMarkerController( + renderer: MapLibreMarkerOverlayRenderer, + private val markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, + private val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractMarkerController( + markerManager = renderer.markerManager, + renderer = renderer, + ), + OnCameraChangeReceiverInterface { + private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() + private val tiledMarkerIds = LinkedHashSet() + + private lateinit var lastCameraPosition: MapCameraPosition + + 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 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() + } + } + 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() + } + } + + override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { + lastCameraPosition = mapCameraPosition + // 判定と昇格は debounce したうえで切り替え器の中で走る(パン中は動かない)。 + viewportSwitch.onCameraChanged(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 + + 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 { + synchronized(this) { + 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 + } +} From 4687baec9f082a9e3c37725bd65902b6c3b01a59 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 26 Sep 2026 22:26:14 -0700 Subject: [PATCH 03/31] 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. --- .../MapLibreRasterLayerOverlayRenderer.kt | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt index dffde3c..0e087ea 100644 --- a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt @@ -181,12 +181,58 @@ class MapLibreRasterLayerOverlayRenderer( ), ) 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. So callers can hand them over in ascending + * z-order, as [rebuildNonMarkerRasterLayers] does. + * + * 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: org.maplibre.android.maps.Style, + layer: RasterLayer, + ) { + val anchor = + style.layers.firstOrNull { + it is org.maplibre.android.style.layers.SymbolLayer && !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("org.maplibre.annotations") || + id.startsWith(MARKERS_LAYER_ID) || + id.startsWith("marker-drag-layer") || + id.startsWith(POLYLINE_LAYER_ID) || + id.startsWith("circle-layer") + private fun buildSource( sourceId: String, source: RasterLayerSource, From 43153eedc6a34960b0620e73fa5de01eaa310b0e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 30 Sep 2026 15:29:52 -0700 Subject: [PATCH 04/31] Register VectorStyleSupport and survive style reloads Overlays vanished on every design change: the same native Layer objects were re-added to each new style, which MapLibre refuses once their core layer was released ("Cannot add layer twice"). Each wrapper now builds a fresh layer per style load, polygons are redrawn on load, and a style that a newer one is replacing is not touched (it throws). --- api/android-for-maplibre.api | 1 + .../mapconductor/maplibre/MapLibreMapView.kt | 13 + .../maplibre/MapLibreViewController.kt | 758 +++++++++--------- .../maplibre/MapLibreViewControllerStyle.kt | 519 ++++++------ .../maplibre/circle/MapLibreCircleLayer.kt | 16 +- .../maplibre/marker/MarkerLayer.kt | 11 +- .../maplibre/polygon/MapLibrePolygonLayer.kt | 11 +- .../polygon/MapLibrePolygonOverlayRenderer.kt | 172 ++-- .../polyline/MapLibrePolylineLayer.kt | 11 +- 9 files changed, 787 insertions(+), 725 deletions(-) diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index 328ad83..b4184f6 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -401,6 +401,7 @@ public final class com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRende public com.mapconductor.maplibre.MapLibreMapViewHolderInterface getHolder(); public kotlinx.coroutines.CoroutineScope getCoroutine(); public java.lang.Object onRemove(java.util.List>>, kotlin.coroutines.Continuation); + public final void redraw(); public java.lang.Object onPostProcess(kotlin.coroutines.Continuation); public java.lang.Object removePolygon(com.mapconductor.core.polygon.PolygonEntityInterface>, kotlin.coroutines.Continuation); public java.lang.Object createPolygon(com.mapconductor.core.polygon.PolygonState, kotlin.coroutines.Continuation>); diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt index fb057af..9e37d0e 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt @@ -13,6 +13,8 @@ import com.mapconductor.core.circle.CircleManager import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPositionInterface 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 @@ -127,6 +129,17 @@ fun MapLibreMapView( mapController.setMapLongClickListener(onMapLongClick) cameraRestriction?.let { mapController.setCameraRestriction(it) } state.setController(mapController) + // MapLibre draws vector styles natively: a layer with a style + // to show hands it over instead of rasterising it. Registered + // here and not in `createMapLibreViewController` because it + // acts on the *state's* design, which non-Compose hosts do + // not have. + state.serviceRegistry.put( + VectorStyleSupportKey, + VectorStyleAsDesign(state) { url, rules -> + MapLibreDesign(id = "vector-style:$url", styleJsonURL = url, attributionRules = rules) + }, + ) // Post an initial camera update after layout to compute visibleRegion correctly holder.mapView.post { mapController.sendInitialCameraUpdate() } } diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 00869b8..1bc3991 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -1,375 +1,383 @@ -package com.mapconductor.maplibre - -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.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.maplibre.circle.MapLibreCircleController -import com.mapconductor.maplibre.groundimage.MapLibreGroundImageController -import com.mapconductor.maplibre.marker.MapLibreMarkerController -import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer -import com.mapconductor.maplibre.marker.MarkerDragLayer -import com.mapconductor.maplibre.marker.MarkerLayer -import com.mapconductor.maplibre.polygon.MapLibrePolygonConductor -import com.mapconductor.maplibre.polyline.MapLibrePolylineController -import com.mapconductor.maplibre.raster.MapLibreRasterLayerController -import org.maplibre.android.geometry.LatLng -import org.maplibre.android.gestures.MoveGestureDetector -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Style -import java.util.UUID -import android.annotation.SuppressLint -import android.util.Log -import android.view.View -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -typealias MapLibreDesignTypeChangeHandler = (MapLibreMapDesignTypeInterface) -> Unit - -class MapLibreViewController( - override val holder: MapLibreMapViewHolderInterface, - internal val markerController: MapLibreMarkerController, - internal val polylineController: MapLibrePolylineController, - internal val polygonController: MapLibrePolygonConductor, - internal val groundImageController: MapLibreGroundImageController, - internal val circleController: MapLibreCircleController, - internal val rasterLayerController: MapLibreRasterLayerController, - override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), - override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), -) : BaseMapViewController(), - MapLibreViewControllerInterface, - MapLibreMap.OnMapClickListener, - MapLibreMap.OnMapLongClickListener, - MapLibreMap.OnMoveListener, - MapLibreMap.OnCameraMoveListener, - MapLibreMap.OnCameraIdleListener { - // Keep reference to the style instance to avoid getting a new one - internal var styleInstance: Style? = null - internal var wasScrollEnabledBeforeDrag: Boolean? = null - internal var dragTouchInterceptor: View.OnTouchListener? = null - internal val polygonZLayers: MutableSet = mutableSetOf() - internal val markerEventControllers = mutableListOf>() - internal var activeDragController: DefaultMarkerEventController? = 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 lastLogicalCameraPosition: MapCameraPosition? = null - - init { - // Style should already be loaded by holderProvider - val style = holder.map.style - if (style != null) { - setupStyle(style) - // Trigger initial camera update after style is ready - sendInitialCameraUpdate() - } - - 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) } - } - } - } - - fun setupListeners() { - holder.map.addOnCameraMoveListener(this) - holder.map.addOnCameraIdleListener(this) - - holder.map.removeOnMapClickListener(this) - holder.map.addOnMapClickListener(this) - - holder.map.removeOnMapLongClickListener(this) - holder.map.addOnMapLongClickListener(this) - - holder.map.removeOnMoveListener(this) - holder.map.addOnMoveListener(this) - } - - override suspend fun clearOverlays() { - markerController.clear() - polylineController.clear() - polygonController.clear() - groundImageController.clear() - circleController.clear() - rasterLayerController.clear() - } - - override fun moveCamera(position: MapCameraPosition) = handleMoveCamera(position) - - override fun animateCamera( - position: MapCameraPosition, - duration: Long, - ) = handleAnimateCamera(position, duration) - - override fun fitBounds( - bounds: GeoRectBounds, - padding: Int, - ) = handleFitBounds(bounds, padding) - - override fun setCameraRestriction(restriction: CameraRestriction?) = handleCameraRestriction(restriction) - - override fun applyUISettings(settings: MapUISettings) { - holder.map.uiSettings.apply { - isScrollGesturesEnabled = settings.scrollGesture - isZoomGesturesEnabled = settings.zoomGesture - isRotateGesturesEnabled = settings.rotateGesture - isTiltGesturesEnabled = settings.tiltGesture - } - } - - // Provide access to the style instance - fun getStyleInstance(): Style? = styleInstance - - private var mapDesignTypeChangeListener: MapLibreDesignTypeChangeHandler? = null - - override fun setMapDesignType(value: MapLibreMapDesignTypeInterface) { - mainCoroutine.launch { - holder.map.setStyle(value.styleJsonURL) { newStyle -> - Log.d("MapLibre", "Style changed to ${value.styleJsonURL}") - setupStyle(newStyle) - } - } - } - - fun sendInitialCameraUpdate() { - mainCoroutine.launch { - notifyMapInitialized() - val mapWidth = holder.mapView.width.toFloat() - val mapHeight = holder.mapView.height.toFloat() - if (mapWidth <= 0 || mapHeight <= 0) return@launch - - val camera = readLogicalCameraPosition() - getMapCameraPosition(camera)?.let { mapCameraPosition -> - defaultCoroutine.launch { notifyMapCameraPosition(mapCameraPosition) } - } - } - } - - override fun setMapDesignTypeChangeListener(listener: MapLibreDesignTypeChangeHandler) { - mapDesignTypeChangeListener = listener - // Don't call listener immediately - it may trigger style reload - // listener(mapDesignType) - } - - override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { - (markerController.renderer as MapLibreMarkerOverlayRenderer).animationOverlayHost = host - } - - override suspend fun compositionPolygons(data: List) { - polygonController.add(data) - getStyleInstance()?.let { ensurePolygonZLayers(it) } - } - - override suspend fun updatePolygon(state: PolygonState) { - polygonController.update(state) - getStyleInstance()?.let { ensurePolygonZLayers(it) } - } - - @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 PolylineState.onClick instead.") - override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { - polylineController.clickListener = listener - } - - @Deprecated("Use PolygonState.onClick instead.") - override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { - polygonController.clickListener = listener - } - - @Deprecated("Use CircleState.onClick instead.") - override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { - this.circleController.clickListener = 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 GroundImageState.onClick instead.") - override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { - this.groundImageController.clickListener = listener - } - - /** - * マーカーのヒットテスト。クリックカスケードの先頭。 - * - * MapLibre は地図クリックの座標からそのまま引けるので、コアの - * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 - */ - override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = - markerEventControllers.dispatchGeoMarkerClick(position) - - // 拡張ファイル(Gestures / Camera)からは基底クラスの protected へ触れないため、 - // ここで internal の入口を用意しておく。 - internal fun emitCameraMoveStart(position: MapCameraPosition) { - cameraMoveStartCallback?.invoke(position) - } - - internal fun emitCameraMoveEnd(position: MapCameraPosition) { - cameraMoveEndCallback?.invoke(position) - } - - override fun onMapClick(point: LatLng): Boolean = handleMapClick(point) - - override fun onMapLongClick(point: LatLng): Boolean = handleMapLongClick(point) - - override fun onMoveBegin(detector: MoveGestureDetector) = handleMoveBegin(detector) - - override fun onMove(detector: MoveGestureDetector) = handleMove(detector) - - override fun onMoveEnd(detector: MoveGestureDetector) = handleMoveEnd(detector) - - @SuppressLint("ClickableViewAccessibility") - override fun onCameraMove() { - mainCoroutine.launch { - getMapCameraPosition(readLogicalCameraPosition())?.let { mapCameraPosition -> - defaultCoroutine.launch { - notifyMapCameraPosition(mapCameraPosition) - } - cameraMoveCallback?.invoke(mapCameraPosition) - } - } - } - - override fun onCameraIdle() { - mainCoroutine.launch { - getMapCameraPosition(readLogicalCameraPosition())?.let { mapCameraPosition -> - defaultCoroutine.launch { - notifyMapCameraPosition(mapCameraPosition) - } - cameraMoveEndCallback?.invoke(mapCameraPosition) - } - } - } - - 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) - - val renderer = controller.renderer as MapLibreMarkerOverlayRenderer - styleInstance?.let { style -> - renderer.ensureDefaultIcon(style) - ensureGeoJsonSource(style, renderer.markerLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.markerLayer.layer, - layerId = renderer.markerLayer.layerId, - aboveId = polylineController.renderer.layer.layerId, - ) - ensureGeoJsonSource(style, renderer.dragLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.dragLayer.layer, - layerId = renderer.dragLayer.layerId, - aboveId = renderer.markerLayer.layerId, - ) - renderer.redraw() - renderer.drawDragLayer() - } - } - - 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 MapLibreMarkerOverlayRenderer( - 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.maplibre + +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.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.maplibre.circle.MapLibreCircleController +import com.mapconductor.maplibre.groundimage.MapLibreGroundImageController +import com.mapconductor.maplibre.marker.MapLibreMarkerController +import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer +import com.mapconductor.maplibre.marker.MarkerDragLayer +import com.mapconductor.maplibre.marker.MarkerLayer +import com.mapconductor.maplibre.polygon.MapLibrePolygonConductor +import com.mapconductor.maplibre.polyline.MapLibrePolylineController +import com.mapconductor.maplibre.raster.MapLibreRasterLayerController +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.gestures.MoveGestureDetector +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import java.util.UUID +import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +typealias MapLibreDesignTypeChangeHandler = (MapLibreMapDesignTypeInterface) -> Unit + +class MapLibreViewController( + override val holder: MapLibreMapViewHolderInterface, + internal val markerController: MapLibreMarkerController, + internal val polylineController: MapLibrePolylineController, + internal val polygonController: MapLibrePolygonConductor, + internal val groundImageController: MapLibreGroundImageController, + internal val circleController: MapLibreCircleController, + internal val rasterLayerController: MapLibreRasterLayerController, + override val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), + override val defaultCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), +) : BaseMapViewController(), + MapLibreViewControllerInterface, + MapLibreMap.OnMapClickListener, + MapLibreMap.OnMapLongClickListener, + MapLibreMap.OnMoveListener, + MapLibreMap.OnCameraMoveListener, + MapLibreMap.OnCameraIdleListener { + // Keep reference to the style instance to avoid getting a new one + internal var styleInstance: Style? = null + internal var wasScrollEnabledBeforeDrag: Boolean? = null + internal var dragTouchInterceptor: View.OnTouchListener? = null + internal val polygonZLayers: MutableSet = mutableSetOf() + internal val markerEventControllers = mutableListOf>() + internal var activeDragController: DefaultMarkerEventController? = 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 lastLogicalCameraPosition: MapCameraPosition? = null + + init { + // Style should already be loaded by holderProvider + val style = holder.map.style + if (style != null) { + setupStyle(style) + // Trigger initial camera update after style is ready + sendInitialCameraUpdate() + } + + 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) } + } + } + } + + fun setupListeners() { + holder.map.addOnCameraMoveListener(this) + holder.map.addOnCameraIdleListener(this) + + holder.map.removeOnMapClickListener(this) + holder.map.addOnMapClickListener(this) + + holder.map.removeOnMapLongClickListener(this) + holder.map.addOnMapLongClickListener(this) + + holder.map.removeOnMoveListener(this) + holder.map.addOnMoveListener(this) + } + + override suspend fun clearOverlays() { + markerController.clear() + polylineController.clear() + polygonController.clear() + groundImageController.clear() + circleController.clear() + rasterLayerController.clear() + } + + override fun moveCamera(position: MapCameraPosition) = handleMoveCamera(position) + + override fun animateCamera( + position: MapCameraPosition, + duration: Long, + ) = handleAnimateCamera(position, duration) + + override fun fitBounds( + bounds: GeoRectBounds, + padding: Int, + ) = handleFitBounds(bounds, padding) + + override fun setCameraRestriction(restriction: CameraRestriction?) = handleCameraRestriction(restriction) + + override fun applyUISettings(settings: MapUISettings) { + holder.map.uiSettings.apply { + isScrollGesturesEnabled = settings.scrollGesture + isZoomGesturesEnabled = settings.zoomGesture + isRotateGesturesEnabled = settings.rotateGesture + isTiltGesturesEnabled = settings.tiltGesture + } + } + + // Provide access to the style instance + fun getStyleInstance(): Style? = styleInstance + + /** + * The style, only while it is the one the map draws. A style that a + * newer one has replaced or is replacing throws on every call + * ("Calling getLayer when a newer style is loading"); overlays added + * then are re-added by `setupStyle` once the new style has loaded. + */ + internal fun loadedStyle(): Style? = styleInstance?.takeIf { it.isFullyLoaded } + + private var mapDesignTypeChangeListener: MapLibreDesignTypeChangeHandler? = null + + override fun setMapDesignType(value: MapLibreMapDesignTypeInterface) { + mainCoroutine.launch { + holder.map.setStyle(value.styleJsonURL) { newStyle -> + Log.d("MapLibre", "Style changed to ${value.styleJsonURL}") + setupStyle(newStyle) + } + } + } + + fun sendInitialCameraUpdate() { + mainCoroutine.launch { + notifyMapInitialized() + val mapWidth = holder.mapView.width.toFloat() + val mapHeight = holder.mapView.height.toFloat() + if (mapWidth <= 0 || mapHeight <= 0) return@launch + + val camera = readLogicalCameraPosition() + getMapCameraPosition(camera)?.let { mapCameraPosition -> + defaultCoroutine.launch { notifyMapCameraPosition(mapCameraPosition) } + } + } + } + + override fun setMapDesignTypeChangeListener(listener: MapLibreDesignTypeChangeHandler) { + mapDesignTypeChangeListener = listener + // Don't call listener immediately - it may trigger style reload + // listener(mapDesignType) + } + + override fun setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost?) { + (markerController.renderer as MapLibreMarkerOverlayRenderer).animationOverlayHost = host + } + + override suspend fun compositionPolygons(data: List) { + polygonController.add(data) + loadedStyle()?.let { ensurePolygonZLayers(it) } + } + + override suspend fun updatePolygon(state: PolygonState) { + polygonController.update(state) + loadedStyle()?.let { ensurePolygonZLayers(it) } + } + + @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 PolylineState.onClick instead.") + override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { + polylineController.clickListener = listener + } + + @Deprecated("Use PolygonState.onClick instead.") + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { + polygonController.clickListener = listener + } + + @Deprecated("Use CircleState.onClick instead.") + override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { + this.circleController.clickListener = 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 GroundImageState.onClick instead.") + override fun setOnGroundImageClickListener(listener: OnGroundImageEventHandler?) { + this.groundImageController.clickListener = listener + } + + /** + * マーカーのヒットテスト。クリックカスケードの先頭。 + * + * MapLibre は地図クリックの座標からそのまま引けるので、コアの + * [dispatchGeoMarkerClick] に委ねる(`clickable = false` の透過もそちら)。 + */ + override fun dispatchMarkerTap(position: GeoPointInterface): Boolean = + markerEventControllers.dispatchGeoMarkerClick(position) + + // 拡張ファイル(Gestures / Camera)からは基底クラスの protected へ触れないため、 + // ここで internal の入口を用意しておく。 + internal fun emitCameraMoveStart(position: MapCameraPosition) { + cameraMoveStartCallback?.invoke(position) + } + + internal fun emitCameraMoveEnd(position: MapCameraPosition) { + cameraMoveEndCallback?.invoke(position) + } + + override fun onMapClick(point: LatLng): Boolean = handleMapClick(point) + + override fun onMapLongClick(point: LatLng): Boolean = handleMapLongClick(point) + + override fun onMoveBegin(detector: MoveGestureDetector) = handleMoveBegin(detector) + + override fun onMove(detector: MoveGestureDetector) = handleMove(detector) + + override fun onMoveEnd(detector: MoveGestureDetector) = handleMoveEnd(detector) + + @SuppressLint("ClickableViewAccessibility") + override fun onCameraMove() { + mainCoroutine.launch { + getMapCameraPosition(readLogicalCameraPosition())?.let { mapCameraPosition -> + defaultCoroutine.launch { + notifyMapCameraPosition(mapCameraPosition) + } + cameraMoveCallback?.invoke(mapCameraPosition) + } + } + } + + override fun onCameraIdle() { + mainCoroutine.launch { + getMapCameraPosition(readLogicalCameraPosition())?.let { mapCameraPosition -> + defaultCoroutine.launch { + notifyMapCameraPosition(mapCameraPosition) + } + cameraMoveEndCallback?.invoke(mapCameraPosition) + } + } + } + + 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) + + val renderer = controller.renderer as MapLibreMarkerOverlayRenderer + loadedStyle()?.let { style -> + renderer.ensureDefaultIcon(style) + ensureGeoJsonSource(style, renderer.markerLayer.sourceId) + addLayerAboveSafely( + style = style, + layer = renderer.markerLayer.layer, + layerId = renderer.markerLayer.layerId, + aboveId = polylineController.renderer.layer.layerId, + ) + ensureGeoJsonSource(style, renderer.dragLayer.sourceId) + addLayerAboveSafely( + style = style, + layer = renderer.dragLayer.layer, + layerId = renderer.dragLayer.layerId, + aboveId = renderer.markerLayer.layerId, + ) + renderer.redraw() + renderer.drawDragLayer() + } + } + + 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 MapLibreMarkerOverlayRenderer( + 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/maplibre/MapLibreViewControllerStyle.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt index 8f6c758..d789e49 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt @@ -1,258 +1,261 @@ -package com.mapconductor.maplibre - -import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer -import org.maplibre.android.maps.Style -import org.maplibre.android.style.expressions.Expression -import org.maplibre.android.style.layers.FillLayer -import org.maplibre.android.style.layers.Layer -import org.maplibre.android.style.layers.LineLayer -import org.maplibre.android.style.layers.Property -import org.maplibre.android.style.layers.PropertyFactory -import org.maplibre.android.style.sources.GeoJsonSource -import android.util.Log -import kotlinx.coroutines.launch - -/** - * スタイル(レイヤーとソース)の組み立て。 - * - * MapLibre はスタイルが読み込み直されるたびにレイヤーが全部消えるため、ここは - * 何度呼ばれても同じ状態になるように書いてある(`ensureGeoJsonSource` / - * `addLayerSafely` はいずれも既存があれば作らない)。 - * - * ポリゴンの z 順は**レイヤーを z 値ごとに用意して**表現する。MapLibre には - * フィーチャー単位の z 指定が無く、レイヤーの並び順だけが順序を決めるため。 - */ -internal fun MapLibreViewController.ensureGeoJsonSource( - style: Style, - sourceId: String, -) { - if (style.getSource(sourceId) != null) return - try { - style.addSource(GeoJsonSource(sourceId)) - } catch (e: Exception) { - Log.w("MapLibre", "Failed to add source: $sourceId (${e.message})") - } -} - -internal fun MapLibreViewController.addLayerSafely( - style: Style, - layer: Layer, - layerId: String, -) { - if (style.getLayer(layerId) != null) return - try { - style.addLayer(layer) - } catch (e: Exception) { - if (style.getLayer(layerId) == null) { - Log.w("MapLibre", "Failed to add layer: $layerId (${e.message})") - } - } -} - -internal fun MapLibreViewController.addLayerAboveSafely( - style: Style, - layer: Layer, - layerId: String, - aboveId: String, -) { - if (style.getLayer(layerId) != null) return - try { - style.addLayerAbove(layer, aboveId) - } catch (_: Exception) { - if (style.getLayer(layerId) != null) return - try { - style.addLayer(layer) - } catch (e2: Exception) { - if (style.getLayer(layerId) == null) { - Log.w("MapLibre", "Failed to add layer: $layerId (${e2.message})") - } - } - } -} - -internal fun MapLibreViewController.setupStyle(style: Style) { - // Store the style instance for future use - styleInstance = style - - // Log existing layers - // val topLayerId = style.layers.lastOrNull()?.id - - // Ensure default icon image exists on this style - (markerController.renderer as MapLibreMarkerOverlayRenderer).ensureDefaultIcon(style) - - // Polygon sources only (layers will be added per zIndex) - ensureGeoJsonSource(style, polygonController.polylineOverlay.layer.sourceId) - ensureGeoJsonSource(style, polygonController.polygonOverlay.layer.sourceId) - - // Circle cts as anchor above polygons - ensureGeoJsonSource(style, circleController.renderer.layer.sourceId) - addLayerSafely( - style = style, - layer = circleController.renderer.layer.layer, - layerId = circleController.renderer.layer.layerId, - ) - // Circle stroke (LineLayer) directly above the circle fill - addLayerAboveSafely( - style = style, - layer = circleController.renderer.layer.strokeLayer, - layerId = circleController.renderer.layer.strokeLayerId, - aboveId = circleController.renderer.layer.layerId, - ) - - // Polyline (general) acts as anchor above circles - ensureGeoJsonSource(style, polylineController.renderer.layer.sourceId) - addLayerSafely( - style = style, - layer = polylineController.renderer.layer.layer, - layerId = polylineController.renderer.layer.layerId, - ) - - // Add z-indexed polygon layers below general polylines - ensurePolygonZLayers(style) - - // Marker - add source and layer at the top - ensureGeoJsonSource(style, (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layer, - layerId = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layerId, - aboveId = polylineController.renderer.layer.layerId, - ) - (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() - - // Drag layer above marker layer - ensureGeoJsonSource(style, (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.layer, - layerId = (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.layerId, - aboveId = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layerId, - ) - (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() - - markerEventControllers - .map { it.renderer as MapLibreMarkerOverlayRenderer } - .filter { it != markerController.renderer } - .forEach { renderer -> - renderer.ensureDefaultIcon(style) - ensureGeoJsonSource(style, renderer.markerLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.markerLayer.layer, - layerId = renderer.markerLayer.layerId, - aboveId = polylineController.renderer.layer.layerId, - ) - ensureGeoJsonSource(style, renderer.dragLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.dragLayer.layer, - layerId = renderer.dragLayer.layerId, - aboveId = renderer.markerLayer.layerId, - ) - renderer.redraw() - renderer.drawDragLayer() - } - - // Force redraw after adding layers - (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() - circleController.renderer.redraw() - polylineController.renderer.redraw() -// polygonController.polygonOverlay.onPostProcess() - mainCoroutine.launch { - groundImageController.reapplyStyle() - rasterLayerController.reapplyStyle() - } -} - -internal fun MapLibreViewController.ensurePolygonZLayers(style: Style) { - val fillSourceId = polygonController.polygonOverlay.layer.sourceId - val outlineSourceId = polygonController.polylineOverlay.layer.sourceId - val anchorId = polylineController.renderer.layer.layerId - - val zSet = - polygonController.polygonOverlay.polygonManager - .allEntities() - .map { it.state.zIndex } - .toSet() - - // Remove stale z-indexed layers we previously created - val toRemove = polygonZLayers.subtract(zSet) - toRemove.forEach { z -> - val fillId = "polygon-fill-layer-$z" - val outlineId = "polygon-outline-layer-$z" - try { - style.removeLayer(outlineId) - } catch (_: Exception) { - } - try { - style.removeLayer(fillId) - } catch (_: Exception) { - } - } - - val zList = zSet.toList().sorted() - zList.forEach { z -> - val fillId = "polygon-fill-layer-$z" - val outlineId = "polygon-outline-layer-$z" - - if (style.getLayer(fillId) == null) { - val fill = - FillLayer(fillId, fillSourceId).apply { - setFilter( - Expression.eq( - Expression - .get("zIndex"), - Expression - .literal(z), - ), - ) - setProperties( - PropertyFactory.fillColor( - Expression - .get("fillColor"), - ), - ) - } - try { - style.addLayerBelow(fill, anchorId) - } catch (_: Exception) { - style.addLayer(fill) - } - } - - if (style.getLayer(outlineId) == null) { - val outline = - LineLayer(outlineId, outlineSourceId).apply { - setFilter( - Expression.eq( - Expression - .get("zIndex"), - Expression - .literal(z), - ), - ) - setProperties( - PropertyFactory - .lineJoin(Property.LINE_JOIN_ROUND), - PropertyFactory - .lineCap(Property.LINE_CAP_ROUND), - PropertyFactory.lineColor( - Expression - .get("strokeColor"), - ), - PropertyFactory.lineWidth( - Expression - .get("strokeWidth"), - ), - ) - } - try { - style.addLayerAbove(outline, fillId) - } catch (_: Exception) { - style.addLayer(outline) - } - } - } - polygonZLayers.clear() - polygonZLayers.addAll(zSet) -} +package com.mapconductor.maplibre + +import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.Layer +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.Property +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.sources.GeoJsonSource +import android.util.Log +import kotlinx.coroutines.launch + +/** + * スタイル(レイヤーとソース)の組み立て。 + * + * MapLibre はスタイルが読み込み直されるたびにレイヤーが全部消えるため、ここは + * 何度呼ばれても同じ状態になるように書いてある(`ensureGeoJsonSource` / + * `addLayerSafely` はいずれも既存があれば作らない)。 + * + * ポリゴンの z 順は**レイヤーを z 値ごとに用意して**表現する。MapLibre には + * フィーチャー単位の z 指定が無く、レイヤーの並び順だけが順序を決めるため。 + */ +internal fun MapLibreViewController.ensureGeoJsonSource( + style: Style, + sourceId: String, +) { + if (style.getSource(sourceId) != null) return + try { + style.addSource(GeoJsonSource(sourceId)) + } catch (e: Exception) { + Log.w("MapLibre", "Failed to add source: $sourceId (${e.message})") + } +} + +internal fun MapLibreViewController.addLayerSafely( + style: Style, + layer: Layer, + layerId: String, +) { + if (style.getLayer(layerId) != null) return + try { + style.addLayer(layer) + } catch (e: Exception) { + if (style.getLayer(layerId) == null) { + Log.w("MapLibre", "Failed to add layer: $layerId (${e.message})") + } + } +} + +internal fun MapLibreViewController.addLayerAboveSafely( + style: Style, + layer: Layer, + layerId: String, + aboveId: String, +) { + if (style.getLayer(layerId) != null) return + try { + style.addLayerAbove(layer, aboveId) + } catch (_: Exception) { + if (style.getLayer(layerId) != null) return + try { + style.addLayer(layer) + } catch (e2: Exception) { + if (style.getLayer(layerId) == null) { + Log.w("MapLibre", "Failed to add layer: $layerId (${e2.message})") + } + } + } +} + +internal fun MapLibreViewController.setupStyle(style: Style) { + // Store the style instance for future use + styleInstance = style + + // Log existing layers + // val topLayerId = style.layers.lastOrNull()?.id + + // Ensure default icon image exists on this style + (markerController.renderer as MapLibreMarkerOverlayRenderer).ensureDefaultIcon(style) + + // Polygon sources only (layers will be added per zIndex) + ensureGeoJsonSource(style, polygonController.polylineOverlay.layer.sourceId) + ensureGeoJsonSource(style, polygonController.polygonOverlay.layer.sourceId) + + // Circle cts as anchor above polygons + ensureGeoJsonSource(style, circleController.renderer.layer.sourceId) + addLayerSafely( + style = style, + layer = circleController.renderer.layer.layer, + layerId = circleController.renderer.layer.layerId, + ) + // Circle stroke (LineLayer) directly above the circle fill + addLayerAboveSafely( + style = style, + layer = circleController.renderer.layer.strokeLayer, + layerId = circleController.renderer.layer.strokeLayerId, + aboveId = circleController.renderer.layer.layerId, + ) + + // Polyline (general) acts as anchor above circles + ensureGeoJsonSource(style, polylineController.renderer.layer.sourceId) + addLayerSafely( + style = style, + layer = polylineController.renderer.layer.layer, + layerId = polylineController.renderer.layer.layerId, + ) + + // Add z-indexed polygon layers below general polylines + ensurePolygonZLayers(style) + + // Marker - add source and layer at the top + ensureGeoJsonSource(style, (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.sourceId) + addLayerAboveSafely( + style = style, + layer = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layer, + layerId = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layerId, + aboveId = polylineController.renderer.layer.layerId, + ) + (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() + + // Drag layer above marker layer + ensureGeoJsonSource(style, (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.sourceId) + addLayerAboveSafely( + style = style, + layer = (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.layer, + layerId = (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.layerId, + aboveId = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layerId, + ) + (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() + + markerEventControllers + .map { it.renderer as MapLibreMarkerOverlayRenderer } + .filter { it != markerController.renderer } + .forEach { renderer -> + renderer.ensureDefaultIcon(style) + ensureGeoJsonSource(style, renderer.markerLayer.sourceId) + addLayerAboveSafely( + style = style, + layer = renderer.markerLayer.layer, + layerId = renderer.markerLayer.layerId, + aboveId = polylineController.renderer.layer.layerId, + ) + ensureGeoJsonSource(style, renderer.dragLayer.sourceId) + addLayerAboveSafely( + style = style, + layer = renderer.dragLayer.layer, + layerId = renderer.dragLayer.layerId, + aboveId = renderer.markerLayer.layerId, + ) + renderer.redraw() + renderer.drawDragLayer() + } + + // Force redraw after adding layers + (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() + circleController.renderer.redraw() + polylineController.renderer.redraw() + // The polygon sources are new and empty on every style; the data has to + // be written into them again, like the markers' and the polylines'. + polygonController.polygonOverlay.redraw() + polygonController.polylineOverlay.redraw() + mainCoroutine.launch { + groundImageController.reapplyStyle() + rasterLayerController.reapplyStyle() + } +} + +internal fun MapLibreViewController.ensurePolygonZLayers(style: Style) { + val fillSourceId = polygonController.polygonOverlay.layer.sourceId + val outlineSourceId = polygonController.polylineOverlay.layer.sourceId + val anchorId = polylineController.renderer.layer.layerId + + val zSet = + polygonController.polygonOverlay.polygonManager + .allEntities() + .map { it.state.zIndex } + .toSet() + + // Remove stale z-indexed layers we previously created + val toRemove = polygonZLayers.subtract(zSet) + toRemove.forEach { z -> + val fillId = "polygon-fill-layer-$z" + val outlineId = "polygon-outline-layer-$z" + try { + style.removeLayer(outlineId) + } catch (_: Exception) { + } + try { + style.removeLayer(fillId) + } catch (_: Exception) { + } + } + + val zList = zSet.toList().sorted() + zList.forEach { z -> + val fillId = "polygon-fill-layer-$z" + val outlineId = "polygon-outline-layer-$z" + + if (style.getLayer(fillId) == null) { + val fill = + FillLayer(fillId, fillSourceId).apply { + setFilter( + Expression.eq( + Expression + .get("zIndex"), + Expression + .literal(z), + ), + ) + setProperties( + PropertyFactory.fillColor( + Expression + .get("fillColor"), + ), + ) + } + try { + style.addLayerBelow(fill, anchorId) + } catch (_: Exception) { + style.addLayer(fill) + } + } + + if (style.getLayer(outlineId) == null) { + val outline = + LineLayer(outlineId, outlineSourceId).apply { + setFilter( + Expression.eq( + Expression + .get("zIndex"), + Expression + .literal(z), + ), + ) + setProperties( + PropertyFactory + .lineJoin(Property.LINE_JOIN_ROUND), + PropertyFactory + .lineCap(Property.LINE_CAP_ROUND), + PropertyFactory.lineColor( + Expression + .get("strokeColor"), + ), + PropertyFactory.lineWidth( + Expression + .get("strokeWidth"), + ), + ) + } + try { + style.addLayerAbove(outline, fillId) + } catch (_: Exception) { + style.addLayer(outline) + } + } + } + polygonZLayers.clear() + polygonZLayers.addAll(zSet) +} diff --git a/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt index 91f179c..f14978d 100644 --- a/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt @@ -43,16 +43,24 @@ class MapLibreCircleLayer( ) /** 塗りレイヤ。レイヤ順序の互換のため id は従来の layerId を引き継ぐ。 */ - val layer: FillLayer = - FillLayer(layerId, sourceId).apply { + // A fresh native layer on every read. MapLibre releases a Layer's core + // object into the style it is added to, and the Java object can never be + // added again ("Cannot add layer twice") -- so the one built for the + // first style was silently refused by every style loaded after it, and + // a design change lost the overlays. Only `setupStyle` reads this, once + // per style load. + val layer: FillLayer + get() = + FillLayer(layerId, sourceId).apply { setProperties( fillColor(Expression.get(Prop.FILL_COLOR)), fillSortKey(Expression.get(Prop.Z_INDEX)), ) } - val strokeLayer: LineLayer = - LineLayer(strokeLayerId, sourceId).apply { + val strokeLayer: LineLayer + get() = + LineLayer(strokeLayerId, sourceId).apply { setProperties( lineJoin(Property.LINE_JOIN_ROUND), lineCap(Property.LINE_CAP_ROUND), diff --git a/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt b/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt index fce53de..b527cb9 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt @@ -19,8 +19,15 @@ open class MarkerLayer( open val sourceId: String, open val layerId: String, ) { - val layer = - SymbolLayer(layerId, sourceId).apply { + // A fresh native layer on every read. MapLibre releases a Layer's core + // object into the style it is added to, and the Java object can never be + // added again ("Cannot add layer twice") -- so the one built for the + // first style was silently refused by every style loaded after it, and + // a design change lost the overlays. Only `setupStyle` reads this, once + // per style load. + val layer: SymbolLayer + get() = + SymbolLayer(layerId, sourceId).apply { setProperties( iconImage(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ID)), // iconSize(get(MapLibreMarkerOverlayRenderer.Prop.SCALE)), diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt index 1457327..5219038 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt @@ -24,8 +24,15 @@ class MapLibrePolygonLayer( FeatureCollection.fromFeatures(emptyList()), ) - val layer: FillLayer = - FillLayer(layerId, sourceId).apply { + // A fresh native layer on every read. MapLibre releases a Layer's core + // object into the style it is added to, and the Java object can never be + // added again ("Cannot add layer twice") -- so the one built for the + // first style was silently refused by every style loaded after it, and + // a design change lost the overlays. Only `setupStyle` reads this, once + // per style load. + val layer: FillLayer + get() = + FillLayer(layerId, sourceId).apply { setProperties( fillColor(get(Prop.FILL_COLOR)), ) diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt index 9fc668a..4c495d2 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt @@ -1,82 +1,90 @@ -package com.mapconductor.maplibre.polygon - -import com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer -import com.mapconductor.core.polygon.PolygonEntityInterface -import com.mapconductor.core.polygon.PolygonManagerInterface -import com.mapconductor.core.polygon.PolygonState -import com.mapconductor.core.polygon.unionHoles -import com.mapconductor.maplibre.MapLibreActualPolygon -import com.mapconductor.maplibre.MapLibreMapViewHolderInterface -import com.mapconductor.maplibre.createMapLibrePolygons -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -/** - * MapLibre Polygon Overlay Renderer - * - * 使用 GeoJSON hole polygons(与 React SDK 相同的方式)而不是 tile-based masking - */ -class MapLibrePolygonOverlayRenderer( - val layer: MapLibrePolygonLayer, - val polygonManager: PolygonManagerInterface, - override val holder: MapLibreMapViewHolderInterface, - override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : AbstractPolygonOverlayRenderer() { - override suspend fun onRemove(data: List>) { - coroutine.launch { - holder.map.style?.let { - layer.draw(getAllPolygonEntities(), it) - } - } - } - - override suspend fun onPostProcess() { - val polygons = getAllPolygonEntities() - - holder.map.style?.let { - coroutine.launch { - layer.draw(polygons, it) - } - } - } - - override suspend fun removePolygon(entity: PolygonEntityInterface) { - // 不单独删除,通过 onPostProcess 重绘所有多边形 - } - - override suspend fun createPolygon(state: PolygonState): MapLibreActualPolygon? { - val resolved = if (state.holes.size > 1) state.unionHoles() else state - val features = - createMapLibrePolygons( - id = resolved.id, - points = resolved.points, - holes = resolved.holes, - geodesic = resolved.geodesic, - fillColor = resolved.fillColor, - zIndex = resolved.zIndex, - ) - - if (features.isEmpty()) { - return null - } - return features // MapLibreActualPolygon = List - } - - override suspend fun updatePolygonProperties( - polygon: MapLibreActualPolygon, - current: PolygonEntityInterface, - prev: PolygonEntityInterface, - ): MapLibreActualPolygon? { - val finger = current.fingerPrint - val prevFinger = prev.fingerPrint - - if (finger != prevFinger) { - return createPolygon(current.state) - } - return polygon - } - - private fun getAllPolygonEntities(): List> = - polygonManager.allEntities() -} +package com.mapconductor.maplibre.polygon + +import com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer +import com.mapconductor.core.polygon.PolygonEntityInterface +import com.mapconductor.core.polygon.PolygonManagerInterface +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polygon.unionHoles +import com.mapconductor.maplibre.MapLibreActualPolygon +import com.mapconductor.maplibre.MapLibreMapViewHolderInterface +import com.mapconductor.maplibre.createMapLibrePolygons +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * MapLibre Polygon Overlay Renderer + * + * 使用 GeoJSON hole polygons(与 React SDK 相同的方式)而不是 tile-based masking + */ +class MapLibrePolygonOverlayRenderer( + val layer: MapLibrePolygonLayer, + val polygonManager: PolygonManagerInterface, + override val holder: MapLibreMapViewHolderInterface, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractPolygonOverlayRenderer() { + override suspend fun onRemove(data: List>) { + coroutine.launch { + holder.map.style?.let { + layer.draw(getAllPolygonEntities(), it) + } + } + } + + /** Draws every polygon into the current style again; for after a style load. */ + fun redraw() { + val polygons = getAllPolygonEntities() + holder.map.style?.let { style -> + coroutine.launch { layer.draw(polygons, style) } + } + } + + override suspend fun onPostProcess() { + val polygons = getAllPolygonEntities() + + holder.map.style?.let { + coroutine.launch { + layer.draw(polygons, it) + } + } + } + + override suspend fun removePolygon(entity: PolygonEntityInterface) { + // 不单独删除,通过 onPostProcess 重绘所有多边形 + } + + override suspend fun createPolygon(state: PolygonState): MapLibreActualPolygon? { + val resolved = if (state.holes.size > 1) state.unionHoles() else state + val features = + createMapLibrePolygons( + id = resolved.id, + points = resolved.points, + holes = resolved.holes, + geodesic = resolved.geodesic, + fillColor = resolved.fillColor, + zIndex = resolved.zIndex, + ) + + if (features.isEmpty()) { + return null + } + return features // MapLibreActualPolygon = List + } + + override suspend fun updatePolygonProperties( + polygon: MapLibreActualPolygon, + current: PolygonEntityInterface, + prev: PolygonEntityInterface, + ): MapLibreActualPolygon? { + val finger = current.fingerPrint + val prevFinger = prev.fingerPrint + + if (finger != prevFinger) { + return createPolygon(current.state) + } + return polygon + } + + private fun getAllPolygonEntities(): List> = + polygonManager.allEntities() +} diff --git a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt index 3961e08..4e7c053 100644 --- a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt @@ -29,8 +29,15 @@ class MapLibrePolylineLayer( FeatureCollection.fromFeatures(emptyList()), ) - val layer: LineLayer = - LineLayer(layerId, sourceId).apply { + // A fresh native layer on every read. MapLibre releases a Layer's core + // object into the style it is added to, and the Java object can never be + // added again ("Cannot add layer twice") -- so the one built for the + // first style was silently refused by every style loaded after it, and + // a design change lost the overlays. Only `setupStyle` reads this, once + // per style load. + val layer: LineLayer + get() = + LineLayer(layerId, sourceId).apply { setProperties( lineJoin(Property.LINE_JOIN_ROUND), lineCap(Property.LINE_CAP_ROUND), From c23859fa0f5c727d813b40dae10552ced0bc5f68 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 5 Oct 2026 14:53:11 -0700 Subject: [PATCH 05/31] update dependencies (#13) --- gradle.properties | 2 +- gradle/libs.versions.toml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 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 5512f88..91b4ead 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,18 @@ [versions] -composeLibs = "1.7.8" +composeLibs = "1.12.1" # material3 は compose-ui とは別のバージョン系列。composeLibs(ui 系)を流用すると # 存在しない material3:1.7.8 を要求してクラスパスごと解決に失敗する。 material3 = "1.4.0" -coroutines = "1.9.0" -okhttp = "5.3.2" -agp = "9.2.1" -kotlin = "2.0.21" +coroutines = "1.11.0" +okhttp = "5.5.0" +agp = "9.4.1" +kotlin = "2.4.20" -composeBom = "2025.02.00" -lifecycleRuntimeKtx = "2.8.7" -mapLibreSdk = "13.3.1" +composeBom = "2026.09.00" +lifecycleRuntimeKtx = "2.11.0" +mapLibreSdk = "13.6.1" mapLibreAnnotation = "3.0.2" -activityCompose = "1.9.3" +activityCompose = "1.13.0" ktLint = "14.2.0" junit = "4.13.2" junitVersion = "1.3.0" From 4953d09eb836b76d6aef6633bab2867a5cca423e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 01:58:48 -0700 Subject: [PATCH 06/31] Update MapLibre to 13.7.1 and OkHttp to 5.5.1 (#14) --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91b4ead..55bdc80 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,13 +4,13 @@ composeLibs = "1.12.1" # 存在しない material3:1.7.8 を要求してクラスパスごと解決に失敗する。 material3 = "1.4.0" coroutines = "1.11.0" -okhttp = "5.5.0" +okhttp = "5.5.1" agp = "9.4.1" kotlin = "2.4.20" composeBom = "2026.09.00" lifecycleRuntimeKtx = "2.11.0" -mapLibreSdk = "13.6.1" +mapLibreSdk = "13.7.1" mapLibreAnnotation = "3.0.2" activityCompose = "1.13.0" ktLint = "14.2.0" From 9e78a6934f174a8ce8531a962e137f7058ba1e69 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 10:51:40 -0700 Subject: [PATCH 07/31] Vector style support with in-place style mutations (#15) * 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-maplibre.api | 2 +- .../mapconductor/maplibre/MapLibreMapView.kt | 49 ++++- .../maplibre/MapLibreStyleMutationTarget.kt | 179 ++++++++++++++++++ .../maplibre/MapLibreViewController.kt | 31 +++ .../maplibre/MapLibreViewControllerStyle.kt | 4 + .../maplibre/circle/MapLibreCircleLayer.kt | 26 +-- .../maplibre/marker/MarkerLayer.kt | 24 +-- .../maplibre/polygon/MapLibrePolygonLayer.kt | 8 +- .../polyline/MapLibrePolylineLayer.kt | 14 +- 9 files changed, 299 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/mapconductor/maplibre/MapLibreStyleMutationTarget.kt diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index b4184f6..615f598 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -79,7 +79,7 @@ public final class com.mapconductor.maplibre.MapLibreMapViewHolderInterface$Defa public static com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, long); } public final class com.mapconductor.maplibre.MapLibreMapViewKt { - public static final void MapLibreMapView(com.mapconductor.maplibre.MapLibreViewState, 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, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void MapLibreMapView(com.mapconductor.maplibre.MapLibreViewState, 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.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); public static final com.mapconductor.maplibre.MapLibreViewController createMapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.MutableMapServiceRegistry); public static final com.mapconductor.maplibre.marker.MapLibreMarkerController getMarkerController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerTilingOptions); public static final com.mapconductor.maplibre.polyline.MapLibrePolylineController getPolylineController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt index 9e37d0e..89af5ea 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt @@ -12,9 +12,13 @@ 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.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 @@ -63,6 +67,15 @@ fun MapLibreMapView( onCameraMoveStart: OnCameraMoveHandler? = null, onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable MapLibreMapViewScope.() -> Unit)? = null, ) { val context = LocalContext.current @@ -136,10 +149,42 @@ fun MapLibreMapView( // not have. state.serviceRegistry.put( VectorStyleSupportKey, - VectorStyleAsDesign(state) { url, rules -> + VectorStyleAsDesign( + state, + // MapLibre's design is a plain style document, so + // "adjust whatever is showing" has something to + // fetch here. Mapbox's is `mapbox://…` and + // MapTiler's is a cloud id, so those answer null for + // their built-ins and the app is told rather than + // shown an unstyled map. + urlOf = { design -> design.styleJsonURL.takeIf { it.startsWith("http") } }, + ) { url, rules -> MapLibreDesign(id = "vector-style:$url", styleJsonURL = url, attributionRules = rules) }, ) + // MapLibre 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( + MapLibreStyleMutationTarget( + style = { mapController.loadedStyle() }, + 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() } // Post an initial camera update after layout to compute visibleRegion correctly holder.mapView.post { mapController.sendInitialCameraUpdate() } } @@ -155,6 +200,8 @@ fun MapLibreMapView( // Pass content if it needs to be rendered within the overlay providers in MapViewBase, // or handle it here if it's specific to MapLibreMapView 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 ) } diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreStyleMutationTarget.kt b/src/main/java/com/mapconductor/maplibre/MapLibreStyleMutationTarget.kt new file mode 100644 index 0000000..e77afb3 --- /dev/null +++ b/src/main/java/com/mapconductor/maplibre/MapLibreStyleMutationTarget.kt @@ -0,0 +1,179 @@ +package com.mapconductor.maplibre + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.map.StyleMutationTarget +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.BackgroundLayer +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.FillExtrusionLayer +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.HeatmapLayer +import org.maplibre.android.style.layers.LayoutPropertyValue +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.PaintPropertyValue +import org.maplibre.android.style.layers.SymbolLayer + +/** + * Changes a loaded MapLibre style in place. + * + * The seven calls [StyleMutationTarget] asks for, against MapLibre'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. + * + * Specific to MapLibre on Android, despite the shape being shared. Unlike + * the browser -- where MapLibre, Mapbox and the MapTiler SDK answer to the + * same method names and one applier serves all three -- the Android SDKs + * share nothing: Mapbox is `com.mapbox.maps`, and MapTiler Android is + * `com.maptiler.maptilersdk`, not MapLibre at all. Each gets its own + * adapter, and core keeps the decisions. + * + * ## Why values arrive as text + * + * A style property's value is a literal or an expression tree, and MapLibre + * wants the two as different Java types — a `String`/`Float`/`Boolean`, or + * an `Expression` built from a Gson tree. Text is what crossed the FFI + * boundary from the compiler, and this is where it becomes one or the + * other. + */ +@InternalMapConductorApi +internal class MapLibreStyleMutationTarget( + 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 + * `MapLibreViewController.isShowingRequestedStyle`. + */ + private val ready: () -> Boolean, +) : StyleMutationTarget { + override val isReady: Boolean get() = ready() + + override fun hasLayer(layerId: String): Boolean = style()?.getLayer(layerId) != null + + override fun setPaint( + layerId: String, + key: String, + valueJson: String, + ) { + val layer = style()?.getLayer(layerId) ?: return + layer.setProperties(PaintPropertyValue(key, valueFor(valueJson))) + } + + override fun setLayout( + layerId: String, + key: String, + valueJson: String, + ) { + val layer = style()?.getLayer(layerId) ?: return + layer.setProperties(LayoutPropertyValue(key, valueFor(valueJson))) + } + + override fun setFilter( + layerId: String, + filterJson: String, + ) { + // `setFilter` is declared on each layer type rather than on `Layer`, + // so there is no way to write this once. A layer type that cannot + // be filtered (background, raster, hillshade) throws, which the + // caller reports as not applied -- correct, because it cannot be. + val layer = style()?.getLayer(layerId) ?: return + // `setFilter` takes an expression, never null, so "no filter" is + // the one that matches everything. + val expression = + JsonParser.parseString(filterJson).takeIf { !it.isJsonNull }?.let(::expressionOf) + ?: Expression.literal(true) + when (layer) { + is FillLayer -> layer.setFilter(expression) + is LineLayer -> layer.setFilter(expression) + is SymbolLayer -> layer.setFilter(expression) + is CircleLayer -> layer.setFilter(expression) + is FillExtrusionLayer -> layer.setFilter(expression) + is HeatmapLayer -> layer.setFilter(expression) + else -> throw UnsupportedOperationException("${layer.javaClass.simpleName} has no filter") + } + } + + override fun setZoomRange( + layerId: String, + minZoom: Float, + maxZoom: Float, + ) { + val layer = style()?.getLayer(layerId) ?: return + layer.minZoom = minZoom + layer.maxZoom = maxZoom + } + + 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. `addLayer` would throw on the duplicate id. + if (target.getLayer(layerId) != null) return + // MapLibre builds layers from typed constructors; there is no "add + // this JSON" call. The compiler only ever adds one kind of layer -- + // a background under a style that has none -- so that is the one + // kind handled, and anything else is refused rather than guessed at. + val layer = JsonParser.parseString(layerJson).asJsonObject + if (layer.get("type")?.asString != "background") { + throw UnsupportedOperationException("only a background layer can be added here") + } + val background = BackgroundLayer(layerId) + layer.getAsJsonObject("paint")?.let { paint -> + val properties = + paint.entrySet().map { (key, value) -> PaintPropertyValue(key, valueFor(value)) } + if (properties.isNotEmpty()) background.setProperties(*properties.toTypedArray()) + } + if (beforeId != null && target.getLayer(beforeId) != null) { + target.addLayerBelow(background, beforeId) + } else { + target.addLayer(background) + } + } + + /** MapLibre answers `false` for a layer it does not have; nothing to guard. */ + override fun removeLayer(layerId: String) { + style()?.removeLayer(layerId) + } + + private fun valueFor(json: String): Any = valueFor(JsonParser.parseString(json)) + + /** + * A JSON node as the Java value MapLibre wants. + * + * Arrays are expressions; everything else is the literal it looks like. + * A number arrives as `Float` because that is what every numeric paint + * property is declared as — handing it a `Double` makes the native + * setter reject the whole property. + */ + private fun valueFor(node: JsonElement): Any = + when { + node.isJsonArray -> expressionOf(node) + node.isJsonNull -> + // Nothing to set. The compiler fills in the spec default for + // the properties it knows, so this is a raw key it did not, + // and refusing is better than writing something invented. + throw UnsupportedOperationException("no value, and no default to put back") + node.isJsonObject -> throw UnsupportedOperationException("an object is not a style value") + else -> { + val primitive = node.asJsonPrimitive + when { + primitive.isBoolean -> primitive.asBoolean + primitive.isNumber -> primitive.asFloat + else -> primitive.asString + } + } + } + + private fun expressionOf(node: JsonElement): Expression = + Expression.Converter.convert(node as? JsonArray ?: node.asJsonArray) +} diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 1bc3991..4bd5b8f 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -163,12 +163,43 @@ class MapLibreViewController( */ internal fun loadedStyle(): Style? = styleInstance?.takeIf { it.isFullyLoaded } + /** The document [loadedStyle] was loaded from, and the one asked for. */ + private var loadedStyleUrl: String? = null + private var requestedStyleUrl: String? = null + + /** + * Whether the style on screen is the document the map was last asked + * for. + * + * What [MapLibreStyleMutationTarget] answers `isReady` with, and not the + * same question as "is a style loaded". A vector style hands over a new + * document and then applies the deltas compiled for it; between those + * two 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" -- five hundred of them, while the map itself + * looked right because the whole set goes on again at + * [styleLoadedListener]. + */ + internal fun isShowingRequestedStyle(): Boolean = + loadedStyle() != null && loadedStyleUrl == requestedStyleUrl + private var mapDesignTypeChangeListener: MapLibreDesignTypeChangeHandler? = 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: everything it + * set is lost with the old style, and this is the moment the new one is + * known to be complete. + */ + internal var styleLoadedListener: (() -> Unit)? = null + override fun setMapDesignType(value: MapLibreMapDesignTypeInterface) { + requestedStyleUrl = value.styleJsonURL mainCoroutine.launch { holder.map.setStyle(value.styleJsonURL) { newStyle -> Log.d("MapLibre", "Style changed to ${value.styleJsonURL}") + loadedStyleUrl = value.styleJsonURL setupStyle(newStyle) } } diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt index d789e49..4dd43d3 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt @@ -165,6 +165,10 @@ internal fun MapLibreViewController.setupStyle(style: Style) { groundImageController.reapplyStyle() rasterLayerController.reapplyStyle() } + // Last, and after the overlays: anything a vector style adjustment put + // on the previous style went with it, and this is the only moment the + // new one is known to be complete. + styleLoadedListener?.invoke() } internal fun MapLibreViewController.ensurePolygonZLayers(style: Style) { diff --git a/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt index f14978d..586a11e 100644 --- a/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt @@ -52,23 +52,23 @@ class MapLibreCircleLayer( val layer: FillLayer get() = FillLayer(layerId, sourceId).apply { - setProperties( - fillColor(Expression.get(Prop.FILL_COLOR)), - fillSortKey(Expression.get(Prop.Z_INDEX)), - ) - } + setProperties( + fillColor(Expression.get(Prop.FILL_COLOR)), + fillSortKey(Expression.get(Prop.Z_INDEX)), + ) + } val strokeLayer: LineLayer get() = LineLayer(strokeLayerId, sourceId).apply { - setProperties( - lineJoin(Property.LINE_JOIN_ROUND), - lineCap(Property.LINE_CAP_ROUND), - lineColor(Expression.get(Prop.STROKE_COLOR)), - lineWidth(Expression.get(Prop.STROKE_WIDTH)), - lineSortKey(Expression.get(Prop.Z_INDEX)), - ) - } + setProperties( + lineJoin(Property.LINE_JOIN_ROUND), + lineCap(Property.LINE_CAP_ROUND), + lineColor(Expression.get(Prop.STROKE_COLOR)), + lineWidth(Expression.get(Prop.STROKE_WIDTH)), + lineSortKey(Expression.get(Prop.Z_INDEX)), + ) + } fun draw( entities: List>, diff --git a/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt b/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt index b527cb9..f0fc551 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt @@ -28,18 +28,18 @@ open class MarkerLayer( val layer: SymbolLayer get() = SymbolLayer(layerId, sourceId).apply { - setProperties( - iconImage(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ID)), - // iconSize(get(MapLibreMarkerOverlayRenderer.Prop.SCALE)), - iconAllowOverlap(true), - iconIgnorePlacement(true), - PropertyFactory.symbolSortKey(get(MapLibreMarkerOverlayRenderer.Prop.Z_INDEX)), - iconAnchor(MapLibreMarkerOverlayRenderer.IconAnchor.TOP_LEFT), - iconTranslateAnchor(MapLibreMarkerOverlayRenderer.IconTranslateAnchor.MAP), - // Each feature always carries icon-offset in properties; use it directly - iconOffset(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ANCHOR)), - ) - } + setProperties( + iconImage(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ID)), + // iconSize(get(MapLibreMarkerOverlayRenderer.Prop.SCALE)), + iconAllowOverlap(true), + iconIgnorePlacement(true), + PropertyFactory.symbolSortKey(get(MapLibreMarkerOverlayRenderer.Prop.Z_INDEX)), + iconAnchor(MapLibreMarkerOverlayRenderer.IconAnchor.TOP_LEFT), + iconTranslateAnchor(MapLibreMarkerOverlayRenderer.IconTranslateAnchor.MAP), + // Each feature always carries icon-offset in properties; use it directly + iconOffset(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ANCHOR)), + ) + } val source: GeoJsonSource = GeoJsonSource( diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt index 5219038..55c4761 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt @@ -33,10 +33,10 @@ class MapLibrePolygonLayer( val layer: FillLayer get() = FillLayer(layerId, sourceId).apply { - setProperties( - fillColor(get(Prop.FILL_COLOR)), - ) - } + setProperties( + fillColor(get(Prop.FILL_COLOR)), + ) + } fun draw( entities: List>, diff --git a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt index 4e7c053..2ce28fd 100644 --- a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt +++ b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt @@ -38,13 +38,13 @@ class MapLibrePolylineLayer( val layer: LineLayer get() = LineLayer(layerId, sourceId).apply { - setProperties( - lineJoin(Property.LINE_JOIN_ROUND), - lineCap(Property.LINE_CAP_ROUND), - lineColor(get(Prop.STROKE_COLOR)), - lineWidth(get(Prop.STROKE_WIDTH)), - ) - } + setProperties( + lineJoin(Property.LINE_JOIN_ROUND), + lineCap(Property.LINE_CAP_ROUND), + lineColor(get(Prop.STROKE_COLOR)), + lineWidth(get(Prop.STROKE_WIDTH)), + ) + } fun draw( entities: List>, From 92ad832ca60cf1ca55213d6421dee74533722904 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 26 Oct 2026 17:59:51 -0700 Subject: [PATCH 08/31] Test that a ground image survives a map design change Instrumented: a real MapView loads two local styles through setMapDesignType, and the ground image is checked in the new style (unchanged image, same design set again, removal after the change). Android is not affected by the iOS bug (ios-for-maplibre#12): setupStyle calls groundImageController.reapplyStyle() on every load. Removing that call makes all three fail. --- build.gradle.kts | 8 + .../MapLibreGroundImageStyleChangeTest.kt | 153 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/androidTest/java/com/mapconductor/maplibre/MapLibreGroundImageStyleChangeTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index feef0d7..303c454 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -99,6 +99,14 @@ dependencies { } else { api("com.mapconductor:compose:$libraryVersion") } + + // 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.junit) + 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/maplibre/MapLibreGroundImageStyleChangeTest.kt b/src/androidTest/java/com/mapconductor/maplibre/MapLibreGroundImageStyleChangeTest.kt new file mode 100644 index 0000000..16eb747 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/maplibre/MapLibreGroundImageStyleChangeTest.kt @@ -0,0 +1,153 @@ +package com.mapconductor.maplibre + +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.mapconductor.compose.groundimage.GroundImage +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageState +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.maplibre.android.maps.Style +import java.io.File + +/** + * 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` → `setStyle` → + * `setupStyle` — on a real MapView loading two local styles. + */ +@RunWith(AndroidJUnit4::class) +class MapLibreGroundImageStyleChangeTest { + @get:Rule + val compose = createComposeRule() + + private lateinit var first: MapLibreDesign + private lateinit var second: MapLibreDesign + + @Before + fun writeStyles() { + val dir = InstrumentationRegistry.getInstrumentation().targetContext.cacheDir + fun style(name: String): MapLibreDesign { + val file = File(dir, "mc-groundimage-$name.json") + file.writeText( + """{"version": 8, "name": "$name", "sources": {}, + "layers": [{"id": "background", "type": "background"}]}""", + ) + return MapLibreDesign(id = name, styleJsonURL = "file://${file.absolutePath}") + } + first = style("first") + second = style("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", + ) + } + + private fun currentStyle(state: MapLibreViewState): Style? { + var style: Style? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + style = state.getMapViewHolder()?.map?.style?.takeIf { it.isFullyLoaded } + } + return style + } + + private fun hasGroundImage(style: Style?): Boolean { + var found = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + found = style != null && + style.getSource("mc-gimg-src-tokyo") != null && + style.getLayer("mc-gimg-lyr-tokyo") != null + } + return found + } + + private fun styleNamed(state: MapLibreViewState, design: MapLibreDesign): Style? { + var style: Style? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + style = state.getMapViewHolder()?.map?.style + ?.takeIf { it.isFullyLoaded && it.uri == design.styleJsonURL } + } + return style + } + + private var shown by mutableStateOf(true) + + private fun show(state: MapLibreViewState, image: GroundImageState) { + compose.setContent { + MapLibreMapView(state = state, modifier = Modifier.fillMaxSize()) { + if (shown) GroundImage(image) + } + } + } + + @Test + fun anUnchangedGroundImageIsInTheNewStyle() { + val state = MapLibreViewState(mapDesignType = first, id = "unchanged") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, first)) } + val before = currentStyle(state) + + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(TIMEOUT) { styleNamed(state, second) != null } + val after = currentStyle(state) + assertNotSame("the design change replaced the style", before, after) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, second)) } + assertTrue("drawn again in the new style, with nothing about it changed", hasGroundImage(after)) + } + + @Test + fun theSameDesignSetAgainKeepsTheImage() { + val state = MapLibreViewState(mapDesignType = first, id = "same") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, first)) } + + compose.runOnUiThread { state.mapDesignType = first } + // Give a reload, if one is started, time to land before checking. + Thread.sleep(SETTLE_MS) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, first)) } + } + + @Test + fun aGroundImageRemovedAfterTheChangeLeavesTheNewStyle() { + val state = MapLibreViewState(mapDesignType = first, id = "removed") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, first)) } + + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(TIMEOUT) { hasGroundImage(styleNamed(state, second)) } + + compose.runOnUiThread { shown = false } + compose.waitUntil(TIMEOUT) { + val style = styleNamed(state, second) + style != null && !hasGroundImage(style) + } + } + + private companion object { + const val TIMEOUT = 20_000L + const val SETTLE_MS = 1_500L + } +} From 26fdcf8bc63f2231a791dd7a69935ceabfa9d236 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 26 Oct 2026 19:12:42 -0700 Subject: [PATCH 09/31] 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 55bdc80..faaa6e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ ktLint = "14.2.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +runner = "1.7.0" [libraries] kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } @@ -47,6 +48,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 2f49df5b4fd19797e8f3f044b96f098436e43e1a Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 28 Oct 2026 13:47:11 -0700 Subject: [PATCH 10/31] 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 16c5dc11a28e6a09a1ac6f3f340d3b9300569cf2 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 29 Oct 2026 01:37:45 -0700 Subject: [PATCH 11/31] Use the core reapplyStyle() for raster layers MapLibreRasterLayerController.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-maplibre.api | 1 - .../raster/MapLibreRasterLayerController.kt | 25 ------------------- 2 files changed, 26 deletions(-) diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index 615f598..3db8253 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -459,7 +459,6 @@ public final class com.mapconductor.maplibre.raster.MapLibreRasterLayerControlle public java.lang.Object update(com.mapconductor.core.raster.RasterLayerState, kotlin.coroutines.Continuation); public java.lang.Object clear(kotlin.coroutines.Continuation); public void destroy(); - public final java.lang.Object reapplyStyle(kotlin.coroutines.Continuation); public java.lang.Object update(java.lang.Object, kotlin.coroutines.Continuation); } public final class com.mapconductor.maplibre.raster.MapLibreRasterLayerHandle { diff --git a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerController.kt b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerController.kt index d45e4b4..14dc737 100644 --- a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerController.kt +++ b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerController.kt @@ -1,10 +1,8 @@ package com.mapconductor.maplibre.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 MapLibreRasterLayerController( @@ -35,27 +33,4 @@ class MapLibreRasterLayerController( MapLibreRasterHeaderInjector.remove(this) super.destroy() } - - 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() - } } From b9d8af02531c60251ec2b569c712dafd6bd17da6 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 04:18:03 -0800 Subject: [PATCH 12/31] Redraw tiles once per burst of tiled-marker changes Relabelling street trees changes a few dozen tiled markers at once, and each change went through update() separately: - the raster source was replaced per marker, and the map fetched every visible tile again each time (45 replacements, 494 tile renders, the last tile 6.1 s after the change, on a Pixel 5a); - renderer.onPostProcess() copied all 144k entities to redraw the native ones, although nothing native had changed (50 ms each, 2.3 s in total); - the icon was baked and all entities copied just to count them. Now changes arriving within a frame share one source replacement, onPostProcess runs only when a native marker became tiled, the icon is baked only for a native marker, and the count is read without copying. Same burst: one replacement, 19 tile renders, the last tile 0.25 s after the change. --- .../marker/MapLibreMarkerController.kt | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt index 9eef9e5..4cc9264 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt @@ -20,8 +20,11 @@ import com.mapconductor.core.raster.TileScheme import com.mapconductor.core.tileserver.TileServerRegistry import com.mapconductor.maplibre.MapLibreActualMarker import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withPermit @@ -110,11 +113,12 @@ class MapLibreMarkerController( 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) { @@ -132,14 +136,19 @@ class MapLibreMarkerController( 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 { @@ -170,7 +179,7 @@ class MapLibreMarkerController( renderer.onPostProcess() if (tiledMarkerIds.isNotEmpty()) { - syncTiledOverlay() + scheduleTileRefresh() } else { removeTileOverlay() } @@ -178,6 +187,30 @@ class MapLibreMarkerController( viewportSwitch.requestReapply() } + private var tileRefreshJob: Job? = null + + /** + * 1 本ずつの変更からの描き直しを 1 回にまとめる。 + * + * ラベルの付け替えのように数十本が続けて変わると、1 本ごとにラスターソースを差し替え、 + * そのたびに地図が見えているタイルを全部取り直していた(Pixel 5a で 45 回、 + * 描き直したタイル 494 枚、最後のタイルまで 6 秒)。続けて来た変更は + * [TILE_REFRESH_COALESCE] の間に受けて、まとめて 1 回だけ差し替える。 + */ + private fun scheduleTileRefresh() { + if (tileRefreshJob?.isActive == true) return + tileRefreshJob = + coroutine.launch { + delay(TILE_REFRESH_COALESCE) + semaphore.withPermit { syncTiledOverlay() } + } + } + + private companion object { + /** 続けて来る変更を 1 回の描き直しにまとめる窓。1 フレーム分。 */ + val TILE_REFRESH_COALESCE = 16.milliseconds + } + override suspend fun clear() { viewportSwitch.destroy() semaphore.withPermit { From 8c4fb97cff56d8181dc6a1c55599902ca02172d5 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 14:58:14 -0800 Subject: [PATCH 13/31] Cancel a pending tile refresh on destroy A refresh still waiting when the controller is destroyed would run afterwards and register this map's tile route again. --- .../mapconductor/maplibre/marker/MapLibreMarkerController.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt index 4cc9264..43d25f5 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt @@ -247,6 +247,8 @@ class MapLibreMarkerController( 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 From 09faaa81a32f45ef55d2f403adc9abc34f1dc43f Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 19:25:12 -0800 Subject: [PATCH 14/31] Keep the whole map design across configuration changes MapLibreMapViewSaver saved only styleJsonURL: the restored design had id osm-bright and no attribution rules whatever it had been. It now saves id, style URL and attributionRules. Adds a device round-trip test. --- .../maplibre/MapLibreMapViewSaverTest.kt | 81 +++++++++++++++++++ .../maplibre/MapLibreViewStateImpl.kt | 7 +- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/androidTest/java/com/mapconductor/maplibre/MapLibreMapViewSaverTest.kt diff --git a/src/androidTest/java/com/mapconductor/maplibre/MapLibreMapViewSaverTest.kt b/src/androidTest/java/com/mapconductor/maplibre/MapLibreMapViewSaverTest.kt new file mode 100644 index 0000000..f2fb611 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/maplibre/MapLibreMapViewSaverTest.kt @@ -0,0 +1,81 @@ +package com.mapconductor.maplibre + +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.MapUISettings +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import android.os.Bundle +import android.os.Parcel + +/** + * 回転などの構成変更で state が [MapLibreMapViewSaver] を通って作り直されても、デザイン(id・ + * attributionRules ほか)と uiSettings・カメラが落ちないこと。Bundle は実機の Parcel を一度通す。 + */ +@RunWith(AndroidJUnit4::class) +class MapLibreMapViewSaverTest { + 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 = MapLibreDesign.MapTilerTonerJa.copy(attributionRules = rules) + val state = MapLibreViewState(mapDesignType = design, id = "maplibre-1", cameraPosition = camera) + state.uiSettings = MapUISettings(rotateGesture = false, tiltGesture = false) + + val restored = roundTrip(MapLibreMapViewSaver().createSaver(), state) + + // 以前は styleJsonURL しか保存しておらず、id は osm-bright、attributionRules は空に戻っていた。 + assertEquals(design, restored.mapDesignType) + assertCommon(restored, "maplibre-1") + } + + @Test + fun keepsPresetDesign() { + val state = MapLibreViewState(mapDesignType = MapLibreDesign.OsmBrightJa, id = "maplibre-2") + val restored = roundTrip(MapLibreMapViewSaver().createSaver(), state) + assertEquals(MapLibreDesign.OsmBrightJa, restored.mapDesignType) + } + + 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/maplibre/MapLibreViewStateImpl.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt index 10fd586..93a0d95 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt @@ -8,6 +8,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 java.util.UUID import android.os.Bundle @@ -53,7 +54,10 @@ class MapLibreMapViewSaver : BaseMapViewSaver() { state: MapLibreViewState, bundle: Bundle, ) { - bundle.putString("styleJsonURL", state.mapDesignType.styleJsonURL) + val design = state.mapDesignType + bundle.putString("id", design.id) + bundle.putString("styleJsonURL", design.styleJsonURL) + MapViewStateBundle.putAttributionRules(bundle, design.attributionRules) } override fun createState( @@ -71,6 +75,7 @@ class MapLibreMapViewSaver : BaseMapViewSaver() { styleJsonURL = mapDesignBundle?.getString("styleJsonURL") ?: MapLibreDesign.OsmBright.styleJsonURL, + attributionRules = MapViewStateBundle.getAttributionRules(mapDesignBundle), ), cameraPosition = cameraPosition, ) From 136dbd175cc166aa965256fee575211647ff3c7b Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 05:40:54 -0800 Subject: [PATCH 15/31] 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-maplibre.api | 2 ++ .../maplibre/MapLibreViewController.kt | 16 +++++++++ .../polygon/MapLibrePolygonConductor.kt | 33 +++++++++++++++++++ .../MapLibreRasterLayerOverlayRenderer.kt | 31 ++++++++++++++++- 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index 3db8253..a0319a1 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -363,6 +363,8 @@ public final class com.mapconductor.maplibre.polygon.MapLibrePolygonConductor im public final com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer getPolygonOverlay(); public final com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer getPolylineOverlay(); public int getZIndex(); + public final com.mapconductor.core.polygon.PolygonTiler getTiler(); + public final void setTiler(com.mapconductor.core.polygon.PolygonTiler); public final kotlin.jvm.functions.Function1 getClickListener(); public final void setClickListener(kotlin.jvm.functions.Function1); public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 4bd5b8f..5d5bd4b 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -17,7 +17,10 @@ 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.POLYGON_TILE_LAYER_ID import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polygon.PolygonTiler +import com.mapconductor.core.polygon.polygonTileRasterLayerState import com.mapconductor.core.polyline.OnPolylineEventHandler import com.mapconductor.maplibre.circle.MapLibreCircleController import com.mapconductor.maplibre.groundimage.MapLibreGroundImageController @@ -104,6 +107,19 @@ class MapLibreViewController( 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) + } + } + } } fun setupListeners() { diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt index f0150eb..66ef553 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.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.maplibre.polyline.MapLibrePolylineOverlayRenderer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class MapLibrePolygonConductor( val polygonOverlay: MapLibrePolygonOverlayRenderer, @@ -21,6 +24,13 @@ class MapLibrePolygonConductor( > { 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 + var clickListener: ((PolygonEvent) -> Unit)? = null override suspend fun add(data: List) { @@ -38,7 +48,19 @@ class MapLibrePolygonConductor( 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 = @@ -64,6 +86,13 @@ class MapLibrePolygonConductor( } 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( @@ -97,6 +126,7 @@ class MapLibrePolygonConductor( polygonOverlay.polygonManager.find(position) as? PolygonEntityInterface override suspend fun clear() { + tiler?.clear() polygonOverlay.polygonManager.clear() polylineOverlay.polylineManager.clear() polygonOverlay.onPostProcess() @@ -134,6 +164,9 @@ class MapLibrePolygonConductor( } } +/** 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/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt index 0e087ea..0be72e1 100644 --- a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt @@ -1,5 +1,6 @@ package com.mapconductor.maplibre.raster +import com.mapconductor.core.polygon.POLYGON_TILE_LAYER_ID import com.mapconductor.core.raster.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface import com.mapconductor.core.raster.RasterLayerSource @@ -24,6 +25,8 @@ class MapLibreRasterLayerOverlayRenderer( 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 + override suspend fun onAdd( data: List, ): List = @@ -96,6 +99,8 @@ class MapLibreRasterLayerOverlayRenderer( try { if (isMarkerTileRaster(state)) { addLayerForMarkerTile(style, layer) + } else if (isPolygonTileRaster(state)) { + addLayerForPolygonTile(style, layer) } else { style.addLayer(layer) } @@ -105,6 +110,26 @@ class MapLibreRasterLayerOverlayRenderer( return handle } + /** + * 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: org.maplibre.android.maps.Style, + layer: RasterLayer, + ) { + val anchor = style.layers.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: org.maplibre.android.maps.Style, layer: RasterLayer, @@ -159,7 +184,7 @@ class MapLibreRasterLayerOverlayRenderer( 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() @@ -262,6 +287,10 @@ class MapLibreRasterLayerOverlayRenderer( 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 7d0f555414d46176f9cc8d5829cb7113c7aa51e6 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 13:16:50 -0800 Subject: [PATCH 16/31] Destroy the MapView when the map leaves the composition Nothing destroyed the MapView. Its render thread kept it, and through the camera-idle listener the old controller with every overlay, so each provider switch added a whole map to the heap. It is now destroyed through MapViewBase's onDestroyMapView. It is not stopped first: it was never started, and MapView.onStop() would deactivate the shared FileSource. --- src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt index 89af5ea..3e1e3db 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt @@ -202,6 +202,11 @@ fun MapLibreMapView( // For now, assuming content relates to overlay definitions. style = style, onStyleDiagnostics = onStyleDiagnostics, + // Never started (onStart is not called), so it is not stopped either -- + // MapView.onStop() would deactivate the shared FileSource the next map + // still uses. Destroying it ends the render thread, whose camera + // listeners otherwise keep the controller and every overlay alive. + onDestroyMapView = { mapView -> mapView.onDestroy() }, content = content, // This might need adjustment based on how overlays are handled ) } From e09ef85fea54304adda59ca655ce00c267e7a9f0 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 13:33:12 -0800 Subject: [PATCH 17/31] 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. --- .../maplibre/polygon/MapLibrePolygonConductor.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt index 66ef553..a201ffd 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt @@ -160,7 +160,10 @@ class MapLibrePolygonConductor( } 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 4b667f74a166dbd01161995f8ed698e285e89b9d Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 16:20:26 -0800 Subject: [PATCH 18/31] 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/maplibre/MapLibreViewStateImpl.kt | 6 ++++-- .../maplibre/polygon/MapLibrePolygonConductor.kt | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt index 93a0d95..1485310 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt @@ -21,7 +21,10 @@ class MapLibreViewState( cameraPosition: MapCameraPosition = MapCameraPosition.Default, ) : MapViewState(cameraPosition), MapLibreViewStateInterface { - private var controller: MapLibreViewControllerInterface? = 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: MapLibreViewControllerInterface? + get() = attachedMapController as? MapLibreViewControllerInterface private var _mapDesignType: MapLibreMapDesignTypeInterface = mapDesignType override var mapDesignType: MapLibreMapDesignTypeInterface @@ -32,7 +35,6 @@ class MapLibreViewState( get() = _mapDesignType internal fun setController(controller: MapLibreViewControllerInterface) { - this.controller = controller attachController(controller) } diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt index f0150eb..096d277 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt @@ -130,7 +130,10 @@ class MapLibrePolygonConductor( } 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 ddc164189ebde9bad65820f7b4d7735771bbfef2 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 17:05:15 -0800 Subject: [PATCH 19/31] Extend core PolygonController for MapLibre polygons MapLibrePolygonConductor 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 MapLibrePolygonController, which extends core's PolygonController, and MapLibrePolygonCompositeRenderer, 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-maplibre.api | 41 ++- docs/MapLibreViewControllerImpl.kt.md | 4 +- .../MapLibrePolygonCompositeRenderer.kt.md | 37 +++ docs/polygon/MapLibrePolygonConductor.kt.md | 244 ------------------ docs/polygon/MapLibrePolygonController.kt.md | 30 +++ .../mapconductor/maplibre/MapLibreMapView.kt | 14 +- .../maplibre/MapLibreViewController.kt | 4 +- .../maplibre/MapLibreViewControllerStyle.kt | 14 +- .../MapLibrePolygonCompositeRenderer.kt | 78 ++++++ .../polygon/MapLibrePolygonConductor.kt | 153 ----------- .../polygon/MapLibrePolygonController.kt | 17 ++ .../polygon/MapLibrePolygonOverlayRenderer.kt | 16 +- 12 files changed, 213 insertions(+), 439 deletions(-) create mode 100644 docs/polygon/MapLibrePolygonCompositeRenderer.kt.md delete mode 100644 docs/polygon/MapLibrePolygonConductor.kt.md create mode 100644 docs/polygon/MapLibrePolygonController.kt.md create mode 100644 src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonCompositeRenderer.kt delete mode 100644 src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt create mode 100644 src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonController.kt diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index 3db8253..c156484 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -83,7 +83,7 @@ public final class com.mapconductor.maplibre.MapLibreMapViewKt { public static final com.mapconductor.maplibre.MapLibreViewController createMapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.MutableMapServiceRegistry); public static final com.mapconductor.maplibre.marker.MapLibreMarkerController getMarkerController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerTilingOptions); public static final com.mapconductor.maplibre.polyline.MapLibrePolylineController getPolylineController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); - public static final com.mapconductor.maplibre.polygon.MapLibrePolygonConductor getPolygonController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); + public static final com.mapconductor.maplibre.polygon.MapLibrePolygonController getPolygonController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); public static final com.mapconductor.maplibre.circle.MapLibreCircleController getCircleController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); public static final com.mapconductor.maplibre.raster.MapLibreRasterLayerController getRasterLayerController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); public static final com.mapconductor.maplibre.groundimage.MapLibreGroundImageController getGroundImageController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); @@ -109,8 +109,8 @@ public final class com.mapconductor.maplibre.MapLibrePolyUtilsKt { public final class com.mapconductor.maplibre.MapLibreTypeAliasKt { } public final class com.mapconductor.maplibre.MapLibreViewController extends com.mapconductor.core.controller.BaseMapViewController implements com.mapconductor.maplibre.MapLibreViewControllerInterface,org.maplibre.android.maps.MapLibreMap$OnMapClickListener,org.maplibre.android.maps.MapLibreMap$OnMapLongClickListener,org.maplibre.android.maps.MapLibreMap$OnMoveListener,org.maplibre.android.maps.MapLibreMap$OnCameraMoveListener,org.maplibre.android.maps.MapLibreMap$OnCameraIdleListener { - public com.mapconductor.maplibre.MapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.maplibre.marker.MapLibreMarkerController, com.mapconductor.maplibre.polyline.MapLibrePolylineController, com.mapconductor.maplibre.polygon.MapLibrePolygonConductor, com.mapconductor.maplibre.groundimage.MapLibreGroundImageController, com.mapconductor.maplibre.circle.MapLibreCircleController, com.mapconductor.maplibre.raster.MapLibreRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope); - public com.mapconductor.maplibre.MapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.maplibre.marker.MapLibreMarkerController, com.mapconductor.maplibre.polyline.MapLibrePolylineController, com.mapconductor.maplibre.polygon.MapLibrePolygonConductor, com.mapconductor.maplibre.groundimage.MapLibreGroundImageController, com.mapconductor.maplibre.circle.MapLibreCircleController, com.mapconductor.maplibre.raster.MapLibreRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.maplibre.MapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.maplibre.marker.MapLibreMarkerController, com.mapconductor.maplibre.polyline.MapLibrePolylineController, com.mapconductor.maplibre.polygon.MapLibrePolygonController, com.mapconductor.maplibre.groundimage.MapLibreGroundImageController, com.mapconductor.maplibre.circle.MapLibreCircleController, com.mapconductor.maplibre.raster.MapLibreRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope); + public com.mapconductor.maplibre.MapLibreViewController(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.maplibre.marker.MapLibreMarkerController, com.mapconductor.maplibre.polyline.MapLibrePolylineController, com.mapconductor.maplibre.polygon.MapLibrePolygonController, com.mapconductor.maplibre.groundimage.MapLibreGroundImageController, com.mapconductor.maplibre.circle.MapLibreCircleController, com.mapconductor.maplibre.raster.MapLibreRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.maplibre.MapLibreMapViewHolderInterface getHolder(); public kotlinx.coroutines.CoroutineScope getMainCoroutine(); public kotlinx.coroutines.CoroutineScope getDefaultCoroutine(); @@ -358,27 +358,22 @@ public class com.mapconductor.maplibre.marker.MarkerLayer { public final boolean wouldSkipDraw(java.util.List>); public final void draw(java.util.List>, org.maplibre.android.maps.Style); } -public final class com.mapconductor.maplibre.polygon.MapLibrePolygonConductor implements com.mapconductor.core.controller.SlottedOverlayController> { - public com.mapconductor.maplibre.polygon.MapLibrePolygonConductor(com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer, com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer); - public final com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer getPolygonOverlay(); - public final com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer getPolylineOverlay(); - public int getZIndex(); - public final kotlin.jvm.functions.Function1 getClickListener(); - public final void setClickListener(kotlin.jvm.functions.Function1); - 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 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.maplibre.polygon.MapLibrePolygonCompositeRenderer implements com.mapconductor.core.polygon.PolygonOverlayRendererInterface> { + public com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer(com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer, com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer); + public final com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer getFill(); + public final com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer 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.maplibre.polygon.MapLibrePolygonConductorKt { +public final class com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRendererKt { +} +public final class com.mapconductor.maplibre.polygon.MapLibrePolygonController extends com.mapconductor.core.polygon.PolygonController> { + public com.mapconductor.maplibre.polygon.MapLibrePolygonController(com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer); + public com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer getRenderer(); + public int getZIndex(); + public com.mapconductor.core.polygon.PolygonOverlayRendererInterface getRenderer(); } public final class com.mapconductor.maplibre.polygon.MapLibrePolygonLayer { public com.mapconductor.maplibre.polygon.MapLibrePolygonLayer(java.lang.String, java.lang.String); diff --git a/docs/MapLibreViewControllerImpl.kt.md b/docs/MapLibreViewControllerImpl.kt.md index 4b0fd76..b58f1a6 100644 --- a/docs/MapLibreViewControllerImpl.kt.md +++ b/docs/MapLibreViewControllerImpl.kt.md @@ -14,7 +14,7 @@ class MapLibreViewController( override val holder: MapLibreMapViewHolderInterface, private val markerController: MapLibreMarkerController, private val polylineController: MapLibrePolylineController, - private val polygonController: MapLibrePolygonConductor, + private val polygonController: MapLibrePolygonController, private val groundImageController: MapLibreGroundImageController, private val circleController: MapLibreCircleController, private val rasterLayerController: MapLibreRasterLayerController, @@ -39,7 +39,7 @@ Initializes a new instance of the `MapLibreViewController`. - Type: `MapLibrePolylineController` - Description: The controller responsible for managing polylines. - `polygonController` - - Type: `MapLibrePolygonConductor` + - Type: `MapLibrePolygonController` - Description: The controller responsible for managing polygons. - `groundImageController` - Type: `MapLibreGroundImageController` diff --git a/docs/polygon/MapLibrePolygonCompositeRenderer.kt.md b/docs/polygon/MapLibrePolygonCompositeRenderer.kt.md new file mode 100644 index 0000000..013734a --- /dev/null +++ b/docs/polygon/MapLibrePolygonCompositeRenderer.kt.md @@ -0,0 +1,37 @@ +# MapLibrePolygonCompositeRenderer + +## Signature + +```kotlin +class MapLibrePolygonCompositeRenderer( + val fill: MapLibrePolygonOverlayRenderer, + val outline: MapLibrePolylineOverlayRenderer, +) : PolygonOverlayRendererInterface +``` + +## Description + +Presents the fill renderer and the outline renderer to the core `PolygonController` as a single +renderer. + +A MapLibre 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: `MapLibrePolygonOverlayRenderer` + - Description: Draws the polygon fill (with holes) into the fill source. +- `outline` + - Type: `MapLibrePolylineOverlayRenderer` + - Description: Draws the outlines into the polygon outline source. diff --git a/docs/polygon/MapLibrePolygonConductor.kt.md b/docs/polygon/MapLibrePolygonConductor.kt.md deleted file mode 100644 index 45e265e..0000000 --- a/docs/polygon/MapLibrePolygonConductor.kt.md +++ /dev/null @@ -1,244 +0,0 @@ -# MapLibrePolygonConductor - -The `MapLibrePolygonConductor` class is a controller responsible for managing polygon overlays on a -MapLibre map. It implements the `OverlayControllerInterface` to handle the lifecycle of polygons, -including their creation, update, and removal. This conductor also manages the rendering of polygon -outlines by creating corresponding polylines. - -It acts as a bridge between the abstract `PolygonState` data and the concrete rendering -implementation provided by `MapLibrePolygonOverlayRenderer` and `MapLibrePolylineOverlayRenderer`. - -## Signature - -```kotlin -class MapLibrePolygonConductor( - val polygonOverlay: MapLibrePolygonOverlayRenderer, - val polylineOverlay: MapLibrePolylineOverlayRenderer, -) : OverlayControllerInterface< - PolygonState, - PolygonEntityInterface, - PolygonEvent, - > -``` - -## Constructor - -### `MapLibrePolygonConductor(...)` - -Initializes a new instance of the `MapLibrePolygonConductor`. - -#### Signature - -```kotlin -MapLibrePolygonConductor( - val polygonOverlay: MapLibrePolygonOverlayRenderer, - val polylineOverlay: MapLibrePolylineOverlayRenderer, -) -``` - -#### Parameters - -- `polygonOverlay` - - Type: `MapLibrePolygonOverlayRenderer` - - Description: The renderer responsible for drawing the filled area of the polygons. -- `polylineOverlay` - - Type: `MapLibrePolylineOverlayRenderer` - - Description: The renderer responsible for drawing the outlines (strokes) of the polygons. - -## Properties - -### `zIndex` - -Specifies the drawing order of the overlay on the map. Higher values are drawn on top of lower -values. - -#### 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 efficiently synchronizes the state of the map with -the provided list. It adds new polygons, updates existing ones, and removes any polygons that are -not in the new list. It also creates and manages the corresponding outlines for each polygon. - -#### Signature - -```kotlin -override suspend fun add(data: List) -``` - -#### Parameters - -- `data` - - Type: `List` - - Description: A list of `PolygonState` objects to display on the map. - -### `update` - -Updates a single polygon on the map based on its `PolygonState`. If a polygon with the same ID -already exists, its properties will be updated. If it does not exist, a new polygon will be created. - -#### Signature - -```kotlin -override suspend fun update(state: PolygonState) -``` - -#### Parameters - -- `state` - - Type: `PolygonState` - - Description: The state object representing the polygon to update or add. - -### `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 the `onClick` handler defined within the specific -`PolygonEvent.state` and also invokes the global `clickListener` if it has been set. - -#### Signature - -```kotlin -fun dispatchClick(event: PolygonEvent) -``` - -#### Parameters - -- `event` - - Type: `PolygonEvent` - - Description: The event object containing details about the click. - -### `find` - -Finds the topmost polygon entity at a given geographic position on the map. - -#### Signature - -```kotlin -override fun find(position: GeoPointInterface): PolygonEntityInterface? -``` - -#### Parameters - -- `position` - - Type: `GeoPointInterface` - - Description: The geographic coordinate (latitude and longitude) to search at. - -#### Returns - -- Type: `PolygonEntityInterface?` - - Description: The found polygon entity, or `null` if no polygon is found at the specified - position. - -### `clear` - -Removes all polygons and their outlines that are currently managed by this conductor from the map. - -#### Signature - -```kotlin -override suspend fun clear() -``` - -### `onCameraChanged` - -A lifecycle method called when the map's camera position changes. This implementation is currently -empty. - -#### Signature - -```kotlin -override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) -``` - -### `destroy` - -Cleans up resources used by the conductor. This implementation is currently empty as there are no -specific native resources to release for polygons. - -#### Signature - -```kotlin -override fun destroy() -``` - -## Example - -Here's an example of how to instantiate and use `MapLibrePolygonConductor` to manage polygons on a -map. - -```kotlin -// 1. Assume polygonOverlay and polylineOverlay are already initialized -// val polygonOverlay: MapLibrePolygonOverlayRenderer = ... -// val polylineOverlay: MapLibrePolylineOverlayRenderer = ... - -// 2. Create an instance of the conductor -val polygonConductor = MapLibrePolygonConductor(polygonOverlay, polylineOverlay) - -// 3. Define the state for the polygons you want to add -val polygonState1 = PolygonState( - id = "polygon-1", - 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 = 5f, - onClick = { event -> - println("Clicked on polygon with ID: ${event.state.id}") - } -) - -val polygonState2 = PolygonState( - id = "polygon-2", - points = listOf( - GeoPoint(41.8781, -87.6298), // Chicago - GeoPoint(39.7392, -104.9903), // Denver - GeoPoint(47.6062, -122.3321) // Seattle - ), - fillColor = Color.argb(100, 255, 0, 0) // Semi-transparent red -) - -// 4. Add the polygons to the map -// This needs to be called from a coroutine scope -lifecycleScope.launch { - polygonConductor.add(listOf(polygonState1, polygonState2)) -} - -// 5. Set a global click listener for all polygons -polygonConductor.clickListener = { event -> - println("Global listener: Clicked on polygon ${event.state.id}") - // You can, for example, show an info window here -} - -// 6. Update a polygon later -lifecycleScope.launch { - val updatedPolygonState1 = polygonState1.copy( - fillColor = Color.argb(100, 0, 255, 0) // Change color to green - ) - polygonConductor.update(updatedPolygonState1) -} - -// 7. Clear all polygons from the map -lifecycleScope.launch { - polygonConductor.clear() -} -``` diff --git a/docs/polygon/MapLibrePolygonController.kt.md b/docs/polygon/MapLibrePolygonController.kt.md new file mode 100644 index 0000000..7640446 --- /dev/null +++ b/docs/polygon/MapLibrePolygonController.kt.md @@ -0,0 +1,30 @@ +# MapLibrePolygonController + +## Signature + +```kotlin +class MapLibrePolygonController( + override val renderer: MapLibrePolygonCompositeRenderer, +) : PolygonController(renderer.fill.polygonManager, renderer) +``` + +## Description + +The polygon controller of the MapLibre 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 [`MapLibrePolygonCompositeRenderer`](./MapLibrePolygonCompositeRenderer.kt.md). + +## Constructor Parameters + +- `renderer` + - Type: `MapLibrePolygonCompositeRenderer` + - 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 `MapLibrePolygonConductor`). diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt index 89af5ea..07f88e4 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt @@ -38,7 +38,8 @@ import com.mapconductor.maplibre.marker.MapLibreMarkerController import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer import com.mapconductor.maplibre.marker.MarkerDragLayer import com.mapconductor.maplibre.marker.MarkerLayer -import com.mapconductor.maplibre.polygon.MapLibrePolygonConductor +import com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer +import com.mapconductor.maplibre.polygon.MapLibrePolygonController import com.mapconductor.maplibre.polygon.MapLibrePolygonLayer import com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer import com.mapconductor.maplibre.polyline.MapLibrePolylineController @@ -314,7 +315,7 @@ fun getPolylineController(holder: MapLibreMapViewHolderInterface): MapLibrePolyl return controller } -fun getPolygonController(holder: MapLibreMapViewHolderInterface): MapLibrePolygonConductor { +fun getPolygonController(holder: MapLibreMapViewHolderInterface): MapLibrePolygonController { val polylineLayer = MapLibrePolylineLayer( sourceId = "polygon-outline-source", @@ -341,9 +342,12 @@ fun getPolygonController(holder: MapLibreMapViewHolderInterface): MapLibrePolygo holder = holder, ) - return MapLibrePolygonConductor( - polygonOverlay = polygonOverlayRenderer, - polylineOverlay = polylineOverlayRenderer, + return MapLibrePolygonController( + renderer = + MapLibrePolygonCompositeRenderer( + fill = polygonOverlayRenderer, + outline = polylineOverlayRenderer, + ), ) } diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 4bd5b8f..5193651 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -25,7 +25,7 @@ import com.mapconductor.maplibre.marker.MapLibreMarkerController import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer import com.mapconductor.maplibre.marker.MarkerDragLayer import com.mapconductor.maplibre.marker.MarkerLayer -import com.mapconductor.maplibre.polygon.MapLibrePolygonConductor +import com.mapconductor.maplibre.polygon.MapLibrePolygonController import com.mapconductor.maplibre.polyline.MapLibrePolylineController import com.mapconductor.maplibre.raster.MapLibreRasterLayerController import org.maplibre.android.geometry.LatLng @@ -46,7 +46,7 @@ class MapLibreViewController( override val holder: MapLibreMapViewHolderInterface, internal val markerController: MapLibreMarkerController, internal val polylineController: MapLibrePolylineController, - internal val polygonController: MapLibrePolygonConductor, + internal val polygonController: MapLibrePolygonController, internal val groundImageController: MapLibreGroundImageController, internal val circleController: MapLibreCircleController, internal val rasterLayerController: MapLibreRasterLayerController, diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt index 4dd43d3..4c1c3e8 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt @@ -81,8 +81,8 @@ internal fun MapLibreViewController.setupStyle(style: Style) { (markerController.renderer as MapLibreMarkerOverlayRenderer).ensureDefaultIcon(style) // Polygon sources only (layers will be added per zIndex) - ensureGeoJsonSource(style, polygonController.polylineOverlay.layer.sourceId) - ensureGeoJsonSource(style, polygonController.polygonOverlay.layer.sourceId) + ensureGeoJsonSource(style, polygonController.renderer.outline.layer.sourceId) + ensureGeoJsonSource(style, polygonController.renderer.fill.layer.sourceId) // Circle cts as anchor above polygons ensureGeoJsonSource(style, circleController.renderer.layer.sourceId) @@ -159,8 +159,8 @@ internal fun MapLibreViewController.setupStyle(style: Style) { polylineController.renderer.redraw() // The polygon sources are new and empty on every style; the data has to // be written into them again, like the markers' and the polylines'. - polygonController.polygonOverlay.redraw() - polygonController.polylineOverlay.redraw() + polygonController.renderer.fill.redraw() + polygonController.renderer.outline.redraw() mainCoroutine.launch { groundImageController.reapplyStyle() rasterLayerController.reapplyStyle() @@ -172,12 +172,12 @@ internal fun MapLibreViewController.setupStyle(style: Style) { } internal fun MapLibreViewController.ensurePolygonZLayers(style: 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/maplibre/polygon/MapLibrePolygonCompositeRenderer.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonCompositeRenderer.kt new file mode 100644 index 0000000..f9c4f8b --- /dev/null +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonCompositeRenderer.kt @@ -0,0 +1,78 @@ +package com.mapconductor.maplibre.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.maplibre.MapLibreActualPolygon +import com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer + +/** + * 塗りと輪郭を 1 つのレンダラとして [com.mapconductor.core.polygon.PolygonController] に渡すための合成レンダラ。 + * + * fill レイヤーは幅 1 px の輪郭しか描けないので、`strokeWidth` を守るために輪郭は + * 別のポリライン(`outline-`)として [outline] で描く。差分計算はコアの + * PolygonController に任せ、ここは「塗りへの指示を輪郭にも写す」ことだけをする。 + * + * 塗りのエンティティはコントローラと同じ [fill] の polygonManager に載る。輪郭は + * [outline] の polylineManager に載り、コントローラからは見えない。 + */ +class MapLibrePolygonCompositeRenderer( + val fill: MapLibrePolygonOverlayRenderer, + val outline: MapLibrePolylineOverlayRenderer, +) : 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/maplibre/polygon/MapLibrePolygonConductor.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt deleted file mode 100644 index f0150eb..0000000 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.mapconductor.maplibre.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.maplibre.polyline.MapLibrePolylineOverlayRenderer - -class MapLibrePolygonConductor( - val polygonOverlay: MapLibrePolygonOverlayRenderer, - val polylineOverlay: MapLibrePolylineOverlayRenderer, -) : SlottedOverlayController< - PolygonState, - PolygonEntityInterface, - > { - override val zIndex: Int = 2 - - var clickListener: ((PolygonEvent) -> Unit)? = null - - 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) - } - - @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/maplibre/polygon/MapLibrePolygonController.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonController.kt new file mode 100644 index 0000000..37a4424 --- /dev/null +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonController.kt @@ -0,0 +1,17 @@ +package com.mapconductor.maplibre.polygon + +import com.mapconductor.core.polygon.PolygonController +import com.mapconductor.maplibre.MapLibreActualPolygon + +/** + * MapLibre 用のポリゴンコントローラ。 + * + * 追加・更新・削除の差分計算、`has`、クリックカスケードはコアの [PolygonController] が担う。 + * 塗りと輪郭(幅のある LineLayer)の 2 系統の描画は [MapLibrePolygonCompositeRenderer] が束ねる。 + */ +class MapLibrePolygonController( + override val renderer: MapLibrePolygonCompositeRenderer, +) : PolygonController(renderer.fill.polygonManager, renderer) { + // 旧 MapLibrePolygonConductor と同じ値を保つ。 + override val zIndex: Int = 2 +} diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt index 4c495d2..321f6a9 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt @@ -23,10 +23,13 @@ class MapLibrePolygonOverlayRenderer( override val holder: MapLibreMapViewHolderInterface, override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), ) : AbstractPolygonOverlayRenderer() { + // 消えた分を除いた残りで描き直す。コントローラはこの呼び出しの後で polygonManager から + // 外すので(clear() では onPostProcess も来ない)、ここで除外しておかないと消えずに残る。 override suspend fun onRemove(data: List>) { - coroutine.launch { - holder.map.style?.let { - layer.draw(getAllPolygonEntities(), it) + val polygons = getRemainingPolygonEntities(data) + holder.map.style?.let { + coroutine.launch { + layer.draw(polygons, it) } } } @@ -87,4 +90,11 @@ class MapLibrePolygonOverlayRenderer( 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 fa9e625cbfca5ade35688d920fcf0b07c94d8198 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:07:32 -0800 Subject: [PATCH 20/31] 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. --- .../maplibre/polygon/MapLibrePolygonConductor.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt index a201ffd..0ae30bc 100644 --- a/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt +++ b/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.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 @@ -88,9 +89,15 @@ class MapLibrePolygonConductor( 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 3c36249700ffc58c2b672ef247cd8ebe3c6169ed Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:52:54 -0800 Subject: [PATCH 21/31] 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. --- .../mapconductor/maplibre/marker/MapLibreMarkerController.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt index 43d25f5..2f16d05 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt @@ -222,6 +222,10 @@ class MapLibreMarkerController( markerManager.clear() tiledMarkerIds.clear() removeTileOverlay() + // onRemove はアイコンの参照数を減らすだけで、地図のソースは onPostProcess が + // manager の中身から描き直すまで変わらない。空にした後で呼ばないと、 + // clearOverlays() の後もマーカーが地図に残る。 + renderer.onPostProcess() } } From 324b60beca80868a435ef2a9009268f92c34693b Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:10:01 -0800 Subject: [PATCH 22/31] 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/maplibre/MapLibreViewController.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 5193651..808806c 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -127,6 +127,8 @@ class MapLibreViewController( groundImageController.clear() circleController.clear() rasterLayerController.clear() + // 拡張モジュール(クラスタリングなど)が登録した Strategy のマーカーも消す。 + clearStrategyMarkerControllers() } override fun moveCamera(position: MapCameraPosition) = handleMoveCamera(position) From 827e8bbc599cb02f54a033652214fe087e4b24e4 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:55:27 -0800 Subject: [PATCH 23/31] Stack overlays by band, independent of add order and style reloads Before: circles were added first (bottom), polygon z-layers were inserted below the polyline layer (so above circles), ground images went directly below the polyline layer (above polygons, and above every polygon added earlier), and a raster added after the style was set went to the very top until the next rebuild put it under the labels. MapLibreOverlayStacking records the band and zIndex of every layer the SDK adds and inserts each one below the lowest SDK layer with a higher key, reading the style's current layer list every time. Rasters additionally stay below the basemap's first label layer (as before). Marker tiles go to the bottom of the marker band, the drag layer to the top. 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-maplibre.api | 9 ++ .../maplibre/MapLibreOverlayStacking.kt | 86 ++++++++++++++ .../maplibre/MapLibreViewController.kt | 17 +-- .../maplibre/MapLibreViewControllerStyle.kt | 97 +++++----------- .../MapLibreGroundImageOverlayRenderer.kt | 28 ++--- .../MapLibreRasterLayerOverlayRenderer.kt | 107 ++++-------------- 6 files changed, 161 insertions(+), 183 deletions(-) create mode 100644 src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index c156484..e1357e6 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -101,6 +101,15 @@ public final class com.mapconductor.maplibre.MapLibreMapViewSaver extends com.ma public final class com.mapconductor.maplibre.MapLibreMapViewScope extends com.mapconductor.compose.MapViewScope { public com.mapconductor.maplibre.MapLibreMapViewScope(); } +public final class com.mapconductor.maplibre.MapLibreOverlayStacking { + public static final com.mapconductor.maplibre.MapLibreOverlayStacking INSTANCE; + public final com.mapconductor.core.controller.OverlayLayerStack stackOf(org.maplibre.android.maps.MapLibreMap); + 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(org.maplibre.android.maps.MapLibreMap, org.maplibre.android.maps.Style, org.maplibre.android.style.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); + public final void remove(org.maplibre.android.maps.MapLibreMap, org.maplibre.android.maps.Style, java.lang.String); +} public final class com.mapconductor.maplibre.MapLibrePolyUtilsKt { public static final java.util.List createMapLibreLines-rXlOB7A(java.lang.String, java.util.List, boolean, long, float, int); public static final java.lang.String toMapLibreColorString-8_81llA(long); diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt b/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt new file mode 100644 index 0000000..52eb34b --- /dev/null +++ b/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt @@ -0,0 +1,86 @@ +package com.mapconductor.maplibre + +import android.util.Log +import com.mapconductor.core.controller.OverlayKind +import com.mapconductor.core.controller.OverlayLayerStack +import com.mapconductor.core.controller.OverlayStacking +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.Layer +import org.maplibre.android.style.layers.SymbolLayer +import java.util.WeakHashMap + +/** + * オーバーレイの層をスタイルのどこへ入れるか([OverlayStacking] の帯の順)。 + * + * 下から: ラスター < GroundImage < ポリゴン < サークル < ポリライン < マーカー + * (マーカーのタイルはマーカーの帯の一番下、ドラッグ中の層は一番上)。 + * + * 位置は毎回スタイルの今の並びから決めるので、追加した順・送り直し・ + * スタイルの読み直し(デザイン変更)に左右されない。 + * + * ラスターだけはベースマップのラベル(SDK の層でない最初の symbol 層)の下に入れる。 + * それ以外の帯はラベルの上に乗る(従来どおり)。 + */ +internal object MapLibreOverlayStacking { + private val stacks = WeakHashMap() + + fun stackOf(map: MapLibreMap): 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 の層が既にあれば何もしない + * (位置を変えたいときは先に [remove] する)。 + */ + fun add( + map: MapLibreMap, + style: Style, + layer: Layer, + key: OverlayStacking.Key, + ) { + val stack = stackOf(map) + stack.put(layer.id, key) + if (style.getLayer(layer.id) != null) return + val layers = style.layers + val ids = layers.map { it.id } + var before = stack.beforeId(ids, key) + if (key.kind == OverlayKind.RasterLayer) { + val label = layers.firstOrNull { it is SymbolLayer && 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.getLayer(layer.id) != null) return + try { + style.addLayer(layer) + } catch (e2: Exception) { + Log.w("MapLibre", "Failed to add layer: ${layer.id} (${e2.message})") + } + } + } + + fun remove( + map: MapLibreMap, + style: Style?, + layerId: String, + ) { + stackOf(map).remove(layerId) + try { + style?.removeLayer(layerId) + } catch (_: Exception) { + } + } +} diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 5193651..2fdc620 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -361,19 +361,14 @@ class MapLibreViewController( loadedStyle()?.let { style -> renderer.ensureDefaultIcon(style) ensureGeoJsonSource(style, renderer.markerLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.markerLayer.layer, - layerId = renderer.markerLayer.layerId, - aboveId = polylineController.renderer.layer.layerId, + MapLibreOverlayStacking.add( + holder.map, + style, + renderer.markerLayer.layer, + MapLibreOverlayStacking.key(com.mapconductor.core.controller.OverlayKind.Marker), ) ensureGeoJsonSource(style, renderer.dragLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.dragLayer.layer, - layerId = renderer.dragLayer.layerId, - aboveId = renderer.markerLayer.layerId, - ) + MapLibreOverlayStacking.add(holder.map, style, renderer.dragLayer.layer, MapLibreOverlayStacking.MARKER_DRAG) renderer.redraw() renderer.drawDragLayer() } diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt index 4c1c3e8..025fcc9 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerStyle.kt @@ -1,5 +1,6 @@ package com.mapconductor.maplibre +import com.mapconductor.core.controller.OverlayKind import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer import org.maplibre.android.maps.Style import org.maplibre.android.style.expressions.Expression @@ -17,7 +18,9 @@ import kotlinx.coroutines.launch * * MapLibre はスタイルが読み込み直されるたびにレイヤーが全部消えるため、ここは * 何度呼ばれても同じ状態になるように書いてある(`ensureGeoJsonSource` / - * `addLayerSafely` はいずれも既存があれば作らない)。 + * [MapLibreOverlayStacking.add] はいずれも既存があれば作らない)。層の位置は + * [MapLibreOverlayStacking] が帯の順(ラスター < GroundImage < ポリゴン < サークル < + * ポリライン < マーカー)で決める。 * * ポリゴンの z 順は**レイヤーを z 値ごとに用意して**表現する。MapLibre には * フィーチャー単位の z 指定が無く、レイヤーの並び順だけが順序を決めるため。 @@ -34,6 +37,7 @@ internal fun MapLibreViewController.ensureGeoJsonSource( } } +// 層の位置は MapLibreOverlayStacking が決める。以下 2 つは公開 API の互換のために残している(SDK 内では未使用)。 internal fun MapLibreViewController.addLayerSafely( style: Style, layer: Layer, @@ -84,51 +88,28 @@ internal fun MapLibreViewController.setupStyle(style: Style) { ensureGeoJsonSource(style, polygonController.renderer.outline.layer.sourceId) ensureGeoJsonSource(style, polygonController.renderer.fill.layer.sourceId) - // Circle cts as anchor above polygons + // 層の位置は MapLibreOverlayStacking が帯の順で決める(追加の順に依らない)。 + val stacking = MapLibreOverlayStacking + val map = holder.map + val markerRenderer = markerController.renderer as MapLibreMarkerOverlayRenderer + ensureGeoJsonSource(style, circleController.renderer.layer.sourceId) - addLayerSafely( - style = style, - layer = circleController.renderer.layer.layer, - layerId = circleController.renderer.layer.layerId, - ) - // Circle stroke (LineLayer) directly above the circle fill - addLayerAboveSafely( - style = style, - layer = circleController.renderer.layer.strokeLayer, - layerId = circleController.renderer.layer.strokeLayerId, - aboveId = circleController.renderer.layer.layerId, - ) + stacking.add(map, style, circleController.renderer.layer.layer, stacking.key(OverlayKind.Circle)) + // 線は塗りの直後(同じ鍵なら後から入れたものが上)。 + stacking.add(map, style, circleController.renderer.layer.strokeLayer, stacking.key(OverlayKind.Circle)) - // Polyline (general) acts as anchor above circles ensureGeoJsonSource(style, polylineController.renderer.layer.sourceId) - addLayerSafely( - style = style, - layer = polylineController.renderer.layer.layer, - layerId = polylineController.renderer.layer.layerId, - ) + stacking.add(map, style, polylineController.renderer.layer.layer, stacking.key(OverlayKind.Polyline)) - // Add z-indexed polygon layers below general polylines ensurePolygonZLayers(style) - // Marker - add source and layer at the top - ensureGeoJsonSource(style, (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layer, - layerId = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layerId, - aboveId = polylineController.renderer.layer.layerId, - ) - (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() + ensureGeoJsonSource(style, markerRenderer.markerLayer.sourceId) + stacking.add(map, style, markerRenderer.markerLayer.layer, stacking.key(OverlayKind.Marker)) + markerRenderer.redraw() - // Drag layer above marker layer - ensureGeoJsonSource(style, (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.layer, - layerId = (markerController.renderer as MapLibreMarkerOverlayRenderer).dragLayer.layerId, - aboveId = (markerController.renderer as MapLibreMarkerOverlayRenderer).markerLayer.layerId, - ) - (markerController.renderer as MapLibreMarkerOverlayRenderer).redraw() + ensureGeoJsonSource(style, markerRenderer.dragLayer.sourceId) + stacking.add(map, style, markerRenderer.dragLayer.layer, stacking.MARKER_DRAG) + markerRenderer.redraw() markerEventControllers .map { it.renderer as MapLibreMarkerOverlayRenderer } @@ -136,19 +117,9 @@ internal fun MapLibreViewController.setupStyle(style: Style) { .forEach { renderer -> renderer.ensureDefaultIcon(style) ensureGeoJsonSource(style, renderer.markerLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.markerLayer.layer, - layerId = renderer.markerLayer.layerId, - aboveId = polylineController.renderer.layer.layerId, - ) + stacking.add(map, style, renderer.markerLayer.layer, stacking.key(OverlayKind.Marker)) ensureGeoJsonSource(style, renderer.dragLayer.sourceId) - addLayerAboveSafely( - style = style, - layer = renderer.dragLayer.layer, - layerId = renderer.dragLayer.layerId, - aboveId = renderer.markerLayer.layerId, - ) + stacking.add(map, style, renderer.dragLayer.layer, stacking.MARKER_DRAG) renderer.redraw() renderer.drawDragLayer() } @@ -174,7 +145,6 @@ internal fun MapLibreViewController.setupStyle(style: Style) { internal fun MapLibreViewController.ensurePolygonZLayers(style: Style) { val fillSourceId = polygonController.renderer.fill.layer.sourceId val outlineSourceId = polygonController.renderer.outline.layer.sourceId - val anchorId = polylineController.renderer.layer.layerId val zSet = polygonController.polygonManager @@ -187,14 +157,8 @@ internal fun MapLibreViewController.ensurePolygonZLayers(style: Style) { toRemove.forEach { z -> val fillId = "polygon-fill-layer-$z" val outlineId = "polygon-outline-layer-$z" - try { - style.removeLayer(outlineId) - } catch (_: Exception) { - } - try { - style.removeLayer(fillId) - } catch (_: Exception) { - } + MapLibreOverlayStacking.remove(holder.map, style, outlineId) + MapLibreOverlayStacking.remove(holder.map, style, fillId) } val zList = zSet.toList().sorted() @@ -220,11 +184,7 @@ internal fun MapLibreViewController.ensurePolygonZLayers(style: Style) { ), ) } - try { - style.addLayerBelow(fill, anchorId) - } catch (_: Exception) { - style.addLayer(fill) - } + MapLibreOverlayStacking.add(holder.map, style, fill, MapLibreOverlayStacking.key(OverlayKind.Polygon, z)) } if (style.getLayer(outlineId) == null) { @@ -253,11 +213,8 @@ internal fun MapLibreViewController.ensurePolygonZLayers(style: Style) { ), ) } - try { - style.addLayerAbove(outline, fillId) - } catch (_: Exception) { - style.addLayer(outline) - } + // 塗りの直後(同じ鍵なら後から入れたものが上)。 + MapLibreOverlayStacking.add(holder.map, style, outline, MapLibreOverlayStacking.key(OverlayKind.Polygon, z)) } } polygonZLayers.clear() diff --git a/src/main/java/com/mapconductor/maplibre/groundimage/MapLibreGroundImageOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/groundimage/MapLibreGroundImageOverlayRenderer.kt index 439c175..45ee6a2 100644 --- a/src/main/java/com/mapconductor/maplibre/groundimage/MapLibreGroundImageOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/groundimage/MapLibreGroundImageOverlayRenderer.kt @@ -1,5 +1,6 @@ package com.mapconductor.maplibre.groundimage +import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.features.GeoRectBounds import com.mapconductor.core.groundimage.AbstractGroundImageOverlayRenderer import com.mapconductor.core.groundimage.GroundImageEntityInterface @@ -7,6 +8,7 @@ import com.mapconductor.core.groundimage.GroundImageFingerPrint import com.mapconductor.core.groundimage.GroundImageState import com.mapconductor.maplibre.MapLibreActualGroundImage import com.mapconductor.maplibre.MapLibreMapViewHolderInterface +import com.mapconductor.maplibre.MapLibreOverlayStacking import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngQuad import org.maplibre.android.maps.Style @@ -104,25 +106,20 @@ class MapLibreGroundImageOverlayRenderer( Log.w("MapLibre", "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("MapLibre", "Failed to add ground image layer: ${e.message}") - } - } + // GroundImage の帯(ラスターの上、ポリゴンの下)。送り直しても同じ位置。 + MapLibreOverlayStacking.add( + holder.map, + style, + layer, + MapLibreOverlayStacking.key(OverlayKind.GroundImage), + ) } private fun removeSourceAndLayerIfExists( style: Style, handle: MapLibreGroundImageHandle, ) { - try { - style.removeLayer(handle.layerId) - } catch (_: Exception) { - } + MapLibreOverlayStacking.remove(holder.map, style, handle.layerId) try { style.removeSource(handle.sourceId) } catch (_: Exception) { @@ -185,7 +182,6 @@ class MapLibreGroundImageOverlayRenderer( opacity = opacity, ) - companion object { - private const val BELOW_LAYER_ID = "polyline-layer" - } + // 公開 API(Companion)の互換のために残している。 + companion object } diff --git a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt index 0e087ea..a611e6d 100644 --- a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt @@ -1,11 +1,13 @@ package com.mapconductor.maplibre.raster +import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.raster.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface import com.mapconductor.core.raster.RasterLayerSource import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.core.raster.TileScheme import com.mapconductor.maplibre.MapLibreMapViewHolderInterface +import com.mapconductor.maplibre.MapLibreOverlayStacking import org.maplibre.android.style.layers.Property import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.RasterLayer @@ -93,38 +95,26 @@ class MapLibreRasterLayerOverlayRenderer( } catch (e: Exception) { Log.w("MapLibre", "Failed to add raster source: ${e.message}") } - try { - if (isMarkerTileRaster(state)) { - addLayerForMarkerTile(style, layer) - } else { - style.addLayer(layer) - } - } catch (e: Exception) { - Log.w("MapLibre", "Failed to add raster layer: ${e.message}") - } + addToStack(style, layer, state) return handle } - private fun addLayerForMarkerTile( + /** + * 帯の順([MapLibreOverlayStacking])で足す。アプリのラスターはラスターの帯 + * (一番下、ベースマップのラベルの下)、マーカーのタイルはマーカーの帯の一番下。 + */ + private fun addToStack( style: org.maplibre.android.maps.Style, layer: RasterLayer, + 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)) { + MapLibreOverlayStacking.MARKER_TILE + } else { + MapLibreOverlayStacking.key(OverlayKind.RasterLayer, state.zIndex) + } + MapLibreOverlayStacking.add(holder.map, style, layer, key) } private fun updateLayer( @@ -145,10 +135,7 @@ class MapLibreRasterLayerOverlayRenderer( private fun removeLayer(entity: RasterLayerEntityInterface) { val style = holder.map.style ?: return val handle = entity.layer - try { - style.removeLayer(handle.layerId) - } catch (_: Exception) { - } + MapLibreOverlayStacking.remove(holder.map, style, handle.layerId) try { style.removeSource(handle.sourceId) } catch (_: Exception) { @@ -164,11 +151,10 @@ class MapLibreRasterLayerOverlayRenderer( .mapNotNull { state -> handleById[state.id]?.let { handle -> state to handle } } .toList() + // zIndex が変わったときのために、いったん外して zIndex の順に入れ直す + // (同じ zIndex なら渡された順)。位置は帯の順で決まる。 ordered.forEach { (_, handle) -> - try { - style.removeLayer(handle.layerId) - } catch (_: Exception) { - } + MapLibreOverlayStacking.remove(holder.map, style, handle.layerId) } ordered.forEach { (state, handle) -> @@ -180,59 +166,10 @@ class MapLibreRasterLayerOverlayRenderer( if (state.visible) Property.VISIBLE else Property.NONE, ), ) - 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. So callers can hand them over in ascending - * z-order, as [rebuildNonMarkerRasterLayers] does. - * - * 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: org.maplibre.android.maps.Style, - layer: RasterLayer, - ) { - val anchor = - style.layers.firstOrNull { - it is org.maplibre.android.style.layers.SymbolLayer && !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("org.maplibre.annotations") || - id.startsWith(MARKERS_LAYER_ID) || - id.startsWith("marker-drag-layer") || - id.startsWith(POLYLINE_LAYER_ID) || - id.startsWith("circle-layer") - private fun buildSource( sourceId: String, source: RasterLayerSource, @@ -260,8 +197,6 @@ class MapLibreRasterLayerOverlayRenderer( 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 f78f720a958336a13902f2bb77b4b19e93c6b5ce Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 06:55:03 -0800 Subject: [PATCH 24/31] 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-maplibre.api | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index e1357e6..198bf7c 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -106,6 +106,7 @@ public final class com.mapconductor.maplibre.MapLibreOverlayStacking { public final com.mapconductor.core.controller.OverlayLayerStack stackOf(org.maplibre.android.maps.MapLibreMap); 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(org.maplibre.android.maps.MapLibreMap, org.maplibre.android.maps.Style, org.maplibre.android.style.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); public final void remove(org.maplibre.android.maps.MapLibreMap, org.maplibre.android.maps.Style, java.lang.String); @@ -305,6 +306,9 @@ public final class com.mapconductor.maplibre.marker.MapLibreMarkerController ext public void destroy(); public java.lang.Object update(java.lang.Object, kotlin.coroutines.Continuation); } +final class com.mapconductor.maplibre.marker.MapLibreMarkerController$Companion { + public final long getTILE_REFRESH_COALESCE-UwyO8pc(); +} public final class com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer extends com.mapconductor.core.marker.AbstractMarkerOverlayRenderer { public com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerManager, com.mapconductor.maplibre.marker.MarkerLayer, com.mapconductor.maplibre.marker.MarkerDragLayer, kotlinx.coroutines.CoroutineScope); public com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.marker.MarkerManager, com.mapconductor.maplibre.marker.MarkerLayer, com.mapconductor.maplibre.marker.MarkerDragLayer, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); @@ -382,6 +386,7 @@ public final class com.mapconductor.maplibre.polygon.MapLibrePolygonController e public com.mapconductor.maplibre.polygon.MapLibrePolygonController(com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer); public com.mapconductor.maplibre.polygon.MapLibrePolygonCompositeRenderer getRenderer(); public int getZIndex(); + public void destroy(); public com.mapconductor.core.polygon.PolygonOverlayRendererInterface getRenderer(); } public final class com.mapconductor.maplibre.polygon.MapLibrePolygonLayer { From 23b58380defb95554e9ece8be45c437d6d14e0e5 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 09:38:06 -0800 Subject: [PATCH 25/31] 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-maplibre.api | 1 + .../maplibre/MapLibreOverlayStacking.kt | 3 +++ .../maplibre/MapLibreViewController.kt | 15 +++++++++++++++ .../polyline/MapLibrePolylineOverlayRenderer.kt | 4 ++-- .../raster/MapLibreRasterLayerOverlayRenderer.kt | 7 ++++++- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index 198bf7c..092ca81 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -107,6 +107,7 @@ public final class com.mapconductor.maplibre.MapLibreOverlayStacking { 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(org.maplibre.android.maps.MapLibreMap, org.maplibre.android.maps.Style, org.maplibre.android.style.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); public final void remove(org.maplibre.android.maps.MapLibreMap, org.maplibre.android.maps.Style, java.lang.String); diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt b/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt index 49c13f8..3d763eb 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt @@ -39,6 +39,9 @@ internal object MapLibreOverlayStacking { /** ポリゴンのタイル(ラスター): ポリゴンの帯の一番下。 */ 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/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 2b865fa..4dae837 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -22,6 +22,9 @@ import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.polygon.PolygonTiler import com.mapconductor.core.polygon.polygonTileRasterLayerState import com.mapconductor.core.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.POLYLINE_TILE_LAYER_ID +import com.mapconductor.core.polyline.PolylineTiler +import com.mapconductor.core.polyline.polylineTileRasterLayerState import com.mapconductor.maplibre.circle.MapLibreCircleController import com.mapconductor.maplibre.groundimage.MapLibreGroundImageController import com.mapconductor.maplibre.marker.MapLibreMarkerController @@ -120,6 +123,18 @@ class MapLibreViewController( } } } + + // 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) + } + } + } } fun setupListeners() { diff --git a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt index 0c576a3..c356138 100644 --- a/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt @@ -67,11 +67,11 @@ class MapLibrePolylineOverlayRenderer( 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 MapLibreActualPolyline?) != null } } fun redraw() { - val entities = polylineManager.allEntities() + val entities = polylineManager.allEntities().filter { (it.polyline as MapLibreActualPolyline?) != null } holder.map.style?.let { coroutine.launch { layer.draw(entities, it) diff --git a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt index 1da08c4..697d665 100644 --- a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt @@ -2,6 +2,7 @@ package com.mapconductor.maplibre.raster 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.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface import com.mapconductor.core.raster.RasterLayerSource @@ -29,6 +30,8 @@ class MapLibreRasterLayerOverlayRenderer( private fun isPolygonTileRaster(state: RasterLayerState): Boolean = state.id == POLYGON_TILE_LAYER_ID + private fun isPolylineTileRaster(state: RasterLayerState): Boolean = state.id == POLYLINE_TILE_LAYER_ID + override suspend fun onAdd( data: List, ): List = @@ -117,6 +120,8 @@ class MapLibreRasterLayerOverlayRenderer( MapLibreOverlayStacking.MARKER_TILE } else if (isPolygonTileRaster(state)) { MapLibreOverlayStacking.POLYGON_TILE + } else if (isPolylineTileRaster(state)) { + MapLibreOverlayStacking.POLYLINE_TILE } else { MapLibreOverlayStacking.key(OverlayKind.RasterLayer, state.zIndex) } @@ -152,7 +157,7 @@ class MapLibreRasterLayerOverlayRenderer( 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 301d9c947c59b4df2f114bad05e0d65b37697c6e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Tue, 10 Nov 2026 16:16:50 -0800 Subject: [PATCH 26/31] Support bulk Circles with raster tiles --- .../MapLibreCircleTilingDeviceTest.kt | 71 +++++++++++++++++++ .../maplibre/MapLibreOverlayStacking.kt | 3 + .../maplibre/MapLibreViewController.kt | 15 ++++ .../circle/MapLibreCircleOverlayRenderer.kt | 2 +- .../MapLibreRasterLayerOverlayRenderer.kt | 7 +- 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/maplibre/MapLibreCircleTilingDeviceTest.kt diff --git a/src/androidTest/java/com/mapconductor/maplibre/MapLibreCircleTilingDeviceTest.kt b/src/androidTest/java/com/mapconductor/maplibre/MapLibreCircleTilingDeviceTest.kt new file mode 100644 index 0000000..83cae1d --- /dev/null +++ b/src/androidTest/java/com/mapconductor/maplibre/MapLibreCircleTilingDeviceTest.kt @@ -0,0 +1,71 @@ +package com.mapconductor.maplibre + +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.mapconductor.compose.circle.Circles +import com.mapconductor.core.circle.CIRCLE_TILE_LAYER_ID +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.features.GeoPoint +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Exercises bulk registration through the real map host, not just the core tiler. */ +@RunWith(AndroidJUnit4::class) +class MapLibreCircleTilingDeviceTest { + @get:Rule val compose = createComposeRule() + + @Test + fun circlesSwitchBetweenTilesAndNativeAndSurviveStyleReload() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + fun design(name: String): MapLibreDesign { + val file = File(context.cacheDir, "circle-device-$name.json") + file.writeText("""{"version":8,"name":"$name","sources":{},"layers":[{"id":"background","type":"background"}]}""") + return MapLibreDesign(id = name, styleJsonURL = "file://${file.absolutePath}") + } + val first = design("first") + val second = design("second") + val state = MapLibreViewState(mapDesignType = first, id = "circle-device") + val many = (0..2000).map { i -> + CircleState(center = GeoPoint(35.6 + i * 0.00001, 139.7), radiusMeters = 20.0, id = "circle-$i") + } + var circles by mutableStateOf(many) + compose.setContent { + MapLibreMapView(state = state, modifier = Modifier.fillMaxSize()) { Circles(circles) } + } + fun tiled(design: String): Boolean { + var result = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val style = state.getMapViewHolder()?.map?.style + result = style?.isFullyLoaded == true && style.uri == (if (design == "first") first.styleJsonURL else second.styleJsonURL) && + style.layers.any { it.id.contains(CIRCLE_TILE_LAYER_ID) } + } + return result + } + compose.waitUntil(30_000) { tiled("first") } + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(30_000) { tiled("second") } + compose.runOnUiThread { circles = many.take(1) } + compose.waitUntil(30_000) { + var native = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val style = state.getMapViewHolder()?.map?.style + native = style?.isFullyLoaded == true && style.layers.none { it.id.contains(CIRCLE_TILE_LAYER_ID) } + } + native + } + compose.runOnUiThread { circles = many } + compose.waitUntil(30_000) { tiled("second") } + assertTrue(tiled("second")) + compose.runOnUiThread { circles = emptyList() } + compose.waitUntil(30_000) { !tiled("second") } + } +} diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt b/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt index 3d763eb..8181e44 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreOverlayStacking.kt @@ -39,6 +39,9 @@ internal object MapLibreOverlayStacking { /** ポリゴンのタイル(ラスター): ポリゴンの帯の一番下。 */ 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/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 4dae837..9e9af8e 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -1,5 +1,8 @@ package com.mapconductor.maplibre +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.circle.OnCircleEventHandler import com.mapconductor.core.controller.BaseMapViewController import com.mapconductor.core.features.GeoPointInterface @@ -124,6 +127,18 @@ class MapLibreViewController( } } + // 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/maplibre/circle/MapLibreCircleOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt index 6315989..b36ed33 100644 --- a/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt @@ -42,7 +42,7 @@ class MapLibreCircleOverlayRenderer( /** Reapply the current composition after a MapLibre style is created or replaced. */ fun redraw() { - val circles = circleManager.allEntities() + val circles = circleManager.nativeEntities().filter { (it.circle as MapLibreActualCircle?) != null } holder.map.style?.let { style -> coroutine.launch { layer.draw(circles, style) } } diff --git a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt index 697d665..6f16208 100644 --- a/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/raster/MapLibreRasterLayerOverlayRenderer.kt @@ -1,5 +1,6 @@ package com.mapconductor.maplibre.raster +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 @@ -32,6 +33,8 @@ class MapLibreRasterLayerOverlayRenderer( private fun isPolylineTileRaster(state: RasterLayerState): Boolean = state.id == POLYLINE_TILE_LAYER_ID + private fun isCircleTileRaster(state: RasterLayerState): Boolean = state.id == CIRCLE_TILE_LAYER_ID + override suspend fun onAdd( data: List, ): List = @@ -120,6 +123,8 @@ class MapLibreRasterLayerOverlayRenderer( MapLibreOverlayStacking.MARKER_TILE } else if (isPolygonTileRaster(state)) { MapLibreOverlayStacking.POLYGON_TILE + } else if (isCircleTileRaster(state)) { + MapLibreOverlayStacking.CIRCLE_TILE } else if (isPolylineTileRaster(state)) { MapLibreOverlayStacking.POLYLINE_TILE } else { @@ -157,7 +162,7 @@ class MapLibreRasterLayerOverlayRenderer( 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 fdde6aaa05cd21e3b8e73325e4851776c1a58dac Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 11 Nov 2026 11:41:05 -0800 Subject: [PATCH 27/31] 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. --- .../marker/MapLibreMarkerOverlayRenderer.kt | 40 ++----------------- 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt index 275802b..a6ede26 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt @@ -3,12 +3,12 @@ package com.mapconductor.maplibre.marker import com.google.gson.JsonArray import com.google.gson.JsonObject 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 @@ -149,17 +149,7 @@ class MapLibreMarkerOverlayRenderer( ) { val entities = markerManager.allEntities() val props = (markerEntity.marker?.properties() ?: JsonObject()).deepCopy() - val previousZIndex = - props - .get(Prop.Z_INDEX) - ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } - ?.asInt - props.addProperty( - Prop.Z_INDEX, - markerEntity.state.zIndex - ?: previousZIndex - ?: calculateZIndex(position), - ) + props.addProperty(Prop.Z_INDEX, MarkerDrawOrder.of(markerEntity.state.zIndex, position)) val feature = Feature.fromGeometry( position.toPoint(), @@ -219,7 +209,7 @@ class MapLibreMarkerOverlayRenderer( } // We don't use the MapLibre SDK's scaling system // addProperty(Prop.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) } @@ -366,11 +356,7 @@ class MapLibreMarkerOverlayRenderer( } addProperty( Prop.Z_INDEX, - resolveZIndexForChange( - current = params.current, - prev = params.prev, - prevProperties = prevProperties, - ), + MarkerDrawOrder.of(params.current.state.zIndex, params.current.state.position), ) } @@ -381,24 +367,6 @@ class MapLibreMarkerOverlayRenderer( } } - private fun resolveZIndexForChange( - current: MarkerEntityInterface, - prev: MarkerEntityInterface, - prevProperties: JsonObject?, - ): Int { - current.state.zIndex?.let { return it } - - if (current.fingerPrint.zIndex == prev.fingerPrint.zIndex) { - prevProperties - ?.get(Prop.Z_INDEX) - ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } - ?.asInt - ?.let { return it } - } - - return calculateZIndex(current.state.position) - } - /** * ドラッグ対象の入れ替え。 * From b528b724a357cfa88fb1254885be491aabeaa6d6 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 11 Nov 2026 15:00:56 -0800 Subject: [PATCH 28/31] 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-maplibre.api | 1 + .../marker/MapLibreMarkerOverlayRenderer.kt | 822 +++++++++--------- 2 files changed, 418 insertions(+), 405 deletions(-) diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index 092ca81..6f94dc5 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -325,6 +325,7 @@ public final class com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRendere public java.lang.Object onRemove(java.util.List>, kotlin.coroutines.Continuation); public final void drawDragLayer(); public final void redraw(); + 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/maplibre/marker/MapLibreMarkerOverlayRenderer.kt b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt index a6ede26..9367222 100644 --- a/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt @@ -1,405 +1,417 @@ -package com.mapconductor.maplibre.marker - -import com.google.gson.JsonArray -import com.google.gson.JsonObject -import com.mapconductor.core.ResourceProvider -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 -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.maplibre.MapLibreActualMarker -import com.mapconductor.maplibre.MapLibreMapViewHolderInterface -import com.mapconductor.maplibre.toPoint -import org.maplibre.geojson.Feature -import org.maplibre.geojson.FeatureCollection -import android.graphics.Bitmap -import android.os.SystemClock -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield - -class MapLibreMarkerOverlayRenderer( - holder: MapLibreMapViewHolderInterface, - val markerManager: MarkerManager, - val markerLayer: MarkerLayer, - val dragLayer: MarkerDragLayer, - coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), -) : AbstractMarkerOverlayRenderer( - holder = holder, - coroutine = coroutine, - ) { - override val supportsAnimationOverlay: Boolean = true - - private val iconRefCounter: MutableMap = mutableMapOf() - private val pendingStyleImageRemovals: MutableMap = mutableMapOf() - private val iconBitmapCache: MutableMap = mutableMapOf() - private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() - - object Prop { - const val ICON_ID = "icon_id" - const val DEFAULT_MARKER_ID = "default" - const val SCALE = "scale" - const val ICON_ANCHOR = "icon-offset" - const val Z_INDEX = "zIndex" - } - - object IconAnchor { - const val CENTER = "center" - const val LEFT = "left" - const val RIGHT = "right" - const val BOTTOM = "bottom" - const val TOP_LEFT = "top-left" - const val TOP_RIGHT = "top-right" - const val BOTTOM_LEFT = "bottom-left" - const val BOTTOM_RIGHT = "bottom-right" - } - - object IconTranslateAnchor { - const val MAP = "map" - const val VIEWPORT = "viewport" - } - - init { - val style = holder.map.style - if (style != null) { - style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) - } else { - holder.map.getStyle { style -> - style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) - } - } - } - - // Ensure marker images exist on the given style (used after style reload). - fun ensureDefaultIcon(style: org.maplibre.android.maps.Style) { - ensureStyleImages(style) - } - - fun ensureStyleImages(style: org.maplibre.android.maps.Style) { - val startedAt = SystemClock.elapsedRealtime() - try { - style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) - } catch (_: Exception) { - } - - val entities = markerManager.allEntities() - val styleIcons = linkedMapOf() - var nativeMarkerCount = 0 - entities.forEach { entity -> - // Tiled markers have no MapLibre Feature and are drawn by MarkerTileRenderer. - // Registering their images with the style is both unnecessary and extremely - // expensive for large data sets (the same bitmap may otherwise be added tens of - // thousands of times during a style reload). - if (entity.marker == null) return@forEach - nativeMarkerCount++ - val icon = entity.state.icon ?: return@forEach - val iconKey = icon.hashCode().toString() - if (!styleIcons.containsKey(iconKey)) { - styleIcons[iconKey] = iconBitmapCache[iconKey] ?: icon.toBitmapIcon().bitmap - } - } - - styleIcons.forEach { (iconKey, bitmap) -> - try { - style.addImage(iconKey, bitmap, false) - } catch (_: Exception) { - } - iconBitmapCache[iconKey] = bitmap - } - } - - private fun decrementIconRef(iconKey: String) { - if (iconKey == Prop.DEFAULT_MARKER_ID) return - val next = (iconRefCounter[iconKey] ?: 0) - 1 - if (next <= 0) { - iconRefCounter.remove(iconKey) - // MapLibre can render one frame behind GeoJSON source updates during marker animation. - // Keep images briefly after last use so icon-image references never point at a missing image. - pendingStyleImageRemovals[iconKey] = System.currentTimeMillis() + STYLE_IMAGE_REMOVAL_GRACE_MS - } else { - iconRefCounter[iconKey] = next - } - } - - private fun incrementIconRef(iconKey: String) { - if (iconKey == Prop.DEFAULT_MARKER_ID) return - pendingStyleImageRemovals.remove(iconKey) - iconRefCounter[iconKey] = (iconRefCounter[iconKey] ?: 0) + 1 - } - - override fun setMarkerVisible( - markerEntity: MarkerEntityInterface, - visible: Boolean, - ) { - markerEntity.visible = visible - redraw() - } - - override fun setMarkerPosition( - markerEntity: MarkerEntityInterface, - position: GeoPoint, - ) { - val entities = markerManager.allEntities() - val props = (markerEntity.marker?.properties() ?: JsonObject()).deepCopy() - props.addProperty(Prop.Z_INDEX, MarkerDrawOrder.of(markerEntity.state.zIndex, position)) - val feature = - Feature.fromGeometry( - position.toPoint(), - props, - "marker-${markerEntity.state.id}", - ) - markerEntity.marker = feature - val features = - entities.map { - if (it.state.id == markerEntity.state.id) { - feature - } else { - it.marker - } - } - // Execute directly instead of launching a new coroutine - holder.map.style?.let { style -> - val styleSource = style.getSourceAs(markerLayer.sourceId) - styleSource?.setGeoJson(FeatureCollection.fromFeatures(features)) - } - } - - override suspend fun onAdd( - data: List, - ): List = - withContext(Dispatchers.Main) { - val style = holder.map.style ?: return@withContext emptyList() - - data.forEach { - it.state.icon?.let { icon -> - val iconKey = icon.hashCode().toString() - try { - style.addImage(iconKey, it.bitmapIcon.bitmap, false) - } catch (_: Exception) { - } - iconBitmapCache[iconKey] = it.bitmapIcon.bitmap - if (!iconRefCounter.contains(iconKey)) iconRefCounter[iconKey] = 0 - } - } - - data.map { - val featureId = "marker-${it.state.id}" - val position = GeoPoint.from(it.state.position).toPoint() - val properties = - JsonObject().apply { - if (it.state.icon != null) { - it.state.icon?.let { icon -> - val iconKey = icon.hashCode().toString() - incrementIconRef(iconKey) - addProperty(Prop.ICON_ID, iconKey) - // icon offset property - add(Prop.ICON_ANCHOR, createIconOffset(icon)) - } - } else { - addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) - add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) - } - // We don't use the MapLibre SDK's scaling system - // addProperty(Prop.SCALE, 1.0) - addProperty(Prop.Z_INDEX, MarkerDrawOrder.of(it.state.zIndex, it.state.position)) - } - Feature.fromGeometry(position, properties, featureId) - } - } - - private fun getDefaultIconOffsetProperty(): JsonArray = createIconOffset(defaultMarkerIcon) - - private fun createIconOffset(icon: BitmapIcon): JsonArray = - JsonArray().apply { - add(-(icon.size.width * icon.anchor.x) / ResourceProvider.getDensity()) - add(-(icon.size.height * icon.anchor.y) / ResourceProvider.getDensity()) - } - - private fun createIconOffset(icon: MarkerIconInterface): JsonArray = createIconOffset(icon.toBitmapIcon()) - - override suspend fun onRemove(data: List>) { - data.forEach { entity -> - val iconKey = - entity.marker - ?.properties() - ?.get(Prop.ICON_ID) - ?.asString - ?: entity.state.icon - ?.hashCode() - ?.toString() - if (iconKey != null) { - decrementIconRef(iconKey) - } - } - } - - fun drawDragLayer() { - holder.map.style?.let { - coroutine.launch { - dragLayer.draw(it) - } - } - } - - fun redraw() { - val entities = markerManager.allEntities() - // Get style from controller to use the same instance - holder.map.style?.let { - coroutine.launch { - markerLayer.draw(entities, it) - } - } - } - - override suspend fun onPostProcess() { - val target = markerManager.allEntities().filter { !it.tiling } - - // withContext(Dispatchers.Main) below is a real cross-thread hop when onPostProcess() - // is invoked from a background ingest dispatcher (as the React Native wrapper does for - // large marker batches) rather than already running on Main (as the native Compose - // sample does). That hop, immediately following a large tiled-marker ingest, lines up - // with severe GC stalls on-device even though the Main-side work below is a few ms at - // most - so skip the hop entirely when there's nothing for it to do. - if (markerLayer.wouldSkipDraw(target) && pendingStyleImageRemovals.isEmpty()) { - return - } - - return withContext(Dispatchers.Main) { - val style = holder.map.style ?: return@withContext - markerLayer.draw(target, style) - yield() - - val now = System.currentTimeMillis() - val expired = - pendingStyleImageRemovals - .asSequence() - .filter { (_, deadline) -> deadline <= now } - .map { (key, _) -> key } - .toList() - - expired.forEach { iconKey -> - if (iconRefCounter.containsKey(iconKey)) { - pendingStyleImageRemovals.remove(iconKey) - return@forEach - } - try { - style.removeImage(iconKey) - } catch (_: Exception) { - } finally { - pendingStyleImageRemovals.remove(iconKey) - iconBitmapCache.remove(iconKey) - } - } - } - } - - override suspend fun onChange( - data: List>, - ): List = - withContext(Dispatchers.Main) { - val style = holder.map.style ?: return@withContext emptyList() - - data.map { params -> - val prevFinger = params.prev.fingerPrint - val currFinger = params.current.fingerPrint - val prevProperties = params.prev.marker?.properties() - - val properties = - JsonObject().apply { - // No additional scaling needed - bitmap is created with device density - // and Bitmap.density is set to prevent MapLibre's automatic scaling - // addProperty(Prop.SCALE, 1.0) - if (currFinger.icon == prevFinger.icon) { - addProperty( - Prop.ICON_ID, - prevProperties?.get(Prop.ICON_ID)?.asString ?: Prop.DEFAULT_MARKER_ID, - ) - - add( - Prop.ICON_ANCHOR, - prevProperties?.get(Prop.ICON_ANCHOR) ?: getDefaultIconOffsetProperty(), - ) - } else { - val prevIconKey = - prevProperties - ?.get(Prop.ICON_ID) - ?.asString - ?: params.prev.state.icon - ?.hashCode() - ?.toString() - ?: Prop.DEFAULT_MARKER_ID - decrementIconRef(prevIconKey) - - if (currFinger.icon == null) { - addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) - add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) - } else { - params.current.state.icon?.let { icon -> - // icon id - val iconKey = icon.hashCode().toString() - style.addImage(iconKey, params.bitmapIcon.bitmap, false) - iconBitmapCache[iconKey] = params.bitmapIcon.bitmap - if (!iconRefCounter.contains(iconKey)) iconRefCounter[iconKey] = 0 - incrementIconRef(iconKey) - addProperty(Prop.ICON_ID, iconKey) - add(Prop.ICON_ANCHOR, createIconOffset(icon)) - } - } - } - addProperty( - Prop.Z_INDEX, - MarkerDrawOrder.of(params.current.state.zIndex, params.current.state.position), - ) - } - - val position = - GeoPoint.from(params.current.state.position).toPoint() - val featureId = "marker-${params.current.state.id}" - Feature.fromGeometry(position, properties, featureId) - } - } - - /** - * ドラッグ対象の入れ替え。 - * - * ドラッグ中のマーカーは通常のレイヤ([markerLayer])から外して専用の - * [dragLayer] へ移す。そうしないと GeoJSON ソースを丸ごと描き直すたびに - * 指の位置と食い違う。以前は各プロバイダのマーカーコントローラと - * ストラテジ用イベントコントローラに同じものが 2 本ずつあった。 - */ - override fun onDragSelectionChanged( - previous: MarkerEntityInterface?, - current: MarkerEntityInterface?, - ) { - if (current == null) { - previous?.let { - dragLayer.updatePosition(GeoPoint.from(it.state.position)) - dragLayer.selected = null - drawDragLayer() - markerManager.registerEntity(it) - redraw() - } - return - } - markerManager.removeEntity(current.state.id) - dragLayer.selected = current - dragLayer.updatePosition(GeoPoint.from(current.state.position)) - redraw() - drawDragLayer() - } - - override fun onDragPositionChanged(position: GeoPointInterface) { - dragLayer.updatePosition(GeoPoint.from(position)) - drawDragLayer() - } -} - -private const val STYLE_IMAGE_REMOVAL_GRACE_MS: Long = 1500L +package com.mapconductor.maplibre.marker + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.mapconductor.core.ResourceProvider +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 +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.maplibre.MapLibreActualMarker +import com.mapconductor.maplibre.MapLibreMapViewHolderInterface +import com.mapconductor.maplibre.toPoint +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import android.graphics.Bitmap +import android.os.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield + +class MapLibreMarkerOverlayRenderer( + holder: MapLibreMapViewHolderInterface, + val markerManager: MarkerManager, + val markerLayer: MarkerLayer, + val dragLayer: MarkerDragLayer, + coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractMarkerOverlayRenderer( + holder = holder, + coroutine = coroutine, + ) { + override val supportsAnimationOverlay: Boolean = true + + private val iconRefCounter: MutableMap = mutableMapOf() + private val pendingStyleImageRemovals: MutableMap = mutableMapOf() + private val iconBitmapCache: MutableMap = mutableMapOf() + private val defaultMarkerIcon: BitmapIcon = DefaultMarkerIcon().toBitmapIcon() + + object Prop { + const val ICON_ID = "icon_id" + const val DEFAULT_MARKER_ID = "default" + const val SCALE = "scale" + const val ICON_ANCHOR = "icon-offset" + const val Z_INDEX = "zIndex" + } + + object IconAnchor { + const val CENTER = "center" + const val LEFT = "left" + const val RIGHT = "right" + const val BOTTOM = "bottom" + const val TOP_LEFT = "top-left" + const val TOP_RIGHT = "top-right" + const val BOTTOM_LEFT = "bottom-left" + const val BOTTOM_RIGHT = "bottom-right" + } + + object IconTranslateAnchor { + const val MAP = "map" + const val VIEWPORT = "viewport" + } + + init { + val style = holder.map.style + if (style != null) { + style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) + } else { + holder.map.getStyle { style -> + style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) + } + } + } + + // Ensure marker images exist on the given style (used after style reload). + fun ensureDefaultIcon(style: org.maplibre.android.maps.Style) { + ensureStyleImages(style) + } + + fun ensureStyleImages(style: org.maplibre.android.maps.Style) { + val startedAt = SystemClock.elapsedRealtime() + try { + style.addImage(Prop.DEFAULT_MARKER_ID, defaultMarkerIcon.bitmap) + } catch (_: Exception) { + } + + val entities = markerManager.allEntities() + val styleIcons = linkedMapOf() + var nativeMarkerCount = 0 + entities.forEach { entity -> + // Tiled markers have no MapLibre Feature and are drawn by MarkerTileRenderer. + // Registering their images with the style is both unnecessary and extremely + // expensive for large data sets (the same bitmap may otherwise be added tens of + // thousands of times during a style reload). + if (entity.marker == null) return@forEach + nativeMarkerCount++ + val icon = entity.state.icon ?: return@forEach + val iconKey = icon.hashCode().toString() + if (!styleIcons.containsKey(iconKey)) { + styleIcons[iconKey] = iconBitmapCache[iconKey] ?: icon.toBitmapIcon().bitmap + } + } + + styleIcons.forEach { (iconKey, bitmap) -> + try { + style.addImage(iconKey, bitmap, false) + } catch (_: Exception) { + } + iconBitmapCache[iconKey] = bitmap + } + } + + private fun decrementIconRef(iconKey: String) { + if (iconKey == Prop.DEFAULT_MARKER_ID) return + val next = (iconRefCounter[iconKey] ?: 0) - 1 + if (next <= 0) { + iconRefCounter.remove(iconKey) + // MapLibre can render one frame behind GeoJSON source updates during marker animation. + // Keep images briefly after last use so icon-image references never point at a missing image. + pendingStyleImageRemovals[iconKey] = System.currentTimeMillis() + STYLE_IMAGE_REMOVAL_GRACE_MS + } else { + iconRefCounter[iconKey] = next + } + } + + private fun incrementIconRef(iconKey: String) { + if (iconKey == Prop.DEFAULT_MARKER_ID) return + pendingStyleImageRemovals.remove(iconKey) + iconRefCounter[iconKey] = (iconRefCounter[iconKey] ?: 0) + 1 + } + + override fun setMarkerVisible( + markerEntity: MarkerEntityInterface, + visible: Boolean, + ) { + markerEntity.visible = visible + redraw() + } + + override fun setMarkerPosition( + markerEntity: MarkerEntityInterface, + position: GeoPoint, + ) { + val entities = markerManager.allEntities() + val props = (markerEntity.marker?.properties() ?: JsonObject()).deepCopy() + props.addProperty(Prop.Z_INDEX, MarkerDrawOrder.of(markerEntity.state.zIndex, position, drawOrderFrame)) + val feature = + Feature.fromGeometry( + position.toPoint(), + props, + "marker-${markerEntity.state.id}", + ) + markerEntity.marker = feature + val features = + entities.map { + if (it.state.id == markerEntity.state.id) { + feature + } else { + it.marker + } + } + // Execute directly instead of launching a new coroutine + holder.map.style?.let { style -> + val styleSource = style.getSourceAs(markerLayer.sourceId) + styleSource?.setGeoJson(FeatureCollection.fromFeatures(features)) + } + } + + override suspend fun onAdd( + data: List, + ): List = + withContext(Dispatchers.Main) { + val style = holder.map.style ?: return@withContext emptyList() + + data.forEach { + it.state.icon?.let { icon -> + val iconKey = icon.hashCode().toString() + try { + style.addImage(iconKey, it.bitmapIcon.bitmap, false) + } catch (_: Exception) { + } + iconBitmapCache[iconKey] = it.bitmapIcon.bitmap + if (!iconRefCounter.contains(iconKey)) iconRefCounter[iconKey] = 0 + } + } + + data.map { + val featureId = "marker-${it.state.id}" + val position = GeoPoint.from(it.state.position).toPoint() + val properties = + JsonObject().apply { + if (it.state.icon != null) { + it.state.icon?.let { icon -> + val iconKey = icon.hashCode().toString() + incrementIconRef(iconKey) + addProperty(Prop.ICON_ID, iconKey) + // icon offset property + add(Prop.ICON_ANCHOR, createIconOffset(icon)) + } + } else { + addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) + add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) + } + // We don't use the MapLibre SDK's scaling system + // addProperty(Prop.SCALE, 1.0) + addProperty(Prop.Z_INDEX, drawOrderOf(it.state)) + } + Feature.fromGeometry(position, properties, featureId) + } + } + + private fun getDefaultIconOffsetProperty(): JsonArray = createIconOffset(defaultMarkerIcon) + + private fun createIconOffset(icon: BitmapIcon): JsonArray = + JsonArray().apply { + add(-(icon.size.width * icon.anchor.x) / ResourceProvider.getDensity()) + add(-(icon.size.height * icon.anchor.y) / ResourceProvider.getDensity()) + } + + private fun createIconOffset(icon: MarkerIconInterface): JsonArray = createIconOffset(icon.toBitmapIcon()) + + override suspend fun onRemove(data: List>) { + data.forEach { entity -> + val iconKey = + entity.marker + ?.properties() + ?.get(Prop.ICON_ID) + ?.asString + ?: entity.state.icon + ?.hashCode() + ?.toString() + if (iconKey != null) { + decrementIconRef(iconKey) + } + } + } + + fun drawDragLayer() { + holder.map.style?.let { + coroutine.launch { + dragLayer.draw(it) + } + } + } + + fun redraw() { + val entities = markerManager.allEntities() + // Get style from controller to use the same instance + holder.map.style?.let { + coroutine.launch { + markerLayer.draw(entities, it) + } + } + } + + // 地図が回ったら、各フィーチャーの並び順キー(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)) + } + val style = holder.map.style ?: return@withContext + markerLayer.draw(target, style) + } + } + + override suspend fun onPostProcess() { + val target = markerManager.allEntities().filter { !it.tiling } + + // withContext(Dispatchers.Main) below is a real cross-thread hop when onPostProcess() + // is invoked from a background ingest dispatcher (as the React Native wrapper does for + // large marker batches) rather than already running on Main (as the native Compose + // sample does). That hop, immediately following a large tiled-marker ingest, lines up + // with severe GC stalls on-device even though the Main-side work below is a few ms at + // most - so skip the hop entirely when there's nothing for it to do. + if (markerLayer.wouldSkipDraw(target) && pendingStyleImageRemovals.isEmpty()) { + return + } + + return withContext(Dispatchers.Main) { + val style = holder.map.style ?: return@withContext + markerLayer.draw(target, style) + yield() + + val now = System.currentTimeMillis() + val expired = + pendingStyleImageRemovals + .asSequence() + .filter { (_, deadline) -> deadline <= now } + .map { (key, _) -> key } + .toList() + + expired.forEach { iconKey -> + if (iconRefCounter.containsKey(iconKey)) { + pendingStyleImageRemovals.remove(iconKey) + return@forEach + } + try { + style.removeImage(iconKey) + } catch (_: Exception) { + } finally { + pendingStyleImageRemovals.remove(iconKey) + iconBitmapCache.remove(iconKey) + } + } + } + } + + override suspend fun onChange( + data: List>, + ): List = + withContext(Dispatchers.Main) { + val style = holder.map.style ?: return@withContext emptyList() + + data.map { params -> + val prevFinger = params.prev.fingerPrint + val currFinger = params.current.fingerPrint + val prevProperties = params.prev.marker?.properties() + + val properties = + JsonObject().apply { + // No additional scaling needed - bitmap is created with device density + // and Bitmap.density is set to prevent MapLibre's automatic scaling + // addProperty(Prop.SCALE, 1.0) + if (currFinger.icon == prevFinger.icon) { + addProperty( + Prop.ICON_ID, + prevProperties?.get(Prop.ICON_ID)?.asString ?: Prop.DEFAULT_MARKER_ID, + ) + + add( + Prop.ICON_ANCHOR, + prevProperties?.get(Prop.ICON_ANCHOR) ?: getDefaultIconOffsetProperty(), + ) + } else { + val prevIconKey = + prevProperties + ?.get(Prop.ICON_ID) + ?.asString + ?: params.prev.state.icon + ?.hashCode() + ?.toString() + ?: Prop.DEFAULT_MARKER_ID + decrementIconRef(prevIconKey) + + if (currFinger.icon == null) { + addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) + add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) + } else { + params.current.state.icon?.let { icon -> + // icon id + val iconKey = icon.hashCode().toString() + style.addImage(iconKey, params.bitmapIcon.bitmap, false) + iconBitmapCache[iconKey] = params.bitmapIcon.bitmap + if (!iconRefCounter.contains(iconKey)) iconRefCounter[iconKey] = 0 + incrementIconRef(iconKey) + addProperty(Prop.ICON_ID, iconKey) + add(Prop.ICON_ANCHOR, createIconOffset(icon)) + } + } + } + addProperty( + Prop.Z_INDEX, + drawOrderOf(params.current.state), + ) + } + + val position = + GeoPoint.from(params.current.state.position).toPoint() + val featureId = "marker-${params.current.state.id}" + Feature.fromGeometry(position, properties, featureId) + } + } + + /** + * ドラッグ対象の入れ替え。 + * + * ドラッグ中のマーカーは通常のレイヤ([markerLayer])から外して専用の + * [dragLayer] へ移す。そうしないと GeoJSON ソースを丸ごと描き直すたびに + * 指の位置と食い違う。以前は各プロバイダのマーカーコントローラと + * ストラテジ用イベントコントローラに同じものが 2 本ずつあった。 + */ + override fun onDragSelectionChanged( + previous: MarkerEntityInterface?, + current: MarkerEntityInterface?, + ) { + if (current == null) { + previous?.let { + dragLayer.updatePosition(GeoPoint.from(it.state.position)) + dragLayer.selected = null + drawDragLayer() + markerManager.registerEntity(it) + redraw() + } + return + } + markerManager.removeEntity(current.state.id) + dragLayer.selected = current + dragLayer.updatePosition(GeoPoint.from(current.state.position)) + redraw() + drawDragLayer() + } + + override fun onDragPositionChanged(position: GeoPointInterface) { + dragLayer.updatePosition(GeoPoint.from(position)) + drawDragLayer() + } +} + +private const val STYLE_IMAGE_REMOVAL_GRACE_MS: Long = 1500L From 48610d824ae882b0c9c5e42075e40e18bc221d0b Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 11 Nov 2026 22:29:03 -0800 Subject: [PATCH 29/31] Apply camera paddings and report fov on MapLibre The padding TODO is enabled: MapCameraPosition.paddings (dp) is passed to MapLibre as px on move, animate and the initial camera, and read back from the camera's padding. MapLibre then places the target at the padded-area center and solves the perspective itself. fov is 36.87, the MapLibre default (measured 36.9 on TB520FU in both orientations). --- api/android-for-maplibre.api | 13 +++- .../maplibre/MapCameraPosition.kt | 59 ++++++++++++++++--- .../mapconductor/maplibre/MapLibreMapView.kt | 2 +- .../maplibre/MapLibreViewControllerCamera.kt | 7 ++- 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index 092ca81..2ae5cdc 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -10,19 +10,27 @@ public final class com.mapconductor.maplibre.GeoRectBoundsKt { public static final com.mapconductor.core.features.GeoRectBounds toGeoRectBounds(org.maplibre.android.geometry.LatLngBounds); } public final class com.mapconductor.maplibre.MapCameraPositionKt { + public static final double MAPLIBRE_VERTICAL_FOV; + public static final double[] toMaplibrePadding(com.mapconductor.core.map.MapPaddingsInterface, float); + public static final com.mapconductor.core.map.MapPaddings toMapPaddings(double[], float); public static final org.maplibre.android.camera.CameraPosition toCameraPosition(com.mapconductor.core.map.MapCameraPosition); + public static final org.maplibre.android.camera.CameraPosition toCameraPosition(com.mapconductor.core.map.MapCameraPosition, java.lang.Float); 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(org.maplibre.android.camera.CameraPosition); public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(org.maplibre.android.camera.CameraPosition, java.lang.Double); + public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(org.maplibre.android.camera.CameraPosition, java.lang.Double, java.lang.Float); } public final class com.mapconductor.maplibre.MapLibreCameraStateSnapshot { - public com.mapconductor.maplibre.MapLibreCameraStateSnapshot(org.maplibre.android.camera.CameraPosition, java.lang.Double); + public com.mapconductor.maplibre.MapLibreCameraStateSnapshot(org.maplibre.android.camera.CameraPosition, java.lang.Double, java.lang.Float); + public com.mapconductor.maplibre.MapLibreCameraStateSnapshot(org.maplibre.android.camera.CameraPosition, java.lang.Double, java.lang.Float, int, kotlin.jvm.internal.DefaultConstructorMarker); public final org.maplibre.android.camera.CameraPosition getCameraPosition(); public final java.lang.Double getLogicalTiltHint(); + public final java.lang.Float getDensity(); public final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(); public final org.maplibre.android.camera.CameraPosition component1(); public final java.lang.Double component2(); - public final com.mapconductor.maplibre.MapLibreCameraStateSnapshot copy(org.maplibre.android.camera.CameraPosition, java.lang.Double); + public final java.lang.Float component3(); + public final com.mapconductor.maplibre.MapLibreCameraStateSnapshot copy(org.maplibre.android.camera.CameraPosition, java.lang.Double, java.lang.Float); public java.lang.String toString(); public int hashCode(); public boolean equals(java.lang.Object); @@ -164,6 +172,7 @@ public final class com.mapconductor.maplibre.MapLibreViewController extends com. } public final class com.mapconductor.maplibre.MapLibreViewControllerCameraKt { public static final com.mapconductor.core.map.MapCameraPosition readLogicalCameraPosition(com.mapconductor.maplibre.MapLibreViewController); + public static final float density(com.mapconductor.maplibre.MapLibreViewController); public static final com.mapconductor.core.map.MapCameraPosition getMapCameraPosition(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPositionInterface); public static final void handleMoveCamera(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPosition); public static final void handleAnimateCamera(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPosition, long); diff --git a/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt b/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt index 84c1d99..d620658 100644 --- a/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt +++ b/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt @@ -4,6 +4,8 @@ import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.map.CameraBearing import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapPaddings +import com.mapconductor.core.map.MapPaddingsInterface import com.mapconductor.core.spherical.Spherical import com.mapconductor.maplibre.zoom.ZoomAltitudeConverter import org.maplibre.android.camera.CameraPosition @@ -12,10 +14,40 @@ import kotlin.math.cos import kotlin.math.tan private val converter = ZoomAltitudeConverter() + +/** + * MapLibre のカメラの縦方向の視野角(度)。MapLibre Native の既定値で、地図の大きさに + * よらず一定(焦点距離 = 地図の高さ × 1.5)。TB520FU の縦・横で 36.9° を実測。 + */ +internal const val MAPLIBRE_VERTICAL_FOV = 36.87 + +/** dp のパディング → MapLibre の px のパディング(left, top, right, bottom)。 */ +internal fun MapPaddingsInterface.toMaplibrePadding(density: Float): DoubleArray = + doubleArrayOf(left * density, top * density, right * density, bottom * density) + +/** MapLibre の px のパディング(left, top, right, bottom)→ dp のパディング。 */ +internal fun DoubleArray.toMapPaddings(density: Float): MapPaddings? { + if (size < 4 || density <= 0f) return null + return MapPaddings( + top = this[1] / density, + left = this[0] / density, + bottom = this[3] / density, + right = this[2] / density, + ) +} + private const val NEGATIVE_TILT_TARGET_DISTANCE_SCALE = 1.83 private const val NEGATIVE_TILT_ZOOM_OFFSET_AT_MAX_TILT = -0.9 -fun MapCameraPosition.toCameraPosition(): CameraPosition { +fun MapCameraPosition.toCameraPosition(): CameraPosition = toCameraPosition(density = null) + +/** + * [density] があれば [MapCameraPosition.paddings] を MapLibre のパディングとして渡す。 + * MapLibre はパディングを除いた領域の中央にカメラの中心を置き、tilt の遠近もエンジンが解く。 + * MapLibre のパディングは次に変えるまで残るので、null(未指定)のときは渡さない。 + */ +internal fun MapCameraPosition.toCameraPosition(density: Float?): CameraPosition { + val padding = if (density != null) paddings?.toMaplibrePadding(density) else null if (tilt >= 0) { return CameraPosition .Builder() @@ -23,8 +55,7 @@ fun MapCameraPosition.toCameraPosition(): CameraPosition { .zoom(ZoomAltitudeConverter.googleZoomToMaplibreZoom(zoom)) .tilt(tilt) .bearing(CameraBearing.toNativeHeading(bearing)) - // TODO: -// .padding(paddings?.toEdgeInsects()) + .apply { padding?.let { padding(it) } } .build() } else { // tilt < 0: MapLibre cannot represent an upward pitch directly. @@ -47,8 +78,7 @@ fun MapCameraPosition.toCameraPosition(): CameraPosition { .zoom(ZoomAltitudeConverter.googleZoomToMaplibreZoom(adjustedZoom)) .tilt(tiltAbsDeg) .bearing(CameraBearing.toNativeHeading(bearing)) - // TODO: -// .padding(paddings?.toEdgeInsects()) + .apply { padding?.let { padding(it) } } .build() } } @@ -62,7 +92,9 @@ fun MapCameraPosition.Companion.from(cameraPosition: MapCameraPositionInterface) zoom = cameraPosition.zoom, bearing = cameraPosition.bearing, tilt = cameraPosition.tilt, + paddings = cameraPosition.paddings, visibleRegion = cameraPosition.visibleRegion, + fov = cameraPosition.fov, ) } @@ -71,11 +103,20 @@ fun CameraPosition.toMapCameraPosition() = toMapCameraPosition(logicalTiltHint = internal data class MapLibreCameraStateSnapshot( val cameraPosition: CameraPosition, val logicalTiltHint: Double?, + val density: Float? = null, ) { - fun toMapCameraPosition(): MapCameraPosition = cameraPosition.toMapCameraPosition(logicalTiltHint) + fun toMapCameraPosition(): MapCameraPosition = cameraPosition.toMapCameraPosition(logicalTiltHint, density) } -internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCameraPosition { +internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCameraPosition = + toMapCameraPosition(logicalTiltHint, density = null) + +/** [density] があれば MapLibre のパディングを dp に直して paddings として返す。 */ +internal fun CameraPosition.toMapCameraPosition( + logicalTiltHint: Double?, + density: Float?, +): MapCameraPosition { + val paddings = density?.let { d -> padding?.toMapPaddings(d) } ?: MapPaddings.Zeros val pitch = tilt val pitchAbsDeg = abs(pitch).coerceIn(0.0, 60.0) @@ -85,7 +126,9 @@ internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCa zoom = ZoomAltitudeConverter.maplibreZoomToGoogleZoom(zoom), bearing = CameraBearing.bearingFromNativeHeading(bearing), tilt = pitch, + paddings = paddings, visibleRegion = null, + fov = MAPLIBRE_VERTICAL_FOV, ) } @@ -107,6 +150,8 @@ internal fun CameraPosition.toMapCameraPosition(logicalTiltHint: Double?): MapCa zoom = originalGoogleZoom, bearing = CameraBearing.bearingFromNativeHeading(bear), tilt = -pitchAbsDeg, + paddings = paddings, visibleRegion = null, + fov = MAPLIBRE_VERTICAL_FOV, ) } diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt index 763fb6b..258ad7d 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt @@ -90,7 +90,7 @@ fun MapLibreMapView( modifier = modifier, viewProvider = { val cameraPosition = - state.cameraPosition.toCameraPosition() + state.cameraPosition.toCameraPosition(context.resources.displayMetrics.density) val mapInitOptions = MapLibreMapOptions .createFromAttributes(context) diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt index a1bc241..c9be20b 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt @@ -20,8 +20,11 @@ internal fun MapLibreViewController.readLogicalCameraPosition(): MapCameraPositi MapLibreCameraStateSnapshot( cameraPosition = holder.map.cameraPosition, logicalTiltHint = lastLogicalCameraPosition?.tilt, + density = density(), ).toMapCameraPosition() +internal fun MapLibreViewController.density(): Float = holder.mapView.resources.displayMetrics.density + internal fun MapLibreViewController.getMapCameraPosition(camera: MapCameraPositionInterface): MapCameraPosition? { // 4 隅の逆投影は全プロバイダ共通なのでコアの buildVisibleRegion を使う。 val visibleRegion = holder.buildVisibleRegion() ?: return null @@ -31,7 +34,7 @@ internal fun MapLibreViewController.getMapCameraPosition(camera: MapCameraPositi internal fun MapLibreViewController.handleMoveCamera(position: MapCameraPosition) { lastLogicalCameraPosition = position mainCoroutine.launch { - val cameraPos = position.toCameraPosition() + val cameraPos = position.toCameraPosition(density()) val cameraUpdate = CameraUpdateFactory .newCameraPosition(cameraPos) @@ -46,7 +49,7 @@ internal fun MapLibreViewController.handleAnimateCamera( ) { lastLogicalCameraPosition = position mainCoroutine.launch { - val cameraPos = position.toCameraPosition() + val cameraPos = position.toCameraPosition(density()) val cameraUpdate = CameraUpdateFactory .newCameraPosition(cameraPos) From 34bcdbe32a5d18d647256a773ea0c6b497c7fceb Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 12 Nov 2026 00:55:18 -0800 Subject: [PATCH 30/31] 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-maplibre.api | 2 + .../mapconductor/maplibre/MapLibreMapView.kt | 4 +- .../maplibre/MapLibreViewController.kt | 5 ++ .../maplibre/MapLibreViewControllerCamera.kt | 77 +++++++++++++++++-- 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index a0ef0f6..788262f 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -172,6 +172,8 @@ public final class com.mapconductor.maplibre.MapLibreViewController extends com. } public final class com.mapconductor.maplibre.MapLibreViewControllerCameraKt { public static final com.mapconductor.core.map.MapCameraPosition readLogicalCameraPosition(com.mapconductor.maplibre.MapLibreViewController); + public static final double viewportHeightDp(com.mapconductor.maplibre.MapLibreViewController); + public static final com.mapconductor.core.map.MapCameraPosition toNativeCamera(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPosition); public static final float density(com.mapconductor.maplibre.MapLibreViewController); public static final com.mapconductor.core.map.MapCameraPosition getMapCameraPosition(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPositionInterface); public static final void handleMoveCamera(com.mapconductor.maplibre.MapLibreViewController, com.mapconductor.core.map.MapCameraPosition); diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt index 258ad7d..4d0bdbe 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt @@ -12,6 +12,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.MapViewStyle import com.mapconductor.core.map.MutableMapServiceRegistry import com.mapconductor.core.map.StyleMutationStore @@ -90,7 +91,8 @@ fun MapLibreMapView( modifier = modifier, viewProvider = { val cameraPosition = - state.cameraPosition.toCameraPosition(context.resources.displayMetrics.density) + // パディングは MapLibre に渡さない。接続時の moveCamera が中心のずらしで適用する。 + state.cameraPosition.copy(paddings = MapPaddings.Zeros).toCameraPosition(context.resources.displayMetrics.density) val mapInitOptions = MapLibreMapOptions .createFromAttributes(context) diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt index 9e9af8e..05685c1 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -10,6 +10,8 @@ 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.MapPaddings +import com.mapconductor.core.map.MapPaddingsInterface import com.mapconductor.core.map.MapUISettings import com.mapconductor.core.marker.DefaultMarkerEventController import com.mapconductor.core.marker.MarkerAnimationOverlayHost @@ -84,6 +86,9 @@ class MapLibreViewController( internal var lastLogicalCameraPosition: MapCameraPosition? = null + /** アプリが指定したパディング(dp)。MapLibre には渡さず、中心のずらしで再現する。 */ + internal var cameraPaddings: MapPaddingsInterface = MapPaddings.Zeros + init { // Style should already be loaded by holderProvider val style = holder.map.style diff --git a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt index c9be20b..2c818e9 100644 --- a/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerCamera.kt @@ -1,9 +1,12 @@ package com.mapconductor.maplibre +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.MapCameraPositionInterface +import com.mapconductor.core.map.MapPaddings import com.mapconductor.core.map.buildVisibleRegion import com.mapconductor.maplibre.zoom.ZoomAltitudeConverter import org.maplibre.android.camera.CameraUpdateFactory @@ -17,11 +20,63 @@ import kotlinx.coroutines.launch * 1 段ずれる。読むときも書くときも [ZoomAltitudeConverter] を通す。 */ internal fun MapLibreViewController.readLogicalCameraPosition(): MapCameraPosition = - MapLibreCameraStateSnapshot( - cameraPosition = holder.map.cameraPosition, - logicalTiltHint = lastLogicalCameraPosition?.tilt, - density = density(), - ).toMapCameraPosition() + CameraPaddingEmulation.fromNativeCamera( + MapLibreCameraStateSnapshot( + cameraPosition = holder.map.cameraPosition, + logicalTiltHint = lastLogicalCameraPosition?.tilt, + density = density(), + ).toMapCameraPosition(), + cameraPaddings, + MAPLIBRE_VERTICAL_FOV, + viewportHeightDp(), + ) + +/** 地図の高さ(dp)。パディングのエミュレーションで遠近を解くのに使う。 */ +internal fun MapLibreViewController.viewportHeightDp(): Double = holder.mapView.height / density().toDouble() + +/** + * パディングを中心のずらしに置き換えたカメラ。MapLibre のパディングは常に 0 にする。 + * + * MapLibre のネイティブのパディングは視錐台を上へ広げる。tilt があると上のパディングが + * 大きいほど画面の上端が地平線の先まで届き、そこまでタイルを読みに行って固まる + * (パディング中は tilt も 56° 前後に下げられる)。中心をずらせば、パディング無しと + * 同じ視錐台のまま target をパディング中央に置ける。paddings が null なら今のパディングを使い続ける。 + */ +internal fun MapLibreViewController.toNativeCamera(position: MapCameraPosition): MapCameraPosition { + position.paddings?.let { cameraPaddings = it } + return CameraPaddingEmulation + .toNativeCamera(position.copy(paddings = cameraPaddings), MAPLIBRE_VERTICAL_FOV, viewportHeightDp()) + .copy(paddings = MapPaddings.Zeros) +} + +/** 地図がまだ大きさを持たない(レイアウト前)ときは、パディングを解けないので最初のレイアウトまで待つ。 */ +private fun MapLibreViewController.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 MapLibreViewController.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 MapLibreViewController.density(): Float = holder.mapView.resources.displayMetrics.density @@ -34,7 +89,11 @@ internal fun MapLibreViewController.getMapCameraPosition(camera: MapCameraPositi internal fun MapLibreViewController.handleMoveCamera(position: MapCameraPosition) { lastLogicalCameraPosition = position mainCoroutine.launch { - val cameraPos = position.toCameraPosition(density()) + if (needsLayoutFor(position)) { + afterFirstLayout { handleMoveCamera(position) } + return@launch + } + val cameraPos = toNativeCamera(position).toCameraPosition(density()) val cameraUpdate = CameraUpdateFactory .newCameraPosition(cameraPos) @@ -49,7 +108,11 @@ internal fun MapLibreViewController.handleAnimateCamera( ) { lastLogicalCameraPosition = position mainCoroutine.launch { - val cameraPos = position.toCameraPosition(density()) + if (needsLayoutFor(position)) { + afterFirstLayout { handleAnimateCamera(position, duration) } + return@launch + } + val cameraPos = toNativeCamera(position).toCameraPosition(density()) val cameraUpdate = CameraUpdateFactory .newCameraPosition(cameraPos) From 3088372c9a4c7aa7e9e72e42801d6caa4797c0d2 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 13 Nov 2026 03:40:13 -0800 Subject: [PATCH 31/31] Release 1.4.0 --- api/android-for-maplibre.api | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/api/android-for-maplibre.api b/api/android-for-maplibre.api index 788262f..6818f8a 100644 --- a/api/android-for-maplibre.api +++ b/api/android-for-maplibre.api @@ -77,6 +77,9 @@ public final class com.mapconductor.maplibre.MapLibreMapViewHolder implements co public androidx.compose.ui.geometry.Offset toScreenOffset-x-9fifI(com.mapconductor.core.features.GeoPointInterface); public com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(long); public java.lang.Object fromScreenOffset-3MmeM6k(long, kotlin.coroutines.Continuation); + 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 java.lang.Object getMapView(); public java.lang.Object getMap(); } @@ -85,6 +88,9 @@ public interface com.mapconductor.maplibre.MapLibreMapViewHolderInterface extend public final class com.mapconductor.maplibre.MapLibreMapViewHolderInterface$DefaultImpls { public static java.lang.Object fromScreenOffset-3MmeM6k(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, long, kotlin.coroutines.Continuation); public static com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, long); + public static com.mapconductor.core.projection.ProjectedPoint toProjectedPoint(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.features.GeoPointInterface); + public static com.mapconductor.core.features.GeoPoint fromProjectedPoint(com.mapconductor.maplibre.MapLibreMapViewHolderInterface, com.mapconductor.core.projection.ProjectedPoint); + public static com.mapconductor.core.projection.MapCoordinateSystem getCoordinateSystem(com.mapconductor.maplibre.MapLibreMapViewHolderInterface); } public final class com.mapconductor.maplibre.MapLibreMapViewKt { public static final void MapLibreMapView(com.mapconductor.maplibre.MapLibreViewState, 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.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); @@ -115,6 +121,7 @@ public final class com.mapconductor.maplibre.MapLibreOverlayStacking { 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(org.maplibre.android.maps.MapLibreMap, org.maplibre.android.maps.Style, org.maplibre.android.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