14 Commits

Author SHA1 Message Date
Lev Shagalov
96844cd526 LruCache internal 2022-07-15 10:52:26 +03:00
Lev Shagalov
e56ed96fdb Merge branch 'main' into limit-parallel-requests
# Conflicts:
#	src/jvmMain/kotlin/Main.kt
#	src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt
2022-07-15 10:41:03 +03:00
Lev Shagalov
5d3db81c4f loadTileAsync refactor
loadTileAsync consumes all tileIds
2022-07-15 10:39:04 +03:00
Lev Shagalov
fc0f223766 LruCache with linkedMapOf 2022-07-15 10:17:42 +03:00
ec2dcdccaa OnClick returns MapViewPoint 2022-07-15 09:31:51 +03:00
Lev Shagalov
0694cd6a07 limit parallel requests 2022-07-15 09:30:36 +03:00
Lev Shagalov
416328e320 Merge branch 'main' into limit-parallel-requests 2022-07-15 09:28:07 +03:00
5984de70b4 Merge TAVRIDA-MR-4: feature/boundbox 2022-07-15 06:26:40 +00:00
Lev Shagalov
7dd59dbf2a LruCache 2022-07-15 09:05:57 +03:00
d720470ea2 Merge TAVRIDA-MR-5: Async load 2022-07-14 18:27:11 +00:00
f92ccb4838 Async load 2022-07-14 20:53:41 +03:00
d3809aca8d Working box by features 2022-07-14 20:32:31 +03:00
9392c0f991 Working box by features (mostly) 2022-07-14 20:19:57 +03:00
1541fb4f39 [WIP] bounding boxes 2022-07-14 10:36:16 +03:00
11 changed files with 283 additions and 82 deletions

0
README.md Normal file
View File

View File

@@ -43,6 +43,8 @@ public class GeodeticMapCoordinates private constructor(public val latitude: Dou
}
}
internal typealias Gmc = GeodeticMapCoordinates
//public interface GeoToScreenConversion {
// public fun getScreenX(gmc: GeodeticMapCoordinates): Double

View File

@@ -0,0 +1,41 @@
package centre.sciprog.maps
import androidx.compose.ui.unit.DpSize
import centre.sciprog.maps.compose.MapFeature
import kotlin.math.*
class GmcBox(val a: GeodeticMapCoordinates, val b: GeodeticMapCoordinates)
fun GmcBox(latitudes: ClosedFloatingPointRange<Double>, longitudes: ClosedFloatingPointRange<Double>) = GmcBox(
Gmc.ofRadians(latitudes.start, longitudes.start),
Gmc.ofRadians(latitudes.endInclusive, longitudes.endInclusive)
)
val GmcBox.center
get() = GeodeticMapCoordinates.ofRadians(
(a.latitude + b.latitude) / 2,
(a.longitude + b.longitude) / 2
)
val GmcBox.left get() = min(a.longitude, b.longitude)
val GmcBox.right get() = max(a.longitude, b.longitude)
val GmcBox.top get() = max(a.latitude, b.latitude)
val GmcBox.bottom get() = min(a.latitude, b.latitude)
//TODO take curvature into account
val GmcBox.width get() = abs(a.longitude - b.longitude)
val GmcBox.height get() = abs(a.latitude - b.latitude)
/**
* Compute a minimal bounding box including all given boxes. Return null if collection is empty
*/
fun Collection<GmcBox>.wrapAll(): GmcBox? {
if (isEmpty()) return null
//TODO optimize computation
val minLat = minOf { it.bottom }
val maxLat = maxOf { it.top }
val minLong = minOf { it.left }
val maxLong = maxOf { it.right }
return GmcBox(minLat..maxLat, minLong..maxLong)
}

View File

