package center.sciprog.maps.compose import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import center.sciprog.maps.coordinates.Gmc import center.sciprog.maps.features.* import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import org.jetbrains.skia.Image import kotlin.math.ceil import kotlin.math.pow private fun IntRange.intersect(other: IntRange) = kotlin.math.max(first, other.first)..kotlin.math.min(last, other.last) private val logger = KotlinLogging.logger("MapView") /** * A component that renders map and provides basic map manipulation capabilities */ @Composable public fun MapView( mapState: MapCanvasState, mapTileProvider: MapTileProvider, features: FeatureGroup<Gmc>, modifier: Modifier, ) { val mapTiles = remember(mapTileProvider) { mutableStateMapOf<TileId, Image>() } with(mapState) { // Load tiles asynchronously LaunchedEffect(viewPoint, canvasSize) { with(mapTileProvider) { val indexRange = 0 until 2.0.pow(intZoom).toInt() val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange) val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale) val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange) for (j in verticalIndices) { for (i in horizontalIndices) { val id = TileId(intZoom, i, j) //ensure that failed tiles do not fail the application supervisorScope { //start all val deferred = loadTileAsync(id) //wait asynchronously for it to finish launch { try { val tile = deferred.await() mapTiles[tile.id] = tile.image } catch (ex: Exception) { //displaying the error is maps responsibility if(ex !is CancellationException) { logger.error(ex) { "Failed to load tile with id=$id" } } } } } mapTiles.keys.filter { it.zoom != intZoom || it.j !in verticalIndices || it.i !in horizontalIndices }.forEach { mapTiles.remove(it) } } } } } } FeatureCanvas(mapState, features, modifier = modifier.canvasControls(mapState, features)) { val tileScale = mapState.tileScale clipRect { val tileSize = IntSize( ceil((mapTileProvider.tileSize.dp * tileScale).toPx()).toInt(), ceil((mapTileProvider.tileSize.dp * tileScale).toPx()).toInt() ) mapTiles.forEach { (id, image) -> //converting back from tile index to screen offset val offset = IntOffset( (mapState.canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - mapState.centerCoordinates.x.dp) * tileScale).roundToPx(), (mapState.canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - mapState.centerCoordinates.y.dp) * tileScale).roundToPx() ) drawImage( image = image.toComposeImageBitmap(), dstOffset = offset, dstSize = tileSize ) } } } } /** * Create a [MapView] with given [features] group. */ @Composable public fun MapView( mapTileProvider: MapTileProvider, config: ViewConfig<Gmc>, features: FeatureGroup<Gmc>, initialViewPoint: ViewPoint<Gmc>? = null, initialRectangle: Rectangle<Gmc>? = null, modifier: Modifier, ) { val mapState = MapCanvasState.remember(mapTileProvider, config, initialViewPoint, initialRectangle) MapView(mapState, mapTileProvider, features, modifier) } /** * Draw a map using convenient parameters. If neither [initialViewPoint], noe [initialRectangle] is defined, * use map features to infer the view region. * @param initialViewPoint The view point of the map using center and zoom. Is used if provided * @param initialRectangle The rectangle to be used for view point computation. Used if [initialViewPoint] is not defined. * @param buildFeatures - a builder for features */ @Composable public fun MapView( mapTileProvider: MapTileProvider, config: ViewConfig<Gmc> = ViewConfig(), initialViewPoint: ViewPoint<Gmc>? = null, initialRectangle: Rectangle<Gmc>? = null, modifier: Modifier = Modifier.fillMaxSize(), buildFeatures: FeatureGroup<Gmc>.() -> Unit = {}, ) { val featureState = FeatureGroup.remember(WebMercatorSpace, buildFeatures) MapView(mapTileProvider, config, featureState, initialViewPoint, initialRectangle, modifier) }