diff --git a/src/commonMain/kotlin/centre/sciprog/maps/GeodeticMapCoordinates.kt b/src/commonMain/kotlin/centre/sciprog/maps/GeodeticMapCoordinates.kt index 9f73bc2..d569e3e 100644 --- a/src/commonMain/kotlin/centre/sciprog/maps/GeodeticMapCoordinates.kt +++ b/src/commonMain/kotlin/centre/sciprog/maps/GeodeticMapCoordinates.kt @@ -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 diff --git a/src/commonMain/kotlin/centre/sciprog/maps/GmcBox.kt b/src/commonMain/kotlin/centre/sciprog/maps/GmcBox.kt new file mode 100644 index 0000000..c44c9e9 --- /dev/null +++ b/src/commonMain/kotlin/centre/sciprog/maps/GmcBox.kt @@ -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, longitudes: ClosedFloatingPointRange) = 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.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) +} \ No newline at end of file diff --git a/src/commonMain/kotlin/centre/sciprog/maps/compose/FeatureBuilder.kt b/src/commonMain/kotlin/centre/sciprog/maps/compose/FeatureBuilder.kt index 36f023d..040a110 100644 --- a/src/commonMain/kotlin/centre/sciprog/maps/compose/FeatureBuilder.kt +++ b/src/commonMain/kotlin/centre/sciprog/maps/compose/FeatureBuilder.kt @@ -15,7 +15,11 @@ interface FeatureBuilder { fun build(): SnapshotStateMap } -internal class MapFeatureBuilder(private val content: SnapshotStateMap = mutableStateMapOf()) : FeatureBuilder { +internal class MapFeatureBuilder(initialFeatures: Map) : FeatureBuilder { + + private val content: SnapshotStateMap = mutableStateMapOf().apply { + putAll(initialFeatures) + } private fun generateID(feature: MapFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]" override fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId { diff --git a/src/commonMain/kotlin/centre/sciprog/maps/compose/MapFeature.kt b/src/commonMain/kotlin/centre/sciprog/maps/compose/MapFeature.kt index 75a3b06..700e7aa 100644 --- a/src/commonMain/kotlin/centre/sciprog/maps/compose/MapFeature.kt +++ b/src/commonMain/kotlin/centre/sciprog/maps/compose/MapFeature.kt @@ -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.computeBoundingBox(zoom: Int): GmcBox? = map { it.getBoundingBox(zoom) }.wrapAll() internal fun Pair.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( diff --git a/src/commonMain/kotlin/centre/sciprog/maps/compose/MapView.kt b/src/commonMain/kotlin/centre/sciprog/maps/compose/MapView.kt index 91c2fca..decc7b0 100644 --- a/src/commonMain/kotlin/centre/sciprog/maps/compose/MapView.kt +++ b/src/commonMain/kotlin/centre/sciprog/maps/compose/MapView.kt @@ -3,18 +3,22 @@ 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, onClick: (GeodeticMapCoordinates) -> Unit = {}, //TODO consider replacing by modifier @@ -24,14 +28,39 @@ expect fun MapView( @Composable fun MapView( - initialViewPoint: MapViewPoint, mapTileProvider: MapTileProvider, + initialViewPoint: MapViewPoint, + features: Map = emptyMap(), onClick: (GeodeticMapCoordinates) -> 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 = 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) } \ No newline at end of file diff --git a/src/jvmMain/kotlin/Main.kt b/src/jvmMain/kotlin/Main.kt index 756d00d..5c19ea9 100644 --- a/src/jvmMain/kotlin/Main.kt +++ b/src/jvmMain/kotlin/Main.kt @@ -40,7 +40,12 @@ fun App() { Column { //display click coordinates 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 pointTwo = 55.929444 to 37.518434 @@ -53,10 +58,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()) + ) } } } diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt index fbb544c..2bdf8eb 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt @@ -21,10 +21,7 @@ import kotlinx.coroutines.launch 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 { @@ -40,15 +37,32 @@ private val logger = KotlinLogging.logger("MapView") @OptIn(ExperimentalComposeUiApi::class) @Composable actual fun MapView( - initialViewPoint: MapViewPoint, mapTileProvider: MapTileProvider, + computeViewPoint: (canvasSize: DpSize) -> MapViewPoint, features: Map, onClick: (GeodeticMapCoordinates) -> Unit, config: MapViewConfig, modifier: Modifier, ) { + var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) } - var viewPoint by remember { mutableStateOf(initialViewPoint) } + var viewPointOverride 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() } @@ -56,9 +70,6 @@ actual fun MapView( val mapTiles = remember { mutableStateListOf() } - //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( @@ -103,7 +114,10 @@ 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 + kotlin.math.min(verticalZoom, horizontalZoom) + ) selectRect = null } } else { @@ -112,7 +126,7 @@ actual fun MapView( onClick(dpPos.toGeodetic()) 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 ) @@ -127,7 +141,7 @@ 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()