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 96 additions and 32 deletions
Showing only changes of commit 0611153555 - Show all commits

fix: stop the Plotly canvas wedging on rapid or overlapping updates

Three defects in the view-preservation path could leave the plot frozen
(no updates, no orbit, stale colours):

1. The merge injected raw _fullLayout objects - including undefined when a
   push landed before the previous react resolved - and plotly validates
   layouts, so one bad value made every later react fail permanently.
   currentView() now deep-copies and validates eye/center/up and each
   range, and mergeView() is wrapped so it can never block an update.
2. Updates were not serialized: overlapping Plotly.react calls on one graph
   div left it unresponsive. otkoUpdate now queues and coalesces (one react
   at a time, latest payload wins), and logs instead of failing silently.
3. A style change re-framed the camera, so re-colouring yanked a view the
   user had orbited. Colour/opacity ride the traces and background/outline
   ride the layout, so set_style is now a non-framing push.

Verified in the browser: a user orbit survives colour changes, 30 rapid
preview updates land on the final value and stay responsive, and deleting
the live camera no longer wedges the plot.
smillmorel 2026-09-16 20:14:05 -04:00

View file

@ -89,6 +89,90 @@ _PAGE = """<!DOCTYPE html>
Plotly.restyle('plot', { x: [[]], y: [[]], z: [[]] }, [hoverIndex]);
}
// --- view preservation -------------------------------------------------
// `Plotly.react` resets any scene attribute the incoming layout omits, so a
// non-framing update carries the live camera/ranges forward. Only plain,
// validated copies are merged: the raw `_fullLayout` objects are owned by
// plotly (react mutates them in place), and injecting an undefined value
// would make every later react fail, wedging the plot.
function toPlain(value) {
try { return JSON.parse(JSON.stringify(value)); } catch (err) { return null; }
}
function currentView() {
var gd = document.getElementById('plot');
var scene = gd && gd._fullLayout ? gd._fullLayout.scene : null;
if (!scene) return null;
var view = { ranges: {} };
var camera = toPlain(scene.camera);
if (camera && camera.eye && camera.center) {
view.camera = { eye: camera.eye, center: camera.center, up: camera.up };
if (camera.projection) view.camera.projection = camera.projection;
}
['xaxis', 'yaxis', 'zaxis'].forEach(function (axis) {
var source = scene[axis];
if (!source || !Array.isArray(source.range) || source.range.length !== 2) return;
var low = Number(source.range[0]);
var high = Number(source.range[1]);
if (isFinite(low) && isFinite(high) && high > low) view.ranges[axis] = [low, high];
});
return view;
}
function mergeView(fig, view) {
try {
if (!view) return;
fig.layout.scene = fig.layout.scene || {};
if (view.camera) fig.layout.scene.camera = view.camera;
Object.keys(view.ranges).forEach(function (axis) {
fig.layout.scene[axis] = fig.layout.scene[axis] || {};
fig.layout.scene[axis].range = view.ranges[axis];
fig.layout.scene[axis].autorange = false;
});
} catch (err) {
// Never let view preservation block the figure update.
if (window.console) console.warn('otko: view merge skipped', err);
}
}
// --- update queue ------------------------------------------------------
// One react at a time, latest payload wins. Overlapping reacts on the same
// graph div are what left the plot unresponsive before.
var pendingUpdate = null;
var reactBusy = false;
function runUpdate() {
if (reactBusy || !pendingUpdate) return;
var job = pendingUpdate;
pendingUpdate = null;
reactBusy = true;
var fig;
try {
fig = JSON.parse(job.payload);
} catch (err) {
reactBusy = false;
if (window.console) console.error('otko: bad payload', err);
return;
}
mergeView(fig, job.preserve ? currentView() : null);
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
findHover();
installHandlers();
}, function (err) {
if (window.console) console.error('otko: react failed', err);
}).then(function () {
reactBusy = false;
runUpdate();
});
}
window.otkoUpdate = function (payloadJson, preserveView) {
pendingUpdate = { payload: payloadJson, preserve: !!preserveView };
runUpdate();
};
function installHandlers() {
if (handlersReady) return;
var gd = document.getElementById('plot');
@ -122,32 +206,6 @@ _PAGE = """<!DOCTYPE html>
gd.on('plotly_unhover', function () { clearHover(); });
}
window.otkoUpdate = function (payloadJson, preserveView) {
var fig = JSON.parse(payloadJson);
// `Plotly.react` resets any scene attribute the incoming layout omits,
// which would snap the camera and the padded ranges back on every data
// update. When this is not an explicit re-frame, carry the user's
// current view forward into the incoming layout.
if (preserveView) {
var gd = document.getElementById('plot');
var scene = gd && gd._fullLayout ? gd._fullLayout.scene : null;
if (scene) {
fig.layout.scene = fig.layout.scene || {};
fig.layout.scene.camera = scene.camera;
['xaxis', 'yaxis', 'zaxis'].forEach(function (axis) {
if (!scene[axis]) return;
fig.layout.scene[axis] = fig.layout.scene[axis] || {};
fig.layout.scene[axis].range = scene[axis].range;
fig.layout.scene[axis].autorange = false;
});
}
}
Plotly.react('plot', fig.data, fig.layout, config).then(function () {
findHover();
installHandlers();
});
};
window.otkoSetCamera = function (cameraJson) {
Plotly.relayout('plot', { 'scene.camera': JSON.parse(cameraJson) });
};

View file

@ -277,12 +277,13 @@ class PlotlyCanvas(QWidget):
def set_style(self, style: RenderStyle) -> None:
"""Swap the visual style and repaint the figure.
Treated as a re-frame because the background and axis outline are
layout-level (``Plotly.restyle`` cannot carry them).
Deliberately *not* a re-frame: colour/opacity are trace props and the
background/outline ride the layout, so a style change (including the
live preview while dragging a colour) updates without snapping the
camera the user has moved.
"""
self._style = style
self._builder.set_style(style)
self._framing_dirty = True
self.render()
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
@ -306,7 +307,8 @@ class PlotlyCanvas(QWidget):
selection change. So a non-framing push marks itself
``preserveView`` and the JS side carries the live camera/ranges
forward; only an explicit re-frame (new project, view preset,
``reset_camera``, style change) sends the computed framing.
``reset_camera``) sends the computed framing. Style changes are
non-framing: they re-colour in place.
"""
self._scene = self._builder.build(self._project, self._options)
if not self._ready:

View file

@ -85,7 +85,8 @@ def test_view_presets_and_reset_re_frame(qtbot) -> None: # type: ignore[no-unty
@pytest.mark.gui
def test_style_change_re_frames_for_layout_only_attrs(qtbot) -> None: # type: ignore[no-untyped-def]
def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # type: ignore[no-untyped-def]
"""Re-colouring must not snap the camera back to the preset."""
from otko.views.canvas3d.style import RenderStyle
canvas, calls = _canvas_with_captured_js(qtbot)
@ -94,4 +95,7 @@ def test_style_change_re_frames_for_layout_only_attrs(qtbot) -> None: # type: i
canvas.set_style(RenderStyle(show_axis_outline=True))
assert calls[-1].endswith(", false)"), "background/outline live in the layout"
assert calls, "a style change must push"
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