State encapsulation #14

Open
ArystanK wants to merge 4 commits from state_encapsulation into main
9 changed files with 446 additions and 329 deletions

View File

@ -2,3 +2,5 @@ import kotlin.math.PI
fun Double.toDegrees() = this * 180 / PI
fun Double.toRadians() = this * PI / 180

View File

@ -10,9 +10,7 @@ import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.maps.compose.*
import center.sciprog.maps.coordinates.Distance
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.MapViewPoint
import center.sciprog.maps.coordinates.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.delay
@ -52,24 +50,11 @@ fun App() {
val pointOne = 55.568548 to 37.568604
var pointTwo by remember { mutableStateOf(55.929444 to 37.518434) }
val pointThree = 60.929444 to 37.518434
MapView(
mapTileProvider = mapTileProvider,
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 state = MapViewState(
mapTileProvider = mapTileProvider,
initialViewPoint = { viewPoint },
) {
image(pointOne, Icons.Filled.Home)
points(
@ -89,7 +74,19 @@ fun App() {
centerCoordinates = pointTwo,
)
draw(position = pointThree) {
draw(
position = pointThree,
getBoundingBox = {
GmcBox.withCenter(
center = GeodeticMapCoordinates.ofDegrees(
pointThree.first,
pointThree.second
),
height = Distance(0.001),
width = Distance(0.001)
)
}
) {
drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red)
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
}
@ -118,6 +115,29 @@ fun App() {
}
}
}
val config = MapViewConfig(
onViewChange = { centerCoordinates = focus },
onDrag = { start, end ->
val markerRadius = 5f
val startPosition = with(state) { start.focus.toOffset(this@MapViewConfig) }
val markerLocation = with(state) {
GeodeticMapCoordinates.ofDegrees(pointTwo.first, pointTwo.second).toOffset(this@MapViewConfig)
}
if (startPosition.x in (markerLocation.x - markerRadius)..(markerLocation.x + markerRadius) &&
startPosition.y in (markerLocation.y - markerRadius)..(markerLocation.y + markerRadius)
) {
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
}
)
MapView(
mapViewState = state,
mapViewConfig = config
)
}
}

View File

@ -41,12 +41,10 @@ public class MapFeatureSelector(
public class MapDrawFeature(
public val position: GeodeticMapCoordinates,
override val zoomRange: IntRange = defaultZoomRange,
private val computeBoundingBox: (zoom: Int) -> GmcBox,
public val drawFeature: DrawScope.() -> Unit,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox {
//TODO add box computation
return GmcBox(position, position)
}
override fun getBoundingBox(zoom: Int): GmcBox = computeBoundingBox(zoom)
}
public class MapPointsFeature(

View File

@ -72,8 +72,9 @@ public fun MapFeatureBuilder.draw(
position: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
id: FeatureId? = null,
getBoundingBox: (Int) -> GmcBox,
drawFeature: DrawScope.() -> Unit,
): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, drawFeature))
): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, getBoundingBox, drawFeature))
public fun MapFeatureBuilder.line(
aCoordinates: Pair<Double, Double>,
@ -144,3 +145,13 @@ public fun MapFeatureBuilder.group(
val feature = MapFeatureGroup(map, zoomRange)
return addFeature(id, feature)
}
public fun MapFeatureBuilder.featureSelector(
id: FeatureId? = null,
onSelect: MapFeatureBuilder.(zoom: Int) -> MapFeature
): FeatureId = addFeature(
id = id,
feature = MapFeatureSelector(
selector = { onSelect(this, it) }
)
)

View File

@ -1,10 +1,15 @@
package center.sciprog.maps.compose
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.NativeCanvas
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.GmcBox
import org.jetbrains.skia.Font
public expect class Font constructor() {
public var size: Float
}
public expect fun NativeCanvas.drawString(text: String, x: Float, y: Float, font: Font, color: Color)
public class MapTextFeature(
public val position: GeodeticMapCoordinates,

View File

@ -3,6 +3,7 @@ package center.sciprog.maps.compose
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import center.sciprog.maps.coordinates.*
import kotlin.math.PI
@ -17,9 +18,8 @@ import kotlin.math.min
*/
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 onDrag: Density.(start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true },
val onViewChange: MapViewPoint.() -> Unit = {},
val onSelect: (GmcBox) -> Unit = {},
val zoomOnSelect: Boolean = true,
@ -28,11 +28,9 @@ 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(),
modifier: Modifier = Modifier,
mapViewState: MapViewState,
mapViewConfig: MapViewConfig,
)
@Composable
@ -47,15 +45,18 @@ public fun MapView(
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
MapView(
mapTileProvider,
{ initialViewPoint },
featuresBuilder.build(),
config,
modifier
mapViewState = MapViewState(
mapTileProvider = mapTileProvider,
initialViewPoint = { initialViewPoint },
features = featuresBuilder.build(),
),
mapViewConfig = 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,
@ -77,10 +78,12 @@ public fun MapView(
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
MapView(
mapTileProvider,
box.computeViewPoint(mapTileProvider),
featuresBuilder.build(),
config,
modifier
mapViewState = MapViewState(
mapTileProvider = mapTileProvider,
features = featuresBuilder.build(),
initialViewPoint = box.computeViewPoint(mapTileProvider),
),
modifier = modifier,
mapViewConfig = config,
)
}

View File

@ -0,0 +1,181 @@
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 mu.KotlinLogging
import kotlin.math.*
@Composable
public fun MapViewState(
initialViewPoint: (canvasSize: DpSize) -> MapViewPoint,
mapTileProvider: MapTileProvider,
features: Map<FeatureId, MapFeature> = emptyMap(),
inferViewBoxFromFeatures: Boolean = false,
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
): MapViewState {
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
return MapViewState(
initialViewPoint = initialViewPoint,
mapTileProvider = mapTileProvider,
features = featuresBuilder.build(),
inferViewBoxFromFeatures = inferViewBoxFromFeatures
)
}
public class MapViewState(
public val initialViewPoint: (canvasSize: DpSize) -> MapViewPoint,
public val mapTileProvider: MapTileProvider,
public val features: Map<FeatureId, MapFeature> = emptyMap(),
inferViewBoxFromFeatures: Boolean = false,
) {
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 (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)
} ?: initialViewPoint(canvasSize)
} else {
initialViewPoint(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)
private val logger = KotlinLogging.logger("MapViewState")
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 = feature.position.toOffset(this@drawFeature)
canvas.nativeCanvas.drawString(
feature.text,
offset.x + 5,
offset.y - 5,
Font().apply(feature.fontConfig),
feature.color
)
}
is MapDrawFeature -> {
val offset = feature.position.toOffset(this)
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

@ -0,0 +1,23 @@
package center.sciprog.maps.compose
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.NativeCanvas
import androidx.compose.ui.graphics.toArgb
import org.jetbrains.skia.Paint
public actual typealias Font = org.jetbrains.skia.Font
public actual fun NativeCanvas.drawString(
text: String,
x: Float,
y: Float,
font: Font,
color: Color
) {
drawString(text, x, y, font, color.toPaint())
}
private fun Color.toPaint(): Paint = Paint().apply {
isAntiAlias = true
color = toArgb()
}

View File

@ -17,15 +17,10 @@ import center.sciprog.maps.coordinates.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import mu.KotlinLogging
import org.jetbrains.skia.Font
import org.jetbrains.skia.Paint
import kotlin.math.*
private fun Color.toPaint(): Paint = Paint().apply {
isAntiAlias = true
color = toArgb()
}
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
@ -48,60 +43,11 @@ private val logger = KotlinLogging.logger("MapView")
@Composable
public actual fun MapView(
mapTileProvider: MapTileProvider,
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
features: Map<FeatureId, MapFeature>,
config: MapViewConfig,
modifier: Modifier,
mapViewState: MapViewState,
mapViewConfig: MapViewConfig,
) {
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) }
with(mapViewState) {
@OptIn(ExperimentalComposeUiApi::class)
val canvasModifier = modifier.pointerInput(Unit) {
forEachGesture {
@ -131,11 +77,12 @@ public actual fun MapView(
rect.topLeft.toDpOffset().toGeodetic(),
rect.bottomRight.toDpOffset().toGeodetic()
)
config.onSelect(gmcBox)
if (config.zoomOnSelect) {
mapViewConfig.onSelect(gmcBox)
if (mapViewConfig.zoomOnSelect) {
val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize)
config.onViewChange(newViewPoint)
mapViewConfig.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
}
selectRect = null
@ -143,13 +90,22 @@ public actual fun MapView(
} else {
val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom))
mapViewConfig.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())
DpOffset(
dragChange.previousPosition.x.toDp(),
dragChange.previousPosition.y.toDp()
)
val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp())
if (!config.onDrag(
if (!mapViewConfig.onDrag(
this,
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)
)
@ -158,7 +114,7 @@ public actual fun MapView(
-dragAmount.x.toDp().value / tileScale,
+dragAmount.y.toDp().value / tileScale
)
config.onViewChange(newViewPoint)
mapViewConfig.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
}
}
@ -171,8 +127,8 @@ public actual fun MapView(
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)
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * mapViewConfig.zoomSpeed, invariant)
mapViewConfig.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
}.fillMaxSize()
@ -215,91 +171,8 @@ 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)
}
}
is MapPointsFeature -> {
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()) {
canvasSize = size.toDpSize()
if (mapViewState.canvasSize != size.toDpSize()) {
mapViewState.canvasSize = size.toDpSize()
logger.debug { "Recalculate canvas. Size: $size" }
}
clipRect {
@ -337,3 +210,4 @@ public actual fun MapView(
}
}
}
}