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.MaterialTheme
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.runtime.* import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.unit.DpSize 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.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.delay 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.net.URL
import java.nio.file.Path import java.nio.file.Path
import kotlin.math.PI import kotlin.math.PI
@ -32,6 +37,7 @@ fun App() {
MaterialTheme { MaterialTheme {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val mapTileProvider = remember { val mapTileProvider = remember {
OpenStreetMapTileProvider( OpenStreetMapTileProvider(
client = HttpClient(CIO), 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 val pointOne = 55.568548 to 37.568604
@ -48,17 +54,9 @@ fun App() {
MapView( MapView(
mapTileProvider = mapTileProvider, 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( 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())) it.copy(color = Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat()))
} }
draw(position = pointThree) { // 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)
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) arc(pointOne, 10.0.kilometers, (PI / 4).radians, -Angle.pi / 2)
line(pointOne, pointTwo, id = "line") line(pointOne, pointTwo, id = "line")
text(pointOne, "Home", font = { size = 32f }) text(pointOne, "Home", font = { size = 32f })
centerCoordinates?.let { centerCoordinates.filterNotNull().onEach {
group(id = "center") { group(id = "center") {
circle(center = it, color = Color.Blue, id = "circle", size = 1.dp) circle(center = it, color = Color.Blue, id = "circle", size = 1.dp)
text(position = it, it.toShortString(), id = "text", color = Color.Blue) text(position = it, it.toShortString(), id = "text", color = Color.Blue)
} }
} }.launchIn(scope)
} }
} }
} }

View File

