js-package/src/jsMain/kotlin/MonolithicJs.kt (173 lines of code) (raw):
/*
* Copyright (c) 2019. JetBrains s.r.o.
* Use of this source code is governed by the MIT license that can be found in the LICENSE file.
*/
/* root package */
import kotlinx.browser.document
import messages.OverlayMessageHandler
import messages.SimpleMessageHandler
import org.jetbrains.letsPlot.commons.geometry.DoubleVector
import org.jetbrains.letsPlot.commons.logging.PortableLogging
import org.jetbrains.letsPlot.core.FeatureSwitch.PLOT_VIEW_TOOLBOX_HTML
import org.jetbrains.letsPlot.core.spec.FailureHandler
import org.jetbrains.letsPlot.core.spec.Option
import org.jetbrains.letsPlot.core.spec.config.PlotConfig
import org.jetbrains.letsPlot.core.util.MonolithicCommon
import org.jetbrains.letsPlot.core.util.MonolithicCommon.PlotsBuildResult.Error
import org.jetbrains.letsPlot.core.util.MonolithicCommon.PlotsBuildResult.Success
import org.jetbrains.letsPlot.core.util.sizing.SizingMode.*
import org.jetbrains.letsPlot.core.util.sizing.SizingPolicy
import org.jetbrains.letsPlot.platf.w3c.jsObject.dynamicObjectToMap
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLElement
import tools.DefaultToolbarJs
import tools.DefaultToolbarJs.Companion.EXPECTED_TOOLBAR_HEIGHT
private val LOG = PortableLogging.logger("MonolithicJs")
/**
* Main entry point for creating plots from the JavaScript environment.
*
* Takes "raw" plot specifications (not processed by plot backend)
* and constructs the plot with the specified sizing configuration.
*
* The `sizingJs` parameter is a JavaScript object with the structure:
* {
* width_mode: String // "fixed", "min", "fit", or "scaled" (case-insensitive)
* height_mode: String // "fixed", "min", "fit", or "scaled" (case-insensitive)
* width: Number // optional
* height: Number // optional
* }
*
* Sizing modes:
*
* 1. FIXED mode:
* - Uses the explicitly provided width/height values
* - Falls back to the default figure size if no values provided
* - Not responsive to container size
*
* 2. MIN mode:
* Applies the smallest dimension among:
* - The default figure size
* - The specified width/height (if provided)
* - The container size (if available)
*
* 3. FIT mode:
* Uses either:
* - The specified width/height if provided
* - Otherwise uses container size if available
* - Falls back to default figure size if neither is available
*
* 4. SCALED mode:
* - Always preserves the figure's aspect ratio
* - Typical usage: one dimension (usually width) uses FIXED/MIN/FIT mode
* and SCALED height adjusts to maintain aspect ratio
* - Special case: when both width and height are SCALED:
* * Requires container size to be available
* * Fits figure within container while preserving aspect ratio
*
*/
@OptIn(ExperimentalJsExport::class)
@Suppress("unused")
@JsName("buildPlotFromRawSpecs")
@JsExport
fun buildPlotFromRawSpecs(
plotSpecJs: dynamic,
parentElement: HTMLElement,
sizingJs: dynamic
): FigureModelJs? {
return try {
val plotSpec = dynamicObjectToMap(plotSpecJs)
PlotConfig.assertFigSpecOrErrorMessage(plotSpec)
val processedSpec = MonolithicCommon.processRawSpecs(plotSpec)
@Suppress("DuplicatedCode")
val sizingOptions: Map<String, Any> = dynamicObjectToMap(sizingJs)
buildPlotFromProcessedSpecsPrivate(
processedSpec,
parentElement,
sizingOptions
)
} catch (e: RuntimeException) {
handleException(e, SimpleMessageHandler(parentElement))
null
}
}
/**
* Main entry point for creating plots from the JavaScript environment.
*
* Takes "processed" plot specifications (processed by plot backend)
* and constructs the plot with the specified sizing configuration.
*
* The `sizingJs` parameter is a JavaScript object with the structure:
* {
* width_mode: String // "fixed", "min", "fit", or "scaled" (case-insensitive)
* height_mode: String // "fixed", "min", "fit", or "scaled" (case-insensitive)
* width: Number // optional
* height: Number // optional
* }
*
* Sizing modes:
*
* 1. FIXED mode:
* - Uses the explicitly provided width/height values
* - Falls back to the default figure size if no values provided
* - Not responsive to container size
*
* 2. MIN mode:
* Applies the smallest dimension among:
* - The default figure size
* - The specified width/height (if provided)
* - The container size (if available)
*
* 3. FIT mode:
* Uses either:
* - The specified width/height if provided
* - Otherwise uses container size if available
* - Falls back to default figure size if neither is available
*
* 4. SCALED mode:
* - Always preserves the figure's aspect ratio
* - Typical usage: one dimension (usually width) uses FIXED/MIN/FIT mode
* and SCALED height adjusts to maintain aspect ratio
* - Special case: when both width and height are SCALED:
* * Requires container size to be available
* * Fits figure within container while preserving aspect ratio
*
*/
@OptIn(ExperimentalJsExport::class)
@Suppress("unused")
@JsName("buildPlotFromProcessedSpecs")
@JsExport
fun buildPlotFromProcessedSpecs(
plotSpecJs: dynamic,
parentElement: HTMLElement,
sizingJs: dynamic
): FigureModelJs? {
return try {
val plotSpec = dynamicObjectToMap(plotSpecJs)
// Though the "plotSpec" might contain already "processed" specs,
// we apply "frontend" transforms anyway, just to be sure that
// we are going to use a truly processed specs.
val processedSpec = MonolithicCommon.processRawSpecs(plotSpec, frontendOnly = true)
val sizingOptions: Map<String, Any> = dynamicObjectToMap(sizingJs)
buildPlotFromProcessedSpecsPrivate(
processedSpec,
parentElement,
sizingOptions
)
} catch (e: RuntimeException) {
handleException(e, SimpleMessageHandler(parentElement))
null
}
}
private fun buildPlotFromProcessedSpecsPrivate(
processedSpec: Map<String, Any>,
containerDiv: HTMLElement,
sizingOptions: Map<String, Any>
): FigureModelJs? {
val showToolbar = PLOT_VIEW_TOOLBOX_HTML || processedSpec.containsKey(Option.Meta.Kind.GG_TOOLBAR)
val (plotContainer: HTMLElement, toolbar: DefaultToolbarJs?) = if (showToolbar) {
// Wrapper for toolbar and chart
val outputDiv = document.createElement("div") as HTMLDivElement
outputDiv.style.display = "inline-block"
containerDiv.appendChild(outputDiv);
// Toolbar
val toolbar = DefaultToolbarJs();
outputDiv.appendChild(toolbar.getElement());
// Plot
val plotContainer = document.createElement("div") as HTMLElement;
plotContainer.style.position = "relative"
outputDiv.appendChild(plotContainer);
Pair(plotContainer, toolbar)
} else {
// We may want to use absolute child positioning later (see OverlayMessageHandler).
containerDiv.style.position = "relative"
Pair(containerDiv, null)
}
// Plot wrapper:
// - will get `width` and `height` style attributes according to the plot dimensions
// (computed later, see: FigureToHtml.eval())
// - will serve as an 'event target' for interactive tools
// - will persist through the figure rebuilds via `FigureModel.updateView()`
val wrapperDiv = document.createElement("div") as HTMLDivElement
plotContainer.appendChild(wrapperDiv)
// Sizing policy
val sizingPolicy = SizingPolicy.create(sizingOptions)
val useContainerHeight = sizingPolicy.run {
heightMode in listOf(FIT, MIN) ||
widthMode == SCALED && heightMode == SCALED
}
if (useContainerHeight && containerDiv.clientHeight <= 0) {
containerDiv.style.height = "100%"
}
// Computation messages handling
val isHeightLimited = useContainerHeight || sizingPolicy.heightMode == FIXED
val messageHandler = createMessageHandler(
plotContainer,
isOverlay = isHeightLimited
)
val containerSize: () -> DoubleVector = {
val height = if (showToolbar) {
maxOf(0.0, (containerDiv.clientHeight - EXPECTED_TOOLBAR_HEIGHT).toDouble())
} else {
containerDiv.clientHeight.toDouble()
}
DoubleVector(
containerDiv.clientWidth.toDouble(),
height
)
}
val figureModelJs = buildPlotFromProcessedSpecsIntern(
processedSpec,
wrapperDiv,
containerSize,
sizingPolicy,
messageHandler
)
if (toolbar != null && figureModelJs != null) {
toolbar.bind(figureModelJs);
}
return figureModelJs
}
/**
* Also used in FigureModelJs.updateView()
*/
internal fun buildPlotFromProcessedSpecsIntern(
plotSpec: Map<String, Any>,
wrapperElement: HTMLElement,
containerSize: () -> DoubleVector,
sizingPolicy: SizingPolicy,
messageHandler: MessageHandler
): FigureModelJs? {
val buildResult = MonolithicCommon.buildPlotsFromProcessedSpecs(
plotSpec,
containerSize.invoke(),
sizingPolicy,
)
if (buildResult.isError) {
val errorMessage = (buildResult as Error).error
messageHandler.showError(errorMessage)
return null
}
val success = buildResult as Success
val result = FigureToHtml(success.buildInfo, wrapperElement).eval(isRoot = true)
val computationMessages = success.buildInfo.computationMessages
messageHandler.showComputationMessages(computationMessages)
return FigureModelJs(
plotSpec,
wrapperElement,
containerSize,
sizingPolicy,
messageHandler.toMute(),
result.toolEventDispatcher,
result.figureRegistration
)
}
private fun handleException(e: RuntimeException, messageHandler: MessageHandler) {
val failureInfo = FailureHandler.failureInfo(e)
messageHandler.showError(failureInfo.message)
if (failureInfo.isInternalError) {
LOG.error(e) { "Unexpected situation in 'MonolithicJs'" }
}
}
private fun createMessageHandler(plotContainer: HTMLElement, isOverlay: Boolean): MessageHandler {
return if (isOverlay) {
OverlayMessageHandler(plotContainer)
} else {
val messagesDiv = document.createElement("div") as HTMLDivElement
plotContainer.appendChild(messagesDiv)
SimpleMessageHandler(messagesDiv)
}
}