Compare commits

...

14 Commits

Author SHA1 Message Date
b5a1296070 working muon monitor 2023-12-27 21:16:13 +03:00
3f144a5dbd More or less working muon monitor 2023-12-27 17:08:24 +03:00
659b9c3525 More or less working muon monitor 2023-12-27 12:01:55 +03:00
72ead21ef0 Merge branch 'dev' into feature/compose
# Conflicts:
#	build.gradle.kts
#	demo/js-playground/src/jsMain/kotlin/JsPlaygroundApp.kt
#	demo/js-playground/src/jsMain/kotlin/gravityDemo.kt
#	demo/js-playground/src/jsMain/kotlin/markupComponent.kt
#	visionforge-core/api/visionforge-core.api
#	visionforge-threejs/src/jsMain/kotlin/space/kscience/visionforge/solid/three/ThreePlugin.kt
2023-12-25 21:31:56 +03:00
49be579bd3 Merge remote-tracking branch 'spc/dev' into dev 2023-12-24 19:46:49 +03:00
4fd5c634bb Fix vision sharing between pages 2023-12-24 19:45:05 +03:00
8deabbcb99 Merge pull request 'Event Display Tutorial' (!77) from teldufalsari/visionforge:teldufalsari/dev into dev
Reviewed-on: #77
2023-12-24 15:30:21 +03:00
60b1b1997e Merge branch 'dev' into teldufalsari/dev 2023-12-24 15:29:31 +03:00
2e2524450d update form builders 2023-12-21 09:28:45 +03:00
e36e4abb7f Advanced backwards events 2023-12-18 09:59:43 +03:00
c5c3868786 Add event display tutorial 2023-12-10 16:02:26 +03:00
c0cf852c62 Merge branch 'dev' into teldufalsari/dev 2023-12-10 15:54:11 +03:00
71f7f59cb3 More docs for ThreePlugin and VisionContainer 2023-11-20 17:29:53 +03:00
7b9fe54363 Write Javadocs for ThreePlugin 2023-11-17 22:17:03 +03:00
50 changed files with 1066 additions and 534 deletions

View File

