Full refacor of map state and arguments

This commit is contained in:
Alexander Nozik 2022-12-28 14:41:46 +03:00
parent 56ccd66db5
commit 9e3eec9533
21 changed files with 404 additions and 305 deletions

View File

@ -2,8 +2,9 @@ import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.unit.DpSize
@ -17,6 +18,10 @@ import center.sciprog.maps.geojson.geoJson
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.net.URL
import java.nio.file.Path
import kotlin.math.PI
@ -32,6 +37,7 @@ fun App() {
MaterialTheme {
val scope = rememberCoroutineScope()
val mapTileProvider = remember {
OpenStreetMapTileProvider(
client = HttpClient(CIO),
@ -39,7 +45,7 @@ fun App() {
)
}
var centerCoordinates by remember { mutableStateOf<Gmc?>(null) }
val centerCoordinates = MutableStateFlow<Gmc?>(null)
val pointOne = 55.568548 to 37.568604
@ -48,17 +54,9 @@ fun App() {
MapView(
mapTileProvider = mapTileProvider,
// initialViewPoint = MapViewPoint(
// GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173),
// 8.0
// ),
// initialRectangle = GmcRectangle.square(
// GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173),
// 50.kilometers,
// 50.kilometers
// ),
config = ViewConfig(
onViewChange = { centerCoordinates = focus },
onViewChange = { centerCoordinates.value = focus },
onClick = { _, viewPoint -> println(viewPoint) }
)
) {
@ -95,22 +93,22 @@ fun App() {
it.copy(color = Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat()))
}
draw(position = pointThree) {
drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red)
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
}
// draw(position = pointThree) {
// drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red)
// drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
// }
arc(pointOne, 10.0.kilometers, (PI / 4).radians, -Angle.pi / 2)
line(pointOne, pointTwo, id = "line")
text(pointOne, "Home", font = { size = 32f })
centerCoordinates?.let {
centerCoordinates.filterNotNull().onEach {
group(id = "center") {
circle(center = it, color = Color.Blue, id = "circle", size = 1.dp)
text(position = it, it.toShortString(), id = "text", color = Color.Blue)
}
}
}.launchIn(scope)
}
}
}

View File

