dima.avdeev
2 years ago
committed by
GitHub
26 changed files with 1120 additions and 5 deletions
@ -0,0 +1,2 @@
|
||||
### Basic implementation of MapView with OpenStreetMap |
||||
- For usage in your project, please read this policies: https://operations.osmfoundation.org/policies/ |
@ -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) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
) |
@ -0,0 +1,5 @@
|
||||
package example.map |
||||
|
||||
interface ContentRepository<K, T> { |
||||
suspend fun loadContent(key: K): T |
||||
} |
@ -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)) |
||||
} |
||||
} |
||||
} |
@ -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 |
@ -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> |
||||
} |
@ -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) |
||||
} |
@ -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 |
@ -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))) |
||||
|
@ -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 |
||||
} |
@ -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() |
||||
} |
@ -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()) |
@ -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 |
||||
} |
@ -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) |
||||
} |
@ -0,0 +1,3 @@
|
||||
package example.map |
||||
|
||||
fun timeMs(): Long = System.currentTimeMillis() |
@ -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() |
||||
} |
@ -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?) |
@ -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() |
||||
} |
@ -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() |
||||
} |
||||
} |
@ -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 |
||||
} |
||||
|
||||
} |
||||
} |
@ -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() |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue