feat/plotly-canvas #5

Merged
smill merged 15 commits from feat/plotly-canvas into main 2026-09-16 20:37:40 -04:00
3 changed files with 73 additions and 2 deletions
Showing only changes of commit 7cf323df23 - Show all commits

feat: surface the Plotly page console in the Console dock

A plotly failure inside the WebEngine page was invisible from Python - the
react promise just rejected and the canvas looked frozen with no evidence.
PlotlyCanvas now installs a QWebEnginePage that forwards
javaScriptConsoleMessage into the Console dock with the right severity
(error/warning/info), so the next silent failure is diagnosable.
smillmorel 2026-09-16 20:18:23 -04:00

View file

@ -18,11 +18,13 @@ from __future__ import annotations
import json
import math
from collections.abc import Callable
from dataclasses import replace
from typing import Any
from PySide6.QtCore import QUrl, Signal
from PySide6.QtCore import QObject, QUrl, Signal
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import QWebEnginePage
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QVBoxLayout, QWidget
@ -47,6 +49,34 @@ _VIEW_DIRECTIONS = {
}
class _LoggingPage(QWebEnginePage):
"""A web page that forwards the JS console to the app's console dock.
A plotly failure inside the page is otherwise invisible from Python (the
``react`` promise just rejects), which makes a frozen canvas very hard to
diagnose.
"""
def __init__(self, log: Callable[[str, str], None], parent: QObject | None = None) -> None:
super().__init__(parent)
self._log = log
def javaScriptConsoleMessage(
self,
level: QWebEnginePage.JavaScriptConsoleMessageLevel,
message: str,
line_number: int,
source_id: str,
) -> None:
names = {
QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: "error",
QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: "warning",
}
severity = names.get(level, "info")
source = source_id.rsplit("/", 1)[-1] or "plotly"
self._log(f"[web] {source}:{line_number} {message}", severity)
class _CameraShim:
"""Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere."""
@ -126,8 +156,10 @@ class PlotlyCanvas(QWidget):
parent: QWidget | None = None,
style: RenderStyle | None = None,
selection: SelectionState | None = None,
log: Callable[[str, str], None] | None = None,
) -> None:
super().__init__(parent)
self._log = log
self._style = style or RenderStyle()
self.selection = selection or SelectionState(self)
self._builder = PlotlyTraceBuilder(self._style)
@ -153,6 +185,8 @@ class PlotlyCanvas(QWidget):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._web = QWebEngineView(self)
if self._log is not None:
self._web.setPage(_LoggingPage(self._log, self._web))
self._channel = QWebChannel(self._web.page())
self._bridge = _Bridge(self)
self._channel.registerObject("otkoBridge", self._bridge)

View file

@ -119,7 +119,12 @@ class MainWindow(
# Imported lazily so a plotly-less install still boots PyVista.
from otko.views.canvas_plotly import PlotlyCanvas
canvas = PlotlyCanvas(self, style=self._style, selection=self._selection)
canvas = PlotlyCanvas(
self,
style=self._style,
selection=self._selection,
log=self._log_canvas_message,
)
else:
canvas = ModelCanvas(self, style=self._style, selection=self._selection)
self._canvases[backend] = canvas
@ -149,6 +154,16 @@ class MainWindow(
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
return self._canvas_backend
def _log_canvas_message(self, message: str, severity: str = "info") -> None:
"""Route a JS console line from the Plotly page to the Console dock.
The dock may not exist yet while canvases are being constructed, so
the lookup is guarded.
"""
dock = getattr(self, "_console_dock", None)
if dock is not None:
dock.log(message, severity=severity)
# ── plot style ───────────────────────────────────────────────────
def _load_plot_style(self) -> RenderStyle:
"""Style from the last session, ignoring anything unrecognised."""

View file

@ -99,3 +99,25 @@ def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # t
payload, preserve = _parse_call(calls[-1])
assert preserve is True, "colour changes must not re-frame the view"
assert payload["layout"]["scene"]["xaxis"]["showgrid"] is True # layout applied
@pytest.mark.gui
def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-untyped-def]
"""A plotly failure inside the page must not be invisible from Python."""
from PySide6.QtWebEngineCore import QWebEnginePage
from otko.views.canvas_plotly.plotly_canvas import _LoggingPage
messages: list[tuple[str, str]] = []
page = _LoggingPage(lambda msg, severity="info": messages.append((severity, msg)))
levels = QWebEnginePage.JavaScriptConsoleMessageLevel
page.javaScriptConsoleMessage(levels.ErrorMessageLevel, "boom", 7, "file:///tmp/plot.html")
page.javaScriptConsoleMessage(levels.WarningMessageLevel, "careful", 8, "file:///tmp/plot.html")
page.javaScriptConsoleMessage(levels.InfoMessageLevel, "hello", 9, "")
assert messages == [
("error", "[web] plot.html:7 boom"),
("warning", "[web] plot.html:8 careful"),
("info", "[web] plotly:9 hello"),
]