@ -79,15 +79,20 @@ fun App() {
) )
} }
) { ) {
SchemeView( val mapState: XYViewScope = rememberMapState(
initialViewPoint = initialViewPoint, ViewConfig(
featuresState = schemeFeaturesState, onClick = {_, click ->
config = ViewConfig( println("${click.focus.x}, ${click.focus.y}")
onClick = {
println("${focus.x}, ${focus.y}")
}, },
onViewChange = { viewPoint = this } 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 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 companion object {
public const val DEFAULT_TILE_SIZE: Int = 256 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.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.coordinates.Gmc
import center.sciprog.maps.features.* import center.sciprog.maps.features.*
import kotlin.math.PI
import kotlin.math.log2
import kotlin.math.min
@Composable @Composable
public expect fun MapView( public expect fun MapView(
mapTileProvider: MapTileProvider, mapState: MapViewScope,
initialViewPoint: MapViewPoint,
featuresState: FeatureCollection<Gmc>, featuresState: FeatureCollection<Gmc>,
config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(), 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. * A builder for a Map with static features.
*/ */
@ -50,20 +27,22 @@ public fun MapView(
config: ViewConfig<Gmc> = ViewConfig(), config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(), 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) } featureMap.forEach { feature(it.key.id, it.value) }
} }
} }
val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) { val mapState: MapViewScope = rememberMapState(
initialViewPoint mapTileProvider,
?: initialRectangle?.computeViewPoint(mapTileProvider) config,
?: featureMap.values.computeBoundingBox(GmcCoordinateSpace, 1f)?.computeViewPoint(mapTileProvider) featuresState.features.values,
?: MapViewPoint.globe 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(), modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: FeatureCollection<Gmc>.() -> Unit = {}, buildFeatures: FeatureCollection<Gmc>.() -> Unit = {},
) { ) {
val featureState = FeatureCollection.remember(GmcCoordinateSpace, buildFeatures)
val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) { val featureState = FeatureCollection.remember(WebMercatorSpace, buildFeatures)
initialViewPoint val mapState: MapViewScope = rememberMapState(
?: initialRectangle?.computeViewPoint(mapTileProvider) mapTileProvider,
?: featureState.features.values.computeBoundingBox(GmcCoordinateSpace, 1f) config,
?.computeViewPoint(mapTileProvider) featureState.features.values,
?: MapViewPoint.globe initialViewPoint = initialViewPoint,
} initialRectangle = initialRectangle,
)
val featureDrag: DragHandle<Gmc> = DragHandle.withPrimaryButton { event, start, end -> val featureDrag: DragHandle<Gmc> = DragHandle.withPrimaryButton { event, start, end ->
featureState.forEachWithAttribute(DraggableAttribute) { _, handle -> featureState.forEachWithAttribute(DraggableAttribute) { _, handle ->
@ -107,16 +86,18 @@ public fun MapView(
DragResult(end) 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( val newConfig = config.copy(
dragHandle = DragHandle.combine(featureDrag, config.dragHandle) dragHandle = config.dragHandle?.let { DragHandle.combine(featureDrag, it) } ?: featureDrag,
onClick = featureClick
) )
MapView( MapView(mapState, featureState, modifier)
mapTileProvider = mapTileProvider,
initialViewPoint = viewPointOverride,
featuresState = featureState,
config = newConfig,
modifier = modifier,
)
} }

View File

@ -11,7 +11,7 @@ public data class MapViewPoint(
override val focus: GeodeticMapCoordinates, override val focus: GeodeticMapCoordinates,
override val zoom: Float, override val zoom: Float,
) : ViewPoint<Gmc>{ ) : ViewPoint<Gmc>{
val scaleFactor: Double by lazy { WebMercatorProjection.scaleFactor(zoom) } val scaleFactor: Float by lazy { WebMercatorProjection.scaleFactor(zoom) }
public companion object{ public companion object{
public val globe: MapViewPoint = MapViewPoint(GeodeticMapCoordinates(0.0.radians, 0.0.radians), 1f) 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.Composable
import androidx.compose.runtime.remember 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.coordinates.*
import center.sciprog.maps.features.* import center.sciprog.maps.features.*
import kotlin.math.* import kotlin.math.*
internal class MapViewState internal constructor( public class MapViewScope internal constructor(
public val mapTileProvider: MapTileProvider,
config: ViewConfig<Gmc>, config: ViewConfig<Gmc>,
canvasSize: DpSize, ) : CoordinateViewScope<Gmc>(config) {
viewPoint: ViewPoint<Gmc>, override val space: CoordinateSpace<Gmc> get() = WebMercatorSpace
val tileSize: Int,
) : CoordinateViewState<Gmc>(config, canvasSize, viewPoint) {
override val space: CoordinateSpace<Gmc> get() = GmcCoordinateSpace
val scaleFactor: Double public val scaleFactor: Float
get() = WebMercatorProjection.scaleFactor(viewPoint.zoom) 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) get() = WebMercatorProjection.toMercator(viewPoint.focus, intZoom)
val tileScale: Float public val tileScale: Float
get() = 2f.pow(viewPoint.zoom - floor(viewPoint.zoom)) get() = 2f.pow(zoom - floor(zoom))
/* /*
* Convert screen independent offset to GMC, adjusting for fractional 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 { override fun Gmc.toDpOffset(): DpOffset {
val mercator = WebMercatorProjection.toMercator(this, intZoom) val mercator = WebMercatorProjection.toMercator(this, intZoom)
return DpOffset( return DpOffset(
(canvasSize.width / 2 + (mercator.x.dp - centerCoordinates.x.dp) * tileScale.toFloat()), (canvasSize.width / 2 + (mercator.x.dp - centerCoordinates.x.dp) * tileScale),
(canvasSize.height / 2 + (mercator.y.dp - centerCoordinates.y.dp) * tileScale.toFloat()) (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) 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( val zoom = log2(
min( min(
canvasSize.width.value / rectangle.longitudeDelta.radians.value, canvasSize.width.value / rectangle.longitudeDelta.radians.value,
canvasSize.height.value / rectangle.latitudeDelta.radians.value canvasSize.height.value / rectangle.latitudeDelta.radians.value
) * PI / tileSize ) * PI / mapTileProvider.tileSize
) )
return MapViewPoint(rectangle.center, zoom.toFloat()) return MapViewPoint(rectangle.center, zoom.toFloat())
} }
@ -78,10 +79,21 @@ internal class MapViewState internal constructor(
@Composable @Composable
internal fun rememberMapState( internal fun rememberMapState(
mapTileProvider: MapTileProvider,
config: ViewConfig<Gmc>, config: ViewConfig<Gmc>,
canvasSize: DpSize, features: Collection<Feature<Gmc>> = emptyList(),
viewPoint: ViewPoint<Gmc>, initialViewPoint: MapViewPoint? = null,
tileSize: Int, initialRectangle: Rectangle<Gmc>? = null,
): MapViewState = remember { ): MapViewScope = remember {
MapViewState(config, canvasSize, viewPoint, tileSize) 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 package center.sciprog.maps.compose
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.* import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.CoordinateSpace import center.sciprog.maps.features.CoordinateSpace
import center.sciprog.maps.features.Rectangle import center.sciprog.maps.features.Rectangle
import center.sciprog.maps.features.ViewPoint import center.sciprog.maps.features.ViewPoint
import kotlin.math.floor
import kotlin.math.pow 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(first: Gmc, second: Gmc): Rectangle<Gmc> = GmcRectangle(first, second)
override fun Rectangle(center: Gmc, zoom: Float, size: DpSize): Rectangle<Gmc> { 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) 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(center: Gmc, zoom: Float): ViewPoint<Gmc> = MapViewPoint(center, zoom)
override fun ViewPoint<Gmc>.moveBy(delta: Gmc): ViewPoint<Gmc> { 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) { 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 { } else {
val difScale = (1 - 2f.pow(-zoomDelta)) val difScale = (1 - 2f.pow(-zoomDelta))
val newCenter = GeodeticMapCoordinates( val newCenter = GeodeticMapCoordinates(
@ -61,6 +71,17 @@ public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
val maxLong = maxOf { it.longitude } val maxLong = maxOf { it.longitude }
return GmcRectangle(GeodeticMapCoordinates(minLat, minLong), GeodeticMapCoordinates(maxLat, maxLong)) 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.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.Gmc 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.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import mu.KotlinLogging import mu.KotlinLogging
@ -40,111 +43,101 @@ private val logger = KotlinLogging.logger("MapView")
*/ */
@Composable @Composable
public actual fun MapView( public actual fun MapView(
mapTileProvider: MapTileProvider, mapState: MapViewScope,
initialViewPoint: MapViewPoint,
featuresState: FeatureCollection<Gmc>, featuresState: FeatureCollection<Gmc>,
config: ViewConfig<Gmc>,
modifier: Modifier, modifier: Modifier,
): Unit = key(initialViewPoint) { ): Unit = with(mapState) {
val state = rememberMapState( val mapTiles = remember(mapTileProvider) { mutableStateListOf<MapTile>() }
config,
defaultCanvasSize,
initialViewPoint,
mapTileProvider.tileSize
)
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 val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
LaunchedEffect(viewPoint, canvasSize) { val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
with(mapTileProvider) { val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange)
val indexRange = 0 until 2.0.pow(intZoom).toInt()
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale mapTiles.clear()
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) for (j in verticalIndices) {
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) for (i in horizontalIndices) {
val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange) val id = TileId(intZoom, i, j)
//ensure that failed tiles do not fail the application
mapTiles.clear() supervisorScope {
//start all
for (j in verticalIndices) { val deferred = loadTileAsync(id)
for (i in horizontalIndices) { //wait asynchronously for it to finish
val id = TileId(intZoom, i, j) launch {
//ensure that failed tiles do not fail the application try {
supervisorScope { mapTiles += deferred.await()
//start all } catch (ex: Exception) {
val deferred = loadTileAsync(id) //displaying the error is maps responsibility
//wait asynchronously for it to finish logger.error(ex) { "Failed to load tile with id=$id" }
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.* 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 { public object WebMercatorProjection {
/** /**
* Compute radians to projection coordinates ratio for given [zoom] factor * 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 { public fun toGeodetic(mercator: WebMercatorCoordinates): GeodeticMapCoordinates {
val scaleFactor = scaleFactor(mercator.zoom.toFloat()) val scaleFactor = scaleFactor(mercator.zoom.toFloat())
@ -32,8 +32,8 @@ public object WebMercatorProjection {
val scaleFactor = scaleFactor(zoom.toFloat()) val scaleFactor = scaleFactor(zoom.toFloat())
return WebMercatorCoordinates( return WebMercatorCoordinates(
zoom = zoom, zoom = zoom,
x = scaleFactor * (gmc.longitude.radians.value + PI), x = scaleFactor * (gmc.longitude.radians.value + PI).toFloat(),
y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude.radians.value / 2))) 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 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> public object VisibleAttribute : Feature.Attribute<Boolean>

View File

@ -1,6 +1,11 @@
package center.sciprog.maps.features 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.DpSize
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.sqrt
public interface Area<T : Any> { 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> 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. * 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 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> = 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.runtime.setValue
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.* 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>, public val config: ViewConfig<T>,
canvasSize: DpSize,
viewPoint: ViewPoint<T>,
) { ) {
public abstract val space: CoordinateSpace<T> public abstract val space: CoordinateSpace<T>
public var canvasSize: DpSize by mutableStateOf(canvasSize) protected var canvasSizeState: MutableState<DpSize?> = mutableStateOf(null)
protected var viewPointState: MutableState<ViewPoint<T>> = mutableStateOf(viewPoint) 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> public var viewPoint: ViewPoint<T>
get() = viewPointState.value get() = viewPointState.value ?: space.defaultViewPoint
set(value) { set(value) {
config.onViewChange(value)
viewPointState.value = value viewPointState.value = value
} }
public val zoom: Float get() = viewPoint.zoom 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 DpOffset.toCoordinates(): T
public abstract fun T.toDpOffset(): DpOffset 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() val dpOffset = this@toOffset.toDpOffset()
Offset(dpOffset.x.toPx(), dpOffset.y.toPx()) 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 ViewPoint<T>.moveBy(x: Dp, y: Dp): ViewPoint<T>
public abstract fun viewPointFor(rectangle: Rectangle<T>): ViewPoint<T> public abstract fun computeViewPoint(rectangle: Rectangle<T>): ViewPoint<T>
// Selection rectangle. If null - no selection
public var selectRect: DpRect? by mutableStateOf(null)
} }

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 fun getBoundingBox(zoom: Float): Rectangle<T>?
} }
public interface PainterFeature<T:Any>: Feature<T> { public interface PainterFeature<T : Any> : Feature<T> {
@Composable @Composable
public fun getPainter(): Painter public fun getPainter(): Painter
} }
public interface SelectableFeature<T : Any> : Feature<T> { public interface SelectableFeature<T : Any> : Feature<T> {
public operator fun contains(point: ViewPoint<T>): Boolean = getBoundingBox(point.zoom)?.let { public fun contains(point: T, zoom: Float): Boolean = getBoundingBox(zoom)?.let {
point.focus in it point in it
} ?: false } ?: false
} }
@ -47,7 +47,7 @@ public interface DraggableFeature<T : Any> : SelectableFeature<T> {
/** /**
* A draggable marker feature. Other features could be bound to this one. * 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 public val center: T
} }
@ -69,10 +69,9 @@ public class FeatureSelector<T : Any>(
override val space: CoordinateSpace<T>, override val space: CoordinateSpace<T>,
override val zoomRange: FloatRange, override val zoomRange: FloatRange,
override val attributes: AttributeMap = AttributeMap(), override val attributes: AttributeMap = AttributeMap(),
public val selector: (zoom: Float) -> Feature<T>, public val selector: (zoom: Float) -> Feature<T>,
) : Feature<T> { ) : Feature<T> {
override fun getBoundingBox(zoom: Float): Rectangle<T>? = selector(zoom).getBoundingBox(zoom) 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> = override fun getBoundingBox(zoom: Float): Rectangle<T> =
space.Rectangle(a, b) space.Rectangle(a, b)
override fun contains(point: ViewPoint<T>): Boolean { override fun contains(point: T, zoom: Float): Boolean = with(space) {
return super.contains(point) 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. * @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>, override val space: CoordinateSpace<T>,
public val rectangle: Rectangle<T>, public val rectangle: Rectangle<T>,
override val zoomRange: FloatRange, override val zoomRange: FloatRange,
override val attributes: AttributeMap = AttributeMap(), override val attributes: AttributeMap = AttributeMap(),
public val painter: @Composable () -> Painter, public val painter: @Composable () -> Painter,
) : Feature<T>, PainterFeature<T>{ ) : Feature<T>, PainterFeature<T> {
@Composable @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") @Suppress("UNCHECKED_CAST")
public fun <F : SelectableFeature<T>> FeatureId<F>.selectable( 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 package center.sciprog.maps.features
import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.unit.DpSize 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>( public data class ViewConfig<T : Any>(
val zoomSpeed: Float = 1f / 3f, val zoomSpeed: Float = 1f / 3f,
val onClick: ViewPoint<T>.(PointerEvent) -> Unit = {}, val onClick: ClickHandle<T>? = null,
val dragHandle: DragHandle<T> = DragHandle.bypass(), val dragHandle: DragHandle<T>? = null,
val onViewChange: ViewPoint<T>.() -> Unit = {}, val onViewChange: ViewPoint<T>.() -> Unit = {},
val onSelect: (Rectangle<T>) -> Unit = {}, val onSelect: (Rectangle<T>) -> Unit = {},
val zoomOnSelect: Boolean = true, val zoomOnSelect: Boolean = true,

View File

@ -1,14 +1,14 @@
package center.sciprog.maps.compose package center.sciprog.maps.compose
import androidx.compose.foundation.gestures.drag 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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.* import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.DpOffset
import center.sciprog.maps.features.CoordinateViewState 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.bottomRight
import center.sciprog.maps.features.topLeft import center.sciprog.maps.features.topLeft
import kotlin.math.max import kotlin.math.max
@ -17,16 +17,37 @@ import kotlin.math.min
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
public fun <T : Any> Modifier.mapControls( public fun <T : Any> Modifier.mapControls(
state: CoordinateViewState<T>, state: CoordinateViewScope<T>,
): Modifier = with(state) { ): Modifier = with(state) {
pointerInput(Unit) { pointerInput(Unit) {
forEachGesture { fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
awaitPointerEventScope { awaitPointerEventScope {
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp()) 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() val event: PointerEvent = awaitPointerEvent()
event.changes.forEach { change -> 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 dragStart = change.position
//val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp()) //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 //apply drag handle and check if it prohibits the drag even propagation
if (selectionStart == null) { if (selectionStart == null) {
val dragResult = config.dragHandle.handle( val dragResult = config.dragHandle?.handle(
event, event,
space.ViewPoint(dpStart.toCoordinates(), viewPoint.zoom), space.ViewPoint(dpStart.toCoordinates(), zoom),
space.ViewPoint(dpEnd.toCoordinates(), viewPoint.zoom) space.ViewPoint(dpEnd.toCoordinates(), zoom)
) )
if(!dragResult.handleNext) return@drag if (dragResult?.handleNext == false) return@drag
} }
if (event.buttons.isPrimaryPressed) { if (event.buttons.isPrimaryPressed) {
@ -85,20 +106,12 @@ public fun <T : Any> Modifier.mapControls(
) )
config.onSelect(coordinateRect) config.onSelect(coordinateRect)
if (config.zoomOnSelect) { if (config.zoomOnSelect) {
viewPoint = viewPointFor(coordinateRect) viewPoint = computeViewPoint(coordinateRect)
} }
selectRect = null 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( public fun <T : Any> DrawScope.drawFeature(
state: CoordinateViewState<T>, state: CoordinateViewScope<T>,
painterCache: Map<PainterFeature<T>, Painter>, painterCache: Map<PainterFeature<T>, Painter>,
feature: Feature<T>, feature: Feature<T>,
): Unit = with(state) { ): Unit = with(state) {

View File

@ -1,6 +1,8 @@
package center.sciprog.maps.scheme package center.sciprog.maps.scheme
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.features.CoordinateSpace import center.sciprog.maps.features.CoordinateSpace
import center.sciprog.maps.features.Rectangle import center.sciprog.maps.features.Rectangle
import center.sciprog.maps.features.ViewPoint import center.sciprog.maps.features.ViewPoint
@ -61,4 +63,11 @@ object XYCoordinateSpace : CoordinateSpace<XY> {
XY(maxX, maxY) 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 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 center.sciprog.maps.features.*
import kotlin.math.min import kotlin.math.min
class XYViewState( class XYViewScope(
config: ViewConfig<XY>, config: ViewConfig<XY>,
canvasSize: DpSize, ) : CoordinateViewScope<XY>(config) {
viewPoint: ViewPoint<XY>,
) : CoordinateViewState<XY>(config, canvasSize, viewPoint) {
override val space: CoordinateSpace<XY> override val space: CoordinateSpace<XY>
get() = XYCoordinateSpace get() = XYCoordinateSpace
@ -21,8 +24,7 @@ class XYViewState(
(canvasSize.width / 2 + (x.dp - viewPoint.focus.x.dp) * viewPoint.zoom), (canvasSize.width / 2 + (x.dp - viewPoint.focus.x.dp) * viewPoint.zoom),
(canvasSize.height / 2 + (viewPoint.focus.y.dp - y.dp) * viewPoint.zoom) (canvasSize.height / 2 + (viewPoint.focus.y.dp - y.dp) * viewPoint.zoom)
) )
override fun computeViewPoint(rectangle: Rectangle<XY>): ViewPoint<XY> {
override fun viewPointFor(rectangle: Rectangle<XY>): ViewPoint<XY> {
val scale = min( val scale = min(
canvasSize.width.value / rectangle.width, canvasSize.width.value / rectangle.width,
canvasSize.height.value / rectangle.height canvasSize.height.value / rectangle.height
@ -41,5 +43,24 @@ class XYViewState(
val bottomRight = rightBottom.toDpOffset() val bottomRight = rightBottom.toDpOffset()
return DpRect(topLeft.x, topLeft.y, bottomRight.x, bottomRight.y) 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.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
@ -22,19 +21,10 @@ private val logger = KotlinLogging.logger("SchemeView")
@Composable @Composable
public fun SchemeView( public fun SchemeView(
initialViewPoint: ViewPoint<XY>, state: XYViewScope,
featuresState: FeatureCollection<XY>, featuresState: FeatureCollection<XY>,
config: ViewConfig<XY>,
modifier: Modifier = Modifier.fillMaxSize(), modifier: Modifier = Modifier.fillMaxSize(),
) = key(initialViewPoint) { ) {
val state = remember {
XYViewState(
config,
defaultCanvasSize,
initialViewPoint,
)
}
with(state) { with(state) {
val painterCache: Map<PainterFeature<XY>, Painter> = key(featuresState) { val painterCache: Map<PainterFeature<XY>, Painter> = key(featuresState) {
featuresState.features.values.filterIsInstance<PainterFeature<XY>>().associateWith { it.getPainter() } featuresState.features.values.filterIsInstance<PainterFeature<XY>>().associateWith { it.getPainter() }
@ -95,20 +85,22 @@ public fun SchemeView(
config: ViewConfig<XY> = ViewConfig(), config: ViewConfig<XY> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(), modifier: Modifier = Modifier.fillMaxSize(),
) { ) {
val featuresState = key(featureMap) { val featuresState = key(featureMap) {
FeatureCollection.build(XYCoordinateSpace) { FeatureCollection.build(XYCoordinateSpace) {
featureMap.forEach { feature(it.key.id, it.value) } featureMap.forEach { feature(it.key.id, it.value) }
} }
} }
val viewPointOverride: ViewPoint<XY> = remember(initialViewPoint, initialRectangle) { val state = rememberMapState(
initialViewPoint config,
?: initialRectangle?.computeViewPoint() featuresState.features.values,
?: featureMap.values.computeBoundingBox(XYCoordinateSpace, 1f)?.computeViewPoint() initialViewPoint = initialViewPoint,
?: XYViewPoint(XY(0f, 0f), 1f) initialRectangle = initialRectangle,
} )
SchemeView(viewPointOverride, featuresState, config, modifier) SchemeView(state, featuresState, modifier)
} }
/** /**
@ -127,37 +119,42 @@ public fun SchemeView(
buildFeatures: FeatureCollection<XY>.() -> Unit = {}, buildFeatures: FeatureCollection<XY>.() -> Unit = {},
) { ) {
val featureState = FeatureCollection.remember(XYCoordinateSpace, buildFeatures) 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) { val featureDrag: DragHandle<XY> = DragHandle.withPrimaryButton { event, start, end ->
initialViewPoint featureState.forEachWithAttribute(DraggableAttribute) { _, handle ->
?: initialRectangle?.computeViewPoint() @Suppress("UNCHECKED_CAST")
?: featureState.features.values.computeBoundingBox(XYCoordinateSpace, 1f)?.computeViewPoint() (handle as DragHandle<XY>)
?: XYViewPoint(XY(0f, 0f), 1f) .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> = val featureClick: ClickHandle<XY> = ClickHandle.withPrimaryButton { event, click ->
DragHandle.withPrimaryButton { event, start: ViewPoint<XY>, end: ViewPoint<XY> -> featureState.forEachWithAttribute(SelectableAttribute) { _, handle ->
featureState.forEachWithAttribute(DraggableAttribute) { _, handle -> @Suppress("UNCHECKED_CAST")
//TODO add safety (handle as ClickHandle<XY>).handle(event, click)
(handle as DragHandle<XY>) config.onClick?.handle(event, click)
.handle(event, start, end)
.takeIf { !it.handleNext }
?.let { return@withPrimaryButton it }
}
DragResult(end)
} }
}
val newConfig = config.copy( val newConfig = config.copy(
dragHandle = DragHandle.combine(featureDrag, config.dragHandle) dragHandle = config.dragHandle?.let { DragHandle.combine(featureDrag, it) } ?: featureDrag,
onClick = featureClick
) )
SchemeView( SchemeView(mapState, featureState, modifier)
initialViewPoint = viewPointOverride,
featuresState = featureState,
config = newConfig,
modifier = modifier,
)
} }
///** ///**