Generalize feature draw logic for schemes

This commit is contained in:
Alexander Nozik 2022-12-25 14:33:31 +03:00
parent 8a94438dfd
commit 5ed46b278c
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
28 changed files with 620 additions and 817 deletions

View File

@ -8,7 +8,7 @@ plugins {
allprojects { allprojects {
group = "center.sciprog" group = "center.sciprog"
version = "0.1.1" version = "0.2.0-dev-1"
} }
apiValidation{ apiValidation{

View File

@ -8,6 +8,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import center.sciprog.maps.features.FeaturesState
import center.sciprog.maps.features.ViewConfig
import center.sciprog.maps.features.ViewPoint
import center.sciprog.maps.features.computeBoundingBox
import center.sciprog.maps.scheme.* import center.sciprog.maps.scheme.*
import center.sciprog.maps.svg.FeatureStateSnapshot import center.sciprog.maps.svg.FeatureStateSnapshot
import center.sciprog.maps.svg.exportToSvg import center.sciprog.maps.svg.exportToSvg
@ -25,7 +29,7 @@ fun App() {
MaterialTheme { MaterialTheme {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val schemeFeaturesState = SchemeFeaturesState.remember { val schemeFeaturesState = FeaturesState.remember(XYCoordinateSpace) {
background(1600f, 1200f) { painterResource("middle-earth.jpg") } background(1600f, 1200f) { painterResource("middle-earth.jpg") }
circle(410.52737 to 868.7676, color = Color.Blue) circle(410.52737 to 868.7676, color = Color.Blue)
text(410.52737 to 868.7676, "Shire", color = Color.Blue) text(410.52737 to 868.7676, "Shire", color = Color.Blue)
@ -33,14 +37,14 @@ fun App() {
text(1132.0881 to 394.99127, "Ordruin", color = Color.Red) text(1132.0881 to 394.99127, "Ordruin", color = Color.Red)
arc(center = 1132.0881 to 394.99127, radius = 20f, startAngle = 0f, 2 * PI.toFloat()) arc(center = 1132.0881 to 394.99127, radius = 20f, startAngle = 0f, 2 * PI.toFloat())
val hobbitId = circle(410.52737 to 868.7676) circle(410.52737 to 868.7676, id = "hobbit")
scope.launch { scope.launch {
var t = 0.0 var t = 0.0
while (isActive) { while (isActive) {
val x = 410.52737 + t * (1132.0881 - 410.52737) val x = 410.52737 + t * (1132.0881 - 410.52737)
val y = 868.7676 + t * (394.99127 - 868.7676) val y = 868.7676 + t * (394.99127 - 868.7676)
circle(x to y, color = Color.Green, id = hobbitId) circle(x to y, color = Color.Green, id = "hobbit")
delay(100) delay(100)
t += 0.005 t += 0.005
if (t >= 1.0) t = 0.0 if (t >= 1.0) t = 0.0
@ -48,14 +52,14 @@ fun App() {
} }
} }
val initialViewPoint: SchemeViewPoint = remember { val initialViewPoint: ViewPoint<XY> = remember {
schemeFeaturesState.features().values.computeBoundingBox(1f)?.computeViewPoint() schemeFeaturesState.features.values.computeBoundingBox(XYCoordinateSpace, 1f)?.computeViewPoint()
?: SchemeViewPoint(SchemeCoordinates(0f, 0f)) ?: XYViewPoint(XY(0f, 0f))
} }
var viewPoint by remember { mutableStateOf<SchemeViewPoint>(initialViewPoint) } var viewPoint: ViewPoint<XY> by remember { mutableStateOf(initialViewPoint) }
var snapshot: FeatureStateSnapshot? by remember { mutableStateOf(null) } var snapshot: FeatureStateSnapshot<XY>? by remember { mutableStateOf(null) }
if (snapshot == null) { if (snapshot == null) {
snapshot = schemeFeaturesState.snapshot() snapshot = schemeFeaturesState.snapshot()
@ -78,7 +82,7 @@ fun App() {
SchemeView( SchemeView(
initialViewPoint = initialViewPoint, initialViewPoint = initialViewPoint,
featuresState = schemeFeaturesState, featuresState = schemeFeaturesState,
config = SchemeViewConfig( config = ViewConfig(
onClick = { onClick = {
println("${focus.x}, ${focus.y}") println("${focus.x}, ${focus.y}")
}, },

View File

@ -8,14 +8,14 @@ import center.sciprog.maps.features.ViewPoint
import kotlin.math.pow import kotlin.math.pow
public object GmcCoordinateSpace : CoordinateSpace<Gmc> { public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
override fun Rectangle(first: Gmc, second: Gmc): GmcRectangle = GmcRectangle(first, second) override fun Rectangle(first: Gmc, second: Gmc): Rectangle<Gmc> = GmcRectangle(first, second)
override fun Rectangle(center: Gmc, zoom: Double, size: DpSize): GmcRectangle { override fun Rectangle(center: Gmc, zoom: Float, size: DpSize): Rectangle<Gmc> {
val scale = WebMercatorProjection.scaleFactor(zoom) val scale = WebMercatorProjection.scaleFactor(zoom)
return buildRectangle(center, (size.width.value / scale).radians, (size.height.value / scale).radians) return Rectangle(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(center: Gmc, zoom: Float): ViewPoint<Gmc> = MapViewPoint(center, zoom)
override fun ViewPoint<Gmc>.moveBy(delta: Gmc): ViewPoint<Gmc> { override fun ViewPoint<Gmc>.moveBy(delta: Gmc): ViewPoint<Gmc> {
val newCoordinates = GeodeticMapCoordinates( val newCoordinates = GeodeticMapCoordinates(
@ -28,21 +28,19 @@ public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
return MapViewPoint(newCoordinates, zoom) return MapViewPoint(newCoordinates, zoom)
} }
override fun ViewPoint<Gmc>.zoomBy(zoomDelta: Double, invariant: Gmc): ViewPoint<Gmc> = if (invariant == focus) { override fun ViewPoint<Gmc>.zoomBy(zoomDelta: Float, invariant: Gmc): ViewPoint<Gmc> = if (invariant == focus) {
ViewPoint(focus, (zoom + zoomDelta).coerceIn(2.0, 18.0) ) ViewPoint(focus, (zoom + zoomDelta).coerceIn(2f, 18f) )
} else { } else {
val difScale = (1 - 2.0.pow(-zoomDelta)) val difScale = (1 - 2f.pow(-zoomDelta))
val newCenter = GeodeticMapCoordinates( val newCenter = GeodeticMapCoordinates(
focus.latitude + (invariant.latitude - focus.latitude) * difScale, focus.latitude + (invariant.latitude - focus.latitude) * difScale,
focus.longitude + (invariant.longitude - focus.longitude) * difScale focus.longitude + (invariant.longitude - focus.longitude) * difScale
) )
MapViewPoint(newCenter, (zoom + zoomDelta).coerceIn(2.0, 18.0)) MapViewPoint(newCenter, (zoom + zoomDelta).coerceIn(2f, 18f))
} }
override fun Rectangle<Gmc>.withCenter(center: Gmc): Rectangle<Gmc> =
override fun Rectangle<Gmc>.withCenter(center: Gmc): GmcRectangle { Rectangle(center, height = latitudeDelta, width = longitudeDelta)
return buildRectangle(center, height = latitudeDelta, width = longitudeDelta)
}
override fun Collection<Rectangle<Gmc>>.wrapRectangles(): Rectangle<Gmc>? { override fun Collection<Rectangle<Gmc>>.wrapRectangles(): Rectangle<Gmc>? {
if (isEmpty()) return null if (isEmpty()) return null
@ -68,24 +66,24 @@ public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
/** /**
* A quasi-square section. Note that latitudinal distance could be imprecise for large distances * A quasi-square section. Note that latitudinal distance could be imprecise for large distances
*/ */
public fun CoordinateSpace<Gmc>.buildRectangle( public fun CoordinateSpace<Gmc>.Rectangle(
center: Gmc, center: Gmc,
height: Distance, height: Distance,
width: Distance, width: Distance,
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84, ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
): GmcRectangle { ): Rectangle<Gmc> {
val reducedRadius = ellipsoid.reducedRadius(center.latitude) val reducedRadius = ellipsoid.reducedRadius(center.latitude)
return buildRectangle(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians) return Rectangle(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians)
} }
/** /**
* A quasi-square section. * A quasi-square section.
*/ */
public fun CoordinateSpace<Gmc>.buildRectangle( public fun CoordinateSpace<Gmc>.Rectangle(
center: GeodeticMapCoordinates, center: GeodeticMapCoordinates,
height: Angle, height: Angle,
width: Angle, width: Angle,
): GmcRectangle { ): Rectangle<Gmc> {
val a = GeodeticMapCoordinates( val a = GeodeticMapCoordinates(
center.latitude - (height / 2), center.latitude - (height / 2),
center.longitude - (width / 2) center.longitude - (width / 2)

View File

@ -12,7 +12,7 @@ import center.sciprog.maps.features.Rectangle
* *
* Note that this is a rectangle only on a Mercator projection. * Note that this is a rectangle only on a Mercator projection.
*/ */
public data class GmcRectangle( internal data class GmcRectangle(
override val a: GeodeticMapCoordinates, override val a: GeodeticMapCoordinates,
override val b: GeodeticMapCoordinates, override val b: GeodeticMapCoordinates,
) : Rectangle<Gmc> { ) : Rectangle<Gmc> {

View File

@ -35,7 +35,7 @@ public fun Rectangle<Gmc>.computeViewPoint(
canvasSize.height.value / latitudeDelta.radians.value canvasSize.height.value / latitudeDelta.radians.value
) * PI / mapTileProvider.tileSize ) * PI / mapTileProvider.tileSize
) )
return MapViewPoint(center, zoom) return MapViewPoint(center, zoom.toFloat())
} }
/** /**
@ -45,7 +45,7 @@ public fun Rectangle<Gmc>.computeViewPoint(
public fun MapView( public fun MapView(
mapTileProvider: MapTileProvider, mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint? = null, initialViewPoint: MapViewPoint? = null,
initialRectangle: GmcRectangle? = null, initialRectangle: Rectangle<Gmc>? = null,
featureMap: Map<FeatureId<*>, MapFeature>, featureMap: Map<FeatureId<*>, MapFeature>,
config: ViewConfig<Gmc> = ViewConfig(), config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(), modifier: Modifier = Modifier.fillMaxSize(),
@ -59,7 +59,7 @@ public fun MapView(
val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) { val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) {
initialViewPoint initialViewPoint
?: initialRectangle?.computeViewPoint(mapTileProvider) ?: initialRectangle?.computeViewPoint(mapTileProvider)
?: featureMap.values.computeBoundingBox(GmcCoordinateSpace, 1.0)?.computeViewPoint(mapTileProvider) ?: featureMap.values.computeBoundingBox(GmcCoordinateSpace, 1f)?.computeViewPoint(mapTileProvider)
?: MapViewPoint.globe ?: MapViewPoint.globe
} }
@ -77,7 +77,7 @@ public fun MapView(
public fun MapView( public fun MapView(
mapTileProvider: MapTileProvider, mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint? = null, initialViewPoint: MapViewPoint? = null,
initialRectangle: GmcRectangle? = null, initialRectangle: Rectangle<Gmc>? = null,
config: ViewConfig<Gmc> = ViewConfig(), config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(), modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: FeaturesState<Gmc>.() -> Unit = {}, buildFeatures: FeaturesState<Gmc>.() -> Unit = {},
@ -87,12 +87,13 @@ public fun MapView(
val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) { val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) {
initialViewPoint initialViewPoint
?: initialRectangle?.computeViewPoint(mapTileProvider) ?: initialRectangle?.computeViewPoint(mapTileProvider)
?: featureState.features.values.computeBoundingBox(GmcCoordinateSpace,1.0)?.computeViewPoint(mapTileProvider) ?: featureState.features.values.computeBoundingBox(GmcCoordinateSpace,1f)?.computeViewPoint(mapTileProvider)
?: MapViewPoint.globe ?: MapViewPoint.globe
} }
val featureDrag: DragHandle<Gmc> = DragHandle.withPrimaryButton { event, start: ViewPoint<Gmc>, end: ViewPoint<Gmc> -> val featureDrag: DragHandle<Gmc> = DragHandle.withPrimaryButton { event, start: ViewPoint<Gmc>, end: ViewPoint<Gmc> ->
featureState.forEachWithAttribute(DraggableAttribute) { _, handle -> featureState.forEachWithAttribute(DraggableAttribute) { _, handle ->
//TODO add safety
handle as DragHandle<Gmc> handle as DragHandle<Gmc>
if (!handle.handle(event, start, end)) return@withPrimaryButton false if (!handle.handle(event, start, end)) return@withPrimaryButton false
} }

View File

@ -9,12 +9,12 @@ import kotlin.math.pow
*/ */
public data class MapViewPoint( public data class MapViewPoint(
override val focus: GeodeticMapCoordinates, override val focus: GeodeticMapCoordinates,
override val zoom: Double, override val zoom: Float,
) : ViewPoint<Gmc>{ ) : ViewPoint<Gmc>{
val scaleFactor: Double by lazy { WebMercatorProjection.scaleFactor(zoom) } val scaleFactor: Double by lazy { WebMercatorProjection.scaleFactor(zoom) }
public companion object{ public companion object{
public val globe: MapViewPoint = MapViewPoint(GeodeticMapCoordinates(0.0.radians, 0.0.radians), 1.0) public val globe: MapViewPoint = MapViewPoint(GeodeticMapCoordinates(0.0.radians, 0.0.radians), 1f)
} }
} }
@ -30,15 +30,15 @@ public fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
} }
public fun MapViewPoint.zoom( public fun MapViewPoint.zoom(
zoomDelta: Double, zoomDelta: Float,
invariant: GeodeticMapCoordinates = focus, invariant: GeodeticMapCoordinates = focus,
): MapViewPoint = if (invariant == focus) { ): MapViewPoint = if (invariant == focus) {
copy(zoom = (zoom + zoomDelta).coerceIn(2.0, 18.0)) copy(zoom = (zoom + zoomDelta).coerceIn(2f, 18f))
} else { } else {
val difScale = (1 - 2.0.pow(-zoomDelta)) val difScale = (1 - 2f.pow(-zoomDelta))
val newCenter = GeodeticMapCoordinates( val newCenter = GeodeticMapCoordinates(
focus.latitude + (invariant.latitude - focus.latitude) * difScale, focus.latitude + (invariant.latitude - focus.latitude) * difScale,
focus.longitude + (invariant.longitude - focus.longitude) * difScale focus.longitude + (invariant.longitude - focus.longitude) * difScale
) )
MapViewPoint(newCenter, (zoom + zoomDelta).coerceIn(2.0, 18.0)) MapViewPoint(newCenter, (zoom + zoomDelta).coerceIn(2f, 18f))
} }

View File

@ -7,7 +7,7 @@ import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.* import center.sciprog.maps.features.*
import kotlin.math.* import kotlin.math.*
internal class MapState internal constructor( internal class MapViewState internal constructor(
config: ViewConfig<Gmc>, config: ViewConfig<Gmc>,
canvasSize: DpSize, canvasSize: DpSize,
viewPoint: ViewPoint<Gmc>, viewPoint: ViewPoint<Gmc>,
@ -15,36 +15,36 @@ 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 scaleFactor: Double val scaleFactor: Double
get() = WebMercatorProjection.scaleFactor(viewPoint.zoom) get() = WebMercatorProjection.scaleFactor(viewPoint.zoom)
val intZoom: Int get() = floor(zoom).toInt() val intZoom: Int get() = floor(zoom).toInt()
public val centerCoordinates: WebMercatorCoordinates val centerCoordinates: WebMercatorCoordinates
get() = WebMercatorProjection.toMercator(viewPoint.focus, intZoom) get() = WebMercatorProjection.toMercator(viewPoint.focus, intZoom)
internal val tileScale: Double val tileScale: Float
get() = 2.0.pow(viewPoint.zoom - floor(viewPoint.zoom)) get() = 2f.pow(viewPoint.zoom - floor(viewPoint.zoom))
private fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
intZoom,
(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 * Convert screen independent offset to GMC, adjusting for fractional zoom
*/ */
override fun DpOffset.toCoordinates(): Gmc = override fun DpOffset.toCoordinates(): Gmc {
WebMercatorProjection.toGeodetic(toMercator()) val mercator = WebMercatorCoordinates(
intZoom,
(x - canvasSize.width / 2).value / tileScale + centerCoordinates.x,
(y - canvasSize.height / 2).value / tileScale + centerCoordinates.y,
)
return WebMercatorProjection.toGeodetic(mercator)
}
internal fun WebMercatorCoordinates.toOffset(): DpOffset = DpOffset( override fun Gmc.toDpOffset(): DpOffset {
(canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()), val mercator = WebMercatorProjection.toMercator(this, intZoom)
(canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()) return DpOffset(
) (canvasSize.width / 2 + (mercator.x.dp - centerCoordinates.x.dp) * tileScale.toFloat()),
(canvasSize.height / 2 + (mercator.y.dp - centerCoordinates.y.dp) * tileScale.toFloat())
override fun Gmc.toDpOffset(): DpOffset = )
WebMercatorProjection.toMercator(this, intZoom).toOffset() }
override fun Rectangle<Gmc>.toDpRect(): DpRect { override fun Rectangle<Gmc>.toDpRect(): DpRect {
val topLeft = topLeft.toDpOffset() val topLeft = topLeft.toDpOffset()
@ -59,7 +59,7 @@ internal class MapState internal constructor(
canvasSize.height.value / rectangle.latitudeDelta.radians.value canvasSize.height.value / rectangle.latitudeDelta.radians.value
) * PI / tileSize ) * PI / tileSize
) )
return MapViewPoint(rectangle.center, zoom) return MapViewPoint(rectangle.center, zoom.toFloat())
} }
override fun ViewPoint<Gmc>.moveBy(x: Dp, y: Dp): ViewPoint<Gmc> { override fun ViewPoint<Gmc>.moveBy(x: Dp, y: Dp): ViewPoint<Gmc> {
@ -82,6 +82,6 @@ internal fun rememberMapState(
canvasSize: DpSize, canvasSize: DpSize,
viewPoint: ViewPoint<Gmc>, viewPoint: ViewPoint<Gmc>,
tileSize: Int, tileSize: Int,
): MapState = remember { ): MapViewState = remember {
MapState(config, canvasSize, viewPoint, tileSize) MapViewState(config, canvasSize, viewPoint, tileSize)
} }

View File

@ -84,7 +84,7 @@ public fun FeaturesState<Gmc>.arc(
id, id,
ArcFeature( ArcFeature(
coordinateSpace, coordinateSpace,
oval = buildRectangle(coordinatesOf(center), radius, radius), oval = Rectangle(coordinatesOf(center), radius, radius),
startAngle = startAngle.radians.toFloat(), startAngle = startAngle.radians.toFloat(),
arcLength = arcLength.radians.toFloat(), arcLength = arcLength.radians.toFloat(),
zoomRange = zoomRange, zoomRange = zoomRange,

View File

@ -8,14 +8,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect 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.toArgb
import androidx.compose.ui.graphics.toComposeImageBitmap 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.Gmc import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.features.* import center.sciprog.maps.features.FeaturesState
import center.sciprog.maps.features.PainterFeature
import center.sciprog.maps.features.ViewConfig
import center.sciprog.maps.features.drawFeature
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import mu.KotlinLogging import mu.KotlinLogging
@ -53,6 +56,7 @@ public actual fun MapView(
initialViewPoint, initialViewPoint,
mapTileProvider.tileSize mapTileProvider.tileSize
) )
with(state) { with(state) {
val mapTiles = remember(mapTileProvider) { mutableStateListOf<MapTile>() } val mapTiles = remember(mapTileProvider) { mutableStateListOf<MapTile>() }
@ -96,8 +100,8 @@ public actual fun MapView(
} }
} }
val painterCache: Map<VectorImageFeature<Gmc>, VectorPainter> = key(featuresState) { val painterCache: Map<PainterFeature<Gmc>, Painter> = key(featuresState) {
featuresState.features.values.filterIsInstance<VectorImageFeature<Gmc>>().associateWith { it.painter() } featuresState.features.values.filterIsInstance<PainterFeature<Gmc>>().associateWith { it.painter() }
} }
Canvas(modifier = modifier.mapControls(state).fillMaxSize()) { Canvas(modifier = modifier.mapControls(state).fillMaxSize()) {

View File

@ -14,10 +14,10 @@ public object WebMercatorProjection {
/** /**
* Compute radians to projection coordinates ratio for given [zoom] factor * Compute radians to projection coordinates ratio for given [zoom] factor
*/ */
public fun scaleFactor(zoom: Double): Double = 256.0 / 2 / PI * 2.0.pow(zoom) public fun scaleFactor(zoom: Float): Double = 256.0 / 2 / PI * 2f.pow(zoom)
public fun toGeodetic(mercator: WebMercatorCoordinates): GeodeticMapCoordinates { public fun toGeodetic(mercator: WebMercatorCoordinates): GeodeticMapCoordinates {
val scaleFactor = scaleFactor(mercator.zoom.toDouble()) val scaleFactor = scaleFactor(mercator.zoom.toFloat())
val longitude = mercator.x / scaleFactor - PI val longitude = mercator.x / scaleFactor - PI
val latitude = (atan(exp(PI - mercator.y / scaleFactor)) - PI / 4) * 2 val latitude = (atan(exp(PI - mercator.y / scaleFactor)) - PI / 4) * 2
return GeodeticMapCoordinates.ofRadians(latitude, longitude) return GeodeticMapCoordinates.ofRadians(latitude, longitude)
@ -29,7 +29,7 @@ public object WebMercatorProjection {
public fun toMercator(gmc: GeodeticMapCoordinates, zoom: Int): WebMercatorCoordinates { public fun toMercator(gmc: GeodeticMapCoordinates, zoom: Int): 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.toFloat())
return WebMercatorCoordinates( return WebMercatorCoordinates(
zoom = zoom, zoom = zoom,
x = scaleFactor * (gmc.longitude.radians.value + PI), x = scaleFactor * (gmc.longitude.radians.value + PI),

View File

@ -29,17 +29,17 @@ public interface CoordinateSpace<T : Any> {
/** /**
* Build a rectangle of visual size [size] * Build a rectangle of visual size [size]
*/ */
public fun Rectangle(center: T, zoom: Double, size: DpSize): Rectangle<T> public fun Rectangle(center: T, zoom: Float, size: DpSize): Rectangle<T>
/** /**
* Create a [ViewPoint] associated with this coordinate space. * Create a [ViewPoint] associated with this coordinate space.
*/ */
public fun ViewPoint(center: T, zoom: Double): ViewPoint<T> public fun ViewPoint(center: T, zoom: Float): ViewPoint<T>
public fun ViewPoint<T>.moveBy(delta: T): ViewPoint<T> public fun ViewPoint<T>.moveBy(delta: T): ViewPoint<T>
public fun ViewPoint<T>.zoomBy( public fun ViewPoint<T>.zoomBy(
zoomDelta: Double, zoomDelta: Float,
invariant: T = focus, invariant: T = focus,
): ViewPoint<T> ): ViewPoint<T>

View File

@ -25,7 +25,7 @@ public abstract class CoordinateViewState<T : Any>(
viewPointState.value = value viewPointState.value = value
} }
public val zoom: Double get() = viewPoint.zoom public val zoom: Float get() = viewPoint.zoom
public abstract fun DpOffset.toCoordinates(): T public abstract fun DpOffset.toCoordinates(): T

View File

@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawStyle import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.graphics.vector.VectorPainter
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
@ -14,24 +15,30 @@ 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
public typealias DoubleRange = ClosedFloatingPointRange<Double> public typealias DoubleRange = FloatRange
public typealias FloatRange = ClosedFloatingPointRange<Float>
public interface Feature<T : Any> { public interface Feature<T : Any> {
public interface Attribute<T> public interface Attribute<T>
public val space: CoordinateSpace<T> public val space: CoordinateSpace<T>
public val zoomRange: DoubleRange public val zoomRange: FloatRange
public var attributes: AttributeMap public var attributes: AttributeMap
public fun getBoundingBox(zoom: Double): Rectangle<T>? public fun getBoundingBox(zoom: Float): Rectangle<T>?
public companion object { public companion object {
public val defaultZoomRange: ClosedFloatingPointRange<Double> = 1.0..Double.POSITIVE_INFINITY public val defaultZoomRange: FloatRange = 0f..Float.POSITIVE_INFINITY
} }
} }
public interface PainterFeature<T:Any>: Feature<T> {
@Composable
public fun painter(): Painter
}
public interface SelectableFeature<T : Any> : Feature<T> { public interface SelectableFeature<T : Any> : Feature<T> {
public operator fun contains(point: ViewPoint<T>): Boolean = getBoundingBox(point.zoom)?.let { public operator fun contains(point: ViewPoint<T>): Boolean = getBoundingBox(point.zoom)?.let {
point.focus in it point.focus in it
@ -44,7 +51,7 @@ public interface DraggableFeature<T : Any> : SelectableFeature<T> {
public fun <T : Any> Iterable<Feature<T>>.computeBoundingBox( public fun <T : Any> Iterable<Feature<T>>.computeBoundingBox(
space: CoordinateSpace<T>, space: CoordinateSpace<T>,
zoom: Double, zoom: Float,
): Rectangle<T>? = with(space) { ): Rectangle<T>? = with(space) {
mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles() mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles()
} }
@ -59,11 +66,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: Double) -> Feature<T>, public val selector: (zoom: Float) -> Feature<T>,
) : Feature<T> { ) : Feature<T> {
override val zoomRange: ClosedFloatingPointRange<Double> get() = defaultZoomRange override val zoomRange: FloatRange get() = defaultZoomRange
override fun getBoundingBox(zoom: Double): Rectangle<T>? = selector(zoom).getBoundingBox(zoom) override fun getBoundingBox(zoom: Float): Rectangle<T>? = selector(zoom).getBoundingBox(zoom)
} }
public class PathFeature<T : Any>( public class PathFeature<T : Any>(
@ -73,7 +80,7 @@ public class PathFeature<T : Any>(
public val brush: Brush, public val brush: Brush,
public val style: DrawStyle = Fill, public val style: DrawStyle = Fill,
public val targetRect: Rect = path.getBounds(), public val targetRect: Rect = path.getBounds(),
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> { ) : DraggableFeature<T> {
override fun withCoordinates(newCoordinates: T): Feature<T> = with(space) { override fun withCoordinates(newCoordinates: T): Feature<T> = with(space) {
@ -88,20 +95,20 @@ public class PathFeature<T : Any>(
) )
} }
override fun getBoundingBox(zoom: Double): Rectangle<T> = rectangle override fun getBoundingBox(zoom: Float): Rectangle<T> = rectangle
} }
public class PointsFeature<T : Any>( public class PointsFeature<T : Any>(
override val space: CoordinateSpace<T>, override val space: CoordinateSpace<T>,
public val points: List<T>, public val points: List<T>,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
public val stroke: Float = 2f, public val stroke: Float = 2f,
public val color: Color = Color.Red, public val color: Color = Color.Red,
public val pointMode: PointMode = PointMode.Points, public val pointMode: PointMode = PointMode.Points,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
) : Feature<T> { ) : Feature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T>? = with(space) { override fun getBoundingBox(zoom: Float): Rectangle<T>? = with(space) {
points.wrapPoints() points.wrapPoints()
} }
} }
@ -109,12 +116,12 @@ public class PointsFeature<T : Any>(
public data class CircleFeature<T : Any>( public data class CircleFeature<T : Any>(
override val space: CoordinateSpace<T>, override val space: CoordinateSpace<T>,
public val center: T, public val center: T,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
public val size: Dp = 5.dp, public val size: Dp = 5.dp,
public val color: Color = Color.Red, public val color: Color = Color.Red,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> { ) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = override fun getBoundingBox(zoom: Float): Rectangle<T> =
space.Rectangle(center, zoom, DpSize(size, size)) space.Rectangle(center, zoom, DpSize(size, size))
override fun withCoordinates(newCoordinates: T): Feature<T> = override fun withCoordinates(newCoordinates: T): Feature<T> =
@ -124,12 +131,12 @@ public data class CircleFeature<T : Any>(
public class RectangleFeature<T : Any>( public class RectangleFeature<T : Any>(
override val space: CoordinateSpace<T>, override val space: CoordinateSpace<T>,
public val center: T, public val center: T,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
public val size: DpSize = DpSize(5.dp, 5.dp), public val size: DpSize = DpSize(5.dp, 5.dp),
public val color: Color = Color.Red, public val color: Color = Color.Red,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> { ) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = override fun getBoundingBox(zoom: Float): Rectangle<T> =
space.Rectangle(center, zoom, size) space.Rectangle(center, zoom, size)
override fun withCoordinates(newCoordinates: T): Feature<T> = override fun withCoordinates(newCoordinates: T): Feature<T> =
@ -140,11 +147,11 @@ public class LineFeature<T : Any>(
override val space: CoordinateSpace<T>, override val space: CoordinateSpace<T>,
public val a: T, public val a: T,
public val b: T, public val b: T,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
public val color: Color = Color.Red, public val color: Color = Color.Red,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
) : SelectableFeature<T> { ) : SelectableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = override fun getBoundingBox(zoom: Float): Rectangle<T> =
space.Rectangle(a, b) space.Rectangle(a, b)
override fun contains(point: ViewPoint<T>): Boolean { override fun contains(point: ViewPoint<T>): Boolean {
@ -161,26 +168,25 @@ public class ArcFeature<T : Any>(
public val oval: Rectangle<T>, public val oval: Rectangle<T>,
public val startAngle: Float, public val startAngle: Float,
public val arcLength: Float, public val arcLength: Float,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
public val color: Color = Color.Red, public val color: Color = Color.Red,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> { ) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = oval override fun getBoundingBox(zoom: Float): Rectangle<T> = oval
override fun withCoordinates(newCoordinates: T): Feature<T> = with(space) { override fun withCoordinates(newCoordinates: T): Feature<T> = with(space) {
ArcFeature(space, oval.withCenter(newCoordinates), startAngle, arcLength, zoomRange, color, attributes) ArcFeature(space, oval.withCenter(newCoordinates), startAngle, arcLength, zoomRange, color, attributes)
} }
} }
public data class DrawFeature<T : Any>( public data class DrawFeature<T : Any>(
override val space: CoordinateSpace<T>, override val space: CoordinateSpace<T>,
public val position: T, public val position: T,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
public val drawFeature: DrawScope.() -> Unit, public val drawFeature: DrawScope.() -> Unit,
) : DraggableFeature<T> { ) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.Rectangle(position, position) override fun getBoundingBox(zoom: Float): Rectangle<T> = space.Rectangle(position, position)
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates) override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
} }
@ -190,10 +196,10 @@ public data class BitmapImageFeature<T : Any>(
public val position: T, public val position: T,
public val size: DpSize, public val size: DpSize,
public val image: ImageBitmap, public val image: ImageBitmap,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> { ) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.Rectangle(position, zoom, size) override fun getBoundingBox(zoom: Float): Rectangle<T> = space.Rectangle(position, zoom, size)
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates) override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
} }
@ -203,27 +209,46 @@ public data class VectorImageFeature<T : Any>(
public val position: T, public val position: T,
public val size: DpSize, public val size: DpSize,
public val image: ImageVector, public val image: ImageVector,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> { ) : DraggableFeature<T>, PainterFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.Rectangle(position, zoom, size) override fun getBoundingBox(zoom: Float): Rectangle<T> = space.Rectangle(position, zoom, size)
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates) override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
@Composable @Composable
public fun painter(): VectorPainter = rememberVectorPainter(image) override fun painter(): VectorPainter = rememberVectorPainter(image)
} }
/**
* A background image that is bound to coordinates and is scaled together with them
*
* @param rectangle the size of background in scheme size units. The screen units to scheme units ratio equals scale.
*/
public class ScalableImageFeature<T: Any>(
override val space: CoordinateSpace<T>,
public val rectangle: Rectangle<T>,
override val zoomRange: FloatRange = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
public val painter: @Composable () -> Painter,
) : Feature<T>, PainterFeature<T>{
@Composable
override fun painter(): Painter = painter.invoke()
override fun getBoundingBox(zoom: Float): Rectangle<T> =rectangle
}
/** /**
* A group of other features * A group of other features
*/ */
public class FeatureGroup<T : Any>( public class FeatureGroup<T : Any>(
override val space: CoordinateSpace<T>, override val space: CoordinateSpace<T>,
public val children: Map<FeatureId<*>, Feature<T>>, public val children: Map<FeatureId<*>, Feature<T>>,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
) : Feature<T> { ) : Feature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T>? = with(space) { override fun getBoundingBox(zoom: Float): Rectangle<T>? = with(space) {
children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles() children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles()
} }
} }
@ -232,12 +257,12 @@ public class TextFeature<T : Any>(
override val space: CoordinateSpace<T>, override val space: CoordinateSpace<T>,
public val position: T, public val position: T,
public val text: String, public val text: String,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, override val zoomRange: FloatRange = defaultZoomRange,
public val color: Color = Color.Black, public val color: Color = Color.Black,
override var attributes: AttributeMap = AttributeMap(), override var attributes: AttributeMap = AttributeMap(),
public val fontConfig: FeatureFont.() -> Unit, public val fontConfig: FeatureFont.() -> Unit,
) : DraggableFeature<T> { ) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.Rectangle(position, position) override fun getBoundingBox(zoom: Float): Rectangle<T> = space.Rectangle(position, position)
override fun withCoordinates(newCoordinates: T): Feature<T> = override fun withCoordinates(newCoordinates: T): Feature<T> =
TextFeature(space, newCoordinates, text, zoomRange, color, attributes, fontConfig) TextFeature(space, newCoordinates, text, zoomRange, color, attributes, fontConfig)

View File

@ -5,6 +5,8 @@ import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
@ -133,7 +135,7 @@ public class FeaturesState<T : Any>(public val coordinateSpace: CoordinateSpace<
public fun <T : Any> FeaturesState<T>.circle( public fun <T : Any> FeaturesState<T>.circle(
center: T, center: T,
zoomRange: DoubleRange = defaultZoomRange, zoomRange: FloatRange = defaultZoomRange,
size: Dp = 5.dp, size: Dp = 5.dp,
color: Color = Color.Red, color: Color = Color.Red,
id: String? = null, id: String? = null,
@ -143,7 +145,7 @@ public fun <T : Any> FeaturesState<T>.circle(
public fun <T : Any> FeaturesState<T>.rectangle( public fun <T : Any> FeaturesState<T>.rectangle(
centerCoordinates: T, centerCoordinates: T,
zoomRange: DoubleRange = defaultZoomRange, zoomRange: FloatRange = defaultZoomRange,
size: DpSize = DpSize(5.dp, 5.dp), size: DpSize = DpSize(5.dp, 5.dp),
color: Color = Color.Red, color: Color = Color.Red,
id: String? = null, id: String? = null,
@ -151,10 +153,20 @@ public fun <T : Any> FeaturesState<T>.rectangle(
id, RectangleFeature(coordinateSpace, centerCoordinates, zoomRange, size, color) id, RectangleFeature(coordinateSpace, centerCoordinates, zoomRange, size, color)
) )
public fun <T : Any> FeaturesState<T>.draw(
position: T,
zoomRange: FloatRange = defaultZoomRange,
id: String? = null,
draw: DrawScope.() -> Unit,
): FeatureId<DrawFeature<T>> = feature(
id,
DrawFeature(coordinateSpace, position, zoomRange, drawFeature = draw)
)
public fun <T : Any> FeaturesState<T>.line( public fun <T : Any> FeaturesState<T>.line(
aCoordinates: T, aCoordinates: T,
bCoordinates: T, bCoordinates: T,
zoomRange: DoubleRange = defaultZoomRange, zoomRange: FloatRange = defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
id: String? = null, id: String? = null,
): FeatureId<LineFeature<T>> = feature( ): FeatureId<LineFeature<T>> = feature(
@ -166,7 +178,7 @@ public fun <T : Any> FeaturesState<T>.arc(
oval: Rectangle<T>, oval: Rectangle<T>,
startAngle: Float, startAngle: Float,
arcLength: Float, arcLength: Float,
zoomRange: DoubleRange = defaultZoomRange, zoomRange: FloatRange = defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
id: String? = null, id: String? = null,
): FeatureId<ArcFeature<T>> = feature( ): FeatureId<ArcFeature<T>> = feature(
@ -176,7 +188,7 @@ public fun <T : Any> FeaturesState<T>.arc(
public fun <T : Any> FeaturesState<T>.points( public fun <T : Any> FeaturesState<T>.points(
points: List<T>, points: List<T>,
zoomRange: DoubleRange = defaultZoomRange, zoomRange: FloatRange = defaultZoomRange,
stroke: Float = 2f, stroke: Float = 2f,
color: Color = Color.Red, color: Color = Color.Red,
pointMode: PointMode = PointMode.Points, pointMode: PointMode = PointMode.Points,
@ -187,7 +199,8 @@ public fun <T : Any> FeaturesState<T>.points(
public fun <T : Any> FeaturesState<T>.image( public fun <T : Any> FeaturesState<T>.image(
position: T, position: T,
image: ImageVector, image: ImageVector,
zoomRange: DoubleRange = defaultZoomRange, zoomRange: FloatRange = defaultZoomRange,
size: DpSize = DpSize(image.defaultWidth, image.defaultHeight),
id: String? = null, id: String? = null,
): FeatureId<VectorImageFeature<T>> = ): FeatureId<VectorImageFeature<T>> =
feature( feature(
@ -195,14 +208,14 @@ public fun <T : Any> FeaturesState<T>.image(
VectorImageFeature( VectorImageFeature(
coordinateSpace, coordinateSpace,
position, position,
DpSize(image.defaultWidth, image.defaultHeight), size,
image, image,
zoomRange zoomRange
) )
) )
public fun <T : Any> FeaturesState<T>.group( public fun <T : Any> FeaturesState<T>.group(
zoomRange: DoubleRange = defaultZoomRange, zoomRange: FloatRange = defaultZoomRange,
id: String? = null, id: String? = null,
builder: FeaturesState<T>.() -> Unit, builder: FeaturesState<T>.() -> Unit,
): FeatureId<FeatureGroup<T>> { ): FeatureId<FeatureGroup<T>> {
@ -211,10 +224,20 @@ public fun <T : Any> FeaturesState<T>.group(
return feature(id, feature) return feature(id, feature)
} }
public fun <T : Any> FeaturesState<T>.scalableImage(
box: Rectangle<T>,
zoomRange: FloatRange = defaultZoomRange,
id: String? = null,
painter: @Composable () -> Painter,
): FeatureId<ScalableImageFeature<T>> = feature(
id,
ScalableImageFeature<T>(coordinateSpace, box, zoomRange, painter = painter)
)
public fun <T : Any> FeaturesState<T>.text( public fun <T : Any> FeaturesState<T>.text(
position: T, position: T,
text: String, text: String,
zoomRange: DoubleRange = defaultZoomRange, zoomRange: FloatRange = defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
font: FeatureFont.() -> Unit = { size = 16f }, font: FeatureFont.() -> Unit = { size = 16f },
id: String? = null, id: String? = null,

View File

@ -4,7 +4,7 @@ import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
public data class ViewConfig<T : Any>( public data class ViewConfig<T : Any>(
val zoomSpeed: Double = 1.0 / 3.0, val zoomSpeed: Float = 1f / 3f,
val onClick: ViewPoint<T>.(PointerEvent) -> Unit = {}, val onClick: ViewPoint<T>.(PointerEvent) -> Unit = {},
val dragHandle: DragHandle<T> = DragHandle.bypass(), val dragHandle: DragHandle<T> = DragHandle.bypass(),
val onViewChange: ViewPoint<T>.() -> Unit = {}, val onViewChange: ViewPoint<T>.() -> Unit = {},

View File

@ -5,5 +5,5 @@ package center.sciprog.maps.features
*/ */
public interface ViewPoint<T: Any> { public interface ViewPoint<T: Any> {
public val focus: T public val focus: T
public val zoom: Double public val zoom: Float
} }

View File

@ -1,4 +1,4 @@
package center.sciprog.maps.features package center.sciprog.maps.compose
import androidx.compose.foundation.gestures.drag import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.foundation.gestures.forEachGesture
@ -8,6 +8,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.* import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import center.sciprog.maps.features.CoordinateViewState
import center.sciprog.maps.features.bottomRight
import center.sciprog.maps.features.topLeft
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -24,7 +27,7 @@ public fun <T : Any> Modifier.mapControls(
val event: PointerEvent = awaitPointerEvent() val event: PointerEvent = awaitPointerEvent()
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
@ -95,7 +98,7 @@ public fun <T : Any> Modifier.mapControls(
//compute invariant point of translation //compute invariant point of translation
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toCoordinates() val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toCoordinates()
viewPoint = with(space) { viewPoint = with(space) {
viewPoint.zoomBy(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant) viewPoint.zoomBy(-change.scrollDelta.y * config.zoomSpeed, invariant)
} }
} }
} }

View File

@ -8,8 +8,8 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.VectorPainter
import org.jetbrains.skia.Font import org.jetbrains.skia.Font
import org.jetbrains.skia.Paint import org.jetbrains.skia.Paint
import kotlin.math.PI import kotlin.math.PI
@ -22,7 +22,7 @@ internal fun Color.toPaint(): Paint = Paint().apply {
public fun <T : Any> DrawScope.drawFeature( public fun <T : Any> DrawScope.drawFeature(
state: CoordinateViewState<T>, state: CoordinateViewState<T>,
painterCache: Map<VectorImageFeature<T>, VectorPainter>, painterCache: Map<PainterFeature<T>, Painter>,
feature: Feature<T>, feature: Feature<T>,
): Unit = with(state) { ): Unit = with(state) {
fun T.toOffset(): Offset = toOffset(this@drawFeature) fun T.toOffset(): Offset = toOffset(this@drawFeature)
@ -117,6 +117,17 @@ public fun <T : Any> DrawScope.drawFeature(
) )
} }
is ScalableImageFeature -> {
val rect = feature.rectangle.toDpRect().toRect()
val offset = rect.topLeft
translate(offset.x, offset.y) {
with(painterCache[feature]!!) {
draw(rect.size)
}
}
}
else -> { else -> {
//logger.error { "Unrecognized feature type: ${feature::class}" } //logger.error { "Unrecognized feature type: ${feature::class}" }
} }

View File

@ -16,6 +16,7 @@ kotlin {
sourceSets { sourceSets {
commonMain { commonMain {
dependencies { dependencies {
api(projects.mapsKtFeatures)
api("io.github.microutils:kotlin-logging:2.1.23") api("io.github.microutils:kotlin-logging:2.1.23")
api(compose.foundation) api(compose.foundation)
} }

View File

@ -1,61 +0,0 @@
package center.sciprog.maps.scheme
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
data class SchemeCoordinates(val x: Float, val y: Float)
data class SchemeRectangle(
val a: SchemeCoordinates,
val b: SchemeCoordinates,
) {
companion object {
fun square(center: SchemeCoordinates, height: Float, width: Float): SchemeRectangle = SchemeRectangle(
SchemeCoordinates(center.x - width / 2, center.y + height / 2),
SchemeCoordinates(center.x + width / 2, center.y - height / 2),
)
}
}
val SchemeRectangle.top get() = max(a.y, b.y)
val SchemeRectangle.bottom get() = min(a.y, b.y)
val SchemeRectangle.right get() = max(a.x, b.x)
val SchemeRectangle.left get() = min(a.x, b.x)
val SchemeRectangle.width get() = abs(a.x - b.x)
val SchemeRectangle.height get() = abs(a.y - b.y)
val SchemeRectangle.center get() = SchemeCoordinates((a.x + b.x) / 2, (a.y + b.y) / 2)
public val SchemeRectangle.leftTop: SchemeCoordinates get() = SchemeCoordinates(left, top)
public val SchemeRectangle.rightBottom: SchemeCoordinates get() = SchemeCoordinates(right, bottom)
fun Collection<SchemeRectangle>.wrapAll(): SchemeRectangle? {
if (isEmpty()) return null
val minX = minOf { it.left }
val maxX = maxOf { it.right }
val minY = minOf { it.bottom }
val maxY = maxOf { it.top }
return SchemeRectangle(
SchemeCoordinates(minX, minY),
SchemeCoordinates(maxX, maxY)
)
}
internal val defaultCanvasSize = DpSize(512.dp, 512.dp)
public fun SchemeRectangle.computeViewPoint(
canvasSize: DpSize = defaultCanvasSize,
): SchemeViewPoint {
val scale = min(
canvasSize.width.value / width,
canvasSize.height.value / height
)
return SchemeViewPoint(center, scale)
}

View File

@ -1,136 +0,0 @@
package center.sciprog.maps.scheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.scheme.SchemeFeature.Companion.defaultScaleRange
internal typealias FloatRange = ClosedFloatingPointRange<Float>
sealed class SchemeFeature(val scaleRange: FloatRange) {
abstract fun getBoundingBox(scale: Float): SchemeRectangle?
companion object {
val defaultScaleRange = 0f..Float.MAX_VALUE
}
}
fun Iterable<SchemeFeature>.computeBoundingBox(scale: Float): SchemeRectangle? =
mapNotNull { it.getBoundingBox(scale) }.wrapAll()
internal fun Pair<Number, Number>.toCoordinates() = SchemeCoordinates(first.toFloat(), second.toFloat())
interface PainterFeature {
val painter: @Composable () -> Painter
}
/**
* A background image that is bound to scheme coordinates and is scaled together with them
*
* @param rectangle the size of background in scheme size units. The screen units to scheme units ratio equals scale.
*/
class SchemeBackgroundFeature(
val rectangle: SchemeRectangle,
scaleRange: FloatRange = defaultScaleRange,
override val painter: @Composable () -> Painter,
) : SchemeFeature(scaleRange), PainterFeature {
override fun getBoundingBox(scale: Float): SchemeRectangle = rectangle
}
class SchemeFeatureSelector(val selector: (scale: Float) -> SchemeFeature) : SchemeFeature(defaultScaleRange) {
override fun getBoundingBox(scale: Float): SchemeRectangle? = selector(scale).getBoundingBox(scale)
}
class SchemeDrawFeature(
val position: SchemeCoordinates,
scaleRange: FloatRange = defaultScaleRange,
val drawFeature: DrawScope.() -> Unit,
) : SchemeFeature(scaleRange) {
override fun getBoundingBox(scale: Float): SchemeRectangle = SchemeRectangle(position, position)
}
class SchemeCircleFeature(
val center: SchemeCoordinates,
scaleRange: FloatRange = defaultScaleRange,
val size: Float = 5f,
val color: Color = Color.Red,
) : SchemeFeature(scaleRange) {
override fun getBoundingBox(scale: Float): SchemeRectangle = SchemeRectangle(center, center)
}
class SchemeLineFeature(
val a: SchemeCoordinates,
val b: SchemeCoordinates,
scaleRange: FloatRange = defaultScaleRange,
val color: Color = Color.Red,
) : SchemeFeature(scaleRange) {
override fun getBoundingBox(scale: Float): SchemeRectangle = SchemeRectangle(a, b)
}
/**
* @param startAngle the angle in radians from parallel downwards for the start of the arc
* @param arcLength arc length in radians
*/
public class SchemeArcFeature(
public val oval: SchemeRectangle,
public val startAngle: Float,
public val arcLength: Float,
scaleRange: FloatRange = defaultScaleRange,
public val color: Color = Color.Red,
) : SchemeFeature(scaleRange) {
override fun getBoundingBox(scale: Float): SchemeRectangle = oval
}
class SchemeTextFeature(
val position: SchemeCoordinates,
val text: String,
scaleRange: FloatRange = defaultScaleRange,
val color: Color = Color.Red,
) : SchemeFeature(scaleRange) {
override fun getBoundingBox(scale: Float): SchemeRectangle = SchemeRectangle(position, position)
}
class SchemeBitmapFeature(
val position: SchemeCoordinates,
val image: ImageBitmap,
val size: IntSize = IntSize(15, 15),
scaleRange: FloatRange = defaultScaleRange,
) : SchemeFeature(scaleRange) {
override fun getBoundingBox(scale: Float): SchemeRectangle = SchemeRectangle(position, position)
}
class SchemeImageFeature(
val position: SchemeCoordinates,
val size: DpSize,
scaleRange: FloatRange = defaultScaleRange,
override val painter: @Composable () -> Painter,
) : SchemeFeature(scaleRange), PainterFeature {
override fun getBoundingBox(scale: Float): SchemeRectangle = SchemeRectangle(position, position)
}
fun SchemeImageFeature(
position: SchemeCoordinates,
image: ImageVector,
size: DpSize = DpSize(20.dp, 20.dp),
scaleRange: FloatRange = defaultScaleRange,
): SchemeImageFeature = SchemeImageFeature(position, size, scaleRange) { rememberVectorPainter(image) }
/**
* A group of other features
*/
class SchemeFeatureGroup(
val children: Map<FeatureId, SchemeFeature>,
scaleRange: FloatRange = defaultScaleRange,
) : SchemeFeature(scaleRange) {
override fun getBoundingBox(scale: Float): SchemeRectangle? =
children.values.mapNotNull { it.getBoundingBox(scale) }.wrapAll()
}

View File

@ -1,206 +1,85 @@
package center.sciprog.maps.scheme package center.sciprog.maps.scheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
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.scheme.SchemeFeature.Companion.defaultScaleRange import center.sciprog.maps.features.*
typealias FeatureId = String internal fun Pair<Number, Number>.toCoordinates(): XY = XY(first.toFloat(), second.toFloat())
fun FeaturesState<XY>.background(
public class SchemeFeaturesState internal constructor(
private val features: MutableMap<FeatureId, SchemeFeature>,
private val attributes: MutableMap<FeatureId, SnapshotStateMap<Attribute<out Any?>, in Any?>>,
) {
public interface Attribute<T>
public fun features(): Map<FeatureId, SchemeFeature> = features
private fun generateID(feature: SchemeFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
public fun addFeature(id: FeatureId?, feature: SchemeFeature): FeatureId {
val safeId = id ?: generateID(feature)
features[id ?: generateID(feature)] = feature
return safeId
}
public fun <T> setAttribute(id: FeatureId, key: Attribute<T>, value: T) {
attributes.getOrPut(id) { mutableStateMapOf() }[key] = value
}
@Suppress("UNCHECKED_CAST")
public fun <T> getAttribute(id: FeatureId, key: Attribute<T>): T? =
attributes[id]?.get(key)?.let { it as T }
@Suppress("UNCHECKED_CAST")
public fun <T> findAllWithAttribute(key: Attribute<T>, condition: (T) -> Boolean): Set<FeatureId> {
return attributes.filterValues {
condition(it[key] as T)
}.keys
}
public companion object {
/**
* Build, but do not remember map feature state
*/
public fun build(
builder: SchemeFeaturesState.() -> Unit = {},
): SchemeFeaturesState = SchemeFeaturesState(
mutableStateMapOf(),
mutableStateMapOf()
).apply(builder)
/**
* Build and remember map feature state
*/
@Composable
public fun remember(
builder: SchemeFeaturesState.() -> Unit = {},
): SchemeFeaturesState = androidx.compose.runtime.remember(builder) {
build(builder)
}
}
}
fun SchemeFeaturesState.background(
box: SchemeRectangle,
id: FeatureId? = null,
painter: @Composable () -> Painter,
): FeatureId = addFeature(
id,
SchemeBackgroundFeature(box, painter = painter)
)
fun SchemeFeaturesState.background(
width: Float, width: Float,
height: Float, height: Float,
offset: SchemeCoordinates = SchemeCoordinates(0f, 0f), offset: XY = XY(0f, 0f),
id: FeatureId? = null, id: String? = null,
painter: @Composable () -> Painter, painter: @Composable () -> Painter,
): FeatureId { ): FeatureId<ScalableImageFeature<XY>> {
val box = SchemeRectangle( val box = XYRectangle(
offset, offset,
SchemeCoordinates(width + offset.x, height + offset.y) XY(width + offset.x, height + offset.y)
) )
return background(box, id, painter = painter) return scalableImage(box, id = id, painter = painter)
} }
fun SchemeFeaturesState.circle( fun FeaturesState<XY>.circle(
center: SchemeCoordinates,
scaleRange: FloatRange = defaultScaleRange,
size: Float = 5f,
color: Color = Color.Red,
id: FeatureId? = null,
) = addFeature(
id, SchemeCircleFeature(center, scaleRange, size, color)
)
fun SchemeFeaturesState.circle(
centerCoordinates: Pair<Number, Number>, centerCoordinates: Pair<Number, Number>,
scaleRange: FloatRange = defaultScaleRange, zoomRange: FloatRange = Feature.defaultZoomRange,
size: Float = 5f, size: Dp = 5.dp,
color: Color = Color.Red, color: Color = Color.Red,
id: FeatureId? = null, id: String? = null,
) = addFeature( ): FeatureId<CircleFeature<XY>> = circle(centerCoordinates.toCoordinates(), zoomRange, size, color, id = id)
id, SchemeCircleFeature(centerCoordinates.toCoordinates(), scaleRange, size, color)
)
fun SchemeFeaturesState.draw( fun FeaturesState<XY>.draw(
position: Pair<Number, Number>, position: Pair<Number, Number>,
scaleRange: FloatRange = defaultScaleRange, zoomRange: FloatRange = Feature.defaultZoomRange,
id: FeatureId? = null, id: String? = null,
drawFeature: DrawScope.() -> Unit, draw: DrawScope.() -> Unit,
) = addFeature(id, SchemeDrawFeature(position.toCoordinates(), scaleRange, drawFeature)) ): FeatureId<DrawFeature<XY>> = draw(position.toCoordinates(), zoomRange = zoomRange, id = id, draw = draw)
fun SchemeFeaturesState.line( fun FeaturesState<XY>.line(
aCoordinates: SchemeCoordinates,
bCoordinates: SchemeCoordinates,
scaleRange: FloatRange = defaultScaleRange,
color: Color = Color.Red,
id: FeatureId? = null,
): FeatureId = addFeature(
id,
SchemeLineFeature(aCoordinates, bCoordinates, scaleRange, color)
)
fun SchemeFeaturesState.line(
aCoordinates: Pair<Number, Number>, aCoordinates: Pair<Number, Number>,
bCoordinates: Pair<Number, Number>, bCoordinates: Pair<Number, Number>,
scaleRange: FloatRange = defaultScaleRange, scaleRange: FloatRange = Feature.defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
id: FeatureId? = null, id: String? = null,
) = line(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), scaleRange, color, id) ): FeatureId<LineFeature<XY>> = line(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), scaleRange, color, id)
public fun SchemeFeaturesState.arc(
oval: SchemeRectangle,
startAngle: Float,
arcLength: Float,
scaleRange: FloatRange = defaultScaleRange,
color: Color = Color.Red,
id: FeatureId? = null,
): FeatureId = addFeature(
id,
SchemeArcFeature(oval, startAngle, arcLength, scaleRange, color)
)
public fun SchemeFeaturesState.arc( public fun FeaturesState<XY>.arc(
center: Pair<Double, Double>, center: Pair<Double, Double>,
radius: Float, radius: Float,
startAngle: Float, startAngle: Float,
arcLength: Float, arcLength: Float,
scaleRange: FloatRange = defaultScaleRange, zoomRange: FloatRange = Feature.defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
id: FeatureId? = null, id: String? = null,
): FeatureId = addFeature( ): FeatureId<ArcFeature<XY>> = arc(
id, oval = XYCoordinateSpace.Rectangle(center.toCoordinates(), radius, radius),
SchemeArcFeature( startAngle = startAngle,
oval = SchemeRectangle.square(center.toCoordinates(), radius, radius), arcLength = arcLength,
startAngle = startAngle, zoomRange = zoomRange,
arcLength = arcLength, color = color
scaleRange = scaleRange,
color = color
)
) )
fun SchemeFeaturesState.text( fun FeaturesState<XY>.image(
position: SchemeCoordinates,
text: String,
scaleRange: FloatRange = defaultScaleRange,
color: Color = Color.Red,
id: FeatureId? = null,
) = addFeature(id, SchemeTextFeature(position, text, scaleRange, color))
fun SchemeFeaturesState.text(
position: Pair<Number, Number>,
text: String,
scaleRange: FloatRange = defaultScaleRange,
color: Color = Color.Red,
id: FeatureId? = null,
) = addFeature(id, SchemeTextFeature(position.toCoordinates(), text, scaleRange, color))
fun SchemeFeaturesState.image(
position: Pair<Number, Number>, position: Pair<Number, Number>,
image: ImageVector, image: ImageVector,
size: DpSize = DpSize(20.dp, 20.dp), size: DpSize = DpSize(image.defaultWidth, image.defaultHeight),
scaleRange: FloatRange = defaultScaleRange, zoomRange: FloatRange = Feature.defaultZoomRange,
id: FeatureId? = null, id: String? = null,
) = addFeature(id, SchemeImageFeature(position.toCoordinates(), image, size, scaleRange)) ): FeatureId<VectorImageFeature<XY>> =
image(position.toCoordinates(), image, size = size, zoomRange = zoomRange, id = id)
fun FeaturesState<XY>.text(
position: Pair<Number, Number>,
text: String,
zoomRange: FloatRange = Feature.defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<TextFeature<XY>> = text(position.toCoordinates(), text, zoomRange, color, id = id)
fun SchemeFeaturesState.group(
scaleRange: FloatRange = defaultScaleRange,
id: FeatureId? = null,
builder: SchemeFeaturesState.() -> Unit,
): FeatureId {
val groupBuilder = SchemeFeaturesState.build(builder)
val feature = SchemeFeatureGroup(groupBuilder.features(), scaleRange)
return addFeature(id, feature)
}

View File

@ -1,23 +0,0 @@
package center.sciprog.maps.scheme
import kotlin.math.pow
data class SchemeViewPoint(val focus: SchemeCoordinates, val scale: Float = 1f)
fun SchemeViewPoint.move(deltaX: Float, deltaY: Float): SchemeViewPoint {
return copy(focus = SchemeCoordinates(focus.x + deltaX, focus.y + deltaY))
}
fun SchemeViewPoint.zoom(
zoom: Float,
invariant: SchemeCoordinates = focus,
): SchemeViewPoint = if (invariant == focus) {
copy(scale = scale * 2f.pow(zoom))
} else {
val difScale = (1 - 2f.pow(-zoom))
val newCenter = SchemeCoordinates(
focus.x + (invariant.x - focus.x) * difScale,
focus.y + (invariant.y - focus.y) * difScale
)
SchemeViewPoint(newCenter, scale * 2f.pow(zoom))
}

View File

@ -0,0 +1,64 @@
package center.sciprog.maps.scheme
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.features.CoordinateSpace
import center.sciprog.maps.features.Rectangle
import center.sciprog.maps.features.ViewPoint
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
data class XY(val x: Float, val y: Float)
internal data class XYRectangle(
override val a: XY,
override val b: XY,
) : Rectangle<XY> {
override fun contains(point: XY): Boolean = point.x in a.x..b.x && point.y in a.y..b.y
// companion object {
// fun square(center: XY, height: Float, width: Float): XYRectangle = XYRectangle(
// XY(center.x - width / 2, center.y + height / 2),
// XY(center.x + width / 2, center.y - height / 2),
// )
// }
}
val Rectangle<XY>.top get() = max(a.y, b.y)
val Rectangle<XY>.bottom get() = min(a.y, b.y)
val Rectangle<XY>.right get() = max(a.x, b.x)
val Rectangle<XY>.left get() = min(a.x, b.x)
val Rectangle<XY>.width: Float get() = abs(a.x - b.x)
val Rectangle<XY>.height: Float get() = abs(a.y - b.y)
val Rectangle<XY>.center get() = XY((a.x + b.x) / 2, (a.y + b.y) / 2)
public val Rectangle<XY>.leftTop: XY get() = XY(left, top)
public val Rectangle<XY>.rightBottom: XY get() = XY(right, bottom)
internal val defaultCanvasSize = DpSize(512.dp, 512.dp)
data class XYViewPoint(
override val focus: XY,
override val zoom: Float = 1f,
) : ViewPoint<XY>
public fun CoordinateSpace<XY>.Rectangle(
center: XY,
height: Float,
width: Float,
): Rectangle<XY> {
val a = XY(
center.x - (width / 2),
center.y - (height / 2)
)
val b = XY(
center.x + (width / 2),
center.y + (height / 2)
)
return XYRectangle(a, b)
}

View File

@ -0,0 +1,64 @@
package center.sciprog.maps.scheme
import androidx.compose.ui.unit.DpSize
import center.sciprog.maps.features.CoordinateSpace
import center.sciprog.maps.features.Rectangle
import center.sciprog.maps.features.ViewPoint
import kotlin.math.pow
object XYCoordinateSpace : CoordinateSpace<XY> {
override fun Rectangle(first: XY, second: XY): Rectangle<XY> =
XYRectangle(first, second)
override fun Rectangle(center: XY, zoom: Float, size: DpSize): Rectangle<XY> =
Rectangle(center, (size.width.value / zoom), (size.height.value / zoom))
override fun ViewPoint(center: XY, zoom: Float): ViewPoint<XY> =
XYViewPoint(center, zoom)
override fun ViewPoint<XY>.moveBy(delta: XY): ViewPoint<XY> =
XYViewPoint(XY(focus.x + delta.x, focus.y + delta.y))
override fun ViewPoint<XY>.zoomBy(
zoomDelta: Float,
invariant: XY,
): ViewPoint<XY> = if (invariant == focus) {
XYViewPoint(focus, zoom = zoom * 2f.pow(zoomDelta))
} else {
val difScale = (1 - 2f.pow(-zoomDelta))
val newCenter = XY(
focus.x + (invariant.x - focus.x) * difScale,
focus.y + (invariant.y - focus.y) * difScale
)
XYViewPoint(newCenter, zoom * 2f.pow(zoomDelta))
}
override fun Rectangle<XY>.withCenter(center: XY): Rectangle<XY> =
Rectangle(center, width, height)
override fun Collection<Rectangle<XY>>.wrapRectangles(): Rectangle<XY>? {
if (isEmpty()) return null
val minX = minOf { it.left }
val maxX = maxOf { it.right }
val minY = minOf { it.bottom }
val maxY = maxOf { it.top }
return XYRectangle(
XY(minX, minY),
XY(maxX, maxY)
)
}
override fun Collection<XY>.wrapPoints(): Rectangle<XY>? {
if (isEmpty()) return null
val minX = minOf { it.x }
val maxX = maxOf { it.x }
val minY = minOf { it.y }
val maxY = maxOf { it.y }
return XYRectangle(
XY(minX, minY),
XY(maxX, maxY)
)
}
}

View File

@ -0,0 +1,45 @@
package center.sciprog.maps.scheme
import androidx.compose.ui.unit.*
import center.sciprog.maps.features.*
import kotlin.math.min
class XYViewState(
config: ViewConfig<XY>,
canvasSize: DpSize,
viewPoint: ViewPoint<XY>,
) : CoordinateViewState<XY>(config, canvasSize, viewPoint) {
override val space: CoordinateSpace<XY>
get() = XYCoordinateSpace
override fun DpOffset.toCoordinates(): XY = XY(
(x - canvasSize.width / 2).value / viewPoint.zoom + viewPoint.focus.x,
(canvasSize.height / 2 - y).value / viewPoint.zoom + viewPoint.focus.y
)
override fun XY.toDpOffset(): DpOffset = DpOffset(
(canvasSize.width / 2 + (x.dp - viewPoint.focus.x.dp) * viewPoint.zoom),
(canvasSize.height / 2 + (viewPoint.focus.y.dp - y.dp) * viewPoint.zoom)
)
override fun viewPointFor(rectangle: Rectangle<XY>): ViewPoint<XY> {
val scale = min(
canvasSize.width.value / rectangle.width,
canvasSize.height.value / rectangle.height
)
return XYViewPoint(rectangle.center, scale)
}
override fun ViewPoint<XY>.moveBy(x: Dp, y: Dp): ViewPoint<XY> {
val newCoordinates = XY(focus.x + x.value / zoom, focus.y + y.value / zoom)
return XYViewPoint(newCoordinates, zoom)
}
override fun Rectangle<XY>.toDpRect(): DpRect {
val topLeft = leftTop.toDpOffset()
val bottomRight = rightBottom.toDpOffset()
return DpRect(topLeft.x, topLeft.y, bottomRight.x, bottomRight.y)
}
}

View File

@ -1,296 +1,120 @@
package center.sciprog.maps.scheme package center.sciprog.maps.scheme
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.* import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.runtime.key
import androidx.compose.runtime.remember
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.Rect import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.* import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import center.sciprog.maps.compose.mapControls
import center.sciprog.maps.features.*
import mu.KotlinLogging import mu.KotlinLogging
import org.jetbrains.skia.Font
import org.jetbrains.skia.Paint
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min import kotlin.math.min
internal fun Color.toSkiaPaint(): 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("SchemeView") private val logger = KotlinLogging.logger("SchemeView")
data class SchemeViewConfig(
val zoomSpeed: Float = 1f / 3f,
val onClick: SchemeViewPoint.() -> Unit = {},
val onViewChange: SchemeViewPoint.() -> Unit = {},
val onSelect: (SchemeRectangle) -> Unit = {},
val zoomOnSelect: Boolean = true,
)
@Composable @Composable
public fun SchemeView( public fun SchemeView(
initialViewPoint: SchemeViewPoint, initialViewPoint: ViewPoint<XY>,
featuresState: SchemeFeaturesState, featuresState: FeaturesState<XY>,
config: SchemeViewConfig = SchemeViewConfig(), config: ViewConfig<XY>,
modifier: Modifier = Modifier.fillMaxSize(), modifier: Modifier = Modifier.fillMaxSize(),
) = key(initialViewPoint) { ) = key(initialViewPoint) {
var canvasSize by remember { mutableStateOf(defaultCanvasSize) } val state = remember {
XYViewState(
var viewPoint by remember { mutableStateOf(initialViewPoint) } config,
defaultCanvasSize,
initialViewPoint,
fun setViewPoint(newViewPoint: SchemeViewPoint) {
config.onViewChange(newViewPoint)
viewPoint = newViewPoint
}
fun DpOffset.toCoordinates(): SchemeCoordinates = SchemeCoordinates(
(x - canvasSize.width / 2).value / viewPoint.scale + viewPoint.focus.x,
(canvasSize.height / 2 - y).value / viewPoint.scale + viewPoint.focus.y
)
// Selection rectangle. If null - no selection
var selectRect by remember { mutableStateOf<Rect?>(null) }
@OptIn(ExperimentalComposeUiApi::class)
val canvasModifier = modifier.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
val event: PointerEvent = awaitPointerEvent()
event.changes.forEach { change ->
if (event.buttons.isPrimaryPressed) {
//Evaluating selection frame
if (event.keyboardModifiers.isShiftPressed) {
selectRect = Rect(change.position, change.position)
drag(change.id) { dragChange ->
selectRect?.let { rect ->
val offset = dragChange.position
selectRect = Rect(
min(offset.x, rect.left),
min(offset.y, rect.top),
max(offset.x, rect.right),
max(offset.y, rect.bottom)
)
}
}
selectRect?.let { rect ->
//Use selection override if it is defined
val box = SchemeRectangle(
rect.topLeft.toDpOffset().toCoordinates(),
rect.bottomRight.toDpOffset().toCoordinates()
)
config.onSelect(box)
if (config.zoomOnSelect) {
val newScale = min(
canvasSize.width.value / box.width,
canvasSize.height.value / box.height
)
val newViewPoint = SchemeViewPoint(box.center, newScale)
setViewPoint(newViewPoint)
}
selectRect = null
}
} else {
val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
config.onClick(SchemeViewPoint(dpPos.toCoordinates(), viewPoint.scale))
drag(change.id) { dragChange ->
val dragAmount = dragChange.position - dragChange.previousPosition
val newViewPoint = viewPoint.move(
-dragAmount.x.toDp().value / viewPoint.scale,
dragAmount.y.toDp().value / viewPoint.scale
)
setViewPoint(newViewPoint)
}
}
}
}
}
}
}.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()
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y * config.zoomSpeed, invariant)
setViewPoint(newViewPoint)
}.fillMaxSize()
val painterCache = key(featuresState) {
featuresState.features().values.filterIsInstance<PainterFeature>().associateWith { it.painter() }
}
Canvas(canvasModifier) {
fun SchemeCoordinates.toOffset(): Offset = Offset(
(canvasSize.width / 2 + (x.dp - viewPoint.focus.x.dp) * viewPoint.scale).toPx(),
(canvasSize.height / 2 + (viewPoint.focus.y.dp - y.dp) * viewPoint.scale).toPx()
) )
}
with(state) {
val painterCache: Map<PainterFeature<XY>, Painter> = key(featuresState) {
featuresState.features.values.filterIsInstance<PainterFeature<XY>>().associateWith { it.painter() }
}
Canvas(modifier = modifier.mapControls(state).fillMaxSize()) {
fun DrawScope.drawFeature(scale: Float, feature: SchemeFeature) { if (canvasSize != size.toDpSize()) {
when (feature) { canvasSize = size.toDpSize()
is SchemeBackgroundFeature -> { logger.debug { "Recalculate canvas. Size: $size" }
val offset = SchemeCoordinates(feature.rectangle.left, feature.rectangle.top).toOffset() }
val backgroundSize = DpSize( clipRect {
(feature.rectangle.width * scale).dp, featuresState.features.values.filterIsInstance<ScalableImageFeature<XY>>().forEach { background ->
(feature.rectangle.height * scale).dp drawFeature(state, painterCache, background)
).toSize()
translate(offset.x, offset.y) {
with(painterCache[feature]!!) {
draw(backgroundSize)
}
}
} }
featuresState.features.values.filter {
is SchemeFeatureSelector -> drawFeature(scale, feature.selector(scale)) it !is ScalableImageFeature && viewPoint.zoom in it.zoomRange
is SchemeCircleFeature -> drawCircle( }.forEach { feature ->
feature.color, drawFeature(state, painterCache, feature)
feature.size, }
center = feature.center.toOffset() }
) selectRect?.let { dpRect ->
val rect = dpRect.toRect()
is SchemeLineFeature -> drawLine( drawRect(
feature.color, color = Color.Blue,
feature.a.toOffset(), topLeft = rect.topLeft,
feature.b.toOffset(), size = rect.size,
) alpha = 0.5f,
style = Stroke(
is SchemeArcFeature -> { width = 2f,
val topLeft = feature.oval.leftTop.toOffset() pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
val bottomRight = feature.oval.rightBottom.toOffset()
val size = Size(abs(topLeft.x - bottomRight.x), abs(topLeft.y - bottomRight.y))
drawArc(
color = feature.color,
startAngle = (feature.startAngle * 180 / PI).toFloat(),
sweepAngle = (feature.arcLength * 180 / PI).toFloat(),
useCenter = false,
topLeft = topLeft,
size = size,
style = Stroke()
) )
}
is SchemeBitmapFeature -> drawImage(feature.image, feature.position.toOffset())
is SchemeImageFeature -> {
val offset = feature.position.toOffset()
val imageSize = feature.size.toSize()
translate(offset.x - imageSize.width / 2, offset.y - imageSize.height / 2) {
with(painterCache[feature]!!) {
draw(imageSize)
}
}
}
is SchemeTextFeature -> drawIntoCanvas { canvas ->
val offset = feature.position.toOffset()
canvas.nativeCanvas.drawString(
feature.text,
offset.x + 5,
offset.y - 5,
Font().apply { size = 16f },
feature.color.toSkiaPaint()
)
}
is SchemeDrawFeature -> {
val offset = feature.position.toOffset()
translate(offset.x, offset.y) {
feature.drawFeature(this)
}
}
is SchemeFeatureGroup -> {
feature.children.values.forEach {
drawFeature(scale, it)
}
}
}
}
if (canvasSize != size.toDpSize()) {
canvasSize = size.toDpSize()
logger.debug { "Recalculate canvas. Size: $size" }
}
clipRect {
featuresState.features().values.filterIsInstance<SchemeBackgroundFeature>().forEach { background ->
drawFeature(viewPoint.scale, background)
}
featuresState.features().values.filter {
it !is SchemeBackgroundFeature && viewPoint.scale in it.scaleRange
}.forEach { feature ->
drawFeature(viewPoint.scale, feature)
}
}
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)
) )
) }
} }
} }
} }
public fun Rectangle<XY>.computeViewPoint(
canvasSize: DpSize = defaultCanvasSize,
): ViewPoint<XY> {
val zoom = min(
canvasSize.width.value / width,
canvasSize.height.value / height
)
return XYViewPoint(center, zoom.toFloat())
}
/** /**
* A builder for a Scheme with static features. * A builder for a Scheme with static features.
*/ */
@Composable @Composable
public fun SchemeView( public fun SchemeView(
initialViewPoint: SchemeViewPoint? = null, initialViewPoint: ViewPoint<XY>? = null,
initialRectangle: SchemeRectangle? = null, initialRectangle: Rectangle<XY>? = null,
featureMap: Map<FeatureId, SchemeFeature>, featureMap: Map<FeatureId<*>, Feature<XY>>,
config: SchemeViewConfig = SchemeViewConfig(), config: ViewConfig<XY> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(), modifier: Modifier = Modifier.fillMaxSize(),
) { ) {
val featuresState = key(featureMap) { val featuresState = key(featureMap) {
SchemeFeaturesState.build { FeaturesState.build(XYCoordinateSpace) {
featureMap.forEach(::addFeature) featureMap.forEach { feature(it.key.id, it.value) }
} }
} }
val viewPointOverride: SchemeViewPoint = remember(initialViewPoint, initialRectangle) { val viewPointOverride: ViewPoint<XY> = remember(initialViewPoint, initialRectangle) {
initialViewPoint initialViewPoint
?: initialRectangle?.computeViewPoint() ?: initialRectangle?.computeViewPoint()
?: featureMap.values.computeBoundingBox(1f)?.computeViewPoint() ?: featureMap.values.computeBoundingBox(XYCoordinateSpace, 1f)?.computeViewPoint()
?: SchemeViewPoint(SchemeCoordinates(0f, 0f)) ?: XYViewPoint(XY(0f, 0f), 1f)
} }
SchemeView(viewPointOverride, featuresState, config, modifier) SchemeView(viewPointOverride, featuresState, config, modifier)
} }
/** /**
* Draw a map using convenient parameters. If neither [initialViewPoint], noe [initialRectangle] is defined, * Draw a scheme using convenient parameters. If neither [initialViewPoint], noe [initialRectangle] is defined,
* use map features to infer view region. * use map features to infer view region.
* @param initialViewPoint The view point of the map using center and zoom. Is used if provided * @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 initialRectangle The rectangle to be used for view point computation. Used if [initialViewPoint] is not defined.
@ -298,45 +122,119 @@ public fun SchemeView(
*/ */
@Composable @Composable
public fun SchemeView( public fun SchemeView(
initialViewPoint: SchemeViewPoint? = null, initialViewPoint: ViewPoint<XY>? = null,
initialRectangle: SchemeRectangle? = null, initialRectangle: Rectangle<XY>? = null,
config: SchemeViewConfig = SchemeViewConfig(), config: ViewConfig<XY> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(), modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: SchemeFeaturesState.() -> Unit = {}, buildFeatures: FeaturesState<XY>.() -> Unit = {},
) { ) {
val featureState = SchemeFeaturesState.remember(buildFeatures) val featureState = FeaturesState.remember(XYCoordinateSpace, buildFeatures)
val features = featureState.features() val viewPointOverride: ViewPoint<XY> = remember(initialViewPoint, initialRectangle) {
val viewPointOverride: SchemeViewPoint = remember(initialViewPoint, initialRectangle) {
initialViewPoint initialViewPoint
?: initialRectangle?.computeViewPoint() ?: initialRectangle?.computeViewPoint()
?: features.values.computeBoundingBox(1f)?.computeViewPoint() ?: featureState.features.values.computeBoundingBox(XYCoordinateSpace, 1f)?.computeViewPoint()
?: SchemeViewPoint(SchemeCoordinates(0f, 0f)) ?: XYViewPoint(XY(0f, 0f), 1f)
} }
// val featureDrag = DragHandle.withPrimaryButton { _, start, end -> val featureDrag: DragHandle<XY> =
// val zoom = start.zoom DragHandle.withPrimaryButton { event, start: ViewPoint<XY>, end: ViewPoint<XY> ->
// featureState.findAllWithAttribute(DraggableAttribute) { it }.forEach { id -> featureState.forEachWithAttribute(DraggableAttribute) { _, handle ->
// val feature = features[id] as? DraggableMapFeature ?: return@forEach //TODO add safety
// val boundingBox = feature.getBoundingBox(zoom) ?: return@forEach handle as DragHandle<XY>
// if (start.focus in boundingBox) { if (!handle.handle(event, start, end)) return@withPrimaryButton false
// featureState.addFeature(id, feature.withCoordinates(end.focus)) }
// return@withPrimaryButton false true
// } }
// }
// return@withPrimaryButton true
// } val newConfig = config.copy(
// dragHandle = DragHandle.combine(featureDrag, config.dragHandle)
// )
// val newConfig = config.copy(
// dragHandle = DragHandle.combine(featureDrag, config.dragHandle)
// )
SchemeView( SchemeView(
initialViewPoint = viewPointOverride, initialViewPoint = viewPointOverride,
featuresState = featureState, featuresState = featureState,
config = config, config = newConfig,
modifier = modifier, modifier = modifier,
) )
} }
///**
// * A builder for a Scheme with static features.
// */
//@Composable
//public fun SchemeView(
// initialViewPoint: XYViewPoint? = null,
// initialRectangle: XYRectangle? = null,
// featureMap: Map<FeatureId<*>,>,
// config: SchemeViewConfig = SchemeViewConfig(),
// modifier: Modifier = Modifier.fillMaxSize(),
//) {
// val featuresState = key(featureMap) {
// SchemeFeaturesState.build {
// featureMap.forEach(::addFeature)
// }
// }
//
// val viewPointOverride: XYViewPoint = remember(initialViewPoint, initialRectangle) {
// initialViewPoint
// ?: initialRectangle?.computeViewPoint()
// ?: featureMap.values.computeBoundingBox(1f)?.computeViewPoint()
// ?: XYViewPoint(XY(0f, 0f))
// }
//
// SchemeView(viewPointOverride, featuresState, config, modifier)
//}
//
///**
// * Draw a map using convenient parameters. If neither [initialViewPoint], noe [initialRectangle] is defined,
// * use map features to infer 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
// */
//@Composable
//public fun SchemeView(
// initialViewPoint: XYViewPoint? = null,
// initialRectangle: Rectangle<XY>? = null,
// config: ViewConfig<XY> = ViewConfig(),
// modifier: Modifier = Modifier.fillMaxSize(),
// buildFeatures: FeaturesState<XY>.() -> Unit = {},
//) {
// val featureState = FeaturesState.remember(XYCoordinateSpace, buildFeatures)
//
// val features = featureState.features
//
// val viewPointOverride: XYViewPoint = remember(initialViewPoint, initialRectangle) {
// initialViewPoint
// ?: initialRectangle?.computeViewPoint()
// ?: features.values.computeBoundingBox(1f)?.computeViewPoint()
// ?: XYViewPoint(XY(0f, 0f))
// }
//
//// val featureDrag = DragHandle.withPrimaryButton { _, start, end ->
//// val zoom = start.zoom
//// featureState.findAllWithAttribute(DraggableAttribute) { it }.forEach { id ->
//// val feature = features[id] as? DraggableMapFeature ?: return@forEach
//// val boundingBox = feature.getBoundingBox(zoom) ?: return@forEach
//// if (start.focus in boundingBox) {
//// featureState.addFeature(id, feature.withCoordinates(end.focus))
//// return@withPrimaryButton false
//// }
//// }
//// return@withPrimaryButton true
//// }
////
////
//// val newConfig = config.copy(
//// dragHandle = DragHandle.combine(featureDrag, config.dragHandle)
//// )
//
// SchemeView(
// initialViewPoint = viewPointOverride,
// featuresState = featureState,
// config = config,
// modifier = modifier,
// )
//}

View File

@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import center.sciprog.maps.features.*
import center.sciprog.maps.scheme.* import center.sciprog.maps.scheme.*
import org.jfree.svg.SVGGraphics2D import org.jfree.svg.SVGGraphics2D
import org.jfree.svg.SVGUtils import org.jfree.svg.SVGUtils
@ -15,34 +16,34 @@ import kotlin.math.PI
import kotlin.math.abs import kotlin.math.abs
class FeatureStateSnapshot( class FeatureStateSnapshot<T : Any>(
val features: Map<FeatureId, SchemeFeature>, val features: Map<FeatureId<*>, Feature<T>>,
val painterCache: Map<PainterFeature, Painter>, val painterCache: Map<PainterFeature<T>, Painter>,
) )
@Composable @Composable
fun SchemeFeaturesState.snapshot(): FeatureStateSnapshot = fun <T: Any> FeaturesState<T>.snapshot(): FeatureStateSnapshot<T> = FeatureStateSnapshot(
FeatureStateSnapshot( features,
features(), features.values.filterIsInstance<PainterFeature<T>>().associateWith { it.painter() }
features().values.filterIsInstance<PainterFeature>().associateWith { it.painter() }) )
fun FeatureStateSnapshot.generateSvg( fun FeatureStateSnapshot<XY>.generateSvg(
viewPoint: SchemeViewPoint, viewPoint: ViewPoint<XY>,
width: Double, width: Double,
height: Double, height: Double,
id: String? = null id: String? = null,
): String{ ): String {
fun SchemeCoordinates.toOffset(): Offset = Offset( fun XY.toOffset(): Offset = Offset(
(width / 2 + (x - viewPoint.focus.x) * viewPoint.scale).toFloat(), (width / 2 + (x - viewPoint.focus.x) * viewPoint.zoom).toFloat(),
(height / 2 + (viewPoint.focus.y - y) * viewPoint.scale).toFloat() (height / 2 + (viewPoint.focus.y - y) * viewPoint.zoom).toFloat()
) )
fun SvgDrawScope.drawFeature(scale: Float, feature: SchemeFeature) { fun SvgDrawScope.drawFeature(scale: Float, feature: Feature<XY>) {
when (feature) { when (feature) {
is SchemeBackgroundFeature -> { is ScalableImageFeature -> {
val offset = SchemeCoordinates(feature.rectangle.left, feature.rectangle.top).toOffset() val offset = XY(feature.rectangle.left, feature.rectangle.top).toOffset()
val backgroundSize = Size( val backgroundSize = Size(
(feature.rectangle.width * scale), (feature.rectangle.width * scale),
(feature.rectangle.height * scale) (feature.rectangle.height * scale)
@ -55,15 +56,17 @@ fun FeatureStateSnapshot.generateSvg(
} }
} }
is SchemeFeatureSelector -> drawFeature(scale, feature.selector(scale)) is FeatureSelector -> drawFeature(scale, feature.selector(scale))
is SchemeCircleFeature -> drawCircle(
is CircleFeature -> drawCircle(
feature.color, feature.color,
feature.size, feature.size.toPx(),
center = feature.center.toOffset() center = feature.center.toOffset()
) )
is SchemeLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset()) is LineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
is SchemeArcFeature -> {
is ArcFeature -> {
val topLeft = feature.oval.leftTop.toOffset() val topLeft = feature.oval.leftTop.toOffset()
val bottomRight = feature.oval.rightBottom.toOffset() val bottomRight = feature.oval.rightBottom.toOffset()
@ -80,9 +83,9 @@ fun FeatureStateSnapshot.generateSvg(
) )
} }
is SchemeBitmapFeature -> drawImage(feature.image, feature.position.toOffset()) is BitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is SchemeImageFeature -> { is VectorImageFeature -> {
val offset = feature.position.toOffset() val offset = feature.position.toOffset()
val imageSize = feature.size.toSize() val imageSize = feature.size.toSize()
translate(offset.x - imageSize.width / 2, offset.y - imageSize.height / 2) { translate(offset.x - imageSize.width / 2, offset.y - imageSize.height / 2) {
@ -92,7 +95,7 @@ fun FeatureStateSnapshot.generateSvg(
} }
} }
is SchemeTextFeature -> drawIntoCanvas { canvas -> is TextFeature -> drawIntoCanvas { canvas ->
val offset = feature.position.toOffset() val offset = feature.position.toOffset()
drawText( drawText(
feature.text, feature.text,
@ -103,14 +106,14 @@ fun FeatureStateSnapshot.generateSvg(
) )
} }
is SchemeDrawFeature -> { is DrawFeature -> {
val offset = feature.position.toOffset() val offset = feature.position.toOffset()
translate(offset.x, offset.y) { translate(offset.x, offset.y) {
feature.drawFeature(this) feature.drawFeature(this)
} }
} }
is SchemeFeatureGroup -> { is FeatureGroup -> {
feature.children.values.forEach { feature.children.values.forEach {
drawFeature(scale, it) drawFeature(scale, it)
} }
@ -122,20 +125,20 @@ fun FeatureStateSnapshot.generateSvg(
val svgScope = SvgDrawScope(svgGraphics2D, Size(width.toFloat(), height.toFloat())) val svgScope = SvgDrawScope(svgGraphics2D, Size(width.toFloat(), height.toFloat()))
svgScope.apply { svgScope.apply {
features.values.filterIsInstance<SchemeBackgroundFeature>().forEach { background -> features.values.filterIsInstance<ScalableImageFeature<XY>>().forEach { background ->
drawFeature(viewPoint.scale, background) drawFeature(viewPoint.zoom, background)
} }
features.values.filter { features.values.filter {
it !is SchemeBackgroundFeature && viewPoint.scale in it.scaleRange it !is ScalableImageFeature && viewPoint.zoom in it.zoomRange
}.forEach { feature -> }.forEach { feature ->
drawFeature(viewPoint.scale, feature) drawFeature(viewPoint.zoom, feature)
} }
} }
return svgGraphics2D.getSVGElement(id) return svgGraphics2D.getSVGElement(id)
} }
fun FeatureStateSnapshot.exportToSvg( fun FeatureStateSnapshot<XY>.exportToSvg(
viewPoint: SchemeViewPoint, viewPoint: ViewPoint<XY>,
width: Double, width: Double,
height: Double, height: Double,
path: java.nio.file.Path, path: java.nio.file.Path,