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