@@ -0,0 +1,53 @@
package centre.sciprog.maps
import kotlin.jvm.Synchronized
internal class LruCache<K, V>(
private var capacity: Int,
) {
private val cache = linkedMapOf<K, V>()
@Synchronized
fun getCache() = cache.toMap()
@Synchronized
fun put(key: K, value: V) = internalPut(key, value)
@Synchronized
operator fun get(key: K) = internalGet(key)
@Synchronized
fun remove(key: K) {
cache.remove(key)
}
@Synchronized
fun getOrPut(key: K, callback: () -> V): V {
val internalGet = internalGet(key)
return internalGet ?: callback().also { internalPut(key, it) }
}
@Synchronized
fun clear(newCapacity: Int? = null) {
cache.clear()
capacity = newCapacity ?: capacity
}
private fun internalGet(key: K): V? {
val value = cache[key]
if (value != null) {
cache.remove(key)
cache[key] = value
}
return value
}
private fun internalPut(key: K, value: V) {
if (cache.size >= capacity) {
cache.remove(cache.iterator().next().key)
}
cache[key] = value
}
}

View File

@@ -15,7 +15,11 @@ interface FeatureBuilder {
fun build(): SnapshotStateMap<FeatureId, MapFeature>
}
internal class MapFeatureBuilder(private val content: SnapshotStateMap<FeatureId, MapFeature> = mutableStateMapOf()) : FeatureBuilder {
internal class MapFeatureBuilder(initialFeatures: Map<FeatureId,MapFeature>) : FeatureBuilder {
private val content: SnapshotStateMap<FeatureId, MapFeature> = mutableStateMapOf<FeatureId, MapFeature>().apply {
putAll(initialFeatures)
}
private fun generateID(feature: MapFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
override fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId {

View File

@@ -9,9 +9,15 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.IntSize
import centre.sciprog.maps.GeodeticMapCoordinates
import centre.sciprog.maps.GmcBox
import centre.sciprog.maps.wrapAll
//TODO replace zoom range with zoom-based representation change
sealed class MapFeature(val zoomRange: IntRange)
sealed class MapFeature(val zoomRange: IntRange) {
abstract fun getBoundingBox(zoom: Int): GmcBox
}
fun Iterable<MapFeature>.computeBoundingBox(zoom: Int): GmcBox? = map { it.getBoundingBox(zoom) }.wrapAll()
internal fun Pair<Double, Double>.toCoordinates() = GeodeticMapCoordinates.ofDegrees(first, second)
@@ -20,35 +26,46 @@ internal val defaultZoomRange = 1..18
/**
* A feature that decides what to show depending on the zoom value (it could change size of shape)
*/
class MapFeatureSelector(val selector: (zoom: Int) -> MapFeature) : MapFeature(defaultZoomRange)
class MapFeatureSelector(val selector: (zoom: Int) -> MapFeature) : MapFeature(defaultZoomRange) {
override fun getBoundingBox(zoom: Int): GmcBox = selector(zoom).getBoundingBox(zoom)
}
class MapCircleFeature(
val center: GeodeticMapCoordinates,
zoomRange: IntRange = defaultZoomRange,
val size: Float = 5f,
val color: Color = Color.Red,
) : MapFeature(zoomRange)
) : MapFeature(zoomRange) {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center)
}
class MapLineFeature(
val a: GeodeticMapCoordinates,
val b: GeodeticMapCoordinates,
zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red,
) : MapFeature(zoomRange)
) : MapFeature(zoomRange) {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(a, b)
}
class MapTextFeature(
val position: GeodeticMapCoordinates,
val text: String,
zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red,
) : MapFeature(zoomRange)
) : MapFeature(zoomRange) {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
}
class MapBitmapImageFeature(
val position: GeodeticMapCoordinates,
val image: ImageBitmap,
val size: IntSize = IntSize(15, 15),
zoomRange: IntRange = defaultZoomRange,
) : MapFeature(zoomRange)
) : MapFeature(zoomRange) {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
}
class MapVectorImageFeature (
@@ -56,7 +73,9 @@ class MapVectorImageFeature (
val painter: Painter,
val size: Size,
zoomRange: IntRange = defaultZoomRange,
) : MapFeature(zoomRange)
) : MapFeature(zoomRange) {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
}
@Composable
fun MapVectorImageFeature(

View File

@@ -1,6 +1,7 @@
package centre.sciprog.maps.compose
import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.coroutines.CoroutineScope
import kotlin.math.floor
data class TileId(
@@ -15,7 +16,7 @@ data class MapTile(
)
interface MapTileProvider {
suspend fun loadTile(id: TileId): MapTile
suspend fun loadTileAsync(tileIds: List<TileId>, scope: CoroutineScope, onTileLoad: (mapTile: MapTile) -> Unit)
val tileSize: Int get() = DEFAULT_TILE_SIZE
@@ -26,4 +27,4 @@ interface MapTileProvider {
companion object {
const val DEFAULT_TILE_SIZE = 256
}
}
}

View File

@@ -3,20 +3,24 @@ package centre.sciprog.maps.compose
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import centre.sciprog.maps.GeodeticMapCoordinates
import centre.sciprog.maps.MapViewPoint
import androidx.compose.ui.unit.DpSize
import centre.sciprog.maps.*
import kotlin.math.PI
import kotlin.math.log2
import kotlin.math.min
data class MapViewConfig(
val zoomSpeed: Double = 1.0 / 3.0,
val inferViewBoxFromFeatures: Boolean = false
)
@Composable
expect fun MapView(
initialViewPoint: MapViewPoint,
mapTileProvider: MapTileProvider,
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
features: Map<FeatureId, MapFeature>,
onClick: (GeodeticMapCoordinates) -> Unit = {},
onClick: MapViewPoint.() -> Unit = {},
//TODO consider replacing by modifier
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
@@ -24,14 +28,39 @@ expect fun MapView(
@Composable
fun MapView(
initialViewPoint: MapViewPoint,
mapTileProvider: MapTileProvider,
onClick: (GeodeticMapCoordinates) -> Unit = {},
initialViewPoint: MapViewPoint,
features: Map<FeatureId, MapFeature> = emptyMap(),
onClick: MapViewPoint.() -> Unit = {},
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
addFeatures: @Composable() (FeatureBuilder.() -> Unit) = {},
buildFeatures: @Composable (FeatureBuilder.() -> Unit) = {},
) {
val featuresBuilder = MapFeatureBuilder()
featuresBuilder.addFeatures()
MapView(initialViewPoint, mapTileProvider, featuresBuilder.build(), onClick, config, modifier)
val featuresBuilder = MapFeatureBuilder(features)
featuresBuilder.buildFeatures()
MapView(mapTileProvider, { initialViewPoint }, featuresBuilder.build(), onClick, config, modifier)
}
@Composable
fun MapView(
mapTileProvider: MapTileProvider,
box: GmcBox,
features: Map<FeatureId, MapFeature> = emptyMap(),
onClick: MapViewPoint.() -> Unit = {},
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: @Composable (FeatureBuilder.() -> Unit) = {},
) {
val featuresBuilder = MapFeatureBuilder(features)
featuresBuilder.buildFeatures()
val computeViewPoint: (canvasSize: DpSize) -> MapViewPoint = { canvasSize ->
val zoom = log2(
min(
canvasSize.width.value / box.width,
canvasSize.height.value / box.height
) * PI / mapTileProvider.tileSize
)
MapViewPoint(box.center, zoom)
}
MapView(mapTileProvider, computeViewPoint, featuresBuilder.build(), onClick, config, modifier)
}

View File

@@ -12,8 +12,8 @@ import androidx.compose.ui.window.application
import centre.sciprog.maps.GeodeticMapCoordinates
import centre.sciprog.maps.MapViewPoint
import centre.sciprog.maps.compose.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -33,14 +33,24 @@ fun App() {
}
val scope = rememberCoroutineScope()
val mapTileProvider = remember { OpenStreetMapTileProvider(scope, HttpClient(CIO), Path.of("mapCache")) }
val mapTileProvider = remember {
OpenStreetMapTileProvider(
client = HttpClient(CIO),
cacheDirectory = Path.of("mapCache")
)
}
var coordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) }
Column {
//display click coordinates
Text(coordinates?.toString() ?: "")
MapView(viewPoint, mapTileProvider, onClick = { gmc: GeodeticMapCoordinates -> coordinates = gmc }) {
MapView(
mapTileProvider = mapTileProvider,
initialViewPoint = viewPoint,
onClick = { coordinates = focus },
config = MapViewConfig(inferViewBoxFromFeatures = true)
) {
val pointOne = 55.568548 to 37.568604
val pointTwo = 55.929444 to 37.518434
@@ -53,10 +63,14 @@ fun App() {
text(pointOne, "Home")
scope.launch {
while (isActive){
while (isActive) {
delay(200)
//Overwrite a feature with new color
circle(pointTwo, id = circleId, color = Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat()))
circle(
pointTwo,
id = circleId,
color = Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat())
)
}
}
}

View File

@@ -20,10 +20,7 @@ import centre.sciprog.maps.*
import mu.KotlinLogging
import org.jetbrains.skia.Font
import org.jetbrains.skia.Paint
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.log2
import kotlin.math.pow
import kotlin.math.*
private fun Color.toPaint(): Paint = Paint().apply {
@@ -39,15 +36,34 @@ private val logger = KotlinLogging.logger("MapView")
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun MapView(
initialViewPoint: MapViewPoint,
mapTileProvider: MapTileProvider,
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
features: Map<FeatureId, MapFeature>,
onClick: (GeodeticMapCoordinates) -> Unit,
onClick: MapViewPoint.() -> Unit,
config: MapViewConfig,
modifier: Modifier,
) {
var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) }
var viewPoint by remember { mutableStateOf(initialViewPoint) }
var viewPointOverride: MapViewPoint? by remember {
mutableStateOf(
if (config.inferViewBoxFromFeatures) {
features.values.computeBoundingBox(1)?.let { box ->
val zoom = log2(
min(
canvasSize.width.value / box.width,
canvasSize.height.value / box.height
) * PI / mapTileProvider.tileSize
)
MapViewPoint(box.center, zoom)
}
} else {
null
}
)
}
val viewPoint by derivedStateOf { viewPointOverride ?: computeViewPoint(canvasSize) }
val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
@@ -55,9 +71,6 @@ actual fun MapView(
val mapTiles = remember { mutableStateListOf<MapTile>() }
//var mapRectangle by remember { mutableStateOf(initialRectangle) }
var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) }
val centerCoordinates by derivedStateOf { WebMercatorProjection.toMercator(viewPoint.focus, zoom) }
fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
@@ -87,10 +100,10 @@ actual fun MapView(
selectRect?.let { rect ->
val offset = dragChange.position
selectRect = Rect(
kotlin.math.min(offset.x, rect.left),
kotlin.math.min(offset.y, rect.top),
kotlin.math.max(offset.x, rect.right),
kotlin.math.max(offset.y, rect.bottom)
min(offset.x, rect.left),
min(offset.y, rect.top),
max(offset.x, rect.right),
max(offset.y, rect.bottom)
)
}
}
@@ -102,16 +115,19 @@ actual fun MapView(
val verticalZoom: Float = log2(canvasSize.height.toPx() / rect.height)
viewPoint = MapViewPoint(centerGmc, viewPoint.zoom + kotlin.math.min(verticalZoom, horizontalZoom))
viewPointOverride = MapViewPoint(
centerGmc,
viewPoint.zoom + min(verticalZoom, horizontalZoom)
)
selectRect = null
}
} else {
val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
onClick(dpPos.toGeodetic())
onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom))
drag(change.id) { dragChange ->
val dragAmount = dragChange.position - dragChange.previousPosition
viewPoint = viewPoint.move(
viewPointOverride = viewPoint.move(
-dragAmount.x.toDp().value / tileScale,
+dragAmount.y.toDp().value / tileScale
)
@@ -126,37 +142,39 @@ actual fun MapView(
val (xPos, yPos) = change.position
//compute invariant point of translation
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic()
viewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant)
viewPointOverride = viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant)
}.fillMaxSize()
// Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) {
val indexRange = 0 until 2.0.pow(zoom).toInt()
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
val horizontalIndices = mapTileProvider.toIndex(left)..mapTileProvider.toIndex(right)
val horizontalIndices = mapTileProvider.toIndex(left)
.rangeTo(mapTileProvider.toIndex(right))
.intersect(indexRange)
val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
val verticalIndices = mapTileProvider.toIndex(bottom)..mapTileProvider.toIndex(top)
val verticalIndices = mapTileProvider.toIndex(bottom)
.rangeTo(mapTileProvider.toIndex(top))
.intersect(indexRange)
mapTiles.clear()
val indexRange = 0 until 2.0.pow(zoom).toInt()
for (j in verticalIndices) {
for (i in horizontalIndices) {
if (i in indexRange && j in indexRange) {
val tileId = TileId(zoom, i, j)
try {
val tile = mapTileProvider.loadTile(tileId)
mapTiles.add(tile)
} catch (ex: Exception) {
logger.error(ex) { "Failed to load tile $tileId" }
}
}
val tileIds = verticalIndices
.flatMap { j ->
horizontalIndices
.asSequence()
.map { TileId(zoom, it, j) }
}
}
mapTileProvider.loadTileAsync(
tileIds = tileIds,
scope = this
) { mapTiles += it }
}
@@ -242,4 +260,4 @@ actual fun MapView(
)
}
}
}
}

