From dec50d77b9a035e9f62d05bfdfc8bf9e40416d21 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 08:35:36 -0800 Subject: [PATCH 1/4] hasMarker: look ids up in a set instead of scanning the list MarkerState.onAdded asks hasMarker once per marker after every composition; a linear scan made that quadratic (1e5 markers, 1e10 comparisons). --- .../maptiler/MapTilerMapViewController.kt | 8 ++++-- .../maptiler/marker/MarkerIdIndex.kt | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/mapconductor/maptiler/marker/MarkerIdIndex.kt diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt index e8451bf..1d3f402 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt @@ -31,6 +31,7 @@ import com.mapconductor.maptiler.circle.MapTilerCircleOverlayRenderer import com.mapconductor.maptiler.groundimage.MapTilerGroundImageController import com.mapconductor.maptiler.groundimage.MapTilerGroundImageOverlayRenderer import com.mapconductor.maptiler.marker.MapTilerMarkerTileRenderer +import com.mapconductor.maptiler.marker.MarkerIdIndex import com.mapconductor.maptiler.polygon.MapTilerPolygonController import com.mapconductor.maptiler.polygon.MapTilerPolygonOverlayRenderer import com.mapconductor.maptiler.polyline.MapTilerPolylineController @@ -206,8 +207,11 @@ class MapTilerMapViewController( } override fun hasMarker(state: MarkerState): Boolean = - _markers.value.any { it.id == state.id } || - (markerTileRenderer?.markers?.any { it.id == state.id } ?: false) + overlayMarkerIds.contains(_markers.value, state.id) || + (markerTileRenderer?.markers?.let { tiledMarkerIds.contains(it, state.id) } ?: false) + + private val tiledMarkerIds = MarkerIdIndex() + private val overlayMarkerIds = MarkerIdIndex() // --- Marker clustering (android-marker-clustering) 連携 --- diff --git a/src/main/java/com/mapconductor/maptiler/marker/MarkerIdIndex.kt b/src/main/java/com/mapconductor/maptiler/marker/MarkerIdIndex.kt new file mode 100644 index 0000000..a913a71 --- /dev/null +++ b/src/main/java/com/mapconductor/maptiler/marker/MarkerIdIndex.kt @@ -0,0 +1,28 @@ +package com.mapconductor.maptiler.marker + +import com.mapconductor.core.marker.MarkerState + +/** + * The ids of a marker list, rebuilt only when the list itself is replaced. + * + * `hasMarker` is asked once per marker after every composition (that is how + * `MarkerState.onAdded` finds out what is on the map) and on every in-place + * change. A linear search of the list made the first of those quadratic: 1e5 + * markers, 1e10 comparisons. + */ +internal class MarkerIdIndex { + private var list: List? = null + private var ids: Set = emptySet() + + @Synchronized + fun contains( + markers: List, + id: String, + ): Boolean { + if (markers !== list) { + ids = markers.mapTo(HashSet(markers.size * 2)) { it.id } + list = markers + } + return id in ids + } +} From 9255e4028df2d1d202192179563601a8880a3036 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 16:23:33 -0800 Subject: [PATCH 2/4] Hold onAdded until the layers are on the page's map; re-add removed ids The MapTiler SDK hands a layer to the page well after MTStyle.addLayer returns (a coroutine, then the SDK's queue until the map's next idle), so onAdded came while the map showed nothing: Boston's 604 downtown polygons reported it 0.1 s after the hand-over and reached the map 15 s later. The renderers' onAdd (polygon, polyline, circle, ground image, raster -- and so the polygon and marker tiles) now return once the page's map has their layers (PageLayerWait), asked through the bridge. Removal went by id only, which left the id in MTStyle's own record: adding the same id again failed with "Layer already exists" and drew nothing, so Downtown after City parcels showed no polygons while reporting onAdded. The layer and source objects are now removed, which clears both. --- .../mapconductor/maptiler/PageLayerWait.kt | 146 ++++++++++++++++++ .../circle/MapTilerCircleOverlayRenderer.kt | 21 ++- .../MapTilerGroundImageOverlayRenderer.kt | 17 +- .../polygon/MapTilerPolygonOverlayRenderer.kt | 21 ++- .../MapTilerPolylineOverlayRenderer.kt | 17 +- .../MapTilerRasterLayerOverlayRenderer.kt | 41 +++-- .../maptiler/PageLayerWaitTest.kt | 109 +++++++++++++ 7 files changed, 348 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/mapconductor/maptiler/PageLayerWait.kt create mode 100644 src/test/java/com/mapconductor/maptiler/PageLayerWaitTest.kt diff --git a/src/main/java/com/mapconductor/maptiler/PageLayerWait.kt b/src/main/java/com/mapconductor/maptiler/PageLayerWait.kt new file mode 100644 index 0000000..1d9e132 --- /dev/null +++ b/src/main/java/com/mapconductor/maptiler/PageLayerWait.kt @@ -0,0 +1,146 @@ +package com.mapconductor.maptiler + +import com.maptiler.maptilersdk.bridge.MTBridgeReturnType +import com.maptiler.maptilersdk.bridge.MTCommand +import com.maptiler.maptilersdk.map.MTMapViewController +import com.maptiler.maptilersdk.map.style.MTStyle +import com.maptiler.maptilersdk.map.style.layer.MTLayer +import com.maptiler.maptilersdk.map.style.source.MTSource +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Waits until the page's map has the layers an overlay was just given. + * + * The MapTiler SDK hands a layer to the page later than `MTStyle.addLayer` + * returns: the call launches a coroutine, and a layer whose source has not + * loaded yet waits in the SDK's queue until the map's next `idle`. + * So a renderer's `onAdd` returned before its layers were on the map, and + * the overlay's `onAdded` (asked for right after the hand-over) came while + * the map still showed nothing. Boston's 604 downtown polygons reported + * `onAdded` 0.1 s after the hand-over; the last of their layers reached the + * map 15 s after it (Lenovo TB520FU). + * Renderers call [await] at the end of `onAdd`, so `compositionXxx` + * returns, and `onAdded` is called, once the layers are there. + * + * Only that the layers are in the style is waited for, not their tiles or + * images (same as every other provider). + */ +internal object PageLayerWait { + /** The page's map, or a falsy value while there is none. */ + const val MAP_EXPR = "window.map" + + /** + * Only a guard against a layer the page refused, which never shows up: + * a few hundred layers can take the page well over 10 s. + */ + const val TIMEOUT_MS = 60_000L + const val INTERVAL_MS = 100L + + /** + * Script that answers how many of [ids], from the front, the map has + * (as text, e.g. `"3"`); `"0"` while there is no map. + */ + fun script(ids: List): String { + val list = ids.joinToString(",", "[", "]") { quote(it) } + return "(function(){try{var m=$MAP_EXPR;if(!m)return '0';var ids=$list;" + + "for(var i=0;i, + timeoutMs: Long = TIMEOUT_MS, + intervalMs: Long = INTERVAL_MS, + evaluate: suspend (String) -> String?, + ): Boolean { + if (ids.isEmpty()) return true + var missing = ids + val done = + withTimeoutOrNull(timeoutMs) { + while (missing.isNotEmpty()) { + val present = + try { + parse(evaluate(script(missing))) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + 0 + } + // Layers mostly arrive in the order they were added: ask only for the rest. + missing = missing.drop(present) + if (missing.isNotEmpty()) delay(intervalMs) + } + true + } ?: false + if (!done) { + runCatching { Log.w(TAG, "${missing.size} of ${ids.size} layers not on the map after $timeoutMs ms") } + } + return done + } + + private fun quote(text: String): String = + buildString { + append('"') + for (c in text) { + when { + c == '"' -> append("\\\"") + c == '\\' -> append("\\\\") + c < ' ' || c == '\u2028' || c == '\u2029' -> append(String.format("\\u%04x", c.code)) + else -> append(c) + } + } + append('"') + } + + private const val TAG = "PageLayerWait" +} + +/** [PageLayerWait.await] against this map's page. */ +internal suspend fun MTMapViewController.awaitPageLayers(ids: List): Boolean = + PageLayerWait.await(ids) { js -> + when (val result = execute(PageScript(js))) { + is MTBridgeReturnType.StringValue -> result.value + is MTBridgeReturnType.DoubleValue -> result.value.toInt().toString() + else -> null + } + } + +/** A script whose (string) value comes back. */ +private class PageScript( + private val js: String, +) : MTCommand { + override val isPrimitiveReturnType: Boolean = true + + override fun toJS(): String = js +} + +/** + * Takes [layer] off the map and out of the style's own record of ids. + * + * `removeLayerById` only reaches the page: `MTStyle` still lists the id, so + * adding a layer of the same id later failed with "Layer already exists" and + * nothing was drawn -- an overlay removed and added again (Boston's Downtown + * after City parcels) stayed off the map while reporting `onAdded`. + * `removeLayer` with the object clears both. The by-id call stays as the + * fallback for a layer the style never recorded. + */ +internal fun MTStyle.removeOwnedLayer(layer: MTLayer) { + runCatching { removeLayer(layer) }.onFailure { runCatching { removeLayerById(layer.identifier) } } +} + +/** [removeOwnedLayer] for a source. */ +internal fun MTStyle.removeOwnedSource(source: MTSource) { + runCatching { removeSource(source) }.onFailure { runCatching { removeSourceById(source.identifier) } } +} diff --git a/src/main/java/com/mapconductor/maptiler/circle/MapTilerCircleOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/circle/MapTilerCircleOverlayRenderer.kt index b3d2db3..e02298d 100644 --- a/src/main/java/com/mapconductor/maptiler/circle/MapTilerCircleOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/circle/MapTilerCircleOverlayRenderer.kt @@ -6,6 +6,9 @@ import com.mapconductor.core.circle.CircleOverlayRendererInterface import com.mapconductor.core.circle.CircleState import com.mapconductor.core.geometry.OverlayGeoJson import com.mapconductor.core.geometry.circleToRing +import com.mapconductor.maptiler.awaitPageLayers +import com.mapconductor.maptiler.removeOwnedLayer +import com.mapconductor.maptiler.removeOwnedSource import com.maptiler.maptilersdk.map.MTMapViewController import com.maptiler.maptilersdk.map.style.dsl.PropertyValue import com.maptiler.maptilersdk.map.style.layer.MTLayerVisibility @@ -30,9 +33,19 @@ import android.util.Log class MapTilerCircleOverlayRenderer( private val mtController: MTMapViewController, ) : CircleOverlayRendererInterface { + /** + * Returns once the layers are on the page's map, so the circles' + * `onAdded` comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). + */ override suspend fun onAdd( data: List, - ): List = data.map { addCircle(it.state) } + ): List { + val handles = data.map { addCircle(it.state) } + if (mtController.style != null) { + mtController.awaitPageLayers(handles.filterNotNull().flatMap { listOf(it.fillLayerId, it.lineLayerId) }) + } + return handles + } override suspend fun onChange( data: List>, @@ -141,9 +154,9 @@ class MapTilerCircleOverlayRenderer( private fun removeCircle(entity: CircleEntityInterface) { val style = mtController.style ?: return val handle = entity.circle - runCatching { style.removeLayerById(handle.lineLayerId) } - runCatching { style.removeLayerById(handle.fillLayerId) } - runCatching { style.removeSourceById(handle.sourceId) } + style.removeOwnedLayer(handle.lineLayer) + style.removeOwnedLayer(handle.fillLayer) + style.removeOwnedSource(handle.source) } /** diff --git a/src/main/java/com/mapconductor/maptiler/groundimage/MapTilerGroundImageOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/groundimage/MapTilerGroundImageOverlayRenderer.kt index 6df3d29..42c3ad8 100644 --- a/src/main/java/com/mapconductor/maptiler/groundimage/MapTilerGroundImageOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/groundimage/MapTilerGroundImageOverlayRenderer.kt @@ -4,6 +4,9 @@ import com.mapconductor.core.features.GeoRectBounds import com.mapconductor.core.groundimage.GroundImageEntityInterface import com.mapconductor.core.groundimage.GroundImageOverlayRendererInterface import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.maptiler.awaitPageLayers +import com.mapconductor.maptiler.removeOwnedLayer +import com.mapconductor.maptiler.removeOwnedSource import com.maptiler.maptilersdk.map.LngLat import com.maptiler.maptilersdk.map.MTMapViewController import com.maptiler.maptilersdk.map.style.dsl.PropertyValue @@ -32,9 +35,17 @@ import android.util.Log class MapTilerGroundImageOverlayRenderer( private val mtController: MTMapViewController, ) : GroundImageOverlayRendererInterface { + /** + * Returns once the layers are on the page's map, so the ground images' + * `onAdded` comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). + */ override suspend fun onAdd( data: List, - ): List = data.map { addGroundImage(it.state) } + ): List { + val handles = data.map { addGroundImage(it.state) } + if (mtController.style != null) mtController.awaitPageLayers(handles.mapNotNull { it?.layerId }) + return handles + } override suspend fun onChange( data: List>, @@ -132,8 +143,8 @@ class MapTilerGroundImageOverlayRenderer( private fun removeGroundImage(entity: GroundImageEntityInterface) { val style = mtController.style ?: return val handle = entity.groundImage - runCatching { style.removeLayerById(handle.layerId) } - runCatching { style.removeSourceById(handle.sourceId) } + style.removeOwnedLayer(handle.layer) + style.removeOwnedSource(handle.source) } /** diff --git a/src/main/java/com/mapconductor/maptiler/polygon/MapTilerPolygonOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/polygon/MapTilerPolygonOverlayRenderer.kt index 73435dd..0488668 100644 --- a/src/main/java/com/mapconductor/maptiler/polygon/MapTilerPolygonOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/polygon/MapTilerPolygonOverlayRenderer.kt @@ -7,6 +7,9 @@ import com.mapconductor.core.polygon.PolygonEntityInterface import com.mapconductor.core.polygon.PolygonOverlayRendererInterface import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.polygon.unionHoles +import com.mapconductor.maptiler.awaitPageLayers +import com.mapconductor.maptiler.removeOwnedLayer +import com.mapconductor.maptiler.removeOwnedSource import com.maptiler.maptilersdk.map.MTMapViewController import com.maptiler.maptilersdk.map.style.dsl.PropertyValue import com.maptiler.maptilersdk.map.style.layer.MTLayerVisibility @@ -30,9 +33,19 @@ import android.util.Log class MapTilerPolygonOverlayRenderer( private val mtController: MTMapViewController, ) : PolygonOverlayRendererInterface { + /** + * Returns once the layers are on the page's map, so the polygons' + * `onAdded` comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). + */ override suspend fun onAdd( data: List, - ): List = data.map { addPolygon(it.state) } + ): List { + val handles = data.map { addPolygon(it.state) } + if (mtController.style != null) { + mtController.awaitPageLayers(handles.filterNotNull().flatMap { listOf(it.fillLayerId, it.lineLayerId) }) + } + return handles + } override suspend fun onChange( data: List>, @@ -164,9 +177,9 @@ class MapTilerPolygonOverlayRenderer( private fun removePolygon(entity: PolygonEntityInterface) { val style = mtController.style ?: return val handle = entity.polygon - runCatching { style.removeLayerById(handle.lineLayerId) } - runCatching { style.removeLayerById(handle.fillLayerId) } - runCatching { style.removeSourceById(handle.sourceId) } + style.removeOwnedLayer(handle.lineLayer) + style.removeOwnedLayer(handle.fillLayer) + style.removeOwnedSource(handle.source) } /** diff --git a/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineOverlayRenderer.kt index f4e226b..0878ba0 100644 --- a/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineOverlayRenderer.kt @@ -6,6 +6,9 @@ import com.mapconductor.core.geometry.buildUnwrappedPolylinePath import com.mapconductor.core.polyline.PolylineEntityInterface import com.mapconductor.core.polyline.PolylineOverlayRendererInterface import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.maptiler.awaitPageLayers +import com.mapconductor.maptiler.removeOwnedLayer +import com.mapconductor.maptiler.removeOwnedSource import com.maptiler.maptilersdk.map.MTMapViewController import com.maptiler.maptilersdk.map.style.dsl.PropertyValue import com.maptiler.maptilersdk.map.style.layer.MTLayerVisibility @@ -28,9 +31,17 @@ import android.util.Log class MapTilerPolylineOverlayRenderer( private val mtController: MTMapViewController, ) : PolylineOverlayRendererInterface { + /** + * Returns once the layers are on the page's map, so the polylines' + * `onAdded` comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). + */ override suspend fun onAdd( data: List, - ): List = data.map { addLine(it.state) } + ): List { + val handles = data.map { addLine(it.state) } + if (mtController.style != null) mtController.awaitPageLayers(handles.mapNotNull { it?.layerId }) + return handles + } override suspend fun onChange( data: List>, @@ -141,8 +152,8 @@ class MapTilerPolylineOverlayRenderer( private fun removeLine(entity: PolylineEntityInterface) { val style = mtController.style ?: return val handle = entity.polyline - runCatching { style.removeLayerById(handle.layerId) } - runCatching { style.removeSourceById(handle.sourceId) } + style.removeOwnedLayer(handle.layer) + style.removeOwnedSource(handle.source) } /** diff --git a/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt index 2df9fb6..efbe79e 100644 --- a/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt @@ -6,6 +6,9 @@ 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.maptiler.awaitPageLayers +import com.mapconductor.maptiler.removeOwnedLayer +import com.mapconductor.maptiler.removeOwnedSource import com.maptiler.maptilersdk.map.MTMapViewController import com.maptiler.maptilersdk.map.style.MTTileScheme import com.maptiler.maptilersdk.map.style.dsl.PropertyValue @@ -35,19 +38,29 @@ class MapTilerRasterLayerOverlayRenderer( /** * MapTiler Android は WebView(MapLibre GL JS) 越しにタイルを取るため、 * ネイティブ側からリクエストヘッダを差し替える口が無い。 + * + * Returns once the layers are on the page's map, so `onAdded` of the + * layer -- and of the polygons and markers drawn into it as tiles -- + * comes after they are (see [com.mapconductor.maptiler.PageLayerWait]). */ override suspend fun onAdd( data: List, - ): List = - data.map { - RasterHeaderRuleSet.warnUnsupported(provider = "MapTiler", state = it.state) - addLayer(it.state) - } + ): List { + val handles = + data.map { + RasterHeaderRuleSet.warnUnsupported(provider = "MapTiler", state = it.state) + addLayer(it.state) + } + awaitOnMap(handles) + return handles + } + /** Returns once a layer added again under a new source is on the map, as [onAdd]. */ override suspend fun onChange( data: List>, - ): List = - data.map { params -> + ): List { + val replaced = mutableListOf() + val handles = data.map { params -> val prev = params.prev val next = params.current.state if (prev.state.source != next.source) { @@ -56,12 +69,20 @@ class MapTilerRasterLayerOverlayRenderer( // add below, which then fails with "Source already exists" and // leaves the layer gone. The marker and polygon tiles change // source on every in-place change. - addLayer(next, generation = ++replacements) + addLayer(next, generation = ++replacements).also { replaced += it } } else { updatePaint(prev.layer, next) prev.layer } } + awaitOnMap(replaced) + return handles + } + + private suspend fun awaitOnMap(handles: List) { + if (mtController.style == null) return + mtController.awaitPageLayers(handles.mapNotNull { it?.layerId }) + } override suspend fun onRemove(data: List>) { data.forEach { removeLayer(it) } @@ -132,8 +153,8 @@ class MapTilerRasterLayerOverlayRenderer( private fun removeLayer(entity: RasterLayerEntityInterface) { val style = mtController.style ?: return val handle = entity.layer - runCatching { style.removeLayerById(handle.layerId) } - runCatching { style.removeSourceById(handle.sourceId) } + style.removeOwnedLayer(handle.layer) + style.removeOwnedSource(handle.source) } private fun buildSource( diff --git a/src/test/java/com/mapconductor/maptiler/PageLayerWaitTest.kt b/src/test/java/com/mapconductor/maptiler/PageLayerWaitTest.kt new file mode 100644 index 0000000..5fcc979 --- /dev/null +++ b/src/test/java/com/mapconductor/maptiler/PageLayerWaitTest.kt @@ -0,0 +1,109 @@ +package com.mapconductor.maptiler + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [PageLayerWait] returns only once the page's map has every layer, which is + * what holds an overlay's `onAdded` until it is really on the map. + * + * The page is faked: [FakePage] answers the script the way the real one does, + * from a set of layer ids that tests add to between polls. + */ +class PageLayerWaitTest { + /** Answers [PageLayerWait.script] from [layers], counting the calls. */ + private class FakePage { + val layers = mutableSetOf() + val asked = mutableListOf>() + var onAsk: (Int) -> Unit = {} + + fun evaluate(js: String): String { + val ids = + Regex("var ids=\\[(.*?)\\];") + .find(js)!! + .groupValues[1] + .split(',') + .filter { it.isNotEmpty() } + .map { it.trim('"') } + asked += ids + onAsk(asked.size) + val present = ids.takeWhile { it in layers }.size + // evaluateJavascript hands a string value back JSON-quoted. + return "\"$present\"" + } + } + + @Test + fun returnsAtOnceForNoLayers() = + runBlocking { + var calls = 0 + assertTrue( + PageLayerWait.await(emptyList()) { + calls++ + "0" + }, + ) + assertEquals(0, calls) + } + + @Test + fun waitsUntilEveryLayerIsOnTheMap() = + runBlocking { + val page = FakePage() + // The layers arrive over three polls, as the SDK's queue empties. + page.onAsk = { n -> + if (n == 2) page.layers += "a" + if (n == 3) page.layers += listOf("b", "c") + } + val done = PageLayerWait.await(listOf("a", "b", "c"), intervalMs = 1) { page.evaluate(it) } + assertTrue(done) + assertEquals(listOf("a", "b", "c"), page.layers.sorted()) + // Asks again only for the layers still missing. + assertEquals(listOf(listOf("a", "b", "c"), listOf("a", "b", "c"), listOf("b", "c")), page.asked) + } + + @Test + fun givesUpAfterTheTimeout() = + runBlocking { + val page = FakePage() + page.layers += "a" + val done = PageLayerWait.await(listOf("a", "never"), timeoutMs = 100, intervalMs = 5) { page.evaluate(it) } + assertFalse(done) + assertTrue(page.asked.size > 1) + } + + @Test + fun aFailedAskCountsAsNothingYet() = + runBlocking { + var calls = 0 + val done = + PageLayerWait.await(listOf("a"), intervalMs = 1) { + calls++ + when (calls) { + 1 -> throw IllegalStateException("bridge not loaded") + 2 -> null + else -> "\"1\"" + } + } + assertTrue(done) + assertEquals(3, calls) + } + + @Test + fun parseReadsTheCountQuotedOrNot() { + assertEquals(3, PageLayerWait.parse("\"3\"")) + assertEquals(3, PageLayerWait.parse("3")) + assertEquals(0, PageLayerWait.parse(null)) + assertEquals(0, PageLayerWait.parse("null")) + } + + @Test + fun idsAreQuotedForTheScript() { + val js = PageLayerWait.script(listOf("polygon-fill-a\"b", "x\\y")) + assertTrue(js, js.contains("[\"polygon-fill-a\\\"b\",\"x\\\\y\"]")) + assertTrue(js.contains(PageLayerWait.MAP_EXPR)) + } +} From 26c40f83c9dade00c91a4e0c8b30d74807b99f9e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 06:59:45 -0800 Subject: [PATCH 3/4] Update the API baseline Additions only (this branch had not run apiDump); in core, one member moved. --- api/android-for-maptiler.api | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/api/android-for-maptiler.api b/api/android-for-maptiler.api index fbed037..fc96fa1 100644 --- a/api/android-for-maptiler.api +++ b/api/android-for-maptiler.api @@ -200,6 +200,23 @@ public final class com.mapconductor.maptiler.MapTilerViewStateImplKt { } public interface com.mapconductor.maptiler.MapTilerViewStateInterface extends com.mapconductor.core.map.MapViewStateInterface { } +public final class com.mapconductor.maptiler.PageLayerWait { + public static final com.mapconductor.maptiler.PageLayerWait INSTANCE; + public static final java.lang.String MAP_EXPR; + public static final long TIMEOUT_MS; + public static final long INTERVAL_MS; + public final java.lang.String script(java.util.List); + public final int parse(java.lang.String); + public final java.lang.Object await(java.util.List, long, long, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.coroutines.Continuation); +} +public final class com.mapconductor.maptiler.PageLayerWaitKt { + public static final java.lang.Object awaitPageLayers(com.maptiler.maptilersdk.map.MTMapViewController, java.util.List, kotlin.coroutines.Continuation); +} +final class com.mapconductor.maptiler.PageScript implements com.maptiler.maptilersdk.bridge.MTCommand { + public com.mapconductor.maptiler.PageScript(java.lang.String); + public boolean isPrimitiveReturnType(); + public java.lang.String toJS(); +} public final class com.mapconductor.maptiler.circle.MapTilerCircleController extends com.mapconductor.core.circle.CircleController { public com.mapconductor.maptiler.circle.MapTilerCircleController(com.mapconductor.maptiler.circle.MapTilerCircleOverlayRenderer, com.mapconductor.core.circle.CircleManagerInterface); public com.mapconductor.maptiler.circle.MapTilerCircleController(com.mapconductor.maptiler.circle.MapTilerCircleOverlayRenderer, com.mapconductor.core.circle.CircleManagerInterface, int, kotlin.jvm.internal.DefaultConstructorMarker); @@ -313,6 +330,10 @@ public final class com.mapconductor.maptiler.marker.MapTilerMarkerTileRenderer { } final class com.mapconductor.maptiler.marker.MapTilerMarkerTileRenderer$Companion { } +public final class com.mapconductor.maptiler.marker.MarkerIdIndex { + public com.mapconductor.maptiler.marker.MarkerIdIndex(); + public final synchronized boolean contains(java.util.List, java.lang.String); +} public final class com.mapconductor.maptiler.polygon.MapTilerPolygonController extends com.mapconductor.core.polygon.PolygonController { public com.mapconductor.maptiler.polygon.MapTilerPolygonController(com.mapconductor.maptiler.polygon.MapTilerPolygonOverlayRenderer, com.mapconductor.core.polygon.PolygonManagerInterface); public com.mapconductor.maptiler.polygon.MapTilerPolygonController(com.mapconductor.maptiler.polygon.MapTilerPolygonOverlayRenderer, com.mapconductor.core.polygon.PolygonManagerInterface, int, kotlin.jvm.internal.DefaultConstructorMarker); From ee1313505c93c56fea46be371a10c18d5c67a854 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 09:38:06 -0800 Subject: [PATCH 4/4] 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-maptiler.api | 1 + .../maptiler/MapTilerMapViewController.kt | 15 +++++++++++++++ .../maptiler/MapTilerOverlayStacking.kt | 3 +++ .../polyline/MapTilerPolylineController.kt | 3 ++- .../raster/MapTilerRasterLayerOverlayRenderer.kt | 3 +++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/api/android-for-maptiler.api b/api/android-for-maptiler.api index fc96fa1..cf68f87 100644 --- a/api/android-for-maptiler.api +++ b/api/android-for-maptiler.api @@ -170,6 +170,7 @@ public final class com.mapconductor.maptiler.MapTilerOverlayStacking { 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 void add(com.maptiler.maptilersdk.map.style.MTStyle, com.maptiler.maptilersdk.map.style.layer.MTLayer, com.mapconductor.core.controller.OverlayStacking$Key); public final void remove(com.maptiler.maptilersdk.map.style.MTStyle, java.lang.String); public final void onIdle(com.maptiler.maptilersdk.map.style.MTStyle); diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt index 6f62b20..07b6e96 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerMapViewController.kt @@ -22,8 +22,11 @@ import com.mapconductor.core.polygon.PolygonCapableInterface 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.PolylineCapableInterface import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTiler +import com.mapconductor.core.polyline.polylineTileRasterLayerState import com.mapconductor.core.raster.RasterLayerCapableInterface import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.maptiler.circle.MapTilerCircleController @@ -336,6 +339,18 @@ class MapTilerMapViewController( } } } + + // 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) + } + } + } // ホルダーの同期投影にカメラを供給する。ホルダーはコントローラより先に作られるので // ここで繋ぐ。これが無いと toScreenOffset が常に null を返し、InfoBubble と // マーカー追従が理由も出ずに落ちる(android-for-longdo と同じ)。 diff --git a/src/main/java/com/mapconductor/maptiler/MapTilerOverlayStacking.kt b/src/main/java/com/mapconductor/maptiler/MapTilerOverlayStacking.kt index 18f9e7c..b0e5747 100644 --- a/src/main/java/com/mapconductor/maptiler/MapTilerOverlayStacking.kt +++ b/src/main/java/com/mapconductor/maptiler/MapTilerOverlayStacking.kt @@ -52,6 +52,9 @@ internal object MapTilerOverlayStacking { /** ポリゴンのタイル: ポリゴンの帯の一番下。 */ val POLYGON_TILE = key(OverlayKind.Polygon, Int.MIN_VALUE) + /** ポリラインのタイル(ラスター): ポリラインの帯の一番下(サークルの上、ネイティブのポリラインの下)。 */ + val POLYLINE_TILE = key(OverlayKind.Polyline, Int.MIN_VALUE) + fun add( style: MTStyle, layer: MTLayer, diff --git a/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineController.kt b/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineController.kt index 524688f..bd3fcbe 100644 --- a/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineController.kt +++ b/src/main/java/com/mapconductor/maptiler/polyline/MapTilerPolylineController.kt @@ -18,6 +18,7 @@ class MapTilerPolylineController( * 既知のポリラインを再適用する(地図 `ready` 後の復元用)。 */ fun reapply() { - renderer.reapply(polylineManager.allEntities().map { it.polyline }) + // タイルで描く線(ハンドル無し)は飛ばす。 + renderer.reapply(polylineManager.allEntities().mapNotNull { it.polyline as MapTilerPolylineHandle? }) } } diff --git a/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt index 42264aa..0c541f9 100644 --- a/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/maptiler/raster/MapTilerRasterLayerOverlayRenderer.kt @@ -2,6 +2,7 @@ package com.mapconductor.maptiler.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.RasterHeaderRuleSet import com.mapconductor.core.raster.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface @@ -119,6 +120,8 @@ class MapTilerRasterLayerOverlayRenderer( MapTilerOverlayStacking.MARKER_TILE } else if (layerId.startsWith("raster-layer-$POLYGON_TILE_LAYER_ID")) { MapTilerOverlayStacking.POLYGON_TILE + } else if (layerId.startsWith("raster-layer-$POLYLINE_TILE_LAYER_ID")) { + MapTilerOverlayStacking.POLYLINE_TILE } else { MapTilerOverlayStacking.key(OverlayKind.RasterLayer, zIndexByLayer[layerId] ?: 0) }