diff --git a/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt b/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt index 7c2c58d..c9e6af7 100644 --- a/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt +++ b/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt @@ -14,8 +14,6 @@ data class MapTile( val image: ImageBitmap, ) - - interface MapTileProvider { suspend fun loadTile(id: TileId): MapTile fun toIndex(d: Double): Int = floor(d / DEFAULT_TILE_SIZE).toInt() diff --git a/src/jvmMain/kotlin/Main.kt b/src/jvmMain/kotlin/Main.kt index 1946886..7a04a8f 100644 --- a/src/jvmMain/kotlin/Main.kt +++ b/src/jvmMain/kotlin/Main.kt @@ -13,32 +13,58 @@ import centre.sciprog.maps.MapViewPoint import centre.sciprog.maps.compose.* import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO +import kotlinx.coroutines.delay import java.nio.file.Path +import kotlin.random.Random + +/** + * initial set of features + */ +@Composable +private fun initialFeatures() = buildList { + val pointOne = 55.568548 to 37.568604 + val pointTwo = 55.929444 to 37.518434 + add(MapVectorImageFeature(pointOne.toCoordinates(), Icons.Filled.Home)) +// add(MapCircleFeature(pointOne)) + add(MapCircleFeature(pointTwo)) + add(MapLineFeature(pointOne, pointTwo)) + add(MapTextFeature(pointOne.toCoordinates(), "Home")) +} + @Composable @Preview fun App() { MaterialTheme { - val viewPoint = MapViewPoint( - GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173), - 6.0 - ) - val pointOne = 55.568548 to 37.568604 - val pointTwo = 55.929444 to 37.518434 - val features = buildList { -// add(MapCircleFeature(pointOne)) - add(MapCircleFeature(pointTwo)) - add(MapLineFeature(pointOne, pointTwo)) - add(MapTextFeature(pointOne.toCoordinates(), "Home")) - add(MapVectorImageFeature(pointOne.toCoordinates(), Icons.Filled.Home)) + //create a view point + val viewPoint = remember { + MapViewPoint( + GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173), + 6.0 + ) } + // observable list of features + val features = mutableStateListOf().apply { + addAll(initialFeatures()) + } + +// // test dynamic rendering +// LaunchedEffect(features) { +// repeat(10000) { +// delay(10) +// val randomPoint = Random.nextDouble(55.568548, 55.929444) to Random.nextDouble(37.518434, 37.568604) +// features.add(MapCircleFeature(randomPoint)) +// } +// } + val scope = rememberCoroutineScope() val mapTileProvider = remember { OpenStreetMapTileProvider(scope, HttpClient(CIO), Path.of("mapCache")) } var coordinates by remember { mutableStateOf(null) } Column { + //display click coordinates Text(coordinates?.toString() ?: "") MapView(viewPoint, mapTileProvider, features = features) { coordinates = it diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt index fd6fac9..7a83fe8 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt @@ -36,7 +36,6 @@ private val logger = KotlinLogging.logger("MapView") /** * A component that renders map and provides basic map manipulation capabilities */ -@OptIn(ExperimentalComposeUiApi::class) @Composable actual fun MapView( initialViewPoint: MapViewPoint, @@ -58,8 +57,6 @@ actual fun MapView( // Load tiles asynchronously LaunchedEffect(viewPoint, canvasSize) { - //remember zoom state to avoid races - val z = zoom val left = centerCoordinates.x - canvasSize.width / 2 val right = centerCoordinates.x + canvasSize.width / 2 val horizontalIndices = mapTileProvider.toIndex(left)..mapTileProvider.toIndex(right) @@ -70,14 +67,18 @@ actual fun MapView( mapTiles.clear() - val indexRange = 0 until 2.0.pow(z).toInt() + val indexRange = 0 until 2.0.pow(zoom).toInt() for (j in verticalIndices) { for (i in horizontalIndices) { - if (z == zoom && i in indexRange && j in indexRange) { - val tileId = TileId(z, i, j) - val tile = mapTileProvider.loadTile(tileId) - mapTiles.add(tile) + if (i in indexRange && j in indexRange) { + val tileId = TileId(zoom, i, j) + try { + val tile = mapTileProvider.loadTile(tileId) + mapTiles.add(tile) + } catch (ex: Exception) { + logger.error(ex) { "Failed to load tile $tileId" } + } } } } @@ -99,6 +100,7 @@ actual fun MapView( fun GeodeticMapCoordinates.toOffset(): Offset = WebMercatorProjection.toMercator(this, zoom).toOffset() + @OptIn(ExperimentalComposeUiApi::class) val canvasModifier = modifier.onPointerEvent(PointerEventType.Press) { onClick(it.changes.first().position.toGeodetic()) }.onPointerEvent(PointerEventType.Scroll) { diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt index 2972e04..439c0c6 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt @@ -5,15 +5,13 @@ import androidx.compose.ui.graphics.toComposeImageBitmap import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.readBytes -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async +import kotlinx.coroutines.* import mu.KotlinLogging import org.jetbrains.skia.Image import java.net.URL import java.nio.file.Path import kotlin.io.path.* +import kotlin.math.pow /** * A [MapTileProvider] based on Open Street Map API. With in-memory and file cache @@ -25,6 +23,9 @@ public class OpenStreetMapTileProvider(private val scope: CoroutineScope, privat private fun TileId.cacheFilePath() = cacheDirectory.resolve("${zoom}/${i}/${j}.png") + /** + * Download and cache the tile image + */ private fun downloadImageAsync(id: TileId) = scope.async(Dispatchers.IO) { id.cacheFilePath()?.let { path -> if (path.exists()) { @@ -53,6 +54,11 @@ public class OpenStreetMapTileProvider(private val scope: CoroutineScope, privat } override suspend fun loadTile(id: TileId): MapTile { + val indexRange = indexRange(id.zoom) + if(id.i !in indexRange || id.j !in indexRange){ + error("Indices (${id.i}, ${id.j}) are not in index range $indexRange for zoom ${id.zoom}") + } + val image = cache.getOrPut(id) { downloadImageAsync(id) }.await() @@ -62,5 +68,6 @@ public class OpenStreetMapTileProvider(private val scope: CoroutineScope, privat companion object{ private val logger = KotlinLogging.logger("OpenStreetMapCache") + private fun indexRange(zoom: Int): IntRange = 0 until 2.0.pow(zoom).toInt() } } \ No newline at end of file