Generalize map control logic
This commit is contained in:
parent
9b8ba884e1
commit
fb13fa1431
@ -57,7 +57,7 @@ fun App() {
|
|||||||
// 50.kilometers,
|
// 50.kilometers,
|
||||||
// 50.kilometers
|
// 50.kilometers
|
||||||
// ),
|
// ),
|
||||||
config = MapViewConfig(
|
config = ViewConfig(
|
||||||
onViewChange = { centerCoordinates = focus },
|
onViewChange = { centerCoordinates = focus },
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
@ -4,17 +4,44 @@ import androidx.compose.ui.unit.DpSize
|
|||||||
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 kotlin.math.pow
|
||||||
|
|
||||||
public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
|
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)
|
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 {
|
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>? {
|
override fun Collection<Rectangle<Gmc>>.wrapRectangles(): Rectangle<Gmc>? {
|
||||||
@ -45,7 +72,7 @@ public fun CoordinateSpace<Gmc>.buildRectangle(
|
|||||||
center: Gmc,
|
center: Gmc,
|
||||||
height: Distance,
|
height: Distance,
|
||||||
width: Distance,
|
width: Distance,
|
||||||
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84
|
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
|
||||||
): GmcRectangle {
|
): GmcRectangle {
|
||||||
val reducedRadius = ellipsoid.reducedRadius(center.latitude)
|
val reducedRadius = ellipsoid.reducedRadius(center.latitude)
|
||||||
return buildRectangle(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians)
|
return buildRectangle(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians)
|
||||||
|
@ -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)
|
||||||
|
}
|
@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.key
|
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.input.pointer.PointerEvent
|
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import center.sciprog.maps.coordinates.Gmc
|
import center.sciprog.maps.coordinates.Gmc
|
||||||
@ -15,25 +14,12 @@ import kotlin.math.log2
|
|||||||
import kotlin.math.min
|
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
|
@Composable
|
||||||
public expect fun MapView(
|
public expect fun MapView(
|
||||||
mapTileProvider: MapTileProvider,
|
mapTileProvider: MapTileProvider,
|
||||||
initialViewPoint: MapViewPoint,
|
initialViewPoint: MapViewPoint,
|
||||||
featuresState: FeaturesState<Gmc>,
|
featuresState: FeaturesState<Gmc>,
|
||||||
config: MapViewConfig = MapViewConfig(),
|
config: ViewConfig<Gmc> = ViewConfig(),
|
||||||
modifier: Modifier = Modifier.fillMaxSize(),
|
modifier: Modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -61,7 +47,7 @@ public fun MapView(
|
|||||||
initialViewPoint: MapViewPoint? = null,
|
initialViewPoint: MapViewPoint? = null,
|
||||||
initialRectangle: GmcRectangle? = null,
|
initialRectangle: GmcRectangle? = null,
|
||||||
featureMap: Map<FeatureId<*>, MapFeature>,
|
featureMap: Map<FeatureId<*>, MapFeature>,
|
||||||
config: MapViewConfig = MapViewConfig(),
|
config: ViewConfig<Gmc> = ViewConfig(),
|
||||||
modifier: Modifier = Modifier.fillMaxSize(),
|
modifier: Modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
val featuresState = key(featureMap) {
|
val featuresState = key(featureMap) {
|
||||||
@ -92,7 +78,7 @@ public fun MapView(
|
|||||||
mapTileProvider: MapTileProvider,
|
mapTileProvider: MapTileProvider,
|
||||||
initialViewPoint: MapViewPoint? = null,
|
initialViewPoint: MapViewPoint? = null,
|
||||||
initialRectangle: GmcRectangle? = null,
|
initialRectangle: GmcRectangle? = null,
|
||||||
config: MapViewConfig = MapViewConfig(),
|
config: ViewConfig<Gmc> = ViewConfig(),
|
||||||
modifier: Modifier = Modifier.fillMaxSize(),
|
modifier: Modifier = Modifier.fillMaxSize(),
|
||||||
buildFeatures: FeaturesState<Gmc>.() -> Unit = {},
|
buildFeatures: FeaturesState<Gmc>.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
package center.sciprog.maps.compose
|
package center.sciprog.maps.compose
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
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.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
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.geometry.Rect
|
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.drawscope.*
|
import androidx.compose.ui.graphics.drawscope.*
|
||||||
import androidx.compose.ui.input.pointer.*
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.IntSize
|
||||||
import center.sciprog.maps.coordinates.*
|
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 center.sciprog.maps.features.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
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)
|
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")
|
private val logger = KotlinLogging.logger("MapView")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,320 +41,208 @@ public actual fun MapView(
|
|||||||
mapTileProvider: MapTileProvider,
|
mapTileProvider: MapTileProvider,
|
||||||
initialViewPoint: MapViewPoint,
|
initialViewPoint: MapViewPoint,
|
||||||
featuresState: FeaturesState<Gmc>,
|
featuresState: FeaturesState<Gmc>,
|
||||||
config: MapViewConfig,
|
config: ViewConfig<Gmc>,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
): Unit = key(initialViewPoint) {
|
): Unit = key(initialViewPoint) {
|
||||||
var canvasSize by remember { mutableStateOf(defaultCanvasSize) }
|
|
||||||
|
|
||||||
var viewPoint by remember { mutableStateOf(initialViewPoint) }
|
val state = rememberMapState(
|
||||||
|
config,
|
||||||
require(viewPoint.zoom in 1.0..18.0) { "Zoom value of ${viewPoint.zoom} is not valid" }
|
defaultCanvasSize,
|
||||||
|
initialViewPoint,
|
||||||
fun setViewPoint(newViewPoint: MapViewPoint) {
|
mapTileProvider.tileSize
|
||||||
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 canvasModifier = modifier.mapControls(state).fillMaxSize()
|
||||||
|
|
||||||
/*
|
with(state) {
|
||||||
* Convert screen independent offset to GMC, adjusting for fractional zoom
|
|
||||||
*/
|
|
||||||
fun DpOffset.toGeodetic() = WebMercatorProjection.toGeodetic(toMercator())
|
|
||||||
|
|
||||||
// Selection rectangle. If null - no selection
|
val mapTiles = remember(mapTileProvider) { mutableStateListOf<MapTile>() }
|
||||||
var selectRect by remember { mutableStateOf<Rect?>(null) }
|
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
// Load tiles asynchronously
|
||||||
val canvasModifier = modifier.pointerInput(Unit) {
|
LaunchedEffect(viewPoint, canvasSize) {
|
||||||
forEachGesture {
|
with(mapTileProvider) {
|
||||||
awaitPointerEventScope {
|
val indexRange = 0 until 2.0.pow(zoom).toInt()
|
||||||
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
|
|
||||||
|
|
||||||
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 top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
|
||||||
val dragStart = change.position
|
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
|
||||||
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
|
val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange)
|
||||||
|
|
||||||
//start selection
|
mapTiles.clear()
|
||||||
var selectionStart: Offset? =
|
|
||||||
if (event.buttons.isPrimaryPressed && event.keyboardModifiers.isShiftPressed) {
|
|
||||||
change.position
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
drag(change.id) { dragChange ->
|
for (j in verticalIndices) {
|
||||||
val dragAmount = dragChange.position - dragChange.previousPosition
|
for (i in horizontalIndices) {
|
||||||
val dpStart = dragChange.previousPosition.toDpOffset()
|
val id = TileId(zoom, i, j)
|
||||||
val dpEnd = dragChange.position.toDpOffset()
|
//ensure that failed tiles do not fail the application
|
||||||
|
supervisorScope {
|
||||||
//apply drag handle and check if it prohibits the drag even propagation
|
//start all
|
||||||
if (selectionStart == null && !config.dragHandle.handle(
|
val deferred = loadTileAsync(id)
|
||||||
event,
|
//wait asynchronously for it to finish
|
||||||
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
|
launch {
|
||||||
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)
|
try {
|
||||||
)
|
mapTiles += deferred.await()
|
||||||
) {
|
} catch (ex: Exception) {
|
||||||
return@drag
|
//displaying the error is maps responsibility
|
||||||
}
|
logger.error(ex) { "Failed to load tile with id=$id" }
|
||||||
|
}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(),
|
val painterCache = key(featuresState) {
|
||||||
rect.bottomRight.toDpOffset().toGeodetic()
|
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
|
is VectorImageFeature -> {
|
||||||
LaunchedEffect(viewPoint, canvasSize) {
|
val offset = feature.position.toOffset()
|
||||||
with(mapTileProvider) {
|
val size = feature.size.toSize()
|
||||||
val indexRange = 0 until 2.0.pow(zoom).toInt()
|
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
|
||||||
|
with(painterCache[feature]!!) {
|
||||||
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
|
draw(size)
|
||||||
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 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)
|
||||||
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 TextFeature -> drawIntoCanvas { canvas ->
|
is FeatureGroup -> {
|
||||||
val offset = feature.position.toOffset()
|
feature.children.values.forEach {
|
||||||
canvas.nativeCanvas.drawString(
|
drawFeature(zoom, it)
|
||||||
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 -> {
|
is PathFeature -> {
|
||||||
feature.children.values.forEach {
|
TODO("MapPathFeature not implemented")
|
||||||
drawFeature(zoom, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is PathFeature -> {
|
|
||||||
TODO("MapPathFeature not implemented")
|
|
||||||
// val offset = feature.rectangle.center.toOffset() - feature.targetRect.center
|
// val offset = feature.rectangle.center.toOffset() - feature.targetRect.center
|
||||||
// translate(offset.x, offset.y) {
|
// translate(offset.x, offset.y) {
|
||||||
// sca
|
// sca
|
||||||
// drawPath(feature.path, brush = feature.brush, style = feature.style)
|
// drawPath(feature.path, brush = feature.brush, style = feature.style)
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
is PointsFeature -> {
|
is PointsFeature -> {
|
||||||
val points = feature.points.map { it.toOffset() }
|
val points = feature.points.map { it.toOffset() }
|
||||||
drawPoints(
|
drawPoints(
|
||||||
points = points,
|
points = points,
|
||||||
color = feature.color,
|
color = feature.color,
|
||||||
strokeWidth = feature.stroke,
|
strokeWidth = feature.stroke,
|
||||||
pointMode = feature.pointMode
|
pointMode = feature.pointMode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// else -> {
|
// else -> {
|
||||||
// logger.error { "Unrecognized feature type: ${feature::class}" }
|
// 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 ->
|
if (canvasSize != size.toDpSize()) {
|
||||||
drawFeature(zoom, feature)
|
logger.debug { "Recalculate canvas. Size: $size" }
|
||||||
|
config.onCanvasSizeChange(canvasSize)
|
||||||
|
canvasSize = size.toDpSize()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
selectRect?.let { rect ->
|
clipRect {
|
||||||
drawRect(
|
val tileSize = IntSize(
|
||||||
color = Color.Blue,
|
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
|
||||||
topLeft = rect.topLeft,
|
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt()
|
||||||
size = rect.size,
|
|
||||||
alpha = 0.5f,
|
|
||||||
style = Stroke(
|
|
||||||
width = 2f,
|
|
||||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
|
||||||
)
|
)
|
||||||
)
|
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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,9 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
api(compose.foundation)
|
api(compose.foundation)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
val jvmMain by getting{
|
||||||
|
|
||||||
}
|
}
|
||||||
val jvmTest by getting {
|
val jvmTest by getting {
|
||||||
dependencies {
|
dependencies {
|
||||||
|
@ -3,7 +3,7 @@ package center.sciprog.maps.features
|
|||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
|
||||||
|
|
||||||
public interface Area<T: Any>{
|
public interface Area<T : Any> {
|
||||||
public operator fun contains(point: T): Boolean
|
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
|
* A map coordinates rectangle. [a] and [b] represent opposing angles
|
||||||
* of the rectangle without specifying which ones.
|
* 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 a: T
|
||||||
public val b: T
|
public val b: T
|
||||||
}
|
}
|
||||||
@ -24,12 +24,24 @@ public interface CoordinateSpace<T : Any> {
|
|||||||
/**
|
/**
|
||||||
* Build a rectangle by two opposing corners
|
* 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]
|
* 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]
|
* 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<Rectangle<T>>.wrapRectangles(): Rectangle<T>?
|
||||||
|
|
||||||
public fun Collection<T>.wrapPoints(): 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)
|
@ -116,7 +116,7 @@ public data class CircleFeature<T : Any>(
|
|||||||
override var attributes: AttributeMap = AttributeMap(),
|
override var attributes: AttributeMap = AttributeMap(),
|
||||||
) : DraggableFeature<T> {
|
) : DraggableFeature<T> {
|
||||||
override fun getBoundingBox(zoom: Double): Rectangle<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> =
|
override fun withCoordinates(newCoordinates: T): Feature<T> =
|
||||||
CircleFeature(space, newCoordinates, zoomRange, size, color, attributes)
|
CircleFeature(space, newCoordinates, zoomRange, size, color, attributes)
|
||||||
@ -131,7 +131,7 @@ public class RectangleFeature<T : Any>(
|
|||||||
override var attributes: AttributeMap = AttributeMap(),
|
override var attributes: AttributeMap = AttributeMap(),
|
||||||
) : DraggableFeature<T> {
|
) : DraggableFeature<T> {
|
||||||
override fun getBoundingBox(zoom: Double): Rectangle<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> =
|
override fun withCoordinates(newCoordinates: T): Feature<T> =
|
||||||
RectangleFeature(space, newCoordinates, zoomRange, size, color, attributes)
|
RectangleFeature(space, newCoordinates, zoomRange, size, color, attributes)
|
||||||
@ -146,7 +146,7 @@ public class LineFeature<T : Any>(
|
|||||||
override var attributes: AttributeMap = AttributeMap(),
|
override var attributes: AttributeMap = AttributeMap(),
|
||||||
) : SelectableFeature<T> {
|
) : SelectableFeature<T> {
|
||||||
override fun getBoundingBox(zoom: Double): Rectangle<T> =
|
override fun getBoundingBox(zoom: Double): Rectangle<T> =
|
||||||
space.buildRectangle(a, b)
|
space.Rectangle(a, b)
|
||||||
|
|
||||||
override fun contains(point: ViewPoint<T>): Boolean {
|
override fun contains(point: ViewPoint<T>): Boolean {
|
||||||
return super.contains(point)
|
return super.contains(point)
|
||||||
@ -181,7 +181,7 @@ public data class DrawFeature<T : Any>(
|
|||||||
override var attributes: AttributeMap = AttributeMap(),
|
override var attributes: AttributeMap = AttributeMap(),
|
||||||
public val drawFeature: DrawScope.() -> Unit,
|
public val drawFeature: DrawScope.() -> Unit,
|
||||||
) : DraggableFeature<T> {
|
) : 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)
|
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 val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
|
||||||
override var attributes: AttributeMap = AttributeMap(),
|
override var attributes: AttributeMap = AttributeMap(),
|
||||||
) : DraggableFeature<T> {
|
) : 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)
|
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 val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
|
||||||
override var attributes: AttributeMap = AttributeMap(),
|
override var attributes: AttributeMap = AttributeMap(),
|
||||||
) : DraggableFeature<T> {
|
) : 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)
|
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
|
||||||
|
|
||||||
@ -238,7 +238,7 @@ public class TextFeature<T : Any>(
|
|||||||
override var attributes: AttributeMap = AttributeMap(),
|
override var attributes: AttributeMap = AttributeMap(),
|
||||||
public val fontConfig: FeatureFont.() -> Unit,
|
public val fontConfig: FeatureFont.() -> Unit,
|
||||||
) : DraggableFeature<T> {
|
) : 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> =
|
override fun withCoordinates(newCoordinates: T): Feature<T> =
|
||||||
TextFeature(space, newCoordinates, text, zoomRange, color, attributes, fontConfig)
|
TextFeature(space, newCoordinates, text, zoomRange, color, attributes, fontConfig)
|
||||||
|
@ -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 = {},
|
||||||
|
)
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,3 @@
|
|||||||
import org.jetbrains.compose.compose
|
|
||||||
import space.kscience.gradle.KScienceVersions.JVM_TARGET
|
import space.kscience.gradle.KScienceVersions.JVM_TARGET
|
||||||
|
|
||||||
|
|
||||||
@ -30,6 +29,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
java{
|
java {
|
||||||
targetCompatibility = JVM_TARGET
|
targetCompatibility = JVM_TARGET
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user