Browse Source

ImageViewer desktop MapView (#3022)

pull/3039/head
dima.avdeev 2 years ago committed by GitHub
parent
commit
a6e71144b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      examples/imageviewer/mapview-desktop/README.md
  2. 26
      examples/imageviewer/mapview-desktop/build.gradle.kts
  3. 51
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/CalcTiles.kt
  4. 71
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Config.kt
  5. 5
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepository.kt
  6. 10
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepositoryAdapter.kt
  7. 30
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/CreateTilesRepository.kt
  8. 118
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/DecorateWithLimitRequestsInParallel.kt
  9. 48
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/GeoPoint.kt
  10. 8
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ImageUtils.kt
  11. 50
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/InternalMapState.kt
  12. 87
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/LibStore.kt
  13. 54
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/MapStateExt.kt
  14. 259
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/MapView.kt
  15. 28
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/SearchOrCrop.kt
  16. 29
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/TileImage.kt
  17. 3
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Time.kt
  18. 13
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Utils.kt
  19. 14
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/collection/ImmutableCollection.kt
  20. 48
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/collection/Stack.kt
  21. 15
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/createRealRepository.kt
  22. 76
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/decorateWithDiskCache.kt
  23. 67
      examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/decorateWithDistinctDownloader.kt
  24. 1
      examples/imageviewer/settings.gradle.kts
  25. 1
      examples/imageviewer/shared/build.gradle.kts
  26. 11
      examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt

2
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/

26
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)
}
}
}
}

51
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<DisplayTileAndTile> {
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<DisplayTileAndTile> = 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
}

71
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
)

5
examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepository.kt

@ -0,0 +1,5 @@
package example.map
interface ContentRepository<K, T> {
suspend fun loadContent(key: K): T
}

10
examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/ContentRepositoryAdapter.kt

@ -0,0 +1,10 @@
package example.map
fun <K, A, B> ContentRepository<K, A>.adapter(transform: (A) -> B): ContentRepository<K, B> {
val origin = this
return object : ContentRepository<K, B> {
override suspend fun loadContent(key: K): B {
return transform(origin.loadContent(key))
}
}
}

30
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<Tile, TileImage> = 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

118
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 <K, T> ContentRepository<K, T>.decorateWithLimitRequestsInParallel(
scope: CoroutineScope,
maxParallelRequests: Int = 2, // Policy: https://operations.osmfoundation.org/policies/tiles/
waitBufferCapacity: Int = 10,
delayBeforeRequestMs: Long = 50
): ContentRepository<K, T> {
val origin = this
data class State(
val stack: ImmutableCollection<ElementWait<K, T>> = createStack(waitBufferCapacity),
val currentRequests: Int = 0
)
val store = scope.createStoreWithSideEffect(
init = State(),
effectHandler = { store, effect: NetworkSideEffect<K, T> ->
when (effect) {
is NetworkSideEffect.Load<K, T> -> {
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<K, T> -> {
scope.launch {
delay(delayBeforeRequestMs)
store.send(Intent.AfterDelay())
}
}
}
}
) { state, intent: Intent<K, T> ->
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<ElementWait<K, T>> = 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<K, T> {
override suspend fun loadContent(key: K): T {
return CompletableDeferred<T>()
.also { store.send(Intent.NewElement(ElementWait(key, it))) }
.await()
}
}
}
private class ElementWait<K, T>(val key: K, val deferred: CompletableDeferred<T>)
private sealed interface Intent<K, T> {
class ElementComplete<K, T> : Intent<K, T>
class NewElement<K, T>(val wait: ElementWait<K, T>) : Intent<K, T>
class AfterDelay<K, T> : Intent<K, T>
}
private sealed interface NetworkSideEffect<K, T> {
class Load<K, T>(val waitElements: List<ElementWait<K, T>>) : NetworkSideEffect<K, T>
class Delay<K, T> : NetworkSideEffect<K, T>
}

48
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)
}

8
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

50
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<T>(
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)))

87
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<STATE, INTENT> = suspend (STATE, INTENT) -> STATE
interface Store<STATE, INTENT> {
fun send(intent: INTENT)
val stateFlow: StateFlow<STATE>
val state get() = stateFlow.value
}
fun <STATE, INTENT> CoroutineScope.createStore(
init: STATE,
reducer: Reducer<STATE, INTENT>
): Store<STATE, INTENT> {
val mutableStateFlow = MutableStateFlow(init)
val channel: Channel<INTENT> = Channel(Channel.UNLIMITED)
return object : Store<STATE, INTENT> {
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<STATE> = mutableStateFlow
}
}
typealias ReducerSE<STATE, INTENT, EFFECT> = suspend (STATE, INTENT) -> ReducerResult<STATE, EFFECT>
data class ReducerResult<STATE, EFFECT>(
val state: STATE,
val sideEffects: List<EFFECT> = emptyList()
)
fun <STATE, INTENT, EFFECT> CoroutineScope.createStoreWithSideEffect(
init: STATE,
effectHandler: (store: Store<STATE, INTENT>, sideEffect: EFFECT) -> Unit,
reducer: ReducerSE<STATE, INTENT, EFFECT>
): Store<STATE, INTENT> {
lateinit var store: Store<STATE, INTENT>
store = createStore(init) { state, intent ->
val result = reducer(state, intent)
result.sideEffects.forEach {
effectHandler(store, it)
}
result.state
}
return store
}
fun <STATE : Any, EFFECT> STATE.noSideEffects() = ReducerResult(this, emptyList<EFFECT>())
fun <STATE : Any, EFFECT> STATE.addSideEffects(sideEffects: List<EFFECT>) =
ReducerResult(this, sideEffects)
fun <STATE : Any, EFFECT> STATE.addSideEffect(effect: EFFECT) = addSideEffects(listOf(effect))
fun <T, R> StateFlow<T>.mapStateFlow(
scope: CoroutineScope,
init: R,
transform: suspend (T) -> R
): StateFlow<R> {
val result = MutableStateFlow(init)
scope.launch {
collect {
result.value = transform(it)
}
}
return result
}

