commonMain.ovh.plrapps.mapcompose.ui.state.TileCanvasState.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of mapcompose-mp-desktop Show documentation
Show all versions of mapcompose-mp-desktop Show documentation
A Compose Multiplatform library to display tiled maps, with support for markers, paths, and rotation
The newest version!
package ovh.plrapps.mapcompose.ui.state
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.*
import ovh.plrapps.mapcompose.core.*
import kotlin.math.pow
/**
* This class contains all the logic related to [Tile] management.
* It defers [Tile] loading to the [TileCollector].
* All internal data manipulation are thread-confined to a single background thread. This is
* guarantied by the [scope] and its custom dispatcher.
* Ultimately, it exposes the list of tiles to render ([tilesToRender]) which is backed by a
* [MutableState]. A composable using [tilesToRender] will be automatically recomposed when this
* list changes.
*
* @author P.Laurence on 04/06/2019
*/
internal class TileCanvasState(
parentScope: CoroutineScope, tileSize: Int,
private val visibleTilesResolver: VisibleTilesResolver,
workerCount: Int, highFidelityColors: Boolean
) {
/* This view-model uses a background thread for its computations */
private val singleThreadDispatcher = newSingleThreadContext("TileCanvasThread")
@OptIn(ExperimentalCoroutinesApi::class)
private val scope = CoroutineScope(
parentScope.coroutineContext + singleThreadDispatcher
)
internal var tilesToRender: List by mutableStateOf(listOf())
private val _layerFlow = MutableStateFlow>(listOf())
internal val layerFlow = _layerFlow.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
private val pool = BitmapPool(Dispatchers.Default.limitedParallelism(1))
private val visibleTileLocationsChannel = Channel(capacity = Channel.RENDEZVOUS)
private val tilesOutput = Channel(capacity = Channel.RENDEZVOUS)
private val visibleStateFlow = MutableStateFlow(null)
internal var alphaTick = 0.07f
set(value) {
field = value.coerceIn(0.01f, 1f)
}
internal var colorFilterProvider: ColorFilterProvider? by mutableStateOf(null)
private val bitmapConfig = BitmapConfiguration(
highFidelityColors,
bytesPerPixel = if (highFidelityColors) 4 else 2
)
private val lastVisible: VisibleTiles?
get() = visibleStateFlow.value?.visibleTiles
/**
* So long as this debounced channel is offered a message, the lambda isn't called.
*/
private val idleDebounced = scope.debounce(400) {
visibleStateFlow.value?.also { (visibleTiles, layerIds, opacities) ->
evictTiles(visibleTiles, layerIds, opacities, aggressiveAttempt = true)
renderTiles(visibleTiles, layerIds)
}
}
private val renderTask = scope.throttle(wait = 34) {
/* Evict, then render */
val (lastVisible, ids, opacities) = visibleStateFlow.value ?: return@throttle
evictTiles(lastVisible, ids, opacities)
renderTiles(lastVisible, ids)
}
private fun renderTiles(visibleTiles: VisibleTiles, layerIds: List) {
/* Right before sending tiles to the view, reorder them so that tiles from current level are
* above others. */
val tilesToRenderCopy = tilesCollected.sortedBy {
val priority =
if (it.zoom == visibleTiles.level && it.subSample == visibleTiles.subSample) 100 else 0
priority + if (layerIds == it.layerIds) 1 else 0
}
tilesToRender = tilesToRenderCopy
}
private val tilesCollected = mutableListOf()
private val tileCollector: TileCollector
init {
/* Collect visible tiles and send specs to the TileCollector */
scope.launch {
collectNewTiles()
}
/* Launch the TileCollector */
tileCollector = TileCollector(workerCount.coerceAtLeast(1), bitmapConfig, tileSize)
scope.launch {
_layerFlow.collectLatest { layers ->
tileCollector.collectTiles(
tileSpecs = visibleTileLocationsChannel,
tilesOutput = tilesOutput,
layers = layers,
tilePool = pool
)
}
}
/* Launch a coroutine to consume the produced tiles */
scope.launch {
consumeTiles(tilesOutput)
}
}
fun setLayers(layers: List) {
/* If there's nothing in common with current layers, the canvas will be cleared */
val clear = layers.intersect(_layerFlow.value.toSet()).isEmpty()
_layerFlow.value = layers
if (clear) {
evictAll()
}
}
/**
* Forgets visible state and previously collected tiles.
* To clear the canvas, call [forgetTiles], then [renderThrottled].
*/
suspend fun forgetTiles() {
scope.launch {
visibleStateFlow.value = null
tilesCollected.clear()
}.join()
}
fun shutdown() {
scope.cancel()
}
suspend fun setViewport(viewport: Viewport) {
/* Thread-confine the tileResolver to the main thread */
val visibleTiles = withContext(Dispatchers.Main) {
visibleTilesResolver.getVisibleTiles(viewport)
}
withContext(scope.coroutineContext) {
setVisibleTiles(visibleTiles)
}
}
private fun setVisibleTiles(visibleTiles: VisibleTiles) {
/* Feed the tile processing machinery */
val layerIds = _layerFlow.value.map { it.id }
val opacities = _layerFlow.value.map { it.alpha }
val visibleTilesForLayers = VisibleState(visibleTiles, layerIds, opacities)
visibleStateFlow.value = visibleTilesForLayers
renderThrottled()
}
/**
* Consumes incoming visible tiles from [visibleStateFlow] and sends [TileSpec] instances to the
* [TileCollector].
*
* Leverage built-in back pressure, as this function will suspend when the tile collector is busy
* to the point it can't handshake the [visibleTileLocationsChannel] channel.
*
* Using [Flow.collectLatest], we cancel any ongoing previous tile list processing. It's
* particularly useful when the [TileCollector] is too slow, so when a new [VisibleTiles] element
* is received from [visibleStateFlow], no new [TileSpec] elements from the previous [VisibleTiles]
* element are sent to the [TileCollector]. When the [TileCollector] is ready to resume processing,
* the latest [VisibleTiles] element is processed right away.
*/
private suspend fun collectNewTiles() {
visibleStateFlow.collectLatest { visibleState ->
val visibleTiles = visibleState?.visibleTiles
if (visibleTiles != null) {
for (e in visibleTiles.tileMatrix) {
val row = e.key
val colRange = e.value
for (col in colRange) {
val alreadyProcessed = tilesCollected.any { tile ->
tile.sameSpecAs(
visibleTiles.level,
row,
col,
visibleTiles.subSample,
visibleState.layerIds,
visibleState.opacities
)
}
/* Only emit specs which haven't already been processed by the collector
* Doing this now results in less object allocations than filtering the flow
* afterwards */
if (!alreadyProcessed) {
visibleTileLocationsChannel.send(
TileSpec(
visibleTiles.level,
row,
col,
visibleTiles.subSample
)
)
}
}
}
}
}
}
/**
* For each [Tile] received, add it to the list of collected tiles if it's visible. Otherwise,
* recycle the tile.
*/
private suspend fun consumeTiles(tileChannel: ReceiveChannel) {
for (tile in tileChannel) {
val lastVisible = lastVisible
if (
(lastVisible == null || lastVisible.contains(tile))
&& !tilesCollected.contains(tile)
&& tile.layerIds == visibleStateFlow.value?.layerIds
) {
tile.prepare()
tilesCollected.add(tile)
renderThrottled()
} else {
tile.recycle()
}
fullEvictionDebounced()
}
}
private fun fullEvictionDebounced() {
idleDebounced.trySend(Unit)
}
/**
* The the alpha needs to be set to [alphaTick], to produce a fade-in effect. If [alphaTick] is
* 1f, the alpha won't be updated and there won't be any fade-in effect.
*/
private fun Tile.prepare() {
alpha = alphaTick
}
private fun VisibleTiles.contains(tile: Tile): Boolean {
if (level != tile.zoom) return false
val colRange = tileMatrix[tile.row] ?: return false
return subSample == tile.subSample && tile.col in colRange
}
private fun VisibleTiles.intersects(tile: Tile): Boolean {
return if (level == tile.zoom) {
val colRange = tileMatrix[tile.row] ?: return false
tile.col in colRange
} else {
val curMinRow = tileMatrix.keys.minOrNull() ?: return false
val curMaxRow = tileMatrix.keys.maxOrNull() ?: return false
val curMinCol = tileMatrix.entries.firstOrNull()?.value?.first ?: return false
val curMaxCol = tileMatrix.entries.firstOrNull()?.value?.last ?: return false
if (tile.zoom > level) { // User is zooming out
val dLevel = tile.zoom - level
val minRowAtLvl = curMinRow.minAtGreaterLevel(dLevel)
val maxRowAtLvl = curMaxRow.maxAtGreaterLevel(dLevel)
val minColAtLvl = curMinCol.minAtGreaterLevel(dLevel)
val maxColAtLvl = curMaxCol.maxAtGreaterLevel(dLevel)
return tile.row in minRowAtLvl..maxRowAtLvl && tile.col in minColAtLvl..maxColAtLvl
} else { // User is zooming in
val dLevel = level - tile.zoom
val minRowAtLvl = tile.row.minAtGreaterLevel(dLevel)
val maxRowAtLvl = tile.row.maxAtGreaterLevel(dLevel)
val minColAtLvl = tile.col.minAtGreaterLevel(dLevel)
val maxColAtLvl = tile.col.maxAtGreaterLevel(dLevel)
return curMinCol <= maxColAtLvl && minColAtLvl <= curMaxCol && curMinRow <= maxRowAtLvl &&
minRowAtLvl <= curMaxRow
}
}
}
/**
* Each time we get a new [VisibleTiles], remove all [Tile] from [tilesCollected] which aren't
* visible or that aren't needed anymore and put their bitmap into the pool.
*/
private fun evictTiles(
visibleTiles: VisibleTiles,
layerIds: List,
opacities: List,
aggressiveAttempt: Boolean = false
) {
val currentLevel = visibleTiles.level
val currentSubSample = visibleTiles.subSample
/* Always perform partial eviction */
partialEviction(visibleTiles, layerIds, opacities)
/* Only perform aggressive eviction when tile collector is idle */
if (aggressiveAttempt && tileCollector.isIdle) {
aggressiveEviction(currentLevel, currentSubSample, layerIds)
}
}
/**
* Evict:
* * tiles of levels different than the current one, that aren't visible,
* * tiles that aren't visible at current level, and tiles from current level which aren't made
* of current layers
*/
private fun partialEviction(
visibleTiles: VisibleTiles,
layerIds: List,
opacities: List
) {
val currentLevel = visibleTiles.level
val currentSubSample = visibleTiles.subSample
val iterator = tilesCollected.iterator()
while (iterator.hasNext()) {
val tile = iterator.next()
if (tile.zoom != currentLevel && !visibleTiles.intersects(tile)) {
iterator.remove()
tile.recycle()
continue
}
if (
tile.zoom == currentLevel
&& tile.subSample == currentSubSample
&& (!visibleTiles.contains(tile) || !shouldKeepTile(tile, layerIds, opacities))
) {
iterator.remove()
tile.recycle()
}
}
}
private fun shouldKeepTile(
tile: Tile,
layerIds: List,
opacities: List
): Boolean {
if (layerIds.isEmpty()) return false
return if (tile.layerIds != layerIds) {
layerIds.containsAll(tile.layerIds) || tile.layerIds.containsAll(layerIds)
} else {
tile.opacities == opacities
}
}
/**
* Removes tiles of other levels, even if they are visible (although they should be drawn beneath
* currently visible tiles).
* Only triggered after the [idleDebounced] fires.
*/
private fun aggressiveEviction(
currentLevel: Int,
currentSubSample: Int,
layerIds: List
) {
val iterator = tilesCollected.iterator()
while (iterator.hasNext()) {
val tile = iterator.next()
/* Remove tiles at the same level but from other layers */
if (
tile.zoom == currentLevel
&& tile.subSample == currentSubSample
&& tile.layerIds != layerIds
) {
iterator.remove()
tile.recycle()
}
/* Remove other tiles at different level and sub-sample */
if ((tile.zoom != currentLevel && tile.subSample == 0)
|| (tile.zoom == 0 && tile.subSample != currentSubSample)
) {
iterator.remove()
tile.recycle()
}
}
}
private fun evictAll() = scope.launch {
val iterator = tilesCollected.iterator()
while (iterator.hasNext()) {
val tile = iterator.next()
iterator.remove()
tile.recycle()
}
}
/**
* Post a new value to the observable. The view should update its UI.
*/
private fun renderThrottled() {
renderTask.trySend(Unit)
}
/**
* After a [Tile] is no longer visible, recycle its Bitmap and Paint if possible, for later use.
*/
private fun Tile.recycle() {
alpha = 0f
pool.put(bitmap ?: return)
}
private fun Int.minAtGreaterLevel(n: Int): Int {
return this * 2.0.pow(n).toInt()
}
private fun Int.maxAtGreaterLevel(n: Int): Int {
return (this + 1) * 2.0.pow(n).toInt() - 1
}
private data class VisibleState(
val visibleTiles: VisibleTiles,
val layerIds: List,
val opacities: List
)
}