6 Commits
main ... dev

Author SHA1 Message Date
c543536d2d Implement plotly-kt for wasm and add an example 2026-02-01 13:50:08 +03:00
efb05ae156 Fix plotly js test 2026-02-01 11:50:58 +03:00
ba4a8aa7c1 Update github actions 2026-01-31 22:13:37 +03:00
d069bd6047 Update github actions 2026-01-31 22:10:31 +03:00
0268ddbca0 Merge remote-tracking branch 'spc/dev' into dev 2026-01-31 20:27:25 +03:00
eacf977a0e add wasmJs target back 2026-01-31 20:27:08 +03:00
19 changed files with 479 additions and 19 deletions

View File

@@ -2,7 +2,7 @@ name: Gradle build
on:
push:
branches: [ dev, master ]
branches: [ dev, main ]
pull_request:
jobs:
@@ -10,8 +10,8 @@ jobs:
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3.5.1
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'liberica'
@@ -19,6 +19,16 @@ jobs:
- name: Gradle Wrapper Validation
uses: gradle/wrapper-validation-action@v1.0.4
- name: Gradle Build
uses: gradle/gradle-build-action@v2.4.2
uses: gradle/gradle-build-action@v3
with:
arguments: test jvmTest
arguments: test jvmTest
- name: Publish Test Report
uses: mikepenz/action-junit-report@v6
if: ${{ !cancelled() }} # always run even if the previous step fails
with:
report_paths: '**/test-results/**/TEST-*.xml'
annotate_only: true
detailed_summary: true
flaky_summary: true
include_empty_in_summary: false
skip_success_summary: true

View File

@@ -3,14 +3,18 @@
## Unreleased
### Added
- Plotly implementation and demo for Kotlin/Wasm
- Wasm targets for plotly-kt and visionforge-core/solid
### Changed
- Order of arguments in Plotly-kt for js plotDiv functions
### Deprecated
### Removed
### Fixed
- Plotly-kt js demo
### Security

View File

