State encapsulation #14

Open
ArystanK wants to merge 4 commits from state_encapsulation into main
6 changed files with 202 additions and 168 deletions
Showing only changes of commit 6b6fa45596 - Show all commits

View File

@ -0,0 +1,4 @@
import kotlin.math.PI
fun Double.toDegrees() = this * 180 / PI

View File

@ -7,6 +7,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import center.sciprog.maps.compose.* import center.sciprog.maps.compose.*
@ -49,41 +50,42 @@ fun App() {
var centerCoordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) } var centerCoordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) }
// val markers = (1..1_000_000).map {
// val position = GeodeticMapCoordinates.ofDegrees( val pointOne = 55.568548 to 37.568604
// latitude = Random.nextDouble(-90.0, 90.0), var pointTwo by remember { mutableStateOf(55.929444 to 37.518434) }
// longitude = Random.nextDouble(0.0, 180.0) val pointThree = 60.929444 to 37.518434
// )
// 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 state = MapViewState( val state = MapViewState(
mapTileProvider = mapTileProvider, mapTileProvider = mapTileProvider,
computeViewPoint = { viewPoint }, initialViewPoint = viewPoint,
config = MapViewConfig( config = MapViewConfig(
inferViewBoxFromFeatures = true, inferViewBoxFromFeatures = true,
onViewChange = { centerCoordinates = focus }, 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) 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 //remember feature Id
val circleId: FeatureId = circle( val circleId: FeatureId = circle(
centerCoordinates = pointTwo, centerCoordinates = pointTwo,
@ -106,15 +108,9 @@ fun App() {
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red) drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
} }
// markers.forEach { feature ->
// featureSelector {
// feature
// }
// }
arc(pointOne, Distance(10.0), 0f, PI) arc(pointOne, Distance(10.0), 0f, PI)
line(pointOne, pointTwo) line(pointOne, pointTwo, id = "line")
text(pointOne, "Home", font = { size = 32f }) text(pointOne, "Home", font = { size = 32f })
centerCoordinates?.let { centerCoordinates?.let {

View File

@ -3,6 +3,7 @@ package center.sciprog.maps.compose
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@ -46,6 +47,18 @@ public class MapDrawFeature(
override fun getBoundingBox(zoom: Int): GmcBox = computeBoundingBox(zoom) override fun getBoundingBox(zoom: Int): GmcBox = computeBoundingBox(zoom)
} }
public class MapPointsFeature(
public val points: List<GeodeticMapCoordinates>,
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 class MapCircleFeature(
public val center: GeodeticMapCoordinates, public val center: GeodeticMapCoordinates,
override val zoomRange: IntRange = defaultZoomRange, override val zoomRange: IntRange = defaultZoomRange,

View File

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
@ -117,6 +118,15 @@ public fun MapFeatureBuilder.arc(
) )
) )
public fun MapFeatureBuilder.points(
points: List<Pair<Double, Double>>,
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 @Composable
public fun MapFeatureBuilder.image( public fun MapFeatureBuilder.image(
position: Pair<Double, Double>, position: Pair<Double, Double>,

View File

@ -11,10 +11,15 @@ import kotlin.math.min
//TODO consider replacing by modifier //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( public data class MapViewConfig(
val zoomSpeed: Double = 1.0 / 3.0, val zoomSpeed: Double = 1.0 / 3.0,
val inferViewBoxFromFeatures: Boolean = false, val inferViewBoxFromFeatures: Boolean = false,
val onClick: MapViewPoint.() -> Unit = {}, val onClick: MapViewPoint.() -> Unit = {},
val onDrag: (start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true },
val onViewChange: MapViewPoint.() -> Unit = {}, val onViewChange: MapViewPoint.() -> Unit = {},
val onSelect: (GmcBox) -> Unit = {}, val onSelect: (GmcBox) -> Unit = {},
val zoomOnSelect: Boolean = true, val zoomOnSelect: Boolean = true,

View File

@ -51,160 +51,166 @@ public actual fun MapView(
mapViewState: MapViewState, mapViewState: MapViewState,
modifier: Modifier, modifier: Modifier,
) { ) {
@OptIn(ExperimentalComposeUiApi::class) with(mapViewState) {
val canvasModifier = modifier.pointerInput(Unit) { @OptIn(ExperimentalComposeUiApi::class)
forEachGesture { val canvasModifier = modifier.pointerInput(Unit) {
awaitPointerEventScope { forEachGesture {
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp()) awaitPointerEventScope {
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
val event: PointerEvent = awaitPointerEvent() val event: PointerEvent = awaitPointerEvent()
event.changes.forEach { change -> event.changes.forEach { change ->
if (event.buttons.isPrimaryPressed) { if (event.buttons.isPrimaryPressed) {
//Evaluating selection frame //Evaluating selection frame
if (event.keyboardModifiers.isShiftPressed) { if (event.keyboardModifiers.isShiftPressed) {
mapViewState.selectRect = Rect(change.position, change.position) selectRect = Rect(change.position, change.position)
drag(change.id) { dragChange -> drag(change.id) { dragChange ->
mapViewState.selectRect?.let { rect -> selectRect?.let { rect ->
val offset = dragChange.position val offset = dragChange.position
mapViewState.selectRect = Rect( selectRect = Rect(
min(offset.x, rect.left), min(offset.x, rect.left),
min(offset.y, rect.top), min(offset.y, rect.top),
max(offset.x, rect.right), max(offset.x, rect.right),
max(offset.y, rect.bottom) 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(), }.onPointerEvent(PointerEventType.Scroll) {
rect.bottomRight.toDpOffset().toGeodetic() val change = it.changes.first()
) val (xPos, yPos) = change.position
} //compute invariant point of translation
mapViewState.config.onSelect(gmcBox) val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic()
if (mapViewState.config.zoomOnSelect) { val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant)
val newViewPoint = gmcBox.computeViewPoint(mapViewState.mapTileProvider) config.onViewChange(newViewPoint)
.invoke(mapViewState.canvasSize) 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 Canvas(canvasModifier) {
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) {
if (mapViewState.canvasSize != size.toDpSize()) { if (mapViewState.canvasSize != size.toDpSize()) {
mapViewState.canvasSize = size.toDpSize() mapViewState.canvasSize = size.toDpSize()
logger.debug { "Recalculate canvas. Size: $size" } logger.debug { "Recalculate canvas. Size: $size" }
} }
clipRect { clipRect {
val tileSize = IntSize( val tileSize = IntSize(
ceil((mapViewState.mapTileProvider.tileSize.dp * mapViewState.tileScale.toFloat()).toPx()).toInt(), ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
ceil((mapViewState.mapTileProvider.tileSize.dp * mapViewState.tileScale.toFloat()).toPx()).toInt() ceil((mapTileProvider.tileSize.dp * 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()
) )
drawImage( mapTiles.forEach { (id, image) ->
image = image, //converting back from tile index to screen offset
dstOffset = offset, val offset = IntOffset(
dstSize = tileSize (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)
)
)
} }
} }
} }