Generalize map control logic
This commit is contained in:
parent
9b8ba884e1
commit
fb13fa1431
@ -57,7 +57,7 @@ fun App() {
|
||||
// 50.kilometers,
|
||||
// 50.kilometers
|
||||
// ),
|
||||
config = MapViewConfig(
|
||||
config = ViewConfig(
|
||||
onViewChange = { centerCoordinates = focus },
|
||||
)
|
||||
) {
|
||||
|
@ -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)
|
||||
|
@ -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.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 = {},
|
||||
) {
|
||||
|
@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,9 @@ kotlin {
|
||||
dependencies {
|
||||
api(compose.foundation)
|
||||
}
|
||||
}
|
||||
val jvmMain by getting{
|
||||
|
||||
}
|
||||
val jvmTest by getting {
|
||||
dependencies {
|
||||
|
@ -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)
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
||||
@ -30,6 +29,6 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
java{
|
||||
java {
|
||||
targetCompatibility = JVM_TARGET
|
||||
}
|
Loading…
Reference in New Issue
Block a user