@ -79,15 +79,20 @@ fun App() {
)
}
) {
SchemeView(
initialViewPoint = initialViewPoint,
featuresState = schemeFeaturesState,
config = ViewConfig(
onClick = {
println("${focus.x}, ${focus.y}")
val mapState: XYViewScope = rememberMapState(
ViewConfig(
onClick = {_, click ->
println("${click.focus.x}, ${click.focus.y}")
},
onViewChange = { viewPoint = this }
),
schemeFeaturesState.features.values,
initialViewPoint = initialViewPoint,
)
SchemeView(
mapState,
schemeFeaturesState,
)
}

View File

@ -21,9 +21,9 @@ public interface MapTileProvider {
public val tileSize: Int get() = DEFAULT_TILE_SIZE
public fun toIndex(d: Double): Int = floor(d / tileSize).toInt()
public fun toIndex(d: Float): Int = floor(d / tileSize).toInt()
public fun toCoordinate(i: Int): Double = (i * tileSize).toDouble()
public fun toCoordinate(i: Int): Float = (i * tileSize).toFloat()
public companion object {
public const val DEFAULT_TILE_SIZE: Int = 256

View File

@ -2,42 +2,19 @@ package center.sciprog.maps.compose
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.features.*
import kotlin.math.PI
import kotlin.math.log2
import kotlin.math.min
@Composable
public expect fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint,
mapState: MapViewScope,
featuresState: FeatureCollection<Gmc>,
config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
)
internal val defaultCanvasSize = DpSize(512.dp, 512.dp)
public fun Rectangle<Gmc>.computeViewPoint(
mapTileProvider: MapTileProvider,
canvasSize: DpSize = defaultCanvasSize,
): MapViewPoint {
val zoom = log2(
min(
canvasSize.width.value / longitudeDelta.radians.value,
canvasSize.height.value / latitudeDelta.radians.value
) * PI / mapTileProvider.tileSize
)
return MapViewPoint(center, zoom.toFloat())
}
/**
* A builder for a Map with static features.
*/
@ -50,20 +27,22 @@ public fun MapView(
config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
) {
val featuresState = key(featureMap) {
FeatureCollection.build(GmcCoordinateSpace) {
val featuresState = remember(featureMap) {
FeatureCollection.build(WebMercatorSpace) {
featureMap.forEach { feature(it.key.id, it.value) }
}
}
val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) {
initialViewPoint
?: initialRectangle?.computeViewPoint(mapTileProvider)
?: featureMap.values.computeBoundingBox(GmcCoordinateSpace, 1f)?.computeViewPoint(mapTileProvider)
?: MapViewPoint.globe
}
val mapState: MapViewScope = rememberMapState(
mapTileProvider,
config,
featuresState.features.values,
initialViewPoint = initialViewPoint,
initialRectangle = initialRectangle,
)
MapView(mapTileProvider, viewPointOverride, featuresState, config, modifier)
MapView(mapState, featuresState, modifier)
}
/**
@ -82,15 +61,15 @@ public fun MapView(
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: FeatureCollection<Gmc>.() -> Unit = {},
) {
val featureState = FeatureCollection.remember(GmcCoordinateSpace, buildFeatures)
val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) {
initialViewPoint
?: initialRectangle?.computeViewPoint(mapTileProvider)
?: featureState.features.values.computeBoundingBox(GmcCoordinateSpace, 1f)
?.computeViewPoint(mapTileProvider)
?: MapViewPoint.globe
}
val featureState = FeatureCollection.remember(WebMercatorSpace, buildFeatures)
val mapState: MapViewScope = rememberMapState(
mapTileProvider,
config,
featureState.features.values,
initialViewPoint = initialViewPoint,
initialRectangle = initialRectangle,
)
val featureDrag: DragHandle<Gmc> = DragHandle.withPrimaryButton { event, start, end ->
featureState.forEachWithAttribute(DraggableAttribute) { _, handle ->
@ -107,16 +86,18 @@ public fun MapView(
DragResult(end)
}
val featureClick: ClickHandle<Gmc> = ClickHandle.withPrimaryButton { event, click ->
featureState.forEachWithAttribute(SelectableAttribute) { _, handle ->
@Suppress("UNCHECKED_CAST")
(handle as ClickHandle<Gmc>).handle(event, click)
config.onClick?.handle(event, click)
}
}
val newConfig = config.copy(
dragHandle = DragHandle.combine(featureDrag, config.dragHandle)
dragHandle = config.dragHandle?.let { DragHandle.combine(featureDrag, it) } ?: featureDrag,
onClick = featureClick
)
MapView(
mapTileProvider = mapTileProvider,
initialViewPoint = viewPointOverride,
featuresState = featureState,
config = newConfig,
modifier = modifier,
)
MapView(mapState, featureState, modifier)
}

View File

@ -11,7 +11,7 @@ public data class MapViewPoint(
override val focus: GeodeticMapCoordinates,
override val zoom: Float,
) : ViewPoint<Gmc>{
val scaleFactor: Double by lazy { WebMercatorProjection.scaleFactor(zoom) }
val scaleFactor: Float by lazy { WebMercatorProjection.scaleFactor(zoom) }
public companion object{
public val globe: MapViewPoint = MapViewPoint(GeodeticMapCoordinates(0.0.radians, 0.0.radians), 1f)

View File

@ -2,29 +2,30 @@ package center.sciprog.maps.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.*
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.*
import kotlin.math.*
internal class MapViewState internal constructor(
public class MapViewScope internal constructor(
public val mapTileProvider: MapTileProvider,
config: ViewConfig<Gmc>,
canvasSize: DpSize,
viewPoint: ViewPoint<Gmc>,
val tileSize: Int,
) : CoordinateViewState<Gmc>(config, canvasSize, viewPoint) {
override val space: CoordinateSpace<Gmc> get() = GmcCoordinateSpace
) : CoordinateViewScope<Gmc>(config) {
override val space: CoordinateSpace<Gmc> get() = WebMercatorSpace
val scaleFactor: Double
get() = WebMercatorProjection.scaleFactor(viewPoint.zoom)
public val scaleFactor: Float
get() = WebMercatorProjection.scaleFactor(zoom)
val intZoom: Int get() = floor(zoom).toInt()
public val intZoom: Int get() = floor(zoom).toInt()
val centerCoordinates: WebMercatorCoordinates
public val centerCoordinates: WebMercatorCoordinates
get() = WebMercatorProjection.toMercator(viewPoint.focus, intZoom)
val tileScale: Float
get() = 2f.pow(viewPoint.zoom - floor(viewPoint.zoom))
public val tileScale: Float
get() = 2f.pow(zoom - floor(zoom))
/*
* Convert screen independent offset to GMC, adjusting for fractional zoom
@ -40,9 +41,9 @@ internal class MapViewState internal constructor(
override fun Gmc.toDpOffset(): DpOffset {
val mercator = WebMercatorProjection.toMercator(this, intZoom)
return DpOffset(
(canvasSize.width / 2 + (mercator.x.dp - centerCoordinates.x.dp) * tileScale.toFloat()),
(canvasSize.height / 2 + (mercator.y.dp - centerCoordinates.y.dp) * tileScale.toFloat())
return DpOffset(
(canvasSize.width / 2 + (mercator.x.dp - centerCoordinates.x.dp) * tileScale),
(canvasSize.height / 2 + (mercator.y.dp - centerCoordinates.y.dp) * tileScale)
)
}
@ -52,12 +53,12 @@ internal class MapViewState internal constructor(
return DpRect(topLeft.x, topLeft.y, bottomRight.x, bottomRight.y)
}
override fun viewPointFor(rectangle: Rectangle<Gmc>): ViewPoint<Gmc> {
override fun computeViewPoint(rectangle: Rectangle<Gmc>): ViewPoint<Gmc> {
val zoom = log2(
min(
canvasSize.width.value / rectangle.longitudeDelta.radians.value,
canvasSize.height.value / rectangle.latitudeDelta.radians.value
) * PI / tileSize
) * PI / mapTileProvider.tileSize
)
return MapViewPoint(rectangle.center, zoom.toFloat())
}
@ -78,10 +79,21 @@ internal class MapViewState internal constructor(
@Composable
internal fun rememberMapState(
mapTileProvider: MapTileProvider,
config: ViewConfig<Gmc>,
canvasSize: DpSize,
viewPoint: ViewPoint<Gmc>,
tileSize: Int,
): MapViewState = remember {
MapViewState(config, canvasSize, viewPoint, tileSize)
features: Collection<Feature<Gmc>> = emptyList(),
initialViewPoint: MapViewPoint? = null,
initialRectangle: Rectangle<Gmc>? = null,
): MapViewScope = remember {
MapViewScope(mapTileProvider, config).also { mapState->
if (initialViewPoint != null) {
mapState.viewPoint = initialViewPoint
} else if (initialRectangle != null) {
mapState.viewPoint = mapState.computeViewPoint(initialRectangle)
} else {
features.computeBoundingBox(WebMercatorSpace, 1f)?.let {
mapState.viewPoint = mapState.computeViewPoint(it)
}
}
}
}

View File

@ -1,13 +1,21 @@
package center.sciprog.maps.compose
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.CoordinateSpace
import center.sciprog.maps.features.Rectangle
import center.sciprog.maps.features.ViewPoint
import kotlin.math.floor
import kotlin.math.pow
public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
public object WebMercatorSpace : CoordinateSpace<Gmc> {
private fun intZoom(zoom: Float): Int = floor(zoom).toInt()
private fun tileScale(zoom: Float): Float = 2f.pow(zoom - floor(zoom))
override fun Rectangle(first: Gmc, second: Gmc): Rectangle<Gmc> = GmcRectangle(first, second)
override fun Rectangle(center: Gmc, zoom: Float, size: DpSize): Rectangle<Gmc> {
@ -15,6 +23,8 @@ public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
return Rectangle(center, (size.width.value / scale).radians, (size.height.value / scale).radians)
}
override val defaultViewPoint: ViewPoint<Gmc> = MapViewPoint.globe
override fun ViewPoint(center: Gmc, zoom: Float): ViewPoint<Gmc> = MapViewPoint(center, zoom)
override fun ViewPoint<Gmc>.moveBy(delta: Gmc): ViewPoint<Gmc> {
@ -29,7 +39,7 @@ public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
}
override fun ViewPoint<Gmc>.zoomBy(zoomDelta: Float, invariant: Gmc): ViewPoint<Gmc> = if (invariant == focus) {
ViewPoint(focus, (zoom + zoomDelta).coerceIn(2f, 18f) )
ViewPoint(focus, (zoom + zoomDelta).coerceIn(2f, 18f))
} else {
val difScale = (1 - 2f.pow(-zoomDelta))
val newCenter = GeodeticMapCoordinates(
@ -61,6 +71,17 @@ public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
val maxLong = maxOf { it.longitude }
return GmcRectangle(GeodeticMapCoordinates(minLat, minLong), GeodeticMapCoordinates(maxLat, maxLong))
}
override fun Gmc.offsetTo(b: Gmc, zoom: Float): DpOffset {
val intZoom = intZoom(zoom)
val mercatorA = WebMercatorProjection.toMercator(this, intZoom)
val mercatorB = WebMercatorProjection.toMercator(b, intZoom)
val tileScale = tileScale(zoom)
return DpOffset(
(mercatorA.x - mercatorB.x).dp * tileScale,
(mercatorA.y - mercatorB.y).dp * tileScale
)
}
}
/**

View File

@ -15,7 +15,10 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.features.*
import center.sciprog.maps.features.FeatureCollection
import center.sciprog.maps.features.PainterFeature
import center.sciprog.maps.features.drawFeature
import center.sciprog.maps.features.z
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import mu.KotlinLogging
@ -40,111 +43,101 @@ private val logger = KotlinLogging.logger("MapView")
*/
@Composable
public actual fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint,
mapState: MapViewScope,
featuresState: FeatureCollection<Gmc>,
config: ViewConfig<Gmc>,
modifier: Modifier,
): Unit = key(initialViewPoint) {
): Unit = with(mapState) {
val state = rememberMapState(
config,
defaultCanvasSize,
initialViewPoint,
mapTileProvider.tileSize
)
val mapTiles = remember(mapTileProvider) { mutableStateListOf<MapTile>() }
with(state) {
// Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) {
with(mapTileProvider) {
val indexRange = 0 until 2.0.pow(intZoom).toInt()
val mapTiles = remember(mapTileProvider) { mutableStateListOf<MapTile>() }
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)
// Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) {
with(mapTileProvider) {
val indexRange = 0 until 2.0.pow(intZoom).toInt()
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)
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)
mapTiles.clear()
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(intZoom, 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" }
}
for (j in verticalIndices) {
for (i in horizontalIndices) {
val id = TileId(intZoom, 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" }
}
}
}
}
}
}
val painterCache: Map<PainterFeature<Gmc>, Painter> = key(featuresState) {
featuresState.features.values.filterIsInstance<PainterFeature<Gmc>>().associateWith { it.getPainter() }
}
Canvas(modifier = modifier.mapControls(state).fillMaxSize()) {
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).toPx()).toInt(),
ceil((mapTileProvider.tileSize.dp * tileScale).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).roundToPx(),
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale).roundToPx()
)
drawImage(
image = image.toComposeImageBitmap(),
dstOffset = offset,
dstSize = tileSize
)
}
featuresState.features.values.filter { viewPoint.zoom in it.zoomRange }.sortedBy { it.z }.forEach { feature ->
drawFeature(state, painterCache, 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)
)
)
}
}
}
val painterCache: Map<PainterFeature<Gmc>, Painter> = key(featuresState) {
featuresState.features.values.filterIsInstance<PainterFeature<Gmc>>().associateWith { it.getPainter() }
}
Canvas(modifier = modifier.mapControls(mapState).fillMaxSize()) {
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).toPx()).toInt(),
ceil((mapTileProvider.tileSize.dp * tileScale).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).roundToPx(),
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale).roundToPx()
)
drawImage(
image = image.toComposeImageBitmap(),
dstOffset = offset,
dstSize = tileSize
)
}
featuresState.features.values.filter { viewPoint.zoom in it.zoomRange }.sortedBy { it.z }
.forEach { feature ->
drawFeature(mapState, painterCache, 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)
)
)
}
}
}

