Generalize feature draw logic

This commit is contained in:
Alexander Nozik 2022-12-25 11:07:45 +03:00
parent fb13fa1431
commit 5b95adc649
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
8 changed files with 202 additions and 168 deletions

View File

@ -2,10 +2,7 @@ package center.sciprog.maps.compose
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.*
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.coordinates.*
import center.sciprog.maps.features.* import center.sciprog.maps.features.*
import kotlin.math.* import kotlin.math.*
@ -18,9 +15,6 @@ internal class MapState internal constructor(
) : CoordinateViewState<Gmc>(config, canvasSize, viewPoint) { ) : CoordinateViewState<Gmc>(config, canvasSize, viewPoint) {
override val space: CoordinateSpace<Gmc> get() = GmcCoordinateSpace override val space: CoordinateSpace<Gmc> get() = GmcCoordinateSpace
public val zoom: Int
get() = floor(viewPoint.zoom).toInt()
public val scaleFactor: Double public val scaleFactor: Double
get() = WebMercatorProjection.scaleFactor(viewPoint.zoom) get() = WebMercatorProjection.scaleFactor(viewPoint.zoom)
@ -31,7 +25,7 @@ internal class MapState internal constructor(
get() = 2.0.pow(viewPoint.zoom - zoom) get() = 2.0.pow(viewPoint.zoom - zoom)
private fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates( private fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
zoom, floor(zoom).toInt(),
(x - canvasSize.width / 2).value / tileScale + centerCoordinates.x, (x - canvasSize.width / 2).value / tileScale + centerCoordinates.x,
(y - canvasSize.height / 2).value / tileScale + centerCoordinates.y, (y - canvasSize.height / 2).value / tileScale + centerCoordinates.y,
) )
@ -50,6 +44,12 @@ internal class MapState internal constructor(
override fun Gmc.toDpOffset(): DpOffset = override fun Gmc.toDpOffset(): DpOffset =
WebMercatorProjection.toMercator(this, zoom).toOffset() WebMercatorProjection.toMercator(this, zoom).toOffset()
override fun Rectangle<Gmc>.toDpRect(): DpRect {
val topLeft = topLeft.toDpOffset()
val bottomRight = bottomRight.toDpOffset()
return DpRect(topLeft.x, topLeft.y, bottomRight.x, bottomRight.y)
}
override fun viewPointFor(rectangle: Rectangle<Gmc>): ViewPoint<Gmc> { override fun viewPointFor(rectangle: Rectangle<Gmc>): ViewPoint<Gmc> {
val zoom = log2( val zoom = log2(
min( min(
@ -80,6 +80,6 @@ internal fun rememberMapState(
canvasSize: DpSize, canvasSize: DpSize,
viewPoint: ViewPoint<Gmc>, viewPoint: ViewPoint<Gmc>,
tileSize: Int, tileSize: Int,
):MapState = remember { ): MapState = remember {
MapState(config, canvasSize, viewPoint, tileSize) MapState(config, canvasSize, viewPoint, tileSize)
} }

View File

@ -4,22 +4,21 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.* import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.graphics.vector.VectorPainter
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.Gmc 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
import mu.KotlinLogging import mu.KotlinLogging
import org.jetbrains.skia.Font
import org.jetbrains.skia.Paint import org.jetbrains.skia.Paint
import kotlin.math.* import kotlin.math.*
@ -51,8 +50,6 @@ public actual fun MapView(
initialViewPoint, initialViewPoint,
mapTileProvider.tileSize mapTileProvider.tileSize
) )
val canvasModifier = modifier.mapControls(state).fillMaxSize()
with(state) { with(state) {
val mapTiles = remember(mapTileProvider) { mutableStateListOf<MapTile>() } val mapTiles = remember(mapTileProvider) { mutableStateListOf<MapTile>() }
@ -74,7 +71,7 @@ public actual fun MapView(
for (j in verticalIndices) { for (j in verticalIndices) {
for (i in horizontalIndices) { for (i in horizontalIndices) {
val id = TileId(zoom, i, j) val id = TileId(floor(zoom).toInt(), i, j)
//ensure that failed tiles do not fail the application //ensure that failed tiles do not fail the application
supervisorScope { supervisorScope {
//start all //start all
@ -96,110 +93,11 @@ public actual fun MapView(
} }
} }
val painterCache = key(featuresState) { val painterCache: Map<VectorImageFeature<Gmc>, VectorPainter> = key(featuresState) {
featuresState.features.values.filterIsInstance<VectorImageFeature<Gmc>>().associateWith { it.painter() } featuresState.features.values.filterIsInstance<VectorImageFeature<Gmc>>().associateWith { it.painter() }
} }
Canvas(canvasModifier) { Canvas(modifier = modifier.mapControls(state).fillMaxSize()) {
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()
)
}
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 ->
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 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
)
}
// else -> {
// logger.error { "Unrecognized feature type: ${feature::class}" }
// }
}
}
if (canvasSize != size.toDpSize()) { if (canvasSize != size.toDpSize()) {
logger.debug { "Recalculate canvas. Size: $size" } logger.debug { "Recalculate canvas. Size: $size" }
@ -226,7 +124,7 @@ public actual fun MapView(
} }
featuresState.features.values.filter { viewPoint.zoom in it.zoomRange }.forEach { feature -> featuresState.features.values.filter { viewPoint.zoom in it.zoomRange }.forEach { feature ->
drawFeature(zoom, feature) drawFeature(state, painterCache, feature)
} }
} }

View File

@ -26,12 +26,12 @@ public object WebMercatorProjection {
/** /**
* https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas * https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas
*/ */
public fun toMercator(gmc: GeodeticMapCoordinates, zoom: Int): WebMercatorCoordinates { public fun toMercator(gmc: GeodeticMapCoordinates, zoom: Double): WebMercatorCoordinates {
require(abs(gmc.latitude) <= MercatorProjection.MAXIMUM_LATITUDE) { "Latitude exceeds the maximum latitude for mercator coordinates" } require(abs(gmc.latitude) <= MercatorProjection.MAXIMUM_LATITUDE) { "Latitude exceeds the maximum latitude for mercator coordinates" }
val scaleFactor = scaleFactor(zoom.toDouble()) val scaleFactor = scaleFactor(zoom)
return WebMercatorCoordinates( return WebMercatorCoordinates(
zoom = zoom, zoom = floor(zoom).toInt(),
x = scaleFactor * (gmc.longitude.radians.value + PI), x = scaleFactor * (gmc.longitude.radians.value + PI),
y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude.radians.value / 2))) y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude.radians.value / 2)))
) )

View File

@ -0,0 +1,52 @@
package center.sciprog.maps.features
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.*
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 val zoom: Double get() = viewPoint.zoom
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 Rectangle<T>.toDpRect(): DpRect
public abstract fun ViewPoint<T>.moveBy(x: Dp, y: Dp): ViewPoint<T>
public abstract fun viewPointFor(rectangle: Rectangle<T>): ViewPoint<T>
// Selection rectangle. If null - no selection
public var selectRect: DpRect? by mutableStateOf(null)
}
public val DpRect.topLeft: DpOffset get() = DpOffset(left, top)
public val DpRect.bottomRight: DpOffset get() = DpOffset(right, bottom)

View File

@ -13,7 +13,6 @@ import androidx.compose.ui.unit.Dp
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.features.Feature.Companion.defaultZoomRange import center.sciprog.maps.features.Feature.Companion.defaultZoomRange
import kotlin.math.floor
public typealias DoubleRange = ClosedFloatingPointRange<Double> public typealias DoubleRange = ClosedFloatingPointRange<Double>
@ -60,11 +59,11 @@ public fun <T : Any> Iterable<Feature<T>>.computeBoundingBox(
public class FeatureSelector<T : Any>( public class FeatureSelector<T : Any>(
override val space: CoordinateSpace<T>, override val space: CoordinateSpace<T>,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
public val selector: (zoom: Int) -> Feature<T>, public val selector: (zoom: Double) -> Feature<T>,
) : Feature<T> { ) : Feature<T> {
override val zoomRange: ClosedFloatingPointRange<Double> get() = defaultZoomRange override val zoomRange: ClosedFloatingPointRange<Double> get() = defaultZoomRange
override fun getBoundingBox(zoom: Double): Rectangle<T>? = selector(floor(zoom).toInt()).getBoundingBox(zoom) override fun getBoundingBox(zoom: Double): Rectangle<T>? = selector(zoom).getBoundingBox(zoom)
} }
public class PathFeature<T : Any>( public class PathFeature<T : Any>(

View File

@ -0,0 +1,124 @@
package center.sciprog.maps.features
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.VectorPainter
import org.jetbrains.skia.Font
import org.jetbrains.skia.Paint
import kotlin.math.PI
internal fun Color.toPaint(): Paint = Paint().apply {
isAntiAlias = true
color = toArgb()
}
public fun <T : Any> DrawScope.drawFeature(
state: CoordinateViewState<T>,
painterCache: Map<VectorImageFeature<T>, VectorPainter>,
feature: Feature<T>,
): Unit = with(state) {
fun T.toOffset(): Offset = toOffset(this@drawFeature)
when (feature) {
is FeatureSelector -> drawFeature(state, painterCache, feature.selector(state.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 dpRect = feature.oval.toDpRect().toRect()
val size = Size(dpRect.width, dpRect.height)
drawArc(
color = feature.color,
startAngle = feature.startAngle / PI.toFloat() * 180f,
sweepAngle = feature.arcLength / PI.toFloat() * 180f,
useCenter = false,
topLeft = dpRect.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 ->
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(state, painterCache, it)
}
}
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
)
}
else -> {
//logger.error { "Unrecognized feature type: ${feature::class}" }
}
}
}

View File

@ -12,45 +12,6 @@ import kotlin.math.max
import kotlin.math.min 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) @OptIn(ExperimentalComposeUiApi::class)
public fun <T : Any> Modifier.mapControls( public fun <T : Any> Modifier.mapControls(
state: CoordinateViewState<T>, state: CoordinateViewState<T>,
@ -64,7 +25,7 @@ public fun <T : Any> Modifier.mapControls(
event.changes.forEach { change -> event.changes.forEach { change ->
val dragStart = change.position val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp()) //val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
//start selection //start selection
val selectionStart: Offset? = val selectionStart: Offset? =