Compare commits

...

2 Commits

Author SHA1 Message Date
4e08680b22 PNG export 2024-10-03 09:22:42 +03:00
bd2804d772 Move svg to features 2024-10-01 20:26:00 +03:00
15 changed files with 242 additions and 231 deletions

View File

@ -4,9 +4,11 @@
### Added ### Added
- `alpha` extension for feature attribute builder - `alpha` extension for feature attribute builder
- PNG export
### Changed ### Changed
- avoid drawing features with VisibleAttribute false - avoid drawing features with VisibleAttribute false
- Move SVG export to `features` and make it usable for maps as well
### Deprecated ### Deprecated

View File

@ -9,7 +9,7 @@ val kmathVersion: String by extra("0.4.0")
allprojects { allprojects {
group = "space.kscience" group = "space.kscience"
version = "0.4.0-dev-2" version = "0.4.0-dev-3"
repositories { repositories {
mavenLocal() mavenLocal()

View File

@ -4,22 +4,20 @@ import androidx.compose.foundation.ContextMenuArea
import androidx.compose.foundation.ContextMenuItem import androidx.compose.foundation.ContextMenuItem
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.rememberTextMeasurer
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 kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import space.kscience.kmath.geometry.Angle import space.kscience.kmath.geometry.Angle
import space.kscience.maps.features.FeatureStore import space.kscience.maps.features.*
import space.kscience.maps.features.ViewConfig
import space.kscience.maps.features.ViewPoint
import space.kscience.maps.features.color
import space.kscience.maps.scheme.* import space.kscience.maps.scheme.*
import space.kscience.maps.svg.FeatureStateSnapshot import space.kscience.maps.svg.exportToPng
import space.kscience.maps.svg.exportToSvg import space.kscience.maps.svg.exportToSvg
import space.kscience.maps.svg.snapshot
import java.awt.Desktop import java.awt.Desktop
import java.nio.file.Files import java.nio.file.Files
@ -58,23 +56,31 @@ fun App() {
var viewPoint: ViewPoint<XY> by remember { mutableStateOf(initialViewPoint) } var viewPoint: ViewPoint<XY> by remember { mutableStateOf(initialViewPoint) }
var snapshot: FeatureStateSnapshot<XY>? by remember { mutableStateOf(null) } val painterCache = features.pointerCache()
if (snapshot == null) { val textMeasurer = rememberTextMeasurer()
snapshot = features.snapshot()
}
ContextMenuArea( ContextMenuArea(
items = { items = {
listOf( listOf(
ContextMenuItem("Export to SVG") { ContextMenuItem("Export to SVG") {
snapshot?.let {
val path = Files.createTempFile("scheme-kt-", ".svg") val path = Files.createTempFile("scheme-kt-", ".svg")
it.exportToSvg(viewPoint, 800.0, 800.0, path) features.exportToSvg(viewPoint, painterCache, Size(800f, 800f), path)
println(path.toFile())
Desktop.getDesktop().browse(path.toFile().toURI())
},
ContextMenuItem("Export to PNG") {
val path = Files.createTempFile("scheme-kt-", ".png")
features.exportToPng(
viewPoint,
painterCache,
textMeasurer,
Size(800f, 800f),
path
)
println(path.toFile()) println(path.toFile())
Desktop.getDesktop().browse(path.toFile().toURI()) Desktop.getDesktop().browse(path.toFile().toURI())
} }
},
) )
} }
) { ) {

View File

@ -15,7 +15,7 @@ import space.kscience.maps.features.*
import kotlin.math.* import kotlin.math.*
public class MapCanvasState private constructor( public class MapCanvasState internal constructor(
public val mapTileProvider: MapTileProvider, public val mapTileProvider: MapTileProvider,
config: ViewConfig<Gmc>, config: ViewConfig<Gmc>,
) : CanvasState<Gmc>(config) { ) : CanvasState<Gmc>(config) {

View File

@ -0,0 +1,55 @@
package space.kscience.maps.compose
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.asSkiaBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import org.jetbrains.skia.Image
import org.jfree.svg.SVGUtils
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.features.FeatureSet
import space.kscience.maps.features.PainterFeature
import space.kscience.maps.features.ViewConfig
import space.kscience.maps.features.ViewPoint
import space.kscience.maps.svg.generateBitmap
import space.kscience.maps.svg.generateSvg
import java.nio.file.Path
import kotlin.io.path.writeBytes
public fun FeatureSet<Gmc>.exportToSvg(
mapTileProvider: MapTileProvider,
viewPoint: ViewPoint<Gmc>,
painterCache: Map<PainterFeature<Gmc>, Painter>,
size: Size,
path: Path,
) {
val mapCanvasState: MapCanvasState = MapCanvasState(mapTileProvider, ViewConfig()).apply {
this.viewPoint = viewPoint
this.canvasSize = DpSize(size.width.dp, size.height.dp)
}
val svgString: String = generateSvg(mapCanvasState, painterCache)
SVGUtils.writeToSVG(path.toFile(), svgString)
}
public fun FeatureSet<Gmc>.exportToPng(
mapTileProvider: MapTileProvider,
viewPoint: ViewPoint<Gmc>,
painterCache: Map<PainterFeature<Gmc>, Painter>,
textMeasurer: TextMeasurer,
size: Size,
path: Path,
) {
val mapCanvasState: MapCanvasState = MapCanvasState(mapTileProvider, ViewConfig()).apply {
this.viewPoint = viewPoint
this.canvasSize = DpSize(size.width.dp, size.height.dp)
}
val bitmap = generateBitmap(mapCanvasState, painterCache, textMeasurer, size)
Image.makeFromBitmap(bitmap.asSkiaBitmap()).encodeToData()?.bytes?.let {
path.writeBytes(it)
}
}

View File

@ -37,4 +37,8 @@ kscience {
api("io.github.oshai:kotlin-logging:6.0.3") api("io.github.oshai:kotlin-logging:6.0.3")
api("com.benasher44:uuid:0.8.4") api("com.benasher44:uuid:0.8.4")
} }
jvmMain{
api("org.jfree:org.jfree.svg:5.0.4")
}
} }

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -75,6 +76,11 @@ public class ComposeFeatureDrawScope<T : Any>(
} }
} }
@Composable
public fun <T: Any> FeatureSet<T>.pointerCache(): Map<PainterFeature<T>, Painter> = key(features) {
features.values.filterIsInstance<PainterFeature<T>>().associateWith { it.getPainter() }
}
/** /**
* Create a canvas with extended functionality (e.g., drawing text) * Create a canvas with extended functionality (e.g., drawing text)
@ -90,7 +96,7 @@ public fun <T : Any> FeatureCanvas(
) { ) {
val textMeasurer = rememberTextMeasurer(0) val textMeasurer = rememberTextMeasurer(0)
val features by featureFlow.sample(sampleDuration).collectAsState(featureFlow.value) val features: Map<String, Feature<T>> by featureFlow.sample(sampleDuration).collectAsState(featureFlow.value)
val painterCache = features.values val painterCache = features.values
.filterIsInstance<PainterFeature<T>>() .filterIsInstance<PainterFeature<T>>()

View File

@ -14,19 +14,21 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import org.jfree.svg.SVGGraphics2D import org.jfree.svg.SVGGraphics2D
import space.kscience.attributes.Attributes import space.kscience.attributes.Attributes
import space.kscience.maps.features.* import space.kscience.maps.features.CanvasState
import space.kscience.maps.scheme.XY import space.kscience.maps.features.ColorAttribute
import space.kscience.maps.features.FeatureDrawScope
import space.kscience.maps.features.PainterFeature
import java.awt.BasicStroke import java.awt.BasicStroke
import java.awt.geom.* import java.awt.geom.*
import java.awt.image.AffineTransformOp import java.awt.image.AffineTransformOp
import java.awt.Color as AWTColor import java.awt.Color as AWTColor
public class SvgDrawScope( public class SvgDrawScope<T: Any>(
state: CanvasState<XY>, state: CanvasState<T>,
private val graphics: SVGGraphics2D, private val graphics: SVGGraphics2D,
private val painterCache: Map<PainterFeature<XY>, Painter>, private val painterCache: Map<PainterFeature<T>, Painter>,
private val defaultStrokeWidth: Float = 1f, private val defaultStrokeWidth: Float = 1f,
) : FeatureDrawScope<XY>(state) { ) : FeatureDrawScope<T>(state) {
override val layoutDirection: LayoutDirection override val layoutDirection: LayoutDirection
get() = LayoutDirection.Ltr get() = LayoutDirection.Ltr
@ -466,14 +468,14 @@ public class SvgDrawScope(
} }
} }
public fun renderText( // public fun renderText(
textFeature: TextFeature<XY>, // textFeature: TextFeature<T>,
) { // ) {
textFeature.color?.let { setupColor(it) } // textFeature.color?.let { setupColor(it) }
graphics.drawString(textFeature.text, textFeature.position.x, textFeature.position.y) // graphics.drawString(textFeature.text, textFeature.position.x, textFeature.position.y)
} // }
override fun painterFor(feature: PainterFeature<XY>): Painter { override fun painterFor(feature: PainterFeature<T>): Painter {
return painterCache[feature]!! return painterCache[feature]!!
} }

View File

@ -0,0 +1,34 @@
package space.kscience.maps.svg
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import space.kscience.maps.features.CanvasState
import space.kscience.maps.features.ComposeFeatureDrawScope
import space.kscience.maps.features.FeatureSet
import space.kscience.maps.features.PainterFeature
public fun <T : Any> FeatureSet<T>.generateBitmap(
canvasState: CanvasState<T>,
painterCache: Map<PainterFeature<T>, Painter>,
textMeasurer: TextMeasurer,
size: Size
): ImageBitmap {
val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt())
CanvasDrawScope().draw(
density = Density(1f),
layoutDirection = LayoutDirection.Ltr,
canvas = Canvas(bitmap),
size = size,
) {
ComposeFeatureDrawScope(this, canvasState, painterCache, textMeasurer).features(this@generateBitmap)
}
return bitmap
}

View File

@ -0,0 +1,45 @@
package space.kscience.maps.svg
import androidx.compose.ui.graphics.painter.Painter
import org.jfree.svg.SVGGraphics2D
import space.kscience.attributes.Attributes
import space.kscience.attributes.plus
import space.kscience.maps.features.*
public fun <T : Any> FeatureDrawScope<T>.features(featureSet: FeatureSet<T>) {
featureSet.features.entries.sortedBy { it.value.z }
.filter { state.viewPoint.zoom in it.value.zoomRange }
.forEach { (id, feature) ->
val attributesCache = mutableMapOf<List<String>, Attributes>()
fun computeGroupAttributes(path: List<String>): Attributes = attributesCache.getOrPut(path) {
if (path.isEmpty()) return Attributes.EMPTY
else if (path.size == 1) {
featureSet.features[path.first()]?.attributes ?: Attributes.EMPTY
} else {
computeGroupAttributes(path.dropLast(1)) +
(featureSet.features[path.first()]?.attributes ?: Attributes.EMPTY)
}
}
val path = id.split("/")
drawFeature(feature, computeGroupAttributes(path.dropLast(1)))
}
}
public fun <T : Any> FeatureSet<T>.generateSvg(
canvasState: CanvasState<T>,
painterCache: Map<PainterFeature<T>, Painter>,
id: String? = null,
): String {
val svgGraphics2D: SVGGraphics2D = SVGGraphics2D(
canvasState.canvasSize.width.value.toDouble(),
canvasState.canvasSize.height.value.toDouble()
)
val svgScope = SvgDrawScope(canvasState, svgGraphics2D, painterCache)
svgScope.features(this)
return svgGraphics2D.getSVGElement(id)
}

View File

@ -5,16 +5,15 @@ plugins {
`maven-publish` `maven-publish`
} }
kscience{ kscience {
jvm() jvm()
// js() // js()
wasm() wasm()
commonMain{ commonMain {
api(projects.mapsKtFeatures) api(projects.mapsKtFeatures)
} }
jvmMain{ jvmMain {
implementation("org.jfree:org.jfree.svg:5.0.4")
api(compose.desktop.currentOs) api(compose.desktop.currentOs)
} }
} }

View File

@ -9,7 +9,7 @@ import androidx.compose.ui.unit.dp
import space.kscience.maps.features.* import space.kscience.maps.features.*
import kotlin.math.min import kotlin.math.min
public class XYCanvasState( public class XYCanvasState internal constructor(
config: ViewConfig<XY>, config: ViewConfig<XY>,
) : CanvasState<XY>(config) { ) : CanvasState<XY>(config) {
override val space: CoordinateSpace<XY> override val space: CoordinateSpace<XY>

View File

@ -1,194 +0,0 @@
package space.kscience.maps.svg
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import org.jfree.svg.SVGGraphics2D
import org.jfree.svg.SVGUtils
import space.kscience.attributes.Attributes
import space.kscience.attributes.plus
import space.kscience.maps.features.*
import space.kscience.maps.scheme.XY
import space.kscience.maps.scheme.XYCanvasState
public class FeatureStateSnapshot<T : Any>(
public val features: Map<String, Feature<T>>,
internal val painterCache: Map<PainterFeature<T>, Painter>,
)
@Composable
public fun <T : Any> FeatureSet<T>.snapshot(): FeatureStateSnapshot<T> = FeatureStateSnapshot(
features,
features.values.filterIsInstance<PainterFeature<T>>().associateWith { it.getPainter() }
)
public fun FeatureStateSnapshot<XY>.generateSvg(
viewPoint: ViewPoint<XY>,
width: Double,
height: Double,
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)
// }
// }
// }
// }
val svgGraphics2D: SVGGraphics2D = SVGGraphics2D(width, height)
val svgCanvasState: XYCanvasState = XYCanvasState(ViewConfig()).apply {
this.viewPoint = viewPoint
this.canvasSize = DpSize(width.dp, height.dp)
}
val svgScope = SvgDrawScope(svgCanvasState, svgGraphics2D, painterCache)
svgScope.apply {
features.entries.sortedBy { it.value.z }
.filter { state.viewPoint.zoom in it.value.zoomRange }
.forEach { (id, feature) ->
val attributesCache = mutableMapOf<List<String>, Attributes>()
fun computeGroupAttributes(path: List<String>): Attributes = attributesCache.getOrPut(path){
if (path.isEmpty()) return Attributes.EMPTY
else if (path.size == 1) {
features[path.first()]?.attributes ?: Attributes.EMPTY
} else {
computeGroupAttributes(path.dropLast(1)) + (features[path.first()]?.attributes ?: Attributes.EMPTY)
}
}
val path = id.split("/")
drawFeature(feature, computeGroupAttributes(path.dropLast(1)))
}
}
return svgGraphics2D.getSVGElement(id)
}
public fun FeatureStateSnapshot<XY>.exportToSvg(
viewPoint: ViewPoint<XY>,
width: Double,
height: Double,
path: java.nio.file.Path,
) {
val svgString: String = generateSvg(viewPoint, width, height)
SVGUtils.writeToSVG(path.toFile(), svgString)
}

View File

@ -0,0 +1,52 @@
package space.kscience.maps.svg
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.asSkiaBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import org.jetbrains.skia.Image
import org.jfree.svg.SVGUtils
import space.kscience.maps.features.FeatureSet
import space.kscience.maps.features.PainterFeature
import space.kscience.maps.features.ViewConfig
import space.kscience.maps.features.ViewPoint
import space.kscience.maps.scheme.XY
import space.kscience.maps.scheme.XYCanvasState
import java.nio.file.Path
import kotlin.io.path.writeBytes
public fun FeatureSet<XY>.exportToSvg(
viewPoint: ViewPoint<XY>,
painterCache: Map<PainterFeature<XY>, Painter>,
size: Size,
path: Path,
) {
val svgCanvasState: XYCanvasState = XYCanvasState(ViewConfig()).apply {
this.viewPoint = viewPoint
this.canvasSize = DpSize(size.width.dp, size.height.dp)
}
val svgString: String = generateSvg(svgCanvasState, painterCache)
SVGUtils.writeToSVG(path.toFile(), svgString)
}
public fun FeatureSet<XY>.exportToPng(
viewPoint: ViewPoint<XY>,
painterCache: Map<PainterFeature<XY>, Painter>,
textMeasurer: TextMeasurer,
size: Size,
path: Path,
) {
val svgCanvasState: XYCanvasState = XYCanvasState(ViewConfig()).apply {
this.viewPoint = viewPoint
this.canvasSize = DpSize(size.width.dp, size.height.dp)
}
val bitmap = generateBitmap(svgCanvasState, painterCache, textMeasurer, size)
Image.makeFromBitmap(bitmap.asSkiaBitmap()).encodeToData()?.bytes?.let {
path.writeBytes(it)
}
}