diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index 76b5a43..a5ba4ed 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -4,6 +4,7 @@ public class com.mapconductor.compose.MapViewScope { public final kotlinx.coroutines.flow.MutableStateFlow> getBubbleFlow(); public final kotlinx.coroutines.flow.MutableStateFlow> getMarkerAnimationFlow(); public final com.mapconductor.core.OverlayCollector getPolylineCollector(); + public final kotlinx.coroutines.flow.MutableStateFlow getPolylineTilingOptions(); public final com.mapconductor.core.OverlayCollector getCircleCollector(); public final com.mapconductor.core.OverlayCollector getPolygonCollector(); public final com.mapconductor.core.OverlayCollector getGroundImageCollector(); @@ -93,6 +94,8 @@ public final class com.mapconductor.compose.polyline.LocalPolylineCollectorKt { } public final class com.mapconductor.compose.polyline.PolylineComponentKt { public static final void Polyline(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineState, androidx.compose.runtime.Composer, int); + public static final void Polylines(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void PolylineTiling(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineTilingOptions, androidx.compose.runtime.Composer, int); public static final void Polyline-bbrV0mI(com.mapconductor.compose.MapViewScope, java.util.List, java.lang.String, long, float, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void Polyline-bbrV0mI(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoRectBounds, java.lang.String, long, float, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); } diff --git a/src/main/java/com/mapconductor/compose/OverlayProvider.kt b/src/main/java/com/mapconductor/compose/OverlayProvider.kt index 29c804f..ec599c0 100644 --- a/src/main/java/com/mapconductor/compose/OverlayProvider.kt +++ b/src/main/java/com/mapconductor/compose/OverlayProvider.kt @@ -19,6 +19,7 @@ import com.mapconductor.core.polygon.PolygonOverlay import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.polyline.PolylineOverlay import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTilingOptions import com.mapconductor.core.raster.RasterLayerOverlay import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.settings.Settings @@ -38,6 +39,12 @@ open class MapViewScope { OverlayCollector( updateDebounce = Settings.Default.composeEventDebounce, ) + + /** + * This map's polyline tiling, set by [com.mapconductor.compose.polyline.PolylineTiling]; + * null keeps the provider's default ([PolylineTilingOptions.Default]). + */ + val polylineTilingOptions = MutableStateFlow(null) val circleCollector = OverlayCollector( updateDebounce = Settings.Default.composeEventDebounce, @@ -59,7 +66,7 @@ open class MapViewScope { val registry = MapOverlayRegistry() registry.register(MarkerOverlay(markerCollector.flow)) registry.register(CircleOverlay(circleCollector.flow)) - registry.register(PolylineOverlay(polylineCollector.flow)) + registry.register(PolylineOverlay(polylineCollector.flow, polylineTilingOptions)) registry.register(PolygonOverlay(polygonCollector.flow)) registry.register(GroundImageOverlay(groundImageCollector.flow)) registry.register(RasterLayerOverlay(rasterLayerCollector.flow)) diff --git a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt index ca6a3b1..d09c26d 100644 --- a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt +++ b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt @@ -3,6 +3,9 @@ package com.mapconductor.compose.polyline 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 @@ -12,7 +15,11 @@ import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.features.GeoRectBounds import com.mapconductor.core.polyline.OnPolylineEventHandler import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTilingOptions import java.io.Serializable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext @Composable fun MapViewScope.Polyline(state: PolylineState) { @@ -28,6 +35,69 @@ fun MapViewScope.Polyline(state: PolylineState) { } } +/** + * Efficiently add many polylines without creating one Composable per polyline. + * + * The same shape as [com.mapconductor.compose.polygon.Polygons] and + * [com.mapconductor.compose.marker.Markers]: one effect diffs the list against + * the ids this call added last time and publishes the result in a single + * collector update. Several Polylines()/Polyline() can share one map. + */ +@Composable +fun MapViewScope.Polylines(states: List) { + val collector = LocalPolylineCollector.current + val prevIdsState = remember { mutableStateOf>(emptySet()) } + + LaunchedEffect(states) { + withContext(Dispatchers.Default) { + val prevIds = prevIdsState.value + val nextIds = states.asSequence().map { it.id }.toSet() + val removedIds = prevIds - nextIds + prevIdsState.value = nextIds + // Merge only the ids this Polylines() owns, so several + // Polylines()/Polyline() can share one map's collector. + collector.flow.update { current -> + val next = current.toMutableMap() + for (id in removedIds) next.remove(id) + for (state in states) next[state.id] = state + next + } + } + } + + DisposableEffect(Unit) { + onDispose { + // Remove only the polylines this Polylines() owns on dispose. + val ownIds = prevIdsState.value + if (ownIds.isNotEmpty()) { + collector.flow.update { current -> + val next = current.toMutableMap() + for (id in ownIds) next.remove(id) + next + } + } + prevIdsState.value = emptySet() + } + } +} + +/** + * How this map draws many polylines: as raster tiles once there are enough + * of them ([PolylineTilingOptions.Default]), never ([PolylineTilingOptions.Disabled]), + * or at other thresholds. Without it the map uses [PolylineTilingOptions.Default]. + * + * Read when the polylines 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.PolylineTiling(options: PolylineTilingOptions) { + val scope = this + SideEffect { + scope.polylineTilingOptions.value = options + } +} + @Composable fun MapViewScope.Polyline( points: List, diff --git a/src/test/java/com/mapconductor/compose/polyline/PolylinesCollectionTest.kt b/src/test/java/com/mapconductor/compose/polyline/PolylinesCollectionTest.kt new file mode 100644 index 0000000..4ee6f69 --- /dev/null +++ b/src/test/java/com/mapconductor/compose/polyline/PolylinesCollectionTest.kt @@ -0,0 +1,172 @@ +package com.mapconductor.compose.polyline + +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.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTilingOptions +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 + +/** + * `Polylines(states)` は `Polygons(states)`・`Markers(states)` と同じく、そのインスタンスが + * 持つ id だけを地図の collector に足し引きする。複数の Polylines / Polyline が同じ地図で + * 共存でき、片方を外しても他方は残る。 + * + * js-sdk-react の `Polylines.test.tsx`、ios-sdk の `PolylinesContentTests` と同じ内容。 + * UI を持たない Applier の上で Compose ランタイムだけを回す(InfoBubbleRegistrationTest と同じ)。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PolylinesCollectionTest { + @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 ごとに変える: PolylineState の equals は id を見ないので、同じ点だと + // 別の一覧に差し替えても mutableStateOf が変化と見なさない。 + private fun line(id: String): PolylineState { + val d = id.hashCode() % 1000 * 1e-5 + return PolylineState( + points = listOf(GeoPoint.fromLatLong(42.35 + d, -71.06), GeoPoint.fromLatLong(42.36 + d, -71.05)), + id = id, + ) + } + + @Test + fun twoPolylinesShareTheMap_andRemovingOneKeepsTheOther() { + val scope = MapViewScope() + val a = listOf(line("a1"), line("a2")) + val b = listOf(line("b1")) + var showB by mutableStateOf(true) + var listA by mutableStateOf(a) + val seen = mutableListOf>() + + compose( + { + CompositionLocalProvider(LocalPolylineCollector provides scope.polylineCollector) { + scope.Polylines(listA) + if (showB) scope.Polylines(b) + scope.Polyline(line("single")) + } + }, + { seen += scope.polylineCollector.flow.value.keys }, + // 片方の Polylines を外す: 自分の id だけ消える + { showB = false }, + { seen += scope.polylineCollector.flow.value.keys }, + // 一覧を差し替える: 消えた id だけ消え、他の Polylines / Polyline のものは残る + { listA = listOf(line("a2"), line("a3")) }, + { seen += scope.polylineCollector.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 polylineTilingSetsTheMapsOptions() { + val scope = MapViewScope() + var options by mutableStateOf(PolylineTilingOptions.Disabled) + val seen = mutableListOf() + assertNull("指定が無ければ null(プロバイダの既定)", scope.polylineTilingOptions.value) + + compose( + { + CompositionLocalProvider(LocalPolylineCollector provides scope.polylineCollector) { + scope.PolylineTiling(options) + } + }, + { seen += scope.polylineTilingOptions.value }, + { options = PolylineTilingOptions(minPolylineCount = 10) }, + { seen += scope.polylineTilingOptions.value }, + ) + + assertEquals(listOf(PolylineTilingOptions.Disabled, PolylineTilingOptions(minPolylineCount = 10)), seen) + } +}