State encapsulation #14

Open
ArystanK wants to merge 4 commits from state_encapsulation into main
5 changed files with 297 additions and 224 deletions
Showing only changes of commit 13b44a8091 - Show all commits

View File

@ -49,34 +49,33 @@ fun App() {
var centerCoordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(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,
getBoundingBox = {
GmcBox.withCenter(
center = position,
width = Distance(0.001),
height = Distance(0.001)
)
}
) {
drawRoundRect(
color = Color.Yellow,
size = Size(10f, 10f)
)
}
}
MapView(
// 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 state = MapViewState(
mapTileProvider = mapTileProvider,
initialViewPoint = viewPoint,
computeViewPoint = { viewPoint },
config = MapViewConfig(
inferViewBoxFromFeatures = true,
onViewChange = { centerCoordinates = focus }
onViewChange = { centerCoordinates = focus },
)
) {
val pointOne = 55.568548 to 37.568604
@ -107,9 +106,11 @@ fun App() {
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
}
featureSelector { zoom ->
markers.groupBy { }
}
// markers.forEach { feature ->
// featureSelector {
// feature
// }
// }
arc(pointOne, Distance(10.0), 0f, PI)
@ -135,6 +136,9 @@ fun App() {
}
}
}
MapView(
mapViewState = state
)
}
}

View File

@ -40,10 +40,10 @@ public class MapFeatureSelector(
public class MapDrawFeature(
public val position: GeodeticMapCoordinates,
override val zoomRange: IntRange = defaultZoomRange,
private val getBoundingBox: (zoom: Int) -> GmcBox,
private val computeBoundingBox: (zoom: Int) -> GmcBox,
public val drawFeature: DrawScope.() -> Unit,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = getBoundingBox(zoom)
override fun getBoundingBox(zoom: Int): GmcBox = computeBoundingBox(zoom)
}
public class MapCircleFeature(

View File

@ -23,11 +23,8 @@ public data class MapViewConfig(
@Composable
public expect fun MapView(
mapTileProvider: MapTileProvider,
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
features: Map<FeatureId, MapFeature>,
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
mapViewState: MapViewState,
modifier: Modifier = Modifier,
)
@Composable
@ -42,15 +39,18 @@ public fun MapView(
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
MapView(
mapTileProvider,
{ initialViewPoint },
featuresBuilder.build(),
config,
modifier
mapViewState = MapViewState(
mapTileProvider = mapTileProvider,
computeViewPoint = { initialViewPoint },
features = featuresBuilder.build(),
config = config,
),
modifier = modifier
)
}
internal fun GmcBox.computeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint = { canvasSize ->
internal fun GmcBox.computeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint =
{ canvasSize ->
val zoom = log2(
min(
canvasSize.width.value / width,
@ -58,7 +58,7 @@ internal fun GmcBox.computeViewPoint(mapTileProvider: MapTileProvider): (canvasS
) * PI / mapTileProvider.tileSize
)
MapViewPoint(center, zoom)
}
}
@Composable
public fun MapView(
@ -72,10 +72,12 @@ public fun MapView(
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
MapView(
mapTileProvider,
box.computeViewPoint(mapTileProvider),
featuresBuilder.build(),
config,
mapViewState = MapViewState(
config = config,
mapTileProvider = mapTileProvider,
features = featuresBuilder.build(),
computeViewPoint = box.computeViewPoint(mapTileProvider),
),
modifier
)
}

View File

@ -0,0 +1,179 @@
package center.sciprog.maps.compose
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Density
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.coordinates.MercatorProjection.Companion.toMercator
import kotlin.math.*
@Composable
public fun MapViewState(
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
mapTileProvider: MapTileProvider,
config: MapViewConfig = MapViewConfig(),
features: Map<FeatureId, MapFeature> = emptyMap(),
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
): MapViewState {
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
return MapViewState(
computeViewPoint = computeViewPoint,
mapTileProvider = mapTileProvider,
config = config,
features = featuresBuilder.build()
)
}
public class MapViewState(
public val computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
public val mapTileProvider: MapTileProvider,
public val config: MapViewConfig = MapViewConfig(),
public val features: Map<FeatureId, MapFeature> = emptyMap(),
) {
public var canvasSize: DpSize by mutableStateOf(DpSize(512.dp, 512.dp))
public var viewPointInternal: MapViewPoint? by mutableStateOf(null)
public val viewPoint: MapViewPoint by derivedStateOf {
viewPointInternal ?: 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)
} ?: computeViewPoint(canvasSize)
} else {
computeViewPoint(canvasSize)
}
}
public val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
public val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
public val mapTiles: SnapshotStateList<MapTile> = mutableStateListOf()
public val centerCoordinates: WebMercatorCoordinates by derivedStateOf {
WebMercatorProjection.toMercator(
viewPoint.focus,
zoom
)
}
public 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
*/
public fun DpOffset.toGeodetic(): GeodeticMapCoordinates =
with(this@MapViewState) { WebMercatorProjection.toGeodetic(toMercator()) }
// Selection rectangle. If null - no selection
public var selectRect: Rect? by mutableStateOf(null)
public fun WebMercatorCoordinates.toOffset(density: Density): Offset =
with(density) {
with(this@MapViewState) {
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
public fun GeodeticMapCoordinates.toOffset(density: Density): Offset =
WebMercatorProjection.toMercator(this, zoom).toOffset(density)
public fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) {
when (feature) {
is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom))
is MapCircleFeature -> drawCircle(
feature.color,
feature.size,
center = feature.center.toOffset(this@drawFeature)
)
is MapRectangleFeature -> drawRect(
feature.color,
topLeft = feature.center.toOffset(this@drawFeature) - Offset(
feature.size.width.toPx() / 2,
feature.size.height.toPx() / 2
),
size = feature.size.toSize()
)
is MapLineFeature -> drawLine(
feature.color,
feature.a.toOffset(this@drawFeature),
feature.b.toOffset(this@drawFeature)
)
is MapArcFeature -> {
val topLeft = feature.oval.topLeft.toOffset(this@drawFeature)
val bottomRight = feature.oval.bottomRight.toOffset(this@drawFeature)
val path = Path().apply {
addArcRad(Rect(topLeft, bottomRight), feature.startAngle, feature.endAngle - feature.startAngle)
}
drawPath(path, color = feature.color, style = Stroke())
}
is MapBitmapImageFeature -> drawImage(
image = feature.image,
topLeft = feature.position.toOffset(this@drawFeature)
)
is MapVectorImageFeature -> {
val offset = feature.position.toOffset(this@drawFeature)
val size = feature.size.toSize()
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
with(feature.painter) {
draw(size)
}
}
}
is MapTextFeature -> drawIntoCanvas { canvas ->
val offset = toOffset(feature.position, mapViewState)
canvas.nativeCanvas.drawString(
feature.text,
offset.x + 5,
offset.y - 5,
Font().apply(feature.fontConfig),
feature.color.toPaint()
)
}
is MapDrawFeature -> {
val offset = toOffset(feature.position, mapViewState)
translate(offset.x, offset.y) {
feature.drawFeature(this)
}
}
is MapFeatureGroup -> {
feature.children.values.forEach {
drawFeature(zoom, it)
}
}
else -> {
logger.error { "Unrecognized feature type: ${feature::class}" }
}
}
}
}

