Compare commits
3 changed files with 9 additions and 127 deletions
BIN
AGENTS.md
BIN
AGENTS.md
Binary file not shown.
|
|
@ -77,36 +77,16 @@ _PAGE = """<!DOCTYPE html>
|
||||||
var meta = gd.data[i] && gd.data[i].meta;
|
var meta = gd.data[i] && gd.data[i].meta;
|
||||||
if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
|
if (meta && meta.kind === 'hover') { hoverIndex = i; break; }
|
||||||
}
|
}
|
||||||
// A freshly pushed figure has an empty hover marker again.
|
|
||||||
hoverShown = false;
|
|
||||||
hoverPoint = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- snap-target marker ------------------------------------------------
|
function setHover(x, y, z) {
|
||||||
// Updated synchronously but only when the visual state actually changes.
|
|
||||||
// Restyling on every hover/unhover made the plot redraw per mouse move,
|
|
||||||
// which re-fired hover and ended in a runaway redraw loop (RangeError:
|
|
||||||
// Maximum call stack size exceeded, frozen canvas). Timers were avoided
|
|
||||||
// deliberately: WebEngine throttles them to ~1 s when the page is not
|
|
||||||
// compositing, which made the preview lag.
|
|
||||||
var hoverShown = false;
|
|
||||||
var hoverPoint = null;
|
|
||||||
|
|
||||||
function samePoint(a, b) {
|
|
||||||
return !!a && !!b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
function setHoverMarker(point) {
|
|
||||||
var want = snapEnabled ? point : null;
|
|
||||||
if (samePoint(want, hoverPoint) && !!want === hoverShown) return;
|
|
||||||
if (!want && !hoverShown) { hoverPoint = null; return; }
|
|
||||||
hoverPoint = want;
|
|
||||||
hoverShown = !!want;
|
|
||||||
if (hoverIndex < 0) return;
|
if (hoverIndex < 0) return;
|
||||||
var x = want ? [want[0]] : [];
|
Plotly.restyle('plot', { x: [[x]], y: [[y]], z: [[z]] }, [hoverIndex]);
|
||||||
var y = want ? [want[1]] : [];
|
}
|
||||||
var z = want ? [want[2]] : [];
|
|
||||||
Plotly.restyle('plot', { x: [x], y: [y], z: [z] }, [hoverIndex]);
|
function clearHover() {
|
||||||
|
if (hoverIndex < 0) return;
|
||||||
|
Plotly.restyle('plot', { x: [[]], y: [[]], z: [[]] }, [hoverIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- view preservation -------------------------------------------------
|
// --- view preservation -------------------------------------------------
|
||||||
|
|
@ -218,17 +198,12 @@ _PAGE = """<!DOCTYPE html>
|
||||||
var pts = ev.points || [];
|
var pts = ev.points || [];
|
||||||
if (!pts.length) return;
|
if (!pts.length) return;
|
||||||
var p = pts[0];
|
var p = pts[0];
|
||||||
if (kindOf(p) !== 'snap') { setHoverMarker(null); return; }
|
if (kindOf(p) !== 'snap') { clearHover(); return; }
|
||||||
var c = p.customdata;
|
var c = p.customdata;
|
||||||
if (c) setHoverMarker([c[0], c[1], c[2]]);
|
setHover(c[0], c[1], c[2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
gd.on('plotly_unhover', function () {
|
gd.on('plotly_unhover', function () { clearHover(); });
|
||||||
// Nothing can be visible unless snapping is armed, so stay out of the
|
|
||||||
// redraw path entirely for ordinary mouse movement.
|
|
||||||
if (!snapEnabled) return;
|
|
||||||
setHoverMarker(null);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.otkoSetCamera = function (cameraJson) {
|
window.otkoSetCamera = function (cameraJson) {
|
||||||
|
|
@ -237,7 +212,7 @@ _PAGE = """<!DOCTYPE html>
|
||||||
|
|
||||||
window.otkoSetSnapEnabled = function (on) {
|
window.otkoSetSnapEnabled = function (on) {
|
||||||
snapEnabled = !!on;
|
snapEnabled = !!on;
|
||||||
if (!snapEnabled) setHoverMarker(null);
|
if (!snapEnabled) clearHover();
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
"""Regression tests for the Plotly hover/snap-marker contract.
|
|
||||||
|
|
||||||
``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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytest.importorskip("PySide6")
|
|
||||||
pytest.importorskip("plotly")
|
|
||||||
|
|
||||||
from otko.services import load_project
|
|
||||||
|
|
||||||
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
|
||||||
|
|
||||||
#: 200 mouse-move events over a *non-snap* point with snapping armed.
|
|
||||||
_CHURN_PROBE = """(function(){
|
|
||||||
var gd = document.getElementById('plot');
|
|
||||||
var n = 0; var original = Plotly.restyle;
|
|
||||||
Plotly.restyle = function () { n++; return original.apply(Plotly, arguments); };
|
|
||||||
var point = { data: gd.data[0], customdata: [0, 0, 0] };
|
|
||||||
try {
|
|
||||||
for (var i = 0; i < 200; i++) {
|
|
||||||
gd.emit('plotly_hover', { points: [point] });
|
|
||||||
gd.emit('plotly_unhover', {});
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
} finally { Plotly.restyle = original; }
|
|
||||||
})()"""
|
|
||||||
|
|
||||||
_HOVER_MARKER_X = (
|
|
||||||
"JSON.stringify((document.getElementById('plot').data"
|
|
||||||
".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)"
|
|
||||||
)
|
|
||||||
|
|
||||||
_EMIT_SNAP_HOVER = """(function(){
|
|
||||||
var gd = document.getElementById('plot');
|
|
||||||
var snap = gd.data.filter(function(d){return d.meta && d.meta.kind === 'snap';})[0];
|
|
||||||
var point = { data: snap, customdata: [snap.x[0], snap.y[0], snap.z[0]] };
|
|
||||||
gd.emit('plotly_hover', { points: [point] });
|
|
||||||
return true;
|
|
||||||
})()"""
|
|
||||||
|
|
||||||
|
|
||||||
def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def]
|
|
||||||
from otko.views.canvas_plotly import PlotlyCanvas
|
|
||||||
|
|
||||||
canvas = PlotlyCanvas()
|
|
||||||
qtbot.addWidget(canvas)
|
|
||||||
canvas.show_project(load_project(EXAMPLES / name))
|
|
||||||
qtbot.waitUntil(lambda: canvas._ready, timeout=30000)
|
|
||||||
qtbot.wait(1500) # let the first Plotly.react settle
|
|
||||||
return canvas
|
|
||||||
|
|
||||||
|
|
||||||
def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[no-untyped-def]
|
|
||||||
box: dict[str, object] = {}
|
|
||||||
canvas._web.page().runJavaScript(script, lambda value: box.update(value=value))
|
|
||||||
qtbot.waitUntil(lambda: "value" in box, timeout=timeout)
|
|
||||||
return box["value"]
|
|
||||||
|
|
||||||
|
|
||||||
@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."""
|
|
||||||
canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
|
|
||||||
|
|
||||||
restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
|
|
||||||
|
|
||||||
assert restyles == 0, f"mouse movement caused {restyles} plot restyles"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
|
||||||
def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
||||||
canvas = _open_canvas(qtbot, "basic_truss.osmodel")
|
|
||||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]"
|
|
||||||
|
|
||||||
canvas.set_snap_preview_enabled(True)
|
|
||||||
qtbot.wait(100)
|
|
||||||
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
|
|
||||||
qtbot.wait(250)
|
|
||||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown"
|
|
||||||
|
|
||||||
_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"
|
|
||||||
Loading…
Reference in a new issue