View File

@ -7,14 +7,14 @@ package center.sciprog.maps.coordinates
import kotlin.math.*
public data class WebMercatorCoordinates(val zoom: Int, val x: Double, val y: Double)
public data class WebMercatorCoordinates(val zoom: Int, val x: Float, val y: Float)
public object WebMercatorProjection {
/**
* Compute radians to projection coordinates ratio for given [zoom] factor
*/
public fun scaleFactor(zoom: Float): Double = 256.0 / 2 / PI * 2f.pow(zoom)
public fun scaleFactor(zoom: Float): Float = (256.0 / 2 / PI * 2f.pow(zoom)).toFloat()
public fun toGeodetic(mercator: WebMercatorCoordinates): GeodeticMapCoordinates {
val scaleFactor = scaleFactor(mercator.zoom.toFloat())
@ -32,8 +32,8 @@ public object WebMercatorProjection {
val scaleFactor = scaleFactor(zoom.toFloat())
return WebMercatorCoordinates(
zoom = zoom,
x = scaleFactor * (gmc.longitude.radians.value + PI),
y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude.radians.value / 2)))
x = scaleFactor * (gmc.longitude.radians.value + PI).toFloat(),
y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude.radians.value / 2))).toFloat()
)
}

View File

@ -5,7 +5,11 @@ import androidx.compose.ui.graphics.Color
public object ZAttribute : Feature.Attribute<Float>
public object SelectableAttribute : Feature.Attribute<(FeatureId<*>, SelectableFeature<*>) -> Unit>
public object DraggableAttribute : Feature.Attribute<DragHandle<Any>>
public object DragListenerAttribute : Feature.Attribute<Set<(begin: Any, end: Any) -> Unit>>
public object SelectableAttribute : Feature.Attribute<ClickHandle<Any>>
public object VisibleAttribute : Feature.Attribute<Boolean>

