in lets-plot-compose/src/desktopMain/kotlin/org/jetbrains/letsPlot/compose/PlotPanelComposeCanvas.kt [53:243]
fun PlotPanelComposeCanvas(
rawSpec: MutableMap<String, Any>,
preserveAspectRatio: Boolean,
modifier: Modifier,
errorTextStyle: TextStyle,
errorModifier: Modifier,
computationMessagesHandler: (List<String>) -> Unit
) {
if (logRecompositions) {
println("PlotPanelRaw: recomposition")
}
val density = LocalDensity.current.density
val skiaFontManager = remember { SkiaFontManager() }
val composeMouseEventMapper = remember { ComposeMouseEventMapper() }
// Update density on each recomposition to handle monitor DPI changes (e.g., drag between HIDPI/regular monitor)
// Cache processed plot spec to avoid reprocessing the same raw spec on every recomposition.
// Note: Use remember(rawSpec.hashCode()), to bypass the equality check and use the content hash directly.
// The issue was that remember(rawSpec) uses some kind of comparison (equals()?) which somehow not working for `MutableMap`.
val processedPlotSpec = remember(rawSpec.hashCode()) {
processRawSpecs(rawSpec, frontendOnly = false)
}
var panelSize by remember { mutableStateOf(DoubleVector.ZERO) }
var plotPosition by remember { mutableStateOf(DoubleVector.ZERO) }
var dispatchComputationMessages by remember { mutableStateOf(true) }
var specOverrideList by remember { mutableStateOf(emptyList<Map<String, Any>>()) }
var plotFigureModel by remember { mutableStateOf<PlotFigureModel?>(null) }
var errorMessage: String? by remember(processedPlotSpec, panelSize) { mutableStateOf(null) }
var redrawTrigger by remember { mutableStateOf(0) }
val skiaCanvasPeer = remember { SkiaCanvasPeer() }
// Reset the old plot on error to prevent blinking
// We can't reset PlotContainer using updateViewmodel(), so we create a new one.
val plotCanvasFigure2 = remember(errorMessage) {
PlotCanvasFigure2().apply {
eventPeer.addEventSource(composeMouseEventMapper)
}
}
val reg = remember(plotCanvasFigure2) {
plotCanvasFigure2.onHrefClick(::browseLink)
CompositeRegistration(
// trigger recomposition on repaint request
plotCanvasFigure2.onRepaintRequested { redrawTrigger++ },
plotCanvasFigure2.mapToCanvas(skiaCanvasPeer),
Registration.onRemove {
plotCanvasFigure2.onHrefClick(handler = {})
}
)
}
// Background
val finalModifier = if (errorMessage != null) {
modifier.background(Color.LightGray)
} else {
if (containsBackground(modifier)) {
// Do not change the user-defined background
modifier
} else {
// Use background color from the plot theme
val lpColor = PlotThemeHelper.plotBackground(processedPlotSpec)
val lpBackground = Color(lpColor.red, lpColor.green, lpColor.blue, lpColor.alpha)
modifier.background(lpBackground)
}
}
DisposableEffect(reg) {
onDispose {
// Try/catch to ensure that any exception in dispose() does not break the Composable lifecycle
// Otherwise, the app window gets unclosable.
try {
reg.dispose()
//plotCanvasFigure2.dispose()
} catch (e: Exception) {
LOG.error(e) { "reg.dispose() failed" }
}
}
}
Column(modifier = finalModifier) {
if (plotFigureModel != null && GG_TOOLBAR in processedPlotSpec) {
PlotToolbar(plotFigureModel!!)
}
Box(
modifier = finalModifier
.weight(1f) // Take the remaining vertical space
.fillMaxWidth() // Fill available width
.onSizeChanged { newSize ->
// Convert logical pixels (from Compose layout) to physical pixels (plot SVG pixels)
panelSize = DoubleVector(newSize.width / density, newSize.height / density)
}
) {
val errMsg = errorMessage
if (errMsg != null) {
// Show error message
BasicTextField(
value = errMsg,
onValueChange = { },
readOnly = true,
textStyle = errorTextStyle,
modifier = errorModifier
)
} else {
// Render the plot
LaunchedEffect(panelSize, processedPlotSpec, specOverrideList, preserveAspectRatio) {
if (PlotConfig.isFailure(processedPlotSpec)) {
errorMessage = PlotConfig.getErrorMessage(processedPlotSpec)
return@LaunchedEffect
}
runCatching {
if (panelSize != DoubleVector.ZERO) {
val plotSpec = applySpecOverride(processedPlotSpec, specOverrideList).toMutableMap()
plotCanvasFigure2.update(plotSpec, fitContainerSize(preserveAspectRatio)) { messages ->
if (dispatchComputationMessages) {
// do once
dispatchComputationMessages = false
computationMessagesHandler(messages)
}
}
if (plotFigureModel == null) {
plotFigureModel = PlotFigureModel(
onUpdateView = { specOverride ->
specOverrideList = FigureModelHelper.updateSpecOverrideList(
specOverrideList = specOverrideList,
newSpecOverride = specOverride
)
}
)
}
plotFigureModel!!.toolEventDispatcher = plotCanvasFigure2.toolEventDispatcher
val plotWidth = plotCanvasFigure2.size.x
val plotHeight = plotCanvasFigure2.size.y
// Calculate centering position in physical pixels
// Both panelSize and plot dimensions are in physical pixels
plotPosition = DoubleVector(
maxOf(0.0, (panelSize.x - plotWidth) / 2.0),
maxOf(0.0, (panelSize.y - plotHeight) / 2.0)
)
composeMouseEventMapper.setOffset(plotPosition.x.toFloat(), plotPosition.y.toFloat())
redrawTrigger++ // trigger repaint
}
}.getOrElse { e ->
errorMessage = "${e.message}"
return@LaunchedEffect
}
}
Canvas(
modifier = modifier
.fillMaxSize()
.pointerHoverIcon(PointerIcon(Cursor(Cursor.CROSSHAIR_CURSOR)))
.onSizeChanged { size ->
// Convert canvas logical pixels (from Compose layout) to physical pixels (plot SVG pixels)
plotCanvasFigure2.resize(size.width / density, size.height / density)
}
.pointerInput(composeMouseEventMapper, composeMouseEventMapper)
) {
// By reading redrawTrigger here, Compose knows to recompose
// this Canvas block whenever it changes.
redrawTrigger
val ctx = SkiaContext2d(drawContext.canvas.nativeCanvas, skiaFontManager)
ctx.scale(density.toDouble(), density.toDouble()) // logical → physical pixels
ctx.translate(plotPosition.x, plotPosition.y)
plotCanvasFigure2.paint(ctx)
ctx.dispose()
}
}
}
}
}