From 1e77bbce1cf5aa355a0eff3874334771800fd662 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Tue, 10 Nov 2026 16:16:50 -0800 Subject: [PATCH] Support bulk Circles with raster tiles --- api/android-sdk-compose.api | 3 + .../mapconductor/compose/OverlayProvider.kt | 9 +- .../compose/circle/CircleCompose.kt | 71 ++++++++ .../compose/circle/CirclesCollectionTest.kt | 169 ++++++++++++++++++ 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/mapconductor/compose/circle/CirclesCollectionTest.kt diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index a5ba4ed..10b1b6d 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -6,6 +6,7 @@ public class com.mapconductor.compose.MapViewScope { public final com.mapconductor.core.OverlayCollector getPolylineCollector(); public final kotlinx.coroutines.flow.MutableStateFlow getPolylineTilingOptions(); public final com.mapconductor.core.OverlayCollector getCircleCollector(); + public final kotlinx.coroutines.flow.MutableStateFlow getCircleTilingOptions(); public final com.mapconductor.core.OverlayCollector getPolygonCollector(); public final com.mapconductor.core.OverlayCollector getGroundImageCollector(); public final com.mapconductor.core.OverlayCollector getRasterLayerCollector(); @@ -16,6 +17,8 @@ public final class com.mapconductor.compose.OverlayProviderKt { } public final class com.mapconductor.compose.circle.CircleComposeKt { public static final void Circle(com.mapconductor.compose.MapViewScope, com.mapconductor.core.circle.CircleState, androidx.compose.runtime.Composer, int); + public static final void Circles(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void CircleTiling(com.mapconductor.compose.MapViewScope, com.mapconductor.core.circle.CircleTilingOptions, androidx.compose.runtime.Composer, int); public static final void Circle-6pqZqJ8(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoPointInterface, double, java.lang.String, boolean, boolean, long, float, long, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.compose.circle.LocalCircleCollectorKt { diff --git a/src/main/java/com/mapconductor/compose/OverlayProvider.kt b/src/main/java/com/mapconductor/compose/OverlayProvider.kt index ec599c0..37dcfbb 100644 --- a/src/main/java/com/mapconductor/compose/OverlayProvider.kt +++ b/src/main/java/com/mapconductor/compose/OverlayProvider.kt @@ -7,6 +7,7 @@ import com.mapconductor.compose.info.InfoBubbleEntry import com.mapconductor.core.OverlayCollector import com.mapconductor.core.circle.CircleOverlay import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.circle.CircleTilingOptions import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.groundimage.GroundImageOverlay import com.mapconductor.core.groundimage.GroundImageState @@ -49,6 +50,12 @@ open class MapViewScope { OverlayCollector( updateDebounce = Settings.Default.composeEventDebounce, ) + + /** + * This map's circle tiling, set by [com.mapconductor.compose.circle.CircleTiling]; + * null keeps the provider's default ([CircleTilingOptions.Default]). + */ + val circleTilingOptions = MutableStateFlow(null) val polygonCollector = OverlayCollector( updateDebounce = Settings.Default.composeEventDebounce, @@ -65,7 +72,7 @@ open class MapViewScope { fun buildRegistry(): MapOverlayRegistry { val registry = MapOverlayRegistry() registry.register(MarkerOverlay(markerCollector.flow)) - registry.register(CircleOverlay(circleCollector.flow)) + registry.register(CircleOverlay(circleCollector.flow, circleTilingOptions)) registry.register(PolylineOverlay(polylineCollector.flow, polylineTilingOptions)) registry.register(PolygonOverlay(polygonCollector.flow)) registry.register(GroundImageOverlay(groundImageCollector.flow)) diff --git a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt index 1afdaac..eb6c5c5 100644 --- a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt +++ b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt @@ -3,14 +3,23 @@ package com.mapconductor.compose.circle import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.mapconductor.compose.MapViewScope import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.copyOverlayStates +import com.mapconductor.core.overlayStateIds +import com.mapconductor.core.circle.CircleTilingOptions import com.mapconductor.core.circle.OnCircleEventHandler import com.mapconductor.core.features.GeoPointInterface import java.io.Serializable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext @Composable fun MapViewScope.Circle(state: CircleState) { @@ -26,6 +35,68 @@ fun MapViewScope.Circle(state: CircleState) { } } +/** + * Efficiently add many circles without creating one Composable per circle. + * + * The same shape as [com.mapconductor.compose.polyline.Polylines] and + * [com.mapconductor.compose.polygon.Polygons]: one effect diffs the list + * against the ids this call added last time and publishes the result in a + * single collector update. Several Circles()/Circle() can share one map. + */ +@Composable +fun MapViewScope.Circles(states: List) { + val collector = LocalCircleCollector.current + val prevIdsState = remember { mutableStateOf>(emptySet()) } + + LaunchedEffect(states) { + withContext(Dispatchers.Default) { + val prevIds = prevIdsState.value + val nextIds = overlayStateIds(states) + prevIdsState.value = nextIds + // Merge only the ids this Circles() owns, so several + // Circles()/Circle() can share one map's collector. + collector.flow.update { current -> + val next = copyOverlayStates(current) + for (id in prevIds) if (id !in nextIds) next.remove(id) + for (state in states) next[state.id] = state + next + } + } + } + + DisposableEffect(Unit) { + onDispose { + // Remove only the circles this Circles() owns on dispose. + val ownIds = prevIdsState.value + if (ownIds.isNotEmpty()) { + collector.flow.update { current -> + val next = copyOverlayStates(current) + for (id in ownIds) next.remove(id) + next + } + } + prevIdsState.value = emptySet() + } + } +} + +/** + * How this map draws many circles: as raster tiles once there are enough of + * them ([CircleTilingOptions.Default]), never ([CircleTilingOptions.Disabled]), + * or at another threshold. Without it the map uses [CircleTilingOptions.Default]. + * + * Read when the circles are handed to the map, so put it in the map's content + * before them; a change takes effect the next time they are handed over + * (recreate the map to apply it to an unchanged list). + */ +@Composable +fun MapViewScope.CircleTiling(options: CircleTilingOptions) { + val scope = this + SideEffect { + scope.circleTilingOptions.value = options + } +} + @Composable fun MapViewScope.Circle( center: GeoPointInterface, diff --git a/src/test/java/com/mapconductor/compose/circle/CirclesCollectionTest.kt b/src/test/java/com/mapconductor/compose/circle/CirclesCollectionTest.kt new file mode 100644 index 0000000..7902f61 --- /dev/null +++ b/src/test/java/com/mapconductor/compose/circle/CirclesCollectionTest.kt @@ -0,0 +1,169 @@ +package com.mapconductor.compose.circle + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.mapconductor.compose.MapViewScope +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.circle.CircleTilingOptions +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * `Circles(states)` は `Polylines(states)`・`Polygons(states)` と同じく、そのインスタンスが + * 持つ id だけを地図の collector に足し引きする。複数の Circles / Circle が同じ地図で + * 共存でき、片方を外しても他方は残る。 + * + * js-sdk-react の `Circles.test.tsx`、ios-sdk の `CirclesContentTests` と同じ内容。 + * UI を持たない Applier の上で Compose ランタイムだけを回す(InfoBubbleRegistrationTest と同じ)。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CirclesCollectionTest { + @Before + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @After + fun tearDown() = Dispatchers.resetMain() + + private class NoNodeApplier : AbstractApplier(Unit) { + override fun insertTopDown( + index: Int, + instance: Unit, + ) = Unit + + override fun insertBottomUp( + index: Int, + instance: Unit, + ) = Unit + + override fun remove( + index: Int, + count: Int, + ) = Unit + + override fun move( + from: Int, + to: Int, + count: Int, + ) = Unit + + override fun onClear() = Unit + } + + /** + * [content] を合成し、[steps] を順に実行して、各手順のあと再合成と + * Polylines の差分(Dispatchers.Default で走る)が落ち着くまで回す。 + */ + private fun compose( + content: @Composable () -> Unit, + vararg steps: () -> Unit, + ) = runBlocking { + val clock = BroadcastFrameClock() + withContext(clock + Dispatchers.Unconfined) { + val recomposer = Recomposer(coroutineContext) + val job = + launch(start = CoroutineStart.UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() + } + val composition = Composition(NoNodeApplier(), recomposer) + composition.setContent(content) + + suspend fun settle() { + repeat(10) { + Snapshot.sendApplyNotifications() + yield() + clock.sendFrame(0L) + yield() + Thread.sleep(20) + } + } + settle() + for (step in steps) { + step() + settle() + } + composition.dispose() + recomposer.close() + job.join() + } + } + + // 中心を id ごとに変える: CircleState の equals は id を見ないので、同じ円だと + // 別の一覧に差し替えても mutableStateOf が変化と見なさない。 + private fun circle(id: String): CircleState { + val d = id.hashCode() % 1000 * 1e-5 + return CircleState(center = GeoPoint.fromLatLong(42.35 + d, -71.06), radiusMeters = 10.0, id = id) + } + + @Test + fun twoCirclesShareTheMap_andRemovingOneKeepsTheOther() { + val scope = MapViewScope() + val a = listOf(circle("a1"), circle("a2")) + val b = listOf(circle("b1")) + var showB by mutableStateOf(true) + var listA by mutableStateOf(a) + val seen = mutableListOf>() + + compose( + { + CompositionLocalProvider(LocalCircleCollector provides scope.circleCollector) { + scope.Circles(listA) + if (showB) scope.Circles(b) + scope.Circle(circle("single")) + } + }, + { seen += scope.circleCollector.flow.value.keys }, + // 片方の Circles を外す: 自分の id だけ消える + { showB = false }, + { seen += scope.circleCollector.flow.value.keys }, + // 一覧を差し替える: 消えた id だけ消え、他の Circles / Circle のものは残る + { listA = listOf(circle("a2"), circle("a3")) }, + { seen += scope.circleCollector.flow.value.keys }, + ) + + assertEquals(setOf("a1", "a2", "b1", "single"), seen[0]) + assertEquals(setOf("a1", "a2", "single"), seen[1]) + assertEquals(setOf("a2", "a3", "single"), seen[2]) + } + + @Test + fun circleTilingSetsTheMapsOptions() { + val scope = MapViewScope() + var options by mutableStateOf(CircleTilingOptions.Disabled) + val seen = mutableListOf() + assertNull("指定が無ければ null(プロバイダの既定)", scope.circleTilingOptions.value) + + compose( + { + CompositionLocalProvider(LocalCircleCollector provides scope.circleCollector) { + scope.CircleTiling(options) + } + }, + { seen += scope.circleTilingOptions.value }, + { options = CircleTilingOptions(minCircleCount = 10) }, + { seen += scope.circleTilingOptions.value }, + ) + + assertEquals(listOf(CircleTilingOptions.Disabled, CircleTilingOptions(minCircleCount = 10)), seen) + } +}