54
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()
}

259
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<MapState> = remember {
mutableStateOf(MapState(latitude ?: 0.0, longitude ?: 0.0, startScale ?: 1.0))
},
onStateChange: (MapState) -> Unit = { (state as? MutableState<MapState>)?.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<DisplayTileWithImage<TileImage>> by derivedStateOf {
val calcTiles: List<DisplayTileAndTile> = internalState.calcTiles()
val tilesToDisplay: MutableList<DisplayTileWithImage<TileImage>> = mutableListOf()
val tilesToLoad: MutableSet<Tile> = 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<MutableState<Offset?>> { mutableStateOf(null) }
var previousPressTime by remember { mutableStateOf(0L) }
var previousPressPos by remember<MutableState<Offset?>> { 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<Path> {
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<Tile, TileImage> by mutableStateOf(mapOf())

28
examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/SearchOrCrop.kt

@ -0,0 +1,28 @@
package example.map
import kotlin.math.max
fun Map<Tile, TileImage>.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
}

29
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)
}

3
examples/imageviewer/mapview-desktop/src/desktopMain/kotlin/example/map/Time.kt

@ -0,0 +1,3 @@
package example.map
fun timeMs(): Long = System.currentTimeMillis()

13
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()
}

14
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<T> {
fun add(element: T): RemoveResult<T>
fun remove(): RemoveResult<T>
val size: Int
fun isEmpty(): Boolean
fun isNotEmpty(): Boolean = isEmpty().not()
}
data class RemoveResult<T>(val collection: ImmutableCollection<T>, val removed: T?)

48
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 <T> createStack(maxSize: Int): ImmutableCollection<T> = Stack(maxSize)
private data class Stack<T>(
val maxSize: Int,
val list: List<T> = emptyList()
) : ImmutableCollection<T> {
init {
check(maxSize > 0) { "specify maxSize > 0" }
}
override fun add(element: T): RemoveResult<T> {
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<T> {
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()
}

15
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<Tile, ByteArray> {
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
override suspend fun loadContent(tile: Tile): ByteArray {
return ktorClient.get(
urlString = Config.createTileUrl(tile)
).readBytes()
}
}

76
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<Tile, ByteArray>.decorateWithDiskCache(
backgroundScope: CoroutineScope,
cacheDir: File
): ContentRepository<Tile, ByteArray> {
class FileSystemLock()
val origin = this
val locksCount = 100
val locks = Array(locksCount) { FileSystemLock() }
fun getLock(key: Tile) = locks[key.hashCode() % locksCount]
return object : ContentRepository<Tile, ByteArray> {
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
}
}
}

67
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<K, T> {
class StartDownload<K, T>(val key: K, val deferred: CompletableDeferred<T>) : Message<K, T>
class DownloadComplete<K, T>(val key: K, val result: T) : Message<K, T>
class DownloadFail<K, T>(val key: K, val exception: Throwable) : Message<K, T>
}
@OptIn(ObsoleteCoroutinesApi::class)
fun <K, T> ContentRepository<K, T>.decorateWithDistinctDownloader(
scope: CoroutineScope
): ContentRepository<K, T> {
val origin = this
val actor = scope.actor<Message<K, T>> {
val mapKeyToRequests: MutableMap<K, MutableList<CompletableDeferred<T>>> = mutableMapOf()
while (true) {
when (val message = receive()) {
is Message.StartDownload<K, T> -> {
val requestsWithSameKey = mapKeyToRequests.getOrPut(message.key) {
val newHandlers = mutableListOf<CompletableDeferred<T>>()
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<K, T> -> {
mapKeyToRequests.remove(message.key)?.forEach {
it.complete(message.result)
}
}
is Message.DownloadFail<K, T> -> {
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<K, T> {
override suspend fun loadContent(key: K): T {
return CompletableDeferred<T>()
.also { actor.send(Message.StartDownload(key, it)) }
.await()
}
}
}

1
examples/imageviewer/settings.gradle.kts

@ -26,3 +26,4 @@ rootProject.name = "imageviewer"
include(":androidApp") include(":androidApp")
include(":shared") include(":shared")
include(":desktopApp") include(":desktopApp")
include(":mapview-desktop")

1
examples/imageviewer/shared/build.gradle.kts

@ -71,6 +71,7 @@ kotlin {
val desktopMain by getting { val desktopMain by getting {
dependencies { dependencies {
implementation(compose.desktop.common) implementation(compose.desktop.common)
implementation(project(":mapview-desktop"))
} }
} }
} }

11
examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/LocationVisualizer.desktop.kt

@ -17,10 +17,11 @@ actual fun LocationVisualizer(
title: String, title: String,
parentScrollEnableState: MutableState<Boolean> parentScrollEnableState: MutableState<Boolean>
) { ) {
Image( example.map.MapView(
painter = painterResource("dummy_map.png"), modifier,
contentDescription = "Map", userAgent = "ComposeMapViewExample",
contentScale = ContentScale.Crop, latitude = gps.latitude,
modifier = modifier longitude = gps.longitude,
startScale = 12_000.0
) )
} }

Loading…
Cancel
Save