@@ -11,7 +11,7 @@ val dataforgeVersion by extra("0.10.2")
allprojects {
group = "space.kscience"
version = "0.5.1"
version = "0.5.2-dev-1"
}
subprojects {

View File

@@ -11,11 +11,14 @@ import kotlinx.html.style
import kotlinx.serialization.json.Json
import org.w3c.dom.HTMLElement
import org.w3c.dom.events.Event
import space.kscience.dataforge.context.Global
import space.kscience.dataforge.meta.MetaSerializer
import space.kscience.plotly.*
import space.kscience.plotly.events.PlotlyEventListenerType
import space.kscience.plotly.models.ScatterMode
import space.kscience.plotly.models.TraceType
import space.kscience.plotly.models.histogram
import space.kscience.plotly.models.scatter
import kotlin.random.Random
private fun onDomLoaded(block: (Event) -> Unit) {
@@ -35,7 +38,7 @@ fun main(): Unit = withCanvas {
div {
style = "height:50%; width=100%;"
h1 { +"Histogram demo" }
plotDiv {
plotDiv(Global) {
val rnd = Random(222)
histogram {
name = "Random data"
@@ -79,7 +82,7 @@ fun main(): Unit = withCanvas {
div {
style = "height:50%; width=100%;"
h1 { +"Dynamic trace demo" }
plotDiv {
plotDiv(Global) {
scatter {
x(1, 2, 3, 4)
y(10, 15, 13, 17)

View File

@@ -0,0 +1,29 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
kotlin("multiplatform")
}
repositories {
mavenCentral()
maven("https://repo.kotlin.link")
}
kotlin {
wasmJs {
browser()
binaries.executable()
}
sourceSets{
wasmJsMain{
dependencies{
implementation(projects.plotlyKt.plotlyKtCore)
implementation(spclibs.kotlinx.coroutines.core)
}
}
}
}

View File

@@ -0,0 +1,146 @@
package space.kscience.plotly.wasmjsdemo
import kotlinx.browser.document
import kotlinx.coroutines.*
import kotlinx.html.TagConsumer
import kotlinx.html.dom.append
import kotlinx.html.h1
import kotlinx.html.js.div
import kotlinx.html.style
import kotlinx.serialization.json.Json
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.events.Event
import space.kscience.dataforge.context.Global
import space.kscience.dataforge.meta.MetaSerializer
import space.kscience.plotly.*
import space.kscience.plotly.models.ScatterMode
import space.kscience.plotly.models.TraceType
import space.kscience.plotly.models.histogram
import space.kscience.plotly.models.scatter
import kotlin.random.Random
private fun onDomLoaded(block: (Event) -> Unit) {
document.addEventListener("DOMContentLoaded", block)
}
private fun withCanvas(block: TagConsumer<Element>.() -> Unit) {
val element = document.getElementById("canvas") as? HTMLElement
?: error("Element with id 'canvas' not found on page")
println("element loaded")
element.append { block() }
}
@OptIn(DelicateCoroutinesApi::class)
fun main(): Unit = withCanvas {
div {
style = "height:50%; width=100%;"
h1 { +"Histogram demo" }
plotDiv(Global) {
val rnd = Random(222)
histogram {
name = "Random data"
GlobalScope.launch {
while (isActive) {
x.numbers = List(500) { rnd.nextDouble() }
delay(300)
}
}
}
layout {
bargap = 0.1
title {
text = "Basic Histogram"
font {
size = 20
color("black")
}
}
xaxis {
title {
text = "Value"
font {
size = 16
}
}
}
yaxis {
title {
text = "Count"
font {
size = 16
}
}
}
}
}
}
div {
style = "height:50%; width=100%;"
h1 { +"Dynamic trace demo" }
plotDiv(Global) {
scatter {
x(1, 2, 3, 4)
y(10, 15, 13, 17)
mode = ScatterMode.markers
type = TraceType.scatter
}
scatter {
x(2, 3, 4, 5)
y(10, 15, 13, 17)
mode = ScatterMode.lines
type = TraceType.scatter
GlobalScope.launch {
while (isActive) {
delay(500)
marker {
if (Random.nextBoolean()) {
color("magenta")
} else {
color("blue")
}
}
}
}
}
scatter {
x(1, 2, 3, 4)
y(12, 5, 2, 12)
mode = ScatterMode.`lines+markers`
type = TraceType.scatter
marker {
color("red")
}
}
layout {
title = "Line and Scatter Plot"
}
}
}
div {
style = "height:50%; width=100%;"
h1 { +"Deserialization" }
val plot = Plotly.plot {
scatter {
x(1, 2, 3, 4)
y(10, 15, 13, 17)
mode = ScatterMode.markers
type = TraceType.scatter
}
}
val serialized = plot.toJsonString()
println(serialized)
val deserialized = Plot(Json.decodeFromString(MetaSerializer, serialized))
plotDiv(plot = deserialized)
// plotDiv(plot = deserialized).on(PlotlyEventListenerType.CLICK){
// println(it.toString())
// }
}
}

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kotlin/Wasm Example</title>
</head>
<body>
<script src="wasm-demo.js"></script>
<div id="canvas"></div>
</body>
<script type="application/javascript">
const unhandledError = (event, error) => {
if (error instanceof WebAssembly.CompileError) {
document.getElementById("warning").style.display = "initial";
// Hide a Scary Webpack Overlay which is less informative in this case.
const webpackOverlay = document.getElementById("webpack-dev-server-client-overlay");
if (webpackOverlay != null) webpackOverlay.style.display = "none";
}
}
addEventListener("error", (event) => unhandledError(event, event.error));
addEventListener("unhandledrejection", (event) => unhandledError(event, event.reason));
</script>
</html>

View File

@@ -11,11 +11,9 @@ val plotlyVersion by extra("2.35.3")
//}
kscience {
// jvm()
// js()
fullStack(bundleName = "js/plotly-kt.js")
native()
// wasm()
wasmJs()
useSerialization()
commonMain {
@@ -29,6 +27,11 @@ kscience {
nativeMain {
implementation("com.squareup.okio:okio:3.3.0")
}
wasmJsMain {
api(npm("plotly.js", plotlyVersion))
api("org.jetbrains.kotlinx:kotlinx-browser:0.5.0")
}
}
kotlinJupyter {

View File

@@ -720,13 +720,11 @@ public fun <T : Scheme> Trace.scheme(
/**
* A base class for Plotly traces
*
* @param uid a unique identifier for this trace
*/
@Serializable
public open class Trace : AbstractVision(), MutableMetaProvider, MetaRepr {
override fun get(name: Name): MutableMeta? = properties.get(name)
override fun get(name: Name): MutableMeta? = properties[name]
override fun set(name: Name, node: Meta?) {
properties[name] = node

View File

@@ -61,9 +61,10 @@ public fun Element.plot(
trace.eventFlow.filterIsInstance<VisionPropertyChangedEvent>().onEach { event ->
val traceData = trace.toDynamic()
//need to wrap coordinates into an additional array because plotly API for some reason expects 2D arrays
Plotly.coordinateNames.forEach { coordinate ->
val data = traceData[coordinate]
if (traceData[coordinate] != null) {
if (data != null) {
traceData[coordinate] = arrayOf(data)
}
}
@@ -111,12 +112,24 @@ public class PlotlyElement(public val div: HTMLElement)
* Create a div element and render the plot in it
*/
@OptIn(DelicateCoroutinesApi::class)
@Deprecated("Change arguments positions", ReplaceWith("plotDiv(plot, plotlyConfig, scope)"))
public fun TagConsumer<HTMLElement>.plotDiv(
plotlyConfig: PlotlyConfig,
plot: Plot,
scope: CoroutineScope = plot.manager?.context ?: GlobalScope,
): PlotlyElement = PlotlyElement(div("plotly-kt-plot").apply { plot(plotlyConfig, plot) })
/**
* Create a div element and render the plot in it
*/
@OptIn(DelicateCoroutinesApi::class)
public fun TagConsumer<HTMLElement>.plotDiv(
plot: Plot,
plotlyConfig: PlotlyConfig = PlotlyConfig(),
scope: CoroutineScope = plot.manager?.context ?: GlobalScope,
): PlotlyElement = PlotlyElement(div("plotly-kt-plot").apply { plot(plotlyConfig, plot) })
/**
* Render plot in the HTML element using direct plotly API.
*/

View File

@@ -7,7 +7,6 @@ import okio.Path.Companion.toPath
/**
* Create a standalone html with the plot
* @param path the reference to html file. If null, create a temporary file
* @param show if true, start the browser after file is created
* @param config represents plotly frame configuration
*/
@UnstablePlotlyAPI

View File

@@ -0,0 +1,57 @@
@file:OptIn(ExperimentalWasmJsInterop::class)
package space.kscience.plotly
import org.w3c.dom.Element
import org.w3c.dom.events.MouseEvent
import kotlin.js.Promise
public external interface ToImgOpts {
public var format: JsString /* 'jpeg' | 'png' | 'webp' | 'svg' */
public var width: JsNumber
public var height: JsNumber
}
public external interface DownloadImgOpts {
public var format: JsString /* 'jpeg' | 'png' | 'webp' | 'svg' */
public var width: JsNumber
public var height: JsNumber
public var filename: JsString
}
@JsName("Plotly")
@JsModule("plotly.js/dist/plotly.js")
public external object PlotlyWasm {
public fun newPlot(
graphDiv: Element,
data: JsArray<JsAny> = definedExternally,
layout: JsAny = definedExternally,
config: JsAny = definedExternally
)
public fun react(
graphDiv: Element,
data: JsArray<JsAny> = definedExternally,
layout: JsAny = definedExternally,
config: JsAny = definedExternally
)
public fun update(
graphDiv: Element,
data: JsAny = definedExternally,
layout: JsAny = definedExternally
)
public fun restyle(graphDiv: Element, update: JsAny, traceIndices: JsArray<JsNumber>? = definedExternally)
public fun relayout(graphDiv: Element, update: JsAny)
public fun toImage(root: Element, opts: ToImgOpts): Promise<JsString>
public fun downloadImage(root: Element, opts: DownloadImgOpts): Promise<JsString>
}
public external interface PlotMouseEvent {
public val points: JsArray<JsAny>
public val event: MouseEvent
}

View File

@@ -0,0 +1,158 @@
@file:OptIn(ExperimentalWasmJsInterop::class)
package space.kscience.plotly
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.html.TagConsumer
import kotlinx.html.div
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import org.w3c.dom.Element
import org.w3c.dom.MutationObserver
import org.w3c.dom.MutationObserverInit
import org.w3c.dom.MutationRecord
import space.kscience.dataforge.meta.MetaRepr
import space.kscience.dataforge.meta.MetaSerializer
import space.kscience.dataforge.meta.toJson
import space.kscience.plotly.Plotly.coordinateNames
import space.kscience.plotly.models.Trace
import space.kscience.visionforge.VisionGroupCompositionChangedEvent
import space.kscience.visionforge.VisionPropertyChangedEvent
@JsFun("s => JSON.parse(s)")
private external fun json(s: String): JsAny
private fun JsonElement.toWasmJs(): JsAny {
val string = toString()
return json(string)
}
@OptIn(ExperimentalSerializationApi::class)
private fun MetaRepr.toWasmJs(): JsAny = Json.encodeToJsonElement(MetaSerializer, toMeta()).toWasmJs()
private fun List<MetaRepr>.toWasmJs(): JsArray<JsAny> = map { it.toWasmJs() }.toJsArray()
@Suppress("UNUSED_PARAMETER")
private fun myMutationObserverInit(
childList: Boolean?,
attributes: Boolean?,
): MutationObserverInit = js("({ childList: childList, attributes: attributes})")
/**
* Attach a plot to this element or update the existing plot
*/
@OptIn(DelicateCoroutinesApi::class)
public fun Element.plot(
plotlyConfig: PlotlyConfig,
plot: Plot,
scope: CoroutineScope = plot.manager?.context ?: GlobalScope
) {
//send initial data
PlotlyWasm.react(
graphDiv = this,
data = plot.data.toWasmJs(),
layout = plot.layout.toWasmJs(),
config = plotlyConfig.toWasmJs()
)
//start updates
val listenJob = scope.launch {
plot.data.forEachIndexed { index, trace: Trace ->
trace.eventFlow.filterIsInstance<VisionPropertyChangedEvent>().onEach { event ->
val traceMeta = trace.toMeta()
//wrap coordinates into an additional array because plotly API for some reason expects 2D arrays
val traceJson = JsonObject(
traceMeta.items.map { (token, item) ->
val key = token.toStringUnescaped()
val valueUnwrapped = item.toJson()
val value = if (key in coordinateNames) JsonArray(listOf(valueUnwrapped)) else valueUnwrapped
key to value
}.toMap()
)
PlotlyWasm.restyle(this@plot, traceJson.toWasmJs(), listOf(index.toJsNumber()).toJsArray())
}.launchIn(this)
}
plot.eventFlow.onEach { event ->
when (event) {
is VisionGroupCompositionChangedEvent -> PlotlyWasm.react(this@plot, plot.data.toWasmJs())
is VisionPropertyChangedEvent -> PlotlyWasm.relayout(this@plot, plot.layout.toWasmJs())
else -> {
//ignore
}
}
}.launchIn(this)
}
//observe node removal to avoid memory leak
MutationObserver { records: JsArray<MutationRecord>, _ ->
if (records.toList().firstOrNull()?.removedNodes?.length != 0) {
listenJob.cancel()
}
}.observe(this, myMutationObserverInit(childList = true, attributes = false))
}
@Deprecated("Change arguments positions", ReplaceWith("plot(plotlyConfig, plot)"))
public fun Element.plot(plot: Plot, plotlyConfig: PlotlyConfig = PlotlyConfig()): Unit = plot(plotlyConfig, plot)
/**
* Create a plot in this element
*/
public inline fun Element.plot(
scope: CoroutineScope,
plotlyConfig: PlotlyConfig = PlotlyConfig(),
plotBuilder: Plot.() -> Unit
) {
plot(plotlyConfig, Plot().apply(plotBuilder), scope)
}
public class PlotlyElement(public val div: Element)
/**
* Create a div element and render the plot in it
*/
@OptIn(DelicateCoroutinesApi::class)
public fun TagConsumer<Element>.plotDiv(
plot: Plot,
plotlyConfig: PlotlyConfig = PlotlyConfig(),
scope: CoroutineScope = plot.manager?.context ?: GlobalScope,
): PlotlyElement = PlotlyElement(div("plotly-kt-plot").apply { plot(plotlyConfig, plot) })
/**
* Render plot in the HTML element using direct plotly API.
*/
public inline fun TagConsumer<Element>.plotDiv(
scope: CoroutineScope,
plotlyConfig: PlotlyConfig = PlotlyConfig(),
plotBuilder: Plot.() -> Unit,
): PlotlyElement = PlotlyElement(div("plotly-kt-plot").apply { plot(scope, plotlyConfig, plotBuilder) })
// TODO implement events
//@OptIn(ExperimentalSerializationApi::class)
//public fun PlotlyElement.on(eventType: PlotlyEventListenerType, block: MouseEvent.(PlotlyEvent) -> Unit) {
// div.addEventListener(eventType.eventType) { event: Event ->
// val eventData = PlotlyEvent(event.points.map {
// PlotlyEventPoint(
// curveNumber = it.curveNumber as Int,
// pointNumber = it.pointNumber as? Int,
// x = Value.of(it.x),
// y = Value.of(it.y),
// data = Json.decodeFromDynamic(it.data)
// )
// })
// event.event.block(eventData)
// }
//}

View File

@@ -68,5 +68,6 @@ include(
// ":plotly:examples:fx-demo",
":plotly-kt:examples:compose-demo",
":plotly-kt:examples:js-demo",
":plotly-kt:examples:native-demo"
":plotly-kt:examples:native-demo",
":plotly-kt:examples:wasm-demo"
)

View File

@@ -8,7 +8,7 @@ kscience {
jvm()
js()
native()
// wasm()
wasmJs()
useCoroutines()
commonMain {
api("space.kscience:dataforge-context:$dataforgeVersion")

View File

@@ -7,6 +7,8 @@ kscience {
js {
binaries.library()
}
// native()
// wasmJs()
dependencies {
api(projects.visionforgeSolid)
api("space.kscience:gdml:0.5.0")
@@ -14,4 +16,9 @@ kscience {
dependencies(jvmTest) {
implementation(spclibs.logback.classic)
}
}
readme {
// TODO remove into a separate library
maturity = space.kscience.gradle.Maturity.DEPRECATED
}

View File

@@ -8,7 +8,7 @@ kscience {
jvm()
js()
native()
// wasm()
wasmJs()
useSerialization {
json()
}

View File

@@ -19,6 +19,8 @@ kscience {
}
}
}
native()
wasmJs()
useSerialization()
commonMain {