From fb13fa14315c8462be2e780606f83d93cd172e73 Mon Sep 17 00:00:00 2001 From: Alexander Nozik Date: Sat, 24 Dec 2022 22:59:33 +0300 Subject: [PATCH] Generalize map control logic --- demo/maps/src/jvmMain/kotlin/Main.kt | 2 +- .../maps/compose/GmcCoordinateSpace.kt | 37 +- .../center/sciprog/maps/compose/MapState.kt | 85 ++++ .../center/sciprog/maps/compose/MapView.kt | 20 +- .../center/sciprog/maps/compose/MapViewJvm.kt | 453 +++++++----------- maps-kt-features/build.gradle.kts | 3 + .../sciprog/maps/features/CoordinateSpace.kt | 26 +- .../center/sciprog/maps/features/Feature.kt | 14 +- .../sciprog/maps/features/ViewConfig.kt | 14 + .../sciprog/maps/features/mapControls.kt | 140 ++++++ maps-kt-scheme/build.gradle.kts | 3 +- 11 files changed, 472 insertions(+), 325 deletions(-) create mode 100644 maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapState.kt create mode 100644 maps-kt-features/src/jvmMain/kotlin/center/sciprog/maps/features/ViewConfig.kt create mode 100644 maps-kt-features/src/jvmMain/kotlin/center/sciprog/maps/features/mapControls.kt diff --git a/demo/maps/src/jvmMain/kotlin/Main.kt b/demo/maps/src/jvmMain/kotlin/Main.kt index 3d56780..b5337f6 100644 --- a/demo/maps/src/jvmMain/kotlin/Main.kt +++ b/demo/maps/src/jvmMain/kotlin/Main.kt @@ -57,7 +57,7 @@ fun App() { // 50.kilometers, // 50.kilometers // ), - config = MapViewConfig( + config = ViewConfig( onViewChange = { centerCoordinates = focus }, ) ) { diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/GmcCoordinateSpace.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/GmcCoordinateSpace.kt index 624b46a..7a64f82 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/GmcCoordinateSpace.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/GmcCoordinateSpace.kt @@ -4,17 +4,44 @@ import androidx.compose.ui.unit.DpSize import center.sciprog.maps.coordinates.* import center.sciprog.maps.features.CoordinateSpace import center.sciprog.maps.features.Rectangle +import center.sciprog.maps.features.ViewPoint +import kotlin.math.pow public object GmcCoordinateSpace : CoordinateSpace { - override fun buildRectangle(first: Gmc, second: Gmc): GmcRectangle = GmcRectangle(first, second) + override fun Rectangle(first: Gmc, second: Gmc): GmcRectangle = GmcRectangle(first, second) - override fun buildRectangle(center: Gmc, zoom: Double, size: DpSize): GmcRectangle{ + override fun Rectangle(center: Gmc, zoom: Double, size: DpSize): GmcRectangle { val scale = WebMercatorProjection.scaleFactor(zoom) - return buildRectangle(center, (size.width.value/scale).radians, (size.height.value / scale).radians) + return buildRectangle(center, (size.width.value / scale).radians, (size.height.value / scale).radians) } + override fun ViewPoint(center: Gmc, zoom: Double): ViewPoint = MapViewPoint(center, zoom) + + override fun ViewPoint.moveBy(delta: Gmc): ViewPoint { + val newCoordinates = GeodeticMapCoordinates( + (focus.latitude + delta.latitude).coerceIn( + -MercatorProjection.MAXIMUM_LATITUDE, + MercatorProjection.MAXIMUM_LATITUDE + ), + focus.longitude + delta.longitude + ) + return MapViewPoint(newCoordinates, zoom) + } + + override fun ViewPoint.zoomBy(zoomDelta: Double, invariant: Gmc): ViewPoint = if (invariant == focus) { + ViewPoint(focus, (zoom + zoomDelta).coerceIn(2.0, 18.0) ) + } else { + val difScale = (1 - 2.0.pow(-zoomDelta)) + val newCenter = GeodeticMapCoordinates( + focus.latitude + (invariant.latitude - focus.latitude) * difScale, + focus.longitude + (invariant.longitude - focus.longitude) * difScale + ) + MapViewPoint(newCenter, (zoom + zoomDelta).coerceIn(2.0, 18.0)) + } + + override fun Rectangle.withCenter(center: Gmc): GmcRectangle { - return buildRectangle(center, height = latitudeDelta, width = longitudeDelta) + return buildRectangle(center, height = latitudeDelta, width = longitudeDelta) } override fun Collection>.wrapRectangles(): Rectangle? { @@ -45,7 +72,7 @@ public fun CoordinateSpace.buildRectangle( center: Gmc, height: Distance, width: Distance, - ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84 + ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84, ): GmcRectangle { val reducedRadius = ellipsoid.reducedRadius(center.latitude) return buildRectangle(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians) diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapState.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapState.kt new file mode 100644 index 0000000..4fc494e --- /dev/null +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapState.kt @@ -0,0 +1,85 @@ +package center.sciprog.maps.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import center.sciprog.maps.coordinates.* +import center.sciprog.maps.features.* +import kotlin.math.* + +internal class MapState internal constructor( + config: ViewConfig, + canvasSize: DpSize, + viewPoint: ViewPoint, + public val tileSize: Int, +) : CoordinateViewState(config, canvasSize, viewPoint) { + override val space: CoordinateSpace get() = GmcCoordinateSpace + + public val zoom: Int + get() = floor(viewPoint.zoom).toInt() + + public val scaleFactor: Double + get() = WebMercatorProjection.scaleFactor(viewPoint.zoom) + + public val centerCoordinates: WebMercatorCoordinates + get() = WebMercatorProjection.toMercator(viewPoint.focus, zoom) + + internal val tileScale: Double + get() = 2.0.pow(viewPoint.zoom - zoom) + + private fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates( + zoom, + (x - canvasSize.width / 2).value / tileScale + centerCoordinates.x, + (y - canvasSize.height / 2).value / tileScale + centerCoordinates.y, + ) + + /* + * Convert screen independent offset to GMC, adjusting for fractional zoom + */ + override fun DpOffset.toCoordinates(): Gmc = + WebMercatorProjection.toGeodetic(toMercator()) + + internal fun WebMercatorCoordinates.toOffset(): DpOffset = DpOffset( + (canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()), + (canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()) + ) + + override fun Gmc.toDpOffset(): DpOffset = + WebMercatorProjection.toMercator(this, zoom).toOffset() + + override fun viewPointFor(rectangle: Rectangle): ViewPoint { + val zoom = log2( + min( + canvasSize.width.value / rectangle.longitudeDelta.radians.value, + canvasSize.height.value / rectangle.latitudeDelta.radians.value + ) * PI / tileSize + ) + return MapViewPoint(rectangle.center, zoom) + } + + override fun ViewPoint.moveBy(x: Dp, y: Dp): ViewPoint { + val deltaX = x.value / tileScale + val deltaY = y.value / tileScale + val newCoordinates = GeodeticMapCoordinates( + (focus.latitude + (deltaY / scaleFactor).radians).coerceIn( + -MercatorProjection.MAXIMUM_LATITUDE, + MercatorProjection.MAXIMUM_LATITUDE + ), + focus.longitude + (deltaX / scaleFactor).radians + ) + return MapViewPoint(newCoordinates, zoom) + } +} + +@Composable +internal fun rememberMapState( + config: ViewConfig, + canvasSize: DpSize, + viewPoint: ViewPoint, + tileSize: Int, +):MapState = remember { + MapState(config, canvasSize, viewPoint, tileSize) +} \ No newline at end of file diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt index 86d73bb..8df7c2a 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import center.sciprog.maps.coordinates.Gmc @@ -15,25 +14,12 @@ import kotlin.math.log2 import kotlin.math.min -//TODO consider replacing by modifier -/** - */ -public data class MapViewConfig( - val zoomSpeed: Double = 1.0 / 3.0, - val onClick: MapViewPoint.(PointerEvent) -> Unit = {}, - val dragHandle: DragHandle = DragHandle.bypass(), - val onViewChange: MapViewPoint.() -> Unit = {}, - val onSelect: (GmcRectangle) -> Unit = {}, - val zoomOnSelect: Boolean = true, - val onCanvasSizeChange: (DpSize) -> Unit = {}, -) - @Composable public expect fun MapView( mapTileProvider: MapTileProvider, initialViewPoint: MapViewPoint, featuresState: FeaturesState, - config: MapViewConfig = MapViewConfig(), + config: ViewConfig = ViewConfig(), modifier: Modifier = Modifier.fillMaxSize(), ) @@ -61,7 +47,7 @@ public fun MapView( initialViewPoint: MapViewPoint? = null, initialRectangle: GmcRectangle? = null, featureMap: Map, MapFeature>, - config: MapViewConfig = MapViewConfig(), + config: ViewConfig = ViewConfig(), modifier: Modifier = Modifier.fillMaxSize(), ) { val featuresState = key(featureMap) { @@ -92,7 +78,7 @@ public fun MapView( mapTileProvider: MapTileProvider, initialViewPoint: MapViewPoint? = null, initialRectangle: GmcRectangle? = null, - config: MapViewConfig = MapViewConfig(), + config: ViewConfig = ViewConfig(), modifier: Modifier = Modifier.fillMaxSize(), buildFeatures: FeaturesState.() -> Unit = {}, ) { diff --git a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt index 936d676..eec17b8 100644 --- a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt +++ b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt @@ -1,20 +1,20 @@ package center.sciprog.maps.compose import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.drag -import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.* -import androidx.compose.ui.input.pointer.* -import androidx.compose.ui.unit.* -import center.sciprog.maps.coordinates.* +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import center.sciprog.maps.coordinates.GeodeticMapCoordinates +import center.sciprog.maps.coordinates.Gmc +import center.sciprog.maps.coordinates.radians +import center.sciprog.maps.coordinates.toFloat import center.sciprog.maps.features.* import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope @@ -31,17 +31,6 @@ private fun Color.toPaint(): Paint = Paint().apply { private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last) -internal fun MapViewPoint.move(deltaX: Double, deltaY: Double): MapViewPoint { - val newCoordinates = GeodeticMapCoordinates( - (focus.latitude + (deltaY / scaleFactor).radians).coerceIn( - -MercatorProjection.MAXIMUM_LATITUDE, - MercatorProjection.MAXIMUM_LATITUDE - ), - focus.longitude + (deltaX / scaleFactor).radians - ) - return MapViewPoint(newCoordinates, zoom) -} - private val logger = KotlinLogging.logger("MapView") /** @@ -52,320 +41,208 @@ public actual fun MapView( mapTileProvider: MapTileProvider, initialViewPoint: MapViewPoint, featuresState: FeaturesState, - config: MapViewConfig, + config: ViewConfig, modifier: Modifier, ): Unit = key(initialViewPoint) { - var canvasSize by remember { mutableStateOf(defaultCanvasSize) } - var viewPoint by remember { mutableStateOf(initialViewPoint) } - - require(viewPoint.zoom in 1.0..18.0) { "Zoom value of ${viewPoint.zoom} is not valid" } - - fun setViewPoint(newViewPoint: MapViewPoint) { - config.onViewChange(newViewPoint) - viewPoint = newViewPoint - } - - val zoom: Int by derivedStateOf { - require(viewPoint.zoom in 1.0..18.0) { "Zoom value of ${viewPoint.zoom} is not valid" } - floor(viewPoint.zoom).toInt() - } - - val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) } - - val mapTiles = remember(mapTileProvider) { mutableStateListOf() } - - val centerCoordinates by derivedStateOf { WebMercatorProjection.toMercator(viewPoint.focus, zoom) } - - fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates( - zoom, - (x - canvasSize.width / 2).value / tileScale + centerCoordinates.x, - (y - canvasSize.height / 2).value / tileScale + centerCoordinates.y, + val state = rememberMapState( + config, + defaultCanvasSize, + initialViewPoint, + mapTileProvider.tileSize ) + val canvasModifier = modifier.mapControls(state).fillMaxSize() - /* - * Convert screen independent offset to GMC, adjusting for fractional zoom - */ - fun DpOffset.toGeodetic() = WebMercatorProjection.toGeodetic(toMercator()) + with(state) { - // Selection rectangle. If null - no selection - var selectRect by remember { mutableStateOf(null) } + val mapTiles = remember(mapTileProvider) { mutableStateListOf() } - @OptIn(ExperimentalComposeUiApi::class) - val canvasModifier = modifier.pointerInput(Unit) { - forEachGesture { - awaitPointerEventScope { - fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp()) + // Load tiles asynchronously + LaunchedEffect(viewPoint, canvasSize) { + with(mapTileProvider) { + val indexRange = 0 until 2.0.pow(zoom).toInt() - val event: PointerEvent = awaitPointerEvent() + val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale + val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale + val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange) - event.changes.forEach { change -> - val dragStart = change.position - val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp()) + val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale) + val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) + val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange) - //start selection - var selectionStart: Offset? = - if (event.buttons.isPrimaryPressed && event.keyboardModifiers.isShiftPressed) { - change.position - } else { - null - } + mapTiles.clear() - drag(change.id) { dragChange -> - val dragAmount = dragChange.position - dragChange.previousPosition - val dpStart = dragChange.previousPosition.toDpOffset() - val dpEnd = dragChange.position.toDpOffset() - - //apply drag handle and check if it prohibits the drag even propagation - if (selectionStart == null && !config.dragHandle.handle( - event, - MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom), - MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom) - ) - ) { - return@drag - } - - if (event.buttons.isPrimaryPressed) { - //If selection process is started, modify the frame - selectionStart?.let { start -> - val offset = dragChange.position - selectRect = Rect( - min(offset.x, start.x), - min(offset.y, start.y), - max(offset.x, start.x), - max(offset.y, start.y) - ) - return@drag + for (j in verticalIndices) { + for (i in horizontalIndices) { + val id = TileId(zoom, i, j) + //ensure that failed tiles do not fail the application + supervisorScope { + //start all + val deferred = loadTileAsync(id) + //wait asynchronously for it to finish + launch { + try { + mapTiles += deferred.await() + } catch (ex: Exception) { + //displaying the error is maps responsibility + logger.error(ex) { "Failed to load tile with id=$id" } + } } - -// config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom), event) - //If no selection, drag map - setViewPoint( - viewPoint.move( - -dragAmount.x.toDp().value / tileScale, - +dragAmount.y.toDp().value / tileScale - ) - ) } + } - // evaluate selection - selectRect?.let { rect -> - //Use selection override if it is defined - val gmcBox = GmcRectangle( - rect.topLeft.toDpOffset().toGeodetic(), - rect.bottomRight.toDpOffset().toGeodetic() + } + } + } + + val painterCache = key(featuresState) { + featuresState.features.values.filterIsInstance>().associateWith { it.painter() } + } + + Canvas(canvasModifier) { + fun GeodeticMapCoordinates.toOffset(): Offset = toOffset(this@Canvas) + + fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) { + when (feature) { + is FeatureSelector -> drawFeature(zoom, feature.selector(zoom)) + is CircleFeature -> drawCircle( + feature.color, + feature.size.toPx(), + center = feature.center.toOffset() + ) + + is RectangleFeature -> drawRect( + feature.color, + topLeft = feature.center.toOffset() - Offset( + feature.size.width.toPx() / 2, + feature.size.height.toPx() / 2 + ), + size = feature.size.toSize() + ) + + is LineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset()) + is ArcFeature -> { + val topLeft = feature.oval.topLeft.toOffset() + val bottomRight = feature.oval.bottomRight.toOffset() + + val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y)) + + drawArc( + color = feature.color, + startAngle = feature.startAngle.radians.degrees.toFloat(), + sweepAngle = feature.arcLength.radians.degrees.toFloat(), + useCenter = false, + topLeft = topLeft, + size = size, + style = Stroke() ) - config.onSelect(gmcBox) - if (config.zoomOnSelect) { - setViewPoint(gmcBox.computeViewPoint(mapTileProvider, canvasSize)) - } - selectRect = null + } - } - } - } - }.onPointerEvent(PointerEventType.Scroll) { - val change = it.changes.first() - val (xPos, yPos) = change.position - //compute invariant point of translation - val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic() - setViewPoint(viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant)) - }.fillMaxSize() + is BitmapImageFeature -> drawImage(feature.image, feature.position.toOffset()) - // Load tiles asynchronously - LaunchedEffect(viewPoint, canvasSize) { - with(mapTileProvider) { - 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: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange) - - val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale) - val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) - val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange) - - mapTiles.clear() - - for (j in verticalIndices) { - for (i in horizontalIndices) { - val id = TileId(zoom, i, j) - //ensure that failed tiles do not fail the application - supervisorScope { - //start all - val deferred = loadTileAsync(id) - //wait asynchronously for it to finish - launch { - try { - mapTiles += deferred.await() - } catch (ex: Exception) { - //displaying the error is maps responsibility - logger.error(ex) { "Failed to load tile with id=$id" } + is VectorImageFeature -> { + val offset = feature.position.toOffset() + val size = feature.size.toSize() + translate(offset.x - size.width / 2, offset.y - size.height / 2) { + with(painterCache[feature]!!) { + draw(size) } } } - } + is TextFeature -> drawIntoCanvas { canvas -> + val offset = feature.position.toOffset() + canvas.nativeCanvas.drawString( + feature.text, + offset.x + 5, + offset.y - 5, + Font().apply(feature.fontConfig), + feature.color.toPaint() + ) + } - } - } - } - - val painterCache = key(featuresState) { - featuresState.features.values.filterIsInstance>().associateWith { it.painter() } - } - - Canvas(canvasModifier) { - fun WebMercatorCoordinates.toOffset(): Offset = Offset( - (canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(), - (canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx() - ) - - //Convert GMC to offset in pixels (not DP), adjusting for zoom - fun GeodeticMapCoordinates.toOffset(): Offset = WebMercatorProjection.toMercator(this, zoom).toOffset() - - - fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) { - when (feature) { - is FeatureSelector -> drawFeature(zoom, feature.selector(zoom)) - is CircleFeature -> drawCircle( - feature.color, - feature.size.toPx(), - center = feature.center.toOffset() - ) - - is RectangleFeature -> drawRect( - feature.color, - topLeft = feature.center.toOffset() - Offset( - feature.size.width.toPx() / 2, - feature.size.height.toPx() / 2 - ), - size = feature.size.toSize() - ) - - is LineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset()) - is ArcFeature -> { - val topLeft = feature.oval.topLeft.toOffset() - val bottomRight = feature.oval.bottomRight.toOffset() - - val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y)) - - drawArc( - color = feature.color, - startAngle = feature.startAngle.radians.degrees.toFloat(), - sweepAngle = feature.arcLength.radians.degrees.toFloat(), - useCenter = false, - topLeft = topLeft, - size = size, - style = Stroke() - ) - - } - - is BitmapImageFeature -> drawImage(feature.image, feature.position.toOffset()) - - is VectorImageFeature -> { - val offset = feature.position.toOffset() - val size = feature.size.toSize() - translate(offset.x - size.width / 2, offset.y - size.height / 2) { - with(painterCache[feature]!!) { - draw(size) + is DrawFeature -> { + val offset = feature.position.toOffset() + translate(offset.x, offset.y) { + feature.drawFeature(this) } } - } - is TextFeature -> drawIntoCanvas { canvas -> - val offset = feature.position.toOffset() - canvas.nativeCanvas.drawString( - feature.text, - offset.x + 5, - offset.y - 5, - Font().apply(feature.fontConfig), - feature.color.toPaint() - ) - } - - is DrawFeature -> { - val offset = feature.position.toOffset() - translate(offset.x, offset.y) { - feature.drawFeature(this) + is FeatureGroup -> { + feature.children.values.forEach { + drawFeature(zoom, it) + } } - } - is FeatureGroup -> { - feature.children.values.forEach { - drawFeature(zoom, it) - } - } - - is PathFeature -> { - TODO("MapPathFeature not implemented") + is PathFeature -> { + TODO("MapPathFeature not implemented") // val offset = feature.rectangle.center.toOffset() - feature.targetRect.center // translate(offset.x, offset.y) { // sca // drawPath(feature.path, brush = feature.brush, style = feature.style) // } - } + } - is PointsFeature -> { - val points = feature.points.map { it.toOffset() } - drawPoints( - points = points, - color = feature.color, - strokeWidth = feature.stroke, - pointMode = feature.pointMode - ) - } + is PointsFeature -> { + val points = feature.points.map { it.toOffset() } + drawPoints( + points = points, + color = feature.color, + strokeWidth = feature.stroke, + pointMode = feature.pointMode + ) + } // else -> { // logger.error { "Unrecognized feature type: ${feature::class}" } // } - } - } - - if (canvasSize != size.toDpSize()) { - logger.debug { "Recalculate canvas. Size: $size" } - config.onCanvasSizeChange(canvasSize) - canvasSize = size.toDpSize() - } - - clipRect { - val tileSize = IntSize( - ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(), - ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt() - ) - mapTiles.forEach { (id, image) -> - //converting back from tile index to screen offset - val offset = IntOffset( - (canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(), - (canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx() - ) - drawImage( - image = image.toComposeImageBitmap(), - dstOffset = offset, - dstSize = tileSize - ) + } } - featuresState.features.values.filter { viewPoint.zoom in it.zoomRange }.forEach { feature -> - drawFeature(zoom, feature) + if (canvasSize != size.toDpSize()) { + logger.debug { "Recalculate canvas. Size: $size" } + config.onCanvasSizeChange(canvasSize) + canvasSize = size.toDpSize() } - } - selectRect?.let { rect -> - drawRect( - color = Color.Blue, - topLeft = rect.topLeft, - size = rect.size, - alpha = 0.5f, - style = Stroke( - width = 2f, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + clipRect { + val tileSize = IntSize( + ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(), + ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt() ) - ) + mapTiles.forEach { (id, image) -> + //converting back from tile index to screen offset + val offset = IntOffset( + (canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(), + (canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx() + ) + drawImage( + image = image.toComposeImageBitmap(), + dstOffset = offset, + dstSize = tileSize + ) + } + + featuresState.features.values.filter { viewPoint.zoom in it.zoomRange }.forEach { feature -> + drawFeature(zoom, feature) + } + } + + selectRect?.let { dpRect -> + val rect = dpRect.toRect() + drawRect( + color = Color.Blue, + topLeft = rect.topLeft, + size = rect.size, + alpha = 0.5f, + style = Stroke( + width = 2f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + ) + } } } } diff --git a/maps-kt-features/build.gradle.kts b/maps-kt-features/build.gradle.kts index b6c1a61..5dfb148 100644 --- a/maps-kt-features/build.gradle.kts +++ b/maps-kt-features/build.gradle.kts @@ -17,6 +17,9 @@ kotlin { dependencies { api(compose.foundation) } + } + val jvmMain by getting{ + } val jvmTest by getting { dependencies { diff --git a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/CoordinateSpace.kt b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/CoordinateSpace.kt index 6c1746e..0bab27c 100644 --- a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/CoordinateSpace.kt +++ b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/CoordinateSpace.kt @@ -3,7 +3,7 @@ package center.sciprog.maps.features import androidx.compose.ui.unit.DpSize -public interface Area{ +public interface Area { public operator fun contains(point: T): Boolean } @@ -11,7 +11,7 @@ public interface Area{ * A map coordinates rectangle. [a] and [b] represent opposing angles * of the rectangle without specifying which ones. */ -public interface Rectangle: Area { +public interface Rectangle : Area { public val a: T public val b: T } @@ -24,12 +24,24 @@ public interface CoordinateSpace { /** * Build a rectangle by two opposing corners */ - public fun buildRectangle(first: T, second: T): Rectangle + public fun Rectangle(first: T, second: T): Rectangle /** * Build a rectangle of visual size [size] */ - public fun buildRectangle(center: T, zoom: Double, size: DpSize): Rectangle + public fun Rectangle(center: T, zoom: Double, size: DpSize): Rectangle + + /** + * Create a [ViewPoint] associated with this coordinate space. + */ + public fun ViewPoint(center: T, zoom: Double): ViewPoint + + public fun ViewPoint.moveBy(delta: T): ViewPoint + + public fun ViewPoint.zoomBy( + zoomDelta: Double, + invariant: T = focus, + ): ViewPoint /** * Move given rectangle to be centered at [center] @@ -39,4 +51,8 @@ public interface CoordinateSpace { public fun Collection>.wrapRectangles(): Rectangle? public fun Collection.wrapPoints(): Rectangle? -} \ No newline at end of file + +} + +public fun CoordinateSpace.Rectangle(viewPoint: ViewPoint, size: DpSize): Rectangle = + Rectangle(viewPoint.focus, viewPoint.zoom, size) \ No newline at end of file diff --git a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Feature.kt b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Feature.kt index 219ed2b..37b63d5 100644 --- a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Feature.kt +++ b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Feature.kt @@ -116,7 +116,7 @@ public data class CircleFeature( override var attributes: AttributeMap = AttributeMap(), ) : DraggableFeature { override fun getBoundingBox(zoom: Double): Rectangle = - space.buildRectangle(center, zoom, DpSize(size, size)) + space.Rectangle(center, zoom, DpSize(size, size)) override fun withCoordinates(newCoordinates: T): Feature = CircleFeature(space, newCoordinates, zoomRange, size, color, attributes) @@ -131,7 +131,7 @@ public class RectangleFeature( override var attributes: AttributeMap = AttributeMap(), ) : DraggableFeature { override fun getBoundingBox(zoom: Double): Rectangle = - space.buildRectangle(center, zoom, size) + space.Rectangle(center, zoom, size) override fun withCoordinates(newCoordinates: T): Feature = RectangleFeature(space, newCoordinates, zoomRange, size, color, attributes) @@ -146,7 +146,7 @@ public class LineFeature( override var attributes: AttributeMap = AttributeMap(), ) : SelectableFeature { override fun getBoundingBox(zoom: Double): Rectangle = - space.buildRectangle(a, b) + space.Rectangle(a, b) override fun contains(point: ViewPoint): Boolean { return super.contains(point) @@ -181,7 +181,7 @@ public data class DrawFeature( override var attributes: AttributeMap = AttributeMap(), public val drawFeature: DrawScope.() -> Unit, ) : DraggableFeature { - override fun getBoundingBox(zoom: Double): Rectangle = space.buildRectangle(position, position) + override fun getBoundingBox(zoom: Double): Rectangle = space.Rectangle(position, position) override fun withCoordinates(newCoordinates: T): Feature = copy(position = newCoordinates) } @@ -194,7 +194,7 @@ public data class BitmapImageFeature( override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, override var attributes: AttributeMap = AttributeMap(), ) : DraggableFeature { - override fun getBoundingBox(zoom: Double): Rectangle = space.buildRectangle(position, zoom, size) + override fun getBoundingBox(zoom: Double): Rectangle = space.Rectangle(position, zoom, size) override fun withCoordinates(newCoordinates: T): Feature = copy(position = newCoordinates) } @@ -207,7 +207,7 @@ public data class VectorImageFeature( override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, override var attributes: AttributeMap = AttributeMap(), ) : DraggableFeature { - override fun getBoundingBox(zoom: Double): Rectangle = space.buildRectangle(position, zoom, size) + override fun getBoundingBox(zoom: Double): Rectangle = space.Rectangle(position, zoom, size) override fun withCoordinates(newCoordinates: T): Feature = copy(position = newCoordinates) @@ -238,7 +238,7 @@ public class TextFeature( override var attributes: AttributeMap = AttributeMap(), public val fontConfig: FeatureFont.() -> Unit, ) : DraggableFeature { - override fun getBoundingBox(zoom: Double): Rectangle = space.buildRectangle(position, position) + override fun getBoundingBox(zoom: Double): Rectangle = space.Rectangle(position, position) override fun withCoordinates(newCoordinates: T): Feature = TextFeature(space, newCoordinates, text, zoomRange, color, attributes, fontConfig) diff --git a/maps-kt-features/src/jvmMain/kotlin/center/sciprog/maps/features/ViewConfig.kt b/maps-kt-features/src/jvmMain/kotlin/center/sciprog/maps/features/ViewConfig.kt new file mode 100644 index 0000000..6030052 --- /dev/null +++ b/maps-kt-features/src/jvmMain/kotlin/center/sciprog/maps/features/ViewConfig.kt @@ -0,0 +1,14 @@ +package center.sciprog.maps.features + +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.unit.DpSize + +public data class ViewConfig( + val zoomSpeed: Double = 1.0 / 3.0, + val onClick: ViewPoint.(PointerEvent) -> Unit = {}, + val dragHandle: DragHandle = DragHandle.bypass(), + val onViewChange: ViewPoint.() -> Unit = {}, + val onSelect: (Rectangle) -> Unit = {}, + val zoomOnSelect: Boolean = true, + val onCanvasSizeChange: (DpSize) -> Unit = {}, +) \ No newline at end of file diff --git a/maps-kt-features/src/jvmMain/kotlin/center/sciprog/maps/features/mapControls.kt b/maps-kt-features/src/jvmMain/kotlin/center/sciprog/maps/features/mapControls.kt new file mode 100644 index 0000000..0a8eb6c --- /dev/null +++ b/maps-kt-features/src/jvmMain/kotlin/center/sciprog/maps/features/mapControls.kt @@ -0,0 +1,140 @@ +package center.sciprog.maps.features + +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.unit.* +import kotlin.math.max +import kotlin.math.min + + +public abstract class CoordinateViewState( + public val config: ViewConfig, + canvasSize: DpSize, + viewPoint: ViewPoint, +) { + + public abstract val space: CoordinateSpace + + public var canvasSize: DpSize by mutableStateOf(canvasSize) + protected var viewPointState: MutableState> = mutableStateOf(viewPoint) + + public var viewPoint: ViewPoint + get() = viewPointState.value + set(value) { + config.onViewChange(value) + viewPointState.value = value + } + + public abstract fun DpOffset.toCoordinates(): T + + public abstract fun T.toDpOffset(): DpOffset + + public fun T.toOffset(density: Density): Offset = with(density){ + val dpOffset = this@toOffset.toDpOffset() + Offset(dpOffset.x.toPx(), dpOffset.y.toPx()) + } + + public abstract fun ViewPoint.moveBy(x: Dp, y: Dp): ViewPoint + + public abstract fun viewPointFor(rectangle: Rectangle): ViewPoint + + // Selection rectangle. If null - no selection + public var selectRect: DpRect? by mutableStateOf(null) +} + +public val DpRect.topLeft: DpOffset get() = DpOffset(left, top) +public val DpRect.bottomRight: DpOffset get() = DpOffset(right, bottom) + + +@OptIn(ExperimentalComposeUiApi::class) +public fun Modifier.mapControls( + state: CoordinateViewState, +): Modifier = with(state) { + pointerInput(Unit) { + forEachGesture { + awaitPointerEventScope { + fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp()) + + val event: PointerEvent = awaitPointerEvent() + + event.changes.forEach { change -> + val dragStart = change.position + val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp()) + + //start selection + val selectionStart: Offset? = + if (event.buttons.isPrimaryPressed && event.keyboardModifiers.isShiftPressed) { + change.position + } else { + null + } + + drag(change.id) { dragChange -> + val dragAmount: Offset = dragChange.position - dragChange.previousPosition + val dpStart = dragChange.previousPosition.toDpOffset() + val dpEnd = dragChange.position.toDpOffset() + + //apply drag handle and check if it prohibits the drag even propagation + if (selectionStart == null && !config.dragHandle.handle( + event, + space.ViewPoint(dpStart.toCoordinates(), viewPoint.zoom), + space.ViewPoint(dpEnd.toCoordinates(), viewPoint.zoom) + ) + ) { + return@drag + } + + if (event.buttons.isPrimaryPressed) { + //If selection process is started, modify the frame + selectionStart?.let { start -> + val offset = dragChange.position + selectRect = DpRect( + min(offset.x, start.x).dp, + min(offset.y, start.y).dp, + max(offset.x, start.x).dp, + max(offset.y, start.y).dp + ) + return@drag + } + +// config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom), event) + //If no selection, drag map + viewPoint = viewPoint.moveBy( + -dragAmount.x.toDp(), + dragAmount.y.toDp() + ) + + } + } + + // evaluate selection + selectRect?.let { rect -> + //Use selection override if it is defined + val coordinateRect = space.Rectangle( + rect.topLeft.toCoordinates(), + rect.bottomRight.toCoordinates() + ) + config.onSelect(coordinateRect) + if (config.zoomOnSelect) { + viewPoint = viewPointFor(coordinateRect) + } + selectRect = null + } + } + } + } + }.onPointerEvent(PointerEventType.Scroll) { + val change = it.changes.first() + val (xPos, yPos) = change.position + //compute invariant point of translation + val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toCoordinates() + viewPoint = with(space) { + viewPoint.zoomBy(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant) + } + } +} \ No newline at end of file diff --git a/maps-kt-scheme/build.gradle.kts b/maps-kt-scheme/build.gradle.kts index 1348dea..08b4e24 100644 --- a/maps-kt-scheme/build.gradle.kts +++ b/maps-kt-scheme/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.compose.compose import space.kscience.gradle.KScienceVersions.JVM_TARGET @@ -30,6 +29,6 @@ kotlin { } } -java{ +java { targetCompatibility = JVM_TARGET } \ No newline at end of file