From 7ec1980916941563943e3044ce1fc97efa57bc7a Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:55:02 -0800 Subject: [PATCH] Add OverlayStacking: one bottom-to-top order for every overlay kind The user decided one stacking order for every provider on every platform, bottom to top: raster layers < ground images < polygons (native and polygon tiles) < circles < polylines < markers (native and marker tiles). zIndex orders within a band; equal keys keep the order they were handed over in. Providers had drifted (TomTom rasters on top of polygons, ground images jumping above polygons after a re-send, circles under polygons on GL providers and ArcGIS). OverlayStacking carries the band order and two tools, one per kind of native API: flatZIndex() for SDKs that compare one number across kinds (Google Maps), and insertBefore()/OverlayLayerStack for SDKs that keep an ordered list of style layers (MapLibre, Mapbox, Mappls). The layer position is computed from the style's current list each time, so it does not depend on add order, re-sends or style reloads. CircleController.zIndex goes from 3 (same as polygons) to 4. Nothing reads the controller zIndex; it is aligned with the band order so it does not say otherwise. OverlayStackingTest pins the band order, that flatZIndex never crosses a band for any Int zIndex, and that insertBefore produces the same order whatever order layers are added in. Public API: additions only. --- api/android-sdk-core.api | 27 ++++ .../core/circle/CircleController.kt | 2 +- .../core/controller/OverlayKind.kt | 5 +- .../core/controller/OverlayStacking.kt | 134 ++++++++++++++++++ .../core/controller/OverlayStackingTest.kt | 127 +++++++++++++++++ 5 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/mapconductor/core/controller/OverlayStacking.kt create mode 100644 src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index df5319a..3aabf63 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -352,6 +352,13 @@ public final class com.mapconductor.core.controller.OverlayKind extends java.lan public static com.mapconductor.core.controller.OverlayKind valueOf(java.lang.String); public static kotlin.enums.EnumEntries getEntries(); } +public final class com.mapconductor.core.controller.OverlayLayerStack { + public com.mapconductor.core.controller.OverlayLayerStack(); + public final void put(java.lang.String, com.mapconductor.core.controller.OverlayStacking$Key); + public final void remove(java.lang.String); + public final com.mapconductor.core.controller.OverlayStacking$Key keyOf(java.lang.String); + public final java.lang.String beforeId(java.util.List, com.mapconductor.core.controller.OverlayStacking$Key); +} public interface com.mapconductor.core.controller.OverlayRendererInterface { public abstract java.lang.Object onAdd(java.util.List, kotlin.coroutines.Continuation>); public abstract java.lang.Object onChange(java.util.List>, kotlin.coroutines.Continuation>); @@ -362,6 +369,26 @@ public interface com.mapconductor.core.controller.OverlayRendererInterface$Chang public abstract EntityType getCurrent(); public abstract EntityType getPrev(); } +public final class com.mapconductor.core.controller.OverlayStacking { + public static final com.mapconductor.core.controller.OverlayStacking INSTANCE; + public static final float BAND_WIDTH; + public final java.util.List getBOTTOM_TO_TOP(); + public final int band(com.mapconductor.core.controller.OverlayKind); + public final float flatZIndex(com.mapconductor.core.controller.OverlayKind, int); + public final L insertBefore(java.util.List, com.mapconductor.core.controller.OverlayStacking$Key, kotlin.jvm.functions.Function1); +} +public final class com.mapconductor.core.controller.OverlayStacking$Key implements java.lang.Comparable { + public final com.mapconductor.core.controller.OverlayKind getKind(); + public final int getZIndex(); + public int compareTo(com.mapconductor.core.controller.OverlayStacking$Key); + public final com.mapconductor.core.controller.OverlayKind component1(); + public final int component2(); + public final com.mapconductor.core.controller.OverlayStacking$Key copy(com.mapconductor.core.controller.OverlayKind, int); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public int compareTo(java.lang.Object); +} public final class com.mapconductor.core.controller.SlottedOverlayController$DefaultImpls { public static boolean has(com.mapconductor.core.controller.SlottedOverlayController, java.lang.String); public static void setClickListenerAny(com.mapconductor.core.controller.SlottedOverlayController, java.lang.Object); diff --git a/src/main/java/com/mapconductor/core/circle/CircleController.kt b/src/main/java/com/mapconductor/core/circle/CircleController.kt index 04c07e3..2d7b963 100644 --- a/src/main/java/com/mapconductor/core/circle/CircleController.kt +++ b/src/main/java/com/mapconductor/core/circle/CircleController.kt @@ -14,7 +14,7 @@ abstract class CircleController( CircleState, CircleEntityInterface, > { - override val zIndex: Int = 3 + override val zIndex: Int = 4 val semaphore = Semaphore(1) var clickListener: OnCircleEventHandler? = null diff --git a/src/main/java/com/mapconductor/core/controller/OverlayKind.kt b/src/main/java/com/mapconductor/core/controller/OverlayKind.kt index 7a2dfc5..ac0bace 100644 --- a/src/main/java/com/mapconductor/core/controller/OverlayKind.kt +++ b/src/main/java/com/mapconductor/core/controller/OverlayKind.kt @@ -9,8 +9,9 @@ package com.mapconductor.core.controller * ジェネリクスは実行時に消えるので状態の型では判別できない。かといって `Class` を * 持たせると、状態型がプロバイダ固有になったときに破綻する。種別という別の軸で持つ。 * - * **描画順(zIndex)とは別軸**であることに注意。現状 polygon(3) > groundImage(2) だが、 - * クリックの探索は groundImage が先。 + * **描画順とは別軸**であることに注意。描画は [OverlayStacking] の帯 + * (ラスター < GroundImage < ポリゴン < サークル < ポリライン < マーカー)で決まるが、 + * クリックの探索は groundImage がポリゴンより先。 */ enum class OverlayKind { Marker, diff --git a/src/main/java/com/mapconductor/core/controller/OverlayStacking.kt b/src/main/java/com/mapconductor/core/controller/OverlayStacking.kt new file mode 100644 index 0000000..0046a16 --- /dev/null +++ b/src/main/java/com/mapconductor/core/controller/OverlayStacking.kt @@ -0,0 +1,134 @@ +package com.mapconductor.core.controller + +import kotlin.math.abs + +/** + * オーバーレイの重なり順(すべてのプロバイダ・3 プラットフォーム共通の決まり)。 + * + * 下から順に: + * 1. ラスターレイヤー(RasterLayerState、アプリが足すタイル) + * 2. GroundImage + * 3. ポリゴン(ネイティブのポリゴンと、SDK のポリゴンのタイルレイヤーが同じ帯) + * 4. サークル + * 5. ポリライン + * 6. マーカー(ネイティブのマーカーと、SDK のマーカーのタイルレイヤーが同じ帯) + * + * 帯の中は zIndex の小さい順、同じ zIndex なら後から渡したものが上。 + * 追加した順・送り直した順(clearOverlays の後など)・デザイン変更の後に左右されない。 + * + * ネイティブ SDK の重なりの持ち方は 2 通りあり、それぞれに 1 つずつ道具を用意する: + * - 種類をまたいで比べられる 1 本の数値(Google Maps の zIndex など)→ [flatZIndex] + * - スタイルの層の並び(MapLibre / Mapbox などの GL 系)→ [insertBefore] + */ +object OverlayStacking { + /** 下から上への帯の並び。 */ + val BOTTOM_TO_TOP: List = + listOf( + OverlayKind.RasterLayer, + OverlayKind.GroundImage, + OverlayKind.Polygon, + OverlayKind.Circle, + OverlayKind.Polyline, + OverlayKind.Marker, + ) + + /** 帯の番号(0 が一番下)。 */ + fun band(kind: OverlayKind): Int = BOTTOM_TO_TOP.indexOf(kind) + + /** + * 帯の幅。[flatZIndex] は `band * BAND_WIDTH` を中心に、zIndex を + * `±BAND_WIDTH / 2` 未満へ押し込める。 + */ + const val BAND_WIDTH: Float = 1000f + + // zIndex が小さいうちはほぼ 1 刻みで並び、大きくなっても帯の外へ出ない。 + private const val SQUASH_SCALE = 100_000.0 + + /** + * 種類をまたいで 1 本の数値で重なりを比べる SDK 向けの値。 + * + * 帯ごとに [BAND_WIDTH] の区間を割り当て、zIndex(Int 全域)を単調に + * その区間の内側へ押し込める。どんな zIndex を渡しても下の帯の上へは出ない。 + * float の精度上、zIndex の差が非常に大きい(数十万以上)ところでは + * 隣り合う値が同じになることがある(その場合の順はネイティブ SDK 次第)。 + */ + fun flatZIndex( + kind: OverlayKind, + zIndex: Int, + ): Float { + val z = zIndex.toDouble() + val squashed = z / (abs(z) + SQUASH_SCALE) * (BAND_WIDTH / 2.0) * 0.999 + return (band(kind) * BAND_WIDTH + squashed).toFloat() + } + + /** + * 重なりの並びを決める鍵。帯 → zIndex の順で比べる。 + * 同じ鍵どうしは、後から入れたものが上([insertBefore] がそう選ぶ)。 + */ + data class Key( + val kind: OverlayKind, + val zIndex: Int = 0, + ) : Comparable { + override fun compareTo(other: Key): Int { + val b = band(kind).compareTo(band(other.kind)) + if (b != 0) return b + return zIndex.compareTo(other.zIndex) + } + } + + /** + * 層の並び(GL 系のスタイルなど)に新しい層を入れるとき、どの層の**下**に入れるか。 + * + * [layersBottomToTop] は今の並び(下から上)。[keyOf] は SDK が足した層なら鍵を、 + * ベースマップなど SDK のものでない層なら null を返す。 + * + * 鍵が [key] より大きい最初の(一番下の)SDK の層を返す。無ければ null + * (呼び出し側は一番上へ足す)。同じ鍵の層の上に入るので、同じ zIndex なら + * 後から渡したものが上になる。 + * + * 並びを持たず毎回スタイルから読むので、スタイルの読み直しや送り直しの後でも + * 結果が変わらない。 + */ + fun insertBefore( + layersBottomToTop: List, + key: Key, + keyOf: (L) -> Key?, + ): L? = + layersBottomToTop.firstOrNull { layer -> + val k = keyOf(layer) ?: return@firstOrNull false + k > key + } +} + +/** + * 層の id で重なりを持つ SDK(GL 系のスタイル)向けの台帳。 + * + * SDK が足した層の id と [OverlayStacking.Key] の対応を持ち、新しい層を入れる位置 + * (どの層の下に入れるか)を [beforeId] で返す。台帳に無い id はベースマップの層として + * 無視する。スタイルを読み直して層が消えても台帳は残るので、足し直すときも同じ位置になる。 + */ +class OverlayLayerStack { + private val keys = java.util.concurrent.ConcurrentHashMap() + + fun put( + layerId: String, + key: OverlayStacking.Key, + ) { + keys[layerId] = key + } + + fun remove(layerId: String) { + keys.remove(layerId) + } + + fun keyOf(layerId: String): OverlayStacking.Key? = keys[layerId] + + /** + * [key] の層を、今の並び [layerIdsBottomToTop](下から上)のどの層の下に入れるか。 + * null なら一番上へ足す。 + */ + fun beforeId( + layerIdsBottomToTop: List, + key: OverlayStacking.Key, + ): String? = OverlayStacking.insertBefore(layerIdsBottomToTop, key) { keys[it] } +} diff --git a/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt b/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt new file mode 100644 index 0000000..a3e21f8 --- /dev/null +++ b/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt @@ -0,0 +1,127 @@ +package com.mapconductor.core.controller + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 重なり順の決まり(ラスター < GroundImage < ポリゴン < サークル < ポリライン < マーカー)。 + * ios-sdk-core / js-sdk-core の同名テストと同じ表。 + */ +class OverlayStackingTest { + @Test + fun `帯は下からラスター・GroundImage・ポリゴン・サークル・ポリライン・マーカー`() { + assertEquals( + listOf( + OverlayKind.RasterLayer, + OverlayKind.GroundImage, + OverlayKind.Polygon, + OverlayKind.Circle, + OverlayKind.Polyline, + OverlayKind.Marker, + ), + OverlayKind.entries.sortedBy { OverlayStacking.band(it) }, + ) + } + + @Test + fun `flatZIndex はどんな zIndex でも帯をまたがない`() { + val extremes = listOf(Int.MIN_VALUE, -35_000_000, -1, 0, 1, 1000, Int.MAX_VALUE) + val bands = OverlayStacking.BOTTOM_TO_TOP + for (i in 0 until bands.size - 1) { + val lowerMax = extremes.maxOf { OverlayStacking.flatZIndex(bands[i], it) } + val upperMin = extremes.minOf { OverlayStacking.flatZIndex(bands[i + 1], it) } + assertTrue("${bands[i]} must stay below ${bands[i + 1]}", lowerMax < upperMin) + } + } + + @Test + fun `flatZIndex は帯の中で zIndex の順を保つ`() { + val zs = listOf(-1000, -10, -1, 0, 1, 2, 10, 1000) + val flat = zs.map { OverlayStacking.flatZIndex(OverlayKind.Polygon, it) } + assertEquals(flat.sorted(), flat) + assertEquals(flat.size, flat.toSet().size) + } + + private data class L( + val id: String, + val key: OverlayStacking.Key?, + ) + + private fun stack(vararg layers: L) = layers.toList() + + private fun k( + kind: OverlayKind, + z: Int = 0, + ) = OverlayStacking.Key(kind, z) + + @Test + fun `insertBefore は鍵が大きい一番下の SDK の層を選び、ベースマップの層は無視する`() { + val layers = + stack( + L("basemap-fill", null), + L("raster", k(OverlayKind.RasterLayer)), + L("basemap-labels", null), + L("polygon", k(OverlayKind.Polygon)), + L("polyline", k(OverlayKind.Polyline)), + L("markers", k(OverlayKind.Marker)), + ) + val before = OverlayStacking.insertBefore(layers, k(OverlayKind.GroundImage)) { it.key } + assertEquals("polygon", before?.id) + assertEquals( + "polyline", + OverlayStacking.insertBefore(layers, k(OverlayKind.Circle)) { it.key }?.id, + ) + assertNull(OverlayStacking.insertBefore(layers, k(OverlayKind.Marker, 5)) { it.key }) + } + + @Test + fun `同じ鍵なら後から入れたものが上、zIndex が大きいものはその上`() { + val layers = + stack( + L("pg-0a", k(OverlayKind.Polygon, 0)), + L("pg-0b", k(OverlayKind.Polygon, 0)), + L("pg-3", k(OverlayKind.Polygon, 3)), + L("circle", k(OverlayKind.Circle)), + ) + assertEquals("pg-3", OverlayStacking.insertBefore(layers, k(OverlayKind.Polygon, 0)) { it.key }?.id) + assertEquals("pg-3", OverlayStacking.insertBefore(layers, k(OverlayKind.Polygon, 1)) { it.key }?.id) + assertEquals("circle", OverlayStacking.insertBefore(layers, k(OverlayKind.Polygon, 3)) { it.key }?.id) + assertEquals("pg-0a", OverlayStacking.insertBefore(layers, k(OverlayKind.Polygon, -1)) { it.key }?.id) + } + + @Test + fun `追加の順によらず同じ並びになる`() { + val kinds = OverlayStacking.BOTTOM_TO_TOP + listOf(kinds, kinds.reversed(), listOf(kinds[3], kinds[0], kinds[5], kinds[1], kinds[4], kinds[2])).forEach { order -> + val layers = mutableListOf() + order.forEach { kind -> + val key = k(kind) + val before = OverlayStacking.insertBefore(layers, key) { it.key } + val at = if (before == null) layers.size else layers.indexOf(before) + layers.add(at, L(kind.name, key)) + } + assertEquals(kinds.map { it.name }, layers.map { it.id }) + } + } +} + +class OverlayLayerStackTest { + @Test + fun `台帳に無い層は無視し、スタイルを読み直しても同じ位置を返す`() { + val stack = OverlayLayerStack() + stack.put("raster-layer-A", OverlayStacking.Key(OverlayKind.RasterLayer)) + stack.put("polygon-fill-layer-0", OverlayStacking.Key(OverlayKind.Polygon, 0)) + stack.put("markers-layer", OverlayStacking.Key(OverlayKind.Marker)) + val layers = listOf("water", "raster-layer-A", "road-label", "polygon-fill-layer-0", "markers-layer") + assertEquals( + "polygon-fill-layer-0", + stack.beforeId(layers, OverlayStacking.Key(OverlayKind.GroundImage)), + ) + // 読み直し直後(SDK の層がまだ何も無い)なら一番上。 + assertNull(stack.beforeId(listOf("water", "road-label"), OverlayStacking.Key(OverlayKind.GroundImage))) + stack.remove("markers-layer") + assertNull(stack.beforeId(layers, OverlayStacking.Key(OverlayKind.Polyline))) + } +}