Full refactor of map state
This commit is contained in:
parent
921aff4685
commit
75b5a69a27
@ -10,7 +10,7 @@ val kmathVersion: String by extra("0.3.1")
|
||||
|
||||
allprojects {
|
||||
group = "center.sciprog"
|
||||
version = "0.2.3-dev-1"
|
||||
version = "0.3.0-dev-1"
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
|
@ -17,7 +17,7 @@ kotlin {
|
||||
implementation(projects.mapsKtGeojson)
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("io.ktor:ktor-client-cio")
|
||||
implementation("ch.qos.logback:logback-classic:1.2.11")
|
||||
implementation(spclibs.logback.classic)
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
|
@ -15,7 +15,9 @@ import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import center.sciprog.attributes.Attributes
|
||||
import center.sciprog.maps.compose.*
|
||||
import center.sciprog.maps.coordinates.*
|
||||
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
|
||||
import center.sciprog.maps.coordinates.Gmc
|
||||
import center.sciprog.maps.coordinates.kilometers
|
||||
import center.sciprog.maps.features.*
|
||||
import center.sciprog.maps.geojson.geoJson
|
||||
import io.ktor.client.HttpClient
|
||||
@ -109,8 +111,11 @@ fun App() {
|
||||
)
|
||||
).pointSize(5f)
|
||||
|
||||
// geodeticLine(Gmc.ofDegrees(40.7128, -74.0060), Gmc.ofDegrees(55.742465, 37.615812)).color(Color.Blue)
|
||||
// line(Gmc.ofDegrees(40.7128, -74.0060), Gmc.ofDegrees(55.742465, 37.615812))
|
||||
|
||||
//remember feature ID
|
||||
|
||||
//remember feature ref
|
||||
val circleId = circle(
|
||||
centerCoordinates = pointTwo,
|
||||
)
|
||||
@ -170,6 +175,7 @@ fun App() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// println(toPrettyString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import center.sciprog.maps.features.*
|
||||
import center.sciprog.maps.scheme.SchemeView
|
||||
import center.sciprog.maps.scheme.XY
|
||||
import center.sciprog.maps.scheme.XYCoordinateSpace
|
||||
import center.sciprog.maps.scheme.XYViewScope
|
||||
import center.sciprog.maps.scheme.XYCanvasState
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
@ -31,7 +31,7 @@ fun App() {
|
||||
)
|
||||
}
|
||||
|
||||
val mapState: XYViewScope = XYViewScope.remember(
|
||||
val mapState: XYCanvasState = XYCanvasState.remember(
|
||||
config = ViewConfig<XY>(
|
||||
onClick = { event, point ->
|
||||
if (event.buttons.isSecondaryPressed) {
|
||||
|
@ -26,6 +26,7 @@ compose{
|
||||
desktop {
|
||||
application {
|
||||
mainClass = "MainKt"
|
||||
//mainClass = "Joker2023Kt"
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "scheme-compose-demo"
|
||||
|
@ -78,7 +78,7 @@ fun App() {
|
||||
)
|
||||
}
|
||||
) {
|
||||
val mapState: XYViewScope = XYViewScope.remember(
|
||||
val mapState: XYCanvasState = XYCanvasState.remember(
|
||||
ViewConfig(
|
||||
onClick = { _, click ->
|
||||
println("${click.focus.x}, ${click.focus.y}")
|
||||
|
76
demo/scheme/src/jvmMain/kotlin/joker2023.kt
Normal file
76
demo/scheme/src/jvmMain/kotlin/joker2023.kt
Normal file
@ -0,0 +1,76 @@
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Face
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import center.sciprog.maps.features.*
|
||||
import center.sciprog.maps.scheme.*
|
||||
import center.sciprog.maps.scheme.XYCoordinateSpace.Rectangle
|
||||
|
||||
|
||||
fun main() = application {
|
||||
Window(onCloseRequest = ::exitApplication, title = "Joker2023 demo", icon = painterResource("SPC-logo.png")) {
|
||||
MaterialTheme {
|
||||
|
||||
SchemeView(
|
||||
initialRectangle = Rectangle(XY(0f, 0f), XY(1734f, 724f)),
|
||||
config = ViewConfig(
|
||||
onClick = { _, pointer ->
|
||||
println("(${pointer.focus.x}, ${pointer.focus.y})")
|
||||
}
|
||||
)
|
||||
) {
|
||||
background(1734f, 724f, id = "background") { painterResource("joker2023.png") }
|
||||
group(id = "hall_1") {
|
||||
polygon(
|
||||
listOf(
|
||||
XY(1582.0042, 210.29636),
|
||||
XY(1433.7021, 127.79796),
|
||||
XY(1370.7639, 127.79796),
|
||||
XY(1315.293, 222.73865),
|
||||
XY(1314.2262, 476.625),
|
||||
XY(1364.3635, 570.4984),
|
||||
XY(1434.7689, 570.4984),
|
||||
XY(1579.8469, 493.69244),
|
||||
)
|
||||
).modifyAttributes {
|
||||
ColorAttribute(Color.Blue)
|
||||
AlphaAttribute(0.4f)
|
||||
}.onClick {
|
||||
println("hall_1")
|
||||
}
|
||||
}
|
||||
|
||||
group(id = "hall_2") {
|
||||
rectanglePolygon(
|
||||
left = 893, right = 1103,
|
||||
bottom = 223, top = 406,
|
||||
).modifyAttributes {
|
||||
ColorAttribute(Color.Blue)
|
||||
AlphaAttribute(0.4f)
|
||||
}
|
||||
}
|
||||
|
||||
group(id = "hall_3") {
|
||||
rectanglePolygon(
|
||||
Rectangle(XY(460f, 374f), width = 140f, height = 122f),
|
||||
).modifyAttributes {
|
||||
ColorAttribute(Color.Blue)
|
||||
AlphaAttribute(0.4f)
|
||||
}
|
||||
}
|
||||
|
||||
group(id = "people") {
|
||||
icon(XY(815.60535, 342.71313), Icons.Default.Face).color(Color.Red)
|
||||
icon(XY(743.751, 381.09064), Icons.Default.Face).color(Color.Red)
|
||||
icon(XY(1349.6648, 417.36014), Icons.Default.Face).color(Color.Red)
|
||||
icon(XY (1362.4658, 287.21667), Icons.Default.Face).color(Color.Red)
|
||||
icon(XY(208.24274, 317.08566), Icons.Default.Face).color(Color.Red)
|
||||
icon(XY (293.5827, 319.21915), Icons.Default.Face).color(Color.Red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
BIN
demo/scheme/src/jvmMain/resources/SPC-logo.png
Normal file
BIN
demo/scheme/src/jvmMain/resources/SPC-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.0 KiB |
@ -1,10 +1,10 @@
|
||||
kotlin.code.style=official
|
||||
|
||||
compose.version=1.4.0
|
||||
agp.version=7.4.2
|
||||
android.useAndroidX=true
|
||||
compose.version=1.5.1
|
||||
#agp.version=7.4.2
|
||||
#android.useAndroidX=true
|
||||
org.jetbrains.compose.experimental.jscanvas.enabled=true
|
||||
|
||||
org.gradle.jvmargs=-Xmx4096m
|
||||
|
||||
toolsVersion=0.14.6-kotlin-1.8.20
|
||||
toolsVersion=0.14.9-kotlin-1.9.0
|
@ -17,7 +17,6 @@ kotlin {
|
||||
api(compose.foundation)
|
||||
api(project.dependencies.platform(spclibs.ktor.bom))
|
||||
api("io.ktor:ktor-client-core")
|
||||
api("io.github.microutils:kotlin-logging:2.1.23")
|
||||
}
|
||||
}
|
||||
val jvmTest by getting {
|
||||
|
@ -14,10 +14,11 @@ import center.sciprog.maps.features.*
|
||||
import space.kscience.kmath.geometry.radians
|
||||
import kotlin.math.*
|
||||
|
||||
public class MapViewScope internal constructor(
|
||||
|
||||
public class MapCanvasState private constructor(
|
||||
public val mapTileProvider: MapTileProvider,
|
||||
config: ViewConfig<Gmc>,
|
||||
) : CoordinateViewScope<Gmc>(config) {
|
||||
) : CanvasState<Gmc>(config) {
|
||||
override val space: CoordinateSpace<Gmc> get() = WebMercatorSpace
|
||||
|
||||
private val scaleFactor: Float
|
||||
@ -87,12 +88,12 @@ public class MapViewScope internal constructor(
|
||||
config: ViewConfig<Gmc> = ViewConfig(),
|
||||
initialViewPoint: ViewPoint<Gmc>? = null,
|
||||
initialRectangle: Rectangle<Gmc>? = null,
|
||||
): MapViewScope = remember {
|
||||
MapViewScope(mapTileProvider, config).also { mapState ->
|
||||
): MapCanvasState = remember {
|
||||
MapCanvasState(mapTileProvider, config).apply {
|
||||
if (initialViewPoint != null) {
|
||||
mapState.viewPoint = initialViewPoint
|
||||
viewPoint = initialViewPoint
|
||||
} else if (initialRectangle != null) {
|
||||
mapState.viewPoint = mapState.computeViewPoint(initialRectangle)
|
||||
viewPoint = computeViewPoint(initialRectangle)
|
||||
}
|
||||
}
|
||||
}
|
@ -2,44 +2,137 @@ package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.maps.coordinates.Gmc
|
||||
import center.sciprog.maps.features.*
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import org.jetbrains.skia.Image
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.pow
|
||||
|
||||
|
||||
@Composable
|
||||
public expect fun MapView(
|
||||
viewScope: MapViewScope,
|
||||
features: FeatureGroup<Gmc>,
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
private fun IntRange.intersect(other: IntRange) = kotlin.math.max(first, other.first)..kotlin.math.min(last, other.last)
|
||||
|
||||
private val logger = KotlinLogging.logger("MapView")
|
||||
|
||||
/**
|
||||
* A builder for a Map with static features.
|
||||
* A component that renders map and provides basic map manipulation capabilities
|
||||
*/
|
||||
@Composable
|
||||
public fun MapView(
|
||||
mapState: MapCanvasState,
|
||||
mapTileProvider: MapTileProvider,
|
||||
features: FeatureGroup<Gmc>,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val mapTiles = remember(mapTileProvider) {
|
||||
mutableStateMapOf<TileId, Image>()
|
||||
}
|
||||
|
||||
with(mapState) {
|
||||
|
||||
// Load tiles asynchronously
|
||||
LaunchedEffect(viewPoint, canvasSize) {
|
||||
with(mapTileProvider) {
|
||||
val indexRange = 0 until 2.0.pow(intZoom).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)
|
||||
|
||||
for (j in verticalIndices) {
|
||||
for (i in horizontalIndices) {
|
||||
val id = TileId(intZoom, 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 {
|
||||
val tile = deferred.await()
|
||||
mapTiles[tile.id] = tile.image
|
||||
} catch (ex: Exception) {
|
||||
//displaying the error is maps responsibility
|
||||
if(ex !is CancellationException) {
|
||||
logger.error(ex) { "Failed to load tile with id=$id" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mapTiles.keys.filter {
|
||||
it.zoom != intZoom || it.j !in verticalIndices || it.i !in horizontalIndices
|
||||
}.forEach {
|
||||
mapTiles.remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
FeatureCanvas(mapState, features, modifier = modifier.canvasControls(mapState, features)) {
|
||||
val tileScale = mapState.tileScale
|
||||
|
||||
clipRect {
|
||||
val tileSize = IntSize(
|
||||
ceil((mapTileProvider.tileSize.dp * tileScale).toPx()).toInt(),
|
||||
ceil((mapTileProvider.tileSize.dp * tileScale).toPx()).toInt()
|
||||
)
|
||||
mapTiles.forEach { (id, image) ->
|
||||
//converting back from tile index to screen offset
|
||||
val offset = IntOffset(
|
||||
(mapState.canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - mapState.centerCoordinates.x.dp) * tileScale).roundToPx(),
|
||||
(mapState.canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - mapState.centerCoordinates.y.dp) * tileScale).roundToPx()
|
||||
)
|
||||
drawImage(
|
||||
image = image.toComposeImageBitmap(),
|
||||
dstOffset = offset,
|
||||
dstSize = tileSize
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a [MapView] with given [features] group.
|
||||
*/
|
||||
@Composable
|
||||
public fun MapView(
|
||||
mapTileProvider: MapTileProvider,
|
||||
config: ViewConfig<Gmc>,
|
||||
features: FeatureGroup<Gmc>,
|
||||
initialViewPoint: ViewPoint<Gmc>? = null,
|
||||
initialRectangle: Rectangle<Gmc>? = null,
|
||||
config: ViewConfig<Gmc> = ViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
modifier: Modifier,
|
||||
) {
|
||||
|
||||
val mapState: MapViewScope = MapViewScope.remember(
|
||||
mapTileProvider,
|
||||
config,
|
||||
initialViewPoint = initialViewPoint,
|
||||
initialRectangle = initialRectangle ?: features.getBoundingBox(Float.MAX_VALUE),
|
||||
)
|
||||
|
||||
MapView(mapState, features, modifier)
|
||||
val mapState = MapCanvasState.remember(mapTileProvider, config, initialViewPoint, initialRectangle)
|
||||
MapView(mapState, mapTileProvider, features, modifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a map using convenient parameters. If neither [initialViewPoint], noe [initialRectangle] is defined,
|
||||
* use map features to infer view region.
|
||||
* use map features to infer the view region.
|
||||
* @param initialViewPoint The view point of the map using center and zoom. Is used if provided
|
||||
* @param initialRectangle The rectangle to be used for view point computation. Used if [initialViewPoint] is not defined.
|
||||
* @param buildFeatures - a builder for features
|
||||
@ -47,24 +140,12 @@ public fun MapView(
|
||||
@Composable
|
||||
public fun MapView(
|
||||
mapTileProvider: MapTileProvider,
|
||||
config: ViewConfig<Gmc> = ViewConfig(),
|
||||
initialViewPoint: ViewPoint<Gmc>? = null,
|
||||
initialRectangle: Rectangle<Gmc>? = null,
|
||||
config: ViewConfig<Gmc> = ViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
buildFeatures: FeatureGroup<Gmc>.() -> Unit = {},
|
||||
) {
|
||||
|
||||
val featureState = FeatureGroup.remember(WebMercatorSpace, buildFeatures)
|
||||
|
||||
val mapState: MapViewScope = MapViewScope.remember(
|
||||
mapTileProvider,
|
||||
config,
|
||||
initialViewPoint = initialViewPoint,
|
||||
initialRectangle = initialRectangle ?: featureState.features.computeBoundingBox(
|
||||
WebMercatorSpace,
|
||||
Float.MAX_VALUE
|
||||
),
|
||||
)
|
||||
|
||||
MapView(mapState, featureState, modifier)
|
||||
MapView(mapTileProvider, config, featureState, initialViewPoint, initialRectangle, modifier)
|
||||
}
|
@ -6,10 +6,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.maps.coordinates.Distance
|
||||
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
|
||||
import center.sciprog.maps.coordinates.Gmc
|
||||
import center.sciprog.maps.coordinates.GmcCurve
|
||||
import center.sciprog.maps.coordinates.*
|
||||
import center.sciprog.maps.features.*
|
||||
import space.kscience.kmath.geometry.Angle
|
||||
import kotlin.math.ceil
|
||||
@ -55,6 +52,39 @@ public fun FeatureGroup<Gmc>.line(
|
||||
LineFeature(space, curve.forward.coordinates, curve.backward.coordinates)
|
||||
)
|
||||
|
||||
/**
|
||||
* A segmented geodetic curve
|
||||
*/
|
||||
public fun FeatureGroup<Gmc>.geodeticLine(
|
||||
curve: GmcCurve,
|
||||
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
|
||||
maxLineDistance: Distance = 100.kilometers,
|
||||
id: String? = null,
|
||||
): FeatureRef<Gmc, Feature<Gmc>> = if (curve.distance < maxLineDistance) {
|
||||
feature(
|
||||
id,
|
||||
LineFeature(space, curve.forward.coordinates, curve.backward.coordinates)
|
||||
)
|
||||
} else {
|
||||
val segments = ceil(curve.distance / maxLineDistance).toInt()
|
||||
val segmentSize = curve.distance / segments
|
||||
val points = buildList<GmcPose> {
|
||||
add(curve.forward)
|
||||
repeat(segments) {
|
||||
val segment = ellipsoid.curveInDirection(this.last(), segmentSize, 1e-2)
|
||||
add(segment.backward)
|
||||
}
|
||||
}
|
||||
multiLine(points.map { it.coordinates }, id = id)
|
||||
}
|
||||
|
||||
public fun FeatureGroup<Gmc>.geodeticLine(
|
||||
from: Gmc,
|
||||
to: Gmc,
|
||||
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
|
||||
maxLineDistance: Distance = 100.kilometers,
|
||||
id: String? = null,
|
||||
): FeatureRef<Gmc, Feature<Gmc>> = geodeticLine(ellipsoid.curveBetween(from, to), ellipsoid, maxLineDistance, id)
|
||||
|
||||
public fun FeatureGroup<Gmc>.line(
|
||||
aCoordinates: Pair<Double, Double>,
|
||||
@ -65,7 +95,6 @@ public fun FeatureGroup<Gmc>.line(
|
||||
LineFeature(space, coordinatesOf(aCoordinates), coordinatesOf(bCoordinates))
|
||||
)
|
||||
|
||||
|
||||
public fun FeatureGroup<Gmc>.arc(
|
||||
center: Pair<Double, Double>,
|
||||
radius: Distance,
|
||||
|
@ -1,15 +1,12 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.readBytes
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.skia.Image
|
||||
import java.net.URL
|
||||
import java.nio.file.Path
|
||||
@ -76,7 +73,9 @@ public class OpenStreetMapTileProvider(
|
||||
//collect the result asynchronously
|
||||
return async {
|
||||
val image: Image = runCatching { imageDeferred.await() }.onFailure {
|
||||
logger.error(it) { "Failed to load tile image with id=$tileId" }
|
||||
if(it !is CancellationException) {
|
||||
logger.error(it) { "Failed to load tile image with id=$tileId" }
|
||||
}
|
||||
cache.remove(tileId)
|
||||
}.getOrThrow()
|
||||
|
||||
|
@ -1,146 +0,0 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.attributes.z
|
||||
import center.sciprog.maps.coordinates.Gmc
|
||||
import center.sciprog.maps.features.FeatureGroup
|
||||
import center.sciprog.maps.features.PainterFeature
|
||||
import center.sciprog.maps.features.drawFeature
|
||||
import center.sciprog.maps.features.zoomRange
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.skia.Image
|
||||
import org.jetbrains.skia.Paint
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
|
||||
private fun Color.toPaint(): Paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = toArgb()
|
||||
}
|
||||
|
||||
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
|
||||
|
||||
private val logger = KotlinLogging.logger("MapView")
|
||||
|
||||
/**
|
||||
* A component that renders map and provides basic map manipulation capabilities
|
||||
*/
|
||||
@Composable
|
||||
public actual fun MapView(
|
||||
viewScope: MapViewScope,
|
||||
features: FeatureGroup<Gmc>,
|
||||
modifier: Modifier,
|
||||
): Unit = with(viewScope) {
|
||||
val mapTiles = remember(mapTileProvider) { mutableStateMapOf<TileId, Image>() }
|
||||
|
||||
// Load tiles asynchronously
|
||||
LaunchedEffect(viewPoint, canvasSize) {
|
||||
with(mapTileProvider) {
|
||||
val indexRange = 0 until 2.0.pow(intZoom).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)
|
||||
|
||||
for (j in verticalIndices) {
|
||||
for (i in horizontalIndices) {
|
||||
val id = TileId(intZoom, 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 {
|
||||
val tile = deferred.await()
|
||||
mapTiles[tile.id] = tile.image
|
||||
} catch (ex: Exception) {
|
||||
//displaying the error is maps responsibility
|
||||
logger.error(ex) { "Failed to load tile with id=$id" }
|
||||
}
|
||||
}
|
||||
}
|
||||
mapTiles.keys.filter {
|
||||
it.zoom != intZoom || it.j !in verticalIndices || it.i !in horizontalIndices
|
||||
}.forEach {
|
||||
mapTiles.remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
key(viewScope, features) {
|
||||
val painterCache: Map<PainterFeature<Gmc>, Painter> =
|
||||
features.features.filterIsInstance<PainterFeature<Gmc>>().associateWith { it.getPainter() }
|
||||
|
||||
Canvas(modifier = modifier.mapControls(viewScope, features)) {
|
||||
|
||||
if (canvasSize != size.toDpSize()) {
|
||||
logger.debug { "Recalculate canvas. Size: $size" }
|
||||
config.onCanvasSizeChange(canvasSize)
|
||||
canvasSize = size.toDpSize()
|
||||
}
|
||||
|
||||
clipRect {
|
||||
val tileSize = IntSize(
|
||||
ceil((mapTileProvider.tileSize.dp * tileScale).toPx()).toInt(),
|
||||
ceil((mapTileProvider.tileSize.dp * tileScale).toPx()).toInt()
|
||||
)
|
||||
mapTiles.forEach { (id, image) ->
|
||||
//converting back from tile index to screen offset
|
||||
val offset = IntOffset(
|
||||
(canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale).roundToPx(),
|
||||
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale).roundToPx()
|
||||
)
|
||||
drawImage(
|
||||
image = image.toComposeImageBitmap(),
|
||||
dstOffset = offset,
|
||||
dstSize = tileSize
|
||||
)
|
||||
}
|
||||
|
||||
features.featureMap.values.sortedBy { it.z }
|
||||
.filter { viewPoint.zoom in it.zoomRange }
|
||||
.forEach { feature ->
|
||||
drawFeature(viewScope, painterCache, feature)
|
||||
}
|
||||
}
|
||||
|
||||
selectRect?.let { dpRect ->
|
||||
val rect = dpRect.toRect()
|
||||
drawRect(
|
||||
color = Color.Blue,
|
||||
topLeft = rect.topLeft,
|
||||
size = rect.size,
|
||||
alpha = 0.5f,
|
||||
style = Stroke(
|
||||
width = 2f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,8 @@
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import space.kscience.kmath.geometry.Angle
|
||||
import space.kscience.kmath.geometry.tan
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
import space.kscience.kmath.geometry.*
|
||||
import kotlin.math.*
|
||||
|
||||
@Serializable
|
||||
public class GeoEllipsoid(public val equatorRadius: Distance, public val polarRadius: Distance) {
|
||||
@ -43,12 +41,7 @@ public class GeoEllipsoid(public val equatorRadius: Distance, public val polarRa
|
||||
polarRadius = Distance(6378.137)
|
||||
)
|
||||
|
||||
// /**
|
||||
// * https://en.wikipedia.org/wiki/Great-circle_distance
|
||||
// */
|
||||
// public fun greatCircleAngleBetween(r1: GMC, r2: GMC): Radians = acos(
|
||||
// sin(r1.latitude) * sin(r2.latitude) + cos(r1.latitude) * cos(r2.latitude) * cos(r1.longitude - r2.longitude)
|
||||
// ).radians
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,22 +52,35 @@ public fun GeoEllipsoid.reducedRadius(latitude: Angle): Distance {
|
||||
val reducedLatitudeTan = (1 - f) * tan(latitude)
|
||||
return equatorRadius / sqrt(1.0 + reducedLatitudeTan.pow(2))
|
||||
}
|
||||
//
|
||||
//
|
||||
///**
|
||||
// * Compute distance between two map points using giv
|
||||
// * https://en.wikipedia.org/wiki/Geographical_distance#Lambert's_formula_for_long_lines
|
||||
// */
|
||||
//public fun GeoEllipsoid.lambertDistanceBetween(r1: GMC, r2: GMC): Distance {
|
||||
// val s = greatCircleAngleBetween(r1, r2)
|
||||
//
|
||||
// val b1: Double = (1 - f) * tan(r1.latitude)
|
||||
// val b2: Double = (1 - f) * tan(r2.latitude)
|
||||
// val p = (b1 + b2) / 2
|
||||
// val q = (b2 - b1) / 2
|
||||
//
|
||||
// val x = (s.value - sin(s)) * sin(p).pow(2) * cos(q).pow(2) / cos(s / 2).pow(2)
|
||||
// val y = (s.value + sin(s)) * cos(p).pow(2) * sin(q).pow(2) / sin(s / 2).pow(2)
|
||||
//
|
||||
// return equatorRadius * (s.value - f / 2 * (x + y))
|
||||
//}
|
||||
|
||||
|
||||
/**
|
||||
* Compute distance between two map points using giv
|
||||
* https://en.wikipedia.org/wiki/Geographical_distance#Lambert's_formula_for_long_lines
|
||||
*/
|
||||
public fun GeoEllipsoid.lambertDistanceBetween(r1: Gmc, r2: Gmc): Distance {
|
||||
|
||||
/**
|
||||
* https://en.wikipedia.org/wiki/Great-circle_distance
|
||||
*/
|
||||
fun greatCircleAngleBetween(
|
||||
r1: Gmc,
|
||||
r2: Gmc,
|
||||
): Radians = acos(
|
||||
sin(r1.latitude) * sin(r2.latitude) +
|
||||
cos(r1.latitude) * cos(r2.latitude) *
|
||||
cos(r1.longitude - r2.longitude)
|
||||
).radians
|
||||
|
||||
val s = greatCircleAngleBetween(r1, r2)
|
||||
|
||||
val b1: Double = (1 - f) * tan(r1.latitude)
|
||||
val b2: Double = (1 - f) * tan(r2.latitude)
|
||||
val p = (b1 + b2) / 2
|
||||
val q = (b2 - b1) / 2
|
||||
|
||||
val x = (s.value - sin(s)) * sin(p).pow(2) * cos(q).pow(2) / cos(s / 2).pow(2)
|
||||
val y = (s.value + sin(s)) * cos(p).pow(2) * sin(q).pow(2) / sin(s / 2).pow(2)
|
||||
|
||||
return equatorRadius * (s.value - f / 2 * (x + y))
|
||||
}
|
@ -5,10 +5,10 @@ import kotlin.math.*
|
||||
|
||||
/**
|
||||
* A directed straight (geodetic) segment on a spheroid with given start, direction, end point and distance.
|
||||
* @param forward coordinate of a start point with forward direction
|
||||
* @param backward coordinate of an end point with backward direction
|
||||
* @param forward coordinate of a start point with the forward direction
|
||||
* @param backward coordinate of an end point with the backward direction
|
||||
*/
|
||||
public class GmcCurve(
|
||||
public class GmcCurve internal constructor(
|
||||
public val forward: GmcPose,
|
||||
public val backward: GmcPose,
|
||||
public val distance: Distance,
|
||||
|
@ -24,6 +24,7 @@ kotlin {
|
||||
dependencies {
|
||||
api(projects.trajectoryKt)
|
||||
api(compose.foundation)
|
||||
api("io.github.oshai:kotlin-logging:5.1.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -66,9 +66,3 @@ public fun <T : Any, A : Attribute<T>> Attributes(
|
||||
): Attributes = Attributes(mapOf(attribute to attrValue))
|
||||
|
||||
public operator fun Attributes.plus(other: Attributes): Attributes = Attributes(content + other.content)
|
||||
|
||||
public val Feature<*>.z: Float
|
||||
get() = attributes[ZAttribute] ?: 0f
|
||||
// set(value) {
|
||||
// attributes[ZAttribute] = value
|
||||
// }
|
@ -1,16 +1,15 @@
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.*
|
||||
|
||||
public abstract class CoordinateViewScope<T : Any>(
|
||||
public val config: ViewConfig<T>,
|
||||
) {
|
||||
|
||||
/**
|
||||
* A state holder for current canvas size and view point. Allows transformation from coordinates to pixels and back
|
||||
*/
|
||||
public abstract class CanvasState<T: Any>(
|
||||
public val viewConfig: ViewConfig<T>
|
||||
){
|
||||
public abstract val space: CoordinateSpace<T>
|
||||
|
||||
private var canvasSizeState: MutableState<DpSize?> = mutableStateOf(null)
|
||||
@ -20,13 +19,14 @@ public abstract class CoordinateViewScope<T : Any>(
|
||||
get() = canvasSizeState.value ?: DpSize(512.dp, 512.dp)
|
||||
set(value) {
|
||||
canvasSizeState.value = value
|
||||
viewConfig.onCanvasSizeChange(value)
|
||||
}
|
||||
|
||||
public var viewPoint: ViewPoint<T>
|
||||
get() = viewPointState.value ?: space.defaultViewPoint
|
||||
set(value) {
|
||||
viewPointState.value = value
|
||||
config.onViewChange(viewPoint)
|
||||
viewConfig.onViewChange(viewPoint)
|
||||
}
|
||||
|
||||
public val zoom: Float get() = viewPoint.zoom
|
||||
@ -35,28 +35,28 @@ public abstract class CoordinateViewScope<T : Any>(
|
||||
// Selection rectangle. If null - no selection
|
||||
public var selectRect: DpRect? by mutableStateOf(null)
|
||||
|
||||
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 fun Offset.toCoordinates(density: Density): T = with(density) {
|
||||
val dpOffset = DpOffset(x.toDp(), y.toDp())
|
||||
dpOffset.toCoordinates()
|
||||
}
|
||||
|
||||
public abstract fun Rectangle<T>.toDpRect(): DpRect
|
||||
|
||||
public abstract fun ViewPoint<T>.moveBy(x: Dp, y: Dp): ViewPoint<T>
|
||||
|
||||
public abstract fun computeViewPoint(rectangle: Rectangle<T>): ViewPoint<T>
|
||||
}
|
||||
|
||||
public abstract fun DpOffset.toCoordinates(): T
|
||||
|
||||
|
||||
public abstract fun T.toDpOffset(): DpOffset
|
||||
|
||||
public fun toCoordinates(offset: Offset, density: Density): T = with(density){
|
||||
val dpOffset = DpOffset(offset.x.toDp(), offset.y.toDp())
|
||||
dpOffset.toCoordinates()
|
||||
}
|
||||
|
||||
public fun toOffset(coordinates: T, density: Density): Offset = with(density){
|
||||
val dpOffset = coordinates.toDpOffset()
|
||||
return Offset(dpOffset.x.toPx(), dpOffset.y.toPx())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public val DpRect.topLeft: DpOffset get() = DpOffset(left, top)
|
||||
|
@ -0,0 +1,108 @@
|
||||
package center.sciprog.maps.features
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScopeMarker
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.DpRect
|
||||
import center.sciprog.attributes.Attributes
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
|
||||
/**
|
||||
* An extension of [DrawScope] to include map-specific features
|
||||
*/
|
||||
@DrawScopeMarker
|
||||
public abstract class FeatureDrawScope<T : Any>(
|
||||
public val state: CanvasState<T>,
|
||||
) : DrawScope {
|
||||
public fun Offset.toCoordinates(): T = with(state) {
|
||||
toCoordinates(this@toCoordinates, this@FeatureDrawScope)
|
||||
}
|
||||
|
||||
public fun T.toOffset(): Offset = with(state) {
|
||||
toOffset(this@toOffset, this@FeatureDrawScope)
|
||||
}
|
||||
|
||||
public fun Rectangle<T>.toDpRect(): DpRect = with(state) { toDpRect() }
|
||||
|
||||
public abstract fun painterFor(feature: PainterFeature<T>): Painter
|
||||
|
||||
public abstract fun drawText(text: String, position: Offset, attributes: Attributes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of FeatureDrawScope to be used in Compose (both schemes and Maps)
|
||||
*/
|
||||
@DrawScopeMarker
|
||||
public class ComposeFeatureDrawScope<T : Any>(
|
||||
drawScope: DrawScope,
|
||||
state: CanvasState<T>,
|
||||
private val painterCache: Map<PainterFeature<T>, Painter>,
|
||||
private val textMeasurer: TextMeasurer,
|
||||
) : FeatureDrawScope<T>(state), DrawScope by drawScope {
|
||||
override fun drawText(text: String, position: Offset, attributes: Attributes) {
|
||||
try {
|
||||
drawText(textMeasurer, text, position)
|
||||
} catch (ex: Exception){
|
||||
KotlinLogging.logger("features").error(ex){"Failed to measure text"}
|
||||
}
|
||||
}
|
||||
|
||||
override fun painterFor(feature: PainterFeature<T>): Painter = painterCache[feature] ?: error("Can't resolve painter for $feature")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a canvas with extended functionality (e.g., drawing text)
|
||||
*/
|
||||
@Composable
|
||||
public fun <T : Any> FeatureCanvas(
|
||||
state: CanvasState<T>,
|
||||
features: FeatureGroup<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
draw: FeatureDrawScope<T>.() -> Unit = {},
|
||||
) {
|
||||
val textMeasurer = rememberTextMeasurer(200)
|
||||
|
||||
val painterCache: Map<PainterFeature<T>, Painter> = features.features.flatMap {
|
||||
if (it is FeatureGroup) it.features else listOf(it)
|
||||
}.filterIsInstance<PainterFeature<T>>().associateWith { it.getPainter() }
|
||||
|
||||
Canvas(modifier) {
|
||||
if (state.canvasSize != size.toDpSize()) {
|
||||
state.canvasSize = size.toDpSize()
|
||||
}
|
||||
ComposeFeatureDrawScope(this, state, painterCache, textMeasurer).apply(draw).apply{
|
||||
clipRect {
|
||||
features.featureMap.values.sortedBy { it.z }
|
||||
.filter { state.viewPoint.zoom in it.zoomRange }
|
||||
.forEach { feature ->
|
||||
this@apply.drawFeature(feature)
|
||||
}
|
||||
}
|
||||
}
|
||||
state.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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
package center.sciprog.maps.features
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
@ -11,7 +9,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.attributes.*
|
||||
import center.sciprog.attributes.Attribute
|
||||
import center.sciprog.attributes.Attributes
|
||||
import space.kscience.kmath.geometry.Angle
|
||||
import space.kscience.kmath.nd.*
|
||||
import space.kscience.kmath.structures.Buffer
|
||||
@ -33,8 +32,12 @@ public val <T : Any, F : Feature<T>> FeatureRef<T, F>.attributes: Attributes get
|
||||
public data class FeatureGroup<T : Any>(
|
||||
override val space: CoordinateSpace<T>,
|
||||
public val featureMap: SnapshotStateMap<String, Feature<T>> = mutableStateMapOf(),
|
||||
override val attributes: Attributes = Attributes.EMPTY,
|
||||
) : CoordinateSpace<T> by space, Feature<T> {
|
||||
|
||||
private val attributesState: MutableState<Attributes> = mutableStateOf(Attributes.EMPTY)
|
||||
|
||||
override val attributes: Attributes get() = attributesState.value
|
||||
|
||||
//
|
||||
// @Suppress("UNCHECKED_CAST")
|
||||
// public operator fun <F : Feature<T>> get(id: FeatureId<F>): F =
|
||||
@ -62,26 +65,8 @@ public data class FeatureGroup<T : Any>(
|
||||
|
||||
public val features: Collection<Feature<T>> get() = featureMap.values.sortedByDescending { it.z }
|
||||
|
||||
public fun visit(visitor: FeatureGroup<T>.(id: String, feature: Feature<T>) -> Unit) {
|
||||
featureMap.entries.sortedByDescending { it.value.z }.forEach { (key, feature) ->
|
||||
if (feature is FeatureGroup<T>) {
|
||||
feature.visit(visitor)
|
||||
} else {
|
||||
visitor(this, key, feature)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun visitUntil(visitor: FeatureGroup<T>.(id: String, feature: Feature<T>) -> Boolean) {
|
||||
featureMap.entries.sortedByDescending { it.value.z }.forEach { (key, feature) ->
|
||||
if (feature is FeatureGroup<T>) {
|
||||
feature.visitUntil(visitor)
|
||||
} else {
|
||||
if (!visitor(this, key, feature)) return@visitUntil
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
//
|
||||
// @Suppress("UNCHECKED_CAST")
|
||||
// public fun <A> getAttribute(id: FeatureId<Feature<T>>, key: Attribute<A>): A? =
|
||||
// get(id).attributes[key]
|
||||
@ -91,7 +76,10 @@ public data class FeatureGroup<T : Any>(
|
||||
featureMap.values.mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles()
|
||||
}
|
||||
|
||||
override fun withAttributes(modify: Attributes.() -> Attributes): Feature<T> = copy(attributes = modify(attributes))
|
||||
override fun withAttributes(modify: Attributes.() -> Attributes): Feature<T> {
|
||||
attributesState.value = attributes.modify()
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
public companion object {
|
||||
@ -118,6 +106,29 @@ public data class FeatureGroup<T : Any>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively search for feature until function returns true
|
||||
*/
|
||||
public fun <T : Any> FeatureGroup<T>.forEachUntil(visitor: FeatureGroup<T>.(id: String, feature: Feature<T>) -> Boolean) {
|
||||
featureMap.entries.sortedByDescending { it.value.z }.forEach { (key, feature) ->
|
||||
if (feature is FeatureGroup<T>) {
|
||||
feature.forEachUntil(visitor)
|
||||
} else {
|
||||
if (!visitor(this, key, feature)) return@forEachUntil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively visit all features in this group
|
||||
*/
|
||||
public fun <T : Any> FeatureGroup<T>.forEach(
|
||||
visitor: FeatureGroup<T>.(id: String, feature: Feature<T>) -> Unit,
|
||||
): Unit = forEachUntil { id, feature ->
|
||||
visitor(id, feature)
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all features with a given attribute from the one with highest [z] to lowest
|
||||
*/
|
||||
@ -125,7 +136,7 @@ public fun <T : Any, A> FeatureGroup<T>.forEachWithAttribute(
|
||||
key: Attribute<A>,
|
||||
block: FeatureGroup<T>.(id: String, feature: Feature<T>, attributeValue: A) -> Unit,
|
||||
) {
|
||||
visit { id, feature ->
|
||||
forEach { id, feature ->
|
||||
feature.attributes[key]?.let {
|
||||
block(id, feature, it)
|
||||
}
|
||||
@ -136,7 +147,7 @@ public fun <T : Any, A> FeatureGroup<T>.forEachWithAttributeUntil(
|
||||
key: Attribute<A>,
|
||||
block: FeatureGroup<T>.(id: String, feature: Feature<T>, attributeValue: A) -> Boolean,
|
||||
) {
|
||||
visitUntil { id, feature ->
|
||||
forEachUntil { id, feature ->
|
||||
feature.attributes[key]?.let {
|
||||
block(id, feature, it)
|
||||
} ?: true
|
||||
@ -146,7 +157,7 @@ public fun <T : Any, A> FeatureGroup<T>.forEachWithAttributeUntil(
|
||||
public inline fun <T : Any, reified F : Feature<T>> FeatureGroup<T>.forEachWithType(
|
||||
crossinline block: (FeatureRef<T, F>) -> Unit,
|
||||
) {
|
||||
visit { id, feature ->
|
||||
forEach { id, feature ->
|
||||
if (feature is F) block(FeatureRef(id, this))
|
||||
}
|
||||
}
|
||||
@ -154,7 +165,7 @@ public inline fun <T : Any, reified F : Feature<T>> FeatureGroup<T>.forEachWithT
|
||||
public inline fun <T : Any, reified F : Feature<T>> FeatureGroup<T>.forEachWithTypeUntil(
|
||||
crossinline block: (FeatureRef<T, F>) -> Boolean,
|
||||
) {
|
||||
visitUntil { id, feature ->
|
||||
forEachUntil { id, feature ->
|
||||
if (feature is F) block(FeatureRef(id, this)) else true
|
||||
}
|
||||
}
|
||||
@ -241,25 +252,23 @@ public fun <T : Any> FeatureGroup<T>.icon(
|
||||
size: DpSize = DpSize(image.defaultWidth, image.defaultHeight),
|
||||
attributes: Attributes = Attributes.EMPTY,
|
||||
id: String? = null,
|
||||
): FeatureRef<T, VectorIconFeature<T>> =
|
||||
feature(
|
||||
id,
|
||||
VectorIconFeature(
|
||||
space,
|
||||
position,
|
||||
size,
|
||||
image,
|
||||
attributes
|
||||
)
|
||||
): FeatureRef<T, VectorIconFeature<T>> = feature(
|
||||
id,
|
||||
VectorIconFeature(
|
||||
space,
|
||||
position,
|
||||
size,
|
||||
image,
|
||||
attributes
|
||||
)
|
||||
)
|
||||
|
||||
public fun <T : Any> FeatureGroup<T>.group(
|
||||
attributes: Attributes = Attributes.EMPTY,
|
||||
id: String? = null,
|
||||
builder: FeatureGroup<T>.() -> Unit,
|
||||
): FeatureRef<T, FeatureGroup<T>> {
|
||||
val collection = FeatureGroup(space).apply(builder)
|
||||
val feature = FeatureGroup(space, collection.featureMap, attributes)
|
||||
val feature = FeatureGroup(space, collection.featureMap)
|
||||
return feature(id, feature)
|
||||
}
|
||||
|
||||
@ -303,3 +312,22 @@ public fun <T : Any> FeatureGroup<T>.pixelMap(
|
||||
id,
|
||||
PixelMapFeature(space, rectangle, pixelMap, attributes = attributes)
|
||||
)
|
||||
|
||||
/**
|
||||
* Create a pretty tree-like representation of this feature group
|
||||
*/
|
||||
public fun FeatureGroup<*>.toPrettyString(): String {
|
||||
fun StringBuilder.printGroup(id: String, group: FeatureGroup<*>, prefix: String) {
|
||||
appendLine("${prefix}* [group] $id")
|
||||
group.featureMap.forEach { (id, feature) ->
|
||||
if (feature is FeatureGroup<*>) {
|
||||
printGroup(id, feature, " ")
|
||||
} else {
|
||||
appendLine("$prefix * [${feature::class.simpleName}] $id ")
|
||||
}
|
||||
}
|
||||
}
|
||||
return buildString {
|
||||
printGroup("root", this@toPrettyString, "")
|
||||
}
|
||||
}
|
@ -2,35 +2,31 @@ package center.sciprog.maps.features
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PointMode
|
||||
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.painter.Painter
|
||||
import center.sciprog.attributes.plus
|
||||
import org.jetbrains.skia.Font
|
||||
import org.jetbrains.skia.Paint
|
||||
import space.kscience.kmath.PerformancePitfall
|
||||
import space.kscience.kmath.geometry.degrees
|
||||
|
||||
|
||||
internal fun Color.toPaint(): Paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = toArgb()
|
||||
}
|
||||
//internal fun Color.toPaint(): Paint = Paint().apply {
|
||||
// isAntiAlias = true
|
||||
// color = toArgb()
|
||||
//}
|
||||
|
||||
public fun <T : Any> DrawScope.drawFeature(
|
||||
state: CoordinateViewScope<T>,
|
||||
painterCache: Map<PainterFeature<T>, Painter>,
|
||||
|
||||
public fun <T : Any> FeatureDrawScope<T>.drawFeature(
|
||||
feature: Feature<T>,
|
||||
): Unit = with(state) {
|
||||
): Unit {
|
||||
val color = feature.color ?: Color.Red
|
||||
val alpha = feature.attributes[AlphaAttribute] ?: 1f
|
||||
fun T.toOffset(): Offset = toOffset(this@drawFeature)
|
||||
|
||||
when (feature) {
|
||||
is FeatureSelector -> drawFeature(state, painterCache, feature.selector(state.zoom))
|
||||
is FeatureSelector -> drawFeature(feature.selector(state.zoom))
|
||||
is CircleFeature -> drawCircle(
|
||||
color,
|
||||
feature.radius.toPx(),
|
||||
@ -78,22 +74,13 @@ public fun <T : Any> DrawScope.drawFeature(
|
||||
val offset = feature.center.toOffset()
|
||||
val size = feature.size.toSize()
|
||||
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
|
||||
with(painterCache[feature]!!) {
|
||||
draw(size)
|
||||
with(this@drawFeature.painterFor(feature)) {
|
||||
draw(size, colorFilter = feature.color?.let { ColorFilter.tint(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ?: Color.Black).toPaint()
|
||||
)
|
||||
}
|
||||
is TextFeature -> drawText(feature.text, feature.position.toOffset(), feature.attributes)
|
||||
|
||||
is DrawFeature -> {
|
||||
val offset = feature.position.toOffset()
|
||||
@ -104,9 +91,7 @@ public fun <T : Any> DrawScope.drawFeature(
|
||||
|
||||
is FeatureGroup -> {
|
||||
feature.featureMap.values.forEach {
|
||||
drawFeature(state, painterCache, it.withAttributes {
|
||||
feature.attributes + this
|
||||
})
|
||||
drawFeature(it.withAttributes { feature.attributes + this })
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,7 +148,7 @@ public fun <T : Any> DrawScope.drawFeature(
|
||||
val offset = rect.topLeft
|
||||
|
||||
translate(offset.x, offset.y) {
|
||||
with(painterCache[feature]!!) {
|
||||
with(this@drawFeature.painterFor(feature)) {
|
||||
draw(rect.size)
|
||||
}
|
||||
}
|
@ -13,6 +13,9 @@ import center.sciprog.attributes.withAttribute
|
||||
|
||||
public object ZAttribute : Attribute<Float>
|
||||
|
||||
public val Feature<*>.z: Float
|
||||
get() = attributes[ZAttribute] ?: 0f
|
||||
|
||||
public object DraggableAttribute : Attribute<DragHandle<Any>>
|
||||
|
||||
public object DragListenerAttribute : SetAttribute<DragListener<Any>>
|
||||
@ -46,12 +49,14 @@ public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.zoomRange(range: FloatRang
|
||||
|
||||
public object AlphaAttribute : Attribute<Float>
|
||||
|
||||
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.modifyAttributes(modify: AttributesBuilder.() -> Unit): FeatureRef<T, F> {
|
||||
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.modifyAttributes(
|
||||
modification: AttributesBuilder.() -> Unit
|
||||
): FeatureRef<T, F> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
parent.feature(
|
||||
id,
|
||||
resolve().withAttributes {
|
||||
AttributesBuilder(this).apply(modify).build()
|
||||
AttributesBuilder(this).apply(modification).build()
|
||||
} as F
|
||||
)
|
||||
return this
|
||||
|
@ -16,10 +16,10 @@ import kotlin.math.min
|
||||
* Create a modifier for Map/Scheme canvas controls on desktop
|
||||
* @param features a collection of features to be rendered in descending [ZAttribute] order
|
||||
*/
|
||||
public fun <T : Any> Modifier.mapControls(
|
||||
state: CoordinateViewScope<T>,
|
||||
public fun <T : Any> Modifier.canvasControls(
|
||||
state: CanvasState<T>,
|
||||
features: FeatureGroup<T>,
|
||||
): Modifier = with(state) {
|
||||
): Modifier = with(state){
|
||||
|
||||
// //selecting all tapabales ahead of time
|
||||
// val allTapable = buildMap {
|
||||
@ -32,8 +32,8 @@ public fun <T : Any> Modifier.mapControls(
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val coordinates = event.changes.first().position.toCoordinates(this)
|
||||
val point = space.ViewPoint(coordinates, zoom)
|
||||
val coordinates = toCoordinates(event.changes.first().position, this)
|
||||
val point = state.space.ViewPoint(coordinates, zoom)
|
||||
|
||||
if (event.type == PointerEventType.Move) {
|
||||
features.forEachWithAttribute(HoverListenerAttribute) { _, feature, listeners ->
|
||||
@ -47,9 +47,9 @@ public fun <T : Any> Modifier.mapControls(
|
||||
}
|
||||
}.pointerInput(Unit) {
|
||||
detectClicks(
|
||||
onDoubleClick = if (state.config.zoomOnDoubleClick) {
|
||||
onDoubleClick = if (viewConfig.zoomOnDoubleClick) {
|
||||
{ event ->
|
||||
val invariant = event.position.toCoordinates(this)
|
||||
val invariant = toCoordinates(event.position, this)
|
||||
viewPoint = with(space) {
|
||||
viewPoint.zoomBy(
|
||||
if (event.buttons.isPrimaryPressed) 1f else if (event.buttons.isSecondaryPressed) -1f else 0f,
|
||||
@ -59,10 +59,10 @@ public fun <T : Any> Modifier.mapControls(
|
||||
}
|
||||
} else null,
|
||||
onClick = { event ->
|
||||
val coordinates = event.position.toCoordinates(this)
|
||||
val coordinates = toCoordinates(event.position, this)
|
||||
val point = space.ViewPoint(coordinates, zoom)
|
||||
|
||||
config.onClick?.handle(
|
||||
viewConfig.onClick?.handle(
|
||||
event,
|
||||
point
|
||||
)
|
||||
@ -88,7 +88,7 @@ public fun <T : Any> Modifier.mapControls(
|
||||
//compute invariant point of translation
|
||||
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toCoordinates()
|
||||
viewPoint = with(space) {
|
||||
viewPoint.zoomBy(-change.scrollDelta.y * config.zoomSpeed, invariant)
|
||||
viewPoint.zoomBy(-change.scrollDelta.y * viewConfig.zoomSpeed, invariant)
|
||||
}
|
||||
change.consume()
|
||||
}
|
||||
@ -110,14 +110,14 @@ public fun <T : Any> Modifier.mapControls(
|
||||
//apply drag handle and check if it prohibits the drag even propagation
|
||||
if (selectionStart == null) {
|
||||
val dragStart = space.ViewPoint(
|
||||
dragChange.previousPosition.toCoordinates(this),
|
||||
toCoordinates(dragChange.previousPosition, this),
|
||||
zoom
|
||||
)
|
||||
val dragEnd = space.ViewPoint(
|
||||
dragChange.position.toCoordinates(this),
|
||||
toCoordinates(dragChange.position, this),
|
||||
zoom
|
||||
)
|
||||
val dragResult = config.dragHandle?.handle(event, dragStart, dragEnd)
|
||||
val dragResult = viewConfig.dragHandle?.handle(event, dragStart, dragEnd)
|
||||
if (dragResult?.handleNext == false) return@drag
|
||||
|
||||
var continueAfter = true
|
||||
@ -132,7 +132,7 @@ public fun <T : Any> Modifier.mapControls(
|
||||
}
|
||||
|
||||
if (event.buttons.isPrimaryPressed) {
|
||||
//If selection process is started, modify the frame
|
||||
//If the selection process is started, modify the frame
|
||||
selectionStart?.let { start ->
|
||||
val offset = dragChange.position
|
||||
selectRect = DpRect(
|
||||
@ -161,8 +161,8 @@ public fun <T : Any> Modifier.mapControls(
|
||||
rect.topLeft.toCoordinates(),
|
||||
rect.bottomRight.toCoordinates()
|
||||
)
|
||||
config.onSelect(coordinateRect)
|
||||
if (config.zoomOnSelect) {
|
||||
viewConfig.onSelect(coordinateRect)
|
||||
if (viewConfig.zoomOnSelect) {
|
||||
viewPoint = computeViewPoint(coordinateRect)
|
||||
}
|
||||
selectRect = null
|
@ -1,18 +1,14 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import center.sciprog.attributes.z
|
||||
import center.sciprog.maps.compose.mapControls
|
||||
import center.sciprog.maps.compose.canvasControls
|
||||
import center.sciprog.maps.features.*
|
||||
import mu.KotlinLogging
|
||||
import kotlin.math.min
|
||||
@ -22,48 +18,14 @@ private val logger = KotlinLogging.logger("SchemeView")
|
||||
|
||||
@Composable
|
||||
public fun SchemeView(
|
||||
state: XYViewScope,
|
||||
state: XYCanvasState,
|
||||
features: FeatureGroup<XY>,
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
): Unit = key(state, features) {
|
||||
with(state) {
|
||||
//Can't do that inside canvas
|
||||
val painterCache: Map<PainterFeature<XY>, Painter> =
|
||||
features.features.filterIsInstance<PainterFeature<XY>>().associateWith { it.getPainter() }
|
||||
|
||||
Canvas(modifier = modifier.mapControls(state, features)) {
|
||||
|
||||
if (canvasSize != size.toDpSize()) {
|
||||
canvasSize = size.toDpSize()
|
||||
logger.debug { "Recalculate canvas. Size: $size" }
|
||||
}
|
||||
|
||||
clipRect {
|
||||
features.featureMap.values.sortedBy { it.z }
|
||||
.filter { viewPoint.zoom in it.zoomRange }
|
||||
.forEach { feature ->
|
||||
drawFeature(state, painterCache, feature)
|
||||
}
|
||||
}
|
||||
|
||||
selectRect?.let { dpRect ->
|
||||
val rect = dpRect.toRect()
|
||||
drawRect(
|
||||
color = Color.Blue,
|
||||
topLeft = rect.topLeft,
|
||||
size = rect.size,
|
||||
alpha = 0.5f,
|
||||
style = Stroke(
|
||||
width = 2f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
): Unit {
|
||||
FeatureCanvas(state, features, modifier = modifier.canvasControls(state, features))
|
||||
}
|
||||
|
||||
|
||||
public fun Rectangle<XY>.computeViewPoint(
|
||||
canvasSize: DpSize = defaultCanvasSize,
|
||||
): ViewPoint<XY> {
|
||||
@ -87,7 +49,7 @@ public fun SchemeView(
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
|
||||
val state = XYViewScope.remember(
|
||||
val state = XYCanvasState.remember(
|
||||
config,
|
||||
initialViewPoint = initialViewPoint,
|
||||
initialRectangle = initialRectangle ?: features.getBoundingBox(Float.MAX_VALUE),
|
||||
@ -112,7 +74,7 @@ public fun SchemeView(
|
||||
buildFeatures: FeatureGroup<XY>.() -> Unit = {},
|
||||
) {
|
||||
val featureState = FeatureGroup.remember(XYCoordinateSpace, buildFeatures)
|
||||
val mapState: XYViewScope = XYViewScope.remember(
|
||||
val mapState: XYCanvasState = XYCanvasState.remember(
|
||||
config,
|
||||
initialViewPoint = initialViewPoint,
|
||||
initialRectangle = initialRectangle ?: featureState.features.computeBoundingBox(
|
@ -12,6 +12,8 @@ import kotlin.math.min
|
||||
|
||||
public data class XY(override val x: Float, override val y: Float): Vector2D<Float>
|
||||
|
||||
public fun XY(x: Number, y: Number): XY = XY(x.toFloat(), y.toFloat())
|
||||
|
||||
internal data class XYRectangle(
|
||||
override val a: XY,
|
||||
override val b: XY,
|
||||
|
@ -9,9 +9,9 @@ import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.maps.features.*
|
||||
import kotlin.math.min
|
||||
|
||||
public class XYViewScope(
|
||||
public class XYCanvasState(
|
||||
config: ViewConfig<XY>,
|
||||
) : CoordinateViewScope<XY>(config) {
|
||||
) : CanvasState<XY>(config) {
|
||||
override val space: CoordinateSpace<XY>
|
||||
get() = XYCoordinateSpace
|
||||
|
||||
@ -54,12 +54,12 @@ public class XYViewScope(
|
||||
config: ViewConfig<XY> = ViewConfig(),
|
||||
initialViewPoint: ViewPoint<XY>? = null,
|
||||
initialRectangle: Rectangle<XY>? = null,
|
||||
): XYViewScope = remember {
|
||||
XYViewScope(config).also { mapState->
|
||||
): XYCanvasState = remember {
|
||||
XYCanvasState(config).apply {
|
||||
if (initialViewPoint != null) {
|
||||
mapState.viewPoint = initialViewPoint
|
||||
viewPoint = initialViewPoint
|
||||
} else if (initialRectangle != null) {
|
||||
mapState.viewPoint = mapState.computeViewPoint(initialRectangle)
|
||||
viewPoint = computeViewPoint(initialRectangle)
|
||||
}
|
||||
}
|
||||
}
|
@ -41,7 +41,7 @@ public fun FeatureGroup<XY>.circle(
|
||||
centerCoordinates: Pair<Number, Number>,
|
||||
size: Dp = 5.dp,
|
||||
id: String? = null,
|
||||
): FeatureRef<XY, CircleFeature<XY>> = circle(centerCoordinates.toCoordinates(), size, id = id)
|
||||
): FeatureRef<XY, CircleFeature<XY>> = circle(centerCoordinates.toCoordinates(), size, id = id)
|
||||
|
||||
public fun FeatureGroup<XY>.draw(
|
||||
position: Pair<Number, Number>,
|
||||
@ -63,7 +63,7 @@ public fun FeatureGroup<XY>.arc(
|
||||
arcLength: Angle,
|
||||
id: String? = null,
|
||||
): FeatureRef<XY, ArcFeature<XY>> = arc(
|
||||
oval = XYCoordinateSpace.Rectangle(center.toCoordinates(), 2*radius, 2*radius),
|
||||
oval = XYCoordinateSpace.Rectangle(center.toCoordinates(), 2 * radius, 2 * radius),
|
||||
startAngle = startAngle,
|
||||
arcLength = arcLength,
|
||||
id = id
|
||||
@ -108,4 +108,33 @@ public fun FeatureGroup<XY>.pixelMap(
|
||||
)
|
||||
)
|
||||
|
||||
public fun FeatureGroup<XY>.rectanglePolygon(
|
||||
left: Number, right: Number,
|
||||
bottom: Number, top: Number,
|
||||
attributes: Attributes = Attributes.EMPTY,
|
||||
id: String? = null,
|
||||
): FeatureRef<XY, PolygonFeature<XY>> = polygon(
|
||||
listOf(
|
||||
XY(left.toFloat(), top.toFloat()),
|
||||
XY(right.toFloat(), top.toFloat()),
|
||||
XY(right.toFloat(), bottom.toFloat()),
|
||||
XY(left.toFloat(), bottom.toFloat())
|
||||
),
|
||||
attributes, id
|
||||
)
|
||||
|
||||
public fun FeatureGroup<XY>.rectanglePolygon(
|
||||
rectangle: Rectangle<XY>,
|
||||
attributes: Attributes = Attributes.EMPTY,
|
||||
id: String? = null,
|
||||
): FeatureRef<XY, PolygonFeature<XY>> = polygon(
|
||||
listOf(
|
||||
XY(rectangle.left, rectangle.top),
|
||||
XY(rectangle.right, rectangle.top),
|
||||
XY(rectangle.right, rectangle.bottom),
|
||||
XY(rectangle.left, rectangle.bottom)
|
||||
),
|
||||
attributes, id
|
||||
)
|
||||
|
||||
|
||||
|
@ -5,21 +5,26 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.drawscope.*
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import center.sciprog.attributes.Attributes
|
||||
import center.sciprog.maps.features.*
|
||||
import center.sciprog.maps.scheme.XY
|
||||
import org.jfree.svg.SVGGraphics2D
|
||||
import java.awt.BasicStroke
|
||||
import java.awt.Font
|
||||
import java.awt.geom.*
|
||||
import java.awt.image.AffineTransformOp
|
||||
import java.awt.Color as AWTColor
|
||||
|
||||
public class SvgDrawScope(
|
||||
state: CanvasState<XY>,
|
||||
private val graphics: SVGGraphics2D,
|
||||
size: Size,
|
||||
private val defaultStrokeWidth: Float = 1f,
|
||||
) : DrawScope {
|
||||
private val painterCache: Map<PainterFeature<XY>, Painter>,
|
||||
private val defaultStrokeWidth: Float = 1f
|
||||
) : FeatureDrawScope<XY>(state) {
|
||||
|
||||
override val layoutDirection: LayoutDirection
|
||||
get() = LayoutDirection.Ltr
|
||||
@ -459,16 +464,20 @@ public class SvgDrawScope(
|
||||
}
|
||||
}
|
||||
|
||||
public fun drawText(
|
||||
text: String,
|
||||
x: Float,
|
||||
y: Float,
|
||||
font: Font,
|
||||
color: Color,
|
||||
public fun renderText(
|
||||
textFeature: TextFeature<XY>,
|
||||
) {
|
||||
setupColor(color)
|
||||
graphics.font = font
|
||||
graphics.drawString(text, x, y)
|
||||
textFeature.color?.let { setupColor(it)}
|
||||
graphics.drawString(textFeature.text, textFeature.position.x, textFeature.position.y)
|
||||
}
|
||||
|
||||
override fun painterFor(feature: PainterFeature<XY>): Painter {
|
||||
return painterCache[feature]!!
|
||||
}
|
||||
|
||||
override fun drawText(text: String, position: Offset, attributes: Attributes) {
|
||||
attributes[ColorAttribute]?.let { setupColor(it)}
|
||||
graphics.drawString(text, position.x, position.y)
|
||||
}
|
||||
|
||||
override val drawContext: DrawContext = SvgDrawContext(graphics, size)
|
||||
|
@ -1,21 +1,13 @@
|
||||
package center.sciprog.maps.svg
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PointMode
|
||||
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.painter.Painter
|
||||
import center.sciprog.maps.features.*
|
||||
import center.sciprog.maps.scheme.*
|
||||
import center.sciprog.maps.scheme.XY
|
||||
import center.sciprog.maps.scheme.XYCanvasState
|
||||
import org.jfree.svg.SVGGraphics2D
|
||||
import org.jfree.svg.SVGUtils
|
||||
import space.kscience.kmath.geometry.degrees
|
||||
import java.awt.Font.PLAIN
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
public class FeatureStateSnapshot<T : Any>(
|
||||
@ -26,9 +18,12 @@ public class FeatureStateSnapshot<T : Any>(
|
||||
@Composable
|
||||
public fun <T : Any> FeatureGroup<T>.snapshot(): FeatureStateSnapshot<T> = FeatureStateSnapshot(
|
||||
featureMap,
|
||||
features.filterIsInstance<PainterFeature<T>>().associateWith { it.getPainter() }
|
||||
features.flatMap {
|
||||
if (it is FeatureGroup) it.features else listOf(it)
|
||||
}.filterIsInstance<PainterFeature<T>>().associateWith { it.getPainter() }
|
||||
)
|
||||
|
||||
|
||||
public fun FeatureStateSnapshot<XY>.generateSvg(
|
||||
viewPoint: ViewPoint<XY>,
|
||||
width: Double,
|
||||
@ -36,139 +31,137 @@ public fun FeatureStateSnapshot<XY>.generateSvg(
|
||||
id: String? = null,
|
||||
): String {
|
||||
|
||||
fun XY.toOffset(): Offset = Offset(
|
||||
(width / 2 + (x - viewPoint.focus.x) * viewPoint.zoom).toFloat(),
|
||||
(height / 2 + (viewPoint.focus.y - y) * viewPoint.zoom).toFloat()
|
||||
)
|
||||
|
||||
|
||||
fun SvgDrawScope.drawFeature(scale: Float, feature: Feature<XY>) {
|
||||
|
||||
val color = feature.color ?: Color.Red
|
||||
val alpha = feature.attributes[AlphaAttribute] ?: 1f
|
||||
|
||||
when (feature) {
|
||||
is ScalableImageFeature -> {
|
||||
val offset = XY(feature.rectangle.left, feature.rectangle.top).toOffset()
|
||||
val backgroundSize = Size(
|
||||
(feature.rectangle.width * scale),
|
||||
(feature.rectangle.height * scale)
|
||||
)
|
||||
|
||||
translate(offset.x, offset.y) {
|
||||
with(painterCache[feature]!!) {
|
||||
draw(backgroundSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is FeatureSelector -> drawFeature(scale, feature.selector(scale))
|
||||
|
||||
is CircleFeature -> drawCircle(
|
||||
color,
|
||||
feature.radius.toPx(),
|
||||
center = feature.center.toOffset(),
|
||||
alpha = alpha
|
||||
)
|
||||
|
||||
is LineFeature -> drawLine(
|
||||
color,
|
||||
feature.a.toOffset(),
|
||||
feature.b.toOffset(),
|
||||
alpha = alpha
|
||||
)
|
||||
|
||||
is PointsFeature -> {
|
||||
val points = feature.points.map { it.toOffset() }
|
||||
drawPoints(
|
||||
points = points,
|
||||
color = color,
|
||||
strokeWidth = feature.attributes[StrokeAttribute] ?: Stroke.HairlineWidth,
|
||||
pointMode = PointMode.Points,
|
||||
pathEffect = feature.attributes[PathEffectAttribute],
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
|
||||
is MultiLineFeature -> {
|
||||
val points = feature.points.map { it.toOffset() }
|
||||
drawPoints(
|
||||
points = points,
|
||||
color = color,
|
||||
strokeWidth = feature.attributes[StrokeAttribute] ?: Stroke.HairlineWidth,
|
||||
pointMode = PointMode.Polygon,
|
||||
pathEffect = feature.attributes[PathEffectAttribute],
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
|
||||
is ArcFeature -> {
|
||||
val topLeft = feature.oval.leftTop.toOffset()
|
||||
val bottomRight = feature.oval.rightBottom.toOffset()
|
||||
|
||||
val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y))
|
||||
|
||||
drawArc(
|
||||
color = color,
|
||||
startAngle = feature.startAngle.degrees.toFloat(),
|
||||
sweepAngle = feature.arcLength.degrees.toFloat(),
|
||||
useCenter = false,
|
||||
topLeft = topLeft,
|
||||
size = size,
|
||||
style = Stroke(),
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
|
||||
is BitmapIconFeature -> drawImage(feature.image, feature.center.toOffset())
|
||||
|
||||
is VectorIconFeature -> {
|
||||
val offset = feature.center.toOffset()
|
||||
val imageSize = feature.size.toSize()
|
||||
translate(offset.x - imageSize.width / 2, offset.y - imageSize.height / 2) {
|
||||
with(painterCache[feature]!!) {
|
||||
draw(imageSize, alpha = alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is TextFeature -> drawIntoCanvas { _ ->
|
||||
val offset = feature.position.toOffset()
|
||||
drawText(
|
||||
feature.text,
|
||||
offset.x + 5,
|
||||
offset.y - 5,
|
||||
java.awt.Font(null, PLAIN, 16),
|
||||
color
|
||||
)
|
||||
}
|
||||
|
||||
is DrawFeature -> {
|
||||
val offset = feature.position.toOffset()
|
||||
translate(offset.x, offset.y) {
|
||||
feature.drawFeature(this)
|
||||
}
|
||||
}
|
||||
|
||||
is FeatureGroup -> {
|
||||
feature.featureMap.values.forEach {
|
||||
drawFeature(scale, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// fun XY.toOffset(): Offset = Offset(
|
||||
// (width / 2 + (x - viewPoint.focus.x) * viewPoint.zoom).toFloat(),
|
||||
// (height / 2 + (viewPoint.focus.y - y) * viewPoint.zoom).toFloat()
|
||||
// )
|
||||
//
|
||||
//
|
||||
// fun SvgDrawScope.drawFeature(scale: Float, feature: Feature<XY>) {
|
||||
//
|
||||
// val color = feature.color ?: Color.Red
|
||||
// val alpha = feature.attributes[AlphaAttribute] ?: 1f
|
||||
//
|
||||
// when (feature) {
|
||||
// is ScalableImageFeature -> {
|
||||
// val offset = XY(feature.rectangle.left, feature.rectangle.top).toOffset()
|
||||
// val backgroundSize = Size(
|
||||
// (feature.rectangle.width * scale),
|
||||
// (feature.rectangle.height * scale)
|
||||
// )
|
||||
//
|
||||
// translate(offset.x, offset.y) {
|
||||
// with(painterCache[feature]!!) {
|
||||
// draw(backgroundSize)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// is FeatureSelector -> drawFeature(scale, feature.selector(scale))
|
||||
//
|
||||
// is CircleFeature -> drawCircle(
|
||||
// color,
|
||||
// feature.radius.toPx(),
|
||||
// center = feature.center.toOffset(),
|
||||
// alpha = alpha
|
||||
// )
|
||||
//
|
||||
// is LineFeature -> drawLine(
|
||||
// color,
|
||||
// feature.a.toOffset(),
|
||||
// feature.b.toOffset(),
|
||||
// alpha = alpha
|
||||
// )
|
||||
//
|
||||
// is PointsFeature -> {
|
||||
// val points = feature.points.map { it.toOffset() }
|
||||
// drawPoints(
|
||||
// points = points,
|
||||
// color = color,
|
||||
// strokeWidth = feature.attributes[StrokeAttribute] ?: Stroke.HairlineWidth,
|
||||
// pointMode = PointMode.Points,
|
||||
// pathEffect = feature.attributes[PathEffectAttribute],
|
||||
// alpha = alpha
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// is MultiLineFeature -> {
|
||||
// val points = feature.points.map { it.toOffset() }
|
||||
// drawPoints(
|
||||
// points = points,
|
||||
// color = color,
|
||||
// strokeWidth = feature.attributes[StrokeAttribute] ?: Stroke.HairlineWidth,
|
||||
// pointMode = PointMode.Polygon,
|
||||
// pathEffect = feature.attributes[PathEffectAttribute],
|
||||
// alpha = alpha
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// is ArcFeature -> {
|
||||
// val topLeft = feature.oval.leftTop.toOffset()
|
||||
// val bottomRight = feature.oval.rightBottom.toOffset()
|
||||
//
|
||||
// val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y))
|
||||
//
|
||||
// drawArc(
|
||||
// color = color,
|
||||
// startAngle = feature.startAngle.degrees.toFloat(),
|
||||
// sweepAngle = feature.arcLength.degrees.toFloat(),
|
||||
// useCenter = false,
|
||||
// topLeft = topLeft,
|
||||
// size = size,
|
||||
// style = Stroke(),
|
||||
// alpha = alpha
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// is BitmapIconFeature -> drawImage(feature.image, feature.center.toOffset())
|
||||
//
|
||||
// is VectorIconFeature -> {
|
||||
// val offset = feature.center.toOffset()
|
||||
// val imageSize = feature.size.toSize()
|
||||
// translate(offset.x - imageSize.width / 2, offset.y - imageSize.height / 2) {
|
||||
// with(painterCache[feature]!!) {
|
||||
// draw(imageSize, alpha = alpha)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// is TextFeature -> drawIntoCanvas { _ ->
|
||||
// val offset = feature.position.toOffset()
|
||||
// drawText(
|
||||
// feature.text,
|
||||
// offset.x + 5,
|
||||
// offset.y - 5,
|
||||
// java.awt.Font(null, PLAIN, 16),
|
||||
// color
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// is DrawFeature -> {
|
||||
// val offset = feature.position.toOffset()
|
||||
// translate(offset.x, offset.y) {
|
||||
// feature.drawFeature(this)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// is FeatureGroup -> {
|
||||
// feature.featureMap.values.forEach {
|
||||
// drawFeature(scale, it)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
val svgGraphics2D: SVGGraphics2D = SVGGraphics2D(width, height)
|
||||
val svgScope = SvgDrawScope(svgGraphics2D, Size(width.toFloat(), height.toFloat()))
|
||||
val svgCanvasState: XYCanvasState = XYCanvasState(ViewConfig())
|
||||
val svgScope = SvgDrawScope(svgCanvasState, svgGraphics2D, Size(width.toFloat(), height.toFloat()), painterCache)
|
||||
|
||||
svgScope.apply {
|
||||
features.values.filterIsInstance<ScalableImageFeature<XY>>().forEach { background ->
|
||||
drawFeature(viewPoint.zoom, background)
|
||||
}
|
||||
features.values.filter {
|
||||
it !is ScalableImageFeature && viewPoint.zoom in it.zoomRange
|
||||
viewPoint.zoom in it.zoomRange
|
||||
}.forEach { feature ->
|
||||
drawFeature(viewPoint.zoom, feature)
|
||||
drawFeature(feature)
|
||||
}
|
||||
}
|
||||
return svgGraphics2D.getSVGElement(id)
|
||||
|
@ -16,8 +16,8 @@ pluginManagement {
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application").version(extra["agp.version"] as String)
|
||||
id("com.android.library").version(extra["agp.version"] as String)
|
||||
// id("com.android.application").version(extra["agp.version"] as String)
|
||||
// id("com.android.library").version(extra["agp.version"] as String)
|
||||
id("org.jetbrains.compose").version(extra["compose.version"] as String)
|
||||
id("space.kscience.gradle.project") version toolsVersion
|
||||
id("space.kscience.gradle.mpp") version toolsVersion
|
||||
|
@ -10,7 +10,7 @@ val kmathVersion: String by rootProject.extra
|
||||
kscience{
|
||||
jvm()
|
||||
js()
|
||||
native()
|
||||
// native()
|
||||
|
||||
useContextReceivers()
|
||||
useSerialization()
|
||||
|
Loading…
Reference in New Issue
Block a user