@ -1,13 +1,32 @@
# Changelog # Changelog
## [Unreleased] ## Unreleased
### Added ### Added
### Changed
- **Breaking API** Move vision cache to upper level for renderers to avoid re-creating visions for page reload.
- **Breaking API** Forms refactor
### Deprecated
### Removed
### Fixed
### Security
## 0.3.0 - 2023-12-23
### Added
- Context receivers flag - Context receivers flag
- MeshLine for thick lines - MeshLine for thick lines
- Custom client-side events and thier processing in VisionServer - Custom client-side events and thier processing in VisionServer
- Control/input visions - Control/input visions
### Changed ### Changed
- Color accessor property is now `colorProperty`. Color uses non-nullable `invoke` instead of `set`. - Color accessor property is now `colorProperty`. Color uses non-nullable `invoke` instead of `set`.
- API update for server and pages - API update for server and pages
- Edges moved to solids module for easier construction - Edges moved to solids module for easier construction
@ -18,17 +37,14 @@
- Naming of Canvas3D options. - Naming of Canvas3D options.
- Lights are added to the scene instead of 3D options. - Lights are added to the scene instead of 3D options.
### Deprecated
### Removed
### Fixed ### Fixed
- Jupyter integration for IDEA and Jupyter lab. - Jupyter integration for IDEA and Jupyter lab.
### Security ## 0.2.0
## [0.2.0]
### Added ### Added
- Server module - Server module
- Change collector - Change collector
- Customizable accessors for colors - Customizable accessors for colors
@ -39,8 +55,8 @@
- Markdown module - Markdown module
- Tables module - Tables module
### Changed ### Changed
- Vision does not implement ItemProvider anymore. Property changes are done via `getProperty`/`setProperty` and `property` delegate. - Vision does not implement ItemProvider anymore. Property changes are done via `getProperty`/`setProperty` and `property` delegate.
- Point3D and Point2D are made separate classes instead of expect/actual (to split up different engines. - Point3D and Point2D are made separate classes instead of expect/actual (to split up different engines.
- JavaFX support moved to a separate module - JavaFX support moved to a separate module
@ -55,16 +71,10 @@
- Property listeners are not triggered if there are no changes. - Property listeners are not triggered if there are no changes.
- Feedback websocket connection in the client. - Feedback websocket connection in the client.
### Deprecated
### Removed ### Removed
- Primary modules dependencies on UI - Primary modules dependencies on UI
### Fixed ### Fixed
- Version conflicts - Version conflicts
### Security

View File

@ -1,50 +0,0 @@
@file:JsModule("react-file-drop")
@file:JsNonModule
package drop
import org.w3c.dom.DragEvent
import org.w3c.files.FileList
import react.Component
import react.Props
import react.State
sealed external class DropEffects {
@JsName("copy")
object Copy : DropEffects
@JsName("move")
object Move : DropEffects
@JsName("link")
object Link : DropEffects
@JsName("none")
object None : DropEffects
}
external interface FileDropProps : Props {
var className: String?
var targetClassName: String?
var draggingOverFrameClassName: String?
var draggingOverTargetClassName: String?
// var frame?: Exclude<HTMLElementTagNameMap[keyof HTMLElementTagNameMap], HTMLElement> | HTMLDocument;
var onFrameDragEnter: ((event: DragEvent) -> Unit)?
var onFrameDragLeave: ((event: DragEvent) -> Unit)?
var onFrameDrop: ((event: DragEvent) -> Unit)?
// var onDragOver: ReactDragEventHandler<HTMLDivElement>?
// var onDragLeave: ReactDragEventHandler<HTMLDivElement>?
var onDrop: ((files: FileList?, event: dynamic) -> Unit)?//event:DragEvent<HTMLDivElement>)
var dropEffect: DropEffects?
}
external interface FileDropState : State {
var draggingOverFrame: Boolean
var draggingOverTarget: Boolean
}
external class FileDrop : Component<FileDropProps, FileDropState> {
override fun render(): dynamic
}

View File

@ -0,0 +1,87 @@
@file:OptIn(ExperimentalComposeWebApi::class)
package space.kscience.visionforge.gdml.demo
import androidx.compose.runtime.*
import org.jetbrains.compose.web.ExperimentalComposeWebApi
import org.jetbrains.compose.web.attributes.InputType
import org.jetbrains.compose.web.attributes.name
import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.I
import org.jetbrains.compose.web.dom.Input
import org.jetbrains.compose.web.dom.Text
import org.w3c.files.FileList
//https://codepen.io/zahedkamal87/pen/PobNNwE
@Composable
fun FileDrop(
title: String = "Drop files or Click here to select files to upload.",
onFileDrop: (FileList) -> Unit,
) {
var dragOver by remember { mutableStateOf(false) }
Div({
id("dropzone")
style {
border(
width = 0.2.cssRem,
style = LineStyle.Dashed,
color = Color("#6583fe")
)
padding(2.cssRem)
borderRadius(0.25.cssRem)
backgroundColor(Color("#fff"))
textAlign("center")
fontSize(1.5.cssRem)
transitions {
all {
delay(0.25.s)
timingFunction(AnimationTimingFunction.EaseInOut)
properties("background-color")
}
}
cursor("pointer")
}
listOf("drag", "dragstart", "dragend", "dragenter").forEach {
addEventListener(it) { event ->
event.preventDefault()
event.stopPropagation()
}
}
onDragOver { event ->
event.preventDefault()
event.stopPropagation()
dragOver = true
}
onDragLeave { event ->
event.preventDefault()
event.stopPropagation()
dragOver = false
}
onDrop { event ->
event.preventDefault()
event.stopPropagation()
dragOver = false
event.dataTransfer?.files?.let {
onFileDrop(it)
}
}
}) {
I({ classes("bi", "bi-cloud-upload", "dropzone-icon") })
Text(title)
Input(type = InputType.File, attrs = {
style {
display(DisplayStyle.None)
}
classes("dropzone-input")
name("files")
})
}
}
//
//dropzone.addEventListener("click", function(e) {
// dropzone_input.click();
//});

View File

@ -69,8 +69,8 @@ fun GDMLApp(solids: Solids, initialVision: Solid?, selected: Name? = null) {
H2 { H2 {
Text("Drag and drop .gdml or .json VisionForge files here") Text("Drag and drop .gdml or .json VisionForge files here")
} }
fileDrop("(drag file here)") { files -> FileDrop("(drag file here)") { files ->
val file = files?.get(0) val file = files[0]
if (file != null) { if (file != null) {
readFileAsync(file) readFileAsync(file)
} }

View File

@ -1,30 +0,0 @@
package space.kscience.visionforge.gdml.demo
import drop.FileDrop
import kotlinx.css.*
import org.w3c.files.FileList
import react.RBuilder
import styled.css
import styled.styledDiv
//TODO move styles to inline
fun RBuilder.fileDrop(title: String, action: (files: FileList?) -> Unit) {
styledDiv {
css {
border = Border(style = BorderStyle.dashed, width = 1.px, color = Color.orange)
flexGrow = 0.0
alignContent = Align.center
}
child(FileDrop::class) {
attrs {
onDrop = { files, _ ->
console.info("loaded $files")
action(files)
}
}
+title
}
}
}

View File

@ -1,3 +0,0 @@
const ringConfig = require('@jetbrains/ring-ui/webpack.config').config;
config.module.rules.push(...ringConfig.module.rules)

View File

@ -1,3 +0,0 @@
const ringConfig = require('@jetbrains/ring-ui/webpack.config').config;
config.module.rules.push(...ringConfig.module.rules)

View File

@ -1,5 +1,6 @@
plugins { plugins {
id("space.kscience.gradle.mpp") id("space.kscience.gradle.mpp")
alias(spclibs.plugins.compose)
application application
} }
@ -14,11 +15,22 @@ kscience {
fullStack( fullStack(
"muon-monitor.js", "muon-monitor.js",
jvmConfig = { withJava() }, jvmConfig = { withJava() },
jsConfig = { useCommonJs() } // jsConfig = { useCommonJs() },
browserConfig = {
webpackTask{
cssSupport{
enabled = true
}
scssSupport{
enabled = true
}
}
}
) )
commonMain { commonMain {
implementation(projects.visionforgeSolid) implementation(projects.visionforgeSolid)
implementation(projects.visionforgeComposeHtml)
} }
jvmMain { jvmMain {
implementation("org.apache.commons:commons-math3:3.6.1") implementation("org.apache.commons:commons-math3:3.6.1")

View File

@ -4,12 +4,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import app.softwork.bootstrapcompose.Button import app.softwork.bootstrapcompose.Button
import app.softwork.bootstrapcompose.ButtonGroup
import app.softwork.bootstrapcompose.Container
import kotlinx.browser.window import kotlinx.browser.window
import kotlinx.coroutines.await import kotlinx.coroutines.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.P import org.jetbrains.compose.web.dom.P
import org.jetbrains.compose.web.dom.Span import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text import org.jetbrains.compose.web.dom.Text
@ -17,8 +18,6 @@ import org.w3c.fetch.RequestInit
import space.kscience.dataforge.meta.invoke import space.kscience.dataforge.meta.invoke
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.visionforge.Colors import space.kscience.visionforge.Colors
import space.kscience.visionforge.compose.FlexColumn
import space.kscience.visionforge.compose.FlexRow
import space.kscience.visionforge.solid.Solids import space.kscience.visionforge.solid.Solids
import space.kscience.visionforge.solid.ambientLight import space.kscience.visionforge.solid.ambientLight
import space.kscience.visionforge.solid.edges import space.kscience.visionforge.solid.edges
@ -51,16 +50,21 @@ fun MMApp(solids: Solids, model: Model, selected: Name? = null) {
val events = remember { mutableStateListOf<Event>() } val events = remember { mutableStateListOf<Event>() }
Div({ Container(fluid = true,
style { attrs = {
height(100.vh - 12.pt) style {
height(100.vh - 12.pt)
}
} }
}) { ) {
ThreeView(solids, root, selected, mmOptions) { ThreeView(
Tab("Events") { solids = solids,
solid = root,
FlexColumn { initialSelected = selected,
FlexRow { options = mmOptions,
sidebarTabs = {
Tab("Events") {
ButtonGroup {
Button("Next") { Button("Next") {
solids.context.launch { solids.context.launch {
val event = window.fetch( val event = window.fetch(
@ -84,23 +88,24 @@ fun MMApp(solids: Solids, model: Model, selected: Name? = null) {
model.reset() model.reset()
} }
} }
}
events.forEach { event -> events.forEach { event ->
P { P {
Span { Span {
Text(event.id.toString()) Text(event.id.toString())
} }
Text(" : ") Text(" : ")
Span({ Span({
style { style {
color(Color.blue) color(Color.blue)
}
}) {
Text(event.hits.toString())
} }
}) {
Text(event.hits.toString())
} }
} }
} }
} }
} )
} }
} }

View File

@ -1,11 +1,13 @@
package ru.mipt.npm.muon.monitor package ru.mipt.npm.muon.monitor
import org.jetbrains.compose.web.css.Style
import org.jetbrains.compose.web.renderComposable import org.jetbrains.compose.web.renderComposable
import org.w3c.dom.Document import org.w3c.dom.Document
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.request import space.kscience.dataforge.context.request
import space.kscience.visionforge.Application import space.kscience.visionforge.Application
import space.kscience.visionforge.VisionManager import space.kscience.visionforge.VisionManager
import space.kscience.visionforge.compose.VisionForgeStyles
import space.kscience.visionforge.solid.Solids import space.kscience.visionforge.solid.Solids
import space.kscience.visionforge.solid.three.ThreePlugin import space.kscience.visionforge.solid.three.ThreePlugin
import space.kscience.visionforge.startApplication import space.kscience.visionforge.startApplication
@ -22,8 +24,8 @@ private class MMDemoApp : Application {
val model = Model(visionManager) val model = Model(visionManager)
val element = document.getElementById("app") ?: error("Element with id 'app' not found on page") renderComposable("app") {
renderComposable(element) { Style(VisionForgeStyles)
MMApp(context.request(Solids), model) MMApp(context.request(Solids), model)
} }
} }

View File

@ -1,3 +0,0 @@
const ringConfig = require('@jetbrains/ring-ui/webpack.config').config;
config.module.rules.push(...ringConfig.module.rules)

View File

@ -16,6 +16,12 @@ kotlin {
js(IR) { js(IR) {
browser { browser {
webpackTask { webpackTask {
cssSupport{
enabled = true
}
scssSupport{
enabled = true
}
mainOutputFileName.set("js/visionforge-playground.js") mainOutputFileName.set("js/visionforge-playground.js")
} }
} }

View File

@ -10,7 +10,7 @@ import space.kscience.dataforge.context.request
import space.kscience.visionforge.VisionManager import space.kscience.visionforge.VisionManager
import space.kscience.visionforge.html.VisionOfHtmlForm import space.kscience.visionforge.html.VisionOfHtmlForm
import space.kscience.visionforge.html.VisionPage import space.kscience.visionforge.html.VisionPage
import space.kscience.visionforge.html.bindForm import space.kscience.visionforge.html.visionOfForm
import space.kscience.visionforge.onPropertyChange import space.kscience.visionforge.onPropertyChange
import space.kscience.visionforge.server.close import space.kscience.visionforge.server.close
import space.kscience.visionforge.server.openInBrowser import space.kscience.visionforge.server.openInBrowser
@ -36,7 +36,7 @@ fun main() {
visionManager, visionManager,
VisionPage.scriptHeader("js/visionforge-playground.js"), VisionPage.scriptHeader("js/visionforge-playground.js"),
) { ) {
bindForm(form) { visionOfForm(form) {
label { label {
htmlFor = "fname" htmlFor = "fname"
+"First name:" +"First name:"
@ -67,8 +67,8 @@ fun main() {
value = "Submit" value = "Submit"
} }
} }
println(form.values)
vision(form) vision(form)
println(form.values)
} }
}.start(false) }.start(false)

View File

@ -1,23 +0,0 @@
const ringConfig = require('@jetbrains/ring-ui/webpack.config').config;
const path = require('path');
config.module.rules.push(...ringConfig.module.rules)
config.module.rules.push(
{
test: /\.css$/,
exclude: [
path.resolve(__dirname, "../../node_modules/@jetbrains/ring-ui")
],
use: [
{
loader: 'style-loader',
options: {}
},
{
loader: 'css-loader',
options: {}
}
]
}
)

View File

@ -12,7 +12,9 @@ kscience {
// useSerialization { // useSerialization {
// json() // json()
// } // }
jvm() jvm{
withJava()
}
jvmMain{ jvmMain{
implementation("io.ktor:ktor-server-cio") implementation("io.ktor:ktor-server-cio")
implementation(projects.visionforgeThreejs.visionforgeThreejsServer) implementation(projects.visionforgeThreejs.visionforgeThreejsServer)

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,416 @@
# Event Display Tutorial
In this tutorial, we will explore properties of Visions and build a simple front-end application. You may find a complete project [here](https://git.sciprog.center/teldufalsari/visionforge-event-display-demo).
__NOTE:__ You will need Kotlin Multiplatform 1.9.0 or higher to complete this tutorial!
### Starting the Project
We will use Idea's default project template for Kotlin Multiplatform. To initialize the project, go to *File -> New -> Project...*, Then choose *Full-Stack Web Application* project template and
*Kotlin Gradle* build system. Then select *Next -> Finish*. You will end up with a project with some sample code.
To check that everything is working correctly, run *application -> run* Gradle target. You should see a greeting page when you open `http://localhost:8080` in a web browser.
We will use Kotlin React as our main UI library and Ktor Netty both as a web server. Our event display frontend and server will reside in `jsMain` and `jvmMain` directories respectively.
Before we start, we have to load necessary dependencies:
* Add SciProgCentre maven repo in `build.gradle.kts` file:
```kotlin
repositories {
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/kotlinx-html/maven")
// Add either the line below:
maven("https://repo.kotlin.link")
// Or this line:
maven("https://maven.sciprog.center/kscience")
}
```
* Add `visionforge-threejs-server` into the list of JS dependencies of your project:
```kotlin
kotlin {
sourceSets {
val jsMain by getting {
dependencies {
implementation("space.kscience:visionforge-threejs-server:0.3.0-dev-14")
}
}
}
}
```
Refresh build model in the Idea to make sure the dependencies are successfully resolved.
__NOTE:__ In previous versions of VisionForge, some imports may be broken. If these dependencies fail to resolve, replace `space.kscience:visionforge-threejs-server:0.3.0-dev-14` with `space.kscience:visionforge-threejs:0.3.0-dev-14`. The resulting bundle will lack a React component used in the tutorial (see "Managing Visions"). You may copy and paste it directly from either [VisionForge](https://git.sciprog.center/kscience/visionforge/src/branch/master/ui/react/src/main/kotlin/space/kscience/visionforge/react/ThreeCanvasComponent.kt) or the [tutorial repo](https://git.sciprog.center/teldufalsari/visionforge-event-display-demo/src/branch/main/src/jsMain/kotlin/canvas/ThreeCanvasComponent.kt), or even come up with a better implementation if your own.
### Setting up Page Markup
We need to create a page layout and set up Netty to serve our page to clients. There is nothing special related to VisionForge, so feel free to copy and paste the code below.
File: `src/jvmMain/.../Server.kt`
```kotlin
// ... imports go here
fun HTML.index() {
head {
// Compatibility headers
meta { charset = "UTF-8" }
meta {
name = "viewport"
content = "width=device-width, initial-scale=1.0"
}
meta {
httpEquiv = "X-UA-Compatible"
content = "IE=edge"
}
title("VF Demo")
}
// Link to our react script
body {
script(src = "/static/vf-demo.js") {}
}
}
fun main() {
// Seting up Netty
embeddedServer(Netty, port = 8080, host = "127.0.0.1") {
routing {
get("/") {
call.respondHtml(HttpStatusCode.OK, HTML::index)
}
static("/static") {
resources()
}
}
}.start(wait = true)
}
```
File: `src/jsMain/.../Client.kt`
```kotlin
fun main() {
val container = document.createElement("div")
document.body!!.appendChild(container)
val eventDisplay = EventDisplay.create {}
createRoot(container).render(eventDisplay)
}
```
File: `src/jsMain/.../Display.kt`
```kotlin
// All markup goes here:
val EventDisplay = FC<Props> {
// Global CSS rules
Global {
styles {
"html,\n" +
"body" {
height = 100.vh
width = 100.vw
margin = 0.px
}
"body > div" {
height = 100.vh
width = 100.vw
display = Display.flex
flexDirection = FlexDirection.column
justifyContent = JustifyContent.start
alignItems = AlignItems.center
}
"*,\n" +
"*:before,\n" +
"*:after" {
boxSizing = BoxSizing.borderBox
}
}
}
div {
css {
height = 100.pct
width = 100.pct
display = Display.flex
flexDirection = FlexDirection.column
alignItems = AlignItems.center
}
div {
css {
width = 100.pct
display = Display.flex
flexDirection = FlexDirection.row
alignItems = AlignItems.center
justifyContent = JustifyContent.center
}
input {
css {
margin = 5.px
padding = 5.px
}
type = InputType.button
value = "Update Events"
}
input {
css {
margin = 5.px
padding = 5.px
}
type = InputType.button
value = "Update Geometry"
}
}
div {
css {
width = 98.pct
height = 1.pct
margin = 5.px
display = Display.flex
flexGrow = number(1.0)
justifyContent = JustifyContent.center
alignItems = AlignItems.center
backgroundColor = Color("#b3b3b3")
}
}
}
}
```
After setting everything up, you should see a gray rectangle with two buttons above it when opening `localhost:8080`.
### Managing Visions
We are approaching the main part of the tutorial - the place where we will create a working demo. In particle accelerator experiments, event displays are employed to visualise particle collision events. Essentially, it requires drawing a detector setup and visual interpretation of events: tracks, detector hits etc. Usually, a number of events share a common detector setup (e.g. if these events occured in a single experiment run). It makes sense to update and re-render only event information, while keeping detector geometry constant between updates.
Visions (namely, the `SolidGroup` class) allow us to create an object tree for our displayed event. `SolidGroup` can hold other Visions as its child nodes, access these nodes by names and update/delete them. We will use this property to update our event display efficiently.
To display Visions as actual 3D object, we will use `ThreePlugin` that renders Visions using *three.js* library. The plugin allows us to create a Three.js representation of a vision that will observe changes of its correspondent Vision. This way we can update only Visions without diving deep into three.js stuff. Using observable Visions is also efficient: Three.js representations are not generated from scratch after each Vision update but are modified too.
First, let's simulate data load operations:
* Add state variables to our `EventDisplay` React component. These variables will be treated as data loaded from a remote server. In real life, these may be JSON string with event data:
```kotlin
val EventDisplay = FC<Props> {
// ...
var eventData: kotlin.Float? by useState(null)
var geometryData: kotlin.Float? by useState(null)
// ...
}
```
* Write two simple functions that will convert data to a Vision. In this case, we will simply parameters of solids like color of size; in real life, these functions will usually take raw data and convert it into Visions.
```kotlin
fun generateEvents(radius: Float): SolidGroup {
val count = Random.nextInt(10, 20)
return SolidGroup {
repeat(count) {
sphere(radius) {
x = 5.0 * (Random.nextFloat() - 0.5)
y = 2.0 * (Random.nextFloat() - 0.5)
z = 2.0 * (Random.nextFloat() - 0.5)
color(Colors.red)
}
}
}
}
fun generateGeometry(distance: Float): SolidGroup {
return SolidGroup {
box(10, 3, 3) {
x = 0.0
y = -distance
z = 0.0
color(Colors.gray)
}
box(10, 3, 3) {
x = 0.0
y = distance
z = 0.0
color(Colors.gray)
}
}
}
```
* Then, let's create our main Vision and add a static light source:
```kotlin
val EventDisplay = FC<Props> {
// ...
val containedVision: SolidGroup by useState(SolidGroup {
ambientLight {
color(Colors.white)
}
})
// ...
}
```
* A `Context` object is required to hold plugins like `ThreePlugin`. It is also necessary to make Visions observable: we have to root our main Vision in the context. Declare a global `Context` in the same file with `EventDisplay` component:
```kotlin
val viewContext = Context {
plugin(Solids)
plugin(ThreePlugin)
}
```
* Import `ThreeCanvasComponent` from VisionForge. This is a React component that handles all display work. It creates three.js canvas, attaches it to its own parent element and creates and draws `Object3D` on the canvas. We will attach this component to a
separate React component. Note order for Visions to update their Three.js representations, these Visions need to be rooted in a `Context`. This way Visions will be observed for changes, and any such change will trigger an update of the corresponding Three.js object.
```kotlin
external interface EventViewProps: Props {
var displayedVision: Solid?
var context: Context
}
val EventView = FC<EventViewProps> { props ->
ThreeCanvasComponent {
solid = props.displayedVision
context = props.context
}
// Make displayedVision observed:
useEffect(props.displayedVision) {
props.displayedVision?.setAsRoot(props.context.visionManager)
}
}
```
__NOTE:__ If you had problems with dependency resolution, `ThreeCanvasComponent` may missing from your import scope. You may find a compatible implementation [here](https://git.sciprog.center/teldufalsari/visionforge-event-display-demo/src/branch/main/src/jsMain/kotlin/canvas/ThreeCanvasComponent.kt).
* Finally, we need to attach EventView to our main component and connect raw data updates to Vision updates using React hooks:
```kotlin
// ...
// Names used as keys to access and update Visions
// Refer to DataForge documentation for more details
val EVENTS_NAME = "DEMO_EVENTS".parseAsName(false)
val GEOMETRY_NAME = "DEMO_GEOMETRY".parseAsName(false)
// ...
val EventDisplay = FC<Props> {
// ...
useEffect(eventData) {
eventData?.let {
containedVision.setChild(EVENTS_NAME, generateEvents(it))
}
}
useEffect(geometryData) {
geometryData?.let {
containedVision.setChild(GEOMETRY_NAME, generateGeometry(it))
}
}
// ...
div {
// ...
div {
css {
width = 98.pct
height = 1.pct
flexGrow = number(1.0)
margin = 5.px
display = Display.flex
justifyContent = JustifyContent.center
alignItems = AlignItems.center
}
// Replace the gray rectangle with an EventView:
EventView {
displayedVision = containedVision
context = viewContext
}
}
}
// ...
}
```
When we press either of the buttons, corresponding raw data changes. This update triggers `UseEffect` hook, which generates new event or geometry data and replaces the old data in the main Vision. Three.js representation is then updated to match our new Vision, so that changes are visible on the canvas.
Recompile the project and go on `http://localhost:8080`. See how the displayed scene changes with each click: for example, when you update geometry, only the distance between "magnets" varies, but spheres remain intact.
### Clearing the Scene
We can erase children Visions from the scene completely. To do so, we cat pass `null` to the function `setChild` as `child` argument. Add these lines to the hooks that update Visions to remove the corresponding Vision from our diplayed `SolidGroup` when raw data changes to `null`:
```kotlin
useEffect(eventData) {
// ...
if (eventData == null) {
containedVision.setChild(EVENT_NAME, null)
}
}
useEffect(geometryData) {
// ...
if (geometryData == null) {
containedVision.setChild(GEOMETRY_NAME, null)
}
}
```
To test how this works, let's create an erase button that will completely clear the scene:
```kotlin
val EventDisplay = FC<Props> {
// ...
div {
// ...
input {
css {
margin = 5.px
padding = 5.px
backgroundColor = NamedColor.lightcoral
color = NamedColor.white
}
type = InputType.button
value = "Clear Scene"
onClick = {
geometryData = null
eventData = null
}
}
}
// ...
}
```
![Picture of an event display with a red button with a caption "Clear Scene" added to the two previous buttons](../images/event-display-final.png "Scene clearing function")
### Making Selection Fine-Grained
You may feel annoyed by how selection works in our demo. That's right, selecting the whole detector or the entire event array is not that useful. This is due to the fact that VisionForge selects object based on names. We used names to distinguish SolidGroups, but in fact not only groups but every single Vision can have a name. But when we were randomly generating Vision, we did not use any names, did we? Right, Vision can be nameless, in which case they are treated as a monolithic object together with their parent. So it should be clear now that when we were selecting a single rectangle, we were in fact selecting the whole pair of rectangles and the other one went lit up as well.
Fortunately, every `Solid` constructor takes a `name` parameter after essential parameters, so it should be easy to fix it. Go to the generator functions and add the change construction invocations to the following:
```kotlin
fun generateGeometry(distance: Float): SolidGroup {
return SolidGroup {
box(10, 3, 3, "Magnet1") {
// ...
}
box(10, 3, 3, "Magnet2") {
// ...
}
}
}
```
For the events part, we will use the index in a loop as a name:
```kotlin
fun generateEvents(radius: Float): SolidGroup {
// ...
repeat(count) {
sphere(radius, it.toString()) {
// ...
}
```
After you update the build, it should be possible to select only one sphere or rectangle:
![Picture of an event display with only one sphere between the magnets selected](../images/event-display-selection.png "Selection demonstration")

View File

@ -41,11 +41,11 @@ dependencyResolutionManagement {
include( include(
// ":ui", // ":ui",
":ui:react", // ":ui:react",
":ui:ring", // ":ui:ring",
// ":ui:material", // ":ui:material",
":ui:bootstrap", // ":ui:bootstrap",
":visionforge-compose", ":visionforge-compose-html",
":visionforge-core", ":visionforge-core",
":visionforge-solid", ":visionforge-solid",
// ":visionforge-fx", // ":visionforge-fx",

View File

@ -1,10 +1,9 @@
plugins { plugins {
id("space.kscience.gradle.mpp") id("space.kscience.gradle.mpp")
alias(spclibs.plugins.compose) alias(spclibs.plugins.compose)
} }
kscience{ kscience {
jvm() jvm()
js() js()
// wasm() // wasm()
@ -13,22 +12,22 @@ kscience{
kotlin { kotlin {
// android() // android()
sourceSets { sourceSets {
commonMain{ commonMain {
dependencies{ dependencies {
api(projects.visionforgeCore) api(projects.visionforgeCore)
api(compose.runtime)
} }
} }
val jvmMain by getting { val jvmMain by getting {
dependencies { dependencies {
api(compose.runtime)
api(compose.foundation) api(compose.foundation)
api(compose.material) api(compose.material)
api(compose.preview) api(compose.preview)
} }
} }
val jsMain by getting{ val jsMain by getting {
dependencies { dependencies {
api(compose.html.core) api(compose.html.core)
api("app.softwork:bootstrap-compose:0.1.15") api("app.softwork:bootstrap-compose:0.1.15")

View File

@ -11,9 +11,15 @@ import space.kscience.dataforge.names.length
@Composable @Composable
public fun NameCrumbs(name: Name?, link: (Name) -> Unit): Unit = Nav({ public fun NameCrumbs(name: Name?, link: (Name) -> Unit): Unit = Nav({
attr("aria-label","breadcrumb") attr("aria-label", "breadcrumb")
}) { }) {
Ol({classes("breadcrumb")}) { Ol({
classes("breadcrumb")
style {
property("--bs-breadcrumb-divider", "'.'")
property("--bs-breadcrumb-item-padding-x",".1rem")
}
}) {
Li({ Li({
classes("breadcrumb-item") classes("breadcrumb-item")
onClick { onClick {
@ -28,10 +34,9 @@ public fun NameCrumbs(name: Name?, link: (Name) -> Unit): Unit = Nav({
name.tokens.forEach { token -> name.tokens.forEach { token ->
tokens.add(token) tokens.add(token)
val fullName = Name(tokens.toList()) val fullName = Name(tokens.toList())
Text(".")
Li({ Li({
classes("breadcrumb-item") classes("breadcrumb-item")
if(tokens.size == name.length) classes("active") if (tokens.size == name.length) classes("active")
onClick { onClick {
link(fullName) link(fullName)
} }

View File

@ -1,16 +1,16 @@
package space.kscience.visionforge.compose package space.kscience.visionforge.compose
import androidx.compose.runtime.* import androidx.compose.runtime.*
import app.softwork.bootstrapcompose.CloseButton
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.web.attributes.disabled
import org.jetbrains.compose.web.css.AlignItems import org.jetbrains.compose.web.css.AlignItems
import org.jetbrains.compose.web.css.alignItems import org.jetbrains.compose.web.css.alignItems
import org.jetbrains.compose.web.css.px import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.css.width import org.jetbrains.compose.web.css.width
import org.jetbrains.compose.web.dom.Button
import org.jetbrains.compose.web.dom.Div import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.Span import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text import org.jetbrains.compose.web.dom.Text
@ -35,13 +35,13 @@ public sealed class EditorPropertyState {
} }
/** /**
* @param meta Root config object - always non-null * @param rootMeta Root config object - always non-null
* @param rootDescriptor Full path to the displayed node in [meta]. Could be empty * @param rootDescriptor Full path to the displayed node in [rootMeta]. Could be empty
*/ */
@Composable @Composable
public fun PropertyEditor( public fun PropertyEditor(
scope: CoroutineScope, scope: CoroutineScope,
meta: MutableMeta, rootMeta: MutableMeta,
getPropertyState: (Name) -> EditorPropertyState, getPropertyState: (Name) -> EditorPropertyState,
updates: Flow<Name>, updates: Flow<Name>,
name: Name = Name.EMPTY, name: Name = Name.EMPTY,
@ -50,11 +50,11 @@ public fun PropertyEditor(
) { ) {
var expanded: Boolean by remember { mutableStateOf(initialExpanded ?: true) } var expanded: Boolean by remember { mutableStateOf(initialExpanded ?: true) }
val descriptor: MetaDescriptor? = remember(rootDescriptor, name) { rootDescriptor?.get(name) } val descriptor: MetaDescriptor? = remember(rootDescriptor, name) { rootDescriptor?.get(name) }
var property: MutableMeta by remember { mutableStateOf(meta.getOrCreate(name)) } var property: MutableMeta by remember { mutableStateOf(rootMeta.getOrCreate(name)) }
var editorPropertyState: EditorPropertyState by remember { mutableStateOf(getPropertyState(name)) } var editorPropertyState: EditorPropertyState by remember { mutableStateOf(getPropertyState(name)) }
val keys = remember(descriptor) { val keys by derivedStateOf {
buildSet { buildSet {
descriptor?.children?.filterNot { descriptor?.children?.filterNot {
it.key.startsWith("@") || it.value.hidden it.key.startsWith("@") || it.value.hidden
@ -68,11 +68,11 @@ public fun PropertyEditor(
val token = name.lastOrNull()?.toString() ?: "Properties" val token = name.lastOrNull()?.toString() ?: "Properties"
fun update() { fun update() {
property = meta.getOrCreate(name) property = rootMeta.getOrCreate(name)
editorPropertyState = getPropertyState(name) editorPropertyState = getPropertyState(name)
} }
LaunchedEffect(meta) { LaunchedEffect(rootMeta) {
updates.collect { updatedName -> updates.collect { updatedName ->
if (updatedName == name) { if (updatedName == name) {
update() update()
@ -116,18 +116,9 @@ public fun PropertyEditor(
} }
} }
Button({ CloseButton(editorPropertyState != EditorPropertyState.Defined){
classes(TreeStyles.propertyEditorButton) rootMeta.remove(name)
if (editorPropertyState != EditorPropertyState.Defined) { update()
disabled()
} else {
onClick {
meta.remove(name)
update()
}
}
}) {
Text("\u00D7")
} }
} }
} }
@ -139,7 +130,7 @@ public fun PropertyEditor(
Div({ Div({
classes(TreeStyles.treeItem) classes(TreeStyles.treeItem)
}) { }) {
PropertyEditor(scope, meta, getPropertyState, updates, name + token, descriptor, expanded) PropertyEditor(scope, rootMeta, getPropertyState, updates, name + token, rootDescriptor, expanded)
} }
} }
} }
@ -155,7 +146,7 @@ public fun PropertyEditor(
) { ) {
PropertyEditor( PropertyEditor(
scope = scope, scope = scope,
meta = properties, rootMeta = properties,
getPropertyState = { name -> getPropertyState = { name ->
if (properties[name] != null) { if (properties[name] != null) {
EditorPropertyState.Defined EditorPropertyState.Defined
@ -172,9 +163,7 @@ public fun PropertyEditor(
} }
} }
invokeOnClose { awaitClose { properties.removeListener(scope) }
properties.removeListener(scope)
}
}, },
name = Name.EMPTY, name = Name.EMPTY,
rootDescriptor = descriptor, rootDescriptor = descriptor,

View File

@ -0,0 +1,94 @@
package space.kscience.visionforge.compose
import androidx.compose.runtime.*
import app.softwork.bootstrapcompose.Card
import app.softwork.bootstrapcompose.NavbarLink
import app.softwork.bootstrapcompose.Styling
import org.jetbrains.compose.web.css.overflowY
import org.jetbrains.compose.web.dom.*
import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.HTMLDivElement
public class ComposeTab(
public val key: String,
public val title: ContentBuilder<HTMLAnchorElement>,
public val disabled: Boolean,
public val content: ContentBuilder<HTMLDivElement>,
)
@Composable
public fun Tabs(
tabs: List<ComposeTab>,
activeKey: String,
styling: (Styling.() -> Unit)? = null,
attrs: AttrBuilderContext<HTMLDivElement>? = null,
) {
var active by remember(activeKey) { mutableStateOf(activeKey) }
val activeTab by derivedStateOf { tabs.find { it.key == active } }
Card(
styling,
attrs,
header = {
Ul({ classes("nav", "nav-tabs", "card-header-tabs") }) {
tabs.forEach { tab ->
Li({
classes("nav-item")
}) {
NavbarLink(
active = active == tab.key,
disabled = tab.disabled,
attrs = {
onClick { event ->
event.preventDefault()
active = tab.key
}
}
) {
tab.title.invoke(this)
}
}
}
}
},
bodyAttrs = {
style {
overflowY("auto")
}
}
) {
activeTab?.content?.invoke(this)
}
}
public class TabsBuilder {
internal val tabs: MutableList<ComposeTab> = mutableListOf()
@Composable
public fun Tab(
key: String,
label: ContentBuilder<HTMLAnchorElement> = { Text(key) },
disabled: Boolean = false,
content: ContentBuilder<HTMLDivElement>,
) {
tabs.add(ComposeTab(key, label, disabled, content))
}
public fun addTab(tab: ComposeTab) {
tabs.add(tab)
}
}
@Composable
public fun Tabs(
activeKey: String? = null,
styling: (Styling.() -> Unit)? = null,
attrs: AttrBuilderContext<HTMLDivElement>? = null,
builder: @Composable TabsBuilder.() -> Unit,
) {
val result = TabsBuilder().apply { builder() }
Tabs(result.tabs, activeKey ?: result.tabs.firstOrNull()?.key ?: "", styling, attrs)
}

View File

@ -5,7 +5,7 @@ import org.jetbrains.compose.web.css.*
@OptIn(ExperimentalComposeWebApi::class) @OptIn(ExperimentalComposeWebApi::class)
public object TreeStyles : StyleSheet() { public object TreeStyles : StyleSheet(VisionForgeStyles) {
/** /**
* Remove default bullets * Remove default bullets
*/ */
@ -22,7 +22,7 @@ public object TreeStyles : StyleSheet() {
cursor("pointer") cursor("pointer")
userSelect(UserSelect.none) userSelect(UserSelect.none)
/* Create the caret/arrow with a unicode, and style it */ /* Create the caret/arrow with a unicode, and style it */
before { (self + before) {
content("\u25B6") content("\u25B6")
color(Color.black) color(Color.black)
display(DisplayStyle.InlineBlock) display(DisplayStyle.InlineBlock)
@ -34,7 +34,7 @@ public object TreeStyles : StyleSheet() {
* Rotate the caret/arrow icon when clicked on (using JavaScript) * Rotate the caret/arrow icon when clicked on (using JavaScript)
*/ */
public val treeCaretDown: String by style { public val treeCaretDown: String by style {
before { (self + before) {
content("\u25B6") content("\u25B6")
color(Color.black) color(Color.black)
display(DisplayStyle.InlineBlock) display(DisplayStyle.InlineBlock)
@ -46,13 +46,11 @@ public object TreeStyles : StyleSheet() {
public val treeItem: String by style { public val treeItem: String by style {
alignItems(AlignItems.Center) alignItems(AlignItems.Center)
paddingLeft(10.px) paddingLeft(10.px)
border { property("border-left", CSSBorder().apply{
left { width(1.px)
width(1.px) color(Color.lightgray)
color(Color.lightgray) style = LineStyle.Dashed
style = LineStyle.Dashed })
}
}
} }
public val treeLabel: String by style { public val treeLabel: String by style {
@ -75,16 +73,16 @@ public object TreeStyles : StyleSheet() {
alignSelf(AlignSelf.Stretch) alignSelf(AlignSelf.Stretch)
marginAll(1.px, 5.px) marginAll(1.px, 5.px)
backgroundColor(Color.white) backgroundColor(Color.white)
border{ border {
style(LineStyle.Solid) style(LineStyle.Solid)
} }
borderRadius(2.px) borderRadius(2.px)
textAlign("center") textAlign("center")
textDecoration("none") textDecoration("none")
cursor("pointer") cursor("pointer")
disabled { (self + disabled) {
cursor("auto") cursor("auto")
border{ border {
style(LineStyle.Dashed) style(LineStyle.Dashed)
} }
color(Color.lightgray) color(Color.lightgray)

View File

@ -0,0 +1,7 @@
package space.kscience.visionforge.compose
import org.jetbrains.compose.web.css.StyleSheet
public object VisionForgeStyles: StyleSheet() {
}

View File

@ -4,7 +4,6 @@ import androidx.compose.runtime.*
import org.jetbrains.compose.web.css.Color import org.jetbrains.compose.web.css.Color
import org.jetbrains.compose.web.css.color import org.jetbrains.compose.web.css.color
import org.jetbrains.compose.web.css.cursor import org.jetbrains.compose.web.css.cursor
import org.jetbrains.compose.web.css.textDecorationLine
import org.jetbrains.compose.web.dom.Div import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.Span import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text import org.jetbrains.compose.web.dom.Text
@ -15,8 +14,6 @@ import space.kscience.dataforge.names.startsWith
import space.kscience.visionforge.Vision import space.kscience.visionforge.Vision
import space.kscience.visionforge.VisionGroup import space.kscience.visionforge.VisionGroup
import space.kscience.visionforge.asSequence import space.kscience.visionforge.asSequence
import space.kscience.visionforge.compose.TreeStyles.hover
import space.kscience.visionforge.compose.TreeStyles.invoke
import space.kscience.visionforge.isEmpty import space.kscience.visionforge.isEmpty
@ -35,10 +32,6 @@ private fun TreeLabel(
style { style {
color(Color("#069")) color(Color("#069"))
cursor("pointer") cursor("pointer")
hover.invoke {
textDecorationLine("underline")
}
} }
onClick { clickCallback(name) } onClick { clickCallback(name) }
}) { }) {

View File

@ -4,14 +4,10 @@ package space.kscience.visionforge.compose
import androidx.compose.runtime.* import androidx.compose.runtime.*
import org.jetbrains.compose.web.attributes.* import org.jetbrains.compose.web.attributes.*
import org.jetbrains.compose.web.css.percent
import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.css.width
import org.jetbrains.compose.web.dom.Input import org.jetbrains.compose.web.dom.Input
import org.jetbrains.compose.web.dom.Option import org.jetbrains.compose.web.dom.Option
import org.jetbrains.compose.web.dom.Select import org.jetbrains.compose.web.dom.Select
import org.jetbrains.compose.web.dom.Text import org.jetbrains.compose.web.dom.Text
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLOptionElement import org.w3c.dom.HTMLOptionElement
import org.w3c.dom.asList import org.w3c.dom.asList
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.*
@ -29,20 +25,16 @@ public fun StringValueChooser(
value: Value?, value: Value?,
onValueChange: (Value?) -> Unit, onValueChange: (Value?) -> Unit,
) { ) {
var stringValue by remember { mutableStateOf(value?.string ?: "") } var stringValue by remember(value, descriptor) { mutableStateOf(value?.string ?: "") }
Input(type = InputType.Text) { Input(type = InputType.Text) {
style { classes("w-100")
width(100.percent)
}
value(stringValue) value(stringValue)
onKeyDown { event -> onChange { event ->
if (event.type == "keydown" && event.asDynamic().key == "Enter") { stringValue = event.value
stringValue = (event.target as HTMLInputElement).value
onValueChange(stringValue.asValue())
}
} }
onChange { onInput { event ->
stringValue = it.target.value stringValue = event.value
onValueChange(event.value.asValue())
} }
} }
} }
@ -55,16 +47,18 @@ public fun BooleanValueChooser(
value: Value?, value: Value?,
onValueChange: (Value?) -> Unit, onValueChange: (Value?) -> Unit,
) { ) {
var innerValue by remember(value, descriptor) {
mutableStateOf(
value?.boolean ?: descriptor?.defaultValue?.boolean
)
}
Input(type = InputType.Checkbox) { Input(type = InputType.Checkbox) {
style { classes("w-100")
width(100.percent) checked(innerValue ?: false)
}
//this.attributes["indeterminate"] = (props.item == null).toString()
checked(value?.boolean ?: false)
onChange { onInput { event ->
val newValue = it.target.checked innerValue = event.value
onValueChange(newValue.asValue()) onValueChange(event.value.asValue())
} }
} }
} }
@ -76,25 +70,18 @@ public fun NumberValueChooser(
value: Value?, value: Value?,
onValueChange: (Value?) -> Unit, onValueChange: (Value?) -> Unit,
) { ) {
var innerValue by remember { mutableStateOf(value?.string ?: "") } var innerValue by remember(value, descriptor) { mutableStateOf(value?.number) }
Input(type = InputType.Number) { Input(type = InputType.Number) {
style { classes("w-100")
width(100.percent)
value(innerValue ?: descriptor?.defaultValue?.number ?: 0.0)
onChange { event ->
innerValue = event.value
} }
value(innerValue) onInput { event ->
onKeyDown { event -> innerValue = event.value
if (event.type == "keydown" && event.asDynamic().key == "Enter") { onValueChange(event.value?.asValue())
innerValue = (event.target as HTMLInputElement).value
val number = innerValue.toDoubleOrNull()
if (number == null) {
console.error("The input value $innerValue is not a number")
} else {
onValueChange(number.asValue())
}
}
}
onChange {
innerValue = it.target.value
} }
descriptor?.attributes?.get("step").number?.let { descriptor?.attributes?.get("step").number?.let {
step(it) step(it)
@ -116,11 +103,10 @@ public fun ComboValueChooser(
value: Value?, value: Value?,
onValueChange: (Value?) -> Unit, onValueChange: (Value?) -> Unit,
) { ) {
var selected by remember { mutableStateOf(value?.string ?: "") } var selected by remember(value, descriptor) { mutableStateOf(value?.string ?: "") }
Select({ Select({
style { classes("w-100")
width(100.percent)
}
onChange { onChange {
selected = it.target.value selected = it.target.value
onValueChange(selected.asValue()) onValueChange(selected.asValue())
@ -142,11 +128,11 @@ public fun ColorValueChooser(
value: Value?, value: Value?,
onValueChange: (Value?) -> Unit, onValueChange: (Value?) -> Unit,
) { ) {
var innerValue by remember { mutableStateOf<String?>(value?.string ?: descriptor?.defaultValue?.string) }
Input(type = InputType.Color) { Input(type = InputType.Color) {
style { classes("w-100")
width(100.percent)
marginAll(0.px)
}
value( value(
value?.let { value -> value?.let { value ->
if (value.type == ValueType.NUMBER) Colors.rgbToString(value.int) if (value.type == ValueType.NUMBER) Colors.rgbToString(value.int)
@ -154,8 +140,12 @@ public fun ColorValueChooser(
//else "#" + Color(value.string).getHexString() //else "#" + Color(value.string).getHexString()
} ?: "#000000" } ?: "#000000"
) )
onChange { onChange { event ->
onValueChange(it.target.value.asValue()) innerValue = event.value
}
onInput { event ->
innerValue = event.value
onValueChange(event.value.asValue())
} }
} }
} }
@ -194,7 +184,7 @@ public fun RangeValueChooser(
value: Value?, value: Value?,
onValueChange: (Value?) -> Unit, onValueChange: (Value?) -> Unit,
) { ) {
var innerValue by remember { mutableStateOf(value?.double) } var innerValue by remember(value, descriptor) { mutableStateOf(value?.double) }
var rangeDisabled: Boolean by remember { mutableStateOf(state != EditorPropertyState.Defined) } var rangeDisabled: Boolean by remember { mutableStateOf(state != EditorPropertyState.Defined) }
@ -219,9 +209,8 @@ public fun RangeValueChooser(
} }
Input(type = InputType.Range) { Input(type = InputType.Range) {
style { classes("w-100")
width(100.percent)
}
if (rangeDisabled) disabled() if (rangeDisabled) disabled()
value(innerValue?.toString() ?: "") value(innerValue?.toString() ?: "")
onChange { onChange {

View File

@ -1,102 +0,0 @@
package space.kscience.visionforge.compose
import androidx.compose.runtime.*
import org.jetbrains.compose.web.dom.*
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLLIElement
public class ComposeTab(
public val key: String,
public val title: String,
public val content: ContentBuilder<HTMLDivElement>,
public val disabled: Boolean,
public val titleExt: ContentBuilder<HTMLLIElement>,
)
@Composable
public fun Tabs(tabs: List<ComposeTab>, activeKey: String) {
var active by remember(activeKey) { mutableStateOf(activeKey) }
Div({ classes("card", "text-center") }) {
Div({ classes("card-header") }) {
Ul({ classes("nav", "nav-tabs", "card-header-tabs") }) {
tabs.forEach { tab ->
Li({
classes("nav-item")
}) {
A(attrs = {
classes("nav-link")
if (active == tab.key) {
classes("active")
}
if (tab.disabled) {
classes("disabled")
}
onClick {
active = tab.key
}
}) {
Text(tab.title)
}
tab.titleExt.invoke(this)
}
}
}
}
tabs.find { it.key == active }?.let { tab ->
Div({ classes("card-body") }) {
tab.content.invoke(this)
}
}
}
}
public class TabBuilder internal constructor(public val key: String) {
private var title: String = key
public var disabled: Boolean = false
private var content: ContentBuilder<HTMLDivElement> = {}
private var titleExt: ContentBuilder<HTMLLIElement> = {}
@Composable
public fun Content(content: ContentBuilder<HTMLDivElement>) {
this.content = content
}
@Composable
public fun Title(title: String, titleExt: ContentBuilder<HTMLLIElement> = {}) {
this.title = title
this.titleExt = titleExt
}
internal fun build(): ComposeTab = ComposeTab(
key,
title,
content,
disabled,
titleExt
)
}
public class TabsBuilder {
public var active: String = ""
internal val tabs: MutableList<ComposeTab> = mutableListOf()
@Composable
public fun Tab(key: String, builder: @Composable TabBuilder.() -> Unit) {
tabs.add(TabBuilder(key).apply { builder() }.build())
}
public fun addTab(tab: ComposeTab) {
tabs.add(tab)
}
}
@Composable
public fun Tabs(builder: @Composable TabsBuilder.() -> Unit) {
val result = TabsBuilder().apply { builder() }
Tabs(result.tabs, result.active)
}

View File

@ -39,7 +39,8 @@ public interface ControlVision : Vision {
@Serializable @Serializable
@SerialName("control.click") @SerialName("control.click")
public class VisionClickEvent(override val meta: Meta) : VisionControlEvent() { public class VisionClickEvent(override val meta: Meta) : VisionControlEvent() {
public val payload: Meta? by meta.node() public val payload: Meta get() = meta[::payload.name] ?: Meta.EMPTY
public val name: Name? get() = meta["name"].string?.parseAsName() public val name: Name? get() = meta["name"].string?.parseAsName()
override fun toString(): String = meta.toString() override fun toString(): String = meta.toString()

View File

@ -10,10 +10,17 @@ import space.kscience.visionforge.VisionChildren.Companion.STATIC_TOKEN_BODY
@DslMarker @DslMarker
public annotation class VisionBuilder public annotation class VisionBuilder
/**
* A container interface with read access to its content
* using DataForge [Name] objects as keys.
*/
public interface VisionContainer<out V : Vision> { public interface VisionContainer<out V : Vision> {
public fun getChild(name: Name): V? public fun getChild(name: Name): V?
} }
/**
* A container interface with write/replace/delete access to its content.
*/
public interface MutableVisionContainer<in V : Vision> { public interface MutableVisionContainer<in V : Vision> {
//TODO add documentation //TODO add documentation
public fun setChild(name: Name?, child: V?) public fun setChild(name: Name?, child: V?)
@ -61,12 +68,22 @@ public inline fun VisionChildren.forEach(block: (NameToken, Vision) -> Unit) {
keys.forEach { block(it, get(it)!!) } keys.forEach { block(it, get(it)!!) }
} }
/**
* A serializable representation of [Vision] children container
* with the ability to modify the container content.
*/
public interface MutableVisionChildren : VisionChildren, MutableVisionContainer<Vision> { public interface MutableVisionChildren : VisionChildren, MutableVisionContainer<Vision> {
public override val parent: MutableVisionGroup public override val parent: MutableVisionGroup
public operator fun set(token: NameToken, value: Vision?) public operator fun set(token: NameToken, value: Vision?)
/**
* Set child [Vision] by name.
* @param name child name. Pass null to add a static child. Note that static children cannot
* be removed, replaced or accessed by name by other means.
* @param child new child value. Pass null to delete the child.
*/
override fun setChild(name: Name?, child: Vision?) { override fun setChild(name: Name?, child: Vision?) {
when { when {
name == null -> { name == null -> {

View File

@ -8,19 +8,23 @@ import space.kscience.dataforge.names.asName
import space.kscience.visionforge.Vision import space.kscience.visionforge.Vision
import space.kscience.visionforge.VisionManager import space.kscience.visionforge.VisionManager
public fun interface HtmlVisionFragment{ public fun interface HtmlVisionFragment {
public fun VisionTagConsumer<*>.append() public fun VisionTagConsumer<*>.append()
} }
public fun HtmlVisionFragment.appendTo(consumer: VisionTagConsumer<*>): Unit = consumer.append() public fun HtmlVisionFragment.appendTo(consumer: VisionTagConsumer<*>): Unit = consumer.append()
public data class VisionDisplay(val visionManager: VisionManager, val vision: Vision, val meta: Meta)
/** /**
* Render a fragment in the given consumer and return a map of extracted visions * Render a fragment in the given consumer and return a map of extracted visions
* @param context a context used to create a vision fragment * @param visionManager a context plugin used to create a vision fragment
* @param embedData embed Vision initial state in the HTML * @param embedData embed Vision initial state in the HTML
* @param fetchDataUrl fetch data after first render from given url * @param fetchDataUrl fetch data after first render from given url
* @param updatesUrl receive push updates from the server at given url * @param updatesUrl receive push updates from the server at given url
* @param idPrefix a prefix to be used before vision ids * @param idPrefix a prefix to be used before vision ids
* @param displayCache external cache for Vision displays. It is required to avoid re-creating visions on page update
* @param fragment the fragment to render
*/ */
public fun TagConsumer<*>.visionFragment( public fun TagConsumer<*>.visionFragment(
visionManager: VisionManager, visionManager: VisionManager,
@ -28,39 +32,31 @@ public fun TagConsumer<*>.visionFragment(
fetchDataUrl: String? = null, fetchDataUrl: String? = null,
updatesUrl: String? = null, updatesUrl: String? = null,
idPrefix: String? = null, idPrefix: String? = null,
onVisionRendered: (Name, Vision) -> Unit = { _, _ -> }, displayCache: MutableMap<Name, VisionDisplay> = mutableMapOf(),
fragment: HtmlVisionFragment, fragment: HtmlVisionFragment,
) { ) {
val collector: MutableMap<Name, Pair<VisionOutput, Vision>> = mutableMapOf()
val consumer = object : VisionTagConsumer<Any?>(this@visionFragment, visionManager, idPrefix) { val consumer = object : VisionTagConsumer<Any?>(this@visionFragment, visionManager, idPrefix) {
override fun <T> TagConsumer<T>.vision(name: Name?, buildOutput: VisionOutput.() -> Vision): T { override fun <T> TagConsumer<T>.vision(name: Name?, buildOutput: VisionOutput.() -> Vision): T {
//Avoid re-creating cached visions //Avoid re-creating cached visions
val actualName = name ?: NameToken( val actualName = name ?: NameToken(
DEFAULT_VISION_NAME, DEFAULT_VISION_NAME,
buildOutput.hashCode().toUInt().toString() buildOutput.hashCode().toString(16)
).asName() ).asName()
val (output, vision) = collector.getOrPut(actualName) { val display = displayCache.getOrPut(actualName) {
val output = VisionOutput(context, actualName) val output = VisionOutput(context, actualName)
val vision = output.buildOutput() val vision = output.buildOutput()
onVisionRendered(actualName, vision) VisionDisplay(output.visionManager, vision, output.meta)
output to vision
} }
return addVision(actualName, output.visionManager, vision, output.meta) return addVision(actualName, display.visionManager, display.vision, display.meta)
} }
override fun DIV.renderVision(manager: VisionManager, name: Name, vision: Vision, outputMeta: Meta) { override fun DIV.renderVision(manager: VisionManager, name: Name, vision: Vision, outputMeta: Meta) {
val (_, actualVision) = collector.getOrPut(name) { displayCache[name] = VisionDisplay(manager, vision, outputMeta)
val output = VisionOutput(context, name)
onVisionRendered(name, vision)
output to vision
}
// Toggle update mode // Toggle update mode
updatesUrl?.let { updatesUrl?.let {
@ -76,7 +72,7 @@ public fun TagConsumer<*>.visionFragment(
type = "text/json" type = "text/json"
attributes["class"] = OUTPUT_DATA_CLASS attributes["class"] = OUTPUT_DATA_CLASS
unsafe { unsafe {
+"\n${manager.encodeToString(actualVision)}\n" +"\n${manager.encodeToString(vision)}\n"
} }
} }
} }
@ -91,8 +87,8 @@ public fun FlowContent.visionFragment(
embedData: Boolean = true, embedData: Boolean = true,
fetchDataUrl: String? = null, fetchDataUrl: String? = null,
updatesUrl: String? = null, updatesUrl: String? = null,
onVisionRendered: (Name, Vision) -> Unit = { _, _ -> },
idPrefix: String? = null, idPrefix: String? = null,
displayCache: MutableMap<Name, VisionDisplay> = mutableMapOf(),
fragment: HtmlVisionFragment, fragment: HtmlVisionFragment,
): Unit = consumer.visionFragment( ): Unit = consumer.visionFragment(
visionManager = visionManager, visionManager = visionManager,
@ -100,6 +96,6 @@ public fun FlowContent.visionFragment(
fetchDataUrl = fetchDataUrl, fetchDataUrl = fetchDataUrl,
updatesUrl = updatesUrl, updatesUrl = updatesUrl,
idPrefix = idPrefix, idPrefix = idPrefix,
onVisionRendered = onVisionRendered, displayCache = displayCache,
fragment = fragment fragment = fragment
) )

View File

@ -1,15 +1,15 @@
package space.kscience.visionforge.html package space.kscience.visionforge.html
import kotlinx.html.FORM import kotlinx.coroutines.CoroutineScope
import kotlinx.html.TagConsumer import kotlinx.coroutines.Job
import kotlinx.html.form import kotlinx.html.*
import kotlinx.html.id
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.node import space.kscience.dataforge.meta.node
import space.kscience.dataforge.meta.string import space.kscience.dataforge.meta.string
import space.kscience.visionforge.ClickControl import space.kscience.visionforge.ClickControl
import space.kscience.visionforge.onClick
/** /**
* @param formId an id of the element in rendered DOM, this form is bound to * @param formId an id of the element in rendered DOM, this form is bound to
@ -18,19 +18,31 @@ import space.kscience.visionforge.ClickControl
@SerialName("html.form") @SerialName("html.form")
public class VisionOfHtmlForm( public class VisionOfHtmlForm(
public val formId: String, public val formId: String,
) : VisionOfHtmlControl() { ) : VisionOfHtmlControl(), ClickControl {
public var values: Meta? by properties.node() public var values: Meta? by properties.node()
} }
public fun <R> TagConsumer<R>.bindForm(
visionOfForm: VisionOfHtmlForm, /**
builder: FORM.() -> Unit, * Create a [VisionOfHtmlForm] and bind this form to the id
): R = form { */
this.id = visionOfForm.formId @HtmlTagMarker
builder() public inline fun <T, C : TagConsumer<T>> C.visionOfForm(
vision: VisionOfHtmlForm,
action: String? = null,
encType: FormEncType? = null,
method: FormMethod? = null,
classes: String? = null,
crossinline block: FORM.() -> Unit = {},
) : T = form(action, encType, method, classes){
this.id = vision.formId
block()
} }
public fun VisionOfHtmlForm.onSubmit(scope: CoroutineScope, block: (Meta?) -> Unit): Job = onClick(scope) { block(payload) }
@Serializable @Serializable
@SerialName("html.button") @SerialName("html.button")
public class VisionOfHtmlButton : VisionOfHtmlControl(), ClickControl { public class VisionOfHtmlButton : VisionOfHtmlControl(), ClickControl {

View File

@ -15,8 +15,10 @@ import space.kscience.dataforge.names.Name
import space.kscience.visionforge.html.VisionOfHtmlButton import space.kscience.visionforge.html.VisionOfHtmlButton
import space.kscience.visionforge.html.VisionOfHtmlForm import space.kscience.visionforge.html.VisionOfHtmlForm
/**
internal fun FormData.toMeta(): Meta { * Convert form data to Meta
*/
public fun FormData.toMeta(): Meta {
@Suppress("UNUSED_VARIABLE") val formData = this @Suppress("UNUSED_VARIABLE") val formData = this
//val res = js("Object.fromEntries(formData);") //val res = js("Object.fromEntries(formData);")
val `object` = js("{}") val `object` = js("{}")
@ -67,8 +69,10 @@ internal val formVisionRenderer: ElementVisionRenderer =
form.onsubmit = { event -> form.onsubmit = { event ->
event.preventDefault() event.preventDefault()
val formData = FormData(form).toMeta() val formData = FormData(form).toMeta()
client.sendMetaEvent(name, formData) client.context.launch {
console.info("Sent: ${formData.toMap()}") client.sendEvent(name, VisionClickEvent(name = name, payload = formData))
}
console.info("Sent form data: ${formData.toMap()}")
false false
} }
} }

View File

@ -17,9 +17,9 @@ import space.kscience.dataforge.context.info
import space.kscience.dataforge.context.logger import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.*
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.visionforge.Vision
import space.kscience.visionforge.VisionManager import space.kscience.visionforge.VisionManager
import space.kscience.visionforge.html.HtmlVisionFragment import space.kscience.visionforge.html.HtmlVisionFragment
import space.kscience.visionforge.html.VisionDisplay
import space.kscience.visionforge.html.visionFragment import space.kscience.visionforge.html.visionFragment
import space.kscience.visionforge.server.VisionRoute import space.kscience.visionforge.server.VisionRoute
import space.kscience.visionforge.server.serveVisionData import space.kscience.visionforge.server.serveVisionData
@ -142,7 +142,7 @@ public class VisionForge(
//server.serveVisionsFromFragment(consumer, "content-${counter++}", fragment) //server.serveVisionsFromFragment(consumer, "content-${counter++}", fragment)
val cellRoute = "content-${counter++}" val cellRoute = "content-${counter++}"
val collector: MutableMap<Name, Vision> = mutableMapOf() val cache: MutableMap<Name, VisionDisplay> = mutableMapOf()
val url = engine.environment.connectors.first().let { val url = engine.environment.connectors.first().let {
url { url {
@ -153,13 +153,13 @@ public class VisionForge(
} }
} }
engine.application.serveVisionData(VisionRoute(cellRoute, visionManager), collector) engine.application.serveVisionData(VisionRoute(cellRoute, visionManager), cache)
visionFragment( visionFragment(
visionManager, visionManager,
embedData = true, embedData = true,
updatesUrl = url, updatesUrl = url,
onVisionRendered = { name, vision -> collector[name] = vision }, displayCache = cache,
fragment = fragment fragment = fragment
) )
} else { } else {

View File

@ -7,6 +7,16 @@ description = "Jupyter api artifact including all common modules"
kscience { kscience {
fullStack( fullStack(
"js/visionforge-jupyter-common.js", "js/visionforge-jupyter-common.js",
browserConfig = {
webpackTask {
cssSupport{
enabled = true
}
scssSupport {
enabled = true
}
}
}
) )
dependencies { dependencies {
api(projects.visionforgeSolid) api(projects.visionforgeSolid)

View File

@ -1,24 +0,0 @@
const ringConfig = require('@jetbrains/ring-ui/webpack.config').config;
const path = require('path');
config.module.rules.push(...ringConfig.module.rules)
config.module.rules.push(
{
test: /\.css$/,
exclude: [
path.resolve(__dirname, "../../node_modules/@jetbrains/ring-ui")
],
use: [
{
loader: 'style-loader',
options: {}
},
{
loader: 'css-loader',
options: {}
}
]
}
)

View File

@ -1,31 +1,50 @@
package space.kscience.visionforge.server package space.kscience.visionforge.server
import io.ktor.http.* import io.ktor.http.ContentType
import io.ktor.server.application.* import io.ktor.http.HttpStatusCode
import io.ktor.server.engine.* import io.ktor.http.URLProtocol
import io.ktor.server.html.* import io.ktor.http.path
import io.ktor.server.http.content.* import io.ktor.server.application.Application
import io.ktor.server.plugins.* import io.ktor.server.application.call
import io.ktor.server.plugins.cors.routing.* import io.ktor.server.application.install
import io.ktor.server.request.* import io.ktor.server.application.log
import io.ktor.server.response.* import io.ktor.server.engine.EngineConnectorConfig
import io.ktor.server.html.respondHtml
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.request.header
import io.ktor.server.request.host
import io.ktor.server.request.port
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.util.* import io.ktor.server.util.getOrFail
import io.ktor.server.websocket.* import io.ktor.server.util.url
import io.ktor.util.pipeline.* import io.ktor.server.websocket.WebSockets
import io.ktor.websocket.* import io.ktor.server.websocket.application
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.html.* import kotlinx.html.body
import kotlinx.html.head
import kotlinx.html.header
import kotlinx.html.meta
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.ContextAware import space.kscience.dataforge.context.ContextAware
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.Configurable
import space.kscience.dataforge.meta.ObservableMutableMeta
import space.kscience.dataforge.meta.enum
import space.kscience.dataforge.meta.long
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.visionforge.* import space.kscience.visionforge.Vision
import space.kscience.visionforge.VisionEvent
import space.kscience.visionforge.VisionManager
import space.kscience.visionforge.flowChanges
import space.kscience.visionforge.html.* import space.kscience.visionforge.html.*
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -72,7 +91,6 @@ public class VisionRoute(
/** /**
* Serve visions in a given [route] without providing a page template. * Serve visions in a given [route] without providing a page template.
* [visions] could be changed during the service.
* *
* @return a [Flow] of backward events, including vision change events * @return a [Flow] of backward events, including vision change events
*/ */
@ -137,8 +155,8 @@ public fun Application.serveVisionData(
public fun Application.serveVisionData( public fun Application.serveVisionData(
configuration: VisionRoute, configuration: VisionRoute,
data: Map<Name, Vision>, data: Map<Name, VisionDisplay>,
): Unit = serveVisionData(configuration) { data[it] } ): Unit = serveVisionData(configuration) { data[it]?.vision }
/** /**
* Serve a page, potentially containing any number of visions at a given [route] with given [header]. * Serve a page, potentially containing any number of visions at a given [route] with given [header].
@ -154,10 +172,10 @@ public fun Application.visionPage(
) { ) {
require(WebSockets) require(WebSockets)
val collector: MutableMap<Name, Vision> = mutableMapOf() val cache: MutableMap<Name, VisionDisplay> = mutableMapOf()
//serve data //serve data
serveVisionData(configuration, collector) serveVisionData(configuration, cache)
//filled pages //filled pages
routing { routing {
@ -193,7 +211,7 @@ public fun Application.visionPage(
path(route, "ws") path(route, "ws")
} }
} else null, } else null,
onVisionRendered = { name, vision -> collector[name] = vision }, displayCache = cache,
fragment = visionFragment fragment = visionFragment
) )
} }

View File

@ -7,7 +7,7 @@ import space.kscience.dataforge.meta.boolean
public class Canvas3DUIScheme : Scheme() { public class Canvas3DUIScheme : Scheme() {
public var enabled: Boolean by boolean{true} public var enabled: Boolean by boolean { true }
public companion object : SchemeSpec<Canvas3DUIScheme>(::Canvas3DUIScheme) public companion object : SchemeSpec<Canvas3DUIScheme>(::Canvas3DUIScheme)
} }

View File

@ -7,8 +7,14 @@ val tablesVersion = "0.3.0"
kscience { kscience {
jvm() jvm()
js { js {
useCommonJs()
binaries.library() binaries.library()
browser {
webpackTask{
scssSupport {
enabled = true
}
}
}
} }
useSerialization() useSerialization()
@ -17,8 +23,8 @@ kscience {
api("space.kscience:tables-kt:${tablesVersion}") api("space.kscience:tables-kt:${tablesVersion}")
} }
jsMain { jsMain {
implementation(npm("tabulator-tables", "5.5.2")) api(npm("tabulator-tables", "5.5.2"))
implementation(npm("@types/tabulator-tables", "5.5.3")) api(npm("@types/tabulator-tables", "5.5.3"))
} }
} }

View File

@ -5,6 +5,7 @@
"NO_EXPLICIT_VISIBILITY_IN_API_MODE_WARNING") "NO_EXPLICIT_VISIBILITY_IN_API_MODE_WARNING")
@file:JsModule("tabulator-tables") @file:JsModule("tabulator-tables")
@file:JsNonModule
package tabulator package tabulator

View File

@ -14,7 +14,7 @@ kscience {
commonMain { commonMain {
api(projects.visionforgeSolid) api(projects.visionforgeSolid)
api(projects.visionforgeCompose) api(projects.visionforgeComposeHtml)
} }
jsMain { jsMain {

View File

@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.jetbrains.compose.web.renderComposable import org.jetbrains.compose.web.renderComposable
import org.w3c.dom.Element import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import space.kscience.dataforge.context.* import space.kscience.dataforge.context.*
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.names.* import space.kscience.dataforge.names.*
@ -17,6 +18,9 @@ import kotlin.collections.set
import kotlin.reflect.KClass import kotlin.reflect.KClass
import three.objects.Group as ThreeGroup import three.objects.Group as ThreeGroup
/**
* A plugin that handles Three Object3D representation of Visions.
*/
public class ThreePlugin : AbstractPlugin(), ElementVisionRenderer { public class ThreePlugin : AbstractPlugin(), ElementVisionRenderer {
override val tag: PluginTag get() = Companion.tag override val tag: PluginTag get() = Companion.tag
@ -49,6 +53,13 @@ public class ThreePlugin : AbstractPlugin(), ElementVisionRenderer {
as ThreeFactory<Solid>? as ThreeFactory<Solid>?
} }
/**
* Build an Object3D representation of the given [Solid].
*
* @param vision [Solid] object to build a representation of;
* @param observe whether the constructed Object3D should be changed when the
* original [Vision] changes.
*/
public suspend fun buildObject3D(vision: Solid, observe: Boolean = true): Object3D = when (vision) { public suspend fun buildObject3D(vision: Solid, observe: Boolean = true): Object3D = when (vision) {
is ThreeJsVision -> vision.render(this) is ThreeJsVision -> vision.render(this)
is SolidReference -> ThreeReferenceFactory.build(this, vision, observe) is SolidReference -> ThreeReferenceFactory.build(this, vision, observe)
@ -124,6 +135,25 @@ public class ThreePlugin : AbstractPlugin(), ElementVisionRenderer {
} }
} }
private val canvasCache = HashMap<Element, ThreeCanvas>()
/**
* Return a [ThreeCanvas] object attached to the given [Element].
* If there is no canvas bound, a new canvas object is created
* and returned.
*
* @param element HTML element to which the canvas is
* (or should be if it is created by this call) attached;
* @param options canvas options that are applied to a newly
* created [ThreeCanvas] in case it does not exist.
*/
public fun getOrCreateCanvas(
element: Element,
options: Canvas3DOptions,
): ThreeCanvas = canvasCache.getOrPut(element) {
ThreeCanvas(this, element, options)
}
override fun content(target: String): Map<Name, Any> { override fun content(target: String): Map<Name, Any> {
return when (target) { return when (target) {
ElementVisionRenderer.TYPE -> mapOf("three".asName() to this) ElementVisionRenderer.TYPE -> mapOf("three".asName() to this)
@ -134,6 +164,27 @@ public class ThreePlugin : AbstractPlugin(), ElementVisionRenderer {
override fun rateVision(vision: Vision): Int = override fun rateVision(vision: Vision): Int =
if (vision is Solid) ElementVisionRenderer.DEFAULT_RATING else ElementVisionRenderer.ZERO_RATING if (vision is Solid) ElementVisionRenderer.DEFAULT_RATING else ElementVisionRenderer.ZERO_RATING
/**
* Render the given [Solid] Vision in a [ThreeCanvas] attached
* to the [element]. Canvas objects are cached, so subsequent calls
* with the same [element] value do not create new canvas objects,
* but they replace existing content, so multiple Visions cannot be
* displayed in a single [ThreeCanvas].
*
* @param element HTML element [ThreeCanvas] should be
* attached to;
* @param vision Vision to render;
* @param options options that are applied to a canvas
* in case it is not in the cache and should be created.
*/
internal fun renderSolid(
element: Element,
vision: Solid,
options: Canvas3DOptions,
): ThreeCanvas = getOrCreateCanvas(element, options).apply {
render(vision)
}
override fun render(element: Element, client: VisionClient, name: Name, vision: Vision, meta: Meta) { override fun render(element: Element, client: VisionClient, name: Name, vision: Vision, meta: Meta) {
require(vision is Solid) { "Expected Solid but found ${vision::class}" } require(vision is Solid) { "Expected Solid but found ${vision::class}" }
renderComposable(element) { renderComposable(element) {
@ -148,6 +199,27 @@ public class ThreePlugin : AbstractPlugin(), ElementVisionRenderer {
} }
} }
/**
* Render the given [Solid] Vision in a [ThreeCanvas] attached
* to the [element]. Canvas objects are cached, so subsequent calls
* with the same [element] value do not create new canvas objects,
* but they replace existing content, so multiple Visions cannot be
* displayed in a single [ThreeCanvas].
*
* @param element HTML element [ThreeCanvas] should be
* attached to;
* @param obj Vision to render;
* @param optionsBuilder option builder that is applied to a canvas
* in case it is not in the cache and should be created.
*/
public fun ThreePlugin.render(
element: HTMLElement,
obj: Solid,
optionsBuilder: Canvas3DOptions.() -> Unit = {},
): ThreeCanvas = renderSolid(element, obj, Canvas3DOptions(optionsBuilder)).apply {
options.apply(optionsBuilder)
}
internal operator fun Object3D.set(token: NameToken, object3D: Object3D) { internal operator fun Object3D.set(token: NameToken, object3D: Object3D) {
object3D.name = token.toString() object3D.name = token.toString()
add(object3D) add(object3D)

View File

@ -1,6 +1,9 @@
package space.kscience.visionforge.solid.three.compose package space.kscience.visionforge.solid.three.compose
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import app.softwork.bootstrapcompose.Column
import app.softwork.bootstrapcompose.Layout.Height
import app.softwork.bootstrapcompose.Row
import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.Button import org.jetbrains.compose.web.dom.Button
import org.jetbrains.compose.web.dom.Text import org.jetbrains.compose.web.dom.Text
@ -18,8 +21,8 @@ internal fun CanvasControls(
vision: Vision?, vision: Vision?,
options: Canvas3DOptions, options: Canvas3DOptions,
) { ) {
FlexColumn { Column {
FlexRow({ Row(attrs = {
style { style {
border { border {
width(1.px) width(1.px)
@ -64,8 +67,11 @@ public fun ThreeControls(
onSelect: (Name?) -> Unit, onSelect: (Name?) -> Unit,
tabBuilder: @Composable TabsBuilder.() -> Unit = {}, tabBuilder: @Composable TabsBuilder.() -> Unit = {},
) { ) {
Tabs { Tabs(
active = "Tree" styling = {
Layout.height = Height.Full
}
) {
vision?.let { vision -> vision?.let { vision ->
Tab("Tree") { Tab("Tree") {
CardTitle("Vision tree") CardTitle("Vision tree")

View File

@ -2,6 +2,10 @@ package space.kscience.visionforge.solid.three.compose
import androidx.compose.runtime.* import androidx.compose.runtime.*
import app.softwork.bootstrapcompose.Card import app.softwork.bootstrapcompose.Card
import app.softwork.bootstrapcompose.Column
import app.softwork.bootstrapcompose.Layout.Height
import app.softwork.bootstrapcompose.Layout.Width
import app.softwork.bootstrapcompose.Row
import kotlinx.dom.clear import kotlinx.dom.clear
import org.jetbrains.compose.web.ExperimentalComposeWebApi import org.jetbrains.compose.web.ExperimentalComposeWebApi
import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.css.*
@ -10,7 +14,6 @@ import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.request import space.kscience.dataforge.context.request
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.isEmpty import space.kscience.dataforge.names.isEmpty
import space.kscience.visionforge.Vision
import space.kscience.visionforge.compose.* import space.kscience.visionforge.compose.*
import space.kscience.visionforge.root import space.kscience.visionforge.root
import space.kscience.visionforge.solid.Solid import space.kscience.visionforge.solid.Solid
@ -29,8 +32,6 @@ private fun SimpleThreeView(
selected: Name?, selected: Name?,
) { ) {
val three: ThreePlugin by derivedStateOf { context.request(ThreePlugin) }
Div({ Div({
style { style {
maxWidth(100.vw) maxWidth(100.vw)
@ -39,9 +40,9 @@ private fun SimpleThreeView(
height(100.percent) height(100.percent)
} }
}) { }) {
var canvas: ThreeCanvas? = null var canvas: ThreeCanvas? by remember { mutableStateOf(null) }
DisposableEffect(options) { DisposableEffect(options) {
canvas = ThreeCanvas(three, scopeElement, options ?: Canvas3DOptions()) canvas = ThreeCanvas(context.request(ThreePlugin), scopeElement, options ?: Canvas3DOptions())
onDispose { onDispose {
scopeElement.clear() scopeElement.clear()
canvas = null canvas = null
@ -71,7 +72,7 @@ public fun ThreeView(
) { ) {
var selected: Name? by remember { mutableStateOf(initialSelected) } var selected: Name? by remember { mutableStateOf(initialSelected) }
val optionsSnapshot = remember(options) { val optionsSnapshot by derivedStateOf {
(options ?: Canvas3DOptions()).apply { (options ?: Canvas3DOptions()).apply {
this.onSelect = { this.onSelect = {
selected = it selected = it
@ -79,35 +80,29 @@ public fun ThreeView(
} }
} }
val selectedVision: Vision? = remember(solid, selected) {
selected?.let {
when {
it.isEmpty() -> solid
else -> (solid as? SolidGroup)?.get(it)
}
}
}
if (optionsSnapshot.controls.enabled) { if (optionsSnapshot.controls.enabled) {
Row(
FlexRow({ styling = {
style { Layout {
height(100.percent) width = Width.Full
width(100.percent) height = Height.Full
flexWrap(FlexWrap.Wrap)
alignItems(AlignItems.Stretch)
alignContent(AlignContent.Stretch)
}
}) {
FlexColumn({
style {
height(100.percent)
minWidth(600.px)
flex(10, 1, 600.px)
position(Position.Relative)
} }
}) { }
) {
Column(
styling = {
Layout {
height = Height.Full
}
},
attrs = {
style {
position(Position.Relative)
minWidth(600.px)
}
}
) {
if (solid == null) { if (solid == null) {
Div({ Div({
style { style {
@ -142,26 +137,43 @@ public fun ThreeView(
SimpleThreeView(solids.context, optionsSnapshot, solid, selected) SimpleThreeView(solids.context, optionsSnapshot, solid, selected)
} }
selectedVision?.let { vision -> key(selected) {
Div({ selected?.let {
style { when {
position(Position.Absolute) it.isEmpty() -> solid
top(5.px) else -> (solid as? SolidGroup)?.get(it)
right(5.px)
width(450.px)
} }
}) { }?.let { vision ->
Card( Card(
attrs = {
style {
position(Position.Absolute)
top(5.px)
right(5.px)
width(450.px)
overflowY("auto")
}
},
headerAttrs = { headerAttrs = {
// border = true style {
alignItems(AlignItems.Center)
}
}, },
header = { header = {
NameCrumbs(selected) { selected = it } NameCrumbs(selected) { selected = it }
},
footer = {
vision.styles.takeIf { it.isNotEmpty() }?.let { styles ->
P {
B { Text("Styles: ") }
Text(styles.joinToString(separator = ", "))
}
}
} }
) { ) {
PropertyEditor( PropertyEditor(
scope = solids.context, scope = solids.context,
meta = vision.properties.root(), rootMeta = vision.properties.root(),
getPropertyState = { name -> getPropertyState = { name ->
if (vision.properties.own?.get(name) != null) { if (vision.properties.own?.get(name) != null) {
EditorPropertyState.Defined EditorPropertyState.Defined
@ -175,29 +187,28 @@ public fun ThreeView(
updates = vision.properties.changes, updates = vision.properties.changes,
rootDescriptor = vision.descriptor rootDescriptor = vision.descriptor
) )
}
vision.styles.takeIf { it.isNotEmpty() }?.let { styles ->
P {
B { Text("Styles: ") }
Text(styles.joinToString(separator = ", "))
}
} }
} }
} }
} }
}
FlexColumn({ Column(
style { auto = true,
paddingAll(4.px) styling = {
minWidth(400.px) Layout {
height(100.percent) height = Height.Full
overflowY("auto") }
flex(1, 10, 300.px) },
attrs = {
style {
paddingAll(4.px)
minWidth(400.px)
height(100.percent)
}
}
) {
ThreeControls(solid, optionsSnapshot, selected, onSelect = { selected = it }, tabBuilder = sidebarTabs)
} }
}) {
ThreeControls(solid, optionsSnapshot, selected, onSelect = { selected = it }, tabBuilder = sidebarTabs)
} }
} else { } else {
SimpleThreeView(solids.context, optionsSnapshot, solid, selected) SimpleThreeView(solids.context, optionsSnapshot, solid, selected)

View File

@ -10,7 +10,7 @@ kscience {
commonMain { commonMain {
api(projects.visionforgeSolid) api(projects.visionforgeSolid)
api(projects.visionforgeCompose) api(projects.visionforgeComposeHtml)
} }
jvmMain{ jvmMain{
@ -19,6 +19,8 @@ kscience {
jsMain{ jsMain{
api(projects.visionforgeThreejs) api(projects.visionforgeThreejs)
implementation(npm("file-saver","2.0.5"))
implementation(npm("@types/file-saver", "2.0.7"))
compileOnly(npm("webpack-bundle-analyzer","4.5.0")) compileOnly(npm("webpack-bundle-analyzer","4.5.0"))
} }
} }