diff --git a/demo/sat-demo/build.gradle.kts b/demo/sat-demo/build.gradle.kts index efaaff60..f5b9ef31 100644 --- a/demo/sat-demo/build.gradle.kts +++ b/demo/sat-demo/build.gradle.kts @@ -18,6 +18,13 @@ kscience{ val ktorVersion: String by rootProject.extra kotlin { + js{ + browser { + webpackTask { + this.outputFileName = "visionforge-solid.js" + } + } + } afterEvaluate { val jsBrowserDistribution by tasks.getting diff --git a/demo/sat-demo/src/jsMain/kotlin/ru/mipt/npm/sat/SatDemoApp.kt b/demo/sat-demo/src/jsMain/kotlin/ru/mipt/npm/sat/SatDemoApp.kt index 45e0283c..a3af18af 100644 --- a/demo/sat-demo/src/jsMain/kotlin/ru/mipt/npm/sat/SatDemoApp.kt +++ b/demo/sat-demo/src/jsMain/kotlin/ru/mipt/npm/sat/SatDemoApp.kt @@ -6,34 +6,10 @@ import hep.dataforge.vision.client.fetchAndRenderAllVisions import hep.dataforge.vision.solid.three.ThreePlugin import kotlinx.browser.window -//private class SatDemoApp : Application { -// -// override fun start(state: Map) { -// val element = document.getElementById("canvas") as? HTMLElement -// ?: error("Element with id 'canvas' not found on page") -// val three = Global.plugins.fetch(ThreePlugin) -// -// val sat = visionOfSatellite( -// ySegments = 3, -// ) -// three.render(element, sat){ -// minSize = 500 -// axes{ -// size = 500.0 -// visible = true -// } -// } -// } -// -//} -// -//fun main() { -// startApplication(::SatDemoApp) -//} - fun main() { //Loading three-js renderer Global.plugins.load(ThreePlugin) + //Fetch from server and render visions for all outputs window.onload = { Global.plugins.fetch(VisionClient).fetchAndRenderAllVisions() } diff --git a/demo/sat-demo/src/jsMain/resources/index.html b/demo/sat-demo/src/jsMain/resources/index.html deleted file mode 100644 index 0fd32551..00000000 --- a/demo/sat-demo/src/jsMain/resources/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Three js demo for particle physics - - - -
- - \ No newline at end of file diff --git a/demo/sat-demo/src/jvmMain/kotlin/ru/mipt/npm/sat/satServer.kt b/demo/sat-demo/src/jvmMain/kotlin/ru/mipt/npm/sat/satServer.kt index d834e433..2e86ff22 100644 --- a/demo/sat-demo/src/jvmMain/kotlin/ru/mipt/npm/sat/satServer.kt +++ b/demo/sat-demo/src/jvmMain/kotlin/ru/mipt/npm/sat/satServer.kt @@ -2,12 +2,9 @@ package ru.mipt.npm.sat import hep.dataforge.context.Global -import hep.dataforge.names.asName import hep.dataforge.names.toName import hep.dataforge.vision.VisionManager -import hep.dataforge.vision.server.close -import hep.dataforge.vision.server.serve -import hep.dataforge.vision.server.show +import hep.dataforge.vision.server.* import hep.dataforge.vision.solid.Solid import hep.dataforge.vision.solid.SolidManager import hep.dataforge.vision.solid.color @@ -15,8 +12,8 @@ import io.ktor.util.KtorExperimentalAPI import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.html.div import kotlinx.html.h1 -import kotlinx.html.script import kotlin.random.Random @OptIn(KtorExperimentalAPI::class) @@ -30,27 +27,27 @@ fun main() { } val server = context.plugins.fetch(VisionManager).serve { - header { - script { - src = "sat-demo.js" - } - } + useScript("visionforge-solid.js") + useCss("css/styles.css") page { - h1 { +"Satellite detector demo" } - vision("main".asName(), sat) - } - launch { - delay(1000) - while (isActive) { - val target = "layer[${Random.nextInt(1,10)}].segment[${Random.nextInt(3)},${Random.nextInt(3)}]".toName() - (sat[target] as? Solid)?.color("red") - delay(300) - (sat[target] as? Solid)?.color = "green" + div("flex-column") { + h1 { +"Satellite detector demo" } + vision(sat) } } } server.show() + context.launch { + while (isActive) { + val target = "layer[${Random.nextInt(1,11)}].segment[${Random.nextInt(3)},${Random.nextInt(3)}]".toName() + (sat[target] as? Solid)?.color("red") + delay(300) + (sat[target] as? Solid)?.color = "green" + delay(10) + } + } + println("Press Enter to close server") while (readLine()!="exit"){ // diff --git a/demo/sat-demo/src/jvmMain/resources/css/styles.css b/demo/sat-demo/src/jvmMain/resources/css/styles.css new file mode 100644 index 00000000..5f4ff253 --- /dev/null +++ b/demo/sat-demo/src/jvmMain/resources/css/styles.css @@ -0,0 +1,16 @@ +body{ + width: 100%; + height: 100%; + overflow: hidden; +} + +.flex-column{ + width: calc(100% - 15px); + height: calc(100% - 15px); + display: flex; + flex-direction: column; +} + +.visionforge-output{ + flex-grow: 1; +} \ No newline at end of file diff --git a/visionforge-core/build.gradle.kts b/visionforge-core/build.gradle.kts index a12e4954..d888a4c3 100644 --- a/visionforge-core/build.gradle.kts +++ b/visionforge-core/build.gradle.kts @@ -16,12 +16,12 @@ kotlin { dependencies { api("hep.dataforge:dataforge-context:$dataforgeVersion") api("org.jetbrains.kotlinx:kotlinx-html:$htmlVersion") + api("org.jetbrains:kotlin-css:1.0.0-$kotlinWrappersVersion") } } jsMain { dependencies { api("org.jetbrains:kotlin-extensions:1.0.1-$kotlinWrappersVersion") - api("org.jetbrains:kotlin-css:1.0.0-$kotlinWrappersVersion") } } } diff --git a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/VisionChange.kt b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/VisionChange.kt index 8c3fbf56..9a09e52e 100644 --- a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/VisionChange.kt +++ b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/VisionChange.kt @@ -35,6 +35,7 @@ public class VisionChangeBuilder : VisionContainerBuilder { propertyChange, childrenChange.mapValues { it.value?.isolate(manager) } ) + //TODO optimize isolation for visions without parents? } private fun Vision.isolate(manager: VisionManager): Vision { @@ -77,12 +78,15 @@ private fun CoroutineScope.collectChange( } } + coroutineContext[Job]?.invokeOnCompletion { + source.config.removeListener(mutex) + } + if (source is VisionGroup) { //Subscribe for children changes source.children.forEach { (token, child) -> collectChange(name + token, child, mutex, collector) } - //TODO update styles? //Subscribe for structure change if (source is MutableVisionGroup) { @@ -94,6 +98,9 @@ private fun CoroutineScope.collectChange( } collector()[name + token] = after } + coroutineContext[Job]?.invokeOnCompletion { + source.removeStructureChangeListener(mutex) + } } } } @@ -102,23 +109,24 @@ private fun CoroutineScope.collectChange( public fun Vision.flowChanges( manager: VisionManager, collectionDuration: Duration, - scope: CoroutineScope = manager.context, ): Flow = flow { - val mutex = Mutex() + supervisorScope { + val mutex = Mutex() - var collector = VisionChangeBuilder() - scope.collectChange(Name.EMPTY, this@flowChanges, mutex) { collector } + var collector = VisionChangeBuilder() + collectChange(Name.EMPTY, this@flowChanges, mutex) { collector } - while (currentCoroutineContext().isActive) { - //Wait for changes to accumulate - delay(collectionDuration) - //Propagate updates only if something is changed - if (!collector.isEmpty()) { - //emit changes - mutex.withLock { - emit(collector.isolate(manager)) - //Reset the collector - collector = VisionChangeBuilder() + while (currentCoroutineContext().isActive) { + //Wait for changes to accumulate + delay(collectionDuration) + //Propagate updates only if something is changed + if (!collector.isEmpty()) { + //emit changes + mutex.withLock { + emit(collector.isolate(manager)) + //Reset the collector + collector = VisionChangeBuilder() + } } } } diff --git a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/BindingOutputTagConsumer.kt b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/BindingOutputTagConsumer.kt index 445ffb0c..9998c442 100644 --- a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/BindingOutputTagConsumer.kt +++ b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/BindingOutputTagConsumer.kt @@ -1,5 +1,6 @@ package hep.dataforge.vision.html +import hep.dataforge.meta.Meta import hep.dataforge.names.Name import hep.dataforge.vision.Vision import kotlinx.html.FlowContent @@ -13,7 +14,7 @@ public class BindingOutputTagConsumer( private val _bindings = HashMap() public val bindings: Map get() = _bindings - override fun FlowContent.renderVision(name: Name, vision: V) { + override fun FlowContent.renderVision(name: Name, vision: V, outputMeta: Meta) { _bindings[name] = vision } } diff --git a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/OutputTagConsumer.kt b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/OutputTagConsumer.kt index 5e402ab4..86ea51f6 100644 --- a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/OutputTagConsumer.kt +++ b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/OutputTagConsumer.kt @@ -1,18 +1,24 @@ package hep.dataforge.vision.html +import hep.dataforge.meta.* import hep.dataforge.names.Name import hep.dataforge.names.toName import hep.dataforge.vision.Vision +import hep.dataforge.vision.VisionManager import kotlinx.html.* + /** - * An HTML div wrapper that includes the output [name] and inherited [render] function + * A placeholder object to attach inline vision builders. */ -public class OutputDiv( - private val div: DIV, - public val name: Name, - public val render: (V) -> Unit, -) : HtmlBlockTag by div +@DFExperimental +public class VisionOutput { + public var meta: Meta = Meta.EMPTY + + public inline fun meta(block: MetaBuilder.() -> Unit) { + this.meta = Meta(block) + } +} /** * Modified [TagConsumer] that allows rendering output fragments and visions in them @@ -26,34 +32,55 @@ public abstract class OutputTagConsumer( /** * Render a vision inside the output fragment + * @param name name of the output container + * @param vision an object to be rendered + * @param outputMeta optional configuration for the output container */ - protected abstract fun FlowContent.renderVision(name: Name, vision: V) + protected abstract fun FlowContent.renderVision(name: Name, vision: V, outputMeta: Meta) /** - * Create a placeholder for an output window + * Create a placeholder for a vision output with optional [Vision] in it */ - public fun TagConsumer.visionOutput( + public fun TagConsumer.vision( name: Name, - block: OutputDiv.() -> Unit = {}, + vision: V? = null, + outputMeta: Meta = Meta.EMPTY, ): T = div { id = resolveId(name) classes = setOf(OUTPUT_CLASS) attributes[OUTPUT_NAME_ATTRIBUTE] = name.toString() - OutputDiv(this, name) { renderVision(name, it) }.block() - } - - public fun TagConsumer.visionOutput( - name: String, - block: OutputDiv.() -> Unit = {}, - ): T = visionOutput(name.toName(), block) - - - public fun TagConsumer.vision(name: Name, vision: V): Unit { - visionOutput(name) { - render(vision) + if (!outputMeta.isEmpty()) { + //Hard-code output configuration + script { + attributes["class"] = OUTPUT_META_CLASS + unsafe { + +VisionManager.defaultJson.encodeToString(MetaSerializer, outputMeta) + } + } } + vision?.let { renderVision(name, it, outputMeta) } } + @OptIn(DFExperimental::class) + public inline fun TagConsumer.vision( + name: Name, + visionProvider: VisionOutput.() -> V, + ): T { + val output = VisionOutput() + val vision = output.visionProvider() + return vision(name, vision, output.meta) + } + + @OptIn(DFExperimental::class) + public inline fun TagConsumer.vision( + name: String, + visionProvider: VisionOutput.() -> V, + ): T = vision(name.toName(), visionProvider) + + public inline fun TagConsumer.vision( + vision: V, + ): T = vision("vision[${vision.hashCode()}]".toName(), vision) + /** * Process the resulting object produced by [TagConsumer] */ @@ -67,6 +94,9 @@ public abstract class OutputTagConsumer( public companion object { public const val OUTPUT_CLASS: String = "visionforge-output" + public const val OUTPUT_META_CLASS: String = "visionforge-output-meta" + public const val OUTPUT_DATA_CLASS: String = "visionforge-output-data" + public const val OUTPUT_NAME_ATTRIBUTE: String = "data-output-name" public const val OUTPUT_ENDPOINT_ATTRIBUTE: String = "data-output-endpoint" public const val DEFAULT_ENDPOINT: String = "." diff --git a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/StaticOutputTagConsumer.kt b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/StaticOutputTagConsumer.kt index 401fe3f8..0e045918 100644 --- a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/StaticOutputTagConsumer.kt +++ b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/StaticOutputTagConsumer.kt @@ -1,12 +1,16 @@ package hep.dataforge.vision.html +import hep.dataforge.meta.Meta import hep.dataforge.names.Name import hep.dataforge.vision.Vision +import hep.dataforge.vision.VisionManager import kotlinx.html.FlowContent import kotlinx.html.TagConsumer +import kotlinx.html.script import kotlinx.html.stream.createHTML +import kotlinx.html.unsafe -public typealias HtmlVisionRenderer = FlowContent.(V) -> Unit +public typealias HtmlVisionRenderer = FlowContent.(V, Meta) -> Unit /** * An [OutputTagConsumer] that directly renders given [Vision] using provided [renderer] @@ -16,8 +20,18 @@ public class StaticOutputTagConsumer( prefix: String? = null, private val renderer: HtmlVisionRenderer, ) : OutputTagConsumer(root, prefix) { + override fun FlowContent.renderVision(name: Name, vision: V, outputMeta: Meta): Unit = renderer(vision, outputMeta) - override fun FlowContent.renderVision(name: Name, vision: V): Unit = renderer(vision) + public companion object { + public fun embed(manager: VisionManager): HtmlVisionRenderer = { vision: Vision, _: Meta -> + script { + attributes["class"] = OUTPUT_DATA_CLASS + unsafe { + +manager.encodeToString(vision) + } + } + } + } } public fun HtmlVisionFragment.renderToObject( @@ -26,5 +40,21 @@ public fun HtmlVisionFragment.renderToObject( renderer: HtmlVisionRenderer, ): T = StaticOutputTagConsumer(root, prefix, renderer).apply(content).finalize() +/** + * Render an object to HTML embedding the data as script bodies + */ +public fun HtmlVisionFragment.embedToObject( + manager: VisionManager, + root: TagConsumer, + prefix: String? = null, +): T = renderToObject(root, prefix, StaticOutputTagConsumer.embed(manager)) + public fun HtmlVisionFragment.renderToString(renderer: HtmlVisionRenderer): String = - renderToObject(createHTML(), null, renderer) \ No newline at end of file + renderToObject(createHTML(), null, renderer) + +/** + * Convert a fragment to a string, embedding all visions data + */ +public fun HtmlVisionFragment.embedToString(manager: VisionManager): String = + embedToObject(manager, createHTML()) + diff --git a/visionforge-core/src/commonTest/kotlin/hep/dataforge/vision/html/HtmlTagTest.kt b/visionforge-core/src/commonTest/kotlin/hep/dataforge/vision/html/HtmlTagTest.kt index 2b322c12..eac0a16a 100644 --- a/visionforge-core/src/commonTest/kotlin/hep/dataforge/vision/html/HtmlTagTest.kt +++ b/visionforge-core/src/commonTest/kotlin/hep/dataforge/vision/html/HtmlTagTest.kt @@ -1,5 +1,6 @@ package hep.dataforge.vision.html +import hep.dataforge.meta.DFExperimental import hep.dataforge.meta.configure import hep.dataforge.meta.set import hep.dataforge.vision.Vision @@ -10,14 +11,18 @@ import kotlin.test.Test class HtmlTagTest { - fun OutputDiv.visionBase(block: VisionBase.() -> Unit) = - render(VisionBase().apply(block)) + @OptIn(DFExperimental::class) + fun VisionOutput.base(block: VisionBase.() -> Unit) = + VisionBase().apply(block) val fragment = buildVisionFragment { div { h1 { +"Head" } - visionOutput("ddd") { - visionBase { + vision("ddd") { + meta{ + "metaProperty" put 87 + } + base { configure { set("myProp", 82) set("otherProp", false) @@ -27,7 +32,7 @@ class HtmlTagTest { } } - val simpleVisionRenderer: HtmlVisionRenderer = { vision -> + val simpleVisionRenderer: HtmlVisionRenderer = { vision, _ -> div { h2 { +"Properties" } ul { @@ -41,7 +46,7 @@ class HtmlTagTest { } } - val groupRenderer: HtmlVisionRenderer = { group -> + val groupRenderer: HtmlVisionRenderer = { group, _ -> p { +"This is group" } } diff --git a/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/client/VisionClient.kt b/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/client/VisionClient.kt index ad022eee..5b51cacd 100644 --- a/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/client/VisionClient.kt +++ b/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/client/VisionClient.kt @@ -2,8 +2,7 @@ package hep.dataforge.vision.client import hep.dataforge.context.* import hep.dataforge.meta.Meta -import hep.dataforge.meta.get -import hep.dataforge.meta.node +import hep.dataforge.meta.MetaSerializer import hep.dataforge.vision.Vision import hep.dataforge.vision.VisionChange import hep.dataforge.vision.VisionManager @@ -42,16 +41,31 @@ public class VisionClient : AbstractPlugin() { public fun findRendererFor(vision: Vision): ElementVisionRenderer? = getRenderers().maxByOrNull { it.rateVision(vision) } + private fun Element.getEmbeddedData(className: String): String? = getElementsByClassName(className)[0]?.innerHTML + /** * Fetch from server and render a vision, described in a given with [OutputTagConsumer.OUTPUT_CLASS] class. */ - public fun fetchAndRenderVision(element: Element, requestUpdates: Boolean = true) { + public fun renderVisionAt(element: Element, requestUpdates: Boolean = true) { val name = resolveName(element) ?: error("The element is not a vision output") console.info("Found DF output with name $name") if (!element.classList.contains(OutputTagConsumer.OUTPUT_CLASS)) error("The element $element is not an output element") val endpoint = resolveEndpoint(element) console.info("Vision server is resolved to $endpoint") + val outputMeta = element.getEmbeddedData(OutputTagConsumer.OUTPUT_META_CLASS)?.let { + VisionManager.defaultJson.decodeFromString(MetaSerializer, it) + } ?: Meta.EMPTY + + //Trying to render embedded vision + val embeddedVision = element.getEmbeddedData(OutputTagConsumer.OUTPUT_DATA_CLASS)?.let { + visionManager.decodeFromString(it) + } + if (embeddedVision != null) { + val renderer = findRendererFor(embeddedVision) ?: error("Could nof find renderer for $embeddedVision") + renderer.render(element, embeddedVision, outputMeta) + } + val fetchUrl = URL(endpoint).apply { searchParams.append("name", name) pathname += "/vision" @@ -63,15 +77,14 @@ public class VisionClient : AbstractPlugin() { response.text().then { text -> val vision = visionManager.decodeFromString(text) val renderer = findRendererFor(vision) ?: error("Could nof find renderer for $vision") - val rendererConfiguration = vision.properties[RENDERER_CONFIGURATION_META_KEY].node ?: Meta.EMPTY - renderer.render(element, vision, rendererConfiguration) + renderer.render(element, vision, outputMeta) if (requestUpdates) { val wsUrl = URL(endpoint).apply { pathname += "/ws" protocol = "ws" searchParams.append("name", name) } - val ws = WebSocket(wsUrl.toString()).apply { + WebSocket(wsUrl.toString()).apply { onmessage = { messageEvent -> val stringData: String? = messageEvent.data as? String if (stringData != null) { @@ -106,8 +119,6 @@ public class VisionClient : AbstractPlugin() { public companion object : PluginFactory { - public const val RENDERER_CONFIGURATION_META_KEY: String = "@renderer" - override fun invoke(meta: Meta, context: Context): VisionClient = VisionClient() override val tag: PluginTag = PluginTag(name = "vision.client", group = PluginTag.DATAFORGE_GROUP) @@ -123,7 +134,7 @@ public fun VisionClient.fetchVisionsInChildren(element: Element, requestUpdates: val elements = element.getElementsByClassName(OutputTagConsumer.OUTPUT_CLASS) console.info("Finished search for outputs. Found ${elements.length} items") elements.asList().forEach { child -> - fetchAndRenderVision(child, requestUpdates) + renderVisionAt(child, requestUpdates) } } diff --git a/visionforge-core/src/jvmMain/kotlin/hep/dataforge/vision/export/htmlExport.kt b/visionforge-core/src/jvmMain/kotlin/hep/dataforge/vision/export/htmlExport.kt new file mode 100644 index 00000000..c7e556ea --- /dev/null +++ b/visionforge-core/src/jvmMain/kotlin/hep/dataforge/vision/export/htmlExport.kt @@ -0,0 +1,67 @@ +//package hep.dataforge.vision.export +// +//package kscience.plotly +// +//import kotlinx.html.* +//import kotlinx.html.stream.createHTML +// +///** +// * A custom HTML fragment including plotly container reference +// */ +//public class PlotlyFragment(public val render: FlowContent.(renderer: PlotlyRenderer) -> Unit) +// +///** +// * A complete page including headers and title +// */ +//public data class PlotlyPage( +// val headers: Collection, +// val fragment: PlotlyFragment, +// val title: String = "Plotly.kt", +// val renderer: PlotlyRenderer = StaticPlotlyRenderer +//) { +// public fun render(): String = createHTML().html { +// head { +// meta { +// charset = "utf-8" +// } +// title(this@PlotlyPage.title) +// headers.distinct().forEach { it.visit(consumer) } +// } +// body { +// fragment.render(this, renderer) +// } +// } +//} +// +//public fun Plotly.fragment(content: FlowContent.(renderer: PlotlyRenderer) -> Unit): PlotlyFragment = PlotlyFragment(content) +// +///** +// * Create a complete page including plots +// */ +//public fun Plotly.page( +// vararg headers: HtmlFragment = arrayOf(cdnPlotlyHeader), +// title: String = "Plotly.kt", +// renderer: PlotlyRenderer = StaticPlotlyRenderer, +// content: FlowContent.(renderer: PlotlyRenderer) -> Unit +//): PlotlyPage = PlotlyPage(headers.toList(), fragment(content), title, renderer) +// +///** +// * Convert an html plot fragment to page +// */ +//public fun PlotlyFragment.toPage( +// vararg headers: HtmlFragment = arrayOf(cdnPlotlyHeader), +// title: String = "Plotly.kt", +// renderer: PlotlyRenderer = StaticPlotlyRenderer +//): PlotlyPage = PlotlyPage(headers.toList(), this, title, renderer) +// +///** +// * Convert a plot to the sigle-plot page +// */ +//public fun Plot.toPage( +// vararg headers: HtmlFragment = arrayOf(cdnPlotlyHeader), +// config: PlotlyConfig = PlotlyConfig.empty(), +// title: String = "Plotly.kt", +// renderer: PlotlyRenderer = StaticPlotlyRenderer +//): PlotlyPage = PlotlyFragment { +// plot(this@toPage, config = config, renderer = renderer) +//}.toPage(*headers, title = title) \ No newline at end of file diff --git a/visionforge-server/src/main/kotlin/hep/dataforge/vision/server/VisionServer.kt b/visionforge-server/src/main/kotlin/hep/dataforge/vision/server/VisionServer.kt index 0b4fb1c4..477f48fa 100644 --- a/visionforge-server/src/main/kotlin/hep/dataforge/vision/server/VisionServer.kt +++ b/visionforge-server/src/main/kotlin/hep/dataforge/vision/server/VisionServer.kt @@ -1,10 +1,7 @@ package hep.dataforge.vision.server import hep.dataforge.context.Context -import hep.dataforge.meta.Config -import hep.dataforge.meta.Configurable -import hep.dataforge.meta.boolean -import hep.dataforge.meta.long +import hep.dataforge.meta.* import hep.dataforge.names.Name import hep.dataforge.names.toName import hep.dataforge.vision.Vision @@ -25,21 +22,16 @@ import io.ktor.http.content.static import io.ktor.http.withCharset import io.ktor.response.respond import io.ktor.response.respondText -import io.ktor.routing.application -import io.ktor.routing.get -import io.ktor.routing.route -import io.ktor.routing.routing +import io.ktor.routing.* import io.ktor.server.cio.CIO import io.ktor.server.engine.ApplicationEngine import io.ktor.server.engine.embeddedServer import io.ktor.util.KtorExperimentalAPI -import io.ktor.util.error import io.ktor.websocket.WebSockets import io.ktor.websocket.webSocket import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.withContext import kotlinx.html.* import kotlinx.html.stream.createHTML import java.awt.Desktop @@ -91,7 +83,70 @@ public class VisionServer internal constructor( return visionMap } - public fun page( + /** + * Server a map of visions without providing explicit html page for them + */ + @OptIn(DFExperimental::class) + public fun serveVisions(route: Route, visions: Map): Unit = route { + application.log.info("Serving visions $visions at $route") + + //Update websocket + webSocket("ws") { + val name: String = call.request.queryParameters["name"] + ?: error("Vision name is not defined in parameters") + + application.log.debug("Opened server socket for $name") + val vision: Vision = visions[name.toName()] ?: error("Plot with id='$name' not registered") + try { + withContext(visionManager.context.coroutineContext) { + vision.flowChanges(visionManager, updateInterval.milliseconds).collect { update -> + val json = VisionManager.defaultJson.encodeToString( + VisionChange.serializer(), + update + ) + outgoing.send(Frame.Text(json)) + } + } + } catch (t: Throwable) { + application.log.info("WebSocket update channel for $name is closed with exception: $t") + } + } + //Plots in their json representation + get("vision") { + val name: String = call.request.queryParameters["name"] + ?: error("Vision name is not defined in parameters") + + val vision: Vision? = visions[name.toName()] + if (vision == null) { + call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found") + } else { + call.respondText( + visionManager.encodeToString(vision), + contentType = ContentType.Application.Json, + status = HttpStatusCode.OK + ) + } + } + } + + /** + * Serv visions in a given [route] without providing a page template + */ + public fun serveVisions(route: String, visions: Map): Unit { + application.routing { + route(rootRoute) { + route(route) { + serveVisions(this, visions) + } + } + } + } + + /** + * Serve a page, potentially containing any number of visions at a given [route] with given [headers]. + * + */ + public fun servePage( visionFragment: HtmlVisionFragment, route: String = DEFAULT_PAGE, title: String = "VisionForge server page '$route'", @@ -100,6 +155,7 @@ public class VisionServer internal constructor( val visions = HashMap() val cachedHtml: String? = if (cacheFragments) { + //Create and cache page html and map of visions createHTML(true).html { visions.putAll(buildPage(visionFragment, title, headers)) } @@ -110,43 +166,17 @@ public class VisionServer internal constructor( application.routing { route(rootRoute) { route(route) { - //Update websocket - webSocket("ws") { - val name: String = call.request.queryParameters["name"] - ?: error("Vision name is not defined in parameters") - - application.log.debug("Opened server socket for $name") - val vision: Vision = visions[name.toName()] ?: error("Plot with id='$name' not registered") - vision.flowChanges(visionManager, updateInterval.milliseconds).onEach { update -> - val json = VisionManager.defaultJson.encodeToString(VisionChange.serializer(), update) - outgoing.send(Frame.Text(json)) - }.catch { ex -> - application.log.error(ex) - }.launchIn(visionManager.context).join() - } - //Plots in their json representation - get("vision") { - val name: String = call.request.queryParameters["name"] - ?: error("Vision name is not defined in parameters") - - val vision: Vision? = visions[name.toName()] - if (vision == null) { - call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found") - } else { - call.respondText( - visionManager.encodeToString(vision), - contentType = ContentType.Application.Json, - status = HttpStatusCode.OK - ) - } - } + serveVisions(this, visions) //filled pages get { if (cachedHtml == null) { + //re-create html and vision list on each call call.respondHtml { + visions.clear() visions.putAll(buildPage(visionFragment, title, headers)) } } else { + //Use cached html call.respondText(cachedHtml, ContentType.Text.Html.withCharset(Charsets.UTF_8)) } } @@ -155,13 +185,16 @@ public class VisionServer internal constructor( } } + /** + * A shortcut method to easily create Complete pages filled with visions + */ public fun page( route: String = DEFAULT_PAGE, title: String = "VisionForge server page '$route'", headers: List = emptyList(), content: OutputTagConsumer<*, Vision>.() -> Unit, ) { - page(buildVisionFragment(content), route, title, headers) + servePage(buildVisionFragment(content), route, title, headers) } @@ -171,11 +204,33 @@ public class VisionServer internal constructor( } } +/** + * Use a script with given [src] as a global header for all pages. + */ +public inline fun VisionServer.useScript(src: String, crossinline block: SCRIPT.() -> Unit = {}) { + header { + script { + type = "text/javascript" + this.src = src + block() + } + } +} + +public inline fun VisionServer.useCss(href: String, crossinline block: LINK.() -> Unit = {}) { + header { + link { + rel = "stylesheet" + this.href = href + block() + } + } +} /** * Attach plotly application to given server */ -public fun Application.visionModule(context: Context, route: String = DEFAULT_PAGE): VisionServer { +public fun Application.visionServer(context: Context, route: String = DEFAULT_PAGE): VisionServer { if (featureOrNull(WebSockets) == null) { install(WebSockets) } @@ -209,7 +264,7 @@ public fun VisionManager.serve( port: Int = 7777, block: VisionServer.() -> Unit, ): ApplicationEngine = context.embeddedServer(CIO, port, host) { - visionModule(context).apply(block) + visionServer(context).apply(block) }.start() public fun ApplicationEngine.show() {