View File

@@ -2,22 +2,29 @@ package centre.sciprog.maps.compose
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.readBytes
import centre.sciprog.maps.LruCache
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import mu.KotlinLogging
import org.jetbrains.skia.Image
import java.net.URL
import java.nio.file.Path
import kotlin.io.path.*
import kotlin.math.pow
/**
* A [MapTileProvider] based on Open Street Map API. With in-memory and file cache
*/
public class OpenStreetMapTileProvider(private val scope: CoroutineScope, private val client: HttpClient, private val cacheDirectory: Path): MapTileProvider {
private val cache = HashMap<TileId, Deferred<ImageBitmap>>()
class OpenStreetMapTileProvider(
private val client: HttpClient,
private val cacheDirectory: Path,
parallelism: Int = 1,
cacheCapacity: Int = 200,
) : MapTileProvider {
private val semaphore = Semaphore(parallelism)
private val cache = LruCache<TileId, Deferred<ImageBitmap>>(cacheCapacity)
private fun TileId.osmUrl() = URL("https://tile.openstreetmap.org/${zoom}/${i}/${j}.png")
@@ -26,7 +33,7 @@ public class OpenStreetMapTileProvider(private val scope: CoroutineScope, privat
/**
* Download and cache the tile image
*/
private fun downloadImageAsync(id: TileId) = scope.async(Dispatchers.IO) {
private fun CoroutineScope.downloadImageAsync(id: TileId) = async(Dispatchers.IO) {
id.cacheFilePath()?.let { path ->
if (path.exists()) {
try {
@@ -53,21 +60,34 @@ public class OpenStreetMapTileProvider(private val scope: CoroutineScope, privat
Image.makeFromEncoded(byteArray).toComposeImageBitmap()
}
override suspend fun loadTile(id: TileId): MapTile {
val indexRange = indexRange(id.zoom)
if(id.i !in indexRange || id.j !in indexRange){
error("Indices (${id.i}, ${id.j}) are not in index range $indexRange for zoom ${id.zoom}")
}
val image = cache.getOrPut(id) {
downloadImageAsync(id)
}.await()
return MapTile(id, image)
override suspend fun loadTileAsync(
tileIds: List<TileId>,
scope: CoroutineScope,
onTileLoad: (mapTile: MapTile) -> Unit,
) {
tileIds
.forEach { id ->
try {
scope.launch {
semaphore.acquire()
try {
val image = cache.getOrPut(id) { downloadImageAsync(id) }
val result = MapTile(id, image.await())
onTileLoad(result)
} catch (e: Exception) {
cache.remove(id)
throw e
} finally {
semaphore.release()
}
}
} catch (ex: Exception) {
logger.error(ex) { "Failed to load tile $id" }
}
}
}
companion object{
companion object {
private val logger = KotlinLogging.logger("OpenStreetMapCache")
private fun indexRange(zoom: Int): IntRange = 0 until 2.0.pow(zoom).toInt()
}
}
}