Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ plugins {
id("io.codearte.nexus-staging") apply false

id("io.github.gradle-nexus.publish-plugin")

id("com.google.devtools.ksp") apply false
// kotlin("jupyter.api") apply false
}

val localProps = Properties()
Expand Down
6 changes: 6 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx2048M
kotlin.jvm.target.validation.mode=error

# Jupyter API
kotlin.jupyter.add.scanner=false

# Versions
kotlin.version=1.9.25
datetime.version=0.3.2
Expand All @@ -23,6 +26,9 @@ dokka.version=1.9.20
nexusStaging.version=0.30.0
nexusPublish.version=1.3.0

ksp.version=1.9.25-1.0.20
jupyterApi.version=0.12.0-308

# Also update JS version in <home>/demo/js-frontend-app/src/main/resources/index.html
letsPlot.version=4.4.2-SNAPSHOT

Expand Down
10 changes: 10 additions & 0 deletions plot-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ plugins {
kotlin("multiplatform")
`maven-publish`
signing
// Add the KSP plugin before the Jupyter API to avoid ksp versions incompatibility.
// May be removed when using further versions of the jupyter api
id("com.google.devtools.ksp")
kotlin("jupyter.api") version "0.12.0-308"
}

val letsPlotVersion = extra["letsPlot.version"] as String
Expand Down Expand Up @@ -63,6 +67,8 @@ kotlin {
dependencies {
// assertj
implementation("org.assertj:assertj-core:$assertjVersion")

implementation("org.jetbrains.lets-plot:lets-plot-image-export:$letsPlotVersion")
}
}
}
Expand Down Expand Up @@ -173,6 +179,10 @@ tasks {
}
}

tasks.processJupyterApiResources {
libraryProducers = listOf("org.jetbrains.letsPlot.jupyter.Integration")
}

//task printIt {
// print("${project.name}: ${uri(project.localMavenRepository)}")
//}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

package org.jetbrains.letsPlot.frontend

import org.jetbrains.letsPlot.Figure
import org.jetbrains.letsPlot.FrontendContext
import org.jetbrains.letsPlot.GGBunch
import org.jetbrains.letsPlot.core.util.PlotHtmlHelper
Expand Down Expand Up @@ -50,6 +51,10 @@ class NotebookFrontendContext(
return getDisplayHtml(plotBunch.toSpec())
}

fun getHtml(figure: Figure): String {
return getDisplayHtml(figure.toSpec())
}

