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))) + } +}