View File

@ -48,60 +48,9 @@ private val logger = KotlinLogging.logger("MapView")
@Composable
public actual fun MapView(
mapTileProvider: MapTileProvider,
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
features: Map<FeatureId, MapFeature>,
config: MapViewConfig,
mapViewState: MapViewState,
modifier: Modifier,
) {
var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) }
var viewPointInternal: MapViewPoint? by remember {
mutableStateOf(null)
}
if (config.resetViewPoint) {
viewPointInternal = null
}
val viewPoint: MapViewPoint by derivedStateOf {
viewPointInternal ?: 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)
} ?: computeViewPoint(canvasSize)
} else {
computeViewPoint(canvasSize)
}
}
val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
val mapTiles = remember { mutableStateListOf<MapTile>() }
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,
)
/*
* Convert screen independent offset to GMC, adjusting for fractional zoom
*/
fun DpOffset.toGeodetic() = WebMercatorProjection.toGeodetic(toMercator())
// Selection rectangle. If null - no selection
var selectRect by remember { mutableStateOf<Rect?>(null) }
@OptIn(ExperimentalComposeUiApi::class)
val canvasModifier = modifier.pointerInput(Unit) {
forEachGesture {
@ -113,11 +62,11 @@ public actual fun MapView(
if (event.buttons.isPrimaryPressed) {
//Evaluating selection frame
if (event.keyboardModifiers.isShiftPressed) {
selectRect = Rect(change.position, change.position)
mapViewState.selectRect = Rect(change.position, change.position)
drag(change.id) { dragChange ->
selectRect?.let { rect ->
mapViewState.selectRect?.let { rect ->
val offset = dragChange.position
selectRect = Rect(
mapViewState.selectRect = Rect(
min(offset.x, rect.left),
min(offset.y, rect.top),
max(offset.x, rect.right),
@ -125,33 +74,41 @@ public actual fun MapView(
)
}
}
selectRect?.let { rect ->
mapViewState.selectRect?.let { rect ->
//Use selection override if it is defined
val gmcBox = GmcBox(
val gmcBox = with(mapViewState) {
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
mapViewState.config.onSelect(gmcBox)
if (mapViewState.config.zoomOnSelect) {
val newViewPoint = gmcBox.computeViewPoint(mapViewState.mapTileProvider)
.invoke(mapViewState.canvasSize)
mapViewState.config.onViewChange(newViewPoint)
mapViewState.viewPointInternal = newViewPoint
}
mapViewState.selectRect = null
}
} else {
val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom))
mapViewState.config.onClick(
MapViewPoint(
with(mapViewState) { dpPos.toGeodetic() },
mapViewState.viewPoint.zoom
)
)
drag(change.id) { dragChange ->
val dragAmount = dragChange.position - dragChange.previousPosition
val newViewPoint = viewPoint.move(
-dragAmount.x.toDp().value / tileScale,
+dragAmount.y.toDp().value / tileScale
val newViewPoint = mapViewState.viewPoint.move(
-dragAmount.x.toDp().value / mapViewState.tileScale,
+dragAmount.y.toDp().value / mapViewState.tileScale
)
config.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
mapViewState.config.onViewChange(newViewPoint)
mapViewState.viewPointInternal = newViewPoint
}
}
}
@ -162,37 +119,42 @@ public actual fun MapView(
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
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(viewPoint, canvasSize) {
with(mapTileProvider) {
val indexRange = 0 until 2.0.pow(zoom).toInt()
LaunchedEffect(mapViewState.viewPoint, mapViewState.canvasSize) {
with(mapViewState.mapTileProvider) {
val indexRange = 0 until 2.0.pow(mapViewState.zoom).toInt()
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
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 = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
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)
mapTiles.clear()
mapViewState.mapTiles.clear()
for (j in verticalIndices) {
for (i in horizontalIndices) {
val id = TileId(zoom, i, j)
val id = TileId(mapViewState.zoom, i, j)
//start all
val deferred = loadTileAsync(id)
//wait asynchronously for it to finish
launch {
try {
mapTiles += deferred.await()
mapViewState.mapTiles += deferred.await()
} catch (ex: Exception) {
if (ex !is CancellationException) {
//displaying the error is maps responsibility
@ -207,94 +169,20 @@ public actual fun MapView(
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 MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom))
is MapCircleFeature -> drawCircle(
feature.color,
feature.size,
center = feature.center.toOffset()
)
is MapRectangleFeature -> drawRect(
feature.color,
topLeft = feature.center.toOffset() - Offset(
feature.size.width.toPx() / 2,
feature.size.height.toPx() / 2
),
size = feature.size.toSize()
)
is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
is MapArcFeature -> {
val topLeft = feature.oval.topLeft.toOffset()
val bottomRight = feature.oval.bottomRight.toOffset()
val path = Path().apply {
addArcRad(Rect(topLeft, bottomRight), feature.startAngle, feature.endAngle - feature.startAngle)
}
drawPath(path, color = feature.color, style = Stroke())
}
is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is MapVectorImageFeature -> {
val offset = feature.position.toOffset()
val size = feature.size.toSize()
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
with(feature.painter) {
draw(size)
}
}
}
is MapTextFeature -> 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 MapDrawFeature -> {
val offset = feature.position.toOffset()
translate(offset.x, offset.y) {
feature.drawFeature(this)
}
}
is MapFeatureGroup -> {
feature.children.values.forEach {
drawFeature(zoom, it)
}
}
else -> {
logger.error { "Unrecognized feature type: ${feature::class}" }
}
}
}
if (canvasSize != size.toDpSize()) {
canvasSize = size.toDpSize()
if (mapViewState.canvasSize != size.toDpSize()) {
mapViewState.canvasSize = size.toDpSize()
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()
ceil((mapViewState.mapTileProvider.tileSize.dp * mapViewState.tileScale.toFloat()).toPx()).toInt(),
ceil((mapViewState.mapTileProvider.tileSize.dp * mapViewState.tileScale.toFloat()).toPx()).toInt()
)
mapTiles.forEach { (id, image) ->
mapViewState.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()
(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(
image = image,
@ -302,11 +190,11 @@ public actual fun MapView(
dstSize = tileSize
)
}
features.values.filter { zoom in it.zoomRange }.forEach { feature ->
drawFeature(zoom, feature)
mapViewState.features.values.filter { mapViewState.zoom in it.zoomRange }.forEach { feature ->
drawFeature(mapViewState.zoom, feature)
}
}
selectRect?.let { rect ->
mapViewState.selectRect?.let { rect ->
drawRect(
color = Color.Blue,
topLeft = rect.topLeft,