Generalize map control logic

This commit is contained in:
Alexander Nozik 2022-12-24 22:59:33 +03:00
parent 9b8ba884e1
commit fb13fa1431
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
11 changed files with 472 additions and 325 deletions

View File

@ -57,7 +57,7 @@ fun App() {
// 50.kilometers,
// 50.kilometers
// ),
config = MapViewConfig(
config = ViewConfig(
onViewChange = { centerCoordinates = focus },
)
) {

View File

@ -4,17 +4,44 @@ import androidx.compose.ui.unit.DpSize
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.pow
public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
override fun buildRectangle(first: Gmc, second: Gmc): GmcRectangle = GmcRectangle(first, second)
override fun Rectangle(first: Gmc, second: Gmc): GmcRectangle = GmcRectangle(first, second)
override fun buildRectangle(center: Gmc, zoom: Double, size: DpSize): GmcRectangle{
override fun Rectangle(center: Gmc, zoom: Double, size: DpSize): GmcRectangle {
val scale = WebMercatorProjection.scaleFactor(zoom)
return buildRectangle(center, (size.width.value/scale).radians, (size.height.value / scale).radians)
return buildRectangle(center, (size.width.value / scale).radians, (size.height.value / scale).radians)
}
override fun ViewPoint(center: Gmc, zoom: Double): ViewPoint<Gmc> = MapViewPoint(center, zoom)
override fun ViewPoint<Gmc>.moveBy(delta: Gmc): ViewPoint<Gmc> {
val newCoordinates = GeodeticMapCoordinates(
(focus.latitude + delta.latitude).coerceIn(
-MercatorProjection.MAXIMUM_LATITUDE,
MercatorProjection.MAXIMUM_LATITUDE
),
focus.longitude + delta.longitude
)
return MapViewPoint(newCoordinates, zoom)
}
override fun ViewPoint<Gmc>.zoomBy(zoomDelta: Double, invariant: Gmc): ViewPoint<Gmc> = if (invariant == focus) {
ViewPoint(focus, (zoom + zoomDelta).coerceIn(2.0, 18.0) )
} else {
val difScale = (1 - 2.0.pow(-zoomDelta))
val newCenter = GeodeticMapCoordinates(
focus.latitude + (invariant.latitude - focus.latitude) * difScale,
focus.longitude + (invariant.longitude - focus.longitude) * difScale
)
MapViewPoint(newCenter, (zoom + zoomDelta).coerceIn(2.0, 18.0))
}
override fun Rectangle<Gmc>.withCenter(center: Gmc): GmcRectangle {
return buildRectangle(center, height = latitudeDelta, width = longitudeDelta)
return buildRectangle(center, height = latitudeDelta, width = longitudeDelta)
}
override fun Collection<Rectangle<Gmc>>.wrapRectangles(): Rectangle<Gmc>? {
@ -45,7 +72,7 @@ public fun CoordinateSpace<Gmc>.buildRectangle(
center: Gmc,
height: Distance,
width: Distance,
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
): GmcRectangle {
val reducedRadius = ellipsoid.reducedRadius(center.latitude)
return buildRectangle(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians)

View File

@ -0,0 +1,85 @@
package center.sciprog.maps.compose
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.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.*
import kotlin.math.*
internal class MapState internal constructor(
config: ViewConfig<Gmc>,
canvasSize: DpSize,
viewPoint: ViewPoint<Gmc>,
public val tileSize: Int,
) : CoordinateViewState<Gmc>(config, canvasSize, viewPoint) {
override val space: CoordinateSpace<Gmc> get() = GmcCoordinateSpace
public val zoom: Int
get() = floor(viewPoint.zoom).toInt()
public val scaleFactor: Double
get() = WebMercatorProjection.scaleFactor(viewPoint.zoom)
public val centerCoordinates: WebMercatorCoordinates
get() = WebMercatorProjection.toMercator(viewPoint.focus, zoom)
internal val tileScale: Double
get() = 2.0.pow(viewPoint.zoom - zoom)
private 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
*/
override fun DpOffset.toCoordinates(): Gmc =
WebMercatorProjection.toGeodetic(toMercator())
internal fun WebMercatorCoordinates.toOffset(): DpOffset = DpOffset(
(canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()),
(canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat())
)
override fun Gmc.toDpOffset(): DpOffset =
WebMercatorProjection.toMercator(this, zoom).toOffset()
override fun viewPointFor(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
)
return MapViewPoint(rectangle.center, zoom)
}
override fun ViewPoint<Gmc>.moveBy(x: Dp, y: Dp): ViewPoint<Gmc> {
val deltaX = x.value / tileScale
val deltaY = y.value / tileScale
val newCoordinates = GeodeticMapCoordinates(
(focus.latitude + (deltaY / scaleFactor).radians).coerceIn(
-MercatorProjection.MAXIMUM_LATITUDE,
MercatorProjection.MAXIMUM_LATITUDE
),
focus.longitude + (deltaX / scaleFactor).radians
)
return MapViewPoint(newCoordinates, zoom)
}
}
@Composable
internal fun rememberMapState(
config: ViewConfig<Gmc>,
canvasSize: DpSize,
viewPoint: ViewPoint<Gmc>,
tileSize: Int,
):MapState = remember {
MapState(config, canvasSize, viewPoint, tileSize)
}

View File

@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.Gmc
@ -15,25 +14,12 @@ import kotlin.math.log2
import kotlin.math.min
//TODO consider replacing by modifier
/**
*/
public data class MapViewConfig(
val zoomSpeed: Double = 1.0 / 3.0,
val onClick: MapViewPoint.(PointerEvent) -> Unit = {},
val dragHandle: DragHandle<Gmc> = DragHandle.bypass(),
val onViewChange: MapViewPoint.() -> Unit = {},
val onSelect: (GmcRectangle) -> Unit = {},
val zoomOnSelect: Boolean = true,
val onCanvasSizeChange: (DpSize) -> Unit = {},
)
@Composable
public expect fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint,
featuresState: FeaturesState<Gmc>,
config: MapViewConfig = MapViewConfig(),
config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
)
@ -61,7 +47,7 @@ public fun MapView(
initialViewPoint: MapViewPoint? = null,
initialRectangle: GmcRectangle? = null,
featureMap: Map<FeatureId<*>, MapFeature>,
config: MapViewConfig = MapViewConfig(),
config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
) {
val featuresState = key(featureMap) {
@ -92,7 +78,7 @@ public fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint? = null,
initialRectangle: GmcRectangle? = null,
config: MapViewConfig = MapViewConfig(),
config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: FeaturesState<Gmc>.() -> Unit = {},
) {

View File

@ -1,20 +1,20 @@
package center.sciprog.maps.compose
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.*
import center.sciprog.maps.coordinates.*
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.coordinates.radians
import center.sciprog.maps.coordinates.toFloat
import center.sciprog.maps.features.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
@ -31,17 +31,6 @@ private fun Color.toPaint(): Paint = Paint().apply {
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
internal fun MapViewPoint.move(deltaX: Double, deltaY: Double): MapViewPoint {
val newCoordinates = GeodeticMapCoordinates(
(focus.latitude + (deltaY / scaleFactor).radians).coerceIn(
-MercatorProjection.MAXIMUM_LATITUDE,
MercatorProjection.MAXIMUM_LATITUDE
),
focus.longitude + (deltaX / scaleFactor).radians
)
return MapViewPoint(newCoordinates, zoom)
}
private val logger = KotlinLogging.logger("MapView")
/**
@ -52,320 +41,208 @@ public actual fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint,
featuresState: FeaturesState<Gmc>,
config: MapViewConfig,
config: ViewConfig<Gmc>,
modifier: Modifier,
): Unit = key(initialViewPoint) {
var canvasSize by remember { mutableStateOf(defaultCanvasSize) }
var viewPoint by remember { mutableStateOf(initialViewPoint) }
require(viewPoint.zoom in 1.0..18.0) { "Zoom value of ${viewPoint.zoom} is not valid" }
fun setViewPoint(newViewPoint: MapViewPoint) {
config.onViewChange(newViewPoint)
viewPoint = newViewPoint
}
val zoom: Int by derivedStateOf {
require(viewPoint.zoom in 1.0..18.0) { "Zoom value of ${viewPoint.zoom} is not valid" }
floor(viewPoint.zoom).toInt()
}
val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
val mapTiles = remember(mapTileProvider) { 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,
val state = rememberMapState(
config,
defaultCanvasSize,
initialViewPoint,
mapTileProvider.tileSize
)
val canvasModifier = modifier.mapControls(state).fillMaxSize()
/*
* Convert screen independent offset to GMC, adjusting for fractional zoom
*/
fun DpOffset.toGeodetic() = WebMercatorProjection.toGeodetic(toMercator())
with(state) {
// Selection rectangle. If null - no selection
var selectRect by remember { mutableStateOf<Rect?>(null) }
val mapTiles = remember(mapTileProvider) { mutableStateListOf<MapTile>() }
@OptIn(ExperimentalComposeUiApi::class)
val canvasModifier = modifier.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
// Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) {
with(mapTileProvider) {
val indexRange = 0 until 2.0.pow(zoom).toInt()
val event: PointerEvent = awaitPointerEvent()
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)
event.changes.forEach { change ->
val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
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)
//start selection
var selectionStart: Offset? =
if (event.buttons.isPrimaryPressed && event.keyboardModifiers.isShiftPressed) {
change.position
} else {
null
}
mapTiles.clear()
drag(change.id) { dragChange ->
val dragAmount = dragChange.position - dragChange.previousPosition
val dpStart = dragChange.previousPosition.toDpOffset()
val dpEnd = dragChange.position.toDpOffset()
//apply drag handle and check if it prohibits the drag even propagation
if (selectionStart == null && !config.dragHandle.handle(
event,
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)
)
) {
return@drag
}
if (event.buttons.isPrimaryPressed) {
//If selection process is started, modify the frame
selectionStart?.let { start ->
val offset = dragChange.position
selectRect = Rect(
min(offset.x, start.x),
min(offset.y, start.y),
max(offset.x, start.x),
max(offset.y, start.y)
)
return@drag
for (j in verticalIndices) {
for (i in horizontalIndices) {
val id = TileId(zoom, 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" }
}
}
// config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom), event)
//If no selection, drag map
setViewPoint(
viewPoint.move(
-dragAmount.x.toDp().value / tileScale,
+dragAmount.y.toDp().value / tileScale
)
)
}
}
// evaluate selection
selectRect?.let { rect ->
//Use selection override if it is defined
val gmcBox = GmcRectangle(
rect.topLeft.toDpOffset().toGeodetic(),
rect.bottomRight.toDpOffset().toGeodetic()
}
}
}
val painterCache = key(featuresState) {
featuresState.features.values.filterIsInstance<VectorImageFeature<Gmc>>().associateWith { it.painter() }
}
Canvas(canvasModifier) {
fun GeodeticMapCoordinates.toOffset(): Offset = toOffset(this@Canvas)
fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) {
when (feature) {
is FeatureSelector -> drawFeature(zoom, feature.selector(zoom))
is CircleFeature -> drawCircle(
feature.color,
feature.size.toPx(),
center = feature.center.toOffset()
)
is RectangleFeature -> drawRect(
feature.color,
topLeft = feature.center.toOffset() - Offset(
feature.size.width.toPx() / 2,
feature.size.height.toPx() / 2
),
size = feature.size.toSize()
)
is LineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
is ArcFeature -> {
val topLeft = feature.oval.topLeft.toOffset()
val bottomRight = feature.oval.bottomRight.toOffset()
val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y))
drawArc(
color = feature.color,
startAngle = feature.startAngle.radians.degrees.toFloat(),
sweepAngle = feature.arcLength.radians.degrees.toFloat(),
useCenter = false,
topLeft = topLeft,
size = size,
style = Stroke()
)
config.onSelect(gmcBox)
if (config.zoomOnSelect) {
setViewPoint(gmcBox.computeViewPoint(mapTileProvider, canvasSize))
}
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()).toGeodetic()
setViewPoint(viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant))
}.fillMaxSize()
is BitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
// Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) {
with(mapTileProvider) {
val indexRange = 0 until 2.0.pow(zoom).toInt()
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange)
val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange)
mapTiles.clear()
for (j in verticalIndices) {
for (i in horizontalIndices) {
val id = TileId(zoom, i, j)
//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" }
is VectorImageFeature -> {
val offset = feature.position.toOffset()
val size = feature.size.toSize()
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
with(painterCache[feature]!!) {
draw(size)
}
}
}
}
is TextFeature -> 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()
)
}
}
}
}
val painterCache = key(featuresState) {
featuresState.features.values.filterIsInstance<VectorImageFeature<Gmc>>().associateWith { it.painter() }
}
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 FeatureSelector -> drawFeature(zoom, feature.selector(zoom))
is CircleFeature -> drawCircle(
feature.color,
feature.size.toPx(),
center = feature.center.toOffset()
)
is RectangleFeature -> drawRect(
feature.color,
topLeft = feature.center.toOffset() - Offset(
feature.size.width.toPx() / 2,
feature.size.height.toPx() / 2
),
size = feature.size.toSize()
)
is LineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
is ArcFeature -> {
val topLeft = feature.oval.topLeft.toOffset()
val bottomRight = feature.oval.bottomRight.toOffset()
val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y))
drawArc(
color = feature.color,
startAngle = feature.startAngle.radians.degrees.toFloat(),
sweepAngle = feature.arcLength.radians.degrees.toFloat(),
useCenter = false,
topLeft = topLeft,
size = size,
style = Stroke()
)
}
is BitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is VectorImageFeature -> {
val offset = feature.position.toOffset()
val size = feature.size.toSize()
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
with(painterCache[feature]!!) {
draw(size)
is DrawFeature -> {
val offset = feature.position.toOffset()
translate(offset.x, offset.y) {
feature.drawFeature(this)
}
}
}
is TextFeature -> 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 DrawFeature -> {
val offset = feature.position.toOffset()
translate(offset.x, offset.y) {
feature.drawFeature(this)
is FeatureGroup -> {
feature.children.values.forEach {
drawFeature(zoom, it)
}
}
}
is FeatureGroup -> {
feature.children.values.forEach {
drawFeature(zoom, it)
}
}
is PathFeature -> {
TODO("MapPathFeature not implemented")
is PathFeature -> {
TODO("MapPathFeature not implemented")
// val offset = feature.rectangle.center.toOffset() - feature.targetRect.center
// translate(offset.x, offset.y) {
// sca
// drawPath(feature.path, brush = feature.brush, style = feature.style)
// }
}
}
is PointsFeature -> {
val points = feature.points.map { it.toOffset() }
drawPoints(
points = points,
color = feature.color,
strokeWidth = feature.stroke,
pointMode = feature.pointMode
)
}
is PointsFeature -> {
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()) {
logger.debug { "Recalculate canvas. Size: $size" }
config.onCanvasSizeChange(canvasSize)
canvasSize = size.toDpSize()
}
clipRect {
val tileSize = IntSize(
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).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.toFloat()).roundToPx(),
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx()
)
drawImage(
image = image.toComposeImageBitmap(),
dstOffset = offset,
dstSize = tileSize
)
}
}
featuresState.features.values.filter { viewPoint.zoom in it.zoomRange }.forEach { feature ->
drawFeature(zoom, feature)
if (canvasSize != size.toDpSize()) {
logger.debug { "Recalculate canvas. Size: $size" }
config.onCanvasSizeChange(canvasSize)
canvasSize = size.toDpSize()
}
}
selectRect?.let { rect ->
drawRect(
color = Color.Blue,
topLeft = rect.topLeft,
size = rect.size,
alpha = 0.5f,
style = Stroke(
width = 2f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
clipRect {
val tileSize = IntSize(
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).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.toFloat()).roundToPx(),
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx()
)
drawImage(
image = image.toComposeImageBitmap(),
dstOffset = offset,
dstSize = tileSize
)
}
featuresState.features.values.filter { viewPoint.zoom in it.zoomRange }.forEach { feature ->
drawFeature(zoom, 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

@ -17,6 +17,9 @@ kotlin {
dependencies {
api(compose.foundation)
}
}
val jvmMain by getting{
}
val jvmTest by getting {
dependencies {

View File

@ -3,7 +3,7 @@ package center.sciprog.maps.features
import androidx.compose.ui.unit.DpSize
public interface Area<T: Any>{
public interface Area<T : Any> {
public operator fun contains(point: T): Boolean
}
@ -11,7 +11,7 @@ public interface Area<T: Any>{
* A map coordinates rectangle. [a] and [b] represent opposing angles
* of the rectangle without specifying which ones.
*/
public interface Rectangle<T : Any>: Area<T> {
public interface Rectangle<T : Any> : Area<T> {
public val a: T
public val b: T
}
@ -24,12 +24,24 @@ public interface CoordinateSpace<T : Any> {
/**
* Build a rectangle by two opposing corners
*/
public fun buildRectangle(first: T, second: T): Rectangle<T>
public fun Rectangle(first: T, second: T): Rectangle<T>
/**
* Build a rectangle of visual size [size]
*/
public fun buildRectangle(center: T, zoom: Double, size: DpSize): Rectangle<T>
public fun Rectangle(center: T, zoom: Double, size: DpSize): Rectangle<T>
/**
* Create a [ViewPoint] associated with this coordinate space.
*/
public fun ViewPoint(center: T, zoom: Double): ViewPoint<T>
public fun ViewPoint<T>.moveBy(delta: T): ViewPoint<T>
public fun ViewPoint<T>.zoomBy(
zoomDelta: Double,
invariant: T = focus,
): ViewPoint<T>
/**
* Move given rectangle to be centered at [center]
@ -39,4 +51,8 @@ public interface CoordinateSpace<T : Any> {
public fun Collection<Rectangle<T>>.wrapRectangles(): Rectangle<T>?
public fun Collection<T>.wrapPoints(): Rectangle<T>?
}
}
public fun <T : Any> CoordinateSpace<T>.Rectangle(viewPoint: ViewPoint<T>, size: DpSize): Rectangle<T> =
Rectangle(viewPoint.focus, viewPoint.zoom, size)

View File

@ -116,7 +116,7 @@ public data class CircleFeature<T : Any>(
override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> =
space.buildRectangle(center, zoom, DpSize(size, size))
space.Rectangle(center, zoom, DpSize(size, size))
override fun withCoordinates(newCoordinates: T): Feature<T> =
CircleFeature(space, newCoordinates, zoomRange, size, color, attributes)
@ -131,7 +131,7 @@ public class RectangleFeature<T : Any>(
override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> =
space.buildRectangle(center, zoom, size)
space.Rectangle(center, zoom, size)
override fun withCoordinates(newCoordinates: T): Feature<T> =
RectangleFeature(space, newCoordinates, zoomRange, size, color, attributes)
@ -146,7 +146,7 @@ public class LineFeature<T : Any>(
override var attributes: AttributeMap = AttributeMap(),
) : SelectableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> =
space.buildRectangle(a, b)
space.Rectangle(a, b)
override fun contains(point: ViewPoint<T>): Boolean {
return super.contains(point)
@ -181,7 +181,7 @@ public data class DrawFeature<T : Any>(
override var attributes: AttributeMap = AttributeMap(),
public val drawFeature: DrawScope.() -> Unit,
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.buildRectangle(position, position)
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.Rectangle(position, position)
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
}
@ -194,7 +194,7 @@ public data class BitmapImageFeature<T : Any>(
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.buildRectangle(position, zoom, size)
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.Rectangle(position, zoom, size)
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
}
@ -207,7 +207,7 @@ public data class VectorImageFeature<T : Any>(
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.buildRectangle(position, zoom, size)
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.Rectangle(position, zoom, size)
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
@ -238,7 +238,7 @@ public class TextFeature<T : Any>(
override var attributes: AttributeMap = AttributeMap(),
public val fontConfig: FeatureFont.() -> Unit,
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.buildRectangle(position, position)
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.Rectangle(position, position)
override fun withCoordinates(newCoordinates: T): Feature<T> =
TextFeature(space, newCoordinates, text, zoomRange, color, attributes, fontConfig)

View File

@ -0,0 +1,14 @@
package center.sciprog.maps.features
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.unit.DpSize
public data class ViewConfig<T : Any>(
val zoomSpeed: Double = 1.0 / 3.0,
val onClick: ViewPoint<T>.(PointerEvent) -> Unit = {},
val dragHandle: DragHandle<T> = DragHandle.bypass(),
val onViewChange: ViewPoint<T>.() -> Unit = {},
val onSelect: (Rectangle<T>) -> Unit = {},
val zoomOnSelect: Boolean = true,
val onCanvasSizeChange: (DpSize) -> Unit = {},
)

View File

@ -0,0 +1,140 @@
package center.sciprog.maps.features
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 kotlin.math.max
import kotlin.math.min
public abstract class CoordinateViewState<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)
public var viewPoint: ViewPoint<T>
get() = viewPointState.value
set(value) {
config.onViewChange(value)
viewPointState.value = value
}
public abstract fun DpOffset.toCoordinates(): T
public abstract fun T.toDpOffset(): DpOffset
public fun T.toOffset(density: Density): Offset = with(density){
val dpOffset = this@toOffset.toDpOffset()
Offset(dpOffset.x.toPx(), dpOffset.y.toPx())
}
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<DpRect?>(null)
}
public val DpRect.topLeft: DpOffset get() = DpOffset(left, top)
public val DpRect.bottomRight: DpOffset get() = DpOffset(right, bottom)
@OptIn(ExperimentalComposeUiApi::class)
public fun <T : Any> Modifier.mapControls(
state: CoordinateViewState<T>,
): Modifier = with(state) {
pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
val event: PointerEvent = awaitPointerEvent()
event.changes.forEach { change ->
val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
//start selection
val selectionStart: Offset? =
if (event.buttons.isPrimaryPressed && event.keyboardModifiers.isShiftPressed) {
change.position
} else {
null
}
drag(change.id) { dragChange ->
val dragAmount: Offset = dragChange.position - dragChange.previousPosition
val dpStart = dragChange.previousPosition.toDpOffset()
val dpEnd = dragChange.position.toDpOffset()
//apply drag handle and check if it prohibits the drag even propagation
if (selectionStart == null && !config.dragHandle.handle(
event,
space.ViewPoint(dpStart.toCoordinates(), viewPoint.zoom),
space.ViewPoint(dpEnd.toCoordinates(), viewPoint.zoom)
)
) {
return@drag
}
if (event.buttons.isPrimaryPressed) {
//If selection process is started, modify the frame
selectionStart?.let { start ->
val offset = dragChange.position
selectRect = DpRect(
min(offset.x, start.x).dp,
min(offset.y, start.y).dp,
max(offset.x, start.x).dp,
max(offset.y, start.y).dp
)
return@drag
}
// config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom), event)
//If no selection, drag map
viewPoint = viewPoint.moveBy(
-dragAmount.x.toDp(),
dragAmount.y.toDp()
)
}
}
// evaluate selection
selectRect?.let { rect ->
//Use selection override if it is defined
val coordinateRect = space.Rectangle(
rect.topLeft.toCoordinates(),
rect.bottomRight.toCoordinates()
)
config.onSelect(coordinateRect)
if (config.zoomOnSelect) {
viewPoint = viewPointFor(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.toDouble() * config.zoomSpeed, invariant)
}
}
}

View File

@ -1,4 +1,3 @@
import org.jetbrains.compose.compose
import space.kscience.gradle.KScienceVersions.JVM_TARGET
@ -30,6 +29,6 @@ kotlin {
}
}
java{
java {
targetCompatibility = JVM_TARGET
}