diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index d430519..3eec280 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -215,6 +215,16 @@ triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`). `_writeState()` (sets `p._selfWrite`), and the panel-json listener skips self-writes — without this every drag frame paid a second JSON.parse + full redraw. +- **Touch bridge** (`_attachTouch`, called from `_attachPanelEvents` for + every panel kind): translates touch gestures into the *existing* mouse / + wheel handlers via real `MouseEvent` / `WheelEvent` dispatch — 1-finger → + mousedown/move/up, 2-finger pinch → wheel (anchored at the gesture + midpoint via `p.mouseX/Y`), double-tap → dblclick. `move`/`up` go to + `document` (handlers listen there for off-canvas drags); `down`/`wheel`/ + `dblclick` go to the overlay canvas. Overlay canvases set + `touch-action:none` so the browser yields gestures to the plot. No + handler rewrites — a working mouse interaction is automatically a working + touch one. - **Geometry channel** (perf): plots that declare `_GEOM_KEYS` on the Python side (Plot2D, Plot3D) split heavy keys (`vertices_b64`, `image_b64`, `colormap_data`, …) into a second `panel__geom` trait, re-sent only diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index dfaca30..016359f 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -694,7 +694,7 @@ function render({ model, el }) { `position:absolute;display:block;border-radius:2px;background:${theme.bgCanvas};`; overlayCanvas = document.createElement('canvas'); overlayCanvas.style.cssText = - 'position:absolute;z-index:5;cursor:default;pointer-events:all;outline:none;'; + 'position:absolute;z-index:5;cursor:default;pointer-events:all;outline:none;touch-action:none;'; overlayCanvas.tabIndex = 0; markersCanvas = document.createElement('canvas'); markersCanvas.style.cssText = 'position:absolute;pointer-events:none;z-index:6;'; @@ -750,7 +750,7 @@ function render({ model, el }) { stack3dGpuCanvas = gpuCanvas; overlayCanvas = document.createElement('canvas'); overlayCanvas.style.cssText = - 'position:absolute;top:0;left:0;z-index:5;pointer-events:all;outline:none;'; + 'position:absolute;top:0;left:0;z-index:5;pointer-events:all;outline:none;touch-action:none;'; wrap3.appendChild(overlayCanvas); markersCanvas = document.createElement('canvas'); markersCanvas.style.cssText = @@ -774,7 +774,7 @@ function render({ model, el }) { outerContainer.appendChild(wrap); overlayCanvas = document.createElement('canvas'); overlayCanvas.style.cssText = - 'position:absolute;top:0;left:0;z-index:5;cursor:crosshair;pointer-events:all;'; + 'position:absolute;top:0;left:0;z-index:5;cursor:crosshair;pointer-events:all;touch-action:none;'; wrap.appendChild(overlayCanvas); markersCanvas = document.createElement('canvas'); markersCanvas.style.cssText = @@ -3920,12 +3920,134 @@ fn fs(in : VsOut) -> @location(0) vec4 { return null; } + // ── touch input bridge ──────────────────────────────────────────────────── + // Touch devices (iPad / iPhone) emit touch* events, not mouse* — but every + // panel handler is written against mouse events. Rather than rewrite ~20 + // handlers per kind, we translate touch gestures into the synthetic mouse / + // wheel events those handlers already understand, attached once per panel: + // + // 1 finger drag → mousedown / mousemove / mouseup (pan, orbit, drag a + // widget / ROI / marker / plane — whatever's under it) + // 2 fingers pinch → wheel (zoom), centred on the gesture midpoint + // double-tap → dblclick → the panel's double_click event (picking / + // app callbacks), exactly as a mouse double-click + // + // A synthetic event carries exactly the fields the handlers read: + // clientX/Y, button, buttons, the modifier flags (always false for touch), + // and a no-op preventDefault. document-level mousemove/up listeners in the + // handlers receive the synthetic move/up too, so drags that start on the + // canvas and continue off it work just like a mouse. + // Dispatch a real MouseEvent so it reaches every listener (including the + // document-level mousemove/mouseup the handlers use for off-canvas drags). + // Native MouseEvent carries clientX/Y, button, buttons and false modifiers — + // exactly what _clientPos / _pointerFields / _modifiers read. + function _dispatchMouse(target, type, clientX, clientY) { + target.dispatchEvent(new MouseEvent(type, { + clientX, clientY, button: 0, + buttons: type === 'mouseup' ? 0 : 1, + bubbles: true, cancelable: true, view: window, + })); + } + + // Dispatch a real WheelEvent (pinch → zoom). dir = -1 zoom in, +1 zoom out + // (matches the handlers' deltaY sign convention). + function _dispatchWheel(target, clientX, clientY, dir) { + target.dispatchEvent(new WheelEvent('wheel', { + clientX, clientY, deltaY: dir * 100, deltaX: 0, + bubbles: true, cancelable: true, view: window, + })); + } + + function _attachTouch(p) { + const oc = p.overlayCanvas; + if (!oc || oc._touchBridged) return; + oc._touchBridged = true; + + let mode = null; // null | 'drag' | 'pinch' + let pinchStartDist = 0; + let lastTapTime = 0, lastTapX = 0, lastTapY = 0; + + const dist = (t0, t1) => + Math.hypot(t0.clientX - t1.clientX, t0.clientY - t1.clientY); + const mid = (t0, t1) => ({ + x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 }); + + oc.addEventListener('touchstart', (e) => { + if (e.touches.length === 1) { + mode = 'drag'; + const t = e.touches[0]; + _dispatchMouse(oc, 'mousedown', t.clientX, t.clientY); + e.preventDefault(); + } else if (e.touches.length === 2) { + // Switching into a pinch — end any single-finger drag cleanly first. + if (mode === 'drag') { + const t = e.touches[0]; + _dispatchMouse(document, 'mouseup', t.clientX, t.clientY); + } + mode = 'pinch'; + pinchStartDist = dist(e.touches[0], e.touches[1]); + e.preventDefault(); + } + }, { passive: false }); + + oc.addEventListener('touchmove', (e) => { + if (mode === 'drag' && e.touches.length >= 1) { + const t = e.touches[0]; + _dispatchMouse(document, 'mousemove', t.clientX, t.clientY); + e.preventDefault(); + } else if (mode === 'pinch' && e.touches.length >= 2) { + const d = dist(e.touches[0], e.touches[1]); + const m = mid(e.touches[0], e.touches[1]); + // Quantise into wheel steps; spread (d>start) zooms IN (deltaY<0). + const ratio = d / (pinchStartDist || d); + if (Math.abs(ratio - 1) > 0.02) { + // Update mouse position so wheel-zoom anchors at the pinch centre. + const { mx, my } = _clientPos({ clientX: m.x, clientY: m.y }, + oc, p.pw, p.ph); + p.mouseX = mx; p.mouseY = my; + _dispatchWheel(oc, m.x, m.y, ratio > 1 ? -1 : 1); + pinchStartDist = d; // incremental — each move is one small step + } + e.preventDefault(); + } + }, { passive: false }); + + const endTouch = (e) => { + if (mode === 'drag') { + const t = (e.changedTouches && e.changedTouches[0]) || { clientX: 0, clientY: 0 }; + _dispatchMouse(document, 'mouseup', t.clientX, t.clientY); + // Double-tap detection (only for a tap, not a drag-release): a quick + // second tap near the first fires dblclick → reset view. + const now = performance.now(); + if (now - lastTapTime < 300 && + Math.hypot(t.clientX - lastTapX, t.clientY - lastTapY) < 30) { + _dispatchMouse(oc, 'dblclick', t.clientX, t.clientY); + lastTapTime = 0; + } else { + lastTapTime = now; lastTapX = t.clientX; lastTapY = t.clientY; + } + } + // If fingers remain (pinch→1 finger), restart a drag from the survivor. + if (e.touches && e.touches.length === 1) { + mode = 'drag'; + const t = e.touches[0]; + _dispatchMouse(oc, 'mousedown', t.clientX, t.clientY); + } else if (!e.touches || e.touches.length === 0) { + mode = null; + } + if (e.cancelable) e.preventDefault(); + }; + oc.addEventListener('touchend', endTouch, { passive: false }); + oc.addEventListener('touchcancel', endTouch, { passive: false }); + } + // ── panel-level event handlers ─────────────────────────────────────────── function _attachPanelEvents(p) { if (p.kind === '2d') _attachEvents2d(p); else if (p.kind === '3d') _attachEvents3d(p); else if (p.kind === 'bar') _attachEventsBar(p); else _attachEvents1d(p); + _attachTouch(p); // touch bridge — translates gestures to mouse/wheel } function _canvasToImg2d(px, py, st, pw, ph) { diff --git a/anyplotlib/tests/test_interactive/test_touch.py b/anyplotlib/tests/test_interactive/test_touch.py new file mode 100644 index 0000000..58532a3 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_touch.py @@ -0,0 +1,229 @@ +""" +Touch input tests — the touch-to-mouse bridge in figure_esm.js makes plots +usable on iPad / iPhone: + + * 1-finger drag → pan / orbit / drag a widget / ROI / marker / plane + * 2-finger pinch → zoom (mapped to wheel) + * double-tap → dblclick → the panel's double_click event (picking / + app callbacks); reset-zoom is the ``r`` key, unchanged + +These drive the SAME handlers the mouse uses, via synthesised MouseEvent / +WheelEvent, so a passing mouse interaction implies a passing touch one. The +tests use Playwright's touch emulation (``has_touch=True``) and dispatch raw +TouchEvents (Playwright has no high-level multi-touch drag helper). +""" +from __future__ import annotations + +import json +import pathlib +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.tests.conftest import _build_interact_html + + +# ── touch-enabled page fixture ──────────────────────────────────────────────── + +@pytest.fixture +def touch_page(_pw_browser): + """Open a figure in a touch-enabled context; return the live Page.""" + contexts, paths = [], [] + + def _open(widget): + html = _build_interact_html(widget) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + paths.append(tmp) + ctx = _pw_browser.new_context(has_touch=True, + viewport={"width": 600, "height": 600}) + contexts.append(ctx) + page = ctx.new_page() + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=15_000) + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + return page + + yield _open + for c in contexts: + try: + c.close() + except Exception: + pass + for p in paths: + p.unlink(missing_ok=True) + + +# ── touch-gesture helpers (raw TouchEvent dispatch) ─────────────────────────── + +_OVERLAY = "[...document.querySelectorAll('canvas')].find(x => x.style.zIndex === '5')" + + +def _overlay_box(page): + return page.evaluate( + f"() => {{ const c = {_OVERLAY}; const r = c.getBoundingClientRect();" + f" return {{ x: r.x, y: r.y, w: r.width, h: r.height }}; }}") + + +def _touch_drag(page, x0, y0, dx, dy, steps=6): + page.evaluate( + f"""([x0,y0,dx,dy,steps]) => {{ + const c = {_OVERLAY}; + const mk = (x,y) => new Touch({{identifier:1, target:c, clientX:x, clientY:y}}); + const tev = (t,x,y) => new TouchEvent(t, {{ + touches: t==='touchend' ? [] : [mk(x,y)], + changedTouches:[mk(x,y)], targetTouches: t==='touchend'?[]:[mk(x,y)], + bubbles:true, cancelable:true }}); + c.dispatchEvent(tev('touchstart', x0, y0)); + for (let i=1;i<=steps;i++) + c.dispatchEvent(tev('touchmove', x0+dx*i/steps, y0+dy*i/steps)); + c.dispatchEvent(tev('touchend', x0+dx, y0+dy)); + }}""", [x0, y0, dx, dy, steps]) + + +def _touch_tap(page, x, y): + page.evaluate( + f"""([x,y]) => {{ + const c = {_OVERLAY}; + const t = new Touch({{identifier:1, target:c, clientX:x, clientY:y}}); + c.dispatchEvent(new TouchEvent('touchstart', {{touches:[t],changedTouches:[t],bubbles:true,cancelable:true}})); + c.dispatchEvent(new TouchEvent('touchend', {{touches:[],changedTouches:[t],bubbles:true,cancelable:true}})); + }}""", [x, y]) + + +def _pinch(page, cx, cy, start_half=20, end_half=130, steps=8): + """Two-finger pinch centred at (cx,cy); spread = zoom in.""" + page.evaluate( + f"""([cx,cy,sh,eh,steps]) => {{ + const c = {_OVERLAY}; + const mk = (id,x,y) => new Touch({{identifier:id, target:c, clientX:x, clientY:y}}); + const tev = (t,ts) => new TouchEvent(t, {{touches:ts, changedTouches:ts, targetTouches:ts, bubbles:true, cancelable:true}}); + c.dispatchEvent(tev('touchstart', [mk(1,cx-sh,cy), mk(2,cx+sh,cy)])); + for (let i=1;i<=steps;i++) {{ + const h = sh + (eh-sh)*i/steps; + c.dispatchEvent(tev('touchmove', [mk(1,cx-h,cy), mk(2,cx+h,cy)])); + }} + c.dispatchEvent(tev('touchend', [])); + }}""", [cx, cy, start_half, end_half, steps]) + + +def _panel_state(page, pid): + return json.loads(page.evaluate(f"() => window._aplModel.get('panel_{pid}_json')")) + + +def _no_errors(page): + errs = [] + page.on("pageerror", lambda e: errs.append(str(e))) + return errs + + +# ── tests ───────────────────────────────────────────────────────────────────── + +class TestTouch2D: + def test_one_finger_drags_crosshair_widget(self, touch_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((64, 64), dtype=np.float32)) + cw = plot.add_widget("crosshair", cx=32, cy=32, color="#ff0000") + page = touch_page(fig) + b = _overlay_box(page) + # crosshair at image-centre → overlay-centre (no axis gutters for plain imshow) + cx, cy = b["x"] + b["w"] * 0.5, b["y"] + b["h"] * 0.5 + before = _panel_state(page, plot._id)["overlay_widgets"][0] + _touch_drag(page, cx, cy, -80, -60) + page.wait_for_timeout(150) + after = _panel_state(page, plot._id)["overlay_widgets"][0] + assert abs(after["cx"] - before["cx"]) > 3 or abs(after["cy"] - before["cy"]) > 3, \ + f"crosshair did not move on 1-finger drag: {before} -> {after}" + + def test_pinch_zooms_image(self, touch_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((64, 64), dtype=np.float32)) + page = touch_page(fig) + b = _overlay_box(page) + z0 = _panel_state(page, plot._id)["zoom"] + _pinch(page, b["x"] + b["w"] * 0.5, b["y"] + b["h"] * 0.5) + page.wait_for_timeout(150) + z1 = _panel_state(page, plot._id)["zoom"] + assert z1 > z0 + 0.1, f"pinch-out did not zoom in: {z0} -> {z1}" + + def test_no_console_errors(self, touch_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.add_widget("crosshair", cx=16, cy=16) + page = touch_page(fig) + errs = _no_errors(page) + b = _overlay_box(page) + _touch_drag(page, b["x"] + b["w"]*0.5, b["y"] + b["h"]*0.5, 40, 30) + _pinch(page, b["x"] + b["w"]*0.5, b["y"] + b["h"]*0.5) + page.wait_for_timeout(150) + assert not errs, f"touch interaction raised errors: {errs}" + + +class TestTouch3D: + def test_one_finger_orbits(self, touch_page): + fig, ax = apl.subplots(1, 1, figsize=(360, 360)) + g = np.linspace(-2, 2, 16); X, Y = np.meshgrid(g, g) + v = ax.plot_surface(X, Y, np.sin(np.sqrt(X**2 + Y**2)), azimuth=-60) + page = touch_page(fig) + b = _overlay_box(page) + az0 = _panel_state(page, v._id)["azimuth"] + _touch_drag(page, b["x"] + b["w"]*0.5, b["y"] + b["h"]*0.5, 90, 0) + page.wait_for_timeout(150) + az1 = _panel_state(page, v._id)["azimuth"] + assert abs(az1 - az0) > 5, f"3-D did not orbit on 1-finger drag: {az0} -> {az1}" + + def test_pinch_zooms(self, touch_page): + fig, ax = apl.subplots(1, 1, figsize=(360, 360)) + g = np.linspace(-2, 2, 16); X, Y = np.meshgrid(g, g) + v = ax.plot_surface(X, Y, np.sin(np.sqrt(X**2 + Y**2))) + page = touch_page(fig) + b = _overlay_box(page) + z0 = _panel_state(page, v._id)["zoom"] + _pinch(page, b["x"] + b["w"]*0.5, b["y"] + b["h"]*0.5) + page.wait_for_timeout(150) + z1 = _panel_state(page, v._id)["zoom"] + assert z1 != z0, f"3-D pinch did not change zoom: {z0} -> {z1}" + + +class TestTouch1D: + def test_one_finger_drags_vline(self, touch_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 260)) + p = ax.plot(np.sin(np.linspace(0, 6, 100))) + p.add_vline_widget(50.0) + page = touch_page(fig) + b = _overlay_box(page) + # vline x=50/99 maps into the data area [PAD_L, w-PAD_R] = [58, w-12] + PAD_L, PAD_R = 58, 12 + line_x = b["x"] + PAD_L + (50/99.0) * (b["w"] - PAD_L - PAD_R) + cy = b["y"] + b["h"] * 0.5 + before = _panel_state(page, p._id)["overlay_widgets"][0]["x"] + _touch_drag(page, line_x, cy, 80, 0) + page.wait_for_timeout(150) + after = _panel_state(page, p._id)["overlay_widgets"][0]["x"] + assert abs(after - before) > 2, f"vline did not move on touch drag: {before} -> {after}" + + +class TestTouchDoubleTap: + def test_double_tap_fires_double_click(self, touch_page): + """A quick second tap near the first synthesises a dblclick, which the + 2-D handler turns into a ``double_click`` event (for picking / app + callbacks) — exactly as a mouse double-click does.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + plot = ax.imshow(np.zeros((64, 64), dtype=np.float32)) + page = touch_page(fig) + b = _overlay_box(page) + cx, cy = b["x"] + b["w"]*0.5, b["y"] + b["h"]*0.5 + _touch_tap(page, cx, cy) + _touch_tap(page, cx, cy) # second tap within 300ms → dblclick + page.wait_for_timeout(120) + ev = json.loads(page.evaluate("() => window._aplModel.get('event_json')")) + assert ev.get("event_type") == "double_click", \ + f"double-tap did not fire double_click: {ev.get('event_type')}" + assert ev.get("panel_id") == plot._id diff --git a/upcoming_changes/+touch.new_feature.rst b/upcoming_changes/+touch.new_feature.rst new file mode 100644 index 0000000..50c3452 --- /dev/null +++ b/upcoming_changes/+touch.new_feature.rst @@ -0,0 +1,8 @@ +Plots are now usable on touch devices (iPad / iPhone) and trackpads. A touch +bridge in the renderer translates gestures into the existing interaction +handlers, so every panel type and every example becomes touch-capable with no +API change: one-finger drag pans / orbits / moves a widget, ROI, marker or +slice plane (whatever is under the finger); two-finger pinch zooms; and +double-tap fires the panel's ``double_click`` event. Overlay canvases set +``touch-action: none`` so the browser hands gestures to the plot instead of +scrolling the page.