diff --git a/build.gradle.kts b/build.gradle.kts index 5f429c66d..c5588ed18 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,11 @@ plugins { id("io.codearte.nexus-staging") apply false id("io.github.gradle-nexus.publish-plugin") + + // 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") apply false + kotlin("jupyter.api") apply false } val localProps = Properties() diff --git a/gradle.properties b/gradle.properties index dfadbc787..77d2d31da 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,17 +12,24 @@ 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 kotlinLogging.version=2.0.5 slf4j.version=1.7.32 assertj.version=3.26.3 +serialization.version=1.7.3 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-313 + # Also update JS version in /demo/js-frontend-app/src/main/resources/index.html letsPlot.version=4.5.1 diff --git a/plot-api/build.gradle.kts b/plot-api/build.gradle.kts index 18e7a24c5..af5cf22e3 100644 --- a/plot-api/build.gradle.kts +++ b/plot-api/build.gradle.kts @@ -177,3 +177,22 @@ tasks { // print("${project.name}: ${uri(project.localMavenRepository)}") //} +// Provide jvm resources to jupyter module +// https://youtrack.jetbrains.com/issue/KTIJ-16582/Consumer-Kotlin-JVM-library-cannot-access-a-Kotlin-Multiplatform-JVM-target-resources-in-multi-module-Gradle-project +tasks { + val jvmProcessResources by getting + val fixMissingResources by creating(Copy::class) { + dependsOn(jvmProcessResources) + from(layout.buildDirectory.dir("processedResources/jvm/main")) + into(layout.buildDirectory.dir("classes/kotlin/jvm/main")) + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + val jvmJar by getting(Jar::class) { + dependsOn(fixMissingResources) + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } +} + +tasks.named("jvmTest") { + dependsOn("fixMissingResources") +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b11d4450f..eb1371ee1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,9 @@ pluginManagement { val nexusStagingVersion = extra["nexusStaging.version"] as String val nexusPublishVersion = extra["nexusPublish.version"] as String + val kspVersion = extra["ksp.version"] as String + val jupyterApiVersion = extra["jupyterApi.version"] as String + kotlin("multiplatform") version kotlinVersion kotlin("jvm").version(kotlinVersion) @@ -23,6 +26,9 @@ pluginManagement { id("io.codearte.nexus-staging") version nexusStagingVersion id("io.github.gradle-nexus.publish-plugin") version nexusPublishVersion + + id("com.google.devtools.ksp") version kspVersion + kotlin("jupyter.api") version jupyterApiVersion } } @@ -39,6 +45,9 @@ include("js-frontend-app") include("geotools") include("geotools-batik") include("dokka") +include("jupyter") +include("geotools-jupyter") +include("json") project(":demo-common").projectDir = File("./demo/demo-common") project(":jvm-javafx").projectDir = File("./demo/jvm-javafx") @@ -50,3 +59,8 @@ project(":geotools").projectDir = File("./toolkit/geotools") project(":geotools-batik").projectDir = File("./demo/geotools-batik") project(":dokka").projectDir = File("./docs/dokka") + +project(":jupyter").projectDir = File("./toolkit/jupyter") +project(":geotools-jupyter").projectDir = File("./toolkit/geotools-jupyter") + +project(":json").projectDir = File("./toolkit/json") \ No newline at end of file diff --git a/toolkit/geotools-jupyter/build.gradle.kts b/toolkit/geotools-jupyter/build.gradle.kts new file mode 100644 index 000000000..724ab6894 --- /dev/null +++ b/toolkit/geotools-jupyter/build.gradle.kts @@ -0,0 +1,103 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") + `maven-publish` + signing + id("com.google.devtools.ksp") + kotlin("jupyter.api") +} + +repositories { + mavenCentral() +} + +val geoToolsVersion = extra["geotools.version"] as String + +dependencies { + implementation(projects.plotApi) + // basic LPK jupyter integration + implementation(projects.jupyter) + + // geotools implementations + implementation(projects.geotools) + implementation("org.geotools:gt-main:$geoToolsVersion") + implementation("org.geotools:gt-geojson:$geoToolsVersion") + + testImplementation(kotlin("test")) +} + +kotlin { + jvmToolchain(11) +} +tasks.withType().all { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + +tasks.withType().all { + sourceCompatibility = JavaVersion.VERSION_11.toString() + targetCompatibility = JavaVersion.VERSION_11.toString() +} + +tasks.processJupyterApiResources { + libraryProducers = listOf("org.jetbrains.letsPlot.toolkit.geotools.jupyter.Integration") +} + +val artifactBaseName = "lets-plot-kotlin-geotools-jupyter" +val artifactGroupId = project.group as String +val artifactVersion = project.version as String + +afterEvaluate { + + publishing { + publications { + // Build artifact with all dependencies. + create("letsPlotKotlinGeotoolsJupyter") { + + groupId = artifactGroupId + artifactId = artifactBaseName + version = artifactVersion + + from(components["java"]) + + pom { + name.set("Lets-Plot Kotlin Geotools Jupyter Integration") + description.set( + "Lets-Plot Kotlin Geotools Integration For Kotlin Jupyter Kernel." + ) + url.set("https://github.com/JetBrains/lets-plot-kotlin") + licenses { + license { + name.set("MIT") + url.set("https://raw.githubusercontent.com/JetBrains/lets-plot-kotlin/master/LICENSE") + } + } + developers { + developer { + id.set("jetbrains") + name.set("JetBrains") + email.set("lets-plot@jetbrains.com") + } + } + scm { + url.set("https://github.com/JetBrains/lets-plot-kotlin") + } + } + } + } + + repositories { + mavenLocal { + val localMavenRepository: String by project + url = uri(localMavenRepository) + } + } + } +} +signing { + if (!(project.version as String).contains("SNAPSHOT")) { + sign(publishing.publications) + } +} diff --git a/toolkit/geotools-jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt b/toolkit/geotools-jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt new file mode 100644 index 000000000..ed84efc82 --- /dev/null +++ b/toolkit/geotools-jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt @@ -0,0 +1,18 @@ +package org.jetbrains.letsPlot.toolkit.geotools.jupyter + +import org.jetbrains.kotlinx.jupyter.api.Notebook +import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration +import org.jetbrains.kotlinx.jupyter.api.libraries.repositories +import org.jetbrains.letsPlot.export.VersionChecker + +@Suppress("unused") +class Integration(private val notebook: Notebook, private val options: MutableMap) : + JupyterIntegration() { + private val api = options["api"] ?: VersionChecker.letsPlotKotlinAPIVersion + override fun Builder.onLoaded() { + repositories { + maven("https://repo.osgeo.org/repository/release") + } + import("org.jetbrains.letsPlot.toolkit.geotools.toSpatialDataset") + } +} diff --git a/toolkit/geotools-jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt b/toolkit/geotools-jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt new file mode 100644 index 000000000..d9ef055ee --- /dev/null +++ b/toolkit/geotools-jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt @@ -0,0 +1,38 @@ +package org.jetbrains.letsPlot.toolkit.geotools.jupyter + +import org.jetbrains.kotlinx.jupyter.testkit.JupyterReplTestCase +import kotlin.test.Test + +class NaturalEarthTest : JupyterReplTestCase() { + private val geotoolsDependencies = """ + USE { + dependencies { + implementation("org.geotools:gt-shapefile:30.0") + } + } + """.trimIndent() + + private val naturalEarthMap = """ + import org.geotools.data.shapefile.ShapefileDataStoreFactory + import org.geotools.data.simple.SimpleFeatureCollection + import java.net.URL + + val factory = ShapefileDataStoreFactory() + val worldFeatures : SimpleFeatureCollection = with("naturalearth_lowres") { + val url = "https://raw.githubusercontent.com/JetBrains/lets-plot-kotlin/master/docs/examples/shp/${'$'}this/${'$'}this.shp" + factory.createDataStore(URL(url)).featureSource.features + } + + val world = worldFeatures.toSpatialDataset(10) + letsPlot() + + geomMap(map = world) + + ggsize(700, 400) + + themeVoid() + """.trimIndent() + + @Test + fun `natural earth example compilation in jupyter`() { + execRendered(geotoolsDependencies) + execRendered(naturalEarthMap) + } +} \ No newline at end of file diff --git a/toolkit/json/build.gradle.kts b/toolkit/json/build.gradle.kts new file mode 100644 index 000000000..e30dc31b7 --- /dev/null +++ b/toolkit/json/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + kotlin("jvm") + `maven-publish` + signing +} + +val serializationVersion = extra["serialization.version"] as String + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") + + testImplementation(kotlin("test")) +} + + +val artifactBaseName = "lets-plot-kotlin-json" +val artifactGroupId = project.group as String +val artifactVersion = project.version as String + +afterEvaluate { + publishing { + publications { + create("letsPlotKotlinJson") { + groupId = artifactGroupId + artifactId = artifactBaseName + version = artifactVersion + + from(components["java"]) + + pom { + name.set("Lets-Plot Kotlin Util") + description.set( + "Lets-Plot Kotlin Util For Json Serialization." + ) + url.set("https://github.com/JetBrains/lets-plot-kotlin") + licenses { + license { + name.set("MIT") + url.set("https://raw.githubusercontent.com/JetBrains/lets-plot-kotlin/master/LICENSE") + } + } + developers { + developer { + id.set("jetbrains") + name.set("JetBrains") + email.set("lets-plot@jetbrains.com") + } + } + scm { + url.set("https://github.com/JetBrains/lets-plot-kotlin") + } + } + } + } + + repositories { + mavenLocal { + val localMavenRepository: String by project + url = uri(localMavenRepository) + } + } + } +} +signing { + if (!(project.version as String).contains("SNAPSHOT")) { + sign(publishing.publications) + } +} diff --git a/toolkit/json/src/main/kotlin/org/jetbrains/letsPlot/toolkit/json/serializeSpec.kt b/toolkit/json/src/main/kotlin/org/jetbrains/letsPlot/toolkit/json/serializeSpec.kt new file mode 100644 index 000000000..d5c50e576 --- /dev/null +++ b/toolkit/json/src/main/kotlin/org/jetbrains/letsPlot/toolkit/json/serializeSpec.kt @@ -0,0 +1,85 @@ +package org.jetbrains.letsPlot.toolkit.json + +import kotlinx.serialization.json.* + +typealias JsonMap = Map + +fun serializeJsonMap(map: JsonMap): JsonElement { + return serialize(map) +} + +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 serialize 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)) + } + } +} + +fun deserializeJsonMap(json: JsonElement): JsonMap { + if (json !is JsonObject) error("Input json should be a key-value object, but it's $json") + val map = deserializeMap(json) + + // TODO: add null handling + for (value in map.values) { + if (value == null) error("Input json shouldn't have null values on the top level") + } + + @Suppress("UNCHECKED_CAST") + return map as JsonMap +} + +private fun deserializeAny(json: JsonElement): Any? { + return when (json) { + is JsonObject -> deserializeMap(json) + is JsonArray -> deserializeList(json) + is JsonPrimitive -> deserializePrimitive(json) + } +} + +private fun deserializePrimitive(json: JsonPrimitive): Any? { + return when { + json is JsonNull -> null + json.isString -> json.content + else -> { + json.booleanOrNull ?: json.doubleOrNull ?: error("Unknown JSON primitive type: [$json]") + } + } +} + +private fun deserializeMap(json: JsonObject): Map { + return buildMap { + for ((key, value) in json) { + put(key, deserializeAny(value)) + } + } +} + +private fun deserializeList(jsonArray: JsonArray): List { + return buildList { + for (el in jsonArray) { + add(deserializeAny(el)) + } + } +} diff --git a/toolkit/jupyter/build.gradle.kts b/toolkit/jupyter/build.gradle.kts new file mode 100644 index 000000000..597074793 --- /dev/null +++ b/toolkit/jupyter/build.gradle.kts @@ -0,0 +1,105 @@ +plugins { + kotlin("jvm") + `maven-publish` + signing + id("com.google.devtools.ksp") + kotlin("jupyter.api") +} + +val letsPlotVersion = extra["letsPlot.version"] as String +val letsPlotKotlinVersion = project.version as String +val kotlinLoggingVersion = extra["kotlinLogging.version"] as String + +dependencies { + // All LP/LPK implementations to be loaded in notebook + implementation(projects.plotApi) + + implementation("org.jetbrains.lets-plot:lets-plot-common:$letsPlotVersion") + implementation("org.jetbrains.lets-plot:platf-awt-jvm:$letsPlotVersion") + implementation("org.jetbrains.lets-plot:lets-plot-image-export:$letsPlotVersion") + implementation("io.github.microutils:kotlin-logging-jvm:$kotlinLoggingVersion") + + implementation(projects.json) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} +kotlin { + jvmToolchain(11) +} +tasks.withType().all { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + +tasks.withType().all { + sourceCompatibility = JavaVersion.VERSION_11.toString() + targetCompatibility = JavaVersion.VERSION_11.toString() +} + +tasks.processJupyterApiResources { + libraryProducers = listOf("org.jetbrains.letsPlot.toolkit.jupyter.Integration") +} + +val artifactBaseName = "lets-plot-kotlin-jupyter" +val artifactGroupId = project.group as String +val artifactVersion = project.version as String + +afterEvaluate { + + publishing { + publications { + // Build artifact with all dependencies. + create("letsPlotKotlinJupyter") { + + groupId = artifactGroupId + artifactId = artifactBaseName + version = artifactVersion + + from(components["java"]) + + pom { + name.set("Lets-Plot Kotlin Jupyter Integration") + description.set( + "Lets-Plot Kotlin Integration For Kotlin Jupyter Kernel." + ) + url.set("https://github.com/JetBrains/lets-plot-kotlin") + licenses { + license { + name.set("MIT") + url.set("https://raw.githubusercontent.com/JetBrains/lets-plot-kotlin/master/LICENSE") + } + } + developers { + developer { + id.set("jetbrains") + name.set("JetBrains") + email.set("lets-plot@jetbrains.com") + } + } + scm { + url.set("https://github.com/JetBrains/lets-plot-kotlin") + } + } + } + } + + repositories { + mavenLocal { + val localMavenRepository: String by project + url = uri(localMavenRepository) + } + } + } +} +signing { + if (!(project.version as String).contains("SNAPSHOT")) { + sign(publishing.publications) + } +} + diff --git a/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/Integration.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/Integration.kt new file mode 100644 index 000000000..dad4cbe99 --- /dev/null +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/Integration.kt @@ -0,0 +1,86 @@ +package org.jetbrains.letsPlot.toolkit.jupyter + +import org.jetbrains.kotlinx.jupyter.api.HTML +import org.jetbrains.kotlinx.jupyter.api.Notebook +import org.jetbrains.kotlinx.jupyter.api.declare +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) : + JupyterIntegration() { + + // used by kandy-lets-plot + internal val config = JupyterConfig() + private lateinit var frontendContext: NotebookFrontendContext + + // Take integration options from descriptor by default; + // If used via Kotlin Notebook plugin as a dependency, + // provide defaults (versions from `VersionChecker` + // and empty `isolatedFrame`) + private val api = options["api"] ?: VersionChecker.letsPlotKotlinAPIVersion + private val js = VersionChecker.letsPlotJsVersion + private val isolatedFrame = options["isolatedFrame"] ?: "" + + + 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 { + 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)) + } + } + renderWithHost
{ 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) + } + } + +} diff --git a/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterConfig.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterConfig.kt new file mode 100644 index 000000000..81f8144f5 --- /dev/null +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterConfig.kt @@ -0,0 +1,21 @@ +package org.jetbrains.letsPlot.toolkit.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 +} diff --git a/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt new file mode 100644 index 000000000..f4d0becc2 --- /dev/null +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt @@ -0,0 +1,83 @@ +package org.jetbrains.letsPlot.toolkit.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 org.jetbrains.letsPlot.toolkit.jupyter.json.extendedByJson +import org.jetbrains.letsPlot.toolkit.json.serializeJsonMap +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.getDisplayHtml(figure.toSpec()) + return buildJsonObject { + put(MimeTypes.HTML, JsonPrimitive(html)) + put("application/plot+json", buildJsonObject { + put("output_type", JsonPrimitive("lets_plot_spec")) + put("output", serializeJsonMap(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
element. + * The SVG is made hidden using an inline + """.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() + ) + } +} \ No newline at end of file diff --git a/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/json/containerUtil.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/json/containerUtil.kt new file mode 100644 index 000000000..4ee96c443 --- /dev/null +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/json/containerUtil.kt @@ -0,0 +1,42 @@ +package org.jetbrains.letsPlot.toolkit.jupyter.json + +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 Map.extendedBy(other: Map, join: (V, V) -> V): Map { + 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 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 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.extendedByJson(other: Map): JsonObject { + val map = this.extendedBy(other) { a, b -> + // This logic might be enhanced + when { + bothOfTypeAnd(a, b) { it.isString } -> JsonPrimitive(a.content + b.content) + bothOfType(a, b) -> JsonArray(a + b) + bothOfType(a, b) -> JsonObject(a + b) + else -> a + } + } + return JsonObject(map) +} diff --git a/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/GGBunchTest.kt b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/GGBunchTest.kt new file mode 100644 index 000000000..50be74f25 --- /dev/null +++ b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/GGBunchTest.kt @@ -0,0 +1,43 @@ +package org.jetbrains.letsPlot.toolkit.jupyter + +import org.junit.jupiter.api.TestInfo +import kotlin.test.Test + +class GGBunchTest : JupyterTest() { + + private val bunch = """ + val data1 = mapOf( + "x" to listOf(1, 2, 3, 4), + "y" to listOf(4, 6, 5, 7) + ) + val plot1 = ggplot(data1) + + geomPoint(size = 5, color = "red", shape = 16) { x = "x"; y = "y" } + + ggtitle("Plot 1") + + ggsize(300, 300) + + val data2 = mapOf( + "x" to listOf(1, 2, 3, 4, 5), + "y" to listOf(1, 2, 3, 2, 1) + ) + val plot2 = ggplot(data2) + + geomLine(size = 2, color = "blue") { x = "x"; y = "y" } + + ggtitle("Plot 2") + + theme(axisTitleX = elementText(color = "blue", size = 14), + axisTitleY = elementText(color = "blue", size = 14)) + + ggsize(300, 300) + + val bunch = GGBunch() + .addPlot(plot1, 0, 0) + .addPlot(plot2, 350, 0) + + bunch + """.trimIndent() + + @Test + fun `compilation of GGBunch in jupyter`() = bunch.checkCompilation() + + @Test + fun `GGBunch output in jupyter`(testInfo: TestInfo) { + assertOutput(execRendered(bunch), testInfo) + } +} \ No newline at end of file diff --git a/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterTest.kt b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterTest.kt new file mode 100644 index 000000000..93f79ca06 --- /dev/null +++ b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterTest.kt @@ -0,0 +1,38 @@ +package org.jetbrains.letsPlot.toolkit.jupyter + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.jetbrains.kotlinx.jupyter.api.Code +import org.jetbrains.kotlinx.jupyter.api.MimeTypedResultEx +import org.jetbrains.kotlinx.jupyter.testkit.JupyterReplTestCase +import org.jetbrains.kotlinx.jupyter.testkit.ReplProvider +import org.junit.jupiter.api.TestInfo +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +abstract class JupyterTest : JupyterReplTestCase( + ReplProvider.forLibrariesTesting(listOf("lets-plot-kotlin")) +) { + + + private val classLoader = (this::class as Any).javaClass.classLoader + + fun Code.checkCompilation() { + execRendered(this) + } + + fun assertOutput(actualOutputText: Any?, testInfo: TestInfo) { + if (actualOutputText !is MimeTypedResultEx) fail("The output is not MimeTypedResultEx") + val outputDataObject = actualOutputText.toJson(JsonObject(mapOf()), null)["data"]?.jsonObject + assertNotNull(outputDataObject) + assertTrue(outputDataObject.contains("text/html")) + assertTrue(outputDataObject.contains("application/plot+json")) + + val resourcePath = "jupyter/${testInfo.testMethod.get().name}.out" + val resource = classLoader.getResource(resourcePath) + assertNotNull(resource) + assertEquals(resource.readText(), outputDataObject["application/plot+json"].toString()) + } +} \ No newline at end of file diff --git a/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/PlotTest.kt b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/PlotTest.kt new file mode 100644 index 000000000..e94b467b2 --- /dev/null +++ b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/PlotTest.kt @@ -0,0 +1,26 @@ +package org.jetbrains.letsPlot.toolkit.jupyter + +import org.junit.jupiter.api.TestInfo +import kotlin.test.Test + +class PlotTest : JupyterTest() { + + private val plot = """ + val data = mapOf( + "type" to listOf("X", "X", "Y", "Y", "X", "X", "Y", "X"), + "cond" to listOf("A", "B", "A", "A", "A", "A", "A", "B") + ) + + var p = letsPlot(data) + p += geomBar(color = "dark_green", alpha = .3) { x = "type"; fill = "cond" } + p + ggsize(700, 350) + """.trimIndent() + + @Test + fun `compilation of Plot in jupyter`() = plot.checkCompilation() + + @Test + fun `Plot output in jupyter`(testInfo: TestInfo) { + assertOutput(execRendered(plot), testInfo) + } +} \ No newline at end of file diff --git a/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/SubPlotsFigureTest.kt b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/SubPlotsFigureTest.kt new file mode 100644 index 000000000..f2522fb5e --- /dev/null +++ b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/SubPlotsFigureTest.kt @@ -0,0 +1,32 @@ +package org.jetbrains.letsPlot.toolkit.jupyter + +import org.junit.jupiter.api.TestInfo +import kotlin.test.Test + +class SubPlotsFigureTest : JupyterTest() { + + private val grid = """ + val data1 = mapOf( + "x" to listOf(1, 2, 3, 4), + "y" to listOf(4, 3, 2, 1) + ) + val plot1 = ggplot(data1) + geomPoint { x = "x"; y = "y" } + ggtitle("Plot 1") + + val data2 = mapOf( + "x" to listOf(1, 2, 3, 4), + "y" to listOf(1, 2, 3, 4) + ) + val plot2 = ggplot(data2) + geomLine { x = "x"; y = "y" } + ggtitle("Plot 2") + + val grid = gggrid(listOf(plot1, plot2)) + grid + """.trimIndent() + + @Test + fun `compilation of SubPlotsFigure in jupyter`() = grid.checkCompilation() + + @Test + fun `SubPlotsFigure output in jupyter`(testInfo: TestInfo) { + assertOutput(execRendered(grid), testInfo) + } +} \ No newline at end of file diff --git a/toolkit/jupyter/src/test/resources/jupyter/GGBunch output in jupyter.out b/toolkit/jupyter/src/test/resources/jupyter/GGBunch output in jupyter.out new file mode 100644 index 000000000..e81b8b1dd --- /dev/null +++ b/toolkit/jupyter/src/test/resources/jupyter/GGBunch output in jupyter.out @@ -0,0 +1 @@ +{"output_type":"lets_plot_spec","output":{"kind":"ggbunch","items":[{"feature_spec":{"ggtitle":{"text":"Plot 1"},"mapping":{},"data":{"x":[1.0,2.0,3.0,4.0],"y":[4.0,6.0,5.0,7.0]},"ggsize":{"width":300.0,"height":300.0},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"x","y":"y"},"stat":"identity","color":"red","shape":16.0,"size":5.0,"position":"identity","geom":"point"}],"data_meta":{"series_annotations":[{"type":"int","column":"x"},{"type":"int","column":"y"}]}},"x":0,"y":0,"width":null,"height":null},{"feature_spec":{"ggtitle":{"text":"Plot 2"},"mapping":{},"data":{"x":[1.0,2.0,3.0,4.0,5.0],"y":[1.0,2.0,3.0,2.0,1.0]},"ggsize":{"width":300.0,"height":300.0},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"x","y":"y"},"stat":"identity","color":"blue","size":2.0,"position":"identity","geom":"line"}],"theme":{"axis_title_y":{"color":"blue","size":14.0,"blank":false},"axis_title_x":{"color":"blue","size":14.0,"blank":false}},"data_meta":{"series_annotations":[{"type":"int","column":"x"},{"type":"int","column":"y"}]}},"x":350,"y":0,"width":null,"height":null}]},"apply_color_scheme":true,"swing_enabled":true} \ No newline at end of file diff --git a/toolkit/jupyter/src/test/resources/jupyter/Plot output in jupyter.out b/toolkit/jupyter/src/test/resources/jupyter/Plot output in jupyter.out new file mode 100644 index 000000000..059aec6c4 --- /dev/null +++ b/toolkit/jupyter/src/test/resources/jupyter/Plot output in jupyter.out @@ -0,0 +1 @@ +{"output_type":"lets_plot_spec","output":{"mapping":{},"data":{"type":["X","X","Y","Y","X","X","Y","X"],"cond":["A","B","A","A","A","A","A","B"]},"ggsize":{"width":700.0,"height":350.0},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"type","fill":"cond"},"stat":"count","color":"dark_green","alpha":0.3,"position":"stack","geom":"bar"}],"data_meta":{"series_annotations":[{"type":"str","column":"type"},{"type":"str","column":"cond"}]}},"apply_color_scheme":true,"swing_enabled":true} \ No newline at end of file diff --git a/toolkit/jupyter/src/test/resources/jupyter/SubPlotsFigure output in jupyter.out b/toolkit/jupyter/src/test/resources/jupyter/SubPlotsFigure output in jupyter.out new file mode 100644 index 000000000..509ad2425 --- /dev/null +++ b/toolkit/jupyter/src/test/resources/jupyter/SubPlotsFigure output in jupyter.out @@ -0,0 +1 @@ +{"output_type":"lets_plot_spec","output":{"kind":"subplots","figures":[{"ggtitle":{"text":"Plot 1"},"mapping":{},"data":{"x":[1.0,2.0,3.0,4.0],"y":[4.0,3.0,2.0,1.0]},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"x","y":"y"},"stat":"identity","position":"identity","geom":"point"}],"data_meta":{"series_annotations":[{"type":"int","column":"x"},{"type":"int","column":"y"}]}},{"ggtitle":{"text":"Plot 2"},"mapping":{},"data":{"x":[1.0,2.0,3.0,4.0],"y":[1.0,2.0,3.0,4.0]},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"x","y":"y"},"stat":"identity","position":"identity","geom":"line"}],"data_meta":{"series_annotations":[{"type":"int","column":"x"},{"type":"int","column":"y"}]}}],"layout":{"name":"grid","ncol":2,"nrow":1,"fit":true,"align":false}},"apply_color_scheme":true,"swing_enabled":true} \ No newline at end of file