diff --git a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/BindingHtmlOutputScope.kt b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/BindingHtmlOutputScope.kt index 069d5635..9ee0555c 100644 --- a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/BindingHtmlOutputScope.kt +++ b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/BindingHtmlOutputScope.kt @@ -2,12 +2,13 @@ package hep.dataforge.vision.html import hep.dataforge.names.Name import hep.dataforge.vision.Vision +import kotlinx.html.FlowContent import kotlinx.html.TagConsumer public class BindingHtmlOutputScope( root: TagConsumer, prefix: String? = null, -) : HtmlOutputScope(root,prefix) { +) : HtmlOutputScope(root, prefix) { private val _bindings = HashMap() public val bindings: Map get() = _bindings @@ -15,4 +16,12 @@ public class BindingHtmlOutputScope( override fun renderVision(htmlOutput: HtmlOutput, vision: V) { _bindings[htmlOutput.name] = vision } +} + +public fun TagConsumer.visionFragment(fragment: HtmlVisionFragment): Map { + return BindingHtmlOutputScope(this).apply(fragment.content).bindings +} + +public fun FlowContent.visionFragment(fragment: HtmlVisionFragment): Map { + return BindingHtmlOutputScope(consumer).apply(fragment.content).bindings } \ No newline at end of file diff --git a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/HtmlOutputScope.kt b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/HtmlOutputScope.kt index 28bd6c49..c04ef207 100644 --- a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/HtmlOutputScope.kt +++ b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/HtmlOutputScope.kt @@ -3,10 +3,7 @@ package hep.dataforge.vision.html import hep.dataforge.names.Name import hep.dataforge.names.toName import hep.dataforge.vision.Vision -import kotlinx.html.DIV -import kotlinx.html.TagConsumer -import kotlinx.html.div -import kotlinx.html.id +import kotlinx.html.* public class HtmlOutput( public val outputScope: HtmlOutputScope<*, V>, @@ -28,7 +25,9 @@ public abstract class HtmlOutputScope( name: Name, crossinline block: HtmlOutput.() -> Unit = {}, ): T = div { - this.id = resolveId(name) + id = resolveId(name) + classes = setOf(OUTPUT_CLASS) + attributes[NAME_ATTRIBUTE] = name.toString() @Suppress("UNCHECKED_CAST") HtmlOutput(this@HtmlOutputScope, name, this).block() } @@ -49,4 +48,20 @@ public abstract class HtmlOutputScope( renderVision(this, vision) } } + + /** + * Process the resulting object produced by [TagConsumer] + */ + protected open fun processResult(result: R) { + //do nothing by default + } + + override fun finalize(): R { + return root.finalize().also { processResult(it) } + } + + public companion object { + public const val OUTPUT_CLASS: String = "visionforge-output" + public const val NAME_ATTRIBUTE: String = "data-output-name" + } } \ No newline at end of file diff --git a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/HtmlVisionFragment.kt b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/HtmlVisionFragment.kt index d0140d39..bddff870 100644 --- a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/HtmlVisionFragment.kt +++ b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/HtmlVisionFragment.kt @@ -1,8 +1,20 @@ package hep.dataforge.vision.html import hep.dataforge.vision.Vision +import kotlinx.html.FlowContent +import kotlinx.html.TagConsumer -public class HtmlVisionFragment(public val layout: HtmlOutputScope.() -> Unit) +public class HtmlFragment(public val content: TagConsumer<*>.() -> Unit) -public fun buildVisionFragment(visit: HtmlOutputScope.() -> Unit): HtmlVisionFragment = - HtmlVisionFragment(visit) +public fun TagConsumer<*>.fragment(fragment: HtmlFragment) { + fragment.content(this) +} + +public fun FlowContent.fragment(fragment: HtmlFragment) { + fragment.content(consumer) +} + +public class HtmlVisionFragment(public val content: HtmlOutputScope<*, V>.() -> Unit) + +public fun buildVisionFragment(block: HtmlOutputScope<*, Vision>.() -> Unit): HtmlVisionFragment = + HtmlVisionFragment(block) diff --git a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/StaticHtmlOutputScope.kt b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/StaticHtmlOutputScope.kt index 02aea7d3..b07c575c 100644 --- a/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/StaticHtmlOutputScope.kt +++ b/visionforge-core/src/commonMain/kotlin/hep/dataforge/vision/html/StaticHtmlOutputScope.kt @@ -22,7 +22,7 @@ public fun HtmlVisionFragment.renderToObject( root: TagConsumer, prefix: String? = null, renderer: HtmlVisionRenderer, -): T = StaticHtmlOutputScope(root, prefix, renderer).apply(layout).finalize() +): T = StaticHtmlOutputScope(root, prefix, renderer).apply(content).finalize() public fun HtmlVisionFragment.renderToString(renderer: HtmlVisionRenderer): String = renderToObject(createHTML(), null, renderer) \ No newline at end of file diff --git a/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/html/DynamicHtmlOutputContext.kt b/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/html/DynamicHtmlOutputContext.kt deleted file mode 100644 index 573055a0..00000000 --- a/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/html/DynamicHtmlOutputContext.kt +++ /dev/null @@ -1,31 +0,0 @@ -package hep.dataforge.vision.html - -import hep.dataforge.vision.Vision -import kotlinx.browser.document -import kotlinx.html.TagConsumer -import org.w3c.dom.Element -import org.w3c.dom.HTMLElement - -public interface HtmlVisionBinding{ - public fun bind(element: Element, vision: V): Unit -} - -public fun Map.bind(binder: HtmlVisionBinding){ - forEach { (id, vision) -> - val element = document.getElementById(id) ?: error("Could not find element with id $id") - binder.bind(element, vision) - } -} - -public fun HtmlVisionFragment.bindToDocument( - root: TagConsumer, - binder: HtmlVisionBinding, -): HTMLElement = BindingHtmlOutputScope(root).apply(layout).let { scope -> - scope.finalize().apply { - scope.bindings.forEach { (name, vision) -> - val id = scope.resolveId(name) - val element = document.getElementById(id) ?: error("Could not find element with name $name and id $id") - binder.bind(element, vision) - } - } -} diff --git a/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/html/elementOutput.kt b/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/html/elementOutput.kt new file mode 100644 index 00000000..2a9f60c0 --- /dev/null +++ b/visionforge-core/src/jsMain/kotlin/hep/dataforge/vision/html/elementOutput.kt @@ -0,0 +1,53 @@ +package hep.dataforge.vision.html + +import hep.dataforge.names.Name +import hep.dataforge.names.toName +import hep.dataforge.vision.Vision +import kotlinx.browser.document +import kotlinx.html.TagConsumer +import org.w3c.dom.* + +public interface ElementVisionRenderer { + public fun render(element: Element, vision: V): Unit +} + +public fun Map.bind(renderer: ElementVisionRenderer) { + forEach { (id, vision) -> + val element = document.getElementById(id) ?: error("Could not find element with id $id") + renderer.render(element, vision) + } +} + +public fun Element.renderVisions(renderer: ElementVisionRenderer, visionProvider: (Name) -> V?) { + val elements = getElementsByClassName(HtmlOutputScope.OUTPUT_CLASS) + elements.asList().forEach { element -> + val name = element.attributes[HtmlOutputScope.NAME_ATTRIBUTE]?.value + if (name == null) { + console.error("Attribute ${HtmlOutputScope.NAME_ATTRIBUTE} not defined in the output element") + return@forEach + } + val vision = visionProvider(name.toName()) + if (vision == null) { + console.error("Vision with name $name is not resolved") + return@forEach + } + renderer.render(element, vision) + } +} + +public fun Document.renderVisions(renderer: ElementVisionRenderer, visionProvider: (Name) -> V?): Unit { + documentElement?.renderVisions(renderer, visionProvider) +} + +public fun HtmlVisionFragment.renderInDocument( + root: TagConsumer, + renderer: ElementVisionRenderer, +): HTMLElement = BindingHtmlOutputScope(root).apply(content).let { scope -> + scope.finalize().apply { + scope.bindings.forEach { (name, vision) -> + val id = scope.resolveId(name) + val element = document.getElementById(id) ?: error("Could not find element with name $name and id $id") + renderer.render(element, vision) + } + } +} 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 32e3262a..aeb43f41 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,92 +1,137 @@ -//package hep.dataforge.vision.server -// -//import hep.dataforge.meta.* -//import hep.dataforge.names.Name -//import hep.dataforge.names.toName -//import hep.dataforge.vision.server.VisionServer.Companion.DEFAULT_PAGE -//import io.ktor.application.Application -//import io.ktor.application.featureOrNull -//import io.ktor.application.install -//import io.ktor.features.CORS -//import io.ktor.http.content.resources -//import io.ktor.http.content.static -//import io.ktor.routing.Routing -//import io.ktor.routing.route -//import io.ktor.routing.routing -//import io.ktor.server.engine.ApplicationEngine -//import io.ktor.websocket.WebSockets -//import kotlinx.html.TagConsumer -//import java.awt.Desktop -//import java.net.URI -//import kotlin.text.get -// -//public enum class ServerUpdateMode { -// NONE, -// PUSH, -// PULL -//} -// -//public class VisionServer internal constructor( -// private val routing: Routing, -// private val rootRoute: String, -//) : Configurable { -// override val config: Config = Config() -// public var updateMode: ServerUpdateMode by config.enum(ServerUpdateMode.NONE, key = UPDATE_MODE_KEY) -// public var updateInterval: Long by config.long(300, key = UPDATE_INTERVAL_KEY) -// public var embedData: Boolean by config.boolean(false) -// -// /** -// * a list of headers that should be applied to all pages -// */ -// private val globalHeaders: ArrayList = ArrayList() -// -// public fun header(block: TagConsumer<*>.() -> Unit) { -// globalHeaders.add(HtmlFragment(block)) -// } -// -// public fun page( -// plotlyFragment: PlotlyFragment, -// route: String = DEFAULT_PAGE, -// title: String = "Plotly server page '$route'", -// headers: List = emptyList(), -// ) { -// routing.createRouteFromPath(rootRoute).apply { -// val plots = HashMap() -// route(route) { -// //Update websocket -// webSocket("ws/{id}") { -// val plotId: String = call.parameters["id"] ?: error("Plot id not defined") -// -// application.log.debug("Opened server socket for $plotId") -// -// val plot = plots[plotId] ?: error("Plot with id='$plotId' not registered") -// -// try { -// plot.collectUpdates(plotId, this, updateInterval).collect { update -> -// val json = update.toJson() -// outgoing.send(Frame.Text(json.toString())) -// } -// } catch (ex: Exception) { -// application.log.debug("Closed server socket for $plotId") -// } -// } -// //Plots in their json representation -// get("data/{id}") { -// val id: String = call.parameters["id"] ?: error("Plot id not defined") -// -// val plot: Plot? = plots[id] -// if (plot == null) { -// call.respond(HttpStatusCode.NotFound, "Plot with id = $id not found") -// } else { -// call.respondText( -// plot.toJsonString(), -// contentType = ContentType.Application.Json, -// status = HttpStatusCode.OK -// ) -// } -// } -// //filled pages -// get { +package hep.dataforge.vision.server + +import hep.dataforge.context.Context +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 hep.dataforge.vision.flowChanges +import hep.dataforge.vision.html.* +import hep.dataforge.vision.server.VisionServer.Companion.DEFAULT_PAGE +import io.ktor.application.* +import io.ktor.features.CORS +import io.ktor.html.respondHtml +import io.ktor.http.* +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.content.resources +import io.ktor.http.content.static +import io.ktor.response.respond +import io.ktor.response.respondText +import io.ktor.routing.* +import io.ktor.server.engine.ApplicationEngine +import io.ktor.websocket.WebSockets +import io.ktor.websocket.webSocket +import kotlinx.html.* +import kotlinx.html.stream.createHTML +import java.awt.Desktop +import java.net.URI +import kotlin.time.milliseconds + +public class VisionServer internal constructor( + private val visionManager: VisionManager, + private val routing: Routing, + private val rootRoute: String, +) : Configurable { + override val config: Config = Config() + public var updateInterval: Long by config.long(300, key = UPDATE_INTERVAL_KEY) + public var cacheFragments: Boolean by config.boolean(true) + + /** + * a list of headers that should be applied to all pages + */ + private val globalHeaders: ArrayList = ArrayList() + + public fun header(block: TagConsumer<*>.() -> Unit) { + globalHeaders.add(HtmlFragment(block)) + } + + private fun HTML.buildPage( + visionFragment: HtmlVisionFragment, + title: String, + headers: List, + ): Map { + lateinit var result: Map + + head { + meta { + charset = "utf-8" + (globalHeaders + headers).forEach { + fragment(it) + } + } + title(title) + } + body { + result = visionFragment(visionFragment) + script { + type = "text/javascript" + + val normalizedRoute = if (rootRoute.endsWith("/")) { + rootRoute + } else { + "$rootRoute/" + } + + src = TODO()//"${normalizedRoute}js/plotlyConnect.js" + } + } + + return result + } + + public fun page( + visionFragment: HtmlVisionFragment, + route: String = DEFAULT_PAGE, + title: String = "VisionForge server page '$route'", + headers: List = emptyList(), + ) { + val visions = HashMap() + + val cachedHtml: String? = if (cacheFragments) { + createHTML(true).html { + visions.putAll(buildPage(visionFragment, title, headers)) + } + } else { + null + } + + routing.createRouteFromPath(rootRoute).apply { + 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") + try { + vision.flowChanges(this, updateInterval.milliseconds).collect { update -> + val json = visionManager.encodeToString(update) + outgoing.send(Frame.Text(json)) + } + } catch (ex: Exception) { + application.log.debug("Closed server socket for $name") + } + } + //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 + ) + } + } + //filled pages + get { // val origin = call.request.origin // val url = URLBuilder().apply { // protocol = URLProtocol.createOrDefault(origin.scheme) @@ -95,125 +140,68 @@ // port = origin.port // encodedPath = origin.uri // }.build() -// call.respondHtml { -// val normalizedRoute = if (rootRoute.endsWith("/")) { -// rootRoute -// } else { -// "$rootRoute/" -// } -// -// head { -// meta { -// charset = "utf-8" -// (globalHeaders + headers).forEach { -// it.visit(consumer) -// } -// script { -// type = "text/javascript" -// src = "${normalizedRoute}js/plotly.min.js" -// } -// script { -// type = "text/javascript" -// src = "${normalizedRoute}js/plotlyConnect.js" -// } -// } -// title(title) -// } -// body { -// val container = -// ServerPlotlyRenderer(url, updateMode, updateInterval, embedData) { plotId, plot -> -// plots[plotId] = plot -// } -// with(plotlyFragment) { -// render(container) -// } -// } -// } -// } -// } -// } -// } -// -// public fun page( -// route: String = DEFAULT_PAGE, -// title: String = "Plotly server page '$route'", -// headers: List = emptyList(), -// content: FlowContent.(renderer: PlotlyRenderer) -> Unit, -// ) { -// page(PlotlyFragment(content), route, title, headers) -// } -// -// -// public companion object { -// public const val DEFAULT_PAGE: String = "/" -// public val UPDATE_MODE_KEY: Name = "update.mode".toName() -// public val UPDATE_INTERVAL_KEY: Name = "update.interval".toName() -// } -//} -// -// -///** -// * Attach plotly application to given server -// */ -//public fun Application.visionModule(route: String = DEFAULT_PAGE): VisionServer { -// if (featureOrNull(WebSockets) == null) { -// install(WebSockets) -// } -// -// if (featureOrNull(CORS) == null) { -// install(CORS) { -// anyHost() -// } -// } -// -// -// val routing = routing { -// route(route) { -// static { -// resources() -// } -// } -// } -// -// return VisionServer(routing, route) -//} -// -// -///** -// * Configure server to start sending updates in push mode. Does not affect loaded pages -// */ -//public fun VisionServer.pushUpdates(interval: Long = 100): VisionServer = apply { -// updateMode = ServerUpdateMode.PUSH -// updateInterval = interval -//} -// -///** -// * Configure client to request regular updates from server. Pull updates are more expensive than push updates since -// * they contain the full plot data and server can't decide what to send. -// */ -//public fun VisionServer.pullUpdates(interval: Long = 1000): VisionServer = apply { -// updateMode = ServerUpdateMode.PULL -// updateInterval = interval -//} -// -/////** -//// * Start static server (updates via reload) -//// */ -////@OptIn(KtorExperimentalAPI::class) -////public fun Plotly.serve( -//// scope: CoroutineScope = GlobalScope, -//// host: String = "localhost", -//// port: Int = 7777, -//// block: PlotlyServer.() -> Unit, -////): ApplicationEngine = scope.embeddedServer(io.ktor.server.cio.CIO, port, host) { -//// plotlyModule().apply(block) -////}.start() -// -// -//public fun ApplicationEngine.show() { -// val connector = environment.connectors.first() -// val uri = URI("http", null, connector.host, connector.port, null, null, null) -// Desktop.getDesktop().browse(uri) -//} -// -//public fun ApplicationEngine.close(): Unit = stop(1000, 5000) \ No newline at end of file + if (cachedHtml == null) { + call.respondHtml { + visions.putAll(buildPage(visionFragment, title, headers)) + } + } else { + call.respondText(cachedHtml, ContentType.Text.Html.withCharset(Charsets.UTF_8)) + } + } + } + } + } + + public fun page( + route: String = DEFAULT_PAGE, + title: String = "Plotly server page '$route'", + headers: List = emptyList(), + content: HtmlOutputScope<*, Vision>.() -> Unit, + ) { + page(buildVisionFragment(content), route, title, headers) + } + + + public companion object { + public const val DEFAULT_PAGE: String = "/" + public val UPDATE_MODE_KEY: Name = "update.mode".toName() + public val UPDATE_INTERVAL_KEY: Name = "update.interval".toName() + } +} + + +/** + * Attach plotly application to given server + */ +public fun Application.visionModule(context: Context, route: String = DEFAULT_PAGE): VisionServer { + if (featureOrNull(WebSockets) == null) { + install(WebSockets) + } + + if (featureOrNull(CORS) == null) { + install(CORS) { + anyHost() + } + } + + + val routing = routing { + route(route) { + static { + resources() + } + } + } + + val visionManager = context.plugins.fetch(VisionManager) + + return VisionServer(visionManager, routing, route) +} + +public fun ApplicationEngine.show() { + val connector = environment.connectors.first() + val uri = URI("http", null, connector.host, connector.port, null, null, null) + Desktop.getDesktop().browse(uri) +} + +public fun ApplicationEngine.close(): Unit = stop(1000, 5000) \ No newline at end of file diff --git a/visionforge-solid/build.gradle.kts b/visionforge-solid/build.gradle.kts index b7b6daa8..fde0d41d 100644 --- a/visionforge-solid/build.gradle.kts +++ b/visionforge-solid/build.gradle.kts @@ -14,11 +14,5 @@ kotlin { api(project(":visionforge-core")) } } - jsMain { - dependencies { - implementation(npm("three", "0.122.0")) - implementation(npm("three-csg-ts", "1.0.1")) - } - } } } \ No newline at end of file diff --git a/visionforge-threejs/src/main/kotlin/hep/dataforge/vision/solid/three/ThreePlugin.kt b/visionforge-threejs/src/main/kotlin/hep/dataforge/vision/solid/three/ThreePlugin.kt index a0f173b9..242edca9 100644 --- a/visionforge-threejs/src/main/kotlin/hep/dataforge/vision/solid/three/ThreePlugin.kt +++ b/visionforge-threejs/src/main/kotlin/hep/dataforge/vision/solid/three/ThreePlugin.kt @@ -4,7 +4,7 @@ import hep.dataforge.context.* import hep.dataforge.meta.Meta import hep.dataforge.names.* import hep.dataforge.vision.Vision -import hep.dataforge.vision.html.HtmlVisionBinding +import hep.dataforge.vision.html.ElementVisionRenderer import hep.dataforge.vision.solid.* import hep.dataforge.vision.solid.specifications.Canvas3DOptions import hep.dataforge.vision.visible @@ -15,7 +15,7 @@ import kotlin.collections.set import kotlin.reflect.KClass import info.laht.threekt.objects.Group as ThreeGroup -public class ThreePlugin : AbstractPlugin(), HtmlVisionBinding { +public class ThreePlugin : AbstractPlugin(), ElementVisionRenderer { override val tag: PluginTag get() = Companion.tag public val solidManager: SolidManager by require(SolidManager) @@ -122,7 +122,7 @@ public class ThreePlugin : AbstractPlugin(), HtmlVisionBinding { attach(element) } - override fun bind(element: Element, vision: Solid) { + override fun render(element: Element, vision: Solid) { createCanvas(element).render(vision) }