// Used by alternative kotlin lets-plot API
// https://github.com/nikitinas/lets-plot-dsl.git
fun getDisplayHtml(rawSpec: MutableMap<String, Any>): String {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package org.jetbrains.letsPlot.jupyter

import org.jetbrains.kotlinx.jupyter.api.*
import org.jetbrains.kotlinx.jupyter.api.libraries.ExecutionHost
import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration
import org.jetbrains.kotlinx.jupyter.api.libraries.resources
import org.jetbrains.letsPlot.Figure
import org.jetbrains.letsPlot.LetsPlot
import org.jetbrains.letsPlot.core.util.PlotHtmlHelper.scriptUrl
import org.jetbrains.letsPlot.export.VersionChecker
import org.jetbrains.letsPlot.frontend.NotebookFrontendContext

@Suppress("unused")
internal class Integration(private val notebook: Notebook, private val options: MutableMap<String, String?>) :
JupyterIntegration() {

private val config = JupyterConfig()
private lateinit var frontendContext: NotebookFrontendContext


override fun Builder.onLoaded() {

import("org.jetbrains.letsPlot.*")
import("org.jetbrains.letsPlot.geom.*")
import("org.jetbrains.letsPlot.geom.extras.*")
import("org.jetbrains.letsPlot.stat.*")
import("org.jetbrains.letsPlot.label.*")
import("org.jetbrains.letsPlot.scale.*")
import("org.jetbrains.letsPlot.facet.*")
import("org.jetbrains.letsPlot.sampling.*")
import("org.jetbrains.letsPlot.export.*")
import("org.jetbrains.letsPlot.tooltips.*")
import("org.jetbrains.letsPlot.annotations.*")
import("org.jetbrains.letsPlot.themes.*")
import("org.jetbrains.letsPlot.font.*")
import("org.jetbrains.letsPlot.coord.*")
import("org.jetbrains.letsPlot.pos.*")
import("org.jetbrains.letsPlot.bistro.corr.*")
import("org.jetbrains.letsPlot.bistro.qq.*")
import("org.jetbrains.letsPlot.bistro.joint.*")
import("org.jetbrains.letsPlot.bistro.residual.*")
import("org.jetbrains.letsPlot.bistro.waterfall.*")
import("org.jetbrains.letsPlot.intern.toSpec")
import("org.jetbrains.letsPlot.spatial.SpatialDataset")


onLoaded {
// Take integration options from descriptor by default;
// If used via Kotlin Notebook plugin as a dependency,
// provide defaults (versions from `VersionChecker`
// and empty `isolatedFrame`)
val api = options["api"] ?: VersionChecker.letsPlotKotlinAPIVersion
val js = options["js"] ?: VersionChecker.letsPlotJsVersion
val isolatedFrame = options["isolatedFrame"] ?: ""
val isolatedFrameParam = if (isolatedFrame.isNotEmpty()) isolatedFrame.toBoolean() else null
frontendContext = LetsPlot.setupNotebook(js, isolatedFrameParam) { display(HTML(it), null) }
LetsPlot.apiVersion = api
// Load library JS
display(HTML(frontendContext.getConfigureHtml()), null)
// add figure renders AFTER frontendContext initialization
addRenders(js)
declare("letsPlotNotebookConfig" to config)
}
}


private fun Builder.addRenders(jsVersion: String) {
var firstFigureRendered = false
resources {
js("letsPlotJs") {
url(scriptUrl(jsVersion))
}
}
renderWithHostTemp<Figure> { host, value ->
// For cases when Integration is added via Kotlin Notebook project dependency;
// display configure HTML with the first `Figure rendering
if (!firstFigureRendered) {
firstFigureRendered = true
host.execute { display(HTML(frontendContext.getConfigureHtml()), null) }
}
NotebookRenderingContext(config, frontendContext).figureToMimeResult(value)
}
}


// copy-pasted from jupyter api;
// it is not possible to use `renderWithHost` directly because it is inline and built with JVM 11
private inline fun <reified T : Any> Builder.renderWithHostTemp(noinline renderer: CodeCell.(ExecutionHost, T) -> Any) {
val execution =
ResultHandlerExecution { host, property ->
val currentCell = notebook.currentCell
?: throw IllegalStateException("Current cell should not be null on renderer invocation")
FieldValue(renderer(currentCell, host, property.value as T), null)
}
addRenderer(SubtypeRendererTypeHandler(T::class, execution))
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.jetbrains.letsPlot.jupyter

/**
* Lets-Plot config for Jupyter.
*/
class JupyterConfig {
/**
* Is IDEA theme applied to plots in the notebook outputs rendered with Swing.
*
* Enabled by default.
*/
var themeApplied: Boolean = true

/**
* Is Java Swing applied to plots in the notebook outputs.
* Otherwise, web (HTML + JS) rendering is used.
*
* Enabled by default.
*/
var swingEnabled: Boolean = true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.jetbrains.letsPlot.jupyter

import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import org.jetbrains.kotlinx.jupyter.api.MimeTypedResultEx
import org.jetbrains.kotlinx.jupyter.api.MimeTypes
import org.jetbrains.letsPlot.Figure
import org.jetbrains.letsPlot.awt.plot.PlotSvgExport
import org.jetbrains.letsPlot.frontend.NotebookFrontendContext
import org.jetbrains.letsPlot.intern.toSpec
import java.util.*

internal class NotebookRenderingContext(
private val config: JupyterConfig,
private val frontendContext: NotebookFrontendContext
) {
/**
* Creates Mime JSON with two output options - HTML and application/plot.
* The HTML output is used in Jupyter Notebooks and Datalore (the other one is ignored).
* The application/plot is used in Kotlin Notebook when native rendering via Swing is enabled.
*/
private fun figureToMimeJson(figure: Figure): JsonObject {
val spec = figure.toSpec()
val html = frontendContext.getHtml(figure)
return buildJsonObject {
put(MimeTypes.HTML, JsonPrimitive(html))
put("application/plot+json", buildJsonObject {
put("output_type", JsonPrimitive("lets_plot_spec"))
put("output", serializeSpec(spec))
put("apply_color_scheme", JsonPrimitive(config.themeApplied))
put("swing_enabled", JsonPrimitive(config.swingEnabled))
})
}
}

/**
* Modifies an SVG by adding a new identifier and setting the width and height of the SVG to 100%,
* while retaining the original dimensions in the style for responsiveness.
* It also adds viewBox and preserveAspectRatio attributes to ensure the SVG scales properly without distortion.
* Updates SVG id with provided one.
*/
private fun updateSvg(svgString: String, id: String): String {
val regex = Regex("""width=["']([^"']*)["']\s*height=["']([^"']*)["']""")
return regex.replace(svgString) {
val currentWidth = it.groupValues[1]
val currentHeight = it.groupValues[2]
"""id=$id width="100%" height="100%" style="max-width: ${currentWidth}px; max-height: ${currentHeight}px;" viewBox="0 0 $currentWidth $currentHeight" preserveAspectRatio="xMinYMin meet""""
}
}

/**
* Converts a `Figure` object into a hidden SVG embedded in an HTML <figure> element.
* The SVG is made hidden using an inline <script> tag. This script does not execute on platforms
* like GitHub or Gist, allowing the output to be displayed statically on those platforms.
*/
private fun figureToHiddenSvg(figure: Figure): Map<String, JsonPrimitive> {
val plotSVG = PlotSvgExport.buildSvgImageFromRawSpecs(figure.toSpec())
val id = UUID.randomUUID().toString()
val svgWithID = with(plotSVG) {
val svgSplit = split('\n')
(listOf(updateSvg(svgSplit.first(), id)) + svgSplit.drop(1)).joinToString("\n")
}
val extraHTML = """
$svgWithID
<script>document.getElementById("$id").style.display = "none";</script>
""".trimIndent()

return mapOf(MimeTypes.HTML to JsonPrimitive(extraHTML))
}

fun figureToMimeResult(figure: Figure): MimeTypedResultEx {
val basicResult = figureToMimeJson(figure)
val extraSvg = figureToHiddenSvg(figure)
return MimeTypedResultEx(
basicResult extendedByJson extraSvg,
id = null,
metadataModifiers = emptyList()
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.jetbrains.letsPlot.jupyter

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract

internal fun <K, V : Any> Map<K, V>.extendedBy(other: Map<K, V>, join: (V, V) -> V): Map<K, V> {
return (this + other).mapValues { (k, v) ->
if (k in this && k in other)
join(this[k]!!, other[k]!!)
else
v
}
}

@OptIn(ExperimentalContracts::class)
private inline fun <reified T> bothOfType(a: Any, b: Any): Boolean {
contract { returns(true) implies (a is T && b is T) }
return a is T && b is T
}

@OptIn(ExperimentalContracts::class)
private inline fun <reified T> bothOfTypeAnd(a: Any, b: Any, condition: (T) -> Boolean): Boolean {
contract { returns(true) implies (a is T && b is T) }
return a is T && b is T && condition(a) && condition(b)
}

internal infix fun Map<String, JsonElement>.extendedByJson(other: Map<String, JsonElement>): JsonObject {
val map = this.extendedBy(other) { a, b ->
// This logic might be enhanced
when {
bothOfTypeAnd<JsonPrimitive>(a, b) { it.isString } -> JsonPrimitive(a.content + b.content)
bothOfType<JsonArray>(a, b) -> JsonArray(a + b)
bothOfType<JsonObject>(a, b) -> JsonObject(a + b)
else -> a
}
}
return JsonObject(map)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.jetbrains.letsPlot.jupyter

import kotlinx.serialization.json.*

/**
* Serialize Lets-Plot spec into `JsonElement`
*/
internal fun serializeSpec(spec: Map<*, *>): JsonElement {
return serialize(spec)
}

private fun serializeAny(obj: Any?): JsonElement {
return when (obj) {
null -> JsonNull
is Map<*, *> -> serialize(obj)
is List<*> -> serialize(obj)
is String -> JsonPrimitive(obj)
is Boolean -> JsonPrimitive(obj)
is Number -> JsonPrimitive(obj)
else -> error("Don't know how to parse object [$obj] of class ${obj::class}")
}
}

private fun serialize(map: Map<*, *>): JsonObject {
return buildJsonObject {
for ((key, value) in map) {
if (key !is String) error("Map key [$key] is of type ${key?.let { it::class }}. Don't know how to serialize it.")
put(key, serializeAny(value))
}
}
}

private fun serialize(list: List<*>): JsonArray {
return buildJsonArray {
for (value in list) {
add(serializeAny(value))
}
}
}
Loading