perf: replace the Plotly snap marker with a projected DOM overlay
Orbiting and the snap preview felt far behind the cursor. Measured in the page: a single Plotly.restyle on a gl3d plot costs about 90 ms even for a one-trace figure, so every marker update stalled the scene and the marker trailed the mouse; each one also queued another redraw while the user was dragging. The marker is now a pointer-events-none div positioned by projecting the snapped world point through glplot.cameraParams (validated: the camera centre lands at the canvas centre), updated with one style write and re-projected on plotly_relayout so it stays glued to the target during orbit. No plotly calls at all on the hover path - 200 mouse-move events now cost 0 restyles - and the empty hover trace is gone from the figure. Also logs the WebGL renderer once (via the console bridge) since hardware acceleration decides how smooth orbit feels and is otherwise invisible.
This commit is contained in:
parent
7839f05384
commit
1b0620a392
5 changed files with 145 additions and 93 deletions
|
|
@ -1,10 +1,10 @@
|
|||
"""Regression tests for the Plotly hover/snap-marker contract.
|
||||
"""Regression tests for the Plotly snap-hover marker.
|
||||
|
||||
``plotly_hover``/``plotly_unhover`` fire continuously while the mouse moves.
|
||||
Touching the plot on each one made it redraw per mouse move, re-firing hover
|
||||
until the stack blew (``RangeError: Maximum call stack size exceeded``) and the
|
||||
canvas froze. The marker is therefore coalesced and only touched when its
|
||||
shown/hidden state actually changes.
|
||||
``Plotly.restyle`` on a gl3d plot costs ~90 ms even for a single trace (measured
|
||||
in-page), so the marker used to lag far behind the cursor and stall orbiting.
|
||||
It is now a pointer-events-none DOM overlay positioned by projecting the snapped
|
||||
world point through gl-plot3d's own camera matrices, which costs microseconds
|
||||
and issues no plotly calls at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -35,10 +35,12 @@ _CHURN_PROBE = """(function(){
|
|||
} finally { Plotly.restyle = original; }
|
||||
})()"""
|
||||
|
||||
_HOVER_MARKER_X = (
|
||||
"JSON.stringify((document.getElementById('plot').data"
|
||||
".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)"
|
||||
)
|
||||
_MARKER_STATE = """(function(){
|
||||
var marker = document.getElementById('otko-snap-marker');
|
||||
if (!marker) return 'missing';
|
||||
return JSON.stringify({ display: marker.style.display, left: marker.style.left,
|
||||
top: marker.style.top });
|
||||
})()"""
|
||||
|
||||
_EMIT_SNAP_HOVER = """(function(){
|
||||
var gd = document.getElementById('plot');
|
||||
|
|
@ -48,6 +50,8 @@ _EMIT_SNAP_HOVER = """(function(){
|
|||
return true;
|
||||
})()"""
|
||||
|
||||
_EMIT_UNHOVER = "document.getElementById('plot').emit('plotly_unhover',{}), true"
|
||||
|
||||
|
||||
def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
|
|
@ -69,7 +73,7 @@ def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[n
|
|||
|
||||
@pytest.mark.gui
|
||||
def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""The unfixed version restyled once per hover/unhover event."""
|
||||
"""The original handler restyled on every hover/unhover event."""
|
||||
canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
|
||||
|
||||
restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
|
||||
|
|
@ -78,16 +82,38 @@ def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore
|
|||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
def test_snap_marker_shows_clears_and_tracks_the_camera(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
canvas = _open_canvas(qtbot, "basic_truss.osmodel")
|
||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]"
|
||||
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
|
||||
|
||||
# Nothing shows while snapping is off (the marker belongs to the draw tools).
|
||||
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
|
||||
qtbot.wait(150)
|
||||
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
|
||||
|
||||
canvas.set_snap_preview_enabled(True)
|
||||
qtbot.wait(100)
|
||||
qtbot.wait(150)
|
||||
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
|
||||
qtbot.wait(250)
|
||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown"
|
||||
qtbot.wait(200)
|
||||
shown = _json_state(_run_js(canvas, qtbot, _MARKER_STATE))
|
||||
assert shown["display"] == "block", "snap target not shown"
|
||||
assert float(shown["left"].rstrip("px")) > 0
|
||||
assert float(shown["top"].rstrip("px")) > 0
|
||||
|
||||
_run_js(canvas, qtbot, "document.getElementById('plot').emit('plotly_unhover',{})")
|
||||
qtbot.wait(250)
|
||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]", "snap target not cleared"
|
||||
# Orbiting moves the target on screen without any plotly redraw.
|
||||
_run_js(canvas, qtbot, "Plotly.relayout('plot',{'scene.camera.eye':{x:-9,y:6,z:4}})")
|
||||
qtbot.wait(600)
|
||||
moved = _json_state(_run_js(canvas, qtbot, _MARKER_STATE))
|
||||
assert moved["display"] == "block"
|
||||
assert (moved["left"], moved["top"]) != (shown["left"], shown["top"])
|
||||
|
||||
_run_js(canvas, qtbot, _EMIT_UNHOVER)
|
||||
qtbot.wait(200)
|
||||
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
|
||||
|
||||
|
||||
def _json_state(raw: object) -> dict:
|
||||
import json
|
||||
|
||||
assert isinstance(raw, str) and raw != "missing", f"marker element missing: {raw!r}"
|
||||
return json.loads(raw)
|
||||
|
|
|
|||
Loading…
Reference in a new issue