Merge TAVRIDA-MR-4: feature/boundbox
This commit is contained in:
commit
5984de70b4
@ -43,6 +43,8 @@ public class GeodeticMapCoordinates private constructor(public val latitude: Dou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal typealias Gmc = GeodeticMapCoordinates
|
||||||
|
|
||||||
|
|
||||||
//public interface GeoToScreenConversion {
|
//public interface GeoToScreenConversion {
|
||||||
// public fun getScreenX(gmc: GeodeticMapCoordinates): Double
|
// public fun getScreenX(gmc: GeodeticMapCoordinates): Double
|
||||||
|
41
src/commonMain/kotlin/centre/sciprog/maps/GmcBox.kt
Normal file
41
src/commonMain/kotlin/centre/sciprog/maps/GmcBox.kt
Normal 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)
|
||||||
|
}
|
@ -15,7 +15,11 @@ interface FeatureBuilder {
|
|||||||
fun build(): SnapshotStateMap<FeatureId, MapFeature>
|
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()}]"
|
private fun generateID(feature: MapFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
|
||||||
|
|
||||||
override fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId {
|
override fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId {
|
||||||
|
@ -9,9 +9,15 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import centre.sciprog.maps.GeodeticMapCoordinates
|
import centre.sciprog.maps.GeodeticMapCoordinates
|
||||||
|
import centre.sciprog.maps.GmcBox
|
||||||
|
import centre.sciprog.maps.wrapAll
|
||||||
|
|
||||||
//TODO replace zoom range with zoom-based representation change
|
//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)
|
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)
|
* 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(
|
class MapCircleFeature(
|
||||||
val center: GeodeticMapCoordinates,
|
val center: GeodeticMapCoordinates,
|
||||||
zoomRange: IntRange = defaultZoomRange,
|
zoomRange: IntRange = defaultZoomRange,
|
||||||
val size: Float = 5f,
|
val size: Float = 5f,
|
||||||
val color: Color = Color.Red,
|
val color: Color = Color.Red,
|
||||||
) : MapFeature(zoomRange)
|
) : MapFeature(zoomRange) {
|
||||||
|
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center)
|
||||||
|
}
|
||||||
|
|
||||||
class MapLineFeature(
|
class MapLineFeature(
|
||||||
val a: GeodeticMapCoordinates,
|
val a: GeodeticMapCoordinates,
|
||||||
val b: GeodeticMapCoordinates,
|
val b: GeodeticMapCoordinates,
|
||||||
zoomRange: IntRange = defaultZoomRange,
|
zoomRange: IntRange = defaultZoomRange,
|
||||||
val color: Color = Color.Red,
|
val color: Color = Color.Red,
|
||||||
) : MapFeature(zoomRange)
|
) : MapFeature(zoomRange) {
|
||||||
|
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
class MapTextFeature(
|
class MapTextFeature(
|
||||||
val position: GeodeticMapCoordinates,
|
val position: GeodeticMapCoordinates,
|
||||||
val text: String,
|
val text: String,
|
||||||
zoomRange: IntRange = defaultZoomRange,
|
zoomRange: IntRange = defaultZoomRange,
|
||||||
val color: Color = Color.Red,
|
val color: Color = Color.Red,
|
||||||
) : MapFeature(zoomRange)
|
) : MapFeature(zoomRange) {
|
||||||
|
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
|
||||||
|
}
|
||||||
|
|
||||||
class MapBitmapImageFeature(
|
class MapBitmapImageFeature(
|
||||||
val position: GeodeticMapCoordinates,
|
val position: GeodeticMapCoordinates,
|
||||||
val image: ImageBitmap,
|
val image: ImageBitmap,
|
||||||
val size: IntSize = IntSize(15, 15),
|
val size: IntSize = IntSize(15, 15),
|
||||||
zoomRange: IntRange = defaultZoomRange,
|
zoomRange: IntRange = defaultZoomRange,
|
||||||
) : MapFeature(zoomRange)
|
) : MapFeature(zoomRange) {
|
||||||
|
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MapVectorImageFeature (
|
class MapVectorImageFeature (
|
||||||
@ -56,7 +73,9 @@ class MapVectorImageFeature (
|
|||||||
val painter: Painter,
|
val painter: Painter,
|
||||||
val size: Size,
|
val size: Size,
|
||||||
zoomRange: IntRange = defaultZoomRange,
|
zoomRange: IntRange = defaultZoomRange,
|
||||||
) : MapFeature(zoomRange)
|
) : MapFeature(zoomRange) {
|
||||||
|
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapVectorImageFeature(
|
fun MapVectorImageFeature(
|
||||||
|
@ -3,18 +3,22 @@ package centre.sciprog.maps.compose
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import centre.sciprog.maps.GeodeticMapCoordinates
|
import androidx.compose.ui.unit.DpSize
|
||||||
import centre.sciprog.maps.MapViewPoint
|
import centre.sciprog.maps.*
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.log2
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
|
||||||
data class MapViewConfig(
|
data class MapViewConfig(
|
||||||
val zoomSpeed: Double = 1.0 / 3.0,
|
val zoomSpeed: Double = 1.0 / 3.0,
|
||||||
|
val inferViewBoxFromFeatures: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun MapView(
|
expect fun MapView(
|
||||||
initialViewPoint: MapViewPoint,
|
|
||||||
mapTileProvider: MapTileProvider,
|
mapTileProvider: MapTileProvider,
|
||||||
|
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
||||||
features: Map<FeatureId, MapFeature>,
|
features: Map<FeatureId, MapFeature>,
|
||||||
onClick: (GeodeticMapCoordinates) -> Unit = {},
|
onClick: (GeodeticMapCoordinates) -> Unit = {},
|
||||||
//TODO consider replacing by modifier
|
//TODO consider replacing by modifier
|
||||||
@ -24,14 +28,39 @@ expect fun MapView(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapView(
|
fun MapView(
|
||||||
initialViewPoint: MapViewPoint,
|
|
||||||
mapTileProvider: MapTileProvider,
|
mapTileProvider: MapTileProvider,
|
||||||
|
initialViewPoint: MapViewPoint,
|
||||||
|
features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||||
onClick: (GeodeticMapCoordinates) -> Unit = {},
|
onClick: (GeodeticMapCoordinates) -> Unit = {},
|
||||||
config: MapViewConfig = MapViewConfig(),
|
config: MapViewConfig = MapViewConfig(),
|
||||||
modifier: Modifier = Modifier.fillMaxSize(),
|
modifier: Modifier = Modifier.fillMaxSize(),
|
||||||
addFeatures: @Composable() (FeatureBuilder.() -> Unit) = {},
|
buildFeatures: @Composable (FeatureBuilder.() -> Unit) = {},
|
||||||
) {
|
) {
|
||||||
val featuresBuilder = MapFeatureBuilder()
|
val featuresBuilder = MapFeatureBuilder(features)
|
||||||
featuresBuilder.addFeatures()
|
featuresBuilder.buildFeatures()
|
||||||
MapView(initialViewPoint, mapTileProvider, featuresBuilder.build(), onClick, config, modifier)
|
MapView(mapTileProvider, { initialViewPoint }, featuresBuilder.build(), onClick, config, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapView(
|
||||||
|
mapTileProvider: MapTileProvider,
|
||||||
|
box: GmcBox,
|
||||||
|
features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||||
|
onClick: (GeodeticMapCoordinates) -> 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)
|
||||||
}
|
}
|
@ -40,7 +40,12 @@ fun App() {
|
|||||||
Column {
|
Column {
|
||||||
//display click coordinates
|
//display click coordinates
|
||||||
Text(coordinates?.toString() ?: "")
|
Text(coordinates?.toString() ?: "")
|
||||||
MapView(viewPoint, mapTileProvider, onClick = { gmc: GeodeticMapCoordinates -> coordinates = gmc }) {
|
MapView(
|
||||||
|
mapTileProvider,
|
||||||
|
viewPoint,
|
||||||
|
onClick = { gmc -> coordinates = gmc },
|
||||||
|
config = MapViewConfig(inferViewBoxFromFeatures = true)
|
||||||
|
) {
|
||||||
val pointOne = 55.568548 to 37.568604
|
val pointOne = 55.568548 to 37.568604
|
||||||
val pointTwo = 55.929444 to 37.518434
|
val pointTwo = 55.929444 to 37.518434
|
||||||
|
|
||||||
@ -56,7 +61,11 @@ fun App() {
|
|||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(200)
|
delay(200)
|
||||||
//Overwrite a feature with new color
|
//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())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,10 +21,7 @@ import kotlinx.coroutines.launch
|
|||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.jetbrains.skia.Font
|
import org.jetbrains.skia.Font
|
||||||
import org.jetbrains.skia.Paint
|
import org.jetbrains.skia.Paint
|
||||||
import kotlin.math.ceil
|
import kotlin.math.*
|
||||||
import kotlin.math.floor
|
|
||||||
import kotlin.math.log2
|
|
||||||
import kotlin.math.pow
|
|
||||||
|
|
||||||
|
|
||||||
private fun Color.toPaint(): Paint = Paint().apply {
|
private fun Color.toPaint(): Paint = Paint().apply {
|
||||||
@ -40,15 +37,32 @@ private val logger = KotlinLogging.logger("MapView")
|
|||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
actual fun MapView(
|
actual fun MapView(
|
||||||
initialViewPoint: MapViewPoint,
|
|
||||||
mapTileProvider: MapTileProvider,
|
mapTileProvider: MapTileProvider,
|
||||||
|
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
||||||
features: Map<FeatureId, MapFeature>,
|
features: Map<FeatureId, MapFeature>,
|
||||||
onClick: (GeodeticMapCoordinates) -> Unit,
|
onClick: (GeodeticMapCoordinates) -> Unit,
|
||||||
config: MapViewConfig,
|
config: MapViewConfig,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
) {
|
) {
|
||||||
|
var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) }
|
||||||
|
|
||||||
var viewPoint by remember { mutableStateOf(initialViewPoint) }
|
var viewPointOverride by remember { mutableStateOf<MapViewPoint?>(
|
||||||
|
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() }
|
val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
|
||||||
|
|
||||||
@ -56,9 +70,6 @@ actual fun MapView(
|
|||||||
|
|
||||||
val mapTiles = remember { mutableStateListOf<MapTile>() }
|
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) }
|
val centerCoordinates by derivedStateOf { WebMercatorProjection.toMercator(viewPoint.focus, zoom) }
|
||||||
|
|
||||||
fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
|
fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
|
||||||
@ -103,7 +114,10 @@ actual fun MapView(
|
|||||||
val verticalZoom: Float = log2(canvasSize.height.toPx() / rect.height)
|
val verticalZoom: Float = log2(canvasSize.height.toPx() / rect.height)
|
||||||
|
|
||||||
|
|
||||||
viewPoint = MapViewPoint(centerGmc, viewPoint.zoom + kotlin.math.min(verticalZoom, horizontalZoom))
|
viewPointOverride = MapViewPoint(
|
||||||
|
centerGmc,
|
||||||
|
viewPoint.zoom + kotlin.math.min(verticalZoom, horizontalZoom)
|
||||||
|
)
|
||||||
selectRect = null
|
selectRect = null
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -112,7 +126,7 @@ actual fun MapView(
|
|||||||
onClick(dpPos.toGeodetic())
|
onClick(dpPos.toGeodetic())
|
||||||
drag(change.id) { dragChange ->
|
drag(change.id) { dragChange ->
|
||||||
val dragAmount = dragChange.position - dragChange.previousPosition
|
val dragAmount = dragChange.position - dragChange.previousPosition
|
||||||
viewPoint = viewPoint.move(
|
viewPointOverride = viewPoint.move(
|
||||||
-dragAmount.x.toDp().value / tileScale,
|
-dragAmount.x.toDp().value / tileScale,
|
||||||
+dragAmount.y.toDp().value / tileScale
|
+dragAmount.y.toDp().value / tileScale
|
||||||
)
|
)
|
||||||
@ -127,7 +141,7 @@ actual fun MapView(
|
|||||||
val (xPos, yPos) = change.position
|
val (xPos, yPos) = change.position
|
||||||
//compute invariant point of translation
|
//compute invariant point of translation
|
||||||
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic()
|
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()
|
}.fillMaxSize()
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user