View File

@ -1,6 +1,11 @@
package center.sciprog.maps.features
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 kotlin.math.abs
import kotlin.math.sqrt
public interface Area<T : Any> {
@ -31,6 +36,11 @@ public interface CoordinateSpace<T : Any> {
*/
public fun Rectangle(center: T, zoom: Float, size: DpSize): Rectangle<T>
/**
* A view point used by default
*/
public val defaultViewPoint: ViewPoint<T>
/**
* Create a [ViewPoint] associated with this coordinate space.
*/
@ -52,6 +62,20 @@ public interface CoordinateSpace<T : Any> {
public fun Collection<T>.wrapPoints(): Rectangle<T>?
public fun T.offsetTo(b: T, zoom: Float): DpOffset
public fun T.distanceTo(b: T, zoom: Float): Dp {
val offset = offsetTo(b, zoom)
return sqrt(offset.x.value * offset.x.value + offset.y.value * offset.y.value).dp
}
public fun T.distanceToLine(a: T, b: T, zoom: Float): Dp {
val d12 = a.offsetTo(b, zoom)
val d01 = offsetTo(a, zoom)
val distanceVale = abs(d12.x.value * d01.y.value - d12.y.value * d01.x.value) / a.distanceTo(b, zoom).value
return distanceVale.dp
}
}
public fun <T : Any> CoordinateSpace<T>.Rectangle(viewPoint: ViewPoint<T>, size: DpSize): Rectangle<T> =

View File

@ -6,32 +6,43 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.*
import kotlin.math.pow
import kotlin.math.sqrt
public abstract class CoordinateViewState<T : Any>(
private fun distanceBetween(a: DpOffset, b: DpOffset): Dp = sqrt((b.x - a.x).value.pow(2) + (b.y - a.y).value.pow(2)).dp
public abstract class CoordinateViewScope<T : Any>(
public val config: ViewConfig<T>,
canvasSize: DpSize,
viewPoint: ViewPoint<T>,
) {
public abstract val space: CoordinateSpace<T>
public var canvasSize: DpSize by mutableStateOf(canvasSize)
protected var viewPointState: MutableState<ViewPoint<T>> = mutableStateOf(viewPoint)
protected var canvasSizeState: MutableState<DpSize?> = mutableStateOf(null)
protected var viewPointState: MutableState<ViewPoint<T>?> = mutableStateOf(null)
public var canvasSize: DpSize
get() = canvasSizeState.value ?: DpSize(512.dp, 512.dp)
set(value) {
canvasSizeState.value = value
}
public var viewPoint: ViewPoint<T>
get() = viewPointState.value
get() = viewPointState.value ?: space.defaultViewPoint
set(value) {
config.onViewChange(value)
viewPointState.value = value
}
public val zoom: Float get() = viewPoint.zoom
// Selection rectangle. If null - no selection
public var selectRect: DpRect? by mutableStateOf(null)
public abstract fun DpOffset.toCoordinates(): T
public abstract fun T.toDpOffset(): DpOffset
public fun T.toOffset(density: Density): Offset = with(density){
public fun T.toOffset(density: Density): Offset = with(density) {
val dpOffset = this@toOffset.toDpOffset()
Offset(dpOffset.x.toPx(), dpOffset.y.toPx())
}
@ -40,10 +51,7 @@ public abstract class CoordinateViewState<T : Any>(
public abstract fun ViewPoint<T>.moveBy(x: Dp, y: Dp): ViewPoint<T>
public abstract fun viewPointFor(rectangle: Rectangle<T>): ViewPoint<T>
// Selection rectangle. If null - no selection
public var selectRect: DpRect? by mutableStateOf(null)
public abstract fun computeViewPoint(rectangle: Rectangle<T>): ViewPoint<T>
}

View File

@ -1,6 +0,0 @@
package center.sciprog.maps.features
public object DraggableAttribute : Feature.Attribute<DragHandle<Any>>
public object DragListenerAttribute : Feature.Attribute<Set<(begin: Any, end: Any) -> Unit>>

View File

@ -29,14 +29,14 @@ public interface Feature<T : Any> {
public fun getBoundingBox(zoom: Float): Rectangle<T>?
}
public interface PainterFeature<T:Any>: Feature<T> {
public interface PainterFeature<T : Any> : Feature<T> {
@Composable
public fun getPainter(): Painter
}
public interface SelectableFeature<T : Any> : Feature<T> {
public operator fun contains(point: ViewPoint<T>): Boolean = getBoundingBox(point.zoom)?.let {
point.focus in it
public fun contains(point: T, zoom: Float): Boolean = getBoundingBox(zoom)?.let {
point in it
} ?: false
}
@ -47,7 +47,7 @@ public interface DraggableFeature<T : Any> : SelectableFeature<T> {
/**
* A draggable marker feature. Other features could be bound to this one.
*/
public interface MarkerFeature<T: Any>: DraggableFeature<T>{
public interface MarkerFeature<T : Any> : DraggableFeature<T> {
public val center: T
}
@ -72,7 +72,6 @@ public class FeatureSelector<T : Any>(
public val selector: (zoom: Float) -> Feature<T>,
) : Feature<T> {
override fun getBoundingBox(zoom: Float): Rectangle<T>? = selector(zoom).getBoundingBox(zoom)
}
@ -157,8 +156,8 @@ public class LineFeature<T : Any>(
override fun getBoundingBox(zoom: Float): Rectangle<T> =
space.Rectangle(a, b)
override fun contains(point: ViewPoint<T>): Boolean {
return super.contains(point)
override fun contains(point: T, zoom: Float): Boolean = with(space) {
point in space.Rectangle(a, b) && point.distanceToLine(a, b, zoom).value < 5f
}
}
@ -228,17 +227,17 @@ public data class VectorImageFeature<T : Any>(
*
* @param rectangle the size of background in scheme size units. The screen units to scheme units ratio equals scale.
*/
public class ScalableImageFeature<T: Any>(
public class ScalableImageFeature<T : Any>(
override val space: CoordinateSpace<T>,
public val rectangle: Rectangle<T>,
override val zoomRange: FloatRange,
override val attributes: AttributeMap = AttributeMap(),
public val painter: @Composable () -> Painter,
) : Feature<T>, PainterFeature<T>{
) : Feature<T>, PainterFeature<T> {
@Composable
override fun getPainter(): Painter = painter.invoke()
override fun getPainter(): Painter = painter.invoke()
override fun getBoundingBox(zoom: Float): Rectangle<T> =rectangle
override fun getBoundingBox(zoom: Float): Rectangle<T> = rectangle
}

View File

@ -119,9 +119,14 @@ public class FeatureCollection<T : Any>(
@Suppress("UNCHECKED_CAST")
public fun <F : SelectableFeature<T>> FeatureId<F>.selectable(
onSelect: (FeatureId<F>, F) -> Unit,
onSelect: () -> Unit,
) {
setAttribute(this, SelectableAttribute) { id, feature -> onSelect(id as FeatureId<F>, feature as F) }
// val handle = ClickHandle<Any> { event, click ->
// val feature: F = get(this@selectable)
// if (feature.contains(this, click.focus))
// }
//
// setAttribute(this, SelectableAttribute, handle)
}

View File

@ -1,12 +1,27 @@
package center.sciprog.maps.features
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.unit.DpSize
public fun interface ClickHandle<T : Any> {
public fun handle(event: PointerEvent, click: ViewPoint<T>): Unit
public companion object {
public fun <T : Any> withPrimaryButton(
block: (event: PointerEvent, click: ViewPoint<T>) -> Unit,
): ClickHandle<T> = ClickHandle { event, click ->
if (event.buttons.isPrimaryPressed) {
block(event, click)
}
}
}
}
public data class ViewConfig<T : Any>(
val zoomSpeed: Float = 1f / 3f,
val onClick: ViewPoint<T>.(PointerEvent) -> Unit = {},
val dragHandle: DragHandle<T> = DragHandle.bypass(),
val onClick: ClickHandle<T>? = null,
val dragHandle: DragHandle<T>? = null,
val onViewChange: ViewPoint<T>.() -> Unit = {},
val onSelect: (Rectangle<T>) -> Unit = {},
val zoomOnSelect: Boolean = true,

View File

@ -1,14 +1,14 @@
package center.sciprog.maps.compose
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 center.sciprog.maps.features.CoordinateViewState
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.dp
import center.sciprog.maps.features.CoordinateViewScope
import center.sciprog.maps.features.bottomRight
import center.sciprog.maps.features.topLeft
import kotlin.math.max
@ -17,16 +17,37 @@ import kotlin.math.min
@OptIn(ExperimentalComposeUiApi::class)
public fun <T : Any> Modifier.mapControls(
state: CoordinateViewState<T>,
state: CoordinateViewScope<T>,
): Modifier = with(state) {
pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Release ) {
config.onClick?.handle(
event,
space.ViewPoint(event.changes.first().position.toDpOffset().toCoordinates(), zoom)
)
}
}
}
}.pointerInput(Unit) {
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
awaitPointerEventScope {
while (true) {
val event: PointerEvent = awaitPointerEvent()
event.changes.forEach { change ->
if (event.type == PointerEventType.Scroll) {
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 * config.zoomSpeed, invariant)
}
change.consume()
}
//val dragStart = change.position
//val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
@ -45,12 +66,12 @@ public fun <T : Any> Modifier.mapControls(
//apply drag handle and check if it prohibits the drag even propagation
if (selectionStart == null) {
val dragResult = config.dragHandle.handle(
val dragResult = config.dragHandle?.handle(
event,
space.ViewPoint(dpStart.toCoordinates(), viewPoint.zoom),
space.ViewPoint(dpEnd.toCoordinates(), viewPoint.zoom)
space.ViewPoint(dpStart.toCoordinates(), zoom),
space.ViewPoint(dpEnd.toCoordinates(), zoom)
)
if(!dragResult.handleNext) return@drag
if (dragResult?.handleNext == false) return@drag
}
if (event.buttons.isPrimaryPressed) {
@ -85,20 +106,12 @@ public fun <T : Any> Modifier.mapControls(
)
config.onSelect(coordinateRect)
if (config.zoomOnSelect) {
viewPoint = viewPointFor(coordinateRect)
viewPoint = computeViewPoint(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 * config.zoomSpeed, invariant)
}
}
}

View File

@ -21,7 +21,7 @@ internal fun Color.toPaint(): Paint = Paint().apply {
}
public fun <T : Any> DrawScope.drawFeature(
state: CoordinateViewState<T>,
state: CoordinateViewScope<T>,
painterCache: Map<PainterFeature<T>, Painter>,
feature: Feature<T>,
): Unit = with(state) {

View File

@ -1,6 +1,8 @@
package center.sciprog.maps.scheme
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.features.CoordinateSpace
import center.sciprog.maps.features.Rectangle
import center.sciprog.maps.features.ViewPoint
@ -61,4 +63,11 @@ object XYCoordinateSpace : CoordinateSpace<XY> {
XY(maxX, maxY)
)
}
override val defaultViewPoint: ViewPoint<XY> = XYViewPoint(XY(0f, 0f), 1f)
override fun XY.offsetTo(b: XY, zoom: Float): DpOffset = DpOffset(
(b.x - x).dp * zoom,
(b.y - y).dp * zoom
)
}

View File

@ -1,14 +1,17 @@
package center.sciprog.maps.scheme
import androidx.compose.ui.unit.*
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.DpRect
import androidx.compose.ui.unit.dp
import center.sciprog.maps.features.*
import kotlin.math.min
class XYViewState(
class XYViewScope(
config: ViewConfig<XY>,
canvasSize: DpSize,
viewPoint: ViewPoint<XY>,
) : CoordinateViewState<XY>(config, canvasSize, viewPoint) {
) : CoordinateViewScope<XY>(config) {
override val space: CoordinateSpace<XY>
get() = XYCoordinateSpace
@ -21,8 +24,7 @@ class XYViewState(
(canvasSize.width / 2 + (x.dp - viewPoint.focus.x.dp) * viewPoint.zoom),
(canvasSize.height / 2 + (viewPoint.focus.y.dp - y.dp) * viewPoint.zoom)
)
override fun viewPointFor(rectangle: Rectangle<XY>): ViewPoint<XY> {
override fun computeViewPoint(rectangle: Rectangle<XY>): ViewPoint<XY> {
val scale = min(
canvasSize.width.value / rectangle.width,
canvasSize.height.value / rectangle.height
@ -41,5 +43,24 @@ class XYViewState(
val bottomRight = rightBottom.toDpOffset()
return DpRect(topLeft.x, topLeft.y, bottomRight.x, bottomRight.y)
}
}
@Composable
public fun rememberMapState(
config: ViewConfig<XY>,
features: Collection<Feature<XY>> = emptyList(),
initialViewPoint: ViewPoint<XY>? = null,
initialRectangle: Rectangle<XY>? = null,
): XYViewScope = remember {
XYViewScope(config).also { mapState->
if (initialViewPoint != null) {
mapState.viewPoint = initialViewPoint
} else if (initialRectangle != null) {
mapState.viewPoint = mapState.computeViewPoint(initialRectangle)
} else {
features.computeBoundingBox(XYCoordinateSpace, 1f)?.let {
mapState.viewPoint = mapState.computeViewPoint(it)
}
}
}
}

View File

@ -4,7 +4,6 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
@ -22,19 +21,10 @@ private val logger = KotlinLogging.logger("SchemeView")
@Composable
public fun SchemeView(
initialViewPoint: ViewPoint<XY>,
state: XYViewScope,
featuresState: FeatureCollection<XY>,
config: ViewConfig<XY>,
modifier: Modifier = Modifier.fillMaxSize(),
) = key(initialViewPoint) {
val state = remember {
XYViewState(
config,
defaultCanvasSize,
initialViewPoint,
)
}
) {
with(state) {
val painterCache: Map<PainterFeature<XY>, Painter> = key(featuresState) {
featuresState.features.values.filterIsInstance<PainterFeature<XY>>().associateWith { it.getPainter() }
@ -95,20 +85,22 @@ public fun SchemeView(
config: ViewConfig<XY> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
) {
val featuresState = key(featureMap) {
FeatureCollection.build(XYCoordinateSpace) {
featureMap.forEach { feature(it.key.id, it.value) }
}
}
val viewPointOverride: ViewPoint<XY> = remember(initialViewPoint, initialRectangle) {
initialViewPoint
?: initialRectangle?.computeViewPoint()
?: featureMap.values.computeBoundingBox(XYCoordinateSpace, 1f)?.computeViewPoint()
?: XYViewPoint(XY(0f, 0f), 1f)
}
val state = rememberMapState(
config,
featuresState.features.values,
initialViewPoint = initialViewPoint,
initialRectangle = initialRectangle,
)
SchemeView(viewPointOverride, featuresState, config, modifier)
SchemeView(state, featuresState, modifier)
}
/**
@ -127,37 +119,42 @@ public fun SchemeView(
buildFeatures: FeatureCollection<XY>.() -> Unit = {},
) {
val featureState = FeatureCollection.remember(XYCoordinateSpace, buildFeatures)
val mapState: XYViewScope = rememberMapState(
config,
featureState.features.values,
initialViewPoint = initialViewPoint,
initialRectangle = initialRectangle,
)
val viewPointOverride: ViewPoint<XY> = remember(initialViewPoint, initialRectangle) {
initialViewPoint
?: initialRectangle?.computeViewPoint()
?: featureState.features.values.computeBoundingBox(XYCoordinateSpace, 1f)?.computeViewPoint()
?: XYViewPoint(XY(0f, 0f), 1f)
val featureDrag: DragHandle<XY> = DragHandle.withPrimaryButton { event, start, end ->
featureState.forEachWithAttribute(DraggableAttribute) { _, handle ->
@Suppress("UNCHECKED_CAST")
(handle as DragHandle<XY>)
.handle(event, start, end)
.takeIf { !it.handleNext }
?.let {
//we expect it already have no bypass
return@withPrimaryButton it
}
}
//bypass
DragResult(end)
}
val featureDrag: DragHandle<XY> =
DragHandle.withPrimaryButton { event, start: ViewPoint<XY>, end: ViewPoint<XY> ->
featureState.forEachWithAttribute(DraggableAttribute) { _, handle ->
//TODO add safety
(handle as DragHandle<XY>)
.handle(event, start, end)
.takeIf { !it.handleNext }
?.let { return@withPrimaryButton it }
}
DragResult(end)
val featureClick: ClickHandle<XY> = ClickHandle.withPrimaryButton { event, click ->
featureState.forEachWithAttribute(SelectableAttribute) { _, handle ->
@Suppress("UNCHECKED_CAST")
(handle as ClickHandle<XY>).handle(event, click)
config.onClick?.handle(event, click)
}
}
val newConfig = config.copy(
dragHandle = DragHandle.combine(featureDrag, config.dragHandle)
dragHandle = config.dragHandle?.let { DragHandle.combine(featureDrag, it) } ?: featureDrag,
onClick = featureClick
)
SchemeView(
initialViewPoint = viewPointOverride,
featuresState = featureState,
config = newConfig,
modifier = modifier,
)
SchemeView(mapState, featureState, modifier)
}
///**