diff --git a/examples/imageviewer/mapview-desktop/README.md b/examples/imageviewer/mapview-desktop/README.md new file mode 100644 index 0000000000..a7804fc3e4 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/README.md @@ -0,0 +1,2 @@ +### Basic implementation of MapView with OpenStreetMap + - For usage in your project, please read this policies: https://operations.osmfoundation.org/policies/ diff --git a/examples/imageviewer/mapview-desktop/build.gradle.kts b/examples/imageviewer/mapview-desktop/build.gradle.kts new file mode 100644 index 0000000000..e942c7baea --- /dev/null +++ b/examples/imageviewer/mapview-desktop/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") + kotlin("plugin.serialization") +} + +version = "1.0-SNAPSHOT" + +kotlin { + jvm("desktop") + + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + } + } + val desktopMain by getting { + dependencies { + implementation("io.ktor:ktor-client-cio:2.2.1") + implementation(compose.desktop.common) + } + } + } +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/CalcTiles.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/CalcTiles.kt new file mode 100644 index 0000000000..79eaab52cc --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/CalcTiles.kt @@ -0,0 +1,51 @@ +package example.map + +import kotlin.math.ceil +import kotlin.math.log2 +import kotlin.math.roundToInt + +fun InternalMapState.calcTiles(): List { + fun geoLengthToDisplay(geoLength: Double): Int { + return (height * geoLength * scale).toInt() + } + + val zoom: Int = minOf( + Config.MAX_ZOOM, + maxOf( + Config.MIN_ZOOM, + ceil(log2(geoLengthToDisplay(1.0) / TILE_SIZE.toDouble())).roundToInt() - Config.FONT_LEVEL + ) + ) + val maxTileIndex: Int = fastPow2ForPositiveInt(zoom) + val tileSize: Int = geoLengthToDisplay(1.0) / maxTileIndex + 1 + val minCol = (topLeft.x * maxTileIndex).toInt() + val minRow = (topLeft.y * maxTileIndex).toInt() + + fun geoXToDisplay(x: Double): Int = geoLengthToDisplay(x - topLeft.x) + fun geoYToDisplay(y: Double): Int = geoLengthToDisplay(y - topLeft.y) + + val tiles: List = buildList { + for (col in minCol until Int.MAX_VALUE) { + val geoX = col.toDouble() / maxTileIndex + val displayX = geoXToDisplay(geoX) + if (displayX >= width) { + break + } + for (row in minRow until Int.MAX_VALUE) { + val geoY = row.toDouble() / maxTileIndex + val displayY = geoYToDisplay(geoY) + if (displayY >= height) { + break + } + val tile = Tile(zoom, col % maxTileIndex, row % maxTileIndex) + add( + DisplayTileAndTile( + DisplayTile(tileSize, displayX, displayY), + tile + ) + ) + } + } + } + return tiles +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Config.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Config.kt new file mode 100644 index 0000000000..f1fde1b6bf --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Config.kt @@ -0,0 +1,71 @@ +package example.map + +val TILE_SIZE = 256 + +object Config { + /** + * Link to OpenStreetMap licensee + */ + val OPENSTREET_MAP_LICENSE: String = "https://wiki.openstreetmap.org/wiki/OpenStreetMap_License" + + /** + * Link to OpenStreetMap policy + */ + val OPENSTREET_MAP_POLICY: String = "https://operations.osmfoundation.org/policies/" + + /** + * Click duration. If duration is bigger, zoom will no happens. + */ + val CLICK_DURATION_MS: Long = 300 + + /** + * Click area with pointer to map. If pointer drags more, then map moves. + */ + val CLICK_AREA_RADIUS_PX: Int = 7 + + /** + * Zoom on click to map + */ + val ZOOM_ON_CLICK = 0.8 + + /** + * Max scale on zoom event (like scroll) + */ + val MAX_SCALE_ON_SINGLE_ZOOM_EVENT = 2.0 + + /** + * Name of temporary directory + */ + val CACHE_DIR_NAME = "map-view-cache" + + /** + * Sensitivity of scroll physics to zoom map + */ + val SCROLL_SENSITIVITY_DESKTOP = 0.05 + + /** + * Minimal available zoom + */ + val MIN_ZOOM = 0 + + /** + * Maximum available zoom + */ + val MAX_ZOOM = 22 + + /** + * How big text should be on map + */ + val FONT_LEVEL = 2 + + fun createTileUrl(tile: Tile): String = + with(tile) { + "https://tile.openstreetmap.org/$zoom/$x/$y.png" + } +} + +data class Tile( + val zoom: Int, + val x: Int, + val y: Int +) diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepository.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepository.kt new file mode 100644 index 0000000000..363534c803 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepository.kt @@ -0,0 +1,5 @@ +package example.map + +interface ContentRepository { + suspend fun loadContent(key: K): T +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepositoryAdapter.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepositoryAdapter.kt new file mode 100644 index 0000000000..9d2b7ad9c2 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepositoryAdapter.kt @@ -0,0 +1,10 @@ +package example.map + +fun ContentRepository.adapter(transform: (A) -> B): ContentRepository { + val origin = this + return object : ContentRepository { + override suspend fun loadContent(key: K): B { + return transform(origin.loadContent(key)) + } + } +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/CreateTilesRepository.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/CreateTilesRepository.kt new file mode 100644 index 0000000000..6c1140661f --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/CreateTilesRepository.kt @@ -0,0 +1,30 @@ +package example.map + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import java.io.File +import kotlin.coroutines.CoroutineContext + +@Composable +internal fun rememberTilesRepository( + userAgent: String, + ioScope: CoroutineScope +): ContentRepository = remember { + val cacheDir = File(System.getProperty("java.io.tmpdir")).resolve(Config.CACHE_DIR_NAME) + createRealRepository(HttpClient(CIO) { + install(UserAgent) { + agent = userAgent + } + }) + .decorateWithLimitRequestsInParallel(ioScope) + .decorateWithDiskCache(ioScope, cacheDir) + .adapter { TileImage(it.toImageBitmap()) } + .decorateWithDistinctDownloader(ioScope) +} + +internal fun getDispatcherIO(): CoroutineContext = Dispatchers.Default diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/DecorateWithLimitRequestsInParallel.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/DecorateWithLimitRequestsInParallel.kt new file mode 100644 index 0000000000..07897eef76 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/DecorateWithLimitRequestsInParallel.kt @@ -0,0 +1,118 @@ +package example.map + +import example.map.collection.ImmutableCollection +import example.map.collection.createStack +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +fun ContentRepository.decorateWithLimitRequestsInParallel( + scope: CoroutineScope, + maxParallelRequests: Int = 2, // Policy: https://operations.osmfoundation.org/policies/tiles/ + waitBufferCapacity: Int = 10, + delayBeforeRequestMs: Long = 50 +): ContentRepository { + val origin = this + + data class State( + val stack: ImmutableCollection> = createStack(waitBufferCapacity), + val currentRequests: Int = 0 + ) + + val store = scope.createStoreWithSideEffect( + init = State(), + effectHandler = { store, effect: NetworkSideEffect -> + when (effect) { + is NetworkSideEffect.Load -> { + effect.waitElements.forEach { element -> + scope.launch { + try { + val result = origin.loadContent(element.key) + element.deferred.complete(result) + } catch (t: Throwable) { + val message = + "caught exception in decorateWithLimitRequestsInParallel" + element.deferred.completeExceptionally(Exception(message, t)) + } finally { + store.send(Intent.ElementComplete()) + } + } + } + } + + is NetworkSideEffect.Delay -> { + scope.launch { + delay(delayBeforeRequestMs) + store.send(Intent.AfterDelay()) + } + } + } + } + ) { state, intent: Intent -> + when (intent) { + is Intent.NewElement -> { + val (fifo, removed) = state.stack.add(intent.wait) + removed?.let { + scope.launch { + // cancel element callback, because hi will wait too long + it.deferred.completeExceptionally(Exception("cancelled in decorateWithLimitRequestsInParallel")) + } + } + state.copy(stack = fifo).addSideEffect(NetworkSideEffect.Delay()) + } + + is Intent.AfterDelay -> { + if (state.stack.isNotEmpty()) { + var fifo = state.stack + val elementsToLoad: MutableList> = mutableListOf() + while (state.currentRequests + elementsToLoad.size < maxParallelRequests && fifo.isNotEmpty()) { + val result = fifo.remove() + result.removed?.let { + elementsToLoad.add(it) + } + fifo = result.collection + } + state.copy( + stack = fifo, + currentRequests = state.currentRequests + elementsToLoad.size + ).addSideEffect(NetworkSideEffect.Load(elementsToLoad)) + } else { + state.noSideEffects() + } + } + + is Intent.ElementComplete -> { + state.copy( + currentRequests = state.currentRequests - 1 + ).run { + if (state.stack.isNotEmpty()) { + addSideEffect(NetworkSideEffect.Delay()) + } else { + noSideEffects() + } + } + } + } + } + + return object : ContentRepository { + override suspend fun loadContent(key: K): T { + return CompletableDeferred() + .also { store.send(Intent.NewElement(ElementWait(key, it))) } + .await() + } + } +} + +private class ElementWait(val key: K, val deferred: CompletableDeferred) +private sealed interface Intent { + class ElementComplete : Intent + class NewElement(val wait: ElementWait) : Intent + class AfterDelay : Intent +} + +private sealed interface NetworkSideEffect { + class Load(val waitElements: List>) : NetworkSideEffect + class Delay : NetworkSideEffect +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/GeoPoint.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/GeoPoint.kt new file mode 100644 index 0000000000..a48f7a8f53 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/GeoPoint.kt @@ -0,0 +1,48 @@ +package example.map + +import kotlin.math.* + +/** + * GeoPoint in relative geo coordinated + * x in range 0f to 1f (equals to longitude -180 .. 180) + * y in range 0 to 1 (equals to latitude 90 .. -90) + */ +data class GeoPoint(val x: Double, val y: Double) + +/** + * DisplayPoint screen coordinates (Also it may be used as distance between 2 screen point) + */ +data class DisplayPoint(val x: Int, val y: Int) + +val GeoPoint.longitude get(): Double = x * 360.0 - 180.0 +val GeoPoint.latitude + get(): Double { + val latRad = atan(sinh(PI * (1 - 2 * y))) + return latRad / PI * 180.0 + } + +fun createGeoPt(latitude: Double, longitude: Double): GeoPoint { + val x = (longitude + 180) / 360 + val y = (1 - ln(tan(latitude.toRad()) + 1 / cos(latitude.toRad())) / PI) / 2 + return GeoPoint(x, y) +} + +fun Double.toRad() = this * PI / 180 + +operator fun DisplayPoint.minus(other: DisplayPoint): DisplayPoint = + DisplayPoint(this.x - other.x, this.y - other.y) + +@Suppress("unused") +fun DisplayPoint.distanceTo(other: DisplayPoint): Double { + val dx = other.x - x + val dy = other.y - y + return sqrt(dx * dx.toDouble() + dy * dy.toDouble()) +} + +operator fun GeoPoint.minus(minus: GeoPoint): GeoPoint { + return GeoPoint(x - minus.x, y - minus.y) +} + +operator fun GeoPoint.plus(other: GeoPoint): GeoPoint { + return GeoPoint(x + other.x, y + other.y) +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ImageUtils.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ImageUtils.kt new file mode 100644 index 0000000000..97504180d6 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ImageUtils.kt @@ -0,0 +1,8 @@ +package example.map + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image + +fun ByteArray.toImageBitmap(): ImageBitmap = Image.makeFromEncoded(this).toComposeImageBitmap() +fun TileImage.extract(): ImageBitmap = platformSpecificData diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/InternalMapState.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/InternalMapState.kt new file mode 100644 index 0000000000..55b37736ef --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/InternalMapState.kt @@ -0,0 +1,50 @@ +package example.map + +data class InternalMapState( + val width: Int = 100, + val height: Int = 100, + val scale: Double = 1.0, + val topLeft: GeoPoint = GeoPoint(0.0, 0.0), +) + +data class DisplayTileWithImage( + val displayTile: DisplayTile, + val image: T?, + val tile: Tile, +) + +data class DisplayTile( + val size: Int, + val x: Int, + val y: Int +) + +data class DisplayTileAndTile( + val display: DisplayTile, + val tile: Tile +) + +val InternalMapState.centerGeo get(): GeoPoint = displayToGeo(DisplayPoint(width / 2, height / 2)) +fun InternalMapState.copyAndChangeCenter(targetCenter: GeoPoint): InternalMapState = + copy( + topLeft = topLeft + targetCenter - centerGeo + ).correctGeoXY() + +fun InternalMapState.correctGeoXY(): InternalMapState = + correctGeoX().correctGeoY() + +fun InternalMapState.correctGeoY(): InternalMapState { + val minGeoY = 0.0 + val maxGeoY: Double = 1 - 1 / scale + return if (topLeft.y < minGeoY) { + copy(topLeft = topLeft.copy(y = minGeoY)) + } else if (topLeft.y > maxGeoY) { + copy(topLeft = topLeft.copy(y = maxGeoY)) + } else { + this + } +} + +fun InternalMapState.correctGeoX(): InternalMapState = + copy(topLeft = topLeft.copy(x = topLeft.x.mod(1.0))) + diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/LibStore.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/LibStore.kt new file mode 100644 index 0000000000..a58e73c6ed --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/LibStore.kt @@ -0,0 +1,87 @@ +package example.map + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch + +typealias Reducer = suspend (STATE, INTENT) -> STATE + +interface Store { + fun send(intent: INTENT) + val stateFlow: StateFlow + val state get() = stateFlow.value +} + +fun CoroutineScope.createStore( + init: STATE, + reducer: Reducer +): Store { + val mutableStateFlow = MutableStateFlow(init) + val channel: Channel = Channel(Channel.UNLIMITED) + + return object : Store { + init { + launch { + channel.consumeAsFlow().collect { intent -> + mutableStateFlow.value = reducer(mutableStateFlow.value, intent) + } + } + } + + override fun send(intent: INTENT) { + launch { + channel.send(intent) + } + } + + override val stateFlow: StateFlow = mutableStateFlow + } +} + +typealias ReducerSE = suspend (STATE, INTENT) -> ReducerResult + +data class ReducerResult( + val state: STATE, + val sideEffects: List = emptyList() +) + +fun CoroutineScope.createStoreWithSideEffect( + init: STATE, + effectHandler: (store: Store, sideEffect: EFFECT) -> Unit, + reducer: ReducerSE +): Store { + lateinit var store: Store + store = createStore(init) { state, intent -> + val result = reducer(state, intent) + + result.sideEffects.forEach { + effectHandler(store, it) + } + + result.state + } + return store +} + +fun STATE.noSideEffects() = ReducerResult(this, emptyList()) +fun STATE.addSideEffects(sideEffects: List) = + ReducerResult(this, sideEffects) + +fun STATE.addSideEffect(effect: EFFECT) = addSideEffects(listOf(effect)) + +fun StateFlow.mapStateFlow( + scope: CoroutineScope, + init: R, + transform: suspend (T) -> R +): StateFlow { + val result = MutableStateFlow(init) + scope.launch { + collect { + result.value = transform(it) + } + } + return result +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/MapStateExt.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/MapStateExt.kt new file mode 100644 index 0000000000..1ab7f2cd9e --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/MapStateExt.kt @@ -0,0 +1,54 @@ +package example.map + +fun InternalMapState.geoLengthToDisplay(geoLength: Double): Int { + return (height * geoLength * scale).toInt() +} + +fun InternalMapState.geoXToDisplay(x: Double): Int = geoLengthToDisplay(x - topLeft.x) +fun InternalMapState.geoYToDisplay(y: Double): Int = geoLengthToDisplay(y - topLeft.y) + +@Suppress("unused") +fun InternalMapState.geoToDisplay(geoPt: GeoPoint): DisplayPoint = + DisplayPoint(geoXToDisplay(geoPt.x), geoYToDisplay(geoPt.y)) + +fun InternalMapState.displayLengthToGeo(displayLength: Int): Double = + displayLength / (scale * height) + +fun InternalMapState.displayLengthToGeo(pt: DisplayPoint): GeoPoint = + GeoPoint(displayLengthToGeo(pt.x), displayLengthToGeo(pt.y)) + +fun InternalMapState.displayToGeo(displayPt: DisplayPoint): GeoPoint { + val x1 = displayLengthToGeo((displayPt.x)) + val y1 = displayLengthToGeo((displayPt.y)) + return topLeft + GeoPoint(x1, y1) +} + +@Suppress("unused") +val InternalMapState.minScale get(): Double = 1.0 + +val InternalMapState.maxScale + get(): Double = + (TILE_SIZE.toDouble() / height) * fastPow2ForPositiveInt(Config.MAX_ZOOM) + +internal fun fastPow2ForPositiveInt(x: Int): Int { + if (x < 0) { + return 0 + } + return 1 shl x +} + +fun InternalMapState.zoom(zoomCenter: DisplayPoint?, change: Double): InternalMapState { + val state = this + val pt = zoomCenter ?: DisplayPoint(state.width / 2, state.height / 2) + var multiply = (1 + change) + if (multiply < 1 / Config.MAX_SCALE_ON_SINGLE_ZOOM_EVENT) { + multiply = 1 / Config.MAX_SCALE_ON_SINGLE_ZOOM_EVENT + } else if (multiply > Config.MAX_SCALE_ON_SINGLE_ZOOM_EVENT) { + multiply = Config.MAX_SCALE_ON_SINGLE_ZOOM_EVENT + } + var scale = state.scale * multiply + scale = scale.coerceIn(state.minScale..state.maxScale) + val scaledState = state.copy(scale = scale) + val geoDelta = state.displayToGeo(pt) - scaledState.displayToGeo(pt) + return scaledState.copy(topLeft = scaledState.topLeft + geoDelta).correctGeoXY() +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/MapView.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/MapView.kt new file mode 100644 index 0000000000..cc6540721f --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/MapView.kt @@ -0,0 +1,259 @@ +package example.map + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import java.awt.Desktop +import java.net.URL + +data class MapState( + val latitude: Double, + val longitude: Double, + val scale: Double, +) + +/** + * MapView to display Earth tile maps. API provided by OpenStreetMap. + * + * @param modifier to specify size strategy for this composable + * + * @param latitude initial Latitude of map center. + * Available values between [-90.0 (South) .. 90.0 (North)] + * + * @param longitude initial Longitude of map center + * Available values between [-180.0 (Left) .. 180.0 (Right)] + * + * @param startScale initial scale + * (value around 1.0 = entire Earth view), + * (value around 30.0 = Countries), + * (value around 150.0 = Cities), + * (value around 40000.0 = Street's) + * + * @param state state for Advanced usage + * You may to configure your own state and control it. + * + * @param onStateChange state change handler for Advanced usage + * You may override change state behaviour in your app + * + * @param onMapViewClick handle click event with point coordinates (latitude, longitude) + * return true to enable zoom on click + * return false to disable zoom on click + * + * @param consumeScroll consume scroll events for disable parent scrolling + */ +@Composable +fun MapView( + modifier: Modifier, + userAgent: String, + latitude: Double? = null, + longitude: Double? = null, + startScale: Double? = null, + state: State = remember { + mutableStateOf(MapState(latitude ?: 0.0, longitude ?: 0.0, startScale ?: 1.0)) + }, + onStateChange: (MapState) -> Unit = { (state as? MutableState)?.value = it }, + onMapViewClick: (latitude: Double, longitude: Double) -> Boolean = { _, _ -> true }, + consumeScroll: Boolean = true, +) { + val viewScope = rememberCoroutineScope() + val ioScope = remember { + CoroutineScope(SupervisorJob(viewScope.coroutineContext.job) + getDispatcherIO()) + } + val imageRepository = rememberTilesRepository(userAgent, ioScope) + + var width: Int by remember { mutableStateOf(100) } + var height: Int by remember { mutableStateOf(100) } + val internalState: InternalMapState by derivedStateOf { + val center = createGeoPt(state.value.latitude, state.value.longitude) + InternalMapState(width, height, state.value.scale) + .copyAndChangeCenter(center) + } + val displayTiles: List> by derivedStateOf { + val calcTiles: List = internalState.calcTiles() + val tilesToDisplay: MutableList> = mutableListOf() + val tilesToLoad: MutableSet = mutableSetOf() + calcTiles.forEach { + val cachedImage = inMemoryCache[it.tile] + if (cachedImage != null) { + tilesToDisplay.add(DisplayTileWithImage(it.display, cachedImage, it.tile)) + } else { + tilesToLoad.add(it.tile) + val croppedImage = inMemoryCache.searchOrCrop(it.tile) + tilesToDisplay.add(DisplayTileWithImage(it.display, croppedImage, it.tile)) + } + } + viewScope.launch { + tilesToLoad.forEach { tile -> + try { + val image: TileImage = imageRepository.loadContent(tile) + inMemoryCache = inMemoryCache + (tile to image) + } catch (t: Throwable) { + println("exception in tiles loading, throwable: $t") + // ignore errors. Tile image loaded with retries + } + } + } + tilesToDisplay + } + + val onZoom = { pt: DisplayPoint?, change: Double -> + onStateChange(internalState.zoom(pt, change).toExternalState()) + } + val onClick = { pt: DisplayPoint -> + val geoPoint = internalState.displayToGeo(pt) + if (onMapViewClick(geoPoint.latitude, geoPoint.longitude)) { + onStateChange(internalState.zoom(pt, Config.ZOOM_ON_CLICK).toExternalState()) + } + } + val onMove = { dx: Int, dy: Int -> + val topLeft = + internalState.topLeft + internalState.displayLengthToGeo(DisplayPoint(-dx, -dy)) + onStateChange(internalState.copy(topLeft = topLeft).correctGeoXY().toExternalState()) + } + var previousMoveDownPos by remember> { mutableStateOf(null) } + var previousPressTime by remember { mutableStateOf(0L) } + var previousPressPos by remember> { mutableStateOf(null) } + fun Modifier.applyPointerInput() = pointerInput(Unit) { + while (true) { + val event = awaitPointerEventScope { + awaitPointerEvent() + } + val current = event.changes.firstOrNull()?.position + if (event.type == PointerEventType.Scroll) { + val scrollY: Float? = event.changes.firstOrNull()?.scrollDelta?.y + if (scrollY != null && scrollY != 0f) { + onZoom(current?.toPt(), -scrollY * Config.SCROLL_SENSITIVITY_DESKTOP) + } + if (consumeScroll) { + event.changes.forEach { + it.consume() + } + } + } + when (event.type) { + PointerEventType.Move -> { + if (event.buttons.isPrimaryPressed) { + val previous = previousMoveDownPos + if (previous != null && current != null) { + val dx = (current.x - previous.x).toInt() + val dy = (current.y - previous.y).toInt() + if (dx != 0 || dy != 0) { + onMove(dx, dy) + } + } + previousMoveDownPos = current + } else { + previousMoveDownPos = null + } + } + + PointerEventType.Press -> { + previousPressTime = timeMs() + previousPressPos = current + previousMoveDownPos = current + } + + PointerEventType.Release -> { + if (timeMs() - previousPressTime < Config.CLICK_DURATION_MS) { + val previous = previousPressPos + if (current != null && previous != null) { + if (current.distanceTo(previous) < Config.CLICK_AREA_RADIUS_PX) { + onClick(current.toPt()) + } + } + } + previousPressTime = timeMs() + previousMoveDownPos = null + } + } + } + } + + Box(modifier) { + Canvas(Modifier.fillMaxSize().applyPointerInput()) { + val p1 = size.width.toInt() + val p2 = size.height.toInt() + width = p1 + height = p2 + onStateChange(internalState.copy(width = p1, height = p2).toExternalState()) + clipRect() { + displayTiles.forEach { (t, img) -> + if (img != null) { + val size = IntSize(t.size, t.size) + val position = IntOffset(t.x, t.y) + drawImage( + img.extract(), + srcOffset = IntOffset(img.offsetX, img.offsetY), + srcSize = IntSize(img.cropSize, img.cropSize), + dstOffset = position, + dstSize = size + ) + } + } + } + drawPath(path = Path().apply { + addRect(Rect(0f, 0f, size.width, size.height)) + }, color = Color.Red, style = Stroke(4f)) + } + Row(Modifier.align(Alignment.BottomCenter)) { + LinkText("OpenStreetMap license", Config.OPENSTREET_MAP_LICENSE) + LinkText("Usage policy", Config.OPENSTREET_MAP_POLICY) + } + } +} + +fun InternalMapState.toExternalState() = + MapState( + centerGeo.latitude, + centerGeo.longitude, + scale + ) + +@Composable +private fun LinkText(text: String, link: String) { + Text( + text = text, + color = Color.Blue, + fontWeight = FontWeight.Bold, + modifier = Modifier.clickable { + navigateToUrl(link) + } + .padding(4.dp) + .background(Color.White.copy(alpha = 0.8f), shape = RoundedCornerShape(5.dp)) + .padding(10.dp) + .clip(RoundedCornerShape(5.dp)) + ) +} + +private fun navigateToUrl(url: String) { + Desktop.getDesktop().browse(URL(url).toURI()) +} + +private var inMemoryCache: Map by mutableStateOf(mapOf()) diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/SearchOrCrop.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/SearchOrCrop.kt new file mode 100644 index 0000000000..c6f4943787 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/SearchOrCrop.kt @@ -0,0 +1,28 @@ +package example.map + +import kotlin.math.max + +fun Map.searchOrCrop(tile: Tile): TileImage? { + val img1 = get(tile) + if (img1 != null) { + return img1 + } + var zoom = tile.zoom + var x = tile.x + var y = tile.y + while (zoom > 0) { + zoom-- + x /= 2 + y /= 2 + val tile2 = Tile(zoom, x, y) + val img2 = get(tile2) + if (img2 != null) { + val deltaZoom = tile.zoom - tile2.zoom + val i = tile.x - (x shl deltaZoom) + val j = tile.y - (y shl deltaZoom) + val size = max(TILE_SIZE ushr deltaZoom, 1) + return img2.cropAndRestoreSize(i * size, j * size, size) + } + } + return null +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/TileImage.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/TileImage.kt new file mode 100644 index 0000000000..c9ee210b8c --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/TileImage.kt @@ -0,0 +1,29 @@ +package example.map + +import androidx.compose.ui.graphics.ImageBitmap +import kotlin.math.roundToInt + +class TileImage( + val platformSpecificData: ImageBitmap, + val offsetX: Int = 0, + val offsetY: Int = 0, + val cropSize: Int = TILE_SIZE, +) { + fun lightweightDuplicate(offsetX: Int, offsetY: Int, cropSize: Int): TileImage = + TileImage( + platformSpecificData, + offsetX = offsetX, + offsetY = offsetY, + cropSize = cropSize + ) +} + +fun TileImage.cropAndRestoreSize(x: Int, y: Int, targetSize: Int): TileImage { + val scale: Float = targetSize.toFloat() / TILE_SIZE + val newSize = maxOf(1, (cropSize * scale).roundToInt()) + val dx = x * newSize / targetSize + val dy = y * newSize / targetSize + val newX = offsetX + dx + val newY = offsetY + dy + return lightweightDuplicate(newX % TILE_SIZE, newY % TILE_SIZE, newSize) +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Time.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Time.kt new file mode 100644 index 0000000000..a0b3419cfd --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Time.kt @@ -0,0 +1,3 @@ +package example.map + +fun timeMs(): Long = System.currentTimeMillis() diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Utils.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Utils.kt new file mode 100644 index 0000000000..64b4f14f20 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Utils.kt @@ -0,0 +1,13 @@ +package example.map + +import androidx.compose.ui.geometry.Offset +import kotlin.math.ceil +import kotlin.math.roundToInt +import kotlin.math.sqrt + +fun Offset.toPt(): DisplayPoint = DisplayPoint(ceil(x).roundToInt(), ceil(y).roundToInt()) +fun Offset.distanceTo(other: Offset): Double { + val dx = other.x - x + val dy = other.y - y + return sqrt(dx * dx + dy * dy).toDouble() +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/collection/ImmutableCollection.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/collection/ImmutableCollection.kt new file mode 100644 index 0000000000..04872200f2 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/collection/ImmutableCollection.kt @@ -0,0 +1,14 @@ +package example.map.collection + +/** + * Interface for thread-safe immutable collections + */ +interface ImmutableCollection { + fun add(element: T): RemoveResult + fun remove(): RemoveResult + val size: Int + fun isEmpty(): Boolean + fun isNotEmpty(): Boolean = isEmpty().not() +} + +data class RemoveResult(val collection: ImmutableCollection, val removed: T?) diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/collection/Stack.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/collection/Stack.kt new file mode 100644 index 0000000000..3f141439e7 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/collection/Stack.kt @@ -0,0 +1,48 @@ +package example.map.collection + +/** + * A stack that works like LRU cache. + * When maxSize overflows, elements are removed from the depth of the stack, + * and a new element is placed on top of the stack. + */ +fun createStack(maxSize: Int): ImmutableCollection = Stack(maxSize) + +private data class Stack( + val maxSize: Int, + val list: List = emptyList() +) : ImmutableCollection { + init { + check(maxSize > 0) { "specify maxSize > 0" } + } + + override fun add(element: T): RemoveResult { + return if (list.size >= maxSize) { + RemoveResult( + collection = copy(list = list.drop(1) + element), + removed = list.first() + ) + } else { + RemoveResult( + collection = copy(list = list + element), + removed = null + ) + } + } + + override fun remove(): RemoveResult { + return if (list.isNotEmpty()) { + RemoveResult( + collection = copy(list = list.dropLast(1)), + removed = list.last() + ) + } else { + RemoveResult( + collection = this, + null + ) + } + } + + override val size: Int get() = list.size + override fun isEmpty(): Boolean = list.isEmpty() +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/createRealRepository.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/createRealRepository.kt new file mode 100644 index 0000000000..0b63da8171 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/createRealRepository.kt @@ -0,0 +1,15 @@ +package example.map + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* + +fun createRealRepository(ktorClient: HttpClient) = + object : ContentRepository { + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun loadContent(tile: Tile): ByteArray { + return ktorClient.get( + urlString = Config.createTileUrl(tile) + ).readBytes() + } + } diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/decorateWithDiskCache.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/decorateWithDiskCache.kt new file mode 100644 index 0000000000..15997ebb11 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/decorateWithDiskCache.kt @@ -0,0 +1,76 @@ +package example.map + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.io.File + +fun ContentRepository.decorateWithDiskCache( + backgroundScope: CoroutineScope, + cacheDir: File +): ContentRepository { + + class FileSystemLock() + + val origin = this + val locksCount = 100 + val locks = Array(locksCount) { FileSystemLock() } + + fun getLock(key: Tile) = locks[key.hashCode() % locksCount] + + return object : ContentRepository { + init { + try { + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + } catch (t: Throwable) { + t.printStackTrace() + println("Can't create cache dir $cacheDir") + } + } + + override suspend fun loadContent(key: Tile): ByteArray { + if (!cacheDir.exists()) { + return origin.loadContent(key) + } + val file = with(key) { + cacheDir.resolve("tile-$zoom-$x-$y.png") + } + + val fromCache: ByteArray? = synchronized(getLock(key)) { + if (file.exists()) { + try { + file.readBytes() + } catch (t: Throwable) { + t.printStackTrace() + println("Can't read file $file") + println("Will work without disk cache") + null + } + } else { + null + } + } + + val result = if (fromCache != null) { + fromCache + } else { + val image = origin.loadContent(key) + backgroundScope.launch { + synchronized(getLock(key)) { + // save to cacheDir + try { + file.writeBytes(image) + } catch (t: Throwable) { + println("Can't save image to file $file") + println("Will work without disk cache") + } + } + } + image + } + return result + } + + } +} diff --git a/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/decorateWithDistinctDownloader.kt b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/decorateWithDistinctDownloader.kt new file mode 100644 index 0000000000..6f73377684 --- /dev/null +++ b/examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/decorateWithDistinctDownloader.kt @@ -0,0 +1,67 @@ +package example.map + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.launch + +private sealed interface Message { + class StartDownload(val key: K, val deferred: CompletableDeferred) : Message + class DownloadComplete(val key: K, val result: T) : Message + class DownloadFail(val key: K, val exception: Throwable) : Message +} + +@OptIn(ObsoleteCoroutinesApi::class) +fun ContentRepository.decorateWithDistinctDownloader( + scope: CoroutineScope +): ContentRepository { + val origin = this + val actor = scope.actor> { + val mapKeyToRequests: MutableMap>> = mutableMapOf() + while (true) { + when (val message = receive()) { + is Message.StartDownload -> { + val requestsWithSameKey = mapKeyToRequests.getOrPut(message.key) { + val newHandlers = mutableListOf>() + scope.launch { + try { + val result = origin.loadContent(message.key) + channel.send( + Message.DownloadComplete(message.key, result) + ) + } catch (t: Throwable) { + channel.send(Message.DownloadFail(message.key, t)) + } + } + newHandlers + } + requestsWithSameKey.add(message.deferred) + } + + is Message.DownloadComplete -> { + mapKeyToRequests.remove(message.key)?.forEach { + it.complete(message.result) + } + } + + is Message.DownloadFail -> { + val exceptionInfo = + "decorateWithDistinctDownloader, fail to load tile ${message.key}" + val exception = Exception(exceptionInfo, message.exception) + mapKeyToRequests.remove(message.key)?.forEach { + it.completeExceptionally(exception) + } + } + } + } + } + + return object : ContentRepository { + override suspend fun loadContent(key: K): T { + return CompletableDeferred() + .also { actor.send(Message.StartDownload(key, it)) } + .await() + } + } +} diff --git a/examples/imageviewer/settings.gradle.kts b/examples/imageviewer/settings.gradle.kts index a2c5064a25..b0c6f499c9 100644 --- a/examples/imageviewer/settings.gradle.kts +++ b/examples/imageviewer/settings.gradle.kts @@ -26,3 +26,4 @@ rootProject.name = "imageviewer" include(":androidApp") include(":shared") include(":desktopApp") +include(":mapview-desktop") diff --git a/examples/imageviewer/shared/build.gradle.kts b/examples/imageviewer/shared/build.gradle.kts index 2cff70a368..af10ca375a 100755 --- a/examples/imageviewer/shared/build.gradle.kts +++ b/examples/imageviewer/shared/build.gradle.kts @@ -71,6 +71,7 @@ kotlin { val desktopMain by getting { dependencies { implementation(compose.desktop.common) + implementation(project(":mapview-desktop")) } } } diff --git a/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt b/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt index 1946dd17b0..34eb9724a2 100644 --- a/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt +++ b/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt @@ -17,10 +17,11 @@ actual fun LocationVisualizer( title: String, parentScrollEnableState: MutableState ) { - Image( - painter = painterResource("dummy_map.png"), - contentDescription = "Map", - contentScale = ContentScale.Crop, - modifier = modifier + example.map.MapView( + modifier, + userAgent = "ComposeMapViewExample", + latitude = gps.latitude, + longitude = gps.longitude, + startScale = 12_000.0 ) }