diff --git a/demo/maps/src/jvmMain/kotlin/AngleConversion.kt b/demo/maps/src/jvmMain/kotlin/AngleConversion.kt new file mode 100644 index 0000000..a7ec5e5 --- /dev/null +++ b/demo/maps/src/jvmMain/kotlin/AngleConversion.kt @@ -0,0 +1,4 @@ +import kotlin.math.PI + +fun Double.toDegrees() = this * 180 / PI + diff --git a/demo/maps/src/jvmMain/kotlin/Main.kt b/demo/maps/src/jvmMain/kotlin/Main.kt index 866bf5b..cd24e90 100644 --- a/demo/maps/src/jvmMain/kotlin/Main.kt +++ b/demo/maps/src/jvmMain/kotlin/Main.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PointMode import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import center.sciprog.maps.compose.* @@ -49,41 +50,42 @@ fun App() { var centerCoordinates by remember { mutableStateOf(null) } -// val markers = (1..1_000_000).map { -// val position = GeodeticMapCoordinates.ofDegrees( -// latitude = Random.nextDouble(-90.0, 90.0), -// longitude = Random.nextDouble(0.0, 180.0) -// ) -// MapDrawFeature( -// position = position, -// computeBoundingBox = { -// GmcBox.withCenter( -// center = position, -// width = Distance(0.001), -// height = Distance(0.001) -// ) -// } -// ) { -// drawRoundRect( -// color = Color.Yellow, -// size = Size(1f, 1f) -// ) -// } -// } + + val pointOne = 55.568548 to 37.568604 + var pointTwo by remember { mutableStateOf(55.929444 to 37.518434) } + val pointThree = 60.929444 to 37.518434 val state = MapViewState( mapTileProvider = mapTileProvider, - computeViewPoint = { viewPoint }, + initialViewPoint = viewPoint, config = MapViewConfig( inferViewBoxFromFeatures = true, onViewChange = { centerCoordinates = focus }, + onDrag = { start, end -> + if (start.focus.latitude.toDegrees() in (pointTwo.first - 0.05)..(pointTwo.first + 0.05) && + start.focus.longitude.toDegrees() in (pointTwo.second - 0.05)..(pointTwo.second + 0.05) + ) { + pointTwo = pointTwo.first + (end.focus.latitude - start.focus.latitude).toDegrees() to + pointTwo.second + (end.focus.longitude - start.focus.longitude).toDegrees() + false// returning false, because when we are dragging circle we don't want to drag map + } else true + } ) ) { - val pointOne = 55.568548 to 37.568604 - val pointTwo = 55.929444 to 37.518434 - val pointThree = 60.929444 to 37.518434 image(pointOne, Icons.Filled.Home) + points( + points = listOf( + 55.742465 to 37.615812, + 55.742713 to 37.616370, + 55.742815 to 37.616659, + 55.742320 to 37.617132, + 55.742086 to 37.616566, + 55.741715 to 37.616716 + ), + pointMode = PointMode.Polygon + ) + //remember feature Id val circleId: FeatureId = circle( centerCoordinates = pointTwo, @@ -106,15 +108,9 @@ fun App() { drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red) } -// markers.forEach { feature -> -// featureSelector { -// feature -// } -// } - arc(pointOne, Distance(10.0), 0f, PI) - line(pointOne, pointTwo) + line(pointOne, pointTwo, id = "line") text(pointOne, "Home", font = { size = 32f }) centerCoordinates?.let { diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt index eadd12e..cb7a077 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt @@ -3,6 +3,7 @@ package center.sciprog.maps.compose import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.PointMode import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector @@ -46,6 +47,18 @@ public class MapDrawFeature( override fun getBoundingBox(zoom: Int): GmcBox = computeBoundingBox(zoom) } +public class MapPointsFeature( + public val points: List, + override val zoomRange: IntRange = defaultZoomRange, + public val stroke: Float = 2f, + public val color: Color = Color.Red, + public val pointMode: PointMode = PointMode.Points +) : MapFeature { + override fun getBoundingBox(zoom: Int): GmcBox { + return GmcBox(points.first(), points.last()) + } +} + public class MapCircleFeature( public val center: GeodeticMapCoordinates, override val zoomRange: IntRange = defaultZoomRange, diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt index 700acee..14ca946 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PointMode import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.DpSize @@ -117,6 +118,15 @@ public fun MapFeatureBuilder.arc( ) ) +public fun MapFeatureBuilder.points( + points: List>, + zoomRange: IntRange = defaultZoomRange, + stroke: Float = 2f, + color: Color = Color.Red, + pointMode: PointMode = PointMode.Points, + id: FeatureId? = null +): FeatureId = addFeature(id, MapPointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode)) + @Composable public fun MapFeatureBuilder.image( position: Pair, 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 22dfff1..b68cfc5 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 @@ -11,10 +11,15 @@ import kotlin.math.min //TODO consider replacing by modifier +/** + * @param onDrag - returns true if you want to drag a map and false, if you want to make map stationary. + * start - is a point where drag begins, end is a point where drag ends + */ public data class MapViewConfig( val zoomSpeed: Double = 1.0 / 3.0, val inferViewBoxFromFeatures: Boolean = false, val onClick: MapViewPoint.() -> Unit = {}, + val onDrag: (start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true }, val onViewChange: MapViewPoint.() -> Unit = {}, val onSelect: (GmcBox) -> Unit = {}, val zoomOnSelect: Boolean = true, 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 ccea0f1..e9cc354 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 @@ -51,160 +51,166 @@ public actual fun MapView( mapViewState: MapViewState, modifier: Modifier, ) { - @OptIn(ExperimentalComposeUiApi::class) - val canvasModifier = modifier.pointerInput(Unit) { - forEachGesture { - awaitPointerEventScope { - fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp()) + with(mapViewState) { + @OptIn(ExperimentalComposeUiApi::class) + val canvasModifier = modifier.pointerInput(Unit) { + forEachGesture { + awaitPointerEventScope { + fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp()) - val event: PointerEvent = awaitPointerEvent() - event.changes.forEach { change -> - if (event.buttons.isPrimaryPressed) { - //Evaluating selection frame - if (event.keyboardModifiers.isShiftPressed) { - mapViewState.selectRect = Rect(change.position, change.position) - drag(change.id) { dragChange -> - mapViewState.selectRect?.let { rect -> - val offset = dragChange.position - mapViewState.selectRect = Rect( - min(offset.x, rect.left), - min(offset.y, rect.top), - max(offset.x, rect.right), - max(offset.y, rect.bottom) + val event: PointerEvent = awaitPointerEvent() + event.changes.forEach { change -> + if (event.buttons.isPrimaryPressed) { + //Evaluating selection frame + if (event.keyboardModifiers.isShiftPressed) { + selectRect = Rect(change.position, change.position) + drag(change.id) { dragChange -> + selectRect?.let { rect -> + val offset = dragChange.position + selectRect = Rect( + min(offset.x, rect.left), + min(offset.y, rect.top), + max(offset.x, rect.right), + max(offset.y, rect.bottom) + ) + } + } + selectRect?.let { rect -> + //Use selection override if it is defined + val gmcBox = GmcBox( + rect.topLeft.toDpOffset().toGeodetic(), + rect.bottomRight.toDpOffset().toGeodetic() + ) + + config.onSelect(gmcBox) + if (config.zoomOnSelect) { + val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize) + + config.onViewChange(newViewPoint) + viewPointInternal = newViewPoint + } + selectRect = null + } + } else { + val dragStart = change.position + val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp()) + config.onClick( + MapViewPoint( + dpPos.toGeodetic() , + viewPoint.zoom ) + ) + drag(change.id) { dragChange -> + val dragAmount = dragChange.position - dragChange.previousPosition + val dpStart = + DpOffset( + dragChange.previousPosition.x.toDp(), + dragChange.previousPosition.y.toDp() + ) + val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp()) + if (!config.onDrag( + MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom), + MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom) + ) + ) return@drag + val newViewPoint = viewPoint.move( + -dragAmount.x.toDp().value / tileScale, + +dragAmount.y.toDp().value / tileScale + ) + config.onViewChange(newViewPoint) + viewPointInternal = newViewPoint } } - mapViewState.selectRect?.let { rect -> - //Use selection override if it is defined - val gmcBox = with(mapViewState) { - GmcBox( - rect.topLeft.toDpOffset().toGeodetic(), - rect.bottomRight.toDpOffset().toGeodetic() - ) - } - mapViewState.config.onSelect(gmcBox) - if (mapViewState.config.zoomOnSelect) { - val newViewPoint = gmcBox.computeViewPoint(mapViewState.mapTileProvider) - .invoke(mapViewState.canvasSize) + } + } + } + } + }.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() + val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant) + config.onViewChange(newViewPoint) + viewPointInternal = newViewPoint + }.fillMaxSize() - mapViewState.config.onViewChange(newViewPoint) - mapViewState.viewPointInternal = newViewPoint + + // 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) + //start all + val deferred = loadTileAsync(id) + //wait asynchronously for it to finish + launch { + try { + mapTiles += deferred.await() + } catch (ex: Exception) { + if (ex !is CancellationException) { + //displaying the error is maps responsibility + logger.error(ex) { "Failed to load tile with id=$id" } } - mapViewState.selectRect = null - } - } else { - val dragStart = change.position - val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp()) - mapViewState.config.onClick( - MapViewPoint( - with(mapViewState) { dpPos.toGeodetic() }, - mapViewState.viewPoint.zoom - ) - ) - drag(change.id) { dragChange -> - val dragAmount = dragChange.position - dragChange.previousPosition - val newViewPoint = mapViewState.viewPoint.move( - -dragAmount.x.toDp().value / mapViewState.tileScale, - +dragAmount.y.toDp().value / mapViewState.tileScale - ) - mapViewState.config.onViewChange(newViewPoint) - mapViewState.viewPointInternal = newViewPoint } } } } } } - }.onPointerEvent(PointerEventType.Scroll) { - val change = it.changes.first() - val (xPos, yPos) = change.position - //compute invariant point of translation - val invariant = with(mapViewState) { DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic() } - val newViewPoint = - mapViewState.viewPoint.zoom(-change.scrollDelta.y.toDouble() * mapViewState.config.zoomSpeed, invariant) - mapViewState.config.onViewChange(newViewPoint) - mapViewState.viewPointInternal = newViewPoint - }.fillMaxSize() - // Load tiles asynchronously - LaunchedEffect(mapViewState.viewPoint, mapViewState.canvasSize) { - with(mapViewState.mapTileProvider) { - val indexRange = 0 until 2.0.pow(mapViewState.zoom).toInt() - - val left = - mapViewState.centerCoordinates.x - mapViewState.canvasSize.width.value / 2 / mapViewState.tileScale - val right = - mapViewState.centerCoordinates.x + mapViewState.canvasSize.width.value / 2 / mapViewState.tileScale - val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange) - - val top = - (mapViewState.centerCoordinates.y + mapViewState.canvasSize.height.value / 2 / mapViewState.tileScale) - val bottom = - (mapViewState.centerCoordinates.y - mapViewState.canvasSize.height.value / 2 / mapViewState.tileScale) - val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange) - - mapViewState.mapTiles.clear() - - for (j in verticalIndices) { - for (i in horizontalIndices) { - val id = TileId(mapViewState.zoom, i, j) - //start all - val deferred = loadTileAsync(id) - //wait asynchronously for it to finish - launch { - try { - mapViewState.mapTiles += deferred.await() - } catch (ex: Exception) { - if (ex !is CancellationException) { - //displaying the error is maps responsibility - logger.error(ex) { "Failed to load tile with id=$id" } - } - } - } - } - } - } - } - - - Canvas(canvasModifier) { + Canvas(canvasModifier) { if (mapViewState.canvasSize != size.toDpSize()) { mapViewState.canvasSize = size.toDpSize() - logger.debug { "Recalculate canvas. Size: $size" } - } - clipRect { - val tileSize = IntSize( - ceil((mapViewState.mapTileProvider.tileSize.dp * mapViewState.tileScale.toFloat()).toPx()).toInt(), - ceil((mapViewState.mapTileProvider.tileSize.dp * mapViewState.tileScale.toFloat()).toPx()).toInt() - ) - mapViewState.mapTiles.forEach { (id, image) -> - //converting back from tile index to screen offset - val offset = IntOffset( - (mapViewState.canvasSize.width / 2 + (mapViewState.mapTileProvider.toCoordinate(id.i).dp - mapViewState.centerCoordinates.x.dp) * mapViewState.tileScale.toFloat()).roundToPx(), - (mapViewState.canvasSize.height / 2 + (mapViewState.mapTileProvider.toCoordinate(id.j).dp - mapViewState.centerCoordinates.y.dp) * mapViewState.tileScale.toFloat()).roundToPx() + logger.debug { "Recalculate canvas. Size: $size" } + } + clipRect { + val tileSize = IntSize( + ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(), + ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt() ) - drawImage( - image = image, - dstOffset = offset, - dstSize = tileSize + 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, + dstOffset = offset, + dstSize = tileSize + ) + } + features.values.filter { zoom in it.zoomRange }.forEach { feature -> + drawFeature(zoom, feature) + } + } + 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) + ) ) } - mapViewState.features.values.filter { mapViewState.zoom in it.zoomRange }.forEach { feature -> - drawFeature(mapViewState.zoom, feature) - } - } - mapViewState.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) - ) - ) } } -} +} \ No newline at end of file