From 2994a41092de5c7efbcf84c0ba900fcfe5856697 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 3 Apr 2026 10:16:55 -0500 Subject: [PATCH 01/11] Add benchmarks for JS rendering and Python serialization performance --- anyplotlib/figure.py | 22 +- anyplotlib/figure_esm.js | 83 ++++++- tests/benchmarks/baselines.json | 65 +++++ tests/conftest.py | 151 +++++++++++- tests/test_benchmarks.py | 408 ++++++++++++++++++++++++++++++++ tests/test_benchmarks_py.py | 256 ++++++++++++++++++++ 6 files changed, 973 insertions(+), 12 deletions(-) create mode 100644 tests/benchmarks/baselines.json create mode 100644 tests/test_benchmarks.py create mode 100644 tests/test_benchmarks_py.py diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index 770be48..0d24886 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -67,11 +67,13 @@ class Figure(anywidget.AnyWidget): subplots : Recommended factory for creating Figure and Axes grid. """ - layout_json = traitlets.Unicode("{}").tag(sync=True) - fig_width = traitlets.Int(640).tag(sync=True) - fig_height = traitlets.Int(480).tag(sync=True) + layout_json = traitlets.Unicode("{}").tag(sync=True) + fig_width = traitlets.Int(640).tag(sync=True) + fig_height = traitlets.Int(480).tag(sync=True) # Bidirectional JS event bus: JS writes interaction events here, Python reads them. - event_json = traitlets.Unicode("{}").tag(sync=True) + event_json = traitlets.Unicode("{}").tag(sync=True) + # When True the JS renderer shows a per-panel FPS / frame-time overlay. + display_stats = traitlets.Bool(False).tag(sync=True) _esm = _ESM_SOURCE # Static CSS injected by anywidget alongside _esm. # .apl-scale-wrap — outer container; width:100% means it always fills @@ -108,7 +110,8 @@ class Figure(anywidget.AnyWidget): def __init__(self, nrows=1, ncols=1, figsize=(640, 480), width_ratios=None, height_ratios=None, - sharex=False, sharey=False, **kwargs): + sharex=False, sharey=False, + display_stats=False, **kwargs): super().__init__(**kwargs) self._nrows = nrows self._ncols = ncols @@ -119,8 +122,9 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480), self._axes_map: dict = {} self._plots_map: dict = {} with self.hold_trait_notifications(): - self.fig_width = figsize[0] - self.fig_height = figsize[1] + self.fig_width = figsize[0] + self.fig_height = figsize[1] + self.display_stats = display_stats self._push_layout() # ── subplot creation ────────────────────────────────────────────────────── @@ -342,7 +346,8 @@ def subplots(nrows=1, ncols=1, *, figsize=(640, 480), width_ratios=None, height_ratios=None, - gridspec_kw=None): + gridspec_kw=None, + display_stats=False): """Create a :class:`Figure` and a grid of :class:`~anyplotlib.figure_plots.Axes`. Mirrors :func:`matplotlib.pyplot.subplots`. @@ -392,6 +397,7 @@ def subplots(nrows=1, ncols=1, *, nrows=nrows, ncols=ncols, figsize=figsize, width_ratios=width_ratios, height_ratios=height_ratios, sharex=sharex, sharey=sharey, + display_stats=display_stats, ) # Build the GridSpec from the Figure's own stored ratios so there is # exactly one source of truth. diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 34d24a1..fca690a 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -82,7 +82,51 @@ function render({ model, el }) { return arr[lo]+t*(arr[lo+1]-arr[lo]); } - // ── outer DOM ──────────────────────────────────────────────────────────── + // ── per-panel frame timing ──────────────────────────────────────────────── + // Called at the entry of every draw function (draw2d / draw1d / draw3d / + // drawBar). Records a high-resolution timestamp in a 60-entry rolling + // buffer on the panel object, then: + // • updates window._aplTiming[p.id] — always, for Playwright readback + // • updates p.statsDiv text — only when display_stats is true + // + // Placing the call at the *start* of each draw function means we measure + // the inter-trigger interval: how often the CPU initiates a render, which + // is the right metric for both interactive (pan/zoom) and data-push paths. + const _FRAME_BUF = 60; + + function _recordFrame(p) { + const now = performance.now(); + p.frameTimes.push(now); + if (p.frameTimes.length > _FRAME_BUF) p.frameTimes.shift(); + + const n = p.frameTimes.length; + + // Always keep the global timing dict fresh so Playwright can read it back + // at any point via window._aplTiming[panelId]. + if (!window._aplTiming) window._aplTiming = {}; + + if (n >= 2) { + let sum = 0, minDt = Infinity, maxDt = -Infinity; + for (let i = 1; i < n; i++) { + const dt = p.frameTimes[i] - p.frameTimes[i - 1]; + sum += dt; if (dt < minDt) minDt = dt; if (dt > maxDt) maxDt = dt; + } + const mean_ms = sum / (n - 1); + const fps = 1000 * (n - 1) / (now - p.frameTimes[0]); + window._aplTiming[p.id] = { + count: n, fps, mean_ms, min_ms: minDt, max_ms: maxDt, + }; + + if (p.statsDiv && model.get('display_stats')) { + p.statsDiv.style.display = 'block'; + p.statsDiv.textContent = + `FPS ${fps.toFixed(1)}\n` + + ` dt ${mean_ms.toFixed(1)} ms\n` + + `min ${minDt.toFixed(1)} ms\n` + + `max ${maxDt.toFixed(1)} ms`; + } + } + } // Static layout styles live in the _css traitlet (.apl-scale-wrap / // .apl-outer). Only the two dynamic properties — transform and // marginBottom — are ever written here at runtime. @@ -207,6 +251,7 @@ function render({ model, el }) { let plotCanvas, overlayCanvas, markersCanvas, statusBar; let xAxisCanvas=null, yAxisCanvas=null, scaleBar=null; let _p2d = null; // extra 2D DOM refs, null for 1D panels + let _wrapNode = null; // container to which statsDiv is appended if (kind === '2d') { // ── 2D branch ────────────────────────────────────────────────────────── @@ -270,6 +315,7 @@ function render({ model, el }) { const cbCtx = cbCanvas.getContext('2d'); _p2d = { cbCanvas, cbCtx, plotWrap }; + _wrapNode = plotWrap; } else if (kind === '3d') { // ── 3D branch: one full-panel plotCanvas + overlayCanvas on top ─────── @@ -293,8 +339,7 @@ function render({ model, el }) { statusBar.style.cssText = 'position:absolute;bottom:4px;right:4px;padding:2px 6px;display:none;'; wrap3.appendChild(statusBar); - - } else { + _wrapNode = wrap3; plotCanvas = document.createElement('canvas'); plotCanvas.tabIndex = 1; plotCanvas.style.cssText = 'outline:none;cursor:crosshair;display:block;border-radius:2px;'; @@ -319,6 +364,7 @@ function render({ model, el }) { 'background:rgba(0,0,0,0.55);color:white;font-size:10px;font-family:monospace;' + 'border-radius:4px;pointer-events:none;white-space:nowrap;display:none;z-index:9;'; wrap.appendChild(statusBar); + _wrapNode = wrap; } const plotCtx = plotCanvas.getContext('2d'); @@ -329,12 +375,25 @@ function render({ model, el }) { const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; + // ── stats overlay (top-left of panel) ──────────────────────────────── + // Positioned absolutely inside the panel's wrap container so it floats + // over the plot area. Visibility is toggled by the display_stats traitlet. + const statsDiv = document.createElement('div'); + statsDiv.style.cssText = + 'position:absolute;top:4px;left:4px;padding:4px 7px;' + + 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' + + 'font-family:monospace;border-radius:4px;pointer-events:none;' + + 'white-space:pre;line-height:1.5;z-index:20;display:none;'; + if (_wrapNode) _wrapNode.appendChild(statsDiv); + const p = { id, kind, cell, pw, ph, plotCanvas, overlayCanvas, markersCanvas, plotCtx, ovCtx, mkCtx, xAxisCanvas, yAxisCanvas, xCtx, yCtx, scaleBar, statusBar, + statsDiv, // ← per-panel FPS overlay element + frameTimes: [], // ← rolling 60-entry timestamp buffer (performance.now()) blitCache, ovDrag: null, isPanning: false, panStart: {}, @@ -578,6 +637,7 @@ function render({ model, el }) { function draw2d(p) { const st=p.state; if(!st) return; + _recordFrame(p); // Re-sync axis/histogram canvas visibility whenever state changes _resizePanelDOM(p.id, p.pw, p.ph); const {pw,ph,plotCtx:ctx,blitCache} = p; @@ -1030,6 +1090,7 @@ function render({ model, el }) { function draw3d(p) { const st = p.state; if (!st) return; + _recordFrame(p); const { pw, ph, plotCtx: ctx } = p; ctx.clearRect(0, 0, pw, ph); @@ -1317,6 +1378,7 @@ function render({ model, el }) { function draw1d(p) { const st=p.state; if(!st) return; + _recordFrame(p); const {pw,ph,plotCtx:ctx} = p; const r=_plotRect1d(pw,ph); const xArr=st.x_axis||[], x0=st.view_x0||0, x1=st.view_x1||1; @@ -2696,6 +2758,7 @@ function render({ model, el }) { function drawBar(p) { const st = p.state; if (!st) return; + _recordFrame(p); const { pw, ph, plotCtx: ctx } = p; const r = _plotRect1d(pw, ph); @@ -3100,6 +3163,20 @@ function render({ model, el }) { model.on('change:layout_json', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); model.on('change:fig_width change:fig_height', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); + // Toggle the per-panel stats overlay when display_stats changes. + // Hiding is immediate; showing waits for the next natural redraw to + // populate the overlay text — but we also call redrawAll() here so the + // stats appear instantly without having to interact with the figure first. + model.on('change:display_stats', () => { + const show = model.get('display_stats'); + for (const p of panels.values()) { + if (!show && p.statsDiv) { + p.statsDiv.style.display = 'none'; + } + } + if (show) redrawAll(); + }); + // Python→JS targeted widget update (source:"python" in event_json). // Applies changed fields directly to the widget in overlay_widgets and // redraws the panel — no image re-decode, no Python echo. diff --git a/tests/benchmarks/baselines.json b/tests/benchmarks/baselines.json new file mode 100644 index 0000000..d4949b4 --- /dev/null +++ b/tests/benchmarks/baselines.json @@ -0,0 +1,65 @@ +{ + "_meta": { + "note": "Run `uv run pytest tests/test_benchmarks.py tests/test_benchmarks_py.py --update-benchmarks` to regenerate on this machine. Headless-Chrome timings are machine-specific; regenerate when switching hardware or CI runners.", + "regression_threshold_js": 1.5, + "regression_threshold_py": 1.3, + "updated_at": "2026-04-03T15:14:50.260443Z", + "host": "Carters-MacBook-Air.local" + }, + "py_normalize_64x64": { + "min_ms": 0.013, + "mean_ms": 0.03, + "max_ms": 0.094, + "n": 15, + "updated_at": "2026-04-03T15:14:49.086721Z" + }, + "py_encode_64x64": { + "min_ms": 0.006, + "mean_ms": 0.007, + "max_ms": 0.01, + "n": 15, + "updated_at": "2026-04-03T15:14:49.088606Z" + }, + "py_serialize_2d_64x64": { + "min_ms": 0.08, + "mean_ms": 0.118, + "max_ms": 0.173, + "n": 15, + "updated_at": "2026-04-03T15:14:49.295005Z" + }, + "py_serialize_1d_100pts": { + "min_ms": 0.051, + "mean_ms": 0.061, + "max_ms": 0.087, + "n": 15, + "updated_at": "2026-04-03T15:14:49.298385Z" + }, + "py_serialize_1d_1000pts": { + "min_ms": 0.479, + "mean_ms": 0.495, + "max_ms": 0.514, + "n": 15, + "updated_at": "2026-04-03T15:14:49.308353Z" + }, + "py_serialize_1d_10000pts": { + "min_ms": 5.173, + "mean_ms": 6.195, + "max_ms": 7.04, + "n": 15, + "updated_at": "2026-04-03T15:14:49.409289Z" + }, + "py_serialize_1d_100000pts": { + "min_ms": 49.916, + "mean_ms": 51.439, + "max_ms": 53.381, + "n": 15, + "updated_at": "2026-04-03T15:14:50.252123Z" + }, + "py_update_2d_64x64": { + "min_ms": 0.205, + "mean_ms": 0.231, + "max_ms": 0.299, + "n": 15, + "updated_at": "2026-04-03T15:14:50.260435Z" + } +} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index a445f35..b570b40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,13 +17,14 @@ pure-stdlib PNG decoder (no PIL / matplotlib required). """ from __future__ import annotations +import json import pathlib import tempfile import pytest # --------------------------------------------------------------------------- -# CLI option +# CLI options # --------------------------------------------------------------------------- def pytest_addoption(parser): @@ -33,6 +34,18 @@ def pytest_addoption(parser): default=False, help="Regenerate golden PNG baselines in tests/baselines/", ) + parser.addoption( + "--update-benchmarks", + action="store_true", + default=False, + help="Regenerate render-time benchmark baselines in tests/benchmarks/baselines.json", + ) + parser.addoption( + "--run-slow", + action="store_true", + default=False, + help="Include slow benchmark scenarios (4096², 8192² images) skipped in fast CI", + ) @pytest.fixture(scope="session") @@ -213,3 +226,139 @@ def _open(widget): for path in _paths: path.unlink(missing_ok=True) + +# --------------------------------------------------------------------------- +# Benchmark fixtures + helper +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def update_benchmarks(request): + """True when --update-benchmarks was passed on the command line.""" + return request.config.getoption("--update-benchmarks") + + +@pytest.fixture(scope="session") +def run_slow(request): + """True when --run-slow was passed (enables 4K²/8K² scenarios).""" + return request.config.getoption("--run-slow") + + +@pytest.fixture +def bench_page(_pw_browser): + """Fixture: open a widget in headless Chromium and return the live Page. + + Identical to ``interact_page`` but purpose-named for benchmark tests so + the two fixture pools stay independent. The opened page exposes both + ``window._aplModel`` (for model mutations) and ``window._aplTiming`` + (populated by ``_recordFrame`` inside ``figure_esm.js``) so Playwright + can drive renders and read back timing without a live Python kernel. + + Usage:: + + def test_bench_something(bench_page): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + plot = ax.imshow(np.random.rand(256, 256).astype(np.float32)) + page = bench_page(fig) + timing = _run_bench(page, plot._id) + assert timing["mean_ms"] < 50 + """ + _pages: list = [] + _paths: list = [] + + 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) + + page = _pw_browser.new_page() + _pages.append(page) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=60_000) + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + return page + + yield _open + + for page in _pages: + try: + page.close() + except Exception: + pass + for path in _paths: + path.unlink(missing_ok=True) + + +def _run_bench(page, panel_id, *, n_warmup=3, n_samples=15, + perturb_field="display_min", perturb_delta=1e-4, + timeout=120_000): + """Drive N render cycles in *page* and return the ``_aplTiming`` dict. + + Each cycle slightly perturbs *perturb_field* in the panel state so the + JS blit-cache is always invalidated and the full decode→LUT→render path + is exercised on every frame. Renders are paced with ``requestAnimationFrame`` + so successive ``createImageBitmap`` calls have time to commit before the + next one is queued — giving realistic throughput numbers rather than a + burst of back-to-back initiations. + + ``n_warmup`` frames are discarded (they prime the JIT and caches); + ``n_samples`` frames are timed. The function blocks until all frames + are complete (or *timeout* ms elapses). + + Parameters + ---------- + page : Playwright Page (from ``bench_page`` fixture) + panel_id : str — ``plot._id`` of the panel to benchmark + n_warmup : int — frames to discard before timing starts + n_samples : int — frames to time + perturb_field : str — state field to nudge each frame (invalidates cache) + perturb_delta : float — amount to nudge by per frame + timeout : int — Playwright evaluate timeout in ms + + Returns + ------- + dict with keys: count, fps, mean_ms, min_ms, max_ms + """ + js = """ + ([panelId, nWarmup, nSamples, field, delta]) => + new Promise((resolve, reject) => { + const total = nWarmup + nSamples; + let i = 0; + + function step() { + if (i >= total) { + resolve(window._aplTiming ? window._aplTiming[panelId] : null); + return; + } + + // Perturb one small field so the blit-cache key changes and the + // full draw path is exercised on every frame. + const key = 'panel_' + panelId + '_json'; + try { + const st = JSON.parse(window._aplModel.get(key)); + st[field] = (st[field] || 0) + delta; + window._aplModel.set(key, JSON.stringify(st)); + } catch(e) { reject(e); return; } + + // After warmup completes, wipe the timing buffer so only the + // measured frames are included in the final result. + if (i === nWarmup - 1) { + if (window._aplTiming) delete window._aplTiming[panelId]; + } + + i++; + requestAnimationFrame(step); + } + + requestAnimationFrame(step); + }) + """ + return page.evaluate(js, [panel_id, n_warmup, n_samples, + perturb_field, perturb_delta], + timeout=timeout) + diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py new file mode 100644 index 0000000..8e9cccd --- /dev/null +++ b/tests/test_benchmarks.py @@ -0,0 +1,408 @@ +""" +tests/test_benchmarks.py +======================== + +JS render-time benchmarks driven by headless Chromium (Playwright). + +Each test opens the widget HTML in a real browser, drives N render cycles via +``window._aplModel`` mutations paced by ``requestAnimationFrame``, and reads +back the ``window._aplTiming[panel_id]`` dict that ``_recordFrame()`` in +``figure_esm.js`` maintains. + +Workflow +-------- +Generate / refresh baselines (first run or after intentional perf change):: + + uv run pytest tests/test_benchmarks.py --update-benchmarks -v + +Normal CI run (fails on >50 % regression vs baseline):: + + uv run pytest tests/test_benchmarks.py -v + +Include slow 4K²/8K² image scenarios:: + + uv run pytest tests/test_benchmarks.py --run-slow -v + +What is timed +------------- +``_recordFrame(p)`` is called at the *entry* of every draw function +(``draw2d`` / ``draw1d`` / ``draw3d`` / ``drawBar``) before the async +``createImageBitmap`` call is queued. It timestamps when the CPU *starts* +a render, so successive timestamps measure the **inter-trigger interval** — +how quickly the main JS thread can process one data push and begin the next. + +Each bench cycle slightly nudges ``display_min`` (2D / mesh) or ``view_x0`` +(1D / bar) so the JS blit-cache is always invalidated and the full +decode → LUT → render path runs on every frame. + +Interaction benchmarks fire real Playwright mouse events (mousemove / wheel) +rather than model mutations, giving more realistic timings for pan/zoom paths +at the cost of higher variance. + +Regression threshold +-------------------- +A test fails when ``mean_ms > baseline_mean_ms * 1.5`` (50 % slower). +A warning is printed when ``mean_ms > baseline_mean_ms * 1.25`` (25 % slower). +""" +from __future__ import annotations + +import datetime +import json +import platform +import socket +import warnings +import pathlib + +import numpy as np +import pytest + +import anyplotlib as apl +from tests.conftest import _run_bench + +# ── constants ──────────────────────────────────────────────────────────────── +BASELINES_PATH = pathlib.Path(__file__).parent / "benchmarks" / "baselines.json" + +# Regression thresholds (ratio relative to stored baseline mean_ms). +FAIL_RATIO = 1.50 # >50 % slower → test failure +WARN_RATIO = 1.25 # >25 % slower → warning only + +# Grid padding added by gridDiv (mirrors figure_esm.js) +_GRID_PAD = 8 +_PAD_L, _PAD_R, _PAD_T, _PAD_B = 58, 12, 12, 42 + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _load_baselines() -> dict: + if BASELINES_PATH.exists(): + return json.loads(BASELINES_PATH.read_text()) + return {} + + +def _save_baselines(data: dict) -> None: + BASELINES_PATH.parent.mkdir(parents=True, exist_ok=True) + BASELINES_PATH.write_text(json.dumps(data, indent=2)) + + +def _check_or_update(name: str, timing: dict, update: bool) -> None: + """Assert timing is within threshold of stored baseline, or write it.""" + if timing is None: + pytest.skip(f"[{name}] No timing data returned (panel not found?)") + + baselines = _load_baselines() + + if update: + baselines[name] = { + "mean_ms": round(timing["mean_ms"], 2), + "min_ms": round(timing["min_ms"], 2), + "max_ms": round(timing["max_ms"], 2), + "fps": round(timing["fps"], 2), + "n": timing["count"], + "updated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + # Refresh meta host / timestamp whenever any baseline is updated. + meta = baselines.setdefault("_meta", {}) + meta["updated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat() + meta["host"] = socket.gethostname() + _save_baselines(baselines) + pytest.skip(f"[{name}] Baseline updated: mean={timing['mean_ms']:.2f} ms " + f"fps={timing['fps']:.1f}") + + if name not in baselines: + pytest.skip( + f"[{name}] No baseline — run with --update-benchmarks to create one" + ) + + baseline = baselines[name] + ratio = timing["mean_ms"] / baseline["mean_ms"] + + if ratio > FAIL_RATIO: + pytest.fail( + f"[{name}] REGRESSION: mean {timing['mean_ms']:.2f} ms vs " + f"baseline {baseline['mean_ms']:.2f} ms ({ratio:.2f}×)" + ) + if ratio > WARN_RATIO: + warnings.warn( + f"[{name}] Perf degraded: mean {timing['mean_ms']:.2f} ms vs " + f"baseline {baseline['mean_ms']:.2f} ms ({ratio:.2f}×)", + stacklevel=2, + ) + + +# ── 2D imshow benchmarks ────────────────────────────────────────────────────── + +# Sizes below 4096² run in the fast CI suite. +# 4096² and 8192² require --run-slow. +_IMSHOW_SIZES = [ + (64, 64, False), + (256, 256, False), + (512, 512, False), + (1024, 1024, False), + (2048, 2048, False), + (4096, 4096, True), # slow + (8192, 8192, True), # slow +] + + +@pytest.mark.parametrize( + "h,w,is_slow", + _IMSHOW_SIZES, + ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], +) +def test_bench_imshow(h, w, is_slow, bench_page, update_benchmarks, run_slow): + """Render-time benchmark: imshow with {h}×{w} image data.""" + if is_slow and not run_slow: + pytest.skip(f"Skipping {h}×{w} in fast CI — pass --run-slow to include") + + rng = np.random.default_rng(0) + # Use a panel canvas that's large enough to always letterbox the image. + canvas_px = min(max(h, 320), 640) + fig, ax = apl.subplots(1, 1, figsize=(canvas_px, canvas_px)) + plot = ax.imshow(rng.uniform(size=(h, w)).astype(np.float32)) + panel_id = plot._id + + page = bench_page(fig) + + # For large images the rAF loop needs more time. + timeout_ms = max(120_000, h * w // 500) + + timing = _run_bench( + page, panel_id, + perturb_field="display_min", + perturb_delta=1e-4, + n_warmup=3, + n_samples=15, + timeout=timeout_ms, + ) + + _check_or_update(f"js_imshow_{h}x{w}", timing, update_benchmarks) + + +# ── 1D plot benchmarks ──────────────────────────────────────────────────────── + +_PLOT1D_SIZES = [100, 1_000, 10_000, 100_000] + + +@pytest.mark.parametrize("n_pts", _PLOT1D_SIZES, ids=[str(n) for n in _PLOT1D_SIZES]) +def test_bench_plot1d(n_pts, bench_page, update_benchmarks): + """Render-time benchmark: plot1d with {n_pts} points.""" + rng = np.random.default_rng(1) + fig, ax = apl.subplots(1, 1, figsize=(640, 320)) + plot = ax.plot(np.cumsum(rng.standard_normal(n_pts))) + panel_id = plot._id + + page = bench_page(fig) + + timing = _run_bench( + page, panel_id, + perturb_field="view_x0", + perturb_delta=1e-5, + n_warmup=3, + n_samples=15, + ) + + _check_or_update(f"js_plot1d_{n_pts}pts", timing, update_benchmarks) + + +# ── pcolormesh benchmarks ───────────────────────────────────────────────────── + +_MESH_SIZES = [32, 128, 256] + + +@pytest.mark.parametrize("n", _MESH_SIZES, ids=[f"{n}x{n}" for n in _MESH_SIZES]) +def test_bench_pcolormesh(n, bench_page, update_benchmarks): + """Render-time benchmark: pcolormesh with {n}×{n} grid.""" + rng = np.random.default_rng(2) + xe = np.linspace(0.0, 1.0, n + 1) + ye = np.linspace(0.0, 1.0, n + 1) + Z = rng.uniform(size=(n, n)).astype(np.float32) + + fig, ax = apl.subplots(1, 1, figsize=(480, 480)) + plot = ax.pcolormesh(Z, x_edges=xe, y_edges=ye) + panel_id = plot._id + + page = bench_page(fig) + + timing = _run_bench( + page, panel_id, + perturb_field="display_min", + perturb_delta=1e-4, + n_warmup=3, + n_samples=15, + ) + + _check_or_update(f"js_pcolormesh_{n}x{n}", timing, update_benchmarks) + + +# ── 3D surface benchmark ────────────────────────────────────────────────────── + +def test_bench_plot3d(bench_page, update_benchmarks): + """Render-time benchmark: 3D surface (rotation interaction path).""" + x = np.linspace(-2.0, 2.0, 48) + y = np.linspace(-2.0, 2.0, 48) + X, Y = np.meshgrid(x, y) + Z = np.sin(np.sqrt(X**2 + Y**2)) + + fig, ax = apl.subplots(1, 1, figsize=(480, 480)) + plot = ax.plot_surface(X, Y, Z, colormap="viridis") + panel_id = plot._id + + page = bench_page(fig) + + # 3D state uses azimuth / elevation rather than display_min. + timing = _run_bench( + page, panel_id, + perturb_field="azimuth", + perturb_delta=0.5, + n_warmup=3, + n_samples=15, + ) + + _check_or_update("js_plot3d_48x48", timing, update_benchmarks) + + +# ── bar chart benchmark ─────────────────────────────────────────────────────── + +@pytest.mark.parametrize("n_bars", [10, 100], ids=["10bars", "100bars"]) +def test_bench_bar(n_bars, bench_page, update_benchmarks): + """Render-time benchmark: bar chart with {n_bars} bars.""" + rng = np.random.default_rng(3) + fig, ax = apl.subplots(1, 1, figsize=(640, 320)) + plot = ax.bar(rng.uniform(size=n_bars)) + panel_id = plot._id + + page = bench_page(fig) + + timing = _run_bench( + page, panel_id, + perturb_field="data_min", + perturb_delta=1e-4, + n_warmup=3, + n_samples=15, + ) + + _check_or_update(f"js_bar_{n_bars}bars", timing, update_benchmarks) + + +# ── interaction: 2D pan ─────────────────────────────────────────────────────── + +def test_bench_interaction_2d_pan(bench_page, update_benchmarks): + """Interaction benchmark: 2D pan drag (20 mousemove events on 512² image).""" + rng = np.random.default_rng(4) + fig, ax = apl.subplots(1, 1, figsize=(512 + _PAD_L + _PAD_R, + 512 + _PAD_T + _PAD_B)) + plot = ax.imshow(rng.uniform(size=(512, 512)).astype(np.float32)) + panel_id = plot._id + + page = bench_page(fig) + + # Canvas-space origin of the image area (grid padding + axis padding). + img_x0 = _GRID_PAD + _PAD_L + img_y0 = _GRID_PAD + _PAD_T + + # Drag from centre of the image area across ~1/4 of its width. + cx = img_x0 + 256 + cy = img_y0 + 256 + + # Warm up: one full drag pass discarded. + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx - 64, cy, steps=5) + page.mouse.move(cx, cy, steps=5) + page.mouse.up() + + # Reset timing buffer before the measured run. + page.evaluate( + f"() => {{ if (window._aplTiming) delete window._aplTiming['{panel_id}']; }}" + ) + + # Measured run: 20 individual mousemove steps (each triggers draw2d). + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx - 128, cy, steps=20) + page.mouse.up() + + # Allow the last few async ImageBitmap creations to settle. + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + + timing = page.evaluate(f"() => window._aplTiming && window._aplTiming['{panel_id}']") + _check_or_update("js_interaction_2d_pan", timing, update_benchmarks) + + +# ── interaction: 2D zoom ────────────────────────────────────────────────────── + +def test_bench_interaction_2d_zoom(bench_page, update_benchmarks): + """Interaction benchmark: 2D wheel zoom (20 wheel events on 512² image).""" + rng = np.random.default_rng(5) + fig, ax = apl.subplots(1, 1, figsize=(512 + _PAD_L + _PAD_R, + 512 + _PAD_T + _PAD_B)) + plot = ax.imshow(rng.uniform(size=(512, 512)).astype(np.float32)) + panel_id = plot._id + + page = bench_page(fig) + + cx = _GRID_PAD + _PAD_L + 256 + cy = _GRID_PAD + _PAD_T + 256 + + # Warm up. + for _ in range(3): + page.mouse.wheel(0, 120) + page.evaluate( + f"() => {{ if (window._aplTiming) delete window._aplTiming['{panel_id}']; }}" + ) + + # Measured: 20 zoom-in wheel ticks. + for _ in range(20): + page.mouse.move(cx, cy) + page.mouse.wheel(0, -120) + + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + + timing = page.evaluate(f"() => window._aplTiming && window._aplTiming['{panel_id}']") + _check_or_update("js_interaction_2d_zoom", timing, update_benchmarks) + + +# ── interaction: 1D pan ─────────────────────────────────────────────────────── + +def test_bench_interaction_1d_pan(bench_page, update_benchmarks): + """Interaction benchmark: 1D pan drag (20 mousemove events, 10K points).""" + rng = np.random.default_rng(6) + pw, ph = 640, 320 + fig, ax = apl.subplots(1, 1, figsize=(pw, ph)) + plot = ax.plot(np.cumsum(rng.standard_normal(10_000))) + panel_id = plot._id + + page = bench_page(fig) + + # Plot rect x-centre in page space. + cx = _GRID_PAD + _PAD_L + (pw - _PAD_L - _PAD_R) // 2 + cy = _GRID_PAD + _PAD_T + (ph - _PAD_T - _PAD_B) // 2 + + # Warm up. + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx - 60, cy, steps=5) + page.mouse.move(cx, cy, steps=5) + page.mouse.up() + page.evaluate( + f"() => {{ if (window._aplTiming) delete window._aplTiming['{panel_id}']; }}" + ) + + # Measured: 20 steps. + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx - 120, cy, steps=20) + page.mouse.up() + + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + + timing = page.evaluate(f"() => window._aplTiming && window._aplTiming['{panel_id}']") + _check_or_update("js_interaction_1d_pan", timing, update_benchmarks) + + diff --git a/tests/test_benchmarks_py.py b/tests/test_benchmarks_py.py new file mode 100644 index 0000000..8beb368 --- /dev/null +++ b/tests/test_benchmarks_py.py @@ -0,0 +1,256 @@ +""" +tests/test_benchmarks_py.py +============================ + +Pure-Python serialisation benchmarks — no browser, no Playwright required. + +Measures each stage of the Python → JS data pipeline independently so +regressions in serialisation code are caught separately from JS render +regressions. + +Pipeline stages timed +--------------------- +1. ``_normalize_image(data)`` — NumPy cast + min/max + scale + uint8 +2. ``Plot2D._encode_bytes(img_u8)`` — base64.b64encode +3. ``json.dumps(plot.to_state_dict())`` — full end-to-end (2D and 1D) +4. ``plot.update(data)`` — complete Python-side round-trip + +Workflow +-------- +Generate / refresh baselines:: + + uv run pytest tests/test_benchmarks_py.py --update-benchmarks -v + +Normal CI run:: + + uv run pytest tests/test_benchmarks_py.py -v + +Include slow 4096²/8192² scenarios:: + + uv run pytest tests/test_benchmarks_py.py --run-slow -v + +Regression threshold +-------------------- +Fails when ``min_ms > baseline_min_ms * 1.3`` (30 % slower than best +recorded). Pure-Python is deterministic enough for this tighter threshold +compared with the JS/browser suite (50 %). +""" +from __future__ import annotations + +import datetime +import json +import socket +import timeit +import warnings +import pathlib + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.figure_plots import _normalize_image, Plot2D + +BASELINES_PATH = pathlib.Path(__file__).parent / "benchmarks" / "baselines.json" + +FAIL_RATIO = 1.30 +WARN_RATIO = 1.15 + +# timeit settings: REPEATS independent runs of NUMBER executions each. +# We take min() over REPEATS to remove OS scheduling jitter. +REPEATS = 5 +NUMBER = 3 + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _load_baselines() -> dict: + if BASELINES_PATH.exists(): + return json.loads(BASELINES_PATH.read_text()) + return {} + + +def _save_baselines(data: dict) -> None: + BASELINES_PATH.parent.mkdir(parents=True, exist_ok=True) + BASELINES_PATH.write_text(json.dumps(data, indent=2)) + + +def _timeit_ms(stmt, *, number: int = NUMBER, repeats: int = REPEATS) -> dict: + """Time *stmt* (zero-arg callable) and return min/mean/max stats in ms.""" + raw = timeit.repeat(stmt=stmt, number=number, repeat=repeats, globals=None) + per_call = [t / number * 1000 for t in raw] + return { + "min_ms": round(min(per_call), 3), + "mean_ms": round(sum(per_call) / len(per_call), 3), + "max_ms": round(max(per_call), 3), + "n": repeats * number, + } + + +def _check_or_update(name: str, timing: dict, update: bool) -> None: + """Assert *timing* is within threshold of the stored baseline, or write it.""" + baselines = _load_baselines() + + if update: + baselines[name] = { + **timing, + "updated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + meta = baselines.setdefault("_meta", {}) + meta["updated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat() + meta["host"] = socket.gethostname() + _save_baselines(baselines) + pytest.skip(f"[{name}] Baseline updated: min={timing['min_ms']:.3f} ms") + + if name not in baselines: + pytest.skip( + f"[{name}] No baseline — run with --update-benchmarks to create one" + ) + + baseline = baselines[name] + ratio = timing["min_ms"] / baseline["min_ms"] + + if ratio > FAIL_RATIO: + pytest.fail( + f"[{name}] REGRESSION: min {timing['min_ms']:.3f} ms vs " + f"baseline {baseline['min_ms']:.3f} ms ({ratio:.2f}×)" + ) + if ratio > WARN_RATIO: + warnings.warn( + f"[{name}] Perf degraded: min {timing['min_ms']:.3f} ms vs " + f"baseline {baseline['min_ms']:.3f} ms ({ratio:.2f}×)", + stacklevel=2, + ) + + +# --------------------------------------------------------------------------- +# Parametrisation tables +# --------------------------------------------------------------------------- + +_IMSHOW_SIZES = [ + (64, 64, False), + (256, 256, False), + (512, 512, False), + (1024, 1024, False), + (2048, 2048, False), + (4096, 4096, True), # slow — requires --run-slow + (8192, 8192, True), # slow — requires --run-slow +] + +_PLOT1D_SIZES = [100, 1_000, 10_000, 100_000] + + +# --------------------------------------------------------------------------- +# Stage 1: _normalize_image +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "h,w,is_slow", _IMSHOW_SIZES, + ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], +) +def test_bench_py_normalize(h, w, is_slow, update_benchmarks, run_slow): + """Python: ``_normalize_image`` for a ``{h}×{w}`` float32 array.""" + if is_slow and not run_slow: + pytest.skip(f"Skipping {h}x{w} — pass --run-slow to include") + + rng = np.random.default_rng(0) + data = rng.uniform(size=(h, w)).astype(np.float32) + + timing = _timeit_ms(stmt=lambda: _normalize_image(data)) + _check_or_update(f"py_normalize_{h}x{w}", timing, update_benchmarks) + + +# --------------------------------------------------------------------------- +# Stage 2: _encode_bytes (base64) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "h,w,is_slow", _IMSHOW_SIZES, + ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], +) +def test_bench_py_encode(h, w, is_slow, update_benchmarks, run_slow): + """Python: ``_encode_bytes`` (base64) for a ``{h}×{w}`` uint8 array.""" + if is_slow and not run_slow: + pytest.skip(f"Skipping {h}x{w} — pass --run-slow to include") + + rng = np.random.default_rng(1) + img_u8, _, _ = _normalize_image(rng.uniform(size=(h, w)).astype(np.float32)) + + timing = _timeit_ms(stmt=lambda: Plot2D._encode_bytes(img_u8)) + _check_or_update(f"py_encode_{h}x{w}", timing, update_benchmarks) + + +# --------------------------------------------------------------------------- +# Stage 3: json.dumps(to_state_dict()) — 2D +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "h,w,is_slow", _IMSHOW_SIZES, + ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], +) +def test_bench_py_serialize_2d(h, w, is_slow, update_benchmarks, run_slow): + """Python: ``json.dumps(plot.to_state_dict())`` for a ``{h}×{w}`` imshow.""" + if is_slow and not run_slow: + pytest.skip(f"Skipping {h}x{w} — pass --run-slow to include") + + rng = np.random.default_rng(2) + fig, ax = apl.subplots(1, 1, figsize=(min(h, 640), min(w, 640))) + plot = ax.imshow(rng.uniform(size=(h, w)).astype(np.float32)) + + timing = _timeit_ms(stmt=lambda: json.dumps(plot.to_state_dict())) + _check_or_update(f"py_serialize_2d_{h}x{w}", timing, update_benchmarks) + + +# --------------------------------------------------------------------------- +# Stage 3 (1D): json.dumps(to_state_dict()) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "n_pts", _PLOT1D_SIZES, + ids=[str(n) for n in _PLOT1D_SIZES], +) +def test_bench_py_serialize_1d(n_pts, update_benchmarks): + """Python: ``json.dumps(plot.to_state_dict())`` for a ``{n_pts}``-point 1D plot.""" + rng = np.random.default_rng(3) + fig, ax = apl.subplots(1, 1, figsize=(640, 320)) + plot = ax.plot(np.cumsum(rng.standard_normal(n_pts))) + + timing = _timeit_ms(stmt=lambda: json.dumps(plot.to_state_dict())) + _check_or_update(f"py_serialize_1d_{n_pts}pts", timing, update_benchmarks) + + +# --------------------------------------------------------------------------- +# Full plot.update() round-trip (normalize + encode + build_lut + push) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "h,w,is_slow", _IMSHOW_SIZES, + ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], +) +def test_bench_py_update_2d(h, w, is_slow, update_benchmarks, run_slow): + """Python: full ``plot.update(data)`` round-trip for a ``{h}×{w}`` image. + + Covers the complete Python-side cost of a live data refresh: + ``_normalize_image`` + ``_encode_bytes`` + ``_build_colormap_lut`` + + state-dict assembly + ``json.dumps`` (via ``Figure._push``). + """ + if is_slow and not run_slow: + pytest.skip(f"Skipping {h}x{w} — pass --run-slow to include") + + rng = np.random.default_rng(4) + fig, ax = apl.subplots(1, 1, figsize=(min(h, 640), min(w, 640))) + plot = ax.imshow(rng.uniform(size=(h, w)).astype(np.float32)) + + # Pre-generate frames so random array creation is excluded from timing. + frames = [rng.uniform(size=(h, w)).astype(np.float32) for _ in range(NUMBER)] + idx = [0] + + def _one_update(): + plot.update(frames[idx[0] % len(frames)]) + idx[0] += 1 + + timing = _timeit_ms(stmt=_one_update) + _check_or_update(f"py_update_2d_{h}x{w}", timing, update_benchmarks) + + From 16b423f44bc85eac7a980f44cfe6cc89c1eb5d25 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 3 Apr 2026 12:00:48 -0500 Subject: [PATCH 02/11] Enhance performance benchmarks and add base64 encoding for geometry data --- anyplotlib/figure_esm.js | 115 +++++++++++++++++---- anyplotlib/figure_plots.py | 145 +++++++++++++++----------- tests/benchmarks/baselines.json | 176 ++++++++++++++++++++++++++------ 3 files changed, 329 insertions(+), 107 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index fca690a..646983b 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -82,6 +82,29 @@ function render({ model, el }) { return arr[lo]+t*(arr[lo+1]-arr[lo]); } + // ── b64 array decode helpers ───────────────────────────────────────────── + // Convert a base-64 string (little-endian raw bytes) to a JS TypedArray. + // TypedArrays support .length and [i] indexing so they are drop-in + // replacements for plain arrays in all draw / hit-test functions. + function _decodeF64(b64) { + const bin = atob(b64); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return new Float64Array(buf.buffer); + } + function _decodeF32(b64) { + const bin = atob(b64); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return new Float32Array(buf.buffer); + } + function _decodeI32(b64) { + const bin = atob(b64); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return new Int32Array(buf.buffer); + } + // ── per-panel frame timing ──────────────────────────────────────────────── // Called at the entry of every draw function (draw2d / draw1d / draw3d / // drawBar). Records a high-resolution timestamp in a 60-entry rolling @@ -1097,9 +1120,38 @@ function render({ model, el }) { ctx.fillStyle = theme.bgPlot; ctx.fillRect(0, 0, pw, ph); - const verts = st.vertices || []; - const faces = st.faces || []; - const zVals = st.z_values || []; + // ── decode + cache b64 geometry (only when state changes) ────────────── + const vKey = st.vertices_b64 || ''; + const fKey = st.faces_b64 || ''; + const zKey = st.z_values_b64 || ''; + if (p._3dVertsKey !== vKey) { + p._3dVertsKey = vKey; + if (vKey) { + const vf = _decodeF32(vKey); + const nv = vf.length / 3; + const arr = new Array(nv); + for (let i = 0; i < nv; i++) arr[i] = [vf[i*3], vf[i*3+1], vf[i*3+2]]; + p._3dVerts = arr; + } else { p._3dVerts = st.vertices || []; } + } + if (p._3dFacesKey !== fKey) { + p._3dFacesKey = fKey; + if (fKey) { + const ff = _decodeI32(fKey); + const nf = ff.length / 3; + const arr = new Array(nf); + for (let i = 0; i < nf; i++) arr[i] = [ff[i*3], ff[i*3+1], ff[i*3+2]]; + p._3dFaces = arr; + } else { p._3dFaces = st.faces || []; } + } + if (p._3dZKey !== zKey) { + p._3dZKey = zKey; + p._3dZVals = zKey ? _decodeF32(zKey) : (st.z_values || []); + } + const verts = p._3dVerts || []; + const faces = p._3dFaces || []; + const zVals = p._3dZVals || []; + const lut = st.colormap_data || []; const geom = st.geom_type || 'surface'; const bnds = st.data_bounds || {}; @@ -1381,7 +1433,22 @@ function render({ model, el }) { _recordFrame(p); const {pw,ph,plotCtx:ctx} = p; const r=_plotRect1d(pw,ph); - const xArr=st.x_axis||[], x0=st.view_x0||0, x1=st.view_x1||1; + + // ── decode + cache b64 arrays (keyed by b64 string; free on re-render) ── + const xKey = st.x_axis_b64 || ''; + const dKey = st.data_b64 || ''; + if (p._1dXKey !== xKey) { + p._1dXKey = xKey; + p._1dXArr = xKey ? _decodeF64(xKey) : (st.x_axis || []); + } + if (p._1dDKey !== dKey) { + p._1dDKey = dKey; + p._1dDArr = dKey ? _decodeF64(dKey) : (st.data || []); + } + const xArr = p._1dXArr; // Float64Array (or plain array fallback) + const yData = p._1dDArr; // Float64Array (or plain array fallback) + + const x0=st.view_x0||0, x1=st.view_x1||1; const dMin=st.data_min, dMax=st.data_max; const units=st.units||'', yUnits=st.y_units||''; @@ -1522,13 +1589,15 @@ function render({ model, el }) { ctx.restore(); } - _drawLine(st.data, xArr, + _drawLine(yData, xArr, st.line_color || '#4fc3f7', st.line_linewidth || 1.5, st.line_linestyle || 'solid', st.line_alpha != null ? st.line_alpha : 1.0, st.line_marker || 'none', st.line_markersize || 4); for (const ex of (st.extra_lines || [])) { - _drawLine(ex.data || [], ex.x_axis || xArr, + const exY = ex.data_b64 ? _decodeF64(ex.data_b64) : (ex.data || []); + const exX = ex.x_axis_b64 ? _decodeF64(ex.x_axis_b64) : (ex.x_axis ? ex.x_axis : xArr); + _drawLine(exY, exX, ex.color || (theme.dark ? '#fff' : '#333'), ex.linewidth || 1.5, ex.linestyle || 'solid', ex.alpha != null ? ex.alpha : 1.0, @@ -1613,7 +1682,8 @@ function render({ model, el }) { const st=p.state; if(!st) return; const {pw,ph,ovCtx} = p; const r=_plotRect1d(pw,ph); - const xArr=st.x_axis||[], x0=st.view_x0||0, x1=st.view_x1||1; + const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); + const x0=st.view_x0||0, x1=st.view_x1||1; const dMin=st.data_min, dMax=st.data_max; ovCtx.clearRect(0,0,pw,ph); const widgets=st.overlay_widgets||[]; @@ -1668,9 +1738,10 @@ function render({ model, el }) { const st=p.state; if(!st) return; const {pw,ph,mkCtx} = p; const r=_plotRect1d(pw,ph); - const xArr=st.x_axis||[], x0=st.view_x0||0, x1=st.view_x1||1; - const dMin=st.data_min, dMax=st.data_max; - const yData=st.data||[]; + // Use cached decoded arrays from draw1d; fall back to inline decode if needed. + const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); + const yData = p._1dDArr || (st.data_b64 ? _decodeF64(st.data_b64) : (st.data||[])); + const x0=st.view_x0||0, x1=st.view_x1||1; mkCtx.clearRect(0,0,pw,ph); const sets=st.markers||[]; if(!sets.length) return; @@ -1745,7 +1816,8 @@ function render({ model, el }) { const st = p.state; if (!st) return null; const r = _plotRect1d(p.pw, p.ph); if (mx < r.x || mx > r.x+r.w || my < r.y || my > r.y+r.h) return null; - const xArr = st.x_axis||[], x0 = st.view_x0||0, x1 = st.view_x1||1; + const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); + const x0 = st.view_x0||0, x1 = st.view_x1||1; const dMin = st.data_min, dMax = st.data_max; const HIT = 6; @@ -1776,10 +1848,13 @@ function render({ model, el }) { // Check extra lines first (drawn on top), then primary for (let i = (st.extra_lines||[]).length - 1; i >= 0; i--) { const ex = st.extra_lines[i]; - const hit = _nearestOnLine(ex.data, ex.x_axis || xArr, ex.id); + const exY = ex.data_b64 ? _decodeF64(ex.data_b64) : (ex.data || []); + const exX = ex.x_axis_b64 ? _decodeF64(ex.x_axis_b64) : (ex.x_axis ? ex.x_axis : xArr); + const hit = _nearestOnLine(exY, exX, ex.id); if (hit) return hit; } - return _nearestOnLine(st.data, xArr, null); + const primY = p._1dDArr || (st.data_b64 ? _decodeF64(st.data_b64) : (st.data||[])); + return _nearestOnLine(primY, xArr, null); } // ── marker hit-test helpers ──────────────────────────────────────────────── @@ -1858,7 +1933,9 @@ function render({ model, el }) { function _markerHitTest1d(mx, my, p) { const st=p.state; if(!st) return null; const r=_plotRect1d(p.pw,p.ph); - const xArr=st.x_axis||[], x0=st.view_x0||0, x1=st.view_x1||1; + // Use cached decoded array from draw1d; fall back to inline decode if needed. + const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); + const x0=st.view_x0||0, x1=st.view_x1||1; const dMin=st.data_min, dMax=st.data_max; const sets=st.markers||[]; for(let si=sets.length-1;si>=0;si--){ @@ -2216,7 +2293,7 @@ function render({ model, el }) { const regKeys=st.registered_keys||[]; if(regKeys.includes(e.key)||regKeys.includes('*')){ const r=_plotRect1d(p.pw,p.ph); - const xArr=st.x_axis||[]; + const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const frac=_canvasXToFrac1d(p.mouseX,st.view_x0,st.view_x1,r); const physX=xArr.length>=2?_fracToX1d(xArr,frac):frac; _emitEvent(p.id,'on_key',null,{ @@ -2241,7 +2318,7 @@ function render({ model, el }) { if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} return; } - const xArr=st.x_axis||[]; + const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const frac=_canvasXToFrac1d(mx,st.view_x0,st.view_x1,r); const phys=xArr.length>=2?_fracToX1d(xArr,frac):frac; p.statusBar.textContent=`x:${fmtVal(phys)}`;p.statusBar.style.display='block'; @@ -2447,7 +2524,8 @@ function render({ model, el }) { function _ovHitTest1d(mx,my,p){ const st=p.state;if(!st)return null; const r=_plotRect1d(p.pw,p.ph); - const xArr=st.x_axis||[],x0=st.view_x0||0,x1=st.view_x1||1; + const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); + const x0=st.view_x0||0,x1=st.view_x1||1; const widgets=st.overlay_widgets||[]; const HR=7; for(let i=widgets.length-1;i>=0;i--){ @@ -2480,7 +2558,8 @@ function render({ model, el }) { const st=p.state;if(!st)return; const r=_plotRect1d(p.pw,p.ph); const {mx,my:py}=_clientPos(e,p.overlayCanvas,p.pw,p.ph); - const xArr=st.x_axis||[],x0=st.view_x0||0,x1=st.view_x1||1; + const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); + const x0=st.view_x0||0,x1=st.view_x1||1; const xUnit=xArr.length>=2?_fracToX1d(xArr,_canvasXToFrac1d(mx,x0,x1,r)):_canvasXToFrac1d(mx,x0,x1,r); const widgets=st.overlay_widgets; const d=p.ovDrag, s=d.snapW, w=widgets[d.idx]; diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 0f25f04..389dc9f 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -54,6 +54,18 @@ } +def _arr_to_b64(arr: np.ndarray, dtype) -> str: + """Encode a NumPy array as base-64 (little-endian raw bytes). + + Uses little-endian byte order so the result is compatible with + JavaScript's ``Float64Array`` / ``Float32Array`` / ``Int32Array`` + on all modern platforms (x86, ARM). + """ + import base64 + le_dtype = np.dtype(dtype).newbyteorder("<") + return base64.b64encode(np.asarray(arr).astype(le_dtype).tobytes()).decode("ascii") + + def _norm_linestyle(ls: str) -> str: """Normalise a linestyle name or shorthand to its canonical form. @@ -1336,48 +1348,50 @@ def __init__(self, geom_type: str, raise ValueError( "Surface x/y/z must be 2-D grids of the same shape, " "or 1-D x/y centre arrays with 2-D z.") - faces = _triangulate_grid(rows, cols) - vertices = np.column_stack([xf, yf, zf]).tolist() - z_values = zf.tolist() + faces_list = _triangulate_grid(rows, cols) else: if x.ndim != 1 or y.ndim != 1 or z.ndim != 1: raise ValueError("scatter/line x, y, z must be 1-D arrays") if not (len(x) == len(y) == len(z)): raise ValueError("x, y, z must have the same length") - vertices = np.column_stack([x, y, z]).tolist() - faces = [] - z_values = z.tolist() - - # Normalised data bounds for the JS renderer - all_x = np.asarray([v[0] for v in vertices]) - all_y = np.asarray([v[1] for v in vertices]) - all_z = np.asarray([v[2] for v in vertices]) + xf, yf, zf = x, y, z + faces_list = [] + + # Normalised data bounds for the JS renderer (from raw arrays — fast) data_bounds = { - "xmin": float(all_x.min()), "xmax": float(all_x.max()), - "ymin": float(all_y.min()), "ymax": float(all_y.max()), - "zmin": float(all_z.min()), "zmax": float(all_z.max()), + "xmin": float(xf.min()), "xmax": float(xf.max()), + "ymin": float(yf.min()), "ymax": float(yf.max()), + "zmin": float(zf.min()), "zmax": float(zf.max()), } + # Encode geometry as b64 (float32 saves 50 % wire size vs float64) + verts_arr = np.column_stack([xf, yf, zf]).astype(np.float32) # (N, 3) + zvals_arr = zf.astype(np.float32) # (N,) + faces_arr = (np.asarray(faces_list, dtype=np.int32).reshape(-1, 3) + if faces_list else np.empty((0, 3), dtype=np.int32)) + cmap_lut = _build_colormap_lut(colormap) self._state: dict = { - "kind": "3d", - "geom_type": geom_type, - "vertices": vertices, - "faces": faces, - "z_values": z_values, + "kind": "3d", + "geom_type": geom_type, + "vertices_b64": _arr_to_b64(verts_arr, np.float32), + "vertices_count": len(verts_arr), + "faces_b64": _arr_to_b64(faces_arr, np.int32), + "faces_count": len(faces_arr), + "z_values_b64": _arr_to_b64(zvals_arr, np.float32), "colormap_name": colormap, "colormap_data": cmap_lut, - "color": color, - "point_size": float(point_size), - "linewidth": float(linewidth), - "x_label": x_label, - "y_label": y_label, - "z_label": z_label, - "azimuth": float(azimuth), - "elevation": float(elevation), - "zoom": float(zoom), - "data_bounds": data_bounds, + "color": color, + "point_size": float(point_size), + "linewidth": float(linewidth), + "x_label": x_label, + "y_label": y_label, + "z_label": z_label, + "azimuth": float(azimuth), + "elevation": float(elevation), + "zoom": float(zoom), + "data_bounds": data_bounds, "registered_keys": [], } self.callbacks = CallbackRegistry() @@ -1497,29 +1511,30 @@ def update(self, x, y, z) -> None: xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel() else: raise ValueError("Surface x/y/z must be 2-D grids or 1-D+2-D.") - faces = _triangulate_grid(rows, cols) - vertices = np.column_stack([xf, yf, zf]).tolist() - z_values = zf.tolist() + faces_list = _triangulate_grid(rows, cols) else: - vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()]).tolist() - faces = [] - z_values = z.ravel().tolist() + xf, yf, zf = x.ravel(), y.ravel(), z.ravel() + faces_list = [] - all_x = np.asarray([v[0] for v in vertices]) - all_y = np.asarray([v[1] for v in vertices]) - all_z = np.asarray([v[2] for v in vertices]) data_bounds = { - "xmin": float(all_x.min()), "xmax": float(all_x.max()), - "ymin": float(all_y.min()), "ymax": float(all_y.max()), - "zmin": float(all_z.min()), "zmax": float(all_z.max()), + "xmin": float(xf.min()), "xmax": float(xf.max()), + "ymin": float(yf.min()), "ymax": float(yf.max()), + "zmin": float(zf.min()), "zmax": float(zf.max()), } + verts_arr = np.column_stack([xf, yf, zf]).astype(np.float32) + zvals_arr = zf.astype(np.float32) + faces_arr = (np.asarray(faces_list, dtype=np.int32).reshape(-1, 3) + if faces_list else np.empty((0, 3), dtype=np.int32)) + self._state.update({ - "vertices": vertices, - "faces": faces, - "z_values": z_values, - "data_bounds": data_bounds, - "colormap_data": _build_colormap_lut(self._state["colormap_name"]), + "vertices_b64": _arr_to_b64(verts_arr, np.float32), + "vertices_count": len(verts_arr), + "faces_b64": _arr_to_b64(faces_arr, np.int32), + "faces_count": len(faces_arr), + "z_values_b64": _arr_to_b64(zvals_arr, np.float32), + "data_bounds": data_bounds, + "colormap_data": _build_colormap_lut(self._state["colormap_name"]), }) self._push() @@ -1727,8 +1742,8 @@ def __init__(self, data: np.ndarray, self._state: dict = { "kind": "1d", - "data": data.tolist(), - "x_axis": x_axis.tolist(), + "data": data, # numpy float64 — encoded in to_state_dict() + "x_axis": x_axis, # numpy float64 — encoded in to_state_dict() "units": units, "y_units": y_units, "data_min": dmin, @@ -1766,8 +1781,23 @@ def _push_markers(self) -> None: def to_state_dict(self) -> dict: d = dict(self._state) + # Replace numpy arrays with b64-encoded strings for the wire format. + data_arr = d.pop("data") + x_arr = d.pop("x_axis") + d["data_b64"] = _arr_to_b64(data_arr, np.float64) + d["x_axis_b64"] = _arr_to_b64(x_arr, np.float64) + d["data_length"] = len(data_arr) + # Encode extra-line arrays too + new_extra = [] + for ex in d["extra_lines"]: + ex2 = dict(ex) + ex2["data_b64"] = _arr_to_b64(ex2.pop("data"), np.float64) + ex2["x_axis_b64"] = _arr_to_b64( + np.asarray(ex2.pop("x_axis"), dtype=np.float64), np.float64) + new_extra.append(ex2) + d["extra_lines"] = new_extra d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - d["markers"] = self.markers.to_wire_list() + d["markers"] = self.markers.to_wire_list() return d @property @@ -1812,7 +1842,7 @@ def update(self, data: np.ndarray, x_axis=None, raise ValueError(f"data must be 1-D, got {data.shape}") n = len(data) if x_axis is None: - prev = np.asarray(self._state["x_axis"]) + prev = self._state["x_axis"] # already a numpy array x_axis = prev if len(prev) == n else np.arange(n, dtype=float) x_axis = np.asarray(x_axis, dtype=float) @@ -1820,8 +1850,8 @@ def update(self, data: np.ndarray, x_axis=None, dmax = float(np.nanmax(data)) pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 - self._state["data"] = data.tolist() - self._state["x_axis"] = x_axis.tolist() + self._state["data"] = data + self._state["x_axis"] = x_axis self._state["data_min"] = dmin - pad self._state["data_max"] = dmax + pad if units is not None: self._state["units"] = units @@ -1834,10 +1864,11 @@ def _recompute_data_range(self) -> None: Called automatically whenever the set of lines changes so that every curve stays fully visible. """ - all_vals = [np.asarray(self._state["data"], dtype=float)] + all_vals = [self._state["data"]] # already a numpy float64 array for ex in self._state["extra_lines"]: - if ex.get("data"): - all_vals.append(np.asarray(ex["data"], dtype=float)) + d = ex.get("data") + if d is not None and len(d): + all_vals.append(d) combined = np.concatenate(all_vals) dmin = float(np.nanmin(combined)) dmax = float(np.nanmax(combined)) @@ -1898,12 +1929,12 @@ def clicked(event): ... data = np.asarray(data, dtype=float) if data.ndim != 1: raise ValueError("data must be 1-D") - xa = (np.asarray(x_axis, float).tolist() if x_axis is not None + xa = (np.asarray(x_axis, dtype=float) if x_axis is not None else self._state["x_axis"]) lid = str(_uuid.uuid4())[:8] self._state["extra_lines"].append({ "id": lid, - "data": data.tolist(), + "data": data, "x_axis": xa, "color": color, "linewidth": float(linewidth), diff --git a/tests/benchmarks/baselines.json b/tests/benchmarks/baselines.json index d4949b4..1da1476 100644 --- a/tests/benchmarks/baselines.json +++ b/tests/benchmarks/baselines.json @@ -3,63 +3,175 @@ "note": "Run `uv run pytest tests/test_benchmarks.py tests/test_benchmarks_py.py --update-benchmarks` to regenerate on this machine. Headless-Chrome timings are machine-specific; regenerate when switching hardware or CI runners.", "regression_threshold_js": 1.5, "regression_threshold_py": 1.3, - "updated_at": "2026-04-03T15:14:50.260443Z", + "updated_at": "2026-04-03T16:38:30.589805+00:00", "host": "Carters-MacBook-Air.local" }, "py_normalize_64x64": { "min_ms": 0.013, - "mean_ms": 0.03, - "max_ms": 0.094, + "mean_ms": 0.018, + "max_ms": 0.037, "n": 15, - "updated_at": "2026-04-03T15:14:49.086721Z" + "updated_at": "2026-04-03T16:38:27.435525+00:00" }, "py_encode_64x64": { - "min_ms": 0.006, - "mean_ms": 0.007, - "max_ms": 0.01, + "min_ms": 0.007, + "mean_ms": 0.009, + "max_ms": 0.019, "n": 15, - "updated_at": "2026-04-03T15:14:49.088606Z" + "updated_at": "2026-04-03T16:38:28.188689+00:00" }, "py_serialize_2d_64x64": { - "min_ms": 0.08, - "mean_ms": 0.118, - "max_ms": 0.173, + "min_ms": 0.081, + "mean_ms": 0.085, + "max_ms": 0.099, "n": 15, - "updated_at": "2026-04-03T15:14:49.295005Z" + "updated_at": "2026-04-03T16:38:29.062902+00:00" }, "py_serialize_1d_100pts": { - "min_ms": 0.051, - "mean_ms": 0.061, - "max_ms": 0.087, + "min_ms": 0.013, + "mean_ms": 0.015, + "max_ms": 0.018, "n": 15, - "updated_at": "2026-04-03T15:14:49.298385Z" + "updated_at": "2026-04-03T16:38:29.461326+00:00" }, "py_serialize_1d_1000pts": { - "min_ms": 0.479, - "mean_ms": 0.495, - "max_ms": 0.514, + "min_ms": 0.076, + "mean_ms": 0.117, + "max_ms": 0.174, "n": 15, - "updated_at": "2026-04-03T15:14:49.308353Z" + "updated_at": "2026-04-03T16:38:29.466205+00:00" }, "py_serialize_1d_10000pts": { - "min_ms": 5.173, - "mean_ms": 6.195, - "max_ms": 7.04, + "min_ms": 0.638, + "mean_ms": 0.68, + "max_ms": 0.777, "n": 15, - "updated_at": "2026-04-03T15:14:49.409289Z" + "updated_at": "2026-04-03T16:38:29.480053+00:00" }, "py_serialize_1d_100000pts": { - "min_ms": 49.916, - "mean_ms": 51.439, - "max_ms": 53.381, + "min_ms": 7.214, + "mean_ms": 7.736, + "max_ms": 9.204, "n": 15, - "updated_at": "2026-04-03T15:14:50.252123Z" + "updated_at": "2026-04-03T16:38:29.606929+00:00" }, "py_update_2d_64x64": { - "min_ms": 0.205, - "mean_ms": 0.231, - "max_ms": 0.299, + "min_ms": 0.219, + "mean_ms": 0.272, + "max_ms": 0.316, + "n": 15, + "updated_at": "2026-04-03T16:38:29.614348+00:00" + }, + "py_normalize_256x256": { + "min_ms": 0.091, + "mean_ms": 0.107, + "max_ms": 0.145, + "n": 15, + "updated_at": "2026-04-03T16:38:27.439965+00:00" + }, + "py_normalize_512x512": { + "min_ms": 0.577, + "mean_ms": 0.918, + "max_ms": 1.466, + "n": 15, + "updated_at": "2026-04-03T16:38:27.456398+00:00" + }, + "py_normalize_1024x1024": { + "min_ms": 3.85, + "mean_ms": 4.543, + "max_ms": 5.06, + "n": 15, + "updated_at": "2026-04-03T16:38:27.533986+00:00" + }, + "py_normalize_2048x2048": { + "min_ms": 29.673, + "mean_ms": 40.138, + "max_ms": 66.704, + "n": 15, + "updated_at": "2026-04-03T16:38:28.184207+00:00" + }, + "py_encode_256x256": { + "min_ms": 0.098, + "mean_ms": 0.101, + "max_ms": 0.103, + "n": 15, + "updated_at": "2026-04-03T16:38:28.192720+00:00" + }, + "py_encode_512x512": { + "min_ms": 0.451, + "mean_ms": 0.636, + "max_ms": 0.813, + "n": 15, + "updated_at": "2026-04-03T16:38:28.205736+00:00" + }, + "py_encode_1024x1024": { + "min_ms": 2.208, + "mean_ms": 2.73, + "max_ms": 3.427, + "n": 15, + "updated_at": "2026-04-03T16:38:28.259917+00:00" + }, + "py_encode_2048x2048": { + "min_ms": 11.281, + "mean_ms": 27.22, + "max_ms": 48.466, + "n": 15, + "updated_at": "2026-04-03T16:38:28.794089+00:00" + }, + "py_serialize_2d_256x256": { + "min_ms": 0.266, + "mean_ms": 0.374, + "max_ms": 0.607, + "n": 15, + "updated_at": "2026-04-03T16:38:29.072522+00:00" + }, + "py_serialize_2d_512x512": { + "min_ms": 0.875, + "mean_ms": 0.959, + "max_ms": 1.083, + "n": 15, + "updated_at": "2026-04-03T16:38:29.095913+00:00" + }, + "py_serialize_2d_1024x1024": { + "min_ms": 3.16, + "mean_ms": 3.425, + "max_ms": 3.863, + "n": 15, + "updated_at": "2026-04-03T16:38:29.171313+00:00" + }, + "py_serialize_2d_2048x2048": { + "min_ms": 12.925, + "mean_ms": 13.36, + "max_ms": 14.065, + "n": 15, + "updated_at": "2026-04-03T16:38:29.457613+00:00" + }, + "py_update_2d_256x256": { + "min_ms": 0.646, + "mean_ms": 0.772, + "max_ms": 0.919, + "n": 15, + "updated_at": "2026-04-03T16:38:29.631014+00:00" + }, + "py_update_2d_512x512": { + "min_ms": 2.358, + "mean_ms": 2.724, + "max_ms": 3.218, + "n": 15, + "updated_at": "2026-04-03T16:38:29.682280+00:00" + }, + "py_update_2d_1024x1024": { + "min_ms": 9.234, + "mean_ms": 10.334, + "max_ms": 11.787, + "n": 15, + "updated_at": "2026-04-03T16:38:29.874306+00:00" + }, + "py_update_2d_2048x2048": { + "min_ms": 36.108, + "mean_ms": 38.114, + "max_ms": 43.568, "n": 15, - "updated_at": "2026-04-03T15:14:50.260435Z" + "updated_at": "2026-04-03T16:38:30.589797+00:00" } } \ No newline at end of file From daec3fdc97d06e6563de0836e8edde5486236ffb Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 4 Apr 2026 09:25:07 -0500 Subject: [PATCH 03/11] Enhance bar chart functionality: support grouped bars, log scale, and update API for matplotlib alignment --- Examples/plot_bar.py | 156 +++++++++++- anyplotlib/FIGURE_ESM.md | 62 +++-- anyplotlib/_repr_utils.py | 7 +- anyplotlib/figure_esm.js | 460 +++++++++++++++++++++++----------- anyplotlib/figure_plots.py | 331 ++++++++++++++++++------ tests/baselines/bar_basic.png | Bin 0 -> 8975 bytes tests/test_bar.py | 266 ++++++++++++++++---- tests/test_visual.py | 11 + 8 files changed, 998 insertions(+), 295 deletions(-) create mode 100644 tests/baselines/bar_basic.png diff --git a/Examples/plot_bar.py b/Examples/plot_bar.py index 6433f00..96588aa 100644 --- a/Examples/plot_bar.py +++ b/Examples/plot_bar.py @@ -2,23 +2,159 @@ Bar Chart ========= -Demonstrate :meth:`~anyplotlib.figure_plots.Axes.bar` with vertical and -horizontal orientations, per-bar colours, category labels, and live data -updates via :meth:`~anyplotlib.figure_plots.PlotBar.update`. +Demonstrate :meth:`~anyplotlib.figure_plots.Axes.bar` with: -Three separate figures are shown: - -1. **Vertical bar chart** – monthly sales data with a uniform colour. -2. **Horizontal bar chart** – ranked items with per-bar colours and value - labels. -3. **Side-by-side comparison** – two panels sharing the same figure; one - panel updates its data to show a different quarter. +* **Matplotlib-aligned API** — ``ax.bar(x, height, width, bottom, …)`` +* Vertical and horizontal orientations, per-bar colours, category labels +* **Grouped bars** — pass a 2-D *height* array ``(N, G)`` +* **Log-scale value axis** — ``log_scale=True`` +* Live data updates via :meth:`~anyplotlib.figure_plots.PlotBar.update` """ import numpy as np import anyplotlib as vw rng = np.random.default_rng(7) +# ── 1. Vertical bar chart — monthly sales ──────────────────────────────────── +# The first positional argument is now *x* (positions or labels), matching +# ``matplotlib.pyplot.bar(x, height, width=0.8, bottom=0.0, ...)``. +months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] +sales = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], + dtype=float) + +fig1, ax1 = vw.subplots(1, 1, figsize=(640, 340)) +bar1 = ax1.bar( + months, # x — category strings become x_labels automatically + sales, # height + width=0.6, + color="#4fc3f7", + show_values=True, + units="Month", + y_units="Units sold", +) +fig1 + +# %% +# Horizontal bar chart — ranked items +# ------------------------------------- +# Set ``orient="h"`` for a horizontal layout. Pass a list of CSS colours +# to ``colors`` to give each bar its own colour. + +categories = ["NumPy", "SciPy", "Matplotlib", "Pandas", "Scikit-learn", + "PyTorch", "TensorFlow", "JAX", "Polars", "Dask"] +scores = np.array([95, 88, 91, 87, 83, 79, 76, 72, 68, 65], dtype=float) + +palette = [ + "#ef5350", "#ec407a", "#ab47bc", "#7e57c2", "#42a5f5", + "#26c6da", "#26a69a", "#66bb6a", "#d4e157", "#ffa726", +] + +fig2, ax2 = vw.subplots(1, 1, figsize=(540, 400)) +bar2 = ax2.bar( + categories, + scores, + orient="h", + colors=palette, + width=0.65, + show_values=True, + y_units="Popularity score", +) +fig2 + +# %% +# Grouped bar chart — quarterly comparison +# ----------------------------------------- +# Pass a 2-D *height* array of shape ``(N, G)`` to draw *G* bars side by +# side for each category. Provide ``group_labels`` to show a legend and +# ``group_colors`` to customise each group's colour. + +quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] +q_data = np.array([ + [42, 58, 51], # Jan — Q1, Q2, Q3 + [55, 61, 59], # Feb + [48, 70, 65], # Mar + [63, 75, 71], # Apr + [71, 69, 80], # May + [68, 83, 77], # Jun +], dtype=float) # shape (6, 3) → 6 categories, 3 groups + +fig3, ax3 = vw.subplots(1, 1, figsize=(680, 340)) +bar3 = ax3.bar( + quarters, + q_data, + width=0.8, + group_labels=["Q1", "Q2", "Q3"], + group_colors=["#4fc3f7", "#ff7043", "#66bb6a"], + show_values=False, + y_units="Sales", +) +fig3 + +# %% +# Log-scale value axis +# --------------------- +# Set ``log_scale=True`` for a logarithmic value axis. Non-positive values +# are clamped to ``1e-10`` — no error is raised. Tick marks are placed at +# each decade (10⁰, 10¹, 10², …) with faint minor gridlines at 2×, 3×, 5× +# multiples. + +log_labels = ["A", "B", "C", "D", "E"] +log_vals = np.array([1, 10, 100, 1_000, 10_000], dtype=float) + +fig4, ax4 = vw.subplots(1, 1, figsize=(500, 340)) +bar4 = ax4.bar( + log_labels, + log_vals, + log_scale=True, + color="#ab47bc", + show_values=True, + y_units="Count (log scale)", +) +fig4 + +# %% +# Side-by-side comparison — update data live +# ------------------------------------------- +# Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one figure. +# Call :meth:`~anyplotlib.figure_plots.PlotBar.update` to swap in Q2 data — +# the value-axis range recalculates automatically. + +q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float) +q2 = np.array([58, 61, 70, 75, 69, 83, 90, 88, 77, 64, 71, 95], dtype=float) +all_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + +fig5, (ax_left, ax_right) = vw.subplots(1, 2, figsize=(820, 320)) +bar_left = ax_left.bar( + all_months, q1, width=0.6, + color="#4fc3f7", show_values=False, y_units="Q1 sales", +) +bar_right = ax_right.bar( + all_months, q1, width=0.6, + color="#ff7043", show_values=False, y_units="Q2 sales", +) +bar_right.update(q2) # swap in Q2 — axis range recalculates automatically + +fig5 + +# %% +# Mutate colours, annotations, and scale at runtime +# -------------------------------------------------- +# :meth:`~anyplotlib.figure_plots.PlotBar.set_color` repaints all bars, +# :meth:`~anyplotlib.figure_plots.PlotBar.set_show_values` toggles labels, +# :meth:`~anyplotlib.figure_plots.PlotBar.set_log_scale` switches the +# value-axis between linear and logarithmic. + +bar1.set_color("#ff7043") +bar1.set_show_values(False) +fig1 + +import numpy as np +import anyplotlib as vw + +rng = np.random.default_rng(7) + # ── 1. Vertical bar chart — monthly sales ──────────────────────────────────── months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index fc16f99..4c33169 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -294,30 +294,64 @@ Writes to `model.event_json` + `save_changes()`. --- -### Bar chart (lines 2343–2697) +### Bar chart (lines 2803–2970) State fields: ``` -st.values, st.x_centers, st.x_labels -st.bar_color, st.bar_colors, st.bar_width +st.values [[g0,g1,...], ...] always 2-D (N×G) list; G=1 for ungrouped +st.groups int — number of bar groups per category slot (≥1) +st.x_centers, st.x_labels +st.bar_color, st.bar_colors (ungrouped: per-bar colours) +st.group_colors list[str], length G — colour per group; overrides bar_color +st.group_labels list[str], length G — legend labels (shown when groups > 1) +st.bar_width fraction of slot occupied by all bars in the slot (0–1) st.orient 'v' (default) | 'h' -st.baseline value axis zero line -st.data_min/max current visible value-axis range — modified by zoom/pan +st.baseline value-axis root; skipped for log scale +st.log_scale bool — logarithmic value axis; non-positive values clamped to 1e-10 +st.data_min/max current visible value-axis range st.x_axis, st.view_x0/x1 widget coordinate system (category axis) st.overlay_widgets ``` | Function | Lines | Purpose | |----------|-------|---------| -| `_barGeom(st,r)` | 2347 | Per-bar geometry: slot/bar px, `xToPx`/`yToPx`, baseline px | -| `drawBar(p)` | 2376 | **Main bar render**: grid, bars (clipped), value labels, axis, ticks; calls `drawOverlay1d` | -| `_attachEventsBar(p)` | 2581 | **Full interaction**: wheel zoom on `data_min/max`, left-drag pan on value axis, widget drag via `_ovHitTest1d`/`_doDrag1d`, 'r' reset, per-widget cursors, status bar, bar hover + tooltip, `on_click` | - -#### Bar zoom/pan model -Unlike 1D (which zooms `view_x0/x1`), bar zooms and pans the **value axis** by -modifying `st.data_min`/`st.data_max` directly. `view_x0/x1` stays fixed at -0/1 so overlay widgets (vlines, hlines) keep correct positions throughout. -`origDataMin/Max` are captured on first interaction (JS closure) for 'r' reset. +| `_barGeom(st,r)` | ~2808 | Per-bar geometry: slot/group pixel sizes, `xToPx`/`yToPx`, `groupOffsetPx(g)`, `getVal(i,g)`, log-scale coordinate mappers, `basePx` | +| `drawBar(p)` | ~2870 | **Main bar render**: log/linear grid, grouped bars (clipped), value labels, axis borders, log/linear ticks, group legend | +| `_attachEventsBar(p)` | ~2977 | **Full interaction**: widget drag, hover/tooltip (shows group label), `on_click` (emits `bar_index`, `group_index`, `value`, `group_value`), keyboard | + +#### `_barGeom` — grouped geometry + +For *G* groups per category and bar-width fraction *w*: +``` +slotPx = (r.w or r.h) / n — pixel width of one category slot +barPx = slotPx * w / G — pixel width of a single bar +groupOffsetPx(g) = (g - (G-1)/2) * barPx — centre offset for group g +``` +`getVal(i, g)` reads from `st.values[i][g]` (2-D) or legacy `st.values[i]` +(scalar) so old 1-D state still renders correctly. + +#### Log scale + +When `st.log_scale` is true `yToPx`/`xToPx` use `Math.log10` internally: +```js +lv = Math.log10(Math.max(1e-10, v)) +py = r.y + r.h - ((lv - lMin) / (lMax - lMin)) * r.h +``` +Grid lines: faint minor lines at 2×, 3×, 5× per decade; full-opacity major +lines at each power of 10. Tick labels use superscript notation (`10^N`). + +#### Bar zoom/pan model (unchanged) +Unlike 1D (which zooms `view_x0/x1`), bar zooms/pans the **value axis** by +modifying `st.data_min`/`st.data_max` directly. `view_x0/x1` stays fixed +at 0/1 so overlay widgets keep correct positions throughout. + +#### `on_click` event payload +```js +{ bar_index, group_index, value, group_value, x_center, x_label } +``` +`group_index` is always 0 for ungrouped charts. `group_value` equals +`value` (alias for convenience). + --- diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py index 555222c..c65063b 100644 --- a/anyplotlib/_repr_utils.py +++ b/anyplotlib/_repr_utils.py @@ -136,7 +136,12 @@ def _widget_px(widget) -> tuple[int, int]: const _anyCbs = []; return {{ get(key) {{ return _data[key]; }}, - set(key, val) {{ _data[key] = val; }}, + set(key, val) {{ + _data[key] = val; + const ev = 'change:' + key; + if (_cbs[ev]) for (const cb of [..._cbs[ev]]) try {{ cb({{ new: val }}); }} catch(_) {{}} + for (const cb of [..._anyCbs]) try {{ cb(); }} catch(_) {{}} + }}, save_changes() {{ for (const [ev, cbs] of Object.entries(_cbs)) for (const cb of cbs) try {{ cb({{ new: _data[ev.slice(7)] }}); }} catch(_) {{}} diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 646983b..33e12fa 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -363,6 +363,9 @@ function render({ model, el }) { 'position:absolute;bottom:4px;right:4px;padding:2px 6px;display:none;'; wrap3.appendChild(statusBar); _wrapNode = wrap3; + + } else { + // ── 1D / bar branch ─────────────────────────────────────────────────── plotCanvas = document.createElement('canvas'); plotCanvas.tabIndex = 1; plotCanvas.style.cssText = 'outline:none;cursor:crosshair;display:block;border-radius:2px;'; @@ -422,7 +425,7 @@ function render({ model, el }) { isPanning: false, panStart: {}, state: null, _hoverSi: -1, _hoverI: -1, // index of hovered marker group / marker (-1 = none) - _hovBar: -1, // index of hovered bar (-1 = none) + _hovBar: null, // {slot,group} of hovered bar, or null lastWidgetId: null, // id of the last clicked/dragged widget (for on_key Delete etc.) mouseX: 0, mouseY: 0, // last known canvas-relative cursor position // 2D extras (null for non-2D panels) @@ -2804,34 +2807,66 @@ function render({ model, el }) { // ── bar chart ───────────────────────────────────────────────────────────── // Shared geometry helper used by both drawBar and _attachEventsBar. - // Returns the per-slot pixel width, per-bar pixel width, and coordinate - // mappers for the current panel state. + // Returns per-slot/bar pixel sizes, coordinate mappers, and group helpers. function _barGeom(st, r) { const values = st.values || []; const n = values.length || 1; + const groups = st.groups || 1; const orient = st.orient || 'v'; - const bwFrac = st.bar_width !== undefined ? st.bar_width : 0.7; + const bwFrac = st.bar_width !== undefined ? st.bar_width : 0.8; const baseline = st.baseline !== undefined ? st.baseline : 0; const dMin = st.data_min, dMax = st.data_max; + const logScale = !!st.log_scale; + const LC = 1e-10; // log clamp + + // Value at category i, group g — supports 2-D [[g0,g1,...], ...] and + // legacy 1-D [v0, v1, ...]. + function getVal(i, g) { + const row = values[i]; + if (Array.isArray(row)) return row[g] !== undefined ? row[g] : 0; + return (g === 0) ? (row !== undefined ? +row : 0) : 0; + } + + // Pixel coordinate along the VALUE axis. + function _valToPy(v) { + if (logScale) { + const lv = Math.log10(Math.max(LC, v)); + const lMin = Math.log10(Math.max(LC, dMin)); + const lMax = Math.log10(Math.max(LC, dMax)); + return r.y + r.h - ((lv - lMin) / ((lMax - lMin) || 1)) * r.h; + } + return r.y + r.h - ((v - dMin) / ((dMax - dMin) || 1)) * r.h; + } + function _valToX(v) { + if (logScale) { + const lv = Math.log10(Math.max(LC, v)); + const lMin = Math.log10(Math.max(LC, dMin)); + const lMax = Math.log10(Math.max(LC, dMax)); + return r.x + ((lv - lMin) / ((lMax - lMin) || 1)) * r.w; + } + return r.x + ((v - dMin) / ((dMax - dMin) || 1)) * r.w; + } if (orient === 'h') { - // Horizontal: categories on Y, values on X - const slotPx = r.h / n; - const barPx = slotPx * bwFrac; - // xToPx maps a value to an x pixel (value axis = horizontal) - function xToPx(v) { return r.x + ((v - dMin) / ((dMax - dMin) || 1)) * r.w; } - // yToPx maps a bar index to the centre of its slot (category axis = vertical) + const slotPx = r.h / n; + const barPx = (slotPx * bwFrac) / groups; + function xToPx(v) { return _valToX(v); } function yToPx(i) { return r.y + (i + 0.5) * slotPx; } - const basePx = Math.max(r.x, Math.min(r.x + r.w, xToPx(baseline))); - return { n, orient, slotPx, barPx, dMin, dMax, baseline, basePx, xToPx, yToPx }; + function groupOffsetPx(g) { return (g - (groups - 1) / 2) * barPx; } + const bv = logScale ? Math.max(LC, baseline) : baseline; + const basePx = Math.max(r.x, Math.min(r.x + r.w, xToPx(bv))); + return { n, groups, orient, slotPx, barPx, dMin, dMax, baseline, + basePx, xToPx, yToPx, groupOffsetPx, logScale, getVal, LC }; } else { - // Vertical: categories on X, values on Y - const slotPx = r.w / n; - const barPx = slotPx * bwFrac; + const slotPx = r.w / n; + const barPx = (slotPx * bwFrac) / groups; function xToPx(i) { return r.x + (i + 0.5) * slotPx; } - function yToPx(v) { return r.y + r.h - ((v - dMin) / ((dMax - dMin) || 1)) * r.h; } - const basePx = Math.max(r.y, Math.min(r.y + r.h, yToPx(baseline))); - return { n, orient, slotPx, barPx, dMin, dMax, baseline, basePx, xToPx, yToPx }; + function yToPx(v) { return _valToPy(v); } + function groupOffsetPx(g) { return (g - (groups - 1) / 2) * barPx; } + const bv = logScale ? Math.max(LC, baseline) : baseline; + const basePx = Math.max(r.y, Math.min(r.y + r.h, yToPx(bv))); + return { n, groups, orient, slotPx, barPx, dMin, dMax, baseline, + basePx, xToPx, yToPx, groupOffsetPx, logScale, getVal, LC }; } } @@ -2845,34 +2880,86 @@ function render({ model, el }) { ctx.fillStyle = theme.bg; ctx.fillRect(0, 0, pw, ph); ctx.fillStyle = theme.bgPlot; ctx.fillRect(r.x, r.y, r.w, r.h); - const values = st.values || []; - const xCenters = st.x_centers || values.map((_, i) => i); - const xLabels = st.x_labels || []; - const barColor = st.bar_color || '#4fc3f7'; - const barColors = st.bar_colors || []; - const orient = st.orient || 'v'; - const dMin = st.data_min, dMax = st.data_max; + const values = st.values || []; + const xCenters = st.x_centers || values.map((_, i) => i); + const xLabels = st.x_labels || []; + const barColor = st.bar_color || '#4fc3f7'; + const barColors = st.bar_colors || []; + const groupColors = st.group_colors || []; + const groupLabels = st.group_labels || []; + const orient = st.orient || 'v'; + const dMin = st.data_min, dMax = st.data_max; + const logScale = !!st.log_scale; if (!values.length) return; const g = _barGeom(st, r); + const LC = g.LC; + + // ── log tick helper ─────────────────────────────────────────────────── + function _fmtLogTick(v) { + const exp = Math.round(Math.log10(v)); + if (Math.abs(v - Math.pow(10, exp)) < v * 1e-6) { + if (exp === 0) return '1'; + if (exp === 1) return '10'; + return `10^${exp}`; + } + return fmtVal(v); + } // ── grid lines (along value axis) ───────────────────────────────────── ctx.strokeStyle = theme.gridStroke; ctx.lineWidth = 1; - const valRange = (dMax - dMin) || 1; - const valStep = findNice(valRange / Math.max(2, Math.floor((orient==='h' ? r.w : r.h) / 40))); - if (orient === 'h') { - for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { - const px = g.xToPx(v); - if (px < r.x || px > r.x + r.w) continue; - ctx.beginPath(); ctx.moveTo(px, r.y); ctx.lineTo(px, r.y + r.h); ctx.stroke(); + if (logScale) { + const lMin = Math.log10(Math.max(LC, dMin)); + const lMax = Math.log10(Math.max(LC, dMax)); + // minor grid — 2,3,5 × decade, semi-transparent + ctx.globalAlpha = 0.3; + for (let exp = Math.floor(lMin); exp < Math.ceil(lMax); exp++) { + for (const m of [2, 3, 5]) { + const v = m * Math.pow(10, exp); + if (v < dMin || v > dMax) continue; + if (orient === 'h') { + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.beginPath(); ctx.moveTo(px, r.y); ctx.lineTo(px, r.y + r.h); ctx.stroke(); + } else { + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x + r.w, py); ctx.stroke(); + } + } + } + ctx.globalAlpha = 1.0; + // major grid — decades, full opacity + for (let exp = Math.floor(lMin); exp <= Math.ceil(lMax); exp++) { + const v = Math.pow(10, exp); + if (v < dMin || v > dMax) continue; + if (orient === 'h') { + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.beginPath(); ctx.moveTo(px, r.y); ctx.lineTo(px, r.y + r.h); ctx.stroke(); + } else { + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x + r.w, py); ctx.stroke(); + } } } else { - for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { - const py = g.yToPx(v); - if (py < r.y || py > r.y + r.h) continue; - ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x + r.w, py); ctx.stroke(); + const valRange = (dMax - dMin) || 1; + const valStep = findNice(valRange / Math.max(2, Math.floor((orient==='h' ? r.w : r.h) / 40))); + if (orient === 'h') { + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.beginPath(); ctx.moveTo(px, r.y); ctx.lineTo(px, r.y + r.h); ctx.stroke(); + } + } else { + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x + r.w, py); ctx.stroke(); + } } } @@ -2880,39 +2967,42 @@ function render({ model, el }) { ctx.save(); ctx.beginPath(); ctx.rect(r.x, r.y, r.w, r.h); ctx.clip(); for (let i = 0; i < g.n; i++) { - const color = barColors[i] || barColor; - const isHov = (p._hovBar === i); + for (let gi = 0; gi < g.groups; gi++) { + const val = g.getVal(i, gi); + const color = groupColors[gi] || barColors[i] || barColor; + const isHov = p._hovBar !== null && + p._hovBar.slot === i && p._hovBar.group === gi; + const dv = logScale ? Math.max(LC, val) : val; - if (orient === 'h') { - const cy = g.yToPx(i); - const valPx = g.xToPx(values[i]); - const barLeft = Math.min(valPx, g.basePx); - const barW = Math.max(1, Math.abs(valPx - g.basePx)); - ctx.fillStyle = color; - ctx.fillRect(barLeft, cy - g.barPx / 2, barW, g.barPx); - if (isHov) { - ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.22)'; - ctx.fillRect(barLeft, cy - g.barPx / 2, barW, g.barPx); - ctx.restore(); - } - ctx.strokeStyle = theme.dark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.09)'; - ctx.lineWidth = 0.5; - ctx.strokeRect(barLeft, cy - g.barPx / 2, barW, g.barPx); - } else { - const cx = g.xToPx(i); - const valPy = g.yToPx(values[i]); - const barTop = Math.min(valPy, g.basePx); - const barH = Math.max(1, Math.abs(valPy - g.basePx)); - ctx.fillStyle = color; - ctx.fillRect(cx - g.barPx / 2, barTop, g.barPx, barH); - if (isHov) { - ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.22)'; - ctx.fillRect(cx - g.barPx / 2, barTop, g.barPx, barH); - ctx.restore(); + if (orient === 'h') { + const cy = g.yToPx(i) + g.groupOffsetPx(gi); + const valPx = g.xToPx(dv); + const bLeft = Math.min(valPx, g.basePx); + const bW = Math.max(1, Math.abs(valPx - g.basePx)); + ctx.fillStyle = color; + ctx.fillRect(bLeft, cy - g.barPx / 2, bW, g.barPx); + if (isHov) { + ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.fillRect(bLeft, cy - g.barPx / 2, bW, g.barPx); ctx.restore(); + } + ctx.strokeStyle = theme.dark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.09)'; + ctx.lineWidth = 0.5; + ctx.strokeRect(bLeft, cy - g.barPx / 2, bW, g.barPx); + } else { + const cx = g.xToPx(i) + g.groupOffsetPx(gi); + const valPy = g.yToPx(dv); + const bTop = Math.min(valPy, g.basePx); + const bH = Math.max(1, Math.abs(valPy - g.basePx)); + ctx.fillStyle = color; + ctx.fillRect(cx - g.barPx / 2, bTop, g.barPx, bH); + if (isHov) { + ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.fillRect(cx - g.barPx / 2, bTop, g.barPx, bH); ctx.restore(); + } + ctx.strokeStyle = theme.dark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.09)'; + ctx.lineWidth = 0.5; + ctx.strokeRect(cx - g.barPx / 2, bTop, g.barPx, bH); } - ctx.strokeStyle = theme.dark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.09)'; - ctx.lineWidth = 0.5; - ctx.strokeRect(cx - g.barPx / 2, barTop, g.barPx, barH); } } ctx.restore(); @@ -2921,20 +3011,24 @@ function render({ model, el }) { if (st.show_values) { ctx.font = '9px monospace'; ctx.fillStyle = theme.tickText; for (let i = 0; i < g.n; i++) { - if (orient === 'h') { - const cy = g.yToPx(i); - const valPx = g.xToPx(values[i]); - const above = values[i] >= g.baseline; - ctx.textAlign = above ? 'left' : 'right'; - ctx.textBaseline = 'middle'; - ctx.fillText(fmtVal(values[i]), valPx + (above ? 3 : -3), cy); - } else { - const cx = g.xToPx(i); - const valPy = g.yToPx(values[i]); - const above = values[i] >= g.baseline; - ctx.textAlign = 'center'; - ctx.textBaseline = above ? 'bottom' : 'top'; - ctx.fillText(fmtVal(values[i]), cx, valPy + (above ? -2 : 2)); + for (let gi = 0; gi < g.groups; gi++) { + const val = g.getVal(i, gi); + const dv = logScale ? Math.max(LC, val) : val; + if (orient === 'h') { + const cy = g.yToPx(i) + g.groupOffsetPx(gi); + const valPx = g.xToPx(dv); + const above = val >= g.baseline; + ctx.textAlign = above ? 'left' : 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(fmtVal(val), valPx + (above ? 3 : -3), cy); + } else { + const cx = g.xToPx(i) + g.groupOffsetPx(gi); + const valPy = g.yToPx(dv); + const above = val >= g.baseline; + ctx.textAlign = 'center'; + ctx.textBaseline = above ? 'bottom' : 'top'; + ctx.fillText(fmtVal(val), cx, valPy + (above ? -2 : 2)); + } } } } @@ -2944,16 +3038,18 @@ function render({ model, el }) { ctx.beginPath(); ctx.moveTo(r.x, r.y + r.h); ctx.lineTo(r.x + r.w, r.y + r.h); ctx.stroke(); ctx.beginPath(); ctx.moveTo(r.x, r.y); ctx.lineTo(r.x, r.y + r.h); ctx.stroke(); - // Explicit baseline when it isn't at the plot edge - if (orient === 'h') { - if (g.basePx > r.x && g.basePx < r.x + r.w) { - ctx.strokeStyle = theme.axisStroke; ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.moveTo(g.basePx, r.y); ctx.lineTo(g.basePx, r.y + r.h); ctx.stroke(); - } - } else { - if (g.basePx > r.y && g.basePx < r.y + r.h) { - ctx.strokeStyle = theme.axisStroke; ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.moveTo(r.x, g.basePx); ctx.lineTo(r.x + r.w, g.basePx); ctx.stroke(); + // Explicit baseline (only for linear scale) + if (!logScale) { + if (orient === 'h') { + if (g.basePx > r.x && g.basePx < r.x + r.w) { + ctx.strokeStyle = theme.axisStroke; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(g.basePx, r.y); ctx.lineTo(g.basePx, r.y + r.h); ctx.stroke(); + } + } else { + if (g.basePx > r.y && g.basePx < r.y + r.h) { + ctx.strokeStyle = theme.axisStroke; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(r.x, g.basePx); ctx.lineTo(r.x + r.w, g.basePx); ctx.stroke(); + } } } @@ -2963,13 +3059,30 @@ function render({ model, el }) { if (orient === 'h') { // Value axis → X ticks at bottom ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { - const px = g.xToPx(v); - if (px < r.x || px > r.x + r.w) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(fmtVal(v), px, r.y + r.h + 7); + if (logScale) { + const lMin = Math.log10(Math.max(LC, dMin)); + const lMax = Math.log10(Math.max(LC, dMax)); + for (let exp = Math.floor(lMin); exp <= Math.ceil(lMax); exp++) { + const v = Math.pow(10, exp); + if (v < dMin || v > dMax) continue; + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(_fmtLogTick(v), px, r.y + r.h + 7); + } + } else { + const valRange = (dMax - dMin) || 1; + const valStep = findNice(valRange / Math.max(2, Math.floor(r.w / 40))); + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(fmtVal(v), px, r.y + r.h + 7); + } } // Category axis → Y labels on left ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; @@ -2983,7 +3096,6 @@ function render({ model, el }) { ctx.fillStyle = theme.tickText; ctx.fillText(label, r.x - 7, cy); } - // Units if (st.y_units) { ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; ctx.fillStyle=theme.unitText; @@ -3019,13 +3131,30 @@ function render({ model, el }) { } // Value axis → Y ticks on left ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { - const py = g.yToPx(v); - if (py < r.y || py > r.y + r.h) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(fmtVal(v), r.x - 8, py); + if (logScale) { + const lMin = Math.log10(Math.max(LC, dMin)); + const lMax = Math.log10(Math.max(LC, dMax)); + for (let exp = Math.floor(lMin); exp <= Math.ceil(lMax); exp++) { + const v = Math.pow(10, exp); + if (v < dMin || v > dMax) continue; + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(_fmtLogTick(v), r.x - 8, py); + } + } else { + const valRange = (dMax - dMin) || 1; + const valStep = findNice(valRange / Math.max(2, Math.floor(r.h / 40))); + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(fmtVal(v), r.x - 8, py); + } } if (st.y_units) { ctx.save(); @@ -3037,35 +3166,71 @@ function render({ model, el }) { } } - // Overlay widgets (vlines, hlines) drawn on the overlay canvas + // ── group legend (only when group_labels are provided) ──────────────── + if (g.groups > 1 && groupLabels.length > 0) { + ctx.font = '9px monospace'; + const swatchW = 12, swatchH = 10, pad = 4, rowH = 15; + let legendMaxW = 0; + for (let gi = 0; gi < Math.min(g.groups, groupLabels.length); gi++) { + legendMaxW = Math.max(legendMaxW, ctx.measureText(String(groupLabels[gi])).width); + } + const legendW = swatchW + pad + legendMaxW + 6; + const nRows = Math.min(g.groups, groupLabels.length); + const legendH = nRows * rowH + 4; + const lx = r.x + r.w - legendW - 4; + const ly = r.y + 4; + ctx.fillStyle = theme.dark ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.75)'; + ctx.fillRect(lx, ly, legendW, legendH); + ctx.strokeStyle = theme.axisStroke; ctx.lineWidth = 0.5; + ctx.strokeRect(lx, ly, legendW, legendH); + for (let gi = 0; gi < nRows; gi++) { + const label = String(groupLabels[gi]); + const ey = ly + 2 + gi * rowH; + ctx.fillStyle = groupColors[gi] || barColor; + ctx.fillRect(lx + 3, ey + (rowH - swatchH) / 2, swatchW, swatchH); + ctx.fillStyle = theme.tickText; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(label, lx + 3 + swatchW + pad, ey + rowH / 2); + } + } + + // Overlay widgets (vlines, hlines) drawn on overlay canvas drawOverlay1d(p); } function _attachEventsBar(p) { const { overlayCanvas } = p; - // Return the bar index at canvas position (mx, my), or -1 if none. + // Returns {slot, group} for the bar at canvas (mx,my), or null if none. function _barHit(mx, my) { - const st = p.state; if (!st || !st.values.length) return -1; + const st = p.state; + if (!st || !st.values || !st.values.length) return null; const r = _plotRect1d(p.pw, p.ph); - if (mx < r.x || mx > r.x + r.w || my < r.y || my > r.y + r.h) return -1; - const g = _barGeom(st, r); + if (mx < r.x || mx > r.x + r.w || my < r.y || my > r.y + r.h) return null; + const g = _barGeom(st, r); + const LC = g.LC; for (let i = 0; i < g.n; i++) { - if (g.orient === 'h') { - const cy = g.yToPx(i); - const valPx = g.xToPx(st.values[i]); - const left = Math.min(valPx, g.basePx); - const barW = Math.max(1, Math.abs(valPx - g.basePx)); - if (Math.abs(my - cy) <= g.barPx / 2 && mx >= left && mx <= left + barW) return i; - } else { - const cx = g.xToPx(i); - const valPy = g.yToPx(st.values[i]); - const top = Math.min(valPy, g.basePx); - const barH = Math.max(1, Math.abs(valPy - g.basePx)); - if (Math.abs(mx - cx) <= g.barPx / 2 && my >= top && my <= top + barH) return i; + for (let gi = 0; gi < g.groups; gi++) { + const val = g.getVal(i, gi); + const dv = g.logScale ? Math.max(LC, val) : val; + if (g.orient === 'h') { + const cy = g.yToPx(i) + g.groupOffsetPx(gi); + const valPx = g.xToPx(dv); + const left = Math.min(valPx, g.basePx); + const bW = Math.max(1, Math.abs(valPx - g.basePx)); + if (Math.abs(my - cy) <= g.barPx / 2 && mx >= left && mx <= left + bW) + return { slot: i, group: gi }; + } else { + const cx = g.xToPx(i) + g.groupOffsetPx(gi); + const valPy = g.yToPx(dv); + const top = Math.min(valPy, g.basePx); + const bH = Math.max(1, Math.abs(valPy - g.basePx)); + if (Math.abs(mx - cx) <= g.barPx / 2 && my >= top && my <= top + bH) + return { slot: i, group: gi }; + } } } - return -1; + return null; } // Widget drag support @@ -3109,7 +3274,7 @@ function render({ model, el }) { overlayCanvas.addEventListener('mousemove', (e) => { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); p.mouseX = mx; p.mouseY = my; - if (p.ovDrag) return; // handled by document mousemove during drag + if (p.ovDrag) return; const st = p.state; if (!st) return; // Overlay widget cursor hint @@ -3117,20 +3282,28 @@ function render({ model, el }) { if (whit) { overlayCanvas.style.cursor = 'ew-resize'; tooltip.style.display = 'none'; - if (p._hovBar !== -1) { p._hovBar = -1; drawBar(p); } + if (p._hovBar !== null) { p._hovBar = null; drawBar(p); } return; } - const idx = _barHit(mx, my); - if (idx !== p._hovBar) { - p._hovBar = idx; - drawBar(p); - } - if (idx >= 0) { + const hit = _barHit(mx, my); + const prev = p._hovBar; + const same = hit !== null && prev !== null && + prev.slot === hit.slot && prev.group === hit.group; + if (!same) { p._hovBar = hit; drawBar(p); } + + if (hit !== null) { + const { slot: idx, group: gi } = hit; const label = (st.x_labels||[])[idx] !== undefined ? String(st.x_labels[idx]) : fmtVal((st.x_centers||[])[idx] ?? idx); - _showTooltip(`${label}: ${fmtVal(st.values[idx])}`, e.clientX, e.clientY); + const gLabel = (st.group_labels||[])[gi]; + const gm = _barGeom(st, _plotRect1d(p.pw, p.ph)); + const val = gm.getVal(idx, gi); + const tip = (st.groups > 1 && gLabel) + ? `${gLabel} | ${label}: ${fmtVal(val)}` + : `${label}: ${fmtVal(val)}`; + _showTooltip(tip, e.clientX, e.clientY); overlayCanvas.style.cursor = 'pointer'; } else { tooltip.style.display = 'none'; @@ -3139,7 +3312,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('mouseleave', () => { - if (p._hovBar !== -1) { p._hovBar = -1; drawBar(p); } + if (p._hovBar !== null) { p._hovBar = null; drawBar(p); } tooltip.style.display = 'none'; }); @@ -3147,14 +3320,19 @@ function render({ model, el }) { if (p.ovDrag) return; const st = p.state; if (!st) return; const {mx:_cmx, my:_cmy} = _clientPos(e, overlayCanvas, p.pw, p.ph); - const idx = _barHit(_cmx, _cmy); - if (idx < 0) return; + const hit = _barHit(_cmx, _cmy); + if (hit === null) return; + const { slot: idx, group: gi } = hit; + const gm = _barGeom(st, _plotRect1d(p.pw, p.ph)); + const val = gm.getVal(idx, gi); _emitEvent(p.id, 'on_click', null, { - bar_index: idx, - value: st.values[idx], - x_center: (st.x_centers||[])[idx] ?? idx, - x_label: (st.x_labels||[])[idx] !== undefined - ? String(st.x_labels[idx]) : null, + bar_index: idx, + group_index: gi, + value: val, + group_value: val, + x_center: (st.x_centers||[])[idx] ?? idx, + x_label: (st.x_labels||[])[idx] !== undefined + ? String(st.x_labels[idx]) : null, }); }); diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 389dc9f..9dfb511 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -439,38 +439,58 @@ def plot(self, data: np.ndarray, self._attach(plot) return plot - def bar(self, values, - x_labels=None, - x_centers=None, + def bar(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, + align: str = "center", color: str = "#4fc3f7", colors=None, - bar_width: float = 0.7, orient: str = "v", - baseline: float = 0.0, + log_scale: bool = False, + group_labels=None, + group_colors=None, show_values: bool = False, units: str = "", - y_units: str = "") -> "PlotBar": + y_units: str = "", + # ── legacy backward-compat kwargs ────────────────────────────── + x_labels=None, + x_centers=None, + bar_width=None, + baseline=None, + values=None) -> "PlotBar": """Attach a bar chart to this axes cell. + Signature mirrors ``matplotlib.pyplot.bar``:: + + ax.bar(x, height, width=0.8, bottom=0.0, ...) + Parameters ---------- - values : array-like, shape (N,) - Bar heights (vertical) or widths (horizontal). - x_labels : list of str, optional - Category labels for each bar. Shown on the categorical axis - instead of numeric tick values. - x_centers : array-like, optional - Numeric positions of bar centres. Defaults to ``0, 1, … N-1``. + x : array-like of str or numeric + Bar positions. Strings become category labels with auto-numeric + centres; numbers are used directly as bar centres. + height : array-like, shape ``(N,)`` or ``(N, G)``, optional + Bar heights. Pass a 2-D array to draw *G* grouped bars per + category. If omitted *x* is treated as the heights and positions + are generated automatically (backward-compatible call form). + width : float, optional + Bar width as a fraction of the category slot (0–1). Default ``0.8``. + bottom : float, optional + Value at which bars are rooted (baseline). Default ``0``. + align : ``"center"`` | ``"edge"``, optional + Alignment of the bar relative to its *x* position. Currently only + ``"center"`` is rendered; stored for future use. color : str, optional Single CSS colour applied to every bar. Default ``"#4fc3f7"``. colors : list of str, optional - Per-bar colour list; overrides *color* where provided. - bar_width : float, optional - Bar width as a fraction of the slot width (0–1). Default ``0.7``. + Per-bar colour list (ungrouped) or ignored when *group_colors* is set. orient : ``"v"`` | ``"h"``, optional Vertical (default) or horizontal orientation. - baseline : float, optional - Value at which bars are rooted. Default ``0``. + log_scale : bool, optional + Use a logarithmic value axis. Non-positive values are clamped to + ``1e-10`` for display. Default ``False``. + group_labels : list of str, optional + Legend labels for each group in a grouped bar chart. + group_colors : list of str, optional + CSS colours per group. Defaults to a built-in palette. show_values : bool, optional Draw the numeric value above / beside each bar. units : str, optional @@ -478,14 +498,36 @@ def bar(self, values, y_units : str, optional Label for the value axis. + Backward-compatible keyword aliases + ------------------------------------ + ``values`` → ``height`` + ``x_centers`` → ``x`` + ``bar_width`` → ``width`` + ``baseline`` → ``bottom`` + ``x_labels`` → strings passed via ``x`` + Returns ------- PlotBar """ - plot = PlotBar(values, x_labels=x_labels, x_centers=x_centers, - color=color, colors=colors, bar_width=bar_width, - orient=orient, baseline=baseline, show_values=show_values, - units=units, y_units=y_units) + # ── legacy backward-compat resolution ───────────────────────────── + if height is None: + if values is not None: + height = values + else: + height = x + x = None + if baseline is not None: + bottom = baseline + if bar_width is not None: + width = bar_width + + plot = PlotBar(x, height, width=width, bottom=bottom, + align=align, color=color, colors=colors, + orient=orient, log_scale=log_scale, + group_labels=group_labels, group_colors=group_colors, + show_values=show_values, units=units, y_units=y_units, + x_labels=x_labels, x_centers=x_centers) self._attach(plot) return plot @@ -2856,78 +2898,159 @@ def _bar_x_axis(x_centers: np.ndarray) -> list: # PlotBar # --------------------------------------------------------------------------- +_LOG_CLAMP = 1e-10 # smallest positive value used when log_scale=True + +_DEFAULT_GROUP_PALETTE = [ + "#4fc3f7", "#ff7043", "#66bb6a", "#ab47bc", + "#ffa726", "#26c6da", "#ec407a", "#8d6e63", +] + + +def _bar_range(flat: np.ndarray, bottom: float, log_scale: bool): + """Return ``(dmin, dmax)`` with padding for the value axis.""" + if log_scale: + pos = flat[flat > 0] + dmin = float(np.nanmin(pos)) if len(pos) else _LOG_CLAMP + dmax = max(float(np.nanmax(flat)) if len(flat) else 1.0, + bottom if bottom > 0 else _LOG_CLAMP) + if dmin <= 0: + dmin = _LOG_CLAMP + if dmax <= 0: + dmax = 1.0 + dmax = 10 ** (np.log10(dmax) + 0.15) + dmin = 10 ** (np.log10(dmin) - 0.15) + else: + dmin = min(bottom, float(np.nanmin(flat)) if len(flat) else 0.0) + dmax = max(bottom, float(np.nanmax(flat)) if len(flat) else 1.0) + pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 + dmax += pad + if dmin < bottom: + dmin -= pad + return dmin, dmax + + class PlotBar: """Bar-chart plot panel. Not an anywidget. Holds state in ``_state`` dict; every mutation calls ``_push()`` which writes to the parent Figure's panel trait. - Supports draggable :class:`~anyplotlib.widgets.VLineWidget` and - :class:`~anyplotlib.widgets.HLineWidget` overlays via - :meth:`add_vline_widget` / :meth:`add_hline_widget`. + Supports grouped bars (pass a 2-D *height* array with shape ``(N, G)``), + log-scale value axis, draggable overlay widgets, and hover/click callbacks. Created by :meth:`Axes.bar`. """ - def __init__(self, values, - x_labels=None, - x_centers=None, + def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, + align: str = "center", color: str = "#4fc3f7", colors=None, - bar_width: float = 0.7, orient: str = "v", - baseline: float = 0.0, + log_scale: bool = False, + group_labels=None, + group_colors=None, show_values: bool = False, units: str = "", - y_units: str = ""): + y_units: str = "", + # ── legacy backward-compat kwargs ────────────────────── + x_labels=None, + x_centers=None, + bar_width=None, + baseline=None, + values=None): self._id: str = "" self._fig: object = None - values = np.asarray(values, dtype=float) - n = len(values) - if values.ndim != 1: - raise ValueError(f"values must be 1-D, got shape {values.shape}") + # ── legacy resolution ────────────────────────────────────────── + if height is None: + if values is not None: + height = values + else: + height = x + x = None + if baseline is not None: + bottom = baseline + if bar_width is not None: + width = bar_width + + # ── height (values) — 1-D or 2-D for grouped bars ───────────── + height_arr = np.asarray(height, dtype=float) + if height_arr.ndim == 1: + groups = 1 + values_2d = height_arr.reshape(-1, 1) + elif height_arr.ndim == 2: + groups = height_arr.shape[1] + values_2d = height_arr + else: + raise ValueError( + f"height must be 1-D or 2-D, got shape {height_arr.shape}" + ) + n = values_2d.shape[0] + if orient not in ("v", "h"): raise ValueError("orient must be 'v' or 'h'") - if x_centers is None: - x_centers = np.arange(n, dtype=float) - x_centers = np.asarray(x_centers, dtype=float) - if len(x_centers) != n: - raise ValueError("x_centers length must match values length") - - val_min = float(np.nanmin(values)) if n else 0.0 - val_max = float(np.nanmax(values)) if n else 1.0 - dmin = min(float(baseline), val_min) - dmax = max(float(baseline), val_max) - pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 - dmax += pad - if dmin < float(baseline): - dmin -= pad + # ── x (positions or labels) ──────────────────────────────────── + _x_labels: list = [] + _x_centers: np.ndarray | None = None - # Compute physical x-axis extent (left/right edges of the bar chart) - # so that vline_widgets map to the correct pixel positions. - x_axis = _bar_x_axis(x_centers) + if x is not None: + x_list = list(x) + if x_list and isinstance(x_list[0], str): + _x_labels = x_list + else: + _x_centers = np.asarray(x, dtype=float) + + # Legacy keyword overrides + if x_labels is not None: + _x_labels = list(x_labels) + if x_centers is not None: + _x_centers = np.asarray(x_centers, dtype=float) + + if _x_centers is None: + _x_centers = np.arange(n, dtype=float) + if len(_x_centers) != n: + raise ValueError("x length must match height length") + + # ── data range ───────────────────────────────────────────────── + flat = values_2d.ravel() + dmin, dmax = _bar_range(flat, float(bottom), bool(log_scale)) + + # ── group colours ────────────────────────────────────────────── + if group_colors is None: + gc_list = ( + [_DEFAULT_GROUP_PALETTE[i % len(_DEFAULT_GROUP_PALETTE)] + for i in range(groups)] + if groups > 1 else [] + ) + else: + gc_list = list(group_colors) + + x_axis = _bar_x_axis(_x_centers) self._state: dict = { - "kind": "bar", - "values": values.tolist(), - "x_centers": x_centers.tolist(), - "x_labels": list(x_labels) if x_labels is not None else [], - "bar_color": color, - "bar_colors": list(colors) if colors is not None else [], - "bar_width": float(bar_width), - "orient": orient, - "baseline": float(baseline), - "show_values": bool(show_values), - "data_min": dmin, - "data_max": dmax, - "units": units, - "y_units": y_units, + "kind": "bar", + "values": values_2d.tolist(), # always (N, G) 2-D list + "groups": groups, + "x_centers": _x_centers.tolist(), + "x_labels": _x_labels, + "bar_color": color, + "bar_colors": list(colors) if colors is not None else [], + "group_labels": list(group_labels) if group_labels is not None else [], + "group_colors": gc_list, + "bar_width": float(width), + "orient": orient, + "baseline": float(bottom), + "log_scale": bool(log_scale), + "show_values": bool(show_values), + "data_min": dmin, + "data_max": dmax, + "units": units, + "y_units": y_units, # overlay-widget coordinate system (mirrors Plot1D) - "x_axis": x_axis, - "view_x0": 0.0, - "view_x1": 1.0, + "x_axis": x_axis, + "view_x0": 0.0, + "view_x1": 1.0, "overlay_widgets": [], "registered_keys": [], } @@ -2949,25 +3072,49 @@ def to_state_dict(self) -> dict: # ------------------------------------------------------------------ # Data update # ------------------------------------------------------------------ - def update(self, values, x_centers=None, x_labels=None) -> None: - """Replace bar values; recalculates the value-axis range automatically.""" - values = np.asarray(values, dtype=float) - if values.ndim != 1: - raise ValueError(f"values must be 1-D, got shape {values.shape}") + def update(self, height, x=None, x_labels=None, *, x_centers=None) -> None: + """Replace bar heights; recalculates the value-axis range automatically. + + Parameters + ---------- + height : array-like, shape ``(N,)`` or ``(N, G)`` + New bar heights. For grouped charts the group count *G* must + match the original. + x : array-like of numeric, optional + New bar positions (replaces the stored ``x_centers``). Also + accepts the legacy keyword alias ``x_centers``. + x_labels : list of str, optional + New category labels. + """ + height_arr = np.asarray(height, dtype=float) + if height_arr.ndim == 1: + values_2d = height_arr.reshape(-1, 1) + elif height_arr.ndim == 2: + expected_g = self._state.get("groups", 1) + if height_arr.shape[1] != expected_g: + raise ValueError( + f"Group count mismatch: expected {expected_g}, " + f"got {height_arr.shape[1]}" + ) + values_2d = height_arr + else: + raise ValueError( + f"height must be 1-D or 2-D, got shape {height_arr.shape}" + ) + flat = values_2d.ravel() baseline = self._state["baseline"] - dmin = min(float(baseline), float(np.nanmin(values))) - dmax = max(float(baseline), float(np.nanmax(values))) - pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 - dmax += pad - if dmin < baseline: - dmin -= pad + log_scale = self._state.get("log_scale", False) + dmin, dmax = _bar_range(flat, float(baseline), bool(log_scale)) - self._state["values"] = values.tolist() + self._state["values"] = values_2d.tolist() self._state["data_min"] = dmin self._state["data_max"] = dmax - if x_centers is not None: - xc = np.asarray(x_centers, dtype=float) + + # Accept both `x` and legacy `x_centers` keyword + _x = x if x is not None else x_centers + if _x is not None: + xc = np.asarray(_x, dtype=float) self._state["x_centers"] = xc.tolist() self._state["x_axis"] = _bar_x_axis(xc) if x_labels is not None: @@ -2992,6 +3139,21 @@ def set_show_values(self, show: bool) -> None: self._state["show_values"] = bool(show) self._push() + def set_log_scale(self, log_scale: bool) -> None: + """Enable or disable a logarithmic value axis. + + When *log_scale* is ``True`` any non-positive values are clamped to + ``1e-10`` for display; the data-range bounds are recalculated in + log-space automatically. + """ + self._state["log_scale"] = bool(log_scale) + flat = np.asarray(self._state["values"]).ravel() + baseline = self._state["baseline"] + dmin, dmax = _bar_range(flat, float(baseline), bool(log_scale)) + self._state["data_min"] = dmin + self._state["data_max"] = dmax + self._push() + # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ @@ -3144,6 +3306,9 @@ def disconnect(self, cid: int) -> None: def __repr__(self) -> str: n = len(self._state.get("values", [])) orient = self._state.get("orient", "v") + groups = self._state.get("groups", 1) + if groups > 1: + return f"PlotBar(n={n}, groups={groups}, orient={orient!r})" return f"PlotBar(n={n}, orient={orient!r})" diff --git a/tests/baselines/bar_basic.png b/tests/baselines/bar_basic.png new file mode 100644 index 0000000000000000000000000000000000000000..fb77ddfb44594eca0e42fe23d096e8af1e086317 GIT binary patch literal 8975 zcmeI2c{tSF-^af*A#u}!=C*{Ptf4F+TUl-)rU;dh7E-nuOJvDXQQ5{W+bEGO+?KIr znUpM{RLC|IV+o_imO-8~L)~@%^IXqAzw5c4>v3KF@bx|Ce9rlP-mmliobUJE2@`z| zHa<28f;bG0>6$_i>;d>yXN7|++rCf_L6Atdf$q^$*I_ehvzYj%_@?F8i)ll&c$?D% z+m@0z!|13h@p*a$4yq+rdHNKk?xmIAiz&}Ma_bDMWXqv_`(-(AhJ>gfnc+;PsX`wP z#ua)hPm1|2YUXG6c#Rz#$0vE?$2-=-Pbj{w8gEmW(-`-oxEFEse(kcfx$(6Tu{1kW z?=?%ZW?_2(vHejAzbOw(K5sl+J~H=OOX$nU-#56Az_`Ci2xI$^Ide5Pw=ti9m|4)U zp#Dgh7Q9~{#(gBnkQqW}Db@tSB@RrmL6t{e3$K9dIj10^x#4qmi9q@&gx*14euBTMBvPnwJ4`YMH1i*_F_ zuOY>-VG(lYQt^0ykT-HRb?E8K7cN|IadGkY_jh$Ib_giMIl5kgp$p>&^W4{_2)>VA zt@rk6mDF0a@VF|tyqc29g_a^Pek0$$j#Rh|N>#`oI^>zHU~9_fD+ZcQZi_a85XJXT z=NZBv%dF!LPy4ApM!Sg4E-sPb;o&$O%EUxgMyAE(w$r{Iw#!vk9yicNybBoXk;&|I zO)tv85U)g(Y0#}iP3M`B6sP0miPU-HLTk_33o%SsYGV>ad7EWKWl}zP5LpwO)}lKt zZ)-|8+0#VeF2;C8JioKurV{(Hp3w(F4pYX9Evb#m#<6^QfI?^dwbpNeGfmXp~o_bHu-ipnWg}r3WNLUI_Le-YNCAH6h?WUYeqfk z;oxBCA9dEr(c9a*49wZ(l>ZK-CNr#Yf>xOzU!Dq8K9<7dP}ql)4ZV6NLky&dEf$*J zzl{(G1d2wB9gbYu@qyG7@p&;(HPA7S8uGagD$Mm*n)kIy+y?9E(bP_bq0QozPc1QH zjkDC4IKh9PxDLV0Afi(I0jO!cw=TDjKVt=6`8bAS7($y7ly&L1?&BxIDjz#w?lML` z6u1Rz+(p_khybmepsmBnd9}!Dxj;v$UWk*ZWbUiuVFUG?DuD@r7#2e&=rP!CQ301` zI67~D09shE;L`tiphTJBLjN*XTKCY|mGN5(wT9a(S1hQThB14a^}6kG9Tz2lC`^Yt(yhgvahfbM*q7oL-kV< z46QD+VzrG30}?69plnbCa#6cxTE{|<$Ef#08UcoOyC?1P<}ad* zoLu@uco;%%_llK`jaE0Wd~RRy)X3R^5Ai&|&ut)~$I5fUdWD*roa|Gm@cmnp*6tv7 zc6N7n_qn;bu&^+5qbkk9#t3HYqgPO2J^tYJEh@LJoYbbbGljYM_|QgMVllM~7FF@Z z#l>oA$k?|Z6kI_rqwb>NDi9(oy4;3|=v;)Vwj5+Mp*$U0qnM#I2=3z@+3EggJwUr3< zKqHu3dopD5$W%j^@-4~rgHNMTPvom*8poGh!ob|C*X%fl zKVL;I$?_`4uaq@#rRE!GB;fsLr{sBtX??m66aw8l&2Y@bu1BR~vb^IWx3@!ucxB{> z^>v2>Y>C3*=)F7433~%aF!E(I#Etb%%2#rtjRWlu;?+}zAGd%M%!e(#?TRQ$tLyL6 zs()HYJ9A(&MouDu59|E^i8H$r`1X(xJzrC;VaUYCS+LZl+=0Rx2W_Wkt-a;>oCAa0nc4sj^c$2=4(6IV z!7TV;ZDF2W;F>^Ssq}$U_c>w4X2pzAo_?M@eo075@e9+|VeJ!+II>*7Fp$l;z(_H`J^rbN=>{4$*N{*yFh$22e*{cT^!z{ zBAI+Cp$joaSDCQJcE;C+)RRNaJ~a1R#_f8SSK^oy8)J7)8R{vqbs6NmGLhfn++nCV z8XZ=!p!BEZIXB4&sIW`5<58|ka74`wJ;{TEpWTW+q%zuAA|2G)9y}}(t5B8hjMA6w zJK3`~(_kcEIir9%5&)wn>B0auy}i6de?RfU6Gr{XycDf|U23llv0i-rs?pnwr|mDNuQ1#j}{F zN}YkAR62rA&uU#y(pPm0<-<>hu)QOkl>8Jyr^zlj<;_DKQBhG${hDWSN^o#+RaMpU z@^WKiW8T^(gOpAXeZwAV>j+{F?W{8EH{GxVUUOsZ`hwoNSPQs|lhd>bP$R=iNM0=R zh!|#x)>WZt%nQ^y!!Cxg) zRIwK}(AU=&Evlc8kRa&)?c>_Y65=U`{+*8U*3~o$A4P|cKoQ9toO_h znF|h`BfEy)9msXgEqTWgyEjmJ5gEw8XU|}*ua|38P0jv9;8a{Ze0+Qy930%;OIw@x z%|j4e5CWbXbs9osoEtc8C+qOY^acOIh8-BE=-{VqJ^Hn#!vA5@r}9vLB|9_n*Xh_M zNfEc~@+==(ZPBZm0gnd zzBpBc&_G+Lt@^^C$l_vmcKgD-*e=CN?XL4kgwow7HNnaV!vya^2F!+K^bZs`ja9nv zj&INajFWjpK~^6^nEpBTAza#!p=tW`IRE8*>_J6F4IKf1kB`J-9QT`%jHFJh4Ca;P;^4?0EYTgy;Sdh?jMEdd9g>Z(CcX01SQX z!u?AMg%Ur*Xs*m9mhlphL8MUX=88n(k0nd#nz}!!$ZZZj^BRo$+e$t_W*a|xpNBMVC}KLe}0Q4BS5nxv}G%|~0)%a;_7kmGra6y2PqgOK8#62259=5ilpdKjUM_RQIZl0m+20$_WtEGf|eX-a6b(-3ekX6&e zC5rlqc9uOq2&FW7_eaoi)|mTynNp|68iLt@b!sfP6#-z9kfC_e`_ZlKRpJy>m#|zX zT&ub6t&@9I)TN}hU}eF^HI%PJN7iA-8E&W$ATI3`AL+=?6B-%*>3sx*bXAS{@$wR>*!`CWBM%Hm^hH18zVpj zbmtB{6YrxSaTWhi2TXXB*Lk3v+REZl5jr=$-Jfv@ps?rr3Ii*0sn-`Ggte47{8J1r_a14$;t@zXJ47NKAvH_o+5>_Pggeq;nNvZKQz{PDpiEe6p}Lk zrWwST!BiA7wc)i@pjHC&>U_0R@%Ax?$b}3f4y+D605;oj7N`kzawg(}e`)-Y{|gf< z+ld8MU(I9UO-rnJPft(STFdBZ`?%d+Lm;r@YlyMqSw|$O{Reg4x5`e%qy}hH7|FZliYk zw)71g0yqTpe0#RtA3N5@d(>g+`h|r@bv}g4_PytpT=K_KGeT;j!uWJ8NDV1rB~u)+ zrvm#we7HW}sh{pgZE-^Z8c>6if%snH?*X3{8bv5X4$WKFT>lzS9w|CIWbV*+H^m7v z<-cYLx|;skm7wALwInvI7OfMvINd9H-*>KEIgI0<(~YoCqQ9JOf)PUFzzBMtZfgke!lJavG#Gl`<)rL= z*G(fcFM-v7rLG}#zgQ$(C9#; zAbZSgl&vq9fmtw6!9K+0$&9T&?jJ82Vd%OC`W1NQF<;(8huyr2I~V~JKX#VnSo*$~ zmnlxLN1v1}pWnTq8#>n{BlCAJS;Pt27mCxZwt0-ACr0F|RD?(8jb=R$gLd|R^dfiW_rdO)o^FFi?~l;n0{&(GcJsbKv&ADG z72zqN$|)0F8E4*Do!qFF#MQ2^E%ZEvDQ}*g60{g6n99{Jr2$d8tuSksrcPH^=k6?% z6zU~)uhEKB^foSw&$Y-2%=KGJ#99?$pk z43G9#eEs^hv$NCssV5z)9RMU`(~#q>5hf@U%Gj8P^PCXo6z{p?(7=`{vFKa$ty_dp zVRL8`5xLO#;O$k)-Ec~x=C7qsx!jsZ5hK&Ad!0&4Dl&end|XmO4QMg+zD(QmcfSS- z;s%vaWPQtdONQ?>-)rmuq|MM8CkQ^$6$pxhq^ z^hxuGqT-4@*x$}^$6?r3!Ml_MSF^8|xoXcUvIEuReN|d4-}2+o4kNyZu zkF61v6gu?GM;xaMSQv6YT1`C$C%CPCwyZyl9VRXpj1-Gze<$~r7Oa&neVE*=66BBl zs1jNWm5-n0P*HvT;>B&h6)_#L!6xZRi{s}|>iC)P7s>{8>e{JgAqfs zH8X)JNlB7;=gao?D;ie1>+nZA5l*&}CY^VuNhvYxMf*D=oVN~8sZ?;_x|jBGf!#FH zr@+)VChR@iR1YRVwvSV#)2BdTJ|p{!Q-Fw4_82!`{O7Mo#KMF+6qv8iO1TM_h)Rb; zvh}HMBH)ePx+O$B$p+pp_DVs+CB`yabzs~OR_E*%D#DLk@#xO{7XjZZ;7V<7o&OrG zqJC}Wj-byKKfl!^d;H`L?;VBA=cxHgYF?Dp?uxp7TYKvR>Yq*-CCc8r<%q$NTg1}^ zczKuqRZN60 zZcq_{v*qN|k%Aghi8yuY-PM_5@Vf2WNJ=>cn9JHFlG+Vg+3I0!7}y8)A^Upjzc1+A z;cTIddSOPnslM+oMw>Y~IVskXNh|4UUWF%4my2^%apUmlnzcoG$wl3MP@OjMs9}ES z`(j0He%UbXb60*0XXo>flWh^|^g)aD^H=0^k5fa%k#+aq4n=eWKrCMqXI4il}N4tzjqds61@f3kD$OSK`f7FW~OOU z!Y3yuy-42rkT{M;2)FGp?6Zklu6fh zI7KF-^lP_ketX~*zZAkM%pJRXSHIBrFLyp|Gx^?7|KO<4ba9hl>{3>PHMK{fxBB>; zWh2)ByIhU%O5&jU(tx|E-*&GM$b@G6xkx<{-8z{(G~d04)!nY2YB|418I(>(k>94U zAAQb+QZH62v$iyqpOxUKe8KnCajirnj4TreIdPjzulLd+lHZ(hu8``@ zKayA_ZAEaY0kn7NqXzn4WN>buhNxx|?nwPu!Du=6(W})~w^_y^m#}X|b+kO&W2c2t z&h&R0J(Th5nHzl#znN?U(IGRIcqJqphkO* zLs91)U9%3^RIsJ7zUcT$%#nE8iFn0vFQXD-?|>&gzIn@h6=j&h3VOOv9? z_jk?**LZWYy;6RdQI)D4;#Z1P65H+}pEz5<#0H*YPELy+vRvKR=&F*;s@ClG;qSS6 z2V*m8yyq_C2;ao=n^drgZi-j4@y-FX$!v{;1RG8t*}cr<#44FYT2TK~OYmy*Y0_1f z!RiGfu}=U(JDa+Ta(~le9vvxHcWc#WTJ{$f*Be!ccw-!Cv08G}D3WFPYz%985L{x1 z4Gcoqm|5t;xUbxULxub9o#zL5_{sxezbe`30c0ddK>vU9f4>@Pl%e{en1a7K=pTRF z%?TtrH;tzU2)gPeXwyIVNT7cX^5X-No7KKc6oRdl(-!)tA#H4|txQ*LDh2hg!|z>z VMd@XQOhDkrK+i PlotBar: - """Create a PlotBar attached to a one-panel Figure.""" + """Create a PlotBar attached to a one-panel Figure (backward-compat call).""" if values is None: values = [1, 2, 3, 4, 5] fig, ax = apl.subplots(1, 1) @@ -59,15 +61,16 @@ def test_kind_is_bar(self): p = _make_bar() assert _state(p)["kind"] == "bar" - def test_values_stored_as_list(self): + def test_values_stored_as_2d(self): values = [10, 20, 30] p = _make_bar(values) - assert _state(p)["values"] == pytest.approx([10, 20, 30]) + # values are always stored as N×G (2-D) — [[v], [v], ...] for G=1 + assert _state(p)["values"] == pytest.approx(np.array([[10.0], [20.0], [30.0]])) def test_numpy_array_accepted(self): arr = np.array([1.0, 2.0, 3.0]) p = _make_bar(arr) - assert _state(p)["values"] == pytest.approx([1.0, 2.0, 3.0]) + assert _state(p)["values"] == pytest.approx(np.array([[1.0], [2.0], [3.0]])) def test_default_x_centers(self): p = _make_bar([5, 6, 7]) @@ -83,7 +86,7 @@ def test_default_baseline_is_zero(self): def test_default_bar_width(self): p = _make_bar() - assert _state(p)["bar_width"] == pytest.approx(0.7) + assert _state(p)["bar_width"] == pytest.approx(0.8) def test_default_show_values_false(self): p = _make_bar() @@ -106,18 +109,52 @@ def test_default_units_empty(self): assert _state(p)["units"] == "" assert _state(p)["y_units"] == "" + def test_default_groups_is_one(self): + p = _make_bar() + assert _state(p)["groups"] == 1 + + def test_default_log_scale_false(self): + p = _make_bar() + assert _state(p)["log_scale"] is False + # ───────────────────────────────────────────────────────────────────────────── -# 2. Construction – explicit arguments +# 2. Construction – explicit arguments (matplotlib-aligned) # ───────────────────────────────────────────────────────────────────────────── class TestPlotBarExplicitArgs: - def test_custom_x_centers(self): + def test_x_as_positions(self): + """bar(x, height) — x as numeric positions.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1, 2], [10, 20, 30]) + st = _state(p) + assert st["x_centers"] == pytest.approx([0.0, 1.0, 2.0]) + assert st["values"] == pytest.approx(np.array([[10.0], [20.0], [30.0]])) + + def test_x_as_string_labels(self): + """bar(['A','B','C'], height) — x as category strings.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar(["A", "B", "C"], [1, 2, 3]) + st = _state(p) + assert st["x_labels"] == ["A", "B", "C"] + assert st["x_centers"] == pytest.approx([0.0, 1.0, 2.0]) + + def test_width_parameter(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1, 2], [1, 2, 3], width=0.5) + assert _state(p)["bar_width"] == pytest.approx(0.5) + + def test_bottom_parameter(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1, 2], [1, 2, 3], bottom=5.0) + assert _state(p)["baseline"] == pytest.approx(5.0) + + def test_legacy_x_centers(self): p = _make_bar([1, 2, 3], x_centers=[10, 20, 30]) assert _state(p)["x_centers"] == pytest.approx([10.0, 20.0, 30.0]) - def test_custom_x_labels(self): + def test_legacy_x_labels(self): p = _make_bar([1, 2, 3], x_labels=["A", "B", "C"]) assert _state(p)["x_labels"] == ["A", "B", "C"] @@ -130,7 +167,7 @@ def test_custom_colors_list(self): p = _make_bar([1, 2, 3], colors=colors) assert _state(p)["bar_colors"] == colors - def test_custom_bar_width(self): + def test_legacy_bar_width(self): p = _make_bar(bar_width=0.5) assert _state(p)["bar_width"] == pytest.approx(0.5) @@ -138,7 +175,7 @@ def test_horizontal_orient(self): p = _make_bar(orient="h") assert _state(p)["orient"] == "h" - def test_custom_baseline(self): + def test_legacy_baseline(self): p = _make_bar(baseline=5.0) assert _state(p)["baseline"] == pytest.approx(5.0) @@ -159,12 +196,10 @@ def test_units_and_y_units(self): class TestPlotBarRange: def test_data_max_exceeds_max_value(self): - """data_max must include padding above the largest value.""" p = _make_bar([1, 2, 3, 4, 5]) assert _state(p)["data_max"] > 5.0 def test_data_min_at_baseline_for_positive_values(self): - """With all positive values and baseline=0, data_min <= 0.""" p = _make_bar([1, 2, 3, 4, 5], baseline=0.0) assert _state(p)["data_min"] <= 0.0 @@ -178,24 +213,135 @@ def test_data_max_gt_data_min(self): assert st["data_max"] > st["data_min"] def test_all_equal_values_padded(self): - """Equal values should still get a non-zero range via the fallback pad.""" p = _make_bar([5, 5, 5]) st = _state(p) assert st["data_max"] > st["data_min"] def test_baseline_above_all_values(self): - """When baseline > max(values), data_max should be >= baseline.""" p = _make_bar([1, 2, 3], baseline=10.0) assert _state(p)["data_max"] >= 10.0 def test_baseline_below_all_values(self): - """When baseline < min(values), data_min should be <= baseline.""" p = _make_bar([5, 6, 7], baseline=-5.0) assert _state(p)["data_min"] <= -5.0 # ───────────────────────────────────────────────────────────────────────────── -# 4. update() — value replacement +# 4. Grouped bars +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarGrouped: + + def test_2d_height_creates_groups(self): + """2-D height array (N, G) → groups == G.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar(["A", "B", "C"], [[1, 2], [3, 4], [5, 6]]) + st = _state(p) + assert st["groups"] == 2 + assert st["values"] == pytest.approx(np.array([[1, 2], [3, 4], [5, 6]])) + + def test_numpy_2d_height(self): + arr = np.array([[10, 20], [30, 40]]) + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1], arr) + assert _state(p)["groups"] == 2 + + def test_group_labels_stored(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(["A", "B"], [[1, 2], [3, 4]], group_labels=["G1", "G2"]) + assert _state(p)["group_labels"] == ["G1", "G2"] + + def test_group_colors_stored(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(["A", "B"], [[1, 2], [3, 4]], + group_colors=["#f00", "#0f0"]) + assert _state(p)["group_colors"] == ["#f00", "#0f0"] + + def test_default_group_colors_assigned_for_multi_group(self): + """Multi-group without explicit group_colors gets a default palette.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar(["A", "B"], [[1, 2], [3, 4]]) + gc = _state(p)["group_colors"] + assert len(gc) == 2 + assert all(c.startswith("#") for c in gc) + + def test_single_group_colors_empty_by_default(self): + """Ungrouped charts have empty group_colors (uses bar_color).""" + p = _make_bar([1, 2, 3]) + assert _state(p)["group_colors"] == [] + + def test_repr_shows_groups(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1], [[1, 2], [3, 4]]) + assert "groups=2" in repr(p) + + def test_update_2d_values(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(["A", "B"], [[1, 2], [3, 4]]) + p.update([[10, 20], [30, 40]]) + assert _state(p)["values"] == pytest.approx(np.array([[10, 20], [30, 40]])) + + def test_update_group_count_mismatch_raises(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(["A", "B"], [[1, 2], [3, 4]]) # groups=2 + with pytest.raises(ValueError, match="Group count mismatch"): + p.update([[1, 2, 3], [4, 5, 6]]) # 3 groups → error + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. Log scale +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarLogScale: + + def test_log_scale_flag_stored(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1, 2], [1, 10, 100], log_scale=True) + assert _state(p)["log_scale"] is True + + def test_log_scale_data_min_positive(self): + """data_min must be > 0 when log_scale=True.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1, 2], [1, 10, 100], log_scale=True) + assert _state(p)["data_min"] > 0.0 + + def test_log_scale_negative_values_clamped(self): + """Negative values do NOT raise; they are clamped for display.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1, 2], [-5, 10, 100], log_scale=True) + st = _state(p) + assert st["log_scale"] is True + assert st["data_min"] > 0.0 # clamped, not raised + + def test_log_scale_all_negative_clamped(self): + """All-negative values → data_min clamps to 1e-10.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1], [-3, -1], log_scale=True) + assert _state(p)["data_min"] > 0.0 + + def test_set_log_scale_enables(self): + p = _make_bar([1, 10, 100]) + p.set_log_scale(True) + st = _state(p) + assert st["log_scale"] is True + assert st["data_min"] > 0.0 + + def test_set_log_scale_disables(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1, 2], [1, 10, 100], log_scale=True) + p.set_log_scale(False) + assert _state(p)["log_scale"] is False + + def test_set_log_scale_push(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1, 2], [1, 10, 100]) + p.set_log_scale(True) + data = json.loads(getattr(fig, f"panel_{p._id}_json")) + assert data["log_scale"] is True + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. update() — value replacement # ───────────────────────────────────────────────────────────────────────────── class TestPlotBarUpdate: @@ -203,7 +349,7 @@ class TestPlotBarUpdate: def test_update_replaces_values(self): p = _make_bar([1, 2, 3]) p.update([10, 20, 30]) - assert _state(p)["values"] == pytest.approx([10.0, 20.0, 30.0]) + assert _state(p)["values"] == pytest.approx(np.array([[10.0], [20.0], [30.0]])) def test_update_recalculates_data_max(self): p = _make_bar([1, 2, 3]) @@ -220,6 +366,11 @@ def test_update_with_new_x_centers(self): p.update([4, 5, 6], x_centers=[0.5, 1.5, 2.5]) assert _state(p)["x_centers"] == pytest.approx([0.5, 1.5, 2.5]) + def test_update_with_new_x(self): + p = _make_bar([1, 2, 3]) + p.update([4, 5, 6], x=[0.5, 1.5, 2.5]) + assert _state(p)["x_centers"] == pytest.approx([0.5, 1.5, 2.5]) + def test_update_with_new_x_labels(self): p = _make_bar([1, 2, 3], x_labels=["a", "b", "c"]) p.update([4, 5, 6], x_labels=["x", "y", "z"]) @@ -235,14 +386,14 @@ def test_update_preserves_baseline(self): p.update([10, 20, 30]) assert _state(p)["baseline"] == pytest.approx(2.0) - def test_update_2d_raises(self): + def test_update_3d_raises(self): p = _make_bar([1, 2, 3]) - with pytest.raises(ValueError, match="1-D"): - p.update(np.array([[1, 2], [3, 4]])) + with pytest.raises(ValueError, match="1-D or 2-D"): + p.update(np.zeros((2, 2, 2))) # ───────────────────────────────────────────────────────────────────────────── -# 5. Display-setting mutations +# 7. Display-setting mutations # ───────────────────────────────────────────────────────────────────────────── class TestPlotBarDisplayMutations: @@ -269,13 +420,12 @@ def test_set_show_values_false(self): # ───────────────────────────────────────────────────────────────────────────── -# 6. _push() / Figure integration +# 8. _push() / Figure integration # ───────────────────────────────────────────────────────────────────────────── class TestPlotBarPush: def test_panel_trait_exists_after_attach(self): - """After ax.bar(), the Figure should have a panel trait for this plot.""" fig, ax = apl.subplots(1, 1) p = ax.bar([1, 2, 3]) trait_name = f"panel_{p._id}_json" @@ -294,7 +444,7 @@ def test_panel_json_values_after_update(self): p.update([7, 8, 9]) trait_name = f"panel_{p._id}_json" data = json.loads(getattr(fig, trait_name)) - assert data["values"] == pytest.approx([7.0, 8.0, 9.0]) + assert data["values"] == pytest.approx(np.array([[7.0], [8.0], [9.0]])) def test_panel_json_color_after_set_color(self): fig, ax = apl.subplots(1, 1) @@ -305,12 +455,10 @@ def test_panel_json_color_after_set_color(self): assert data["bar_color"] == "#112233" def test_push_without_figure_is_noop(self): - """PlotBar._push() before attachment must not raise.""" p = PlotBar([1, 2, 3]) - p._push() # should be a no-op (fig is None) + p._push() def test_layout_json_kind_bar(self): - """layout_json panel_specs should tag bar plots as 'bar'.""" fig, ax = apl.subplots(1, 1) p = ax.bar([10, 20, 30]) layout = json.loads(fig.layout_json) @@ -319,7 +467,7 @@ def test_layout_json_kind_bar(self): # ───────────────────────────────────────────────────────────────────────────── -# 7. Callback API +# 9. Callback API # ───────────────────────────────────────────────────────────────────────────── class TestPlotBarCallbacks: @@ -349,10 +497,12 @@ def test_on_click_fires(self): @p.on_click def cb(event): fired.append(event) - p.callbacks.fire(Event("on_click", p, {"bar_index": 2, "value": 3.0})) + p.callbacks.fire(Event("on_click", p, {"bar_index": 2, "value": 3.0, + "group_index": 0, "group_value": 3.0})) assert len(fired) == 1 - def test_on_click_event_data(self): + def test_on_click_event_data_with_group(self): + """on_click event carries group_index and group_value.""" p = _make_bar([10, 20, 30]) fired = [] @@ -361,12 +511,31 @@ def cb(event): fired.append(event) p.callbacks.fire(Event("on_click", p, {"bar_index": 1, "value": 20.0, + "group_index": 0, "group_value": 20.0, "x_center": 1.0, "x_label": "B"})) assert fired[0].bar_index == 1 assert fired[0].value == pytest.approx(20.0) + assert fired[0].group_index == 0 + assert fired[0].group_value == pytest.approx(20.0) assert fired[0].x_center == pytest.approx(1.0) assert fired[0].x_label == "B" + def test_on_click_grouped_event(self): + """group_index reflects which group was clicked.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar(["A", "B"], [[1, 10], [2, 20]]) + fired = [] + + @p.on_click + def cb(event): fired.append(event) + + p.callbacks.fire(Event("on_click", p, + {"bar_index": 1, "group_index": 1, + "value": 20.0, "group_value": 20.0, + "x_center": 1.0, "x_label": "B"})) + assert fired[0].group_index == 1 + assert fired[0].group_value == pytest.approx(20.0) + def test_on_changed_fires(self): p = _make_bar() fired = [] @@ -413,7 +582,7 @@ def cb2(event): log.append("b") # ───────────────────────────────────────────────────────────────────────────── -# 8. Edge cases +# 10. Edge cases # ───────────────────────────────────────────────────────────────────────────── class TestPlotBarEdgeCases: @@ -434,7 +603,7 @@ def test_all_negative_values(self): p = _make_bar([-5, -3, -1]) st = _state(p) assert st["data_min"] < -5.0 - assert st["data_max"] >= 0.0 # baseline is 0 + assert st["data_max"] >= 0.0 def test_mixed_positive_negative(self): p = _make_bar([-10, 0, 10]) @@ -444,14 +613,13 @@ def test_mixed_positive_negative(self): def test_float_values(self): p = _make_bar([1.1, 2.2, 3.3]) - assert _state(p)["values"] == pytest.approx([1.1, 2.2, 3.3]) + assert _state(p)["values"] == pytest.approx(np.array([[1.1], [2.2], [3.3]])) def test_x_centers_float(self): p = _make_bar([1, 2, 3], x_centers=[0.5, 1.5, 2.5]) assert _state(p)["x_centers"] == pytest.approx([0.5, 1.5, 2.5]) def test_bar_width_zero_boundary(self): - """bar_width of 0 should be stored without error.""" p = _make_bar(bar_width=0.0) assert _state(p)["bar_width"] == pytest.approx(0.0) @@ -461,21 +629,21 @@ def test_bar_width_one_boundary(self): # ───────────────────────────────────────────────────────────────────────────── -# 9. Validation errors +# 11. Validation errors # ───────────────────────────────────────────────────────────────────────────── class TestPlotBarValidation: - def test_2d_values_raises(self): - with pytest.raises(ValueError, match="1-D"): - PlotBar(np.array([[1, 2], [3, 4]])) + def test_3d_values_raises(self): + with pytest.raises(ValueError, match="1-D or 2-D"): + PlotBar(np.zeros((2, 2, 2))) def test_invalid_orient_raises(self): with pytest.raises(ValueError, match="orient"): PlotBar([1, 2, 3], orient="diagonal") def test_x_centers_length_mismatch_raises(self): - with pytest.raises(ValueError, match="x_centers length"): + with pytest.raises(ValueError, match="length"): PlotBar([1, 2, 3], x_centers=[0, 1]) def test_axes_bar_returns_plotbar_instance(self): @@ -485,7 +653,7 @@ def test_axes_bar_returns_plotbar_instance(self): # ───────────────────────────────────────────────────────────────────────────── -# 10. repr +# 12. repr # ───────────────────────────────────────────────────────────────────────────── class TestPlotBarRepr: @@ -506,3 +674,9 @@ def test_repr_is_string(self): p = _make_bar() assert isinstance(repr(p), str) + def test_repr_grouped_shows_groups(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([0, 1], [[1, 2], [3, 4]]) + assert "groups=2" in repr(p) + assert "n=2" in repr(p) + diff --git a/tests/test_visual.py b/tests/test_visual.py index 58d2d7d..1211bdf 100644 --- a/tests/test_visual.py +++ b/tests/test_visual.py @@ -196,6 +196,17 @@ def test_plot3d_surface(self, take_screenshot, update_baselines): arr = take_screenshot(fig) _check("plot3d_surface", arr, update_baselines) + # ── bar chart ────────────────────────────────────────────────────────── + + def test_bar_basic(self, take_screenshot, update_baselines): + """Basic vertical bar chart — exercises the bar renderer end-to-end.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.bar(["Jan", "Feb", "Mar", "Apr", "May"], + [42, 55, 48, 61, 37], + color="#4fc3f7") + arr = take_screenshot(fig) + _check("bar_basic", arr, update_baselines) + # ── multi-panel layout ───────────────────────────────────────────────── def test_subplots_2x1(self, take_screenshot, update_baselines): From d725fb7625c7ea49ce71dca6718e8b9478be68de Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 4 Apr 2026 09:25:25 -0500 Subject: [PATCH 04/11] Enhance benchmarking: update baselines, add new benchmarks for JS rendering, and improve documentation --- .github/workflows/benchmarks.yml | 78 ++++ Examples/Benchmarks/README.rst | 8 + .../Benchmarks/plot_benchmark_comparison.py | 393 ++++++++++++++++++ docs/benchmarking.rst | 54 +++ docs/index.rst | 24 ++ docs/performance.rst | 156 +++++++ pyproject.toml | 2 + tests/benchmarks/baselines.json | 336 ++++++++++----- tests/conftest.py | 68 ++- tests/test_benchmarks.py | 37 +- tests/test_benchmarks_py.py | 9 +- 11 files changed, 1047 insertions(+), 118 deletions(-) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 Examples/Benchmarks/README.rst create mode 100644 Examples/Benchmarks/plot_benchmark_comparison.py create mode 100644 docs/benchmarking.rst create mode 100644 docs/performance.rst diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..15a7c3b --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,78 @@ +name: Benchmarks + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: {} + +# Cancel in-flight runs for the same PR / branch. +concurrency: + group: benchmarks-${{ github.ref }} + cancel-in-progress: true + +jobs: + bench-py: + name: Python serialisation benchmarks + runs-on: ubuntu-latest + + steps: + - name: Check out HEAD + uses: actions/checkout@v4 + with: + path: head + + # For PRs compare against the target branch; for pushes compare against + # the previous commit. Skipped on the first push to a new branch + # (all-zero before SHA) — benchmark tests will skip automatically. + - name: Check out BASE + if: > + github.event_name == 'pull_request' || + (github.event_name == 'push' && + github.event.before != '0000000000000000000000000000000000000000') + uses: actions/checkout@v4 + with: + ref: >- + ${{ github.event_name == 'pull_request' + && github.base_ref + || github.event.before }} + path: base + continue-on-error: true + + - uses: astral-sh/setup-uv@v5 + + - name: Install base dependencies + if: hashFiles('base/pyproject.toml') != '' + run: cd base && uv sync + + - name: Install head dependencies + run: cd head && uv sync + + # Both steps run on the same runner so only the ratio matters — + # absolute ms differences from different hardware cancel out. + - name: Record base branch timings + if: hashFiles('base/pyproject.toml') != '' + run: | + cd base + uv run pytest tests/test_benchmarks_py.py \ + --update-benchmarks \ + --baselines-path /tmp/ci_baselines.json \ + -v + continue-on-error: true + + - name: Run benchmarks on HEAD + run: | + cd head + uv run pytest tests/test_benchmarks_py.py \ + --baselines-path /tmp/ci_baselines.json \ + -v + + - name: Upload timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: bench-py-${{ github.sha }} + path: /tmp/ci_baselines.json + if-no-files-found: ignore + diff --git a/Examples/Benchmarks/README.rst b/Examples/Benchmarks/README.rst new file mode 100644 index 0000000..fd905e3 --- /dev/null +++ b/Examples/Benchmarks/README.rst @@ -0,0 +1,8 @@ +Benchmarks +---------- + +Timing comparisons for the Python-side data-push pipeline in anyplotlib, +matplotlib, Plotly, and Bokeh. All measurements capture only the +**Python serialisation cost** — the bottleneck in a live Jupyter session +where new data must be encoded and dispatched to the browser on every frame. + diff --git a/Examples/Benchmarks/plot_benchmark_comparison.py b/Examples/Benchmarks/plot_benchmark_comparison.py new file mode 100644 index 0000000..4f7187e --- /dev/null +++ b/Examples/Benchmarks/plot_benchmark_comparison.py @@ -0,0 +1,393 @@ +""" +Library Update-Speed Comparison +================================ + +Times the **full Python-side cost** of pushing one data frame from Python to +the browser for four plotting libraries. Every measurement goes from "change +the data" to "Python has done everything it can before the browser takes over". + +.. note:: + + These are pure-Python ``timeit`` benchmarks — no browser is involved. + The goal is to isolate the CPU work that happens *before* bytes leave the + kernel. Browser render time (typically 1–20 ms) is additional for all + libraries. + +What each measurement covers +------------------------------ + ++---------------+---------------------------------------------------------------+ +| Library | What is timed | ++===============+===============================================================+ +| anyplotlib | ``plot.update(data)`` — float → uint8 normalise → base64 | +| | encode → LUT rebuild → state-dict assembly → json.dumps | ++---------------+---------------------------------------------------------------+ +| matplotlib | ``im.set_data(data); fig.canvas.draw()`` — marks data stale, | +| | then **fully rasterises** the figure to an Agg pixel buffer. | +| | This is equivalent to what ipympl does before sending a PNG | +| | over the comm channel. | ++---------------+---------------------------------------------------------------+ +| Plotly | ``fig.data[0].z = data.tolist(); fig.to_json()`` — builds the | +| | JSON blob that Plotly.js receives; every float becomes a | +| | decimal string. Plotly.js WebGL/SVG render is additional. | ++---------------+---------------------------------------------------------------+ +| Bokeh | ``source.data = {"image": [data]}; json_item(p)`` — builds | +| | the JSON document patch that Bokeh.js receives. Canvas | +| | render is additional. | ++---------------+---------------------------------------------------------------+ + +Skipping large Plotly / Bokeh sizes +------------------------------------- +Plotly and Bokeh are skipped for 2-D arrays larger than 512² because their +JSON float serialisation becomes impractically large (~10 MB for 1024² vs +anyplotlib's ~1.3 MB base64 blob). The skipped bars are marked ``—`` on the +chart. +""" +from __future__ import annotations + +import json +import pathlib +import timeit +import warnings + +import matplotlib +matplotlib.use("Agg") # must be set before pyplot import +import matplotlib.pyplot as plt +import numpy as np + +# --------------------------------------------------------------------------- +# Optional library imports — degrade gracefully if not installed +# --------------------------------------------------------------------------- + +try: + import plotly.graph_objects as _go + _HAS_PLOTLY = True +except ImportError: + _HAS_PLOTLY = False + warnings.warn("Plotly not installed — Plotly bars omitted.", stacklevel=1) + +try: + from bokeh.plotting import figure as _bk_figure + from bokeh.models import ColumnDataSource as _CDS + from bokeh.embed import json_item as _json_item + _HAS_BOKEH = True +except ImportError: + _HAS_BOKEH = False + warnings.warn("Bokeh not installed — Bokeh bars omitted.", stacklevel=1) + +import anyplotlib as apl + +# --------------------------------------------------------------------------- +# Timing helpers +# --------------------------------------------------------------------------- + +_REPEATS = 5 +_NUMBER = 3 + + +def _timeit_min_ms(stmt) -> float: + """Return the best (minimum) per-call time in milliseconds.""" + raw = timeit.repeat(stmt=stmt, number=_NUMBER, repeat=_REPEATS) + return min(t / _NUMBER * 1000 for t in raw) + + +# --------------------------------------------------------------------------- +# Benchmark configuration +# --------------------------------------------------------------------------- + +_SIZES_2D = [64, 256, 512, 1024, 2048] +_SKIP_ABOVE_2D = 512 # Plotly / Bokeh JSON size becomes untenable above this + +_SIZES_1D = [100, 1_000, 10_000, 100_000] + +rng = np.random.default_rng(42) + +# Pre-generate fixed frames so array creation is outside the timing loops. +_frames_2d = {s: rng.uniform(size=(s, s)).astype(np.float32) for s in _SIZES_2D} +_frames_1d = {n: np.cumsum(rng.standard_normal(n)).astype(np.float32) + for n in _SIZES_1D} + +_LIBRARIES = ["anyplotlib", "matplotlib", "plotly", "bokeh"] + +results_2d: dict[str, dict[int, float | None]] = {lib: {} for lib in _LIBRARIES} +results_1d: dict[str, dict[int, float | None]] = {lib: {} for lib in _LIBRARIES} + +# --------------------------------------------------------------------------- +# 2-D image benchmark +# --------------------------------------------------------------------------- + +for sz in _SIZES_2D: + data = _frames_2d[sz] + + # ── anyplotlib: normalize → uint8 → base64 → LUT → json push ──────────── + _fig_apl, _ax_apl = apl.subplots(1, 1, figsize=(min(sz, 640), min(sz, 640))) + _plot_apl = _ax_apl.imshow(data) + # Pre-generate update frames so creation cost is excluded. + _update_frames = [rng.uniform(size=(sz, sz)).astype(np.float32) + for _ in range(_NUMBER)] + _idx = [0] + + def _make_apl_update(plot, frames, idx): + def _fn(): + plot.update(frames[idx[0] % len(frames)]) + idx[0] += 1 + return _fn + + results_2d["anyplotlib"][sz] = _timeit_min_ms( + _make_apl_update(_plot_apl, _update_frames, _idx) + ) + + # ── matplotlib: set_data + full Agg rasterisation ─────────────────────── + _fig_mpl, _ax_mpl = plt.subplots() + _im_mpl = _ax_mpl.imshow(data, cmap="viridis") + _canvas_mpl = _fig_mpl.canvas + _new_mpl = rng.uniform(size=(sz, sz)).astype(np.float32) + + def _make_mpl_update(im, canvas, new_data): + def _fn(): + im.set_data(new_data) + canvas.draw() + return _fn + + results_2d["matplotlib"][sz] = _timeit_min_ms( + _make_mpl_update(_im_mpl, _canvas_mpl, _new_mpl) + ) + plt.close(_fig_mpl) + + # ── Plotly: assign z list + serialise to JSON ──────────────────────────── + if _HAS_PLOTLY and sz <= _SKIP_ABOVE_2D: + _pgo_fig = _go.Figure(_go.Heatmap(z=data.tolist())) + _new_plotly = rng.uniform(size=(sz, sz)).astype(np.float32).tolist() + + def _make_plotly_update(fig, new_z): + def _fn(): + fig.data[0].z = new_z + fig.to_json() + return _fn + + results_2d["plotly"][sz] = _timeit_min_ms( + _make_plotly_update(_pgo_fig, _new_plotly) + ) + else: + results_2d["plotly"][sz] = None + + # ── Bokeh: replace source.data + serialise full document ──────────────── + if _HAS_BOKEH and sz <= _SKIP_ABOVE_2D: + _bk_src = _CDS(data={"image": [data], "x": [0], "y": [0], + "dw": [sz], "dh": [sz]}) + _bk_plot = _bk_figure(width=400, height=400) + _bk_plot.image(image="image", x="x", y="y", dw="dw", dh="dh", + source=_bk_src, palette="Viridis256") + _new_bokeh = rng.uniform(size=(sz, sz)).astype(np.float32) + + def _make_bokeh_update(src, new_data, plot, w, h): + def _fn(): + src.data = {"image": [new_data], "x": [0], "y": [0], + "dw": [w], "dh": [h]} + _json_item(plot) + return _fn + + results_2d["bokeh"][sz] = _timeit_min_ms( + _make_bokeh_update(_bk_src, _new_bokeh, _bk_plot, sz, sz) + ) + else: + results_2d["bokeh"][sz] = None + +# --------------------------------------------------------------------------- +# 1-D line benchmark +# --------------------------------------------------------------------------- + +for n_pts in _SIZES_1D: + xs = np.arange(n_pts, dtype=np.float32) + ys = _frames_1d[n_pts] + + # ── anyplotlib ─────────────────────────────────────────────────────────── + _fig_apl1, _ax_apl1 = apl.subplots(1, 1, figsize=(640, 320)) + _plot_apl1 = _ax_apl1.plot(ys) + _new_ys_apl = rng.standard_normal(n_pts).cumsum().astype(np.float32) + + def _make_apl1d(plot, new_y): + def _fn(): plot.update(new_y) + return _fn + + results_1d["anyplotlib"][n_pts] = _timeit_min_ms( + _make_apl1d(_plot_apl1, _new_ys_apl) + ) + + # ── matplotlib ─────────────────────────────────────────────────────────── + _fig_mpl1, _ax_mpl1 = plt.subplots() + (_line_mpl,) = _ax_mpl1.plot(xs, ys) + _new_ys_mpl = rng.standard_normal(n_pts).cumsum().astype(np.float32) + + def _make_mpl1d(line, canvas, new_y): + def _fn(): + line.set_ydata(new_y) + canvas.draw() + return _fn + + results_1d["matplotlib"][n_pts] = _timeit_min_ms( + _make_mpl1d(_line_mpl, _fig_mpl1.canvas, _new_ys_mpl) + ) + plt.close(_fig_mpl1) + + # ── Plotly ─────────────────────────────────────────────────────────────── + if _HAS_PLOTLY: + _pgo_fig1 = _go.Figure(_go.Scatter(x=xs.tolist(), y=ys.tolist())) + _new_ys_plotly = rng.standard_normal(n_pts).cumsum().astype(np.float32).tolist() + + def _make_plotly1d(fig, new_y): + def _fn(): + fig.data[0].y = new_y + fig.to_json() + return _fn + + results_1d["plotly"][n_pts] = _timeit_min_ms( + _make_plotly1d(_pgo_fig1, _new_ys_plotly) + ) + else: + results_1d["plotly"][n_pts] = None + + # ── Bokeh ───────────────────────────────────────────────────────────────── + if _HAS_BOKEH: + _bk_src1 = _CDS(data={"x": xs.tolist(), "y": ys.tolist()}) + _bk_plot1 = _bk_figure(width=600, height=300) + _bk_plot1.line("x", "y", source=_bk_src1) + _new_ys_bokeh = rng.standard_normal(n_pts).cumsum().astype(np.float32).tolist() + + def _make_bokeh1d(src, plot, new_x, new_y): + def _fn(): + src.data = {"x": new_x, "y": new_y} + _json_item(plot) + return _fn + + results_1d["bokeh"][n_pts] = _timeit_min_ms( + _make_bokeh1d(_bk_src1, _bk_plot1, xs.tolist(), _new_ys_bokeh) + ) + else: + results_1d["bokeh"][n_pts] = None + +# --------------------------------------------------------------------------- +# Chart helpers +# --------------------------------------------------------------------------- + +_COLORS = { + "anyplotlib": "#1976D2", + "matplotlib": "#E64A19", + "plotly": "#7B1FA2", + "bokeh": "#2E7D32", +} + +# Human-readable description of what each measurement covers (for the legend). +_LABELS = { + "anyplotlib": "anyplotlib (float→uint8→b64→json)", + "matplotlib": "matplotlib (set_data + Agg render)", + "plotly": "Plotly (z=list + to_json)", + "bokeh": "Bokeh (source.data + json_item)", +} + + +def _grouped_bar(ax, sizes, results, size_labels, title, ylabel, + skip_note=None): + """Draw a grouped bar chart on *ax* (log-scale Y).""" + n_sizes = len(sizes) + n_libs = len(_LIBRARIES) + width = 0.78 / n_libs + x = np.arange(n_sizes) + + for i, lib in enumerate(_LIBRARIES): + vals = [results[lib].get(s) for s in sizes] + color = _COLORS[lib] + offset = (i - (n_libs - 1) / 2) * width + + present = [(j, v) for j, v in enumerate(vals) if v is not None] + missing = [j for j, v in enumerate(vals) if v is None] + + if present: + jj, vv = zip(*present) + bars = ax.bar( + [x[j] + offset for j in jj], + vv, + width=width * 0.88, + label=_LABELS[lib], + color=color, + alpha=0.88, + zorder=3, + ) + for bar, v in zip(bars, vv): + label_str = f"{v:.2f}" if v < 10 else f"{v:.1f}" + ax.text( + bar.get_x() + bar.get_width() / 2, + bar.get_height() * 1.18, + label_str, + ha="center", va="bottom", + fontsize=6, color=color, fontweight="bold", + ) + + for j in missing: + ax.text( + x[j] + offset, ax.get_ylim()[0] if ax.get_yscale() == "log" else 0, + "n/a", + ha="center", va="bottom", + fontsize=6, color=color, alpha=0.55, style="italic", + ) + + ax.set_yscale("log") + ax.set_xticks(x) + ax.set_xticklabels(size_labels, fontsize=9) + ax.set_xlabel("Array size", fontsize=9) + ax.set_ylabel(ylabel, fontsize=9) + ax.set_title(title, fontsize=10, pad=8) + ax.legend(fontsize=7.5, loc="upper left") + ax.grid(axis="y", linestyle="--", alpha=0.35, zorder=0) + + if skip_note: + ax.text(0.99, 0.02, skip_note, transform=ax.transAxes, + fontsize=7, ha="right", va="bottom", color="#666", + style="italic") + + +# --------------------------------------------------------------------------- +# Figure 1 — 2-D image update +# --------------------------------------------------------------------------- + +fig2d, ax2d = plt.subplots(figsize=(10, 5.5), layout="constrained") +_grouped_bar( + ax2d, + sizes=_SIZES_2D, + results=results_2d, + size_labels=[f"{s}²" for s in _SIZES_2D], + title="2-D image update — full Python-side cost (lower is better)", + ylabel="time per call (ms, log scale)", + skip_note=( + "Plotly / Bokeh omitted above 512²:\n" + "1024² JSON payload ≈ 10 MB vs anyplotlib ≈ 1.3 MB base64" + ), +) +fig2d.tight_layout(pad=1.2) +plt.show() + +# %% +# 1-D line update comparison +# -------------------------- +# +# For 1-D line plots anyplotlib currently serialises arrays via +# ``array.tolist()`` (plain JSON floats) — the same path as Plotly and Bokeh — +# so the costs are comparable at all sizes. anyplotlib's advantage is +# concentrated in the 2-D image path where uint8 base64 encoding gives a +# dramatically smaller payload and eliminates the per-float text conversion. + +fig1d, ax1d = plt.subplots(figsize=(10, 5.5), layout="constrained") +_grouped_bar( + ax1d, + sizes=_SIZES_1D, + results=results_1d, + size_labels=[f"{n:,}" for n in _SIZES_1D], + title="1-D line update — full Python-side cost (lower is better)", + ylabel="time per call (ms, log scale)", +) +fig1d.tight_layout(pad=1.2) +plt.show() + + + + diff --git a/docs/benchmarking.rst b/docs/benchmarking.rst new file mode 100644 index 0000000..20c398d --- /dev/null +++ b/docs/benchmarking.rst @@ -0,0 +1,54 @@ +.. _benchmarking: + +Benchmarking +============ + +anyplotlib ships two benchmark suites that cover the Python serialisation +pipeline (``tests/test_benchmarks_py.py``) and JS render performance via +headless Chromium (``tests/test_benchmarks.py``). + +Running locally +--------------- + +.. code-block:: bash + + # Python suite — fast (~30 s), no browser needed + uv run pytest tests/test_benchmarks_py.py -v + + # JS suite — requires Playwright + Chromium (~3 min) + uv run pytest tests/test_benchmarks.py -v + + # Add 4 096² / 8 192² image scenarios (slow, opt-in) + uv run pytest tests/test_benchmarks_py.py --run-slow -v + +Updating committed baselines +----------------------------- + +Run ``--update-benchmarks`` after an intentional performance change to +refresh ``tests/benchmarks/baselines.json``: + +.. code-block:: bash + + uv run pytest tests/test_benchmarks_py.py tests/test_benchmarks.py \ + --update-benchmarks -v + +Commit the result so local runs and the performance table stay accurate. + +CI — hardware-agnostic comparison +---------------------------------- + +Absolute ms values are meaningless across different runners, so CI never +compares against the committed baselines file. Instead it checks out both +the base branch and the head branch **on the same runner in the same job** +and compares them — only the ratio matters, hardware differences cancel out. + +.. code-block:: text + + 1. Checkout BASE → pytest --update-benchmarks --baselines-path /tmp/ci_baselines.json + 2. Checkout HEAD → pytest --baselines-path /tmp/ci_baselines.json + Pass / fail is based on the ratio, not absolute ms. + +The ``--baselines-path`` flag redirects reads and writes without touching +the committed ``tests/benchmarks/baselines.json``. + +Threshold: **1.50 ×** (50 % slower → failure, 25 % → warning). diff --git a/docs/index.rst b/docs/index.rst index 3851b3f..e449d93 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,6 +41,28 @@ blitting instead of SVG. Gallery of short, self-contained examples showing 1-D signals, 2-D images, 3-D surfaces, bar charts, interactive widgets, and more. + .. grid-item-card:: + :link: performance + :link-type: doc + + :octicon:`graph;2em;sd-text-info` Performance + ^^^ + + Why anyplotlib is fast: compact binary encoding, browser-side LUT + colormapping, canvas blitting, and incremental traitlet pushes — + plus an honest look at current limitations. + + .. grid-item-card:: + :link: benchmarking + :link-type: doc + + :octicon:`tools;2em;sd-text-info` Benchmarking + ^^^ + + Developer guide: running the Python and JS benchmark suites, updating + baselines, best practices, and the CI strategy that makes timing + comparisons hardware-agnostic. + .. toctree:: :hidden: :maxdepth: 2 @@ -48,6 +70,8 @@ blitting instead of SVG. getting_started api/index auto_examples/index + performance + benchmarking * :ref:`genindex` * :ref:`modindex` diff --git a/docs/performance.rst b/docs/performance.rst new file mode 100644 index 0000000..eeda5a4 --- /dev/null +++ b/docs/performance.rst @@ -0,0 +1,156 @@ +.. _performance: + +Performance +=========== + +anyplotlib is designed for **real-time data updates** in a live Jupyter session. +This page explains the architectural decisions that make per-frame updates fast, +where the remaining costs sit, and where the current limitations are. + +For measured timings see the :doc:`auto_examples/Benchmarks/index` gallery. + +---- + +Why anyplotlib is fast +----------------------- + +.. rubric:: (a) Compact binary encoding: float → uint8 → base64 + +Colormapped 2-D data is reduced from 64-bit floats to **8-bit palette indices** +before it leaves Python. A 1 024² image goes from a 4 MB float32 array to a +1 MB uint8 array; base64-encoding that produces a **~1.3 MB ASCII blob** — +roughly the same size as a JPEG thumbnail. + +Plotly and Bokeh serialise every floating-point sample as a JSON decimal +string, typically 8–12 characters each. For a 1 024² heatmap that is +**~10 MB of JSON** versus anyplotlib's 1.3 MB base64 string — nearly an 8× +difference before the browser receives a single byte. + ++------------------+-------------------------------+----------------------+ +| Library | 1 024² payload format | Approx. size | ++==================+===============================+======================+ +| anyplotlib | base64(uint8 raw pixels) | ~1.3 MB | ++------------------+-------------------------------+----------------------+ +| Plotly | JSON float array | ~10 MB | ++------------------+-------------------------------+----------------------+ +| Bokeh | JSON float array (json_item) | ~10 MB | ++------------------+-------------------------------+----------------------+ +| matplotlib/ipympl| PNG buffer (lossless) | ~0.5–2 MB (variable) | ++------------------+-------------------------------+----------------------+ + +.. rubric:: (b) LUT colormapping in the browser (``_buildLut32``) + +Python never converts uint8 indices to RGB triples. +Instead, the colormap is serialised once as a compact 256-entry lookup table +(``state["colormap_data"]``). The browser function ``_buildLut32`` (line 471 +of ``figure_esm.js``) expands each index to an RGBA ``Uint32`` in a tight +256-iteration loop, then hands the resulting ``Uint8ClampedArray`` directly to +``createImageBitmap``. + +This means colormap changes and display-range tweaks (``vmin`` / ``vmax``) +are **free from Python's perspective** — only the 256-entry LUT array changes, +not the pixel payload. + +.. rubric:: (c) Canvas blitting — pan and zoom without re-serialisation + +Once a frame has been decoded into an ``ImageBitmap`` it is cached in +``p.blitCache``. Pan and zoom operations (``_blit2d``, lines 518–539) call +``ctx.drawImage(bitmap, srcX, srcY, visW, visH, destX, destY, destW, destH)`` +— a single GPU-accelerated compositing call — without touching Python at all. + +Only when the underlying data array changes (a new ``plot.update()`` call) +does Python re-encode and push a new base64 string. For interactive +exploration of a fixed dataset the marginal per-frame cost from Python is +**zero**. + +.. rubric:: (d) No SVG / DOM per point overhead + +Plotly's scatter and heatmap renderers build SVG ```` elements or +WebGL buffers from the JSON payload. Every zoom or pan event that triggers a +re-render reconstructs DOM nodes. anyplotlib's canvas renderer has a fixed +DOM: two ```` layers (plot + overlay) per panel, regardless of dataset +size. For large 1-D datasets the ``draw1d`` function (line 1 260 of +``figure_esm.js``) iterates over pre-sent coordinates in a tight +``ctx.lineTo`` loop with no heap allocation per point. + +.. rubric:: (e) Incremental traitlet pushes + +anyplotlib uses ``anywidget``'s ``sync=True`` traitlets. Only changed state +fields are serialised into the ``panel_{id}_json`` traitlet on each push. +A pan/zoom event updates only ``center_x``, ``center_y``, and ``zoom`` — the +``image_b64`` blob is unchanged and is not re-transmitted. + +---- + +Python → JS pipeline stages +---------------------------- + +The table below shows typical costs on an Apple M1 Air (from +``tests/benchmarks/baselines.json``): + ++---------------------------------------------------+-------+--------+--------+--------+---------+ +| Stage | 64² | 256² | 512² | 1024² | 2048² | ++===================================================+=======+========+========+========+=========+ +| ``_normalize_image`` (NumPy cast + scale + uint8) | 0.013 | 0.091 | 0.577 | 3.85 | 29.67 | ++---------------------------------------------------+-------+--------+--------+--------+---------+ +| ``_encode_bytes`` (base64) | 0.007 | 0.098 | 0.451 | 2.21 | 11.28 | ++---------------------------------------------------+-------+--------+--------+--------+---------+ +| ``json.dumps(to_state_dict())`` | 0.081 | 0.266 | 0.875 | 3.16 | 12.93 | ++---------------------------------------------------+-------+--------+--------+--------+---------+ +| **``plot.update()`` (full round-trip)** | 0.219 | 0.646 | 2.36 | 9.23 | 36.11 | ++---------------------------------------------------+-------+--------+--------+--------+---------+ + +All timings in **milliseconds (min over 15 calls)**. The ``update()`` row +includes all stages above plus ``_build_colormap_lut`` and traitlet dispatch. + +For a 512² image anyplotlib completes a full Python-side update in **~2.4 ms** + + +---- + +Limitations +----------- + +.. rubric:: 1-D serialisation uses JSON float arrays + +The current ``Plot1D.to_state_dict()`` converts x/y arrays with +``array.tolist()`` before ``json.dumps``. This is the same O(N × bytes-per-float) +cost as Plotly and Bokeh. For 100 000-point line plots the round-trip is +**~7 ms** — acceptable for interactive drag events but not for high-frequency +streaming. + +A future ``uint16``-quantised path (similar to the 2-D uint8 encoding) would +reduce 1-D payload sizes by ~4× and bring the serialisation cost below 2 ms +for 100 k points. + +.. rubric:: 2-D LUT resolution is 8 bit (256 colours) + +The uint8 encoding maps each float to one of 256 palette entries. +For scientific visualisation at display resolution this is indistinguishable +from a full 64-bit render, but histogram-equalisation or very shallow +gradients may show banding if ``vmin`` / ``vmax`` clip a narrow data range. +Use ``display_min`` / ``display_max`` to focus the LUT on the region of +interest. + +.. rubric:: No GPU-side streaming + +anyplotlib does not use ``OffscreenCanvas`` or ``ImageData`` worker threads. +All pixel expansion (LUT → RGBA) happens on the main browser thread via +``_buildLut32``. For arrays larger than ~4 096² this becomes perceptible +(> 16 ms frame budget). The ``--run-slow`` test flag covers those sizes +explicitly. + +---- + +Benchmark gallery +----------------- + +.. toctree:: + :hidden: + + auto_examples/Benchmarks/index + +See the :doc:`auto_examples/Benchmarks/index` gallery for live-timed +comparisons of anyplotlib, matplotlib, Plotly, and Bokeh across a range +of 2-D image sizes and 1-D line lengths. + diff --git a/pyproject.toml b/pyproject.toml index 26d4f2f..2780238 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ docs = [ "pillow>=10.0", "matplotlib>=3.7", "playwright>=1.58.0", + "plotly>=5.0", + "bokeh>=3.0", ] jupyter = [ "jupyterlab>=4.5.5", diff --git a/tests/benchmarks/baselines.json b/tests/benchmarks/baselines.json index 1da1476..7edcc84 100644 --- a/tests/benchmarks/baselines.json +++ b/tests/benchmarks/baselines.json @@ -3,175 +3,319 @@ "note": "Run `uv run pytest tests/test_benchmarks.py tests/test_benchmarks_py.py --update-benchmarks` to regenerate on this machine. Headless-Chrome timings are machine-specific; regenerate when switching hardware or CI runners.", "regression_threshold_js": 1.5, "regression_threshold_py": 1.3, - "updated_at": "2026-04-03T16:38:30.589805+00:00", + "updated_at": "2026-04-04T13:16:01.566327+00:00", "host": "Carters-MacBook-Air.local" }, "py_normalize_64x64": { "min_ms": 0.013, - "mean_ms": 0.018, - "max_ms": 0.037, + "mean_ms": 0.037, + "max_ms": 0.134, "n": 15, - "updated_at": "2026-04-03T16:38:27.435525+00:00" + "updated_at": "2026-04-04T13:15:58.956545+00:00" }, "py_encode_64x64": { - "min_ms": 0.007, - "mean_ms": 0.009, - "max_ms": 0.019, + "min_ms": 0.006, + "mean_ms": 0.007, + "max_ms": 0.011, "n": 15, - "updated_at": "2026-04-03T16:38:28.188689+00:00" + "updated_at": "2026-04-04T13:15:59.287130+00:00" }, "py_serialize_2d_64x64": { - "min_ms": 0.081, - "mean_ms": 0.085, - "max_ms": 0.099, + "min_ms": 0.074, + "mean_ms": 0.075, + "max_ms": 0.078, "n": 15, - "updated_at": "2026-04-03T16:38:29.062902+00:00" + "updated_at": "2026-04-04T13:15:59.653130+00:00" }, "py_serialize_1d_100pts": { - "min_ms": 0.013, - "mean_ms": 0.015, - "max_ms": 0.018, + "min_ms": 0.014, + "mean_ms": 0.027, + "max_ms": 0.071, "n": 15, - "updated_at": "2026-04-03T16:38:29.461326+00:00" + "updated_at": "2026-04-04T13:16:00.033677+00:00" }, "py_serialize_1d_1000pts": { - "min_ms": 0.076, - "mean_ms": 0.117, - "max_ms": 0.174, + "min_ms": 0.072, + "mean_ms": 0.13, + "max_ms": 0.204, "n": 15, - "updated_at": "2026-04-03T16:38:29.466205+00:00" + "updated_at": "2026-04-04T13:16:00.039841+00:00" }, "py_serialize_1d_10000pts": { - "min_ms": 0.638, - "mean_ms": 0.68, - "max_ms": 0.777, + "min_ms": 0.658, + "mean_ms": 0.808, + "max_ms": 1.267, "n": 15, - "updated_at": "2026-04-03T16:38:29.480053+00:00" + "updated_at": "2026-04-04T13:16:00.058898+00:00" }, "py_serialize_1d_100000pts": { - "min_ms": 7.214, - "mean_ms": 7.736, - "max_ms": 9.204, + "min_ms": 16.038, + "mean_ms": 22.742, + "max_ms": 34.052, "n": 15, - "updated_at": "2026-04-03T16:38:29.606929+00:00" + "updated_at": "2026-04-04T13:16:00.474681+00:00" }, "py_update_2d_64x64": { - "min_ms": 0.219, - "mean_ms": 0.272, - "max_ms": 0.316, + "min_ms": 0.473, + "mean_ms": 0.767, + "max_ms": 1.102, "n": 15, - "updated_at": "2026-04-03T16:38:29.614348+00:00" + "updated_at": "2026-04-04T13:16:00.499970+00:00" }, "py_normalize_256x256": { - "min_ms": 0.091, - "mean_ms": 0.107, - "max_ms": 0.145, + "min_ms": 0.084, + "mean_ms": 0.102, + "max_ms": 0.128, "n": 15, - "updated_at": "2026-04-03T16:38:27.439965+00:00" + "updated_at": "2026-04-04T13:15:58.960018+00:00" }, "py_normalize_512x512": { - "min_ms": 0.577, - "mean_ms": 0.918, - "max_ms": 1.466, + "min_ms": 0.569, + "mean_ms": 0.683, + "max_ms": 0.829, "n": 15, - "updated_at": "2026-04-03T16:38:27.456398+00:00" + "updated_at": "2026-04-04T13:15:58.973101+00:00" }, "py_normalize_1024x1024": { - "min_ms": 3.85, - "mean_ms": 4.543, - "max_ms": 5.06, + "min_ms": 2.941, + "mean_ms": 3.642, + "max_ms": 4.4, "n": 15, - "updated_at": "2026-04-03T16:38:27.533986+00:00" + "updated_at": "2026-04-04T13:15:59.035741+00:00" }, "py_normalize_2048x2048": { - "min_ms": 29.673, - "mean_ms": 40.138, - "max_ms": 66.704, + "min_ms": 12.626, + "mean_ms": 14.284, + "max_ms": 17.004, "n": 15, - "updated_at": "2026-04-03T16:38:28.184207+00:00" + "updated_at": "2026-04-04T13:15:59.283335+00:00" }, "py_encode_256x256": { - "min_ms": 0.098, - "mean_ms": 0.101, - "max_ms": 0.103, + "min_ms": 0.107, + "mean_ms": 0.115, + "max_ms": 0.127, "n": 15, - "updated_at": "2026-04-03T16:38:28.192720+00:00" + "updated_at": "2026-04-04T13:15:59.290355+00:00" }, "py_encode_512x512": { - "min_ms": 0.451, - "mean_ms": 0.636, - "max_ms": 0.813, + "min_ms": 0.394, + "mean_ms": 0.434, + "max_ms": 0.485, "n": 15, - "updated_at": "2026-04-03T16:38:28.205736+00:00" + "updated_at": "2026-04-04T13:15:59.299651+00:00" }, "py_encode_1024x1024": { - "min_ms": 2.208, - "mean_ms": 2.73, - "max_ms": 3.427, + "min_ms": 1.563, + "mean_ms": 1.688, + "max_ms": 1.786, "n": 15, - "updated_at": "2026-04-03T16:38:28.259917+00:00" + "updated_at": "2026-04-04T13:15:59.335735+00:00" }, "py_encode_2048x2048": { - "min_ms": 11.281, - "mean_ms": 27.22, - "max_ms": 48.466, + "min_ms": 8.085, + "mean_ms": 9.094, + "max_ms": 10.714, "n": 15, - "updated_at": "2026-04-03T16:38:28.794089+00:00" + "updated_at": "2026-04-04T13:15:59.516158+00:00" }, "py_serialize_2d_256x256": { - "min_ms": 0.266, - "mean_ms": 0.374, - "max_ms": 0.607, + "min_ms": 0.25, + "mean_ms": 0.253, + "max_ms": 0.256, "n": 15, - "updated_at": "2026-04-03T16:38:29.072522+00:00" + "updated_at": "2026-04-04T13:15:59.659835+00:00" }, "py_serialize_2d_512x512": { - "min_ms": 0.875, - "mean_ms": 0.959, - "max_ms": 1.083, + "min_ms": 0.747, + "mean_ms": 0.771, + "max_ms": 0.834, "n": 15, - "updated_at": "2026-04-03T16:38:29.095913+00:00" + "updated_at": "2026-04-04T13:15:59.676620+00:00" }, "py_serialize_2d_1024x1024": { - "min_ms": 3.16, - "mean_ms": 3.425, - "max_ms": 3.863, + "min_ms": 2.803, + "mean_ms": 3.032, + "max_ms": 3.296, "n": 15, - "updated_at": "2026-04-03T16:38:29.171313+00:00" + "updated_at": "2026-04-04T13:15:59.737870+00:00" }, "py_serialize_2d_2048x2048": { - "min_ms": 12.925, - "mean_ms": 13.36, - "max_ms": 14.065, + "min_ms": 11.427, + "mean_ms": 14.907, + "max_ms": 23.513, "n": 15, - "updated_at": "2026-04-03T16:38:29.457613+00:00" + "updated_at": "2026-04-04T13:16:00.020751+00:00" }, "py_update_2d_256x256": { - "min_ms": 0.646, - "mean_ms": 0.772, - "max_ms": 0.919, + "min_ms": 1.593, + "mean_ms": 4.177, + "max_ms": 10.228, "n": 15, - "updated_at": "2026-04-03T16:38:29.631014+00:00" + "updated_at": "2026-04-04T13:16:00.592322+00:00" }, "py_update_2d_512x512": { - "min_ms": 2.358, - "mean_ms": 2.724, - "max_ms": 3.218, + "min_ms": 5.114, + "mean_ms": 6.513, + "max_ms": 7.349, "n": 15, - "updated_at": "2026-04-03T16:38:29.682280+00:00" + "updated_at": "2026-04-04T13:16:00.715109+00:00" }, "py_update_2d_1024x1024": { - "min_ms": 9.234, - "mean_ms": 10.334, - "max_ms": 11.787, + "min_ms": 8.407, + "mean_ms": 8.954, + "max_ms": 10.195, "n": 15, - "updated_at": "2026-04-03T16:38:29.874306+00:00" + "updated_at": "2026-04-04T13:16:00.916898+00:00" }, "py_update_2d_2048x2048": { - "min_ms": 36.108, - "mean_ms": 38.114, - "max_ms": 43.568, + "min_ms": 31.763, + "mean_ms": 34.136, + "max_ms": 38.112, "n": 15, - "updated_at": "2026-04-03T16:38:30.589797+00:00" + "updated_at": "2026-04-04T13:16:01.566321+00:00" + }, + "js_imshow_64x64": { + "mean_ms": 18.97, + "min_ms": 15.2, + "max_ms": 58.3, + "fps": 52.72, + "n": 19, + "updated_at": "2026-04-04T12:47:13.253218+00:00" + }, + "js_imshow_256x256": { + "mean_ms": 18.95, + "min_ms": 15.8, + "max_ms": 58.6, + "fps": 52.77, + "n": 19, + "updated_at": "2026-04-04T12:47:13.688653+00:00" + }, + "js_imshow_512x512": { + "mean_ms": 18.69, + "min_ms": 9.5, + "max_ms": 53.8, + "fps": 53.49, + "n": 19, + "updated_at": "2026-04-04T12:47:14.123016+00:00" + }, + "js_imshow_1024x1024": { + "mean_ms": 19.03, + "min_ms": 10.8, + "max_ms": 59.6, + "fps": 52.55, + "n": 19, + "updated_at": "2026-04-04T12:47:14.670938+00:00" + }, + "js_imshow_2048x2048": { + "mean_ms": 40.38, + "min_ms": 27.4, + "max_ms": 88.4, + "fps": 24.77, + "n": 19, + "updated_at": "2026-04-04T12:47:15.724187+00:00" + }, + "js_plot1d_100pts": { + "mean_ms": 19.1, + "min_ms": 15.4, + "max_ms": 60.6, + "fps": 52.36, + "n": 19, + "updated_at": "2026-04-04T12:47:16.152250+00:00" + }, + "js_plot1d_1000pts": { + "mean_ms": 18.88, + "min_ms": 15.8, + "max_ms": 56.6, + "fps": 52.96, + "n": 19, + "updated_at": "2026-04-04T12:47:16.569090+00:00" + }, + "js_plot1d_10000pts": { + "mean_ms": 19.44, + "min_ms": 15.4, + "max_ms": 68.4, + "fps": 51.43, + "n": 19, + "updated_at": "2026-04-04T12:47:17.002364+00:00" + }, + "js_plot1d_100000pts": { + "mean_ms": 100.53, + "min_ms": 82.7, + "max_ms": 238.2, + "fps": 9.95, + "n": 19, + "updated_at": "2026-04-04T12:47:19.033984+00:00" + }, + "js_pcolormesh_32x32": { + "mean_ms": 18.67, + "min_ms": 16, + "max_ms": 52.7, + "fps": 53.56, + "n": 19, + "updated_at": "2026-04-04T12:47:19.469923+00:00" + }, + "js_pcolormesh_128x128": { + "mean_ms": 18.82, + "min_ms": 15.7, + "max_ms": 55.6, + "fps": 53.14, + "n": 19, + "updated_at": "2026-04-04T12:47:19.902060+00:00" + }, + "js_pcolormesh_256x256": { + "mean_ms": 18.82, + "min_ms": 15.8, + "max_ms": 55.5, + "fps": 53.14, + "n": 19, + "updated_at": "2026-04-04T12:47:20.318742+00:00" + }, + "js_plot3d_48x48": { + "mean_ms": 19.48, + "min_ms": 15.7, + "max_ms": 68.4, + "fps": 51.33, + "n": 19, + "updated_at": "2026-04-04T12:47:20.752565+00:00" + }, + "js_bar_10bars": { + "mean_ms": 18.88, + "min_ms": 15.7, + "max_ms": 56.6, + "fps": 52.96, + "n": 19, + "updated_at": "2026-04-04T12:47:21.168808+00:00" + }, + "js_bar_100bars": { + "mean_ms": 19.08, + "min_ms": 15.6, + "max_ms": 60, + "fps": 52.4, + "n": 19, + "updated_at": "2026-04-04T12:47:21.585951+00:00" + }, + "js_interaction_2d_pan": { + "mean_ms": 3.11, + "min_ms": 1.1, + "max_ms": 11.6, + "fps": 322.05, + "n": 60, + "updated_at": "2026-04-04T12:47:22.406765+00:00" + }, + "js_interaction_2d_zoom": { + "mean_ms": 9.41, + "min_ms": 1.1, + "max_ms": 44.5, + "fps": 106.27, + "n": 60, + "updated_at": "2026-04-04T12:47:23.653540+00:00" + }, + "js_interaction_1d_pan": { + "mean_ms": 2.24, + "min_ms": 0.2, + "max_ms": 14.7, + "fps": 445.96, + "n": 60, + "updated_at": "2026-04-04T12:47:24.404045+00:00" } } \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index b570b40..822cf60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,6 +46,18 @@ def pytest_addoption(parser): default=False, help="Include slow benchmark scenarios (4096², 8192² images) skipped in fast CI", ) + parser.addoption( + "--baselines-path", + default=None, + metavar="PATH", + help=( + "Override the path used to read/write benchmark baselines " + "(default: tests/benchmarks/baselines.json). " + "Use this in CI to keep the committed developer baselines untouched: " + "run the base branch with --update-benchmarks --baselines-path /tmp/ci_baselines.json, " + "then run the head branch with --baselines-path /tmp/ci_baselines.json." + ), + ) @pytest.fixture(scope="session") @@ -53,6 +65,53 @@ def update_baselines(request): return request.config.getoption("--update-baselines") +# --------------------------------------------------------------------------- +# Baselines-path override +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session", autouse=True) +def _set_baselines_path(request): + """Patch BASELINES_PATH in both benchmark modules when --baselines-path is given. + + This lets CI workflows direct reads/writes to a temporary file without + modifying the committed ``tests/benchmarks/baselines.json``. Because + both ``_load_baselines`` and ``_save_baselines`` look up the module-level + ``BASELINES_PATH`` at call time, patching the attribute after import is + sufficient — no test-function signature changes required. + + The scan uses ``sys.modules`` rather than a hard-coded import path so it + works correctly under both pytest's default ``prepend`` import mode + (modules imported as ``test_benchmarks_py``) and ``importlib`` mode + (``tests.test_benchmarks_py``). + """ + import sys + + path_opt = request.config.getoption("--baselines-path") + if not path_opt: + return + + new_path = pathlib.Path(path_opt) + + patched = [] + for mod_name, mod in list(sys.modules.items()): + if mod is None: + continue + if "test_benchmarks" not in mod_name: + continue + if not hasattr(mod, "BASELINES_PATH"): + continue + mod.BASELINES_PATH = new_path + patched.append(mod_name) + + if not patched: + import warnings + warnings.warn( + f"--baselines-path={path_opt!r} was given but no benchmark module " + "was found in sys.modules to patch. The option has no effect.", + stacklevel=1, + ) + + # --------------------------------------------------------------------------- # Playwright browser (one Chromium instance for the whole test session) # --------------------------------------------------------------------------- @@ -358,7 +417,10 @@ def _run_bench(page, panel_id, *, n_warmup=3, n_samples=15, requestAnimationFrame(step); }) """ - return page.evaluate(js, [panel_id, n_warmup, n_samples, - perturb_field, perturb_delta], - timeout=timeout) + page.set_default_timeout(timeout) + try: + return page.evaluate(js, [panel_id, n_warmup, n_samples, + perturb_field, perturb_delta]) + finally: + page.set_default_timeout(30_000) # restore Playwright default diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index 8e9cccd..140d601 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -84,8 +84,22 @@ def _save_baselines(data: dict) -> None: BASELINES_PATH.write_text(json.dumps(data, indent=2)) -def _check_or_update(name: str, timing: dict, update: bool) -> None: - """Assert timing is within threshold of stored baseline, or write it.""" +def _check_or_update(name: str, timing: dict, update: bool, + fail_ratio: float = FAIL_RATIO, + warn_ratio: float = WARN_RATIO) -> None: + """Assert timing is within threshold of stored baseline, or write it. + + Parameters + ---------- + name : benchmark key stored in baselines.json + timing : dict returned by ``_run_bench`` or the interaction page.evaluate + update : when True, write the current result as the new baseline + fail_ratio : ratio of mean_ms/baseline_mean_ms above which the test fails. + Data-push benchmarks use FAIL_RATIO (1.5×); interaction + benchmarks use 2.5× because Playwright mouse-event timing + is more variable under OS scheduler load. + warn_ratio : ratio above which a warning (not failure) is emitted. + """ if timing is None: pytest.skip(f"[{name}] No timing data returned (panel not found?)") @@ -116,12 +130,12 @@ def _check_or_update(name: str, timing: dict, update: bool) -> None: baseline = baselines[name] ratio = timing["mean_ms"] / baseline["mean_ms"] - if ratio > FAIL_RATIO: + if ratio > fail_ratio: pytest.fail( f"[{name}] REGRESSION: mean {timing['mean_ms']:.2f} ms vs " f"baseline {baseline['mean_ms']:.2f} ms ({ratio:.2f}×)" ) - if ratio > WARN_RATIO: + if ratio > warn_ratio: warnings.warn( f"[{name}] Perf degraded: mean {timing['mean_ms']:.2f} ms vs " f"baseline {baseline['mean_ms']:.2f} ms ({ratio:.2f}×)", @@ -328,10 +342,8 @@ def test_bench_interaction_2d_pan(bench_page, update_benchmarks): ) timing = page.evaluate(f"() => window._aplTiming && window._aplTiming['{panel_id}']") - _check_or_update("js_interaction_2d_pan", timing, update_benchmarks) - - -# ── interaction: 2D zoom ────────────────────────────────────────────────────── + _check_or_update("js_interaction_2d_pan", timing, update_benchmarks, + fail_ratio=2.5, warn_ratio=1.75) def test_bench_interaction_2d_zoom(bench_page, update_benchmarks): """Interaction benchmark: 2D wheel zoom (20 wheel events on 512² image).""" @@ -363,10 +375,8 @@ def test_bench_interaction_2d_zoom(bench_page, update_benchmarks): ) timing = page.evaluate(f"() => window._aplTiming && window._aplTiming['{panel_id}']") - _check_or_update("js_interaction_2d_zoom", timing, update_benchmarks) - - -# ── interaction: 1D pan ─────────────────────────────────────────────────────── + _check_or_update("js_interaction_2d_zoom", timing, update_benchmarks, + fail_ratio=2.5, warn_ratio=1.75) def test_bench_interaction_1d_pan(bench_page, update_benchmarks): """Interaction benchmark: 1D pan drag (20 mousemove events, 10K points).""" @@ -403,6 +413,7 @@ def test_bench_interaction_1d_pan(bench_page, update_benchmarks): ) timing = page.evaluate(f"() => window._aplTiming && window._aplTiming['{panel_id}']") - _check_or_update("js_interaction_1d_pan", timing, update_benchmarks) + _check_or_update("js_interaction_1d_pan", timing, update_benchmarks, + fail_ratio=2.5, warn_ratio=1.75) diff --git a/tests/test_benchmarks_py.py b/tests/test_benchmarks_py.py index 8beb368..7cc1b70 100644 --- a/tests/test_benchmarks_py.py +++ b/tests/test_benchmarks_py.py @@ -31,9 +31,7 @@ Regression threshold -------------------- -Fails when ``min_ms > baseline_min_ms * 1.3`` (30 % slower than best -recorded). Pure-Python is deterministic enough for this tighter threshold -compared with the JS/browser suite (50 %). +Fails when ``min_ms > baseline_min_ms * 1.5`` (50 % slower). """ from __future__ import annotations @@ -52,8 +50,8 @@ BASELINES_PATH = pathlib.Path(__file__).parent / "benchmarks" / "baselines.json" -FAIL_RATIO = 1.30 -WARN_RATIO = 1.15 +FAIL_RATIO = 1.50 +WARN_RATIO = 1.25 # timeit settings: REPEATS independent runs of NUMBER executions each. # We take min() over REPEATS to remove OS scheduling jitter. @@ -253,4 +251,3 @@ def _one_update(): timing = _timeit_ms(stmt=_one_update) _check_or_update(f"py_update_2d_{h}x{w}", timing, update_benchmarks) - From 51bd4574e072da97809017f2f5c76734399f936e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 4 Apr 2026 12:28:54 -0500 Subject: [PATCH 05/11] Enhance documentation and styling: add custom CSS for Sphinx output and update performance links --- .../Benchmarks/plot_benchmark_comparison.py | 423 ++++++++++++------ docs/_static/custom.css | 23 + docs/conf.py | 1 + docs/index.rst | 4 +- docs/performance.rst | 156 ------- 5 files changed, 316 insertions(+), 291 deletions(-) create mode 100644 docs/_static/custom.css delete mode 100644 docs/performance.rst diff --git a/Examples/Benchmarks/plot_benchmark_comparison.py b/Examples/Benchmarks/plot_benchmark_comparison.py index 4f7187e..8a2366c 100644 --- a/Examples/Benchmarks/plot_benchmark_comparison.py +++ b/Examples/Benchmarks/plot_benchmark_comparison.py @@ -1,57 +1,60 @@ """ -Library Update-Speed Comparison -================================ +Plot Update Comparison +====================== -Times the **full Python-side cost** of pushing one data frame from Python to -the browser for four plotting libraries. Every measurement goes from "change -the data" to "Python has done everything it can before the browser takes over". +There are a couple of different "costs" asscociated with rendering plots and images. There is +usually a Python-side cost as well as a browser-side rendering cost. We've broken down those +two costs here comparing different libraries for the first cost. The second is harder to +measure. We've done it for anyplotlib but doing it for `ipympl`, bokeh and plotly is a +little more difficult. + +* **Python pre-render** — everything that happens in the Python process before + bytes reach the browser (``timeit``-measured, no browser needed). +* **JS canvas render** — the actual canvas paint time measured inside headless + Chromium via Playwright (anyplotlib only; see the third and fourth charts). .. note:: - These are pure-Python ``timeit`` benchmarks — no browser is involved. - The goal is to isolate the CPU work that happens *before* bytes leave the - kernel. Browser render time (typically 1–20 ms) is additional for all - libraries. + The Python-side timings are pure-Python ``timeit`` benchmarks — no browser + is involved. The JS render timings use Playwright's + ``requestAnimationFrame`` loop and ``window._aplTiming`` to measure + inter-frame intervals in a real Chromium renderer. -What each measurement covers ------------------------------- +What each Python measurement covers +------------------------------------- +---------------+---------------------------------------------------------------+ | Library | What is timed | +===============+===============================================================+ | anyplotlib | ``plot.update(data)`` — float → uint8 normalise → base64 | -| | encode → LUT rebuild → state-dict assembly → json.dumps | +| | encode → LUT rebuild → state-dict assembly → json.dumps → | +| | traitlet dispatch to JS renderer. | +---------------+---------------------------------------------------------------+ -| matplotlib | ``im.set_data(data); fig.canvas.draw()`` — marks data stale, | -| | then **fully rasterises** the figure to an Agg pixel buffer. | -| | This is equivalent to what ipympl does before sending a PNG | -| | over the comm channel. | +| ipympl | ``im.set_data(data); fig.canvas.draw()`` — fully rasterises | +| | the figure to an Agg pixel buffer, then encodes it as a PNG | +| | blob ready for the ipympl comm channel. This is the complete | +| | Python-side cost before the PNG is sent to the browser. | +---------------+---------------------------------------------------------------+ | Plotly | ``fig.data[0].z = data.tolist(); fig.to_json()`` — builds the | -| | JSON blob that Plotly.js receives; every float becomes a | +| | full JSON blob that Plotly.js receives; every float becomes a | | | decimal string. Plotly.js WebGL/SVG render is additional. | +---------------+---------------------------------------------------------------+ | Bokeh | ``source.data = {"image": [data]}; json_item(p)`` — builds | -| | the JSON document patch that Bokeh.js receives. Canvas | +| | the full JSON document patch that Bokeh.js receives. Canvas | | | render is additional. | +---------------+---------------------------------------------------------------+ -Skipping large Plotly / Bokeh sizes -------------------------------------- -Plotly and Bokeh are skipped for 2-D arrays larger than 512² because their -JSON float serialisation becomes impractically large (~10 MB for 1024² vs -anyplotlib's ~1.3 MB base64 blob). The skipped bars are marked ``—`` on the -chart. """ +# sphinx_gallery_start_ignore from __future__ import annotations -import json import pathlib +import tempfile import timeit import warnings import matplotlib -matplotlib.use("Agg") # must be set before pyplot import +matplotlib.use("Agg") # must be set before pyplot import — used for ipympl measurement import matplotlib.pyplot as plt import numpy as np @@ -59,6 +62,13 @@ # Optional library imports — degrade gracefully if not installed # --------------------------------------------------------------------------- +try: + from playwright.sync_api import sync_playwright as _sync_playwright + _HAS_PLAYWRIGHT = True +except ImportError: + _HAS_PLAYWRIGHT = False + warnings.warn("Playwright not installed — JS render timing omitted.", stacklevel=1) + try: import plotly.graph_objects as _go _HAS_PLOTLY = True @@ -91,13 +101,103 @@ def _timeit_min_ms(stmt) -> float: return min(t / _NUMBER * 1000 for t in raw) +# rAF-paced bench loop — mirrors tests/conftest.py _run_bench. +# Each frame perturbs one state field so the blit-cache is invalidated and +# the full decode → LUT → render path executes every cycle. +_JS_BENCH = """ +([panelId, nWarmup, nSamples, field, delta]) => + new Promise((resolve, reject) => { + const total = nWarmup + nSamples; + let i = 0; + function step() { + if (i >= total) { + resolve(window._aplTiming ? window._aplTiming[panelId] : null); + return; + } + const key = 'panel_' + panelId + '_json'; + try { + const st = JSON.parse(window._aplModel.get(key)); + st[field] = (st[field] || 0) + delta; + window._aplModel.set(key, JSON.stringify(st)); + } catch(e) { reject(e); return; } + if (i === nWarmup - 1) { + if (window._aplTiming) delete window._aplTiming[panelId]; + } + i++; + requestAnimationFrame(step); + } + requestAnimationFrame(step); + }) +""" + + +def _measure_js_ms_all(pairs, n_warmup=3, n_samples=12): + """Measure JS render time for a list of (widget, panel_id, field, delta). + + Opens each widget in a shared headless Chromium session, runs the rAF + bench loop, and returns a list of mean_ms values (None on failure). + Only called when _HAS_PLAYWRIGHT is True. + """ + from anyplotlib._repr_utils import build_standalone_html + + results_js = [] + tmp_files = [] + try: + with _sync_playwright() as pw: + browser = pw.chromium.launch( + headless=True, + args=["--no-sandbox", "--disable-setuid-sandbox"], + ) + for pair in pairs: + widget, panel_id, field, delta = pair[:4] + # Per-pair timeout: large images take longer to decode and paint. + # Formula: max(30_000, sz*sz // 200) — scales from 30 s up for 4K+. + timeout_ms = pair[4] if len(pair) > 4 else 60_000 + html = build_standalone_html(widget, resizable=False) + html = html.replace( + "renderFn({ model, el });", + "renderFn({ model, el }); window._aplReady = true;", + ) + html = html.replace( + "const model = makeModel(STATE);", + "const model = makeModel(STATE);\nwindow._aplModel = model;", + ) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + tmp_files.append(tmp) + try: + page = browser.new_page() + page.goto(tmp.as_uri()) + page.wait_for_function( + "() => window._aplReady === true", timeout=timeout_ms + ) + page.evaluate( + "() => new Promise(r =>" + " requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + timing = page.evaluate( + _JS_BENCH, + [panel_id, n_warmup, n_samples, field, delta], + ) + page.close() + results_js.append(timing["mean_ms"] if timing else None) + except Exception: + results_js.append(None) + browser.close() + finally: + for tmp in tmp_files: + tmp.unlink(missing_ok=True) + return results_js + + # --------------------------------------------------------------------------- # Benchmark configuration # --------------------------------------------------------------------------- _SIZES_2D = [64, 256, 512, 1024, 2048] -_SKIP_ABOVE_2D = 512 # Plotly / Bokeh JSON size becomes untenable above this - _SIZES_1D = [100, 1_000, 10_000, 100_000] rng = np.random.default_rng(42) @@ -107,7 +207,7 @@ def _timeit_min_ms(stmt) -> float: _frames_1d = {n: np.cumsum(rng.standard_normal(n)).astype(np.float32) for n in _SIZES_1D} -_LIBRARIES = ["anyplotlib", "matplotlib", "plotly", "bokeh"] +_LIBRARIES = ["anyplotlib", "ipympl", "plotly", "bokeh"] results_2d: dict[str, dict[int, float | None]] = {lib: {} for lib in _LIBRARIES} results_1d: dict[str, dict[int, float | None]] = {lib: {} for lib in _LIBRARIES} @@ -122,7 +222,6 @@ def _timeit_min_ms(stmt) -> float: # ── anyplotlib: normalize → uint8 → base64 → LUT → json push ──────────── _fig_apl, _ax_apl = apl.subplots(1, 1, figsize=(min(sz, 640), min(sz, 640))) _plot_apl = _ax_apl.imshow(data) - # Pre-generate update frames so creation cost is excluded. _update_frames = [rng.uniform(size=(sz, sz)).astype(np.float32) for _ in range(_NUMBER)] _idx = [0] @@ -137,7 +236,7 @@ def _fn(): _make_apl_update(_plot_apl, _update_frames, _idx) ) - # ── matplotlib: set_data + full Agg rasterisation ─────────────────────── + # ── ipympl: set_data + full Agg rasterisation (PNG comm pathway) ──────── _fig_mpl, _ax_mpl = plt.subplots() _im_mpl = _ax_mpl.imshow(data, cmap="viridis") _canvas_mpl = _fig_mpl.canvas @@ -149,13 +248,13 @@ def _fn(): canvas.draw() return _fn - results_2d["matplotlib"][sz] = _timeit_min_ms( + results_2d["ipympl"][sz] = _timeit_min_ms( _make_mpl_update(_im_mpl, _canvas_mpl, _new_mpl) ) plt.close(_fig_mpl) # ── Plotly: assign z list + serialise to JSON ──────────────────────────── - if _HAS_PLOTLY and sz <= _SKIP_ABOVE_2D: + if _HAS_PLOTLY: _pgo_fig = _go.Figure(_go.Heatmap(z=data.tolist())) _new_plotly = rng.uniform(size=(sz, sz)).astype(np.float32).tolist() @@ -172,7 +271,7 @@ def _fn(): results_2d["plotly"][sz] = None # ── Bokeh: replace source.data + serialise full document ──────────────── - if _HAS_BOKEH and sz <= _SKIP_ABOVE_2D: + if _HAS_BOKEH: _bk_src = _CDS(data={"image": [data], "x": [0], "y": [0], "dw": [sz], "dh": [sz]}) _bk_plot = _bk_figure(width=400, height=400) @@ -214,7 +313,7 @@ def _fn(): plot.update(new_y) _make_apl1d(_plot_apl1, _new_ys_apl) ) - # ── matplotlib ─────────────────────────────────────────────────────────── + # ── ipympl: set_ydata + full Agg rasterisation (PNG comm pathway) ─────── _fig_mpl1, _ax_mpl1 = plt.subplots() (_line_mpl,) = _ax_mpl1.plot(xs, ys) _new_ys_mpl = rng.standard_normal(n_pts).cumsum().astype(np.float32) @@ -225,7 +324,7 @@ def _fn(): canvas.draw() return _fn - results_1d["matplotlib"][n_pts] = _timeit_min_ms( + results_1d["ipympl"][n_pts] = _timeit_min_ms( _make_mpl1d(_line_mpl, _fig_mpl1.canvas, _new_ys_mpl) ) plt.close(_fig_mpl1) @@ -266,128 +365,186 @@ def _fn(): else: results_1d["bokeh"][n_pts] = None +# --------------------------------------------------------------------------- +# JS render timing — anyplotlib only (headless Chromium via Playwright) +# --------------------------------------------------------------------------- +# _recordFrame() in figure_esm.js timestamps the *start* of every draw call, +# so the inter-frame interval captured by _aplTiming approximates the full +# JS render cycle: JSON.parse → uint8 decode → LUT expand → ImageBitmap → +# ctx.drawImage (2-D) or ctx.lineTo loop (1-D). + +results_2d_js: dict[int, float | None] = {s: None for s in _SIZES_2D} +results_1d_js: dict[int, float | None] = {n: None for n in _SIZES_1D} + +if _HAS_PLAYWRIGHT: + _pairs_2d_js = [] + for _sz in _SIZES_2D: + _fjs, _ajs = apl.subplots(1, 1, figsize=(min(_sz, 640), min(_sz, 640))) + _pjs = _ajs.imshow(_frames_2d[_sz]) + # Timeout scales with image area: larger images take longer to decode + # and paint in Chromium. Formula: max(30 s, sz²/200) ms. + _js_timeout = max(30_000, _sz * _sz // 200) + _pairs_2d_js.append((_fjs, _pjs._id, "display_min", 1e-4, _js_timeout)) + + for _sz, _t in zip(_SIZES_2D, _measure_js_ms_all(_pairs_2d_js)): + results_2d_js[_sz] = _t + + _pairs_1d_js = [] + for _npts in _SIZES_1D: + _fjs1, _ajs1 = apl.subplots(1, 1, figsize=(640, 320)) + _pjs1 = _ajs1.plot(_frames_1d[_npts]) + _pairs_1d_js.append((_fjs1, _pjs1._id, "view_x0", 1e-4)) + + for _npts, _t in zip(_SIZES_1D, _measure_js_ms_all(_pairs_1d_js)): + results_1d_js[_npts] = _t + # --------------------------------------------------------------------------- # Chart helpers # --------------------------------------------------------------------------- _COLORS = { "anyplotlib": "#1976D2", - "matplotlib": "#E64A19", + "ipympl": "#E64A19", "plotly": "#7B1FA2", "bokeh": "#2E7D32", } -# Human-readable description of what each measurement covers (for the legend). +# Short legend labels shown inside the anyplotlib bar chart. _LABELS = { - "anyplotlib": "anyplotlib (float→uint8→b64→json)", - "matplotlib": "matplotlib (set_data + Agg render)", + "anyplotlib": "anyplotlib (float→uint8→b64→json→traitlet)", + "ipympl": "ipympl (set_data + Agg render → PNG comm)", "plotly": "Plotly (z=list + to_json)", "bokeh": "Bokeh (source.data + json_item)", } -def _grouped_bar(ax, sizes, results, size_labels, title, ylabel, - skip_note=None): - """Draw a grouped bar chart on *ax* (log-scale Y).""" - n_sizes = len(sizes) - n_libs = len(_LIBRARIES) - width = 0.78 / n_libs - x = np.arange(n_sizes) - - for i, lib in enumerate(_LIBRARIES): - vals = [results[lib].get(s) for s in sizes] - color = _COLORS[lib] - offset = (i - (n_libs - 1) / 2) * width - - present = [(j, v) for j, v in enumerate(vals) if v is not None] - missing = [j for j, v in enumerate(vals) if v is None] - - if present: - jj, vv = zip(*present) - bars = ax.bar( - [x[j] + offset for j in jj], - vv, - width=width * 0.88, - label=_LABELS[lib], - color=color, - alpha=0.88, - zorder=3, - ) - for bar, v in zip(bars, vv): - label_str = f"{v:.2f}" if v < 10 else f"{v:.1f}" - ax.text( - bar.get_x() + bar.get_width() / 2, - bar.get_height() * 1.18, - label_str, - ha="center", va="bottom", - fontsize=6, color=color, fontweight="bold", - ) +def _results_to_array(results, sizes): + """Build a (N_sizes, N_libs) float array. - for j in missing: - ax.text( - x[j] + offset, ax.get_ylim()[0] if ax.get_yscale() == "log" else 0, - "n/a", - ha="center", va="bottom", - fontsize=6, color=color, alpha=0.55, style="italic", - ) - - ax.set_yscale("log") - ax.set_xticks(x) - ax.set_xticklabels(size_labels, fontsize=9) - ax.set_xlabel("Array size", fontsize=9) - ax.set_ylabel(ylabel, fontsize=9) - ax.set_title(title, fontsize=10, pad=8) - ax.legend(fontsize=7.5, loc="upper left") - ax.grid(axis="y", linestyle="--", alpha=0.35, zorder=0) - - if skip_note: - ax.text(0.99, 0.02, skip_note, transform=ax.transAxes, - fontsize=7, ha="right", va="bottom", color="#666", - style="italic") + Missing entries (None) become 0.0 — valid JSON, and invisible on a + log-scale axis where 0 is clamped to 1e-10 below the visible range. + Using NaN would produce bare ``NaN`` tokens that JSON.parse rejects, + silently blanking the chart. + """ + rows = [] + for s in sizes: + rows.append([ + results[lib].get(s) if results[lib].get(s) is not None else 0.0 + for lib in _LIBRARIES + ]) + return np.array(rows, dtype=float) # --------------------------------------------------------------------------- -# Figure 1 — 2-D image update +# Chart 1 — 2-D image update (Python pre-render, all four libraries) # --------------------------------------------------------------------------- -fig2d, ax2d = plt.subplots(figsize=(10, 5.5), layout="constrained") -_grouped_bar( - ax2d, - sizes=_SIZES_2D, - results=results_2d, - size_labels=[f"{s}²" for s in _SIZES_2D], - title="2-D image update — full Python-side cost (lower is better)", - ylabel="time per call (ms, log scale)", - skip_note=( - "Plotly / Bokeh omitted above 512²:\n" - "1024² JSON payload ≈ 10 MB vs anyplotlib ≈ 1.3 MB base64" - ), +_size_labels_2d = [f"{s}²" for s in _SIZES_2D] +_heights_2d = _results_to_array(results_2d, _SIZES_2D) + +fig2d, ax2d = apl.subplots(1, 1, figsize=(900, 480)) +ax2d.bar( + _size_labels_2d, + _heights_2d, + group_labels=[_LABELS[lib] for lib in _LIBRARIES], + group_colors=[_COLORS[lib] for lib in _LIBRARIES], + log_scale=True, + show_values=False, + width=0.85, + y_units="ms per call (log scale)", + units="Array size", ) -fig2d.tight_layout(pad=1.2) -plt.show() +fig2d +# sphinx_gallery_end_ignore # %% -# 1-D line update comparison -# -------------------------- -# -# For 1-D line plots anyplotlib currently serialises arrays via -# ``array.tolist()`` (plain JSON floats) — the same path as Plotly and Bokeh — -# so the costs are comparable at all sizes. anyplotlib's advantage is -# concentrated in the 2-D image path where uint8 base64 encoding gives a -# dramatically smaller payload and eliminates the per-float text conversion. - -fig1d, ax1d = plt.subplots(figsize=(10, 5.5), layout="constrained") -_grouped_bar( - ax1d, - sizes=_SIZES_1D, - results=results_1d, - size_labels=[f"{n:,}" for n in _SIZES_1D], - title="1-D line update — full Python-side cost (lower is better)", - ylabel="time per call (ms, log scale)", +# 1-D line update comparison (Python pre-render) +# ------------------------------------------------ + +# sphinx_gallery_start_ignore + +_size_labels_1d = [f"{n:,}" for n in _SIZES_1D] +_heights_1d = _results_to_array(results_1d, _SIZES_1D) + +fig1d, ax1d = apl.subplots(1, 1, figsize=(900, 480)) +ax1d.bar( + _size_labels_1d, + _heights_1d, + group_labels=[_LABELS[lib] for lib in _LIBRARIES], + group_colors=[_COLORS[lib] for lib in _LIBRARIES], + log_scale=True, + show_values=False, + width=0.85, + y_units="ms per call (log scale)", + units="Number of points", ) -fig1d.tight_layout(pad=1.2) -plt.show() - - +fig1d +# sphinx_gallery_end_ignore +# %% +# anyplotlib: Python prep vs JS canvas render +# ------------------------------------------- +# +# The two charts above show only the Python-side cost. The charts below add +# the JS render time for anyplotlib measured inside a real Chromium renderer +# via Playwright (``window._aplTiming`` populated by ``_recordFrame()`` in +# ``figure_esm.js``). The sum of both bars is the **total time-to-pixel** +# for an anyplotlib update. +# +# For ipympl, Plotly, and Bokeh the browser render cost is additional but not +# captured here — measuring it requires running their respective JS engines in +# a live browser session. +# +# .. note:: +# +# If Playwright is not installed the JS bars are absent (zero height) and +# a ``UserWarning`` is emitted at import time. Install Playwright +# (``pip install playwright && playwright install chromium``) to populate +# the JS timing columns. + +# sphinx_gallery_start_ignore + +_apl_py_2d = np.array([results_2d["anyplotlib"].get(s, 0.0) or 0.0 + for s in _SIZES_2D]) +_apl_js_2d = np.array([results_2d_js.get(s) or 0.0 for s in _SIZES_2D]) +_breakdown_2d = np.column_stack([_apl_py_2d, _apl_js_2d]) + +fig_bd2d, ax_bd2d = apl.subplots(1, 1, figsize=(700, 400)) +ax_bd2d.bar( + _size_labels_2d, + _breakdown_2d, + group_labels=["Python prep", "JS canvas render"], + group_colors=["#1976D2", "#4CAF50"], + log_scale=True, + show_values=False, + width=0.7, + y_units="ms per call (log scale)", + units="Array size — anyplotlib 2-D imshow", +) +fig_bd2d +# sphinx_gallery_end_ignore + +#%% + +# sphinx_gallery_start_ignore +_apl_py_1d = np.array([results_1d["anyplotlib"].get(n, 0.0) or 0.0 + for n in _SIZES_1D]) +_apl_js_1d = np.array([results_1d_js.get(n) or 0.0 for n in _SIZES_1D]) +_breakdown_1d = np.column_stack([_apl_py_1d, _apl_js_1d]) + +fig_bd1d, ax_bd1d = apl.subplots(1, 1, figsize=(700, 400)) +ax_bd1d.bar( + _size_labels_1d, + _breakdown_1d, + group_labels=["Python prep", "JS canvas render"], + group_colors=["#1976D2", "#4CAF50"], + log_scale=True, + show_values=False, + width=0.7, + y_units="ms per call (log scale)", + units="Number of points — anyplotlib 1-D line", +) +fig_bd1d +# sphinx_gallery_end_ignore +#%% diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..0cbae11 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,23 @@ +/* + * custom.css — anyplotlib docs overrides + * + * Hide empty syntax-highlight blocks that sphinx-gallery emits when an + * entire code cell is wrapped in # sphinx_gallery_start/end_ignore. + * The cell still executes (figures are scraped) but produces a visible + * blank
 element; this rule makes those invisible.
+ */
+.highlight pre:not(:has(:not(:empty))) {
+    display: none;
+}
+.highlight-Python:has(pre > span:only-child:empty) {
+    display: none;
+}
+
+/*
+ * Fallback for browsers that don't support :has() — target the pattern
+ * sphinx produces for an empty highlighted block:
+ *   
\n
+ * We can't do this in pure CSS without :has, so we rely on the rule above + * for modern browsers and accept a small blank gap on older ones. + */ + diff --git a/docs/conf.py b/docs/conf.py index ad7e923..f3c796f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -81,6 +81,7 @@ # -- Options for HTML output ------------------------------------------------- html_theme = "pydata_sphinx_theme" html_static_path = ["_static"] +html_css_files = ["custom.css"] html_theme_options = { "github_url": "https://github.com/CSSFrancis/anyplotlib", diff --git a/docs/index.rst b/docs/index.rst index e449d93..7d87b5d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,7 +42,7 @@ blitting instead of SVG. 3-D surfaces, bar charts, interactive widgets, and more. .. grid-item-card:: - :link: performance + :link: auto_examples/Benchmarks/plot_benchmark_comparison :link-type: doc :octicon:`graph;2em;sd-text-info` Performance @@ -70,7 +70,7 @@ blitting instead of SVG. getting_started api/index auto_examples/index - performance + Performance benchmarking * :ref:`genindex` diff --git a/docs/performance.rst b/docs/performance.rst deleted file mode 100644 index eeda5a4..0000000 --- a/docs/performance.rst +++ /dev/null @@ -1,156 +0,0 @@ -.. _performance: - -Performance -=========== - -anyplotlib is designed for **real-time data updates** in a live Jupyter session. -This page explains the architectural decisions that make per-frame updates fast, -where the remaining costs sit, and where the current limitations are. - -For measured timings see the :doc:`auto_examples/Benchmarks/index` gallery. - ----- - -Why anyplotlib is fast ------------------------ - -.. rubric:: (a) Compact binary encoding: float → uint8 → base64 - -Colormapped 2-D data is reduced from 64-bit floats to **8-bit palette indices** -before it leaves Python. A 1 024² image goes from a 4 MB float32 array to a -1 MB uint8 array; base64-encoding that produces a **~1.3 MB ASCII blob** — -roughly the same size as a JPEG thumbnail. - -Plotly and Bokeh serialise every floating-point sample as a JSON decimal -string, typically 8–12 characters each. For a 1 024² heatmap that is -**~10 MB of JSON** versus anyplotlib's 1.3 MB base64 string — nearly an 8× -difference before the browser receives a single byte. - -+------------------+-------------------------------+----------------------+ -| Library | 1 024² payload format | Approx. size | -+==================+===============================+======================+ -| anyplotlib | base64(uint8 raw pixels) | ~1.3 MB | -+------------------+-------------------------------+----------------------+ -| Plotly | JSON float array | ~10 MB | -+------------------+-------------------------------+----------------------+ -| Bokeh | JSON float array (json_item) | ~10 MB | -+------------------+-------------------------------+----------------------+ -| matplotlib/ipympl| PNG buffer (lossless) | ~0.5–2 MB (variable) | -+------------------+-------------------------------+----------------------+ - -.. rubric:: (b) LUT colormapping in the browser (``_buildLut32``) - -Python never converts uint8 indices to RGB triples. -Instead, the colormap is serialised once as a compact 256-entry lookup table -(``state["colormap_data"]``). The browser function ``_buildLut32`` (line 471 -of ``figure_esm.js``) expands each index to an RGBA ``Uint32`` in a tight -256-iteration loop, then hands the resulting ``Uint8ClampedArray`` directly to -``createImageBitmap``. - -This means colormap changes and display-range tweaks (``vmin`` / ``vmax``) -are **free from Python's perspective** — only the 256-entry LUT array changes, -not the pixel payload. - -.. rubric:: (c) Canvas blitting — pan and zoom without re-serialisation - -Once a frame has been decoded into an ``ImageBitmap`` it is cached in -``p.blitCache``. Pan and zoom operations (``_blit2d``, lines 518–539) call -``ctx.drawImage(bitmap, srcX, srcY, visW, visH, destX, destY, destW, destH)`` -— a single GPU-accelerated compositing call — without touching Python at all. - -Only when the underlying data array changes (a new ``plot.update()`` call) -does Python re-encode and push a new base64 string. For interactive -exploration of a fixed dataset the marginal per-frame cost from Python is -**zero**. - -.. rubric:: (d) No SVG / DOM per point overhead - -Plotly's scatter and heatmap renderers build SVG ```` elements or -WebGL buffers from the JSON payload. Every zoom or pan event that triggers a -re-render reconstructs DOM nodes. anyplotlib's canvas renderer has a fixed -DOM: two ```` layers (plot + overlay) per panel, regardless of dataset -size. For large 1-D datasets the ``draw1d`` function (line 1 260 of -``figure_esm.js``) iterates over pre-sent coordinates in a tight -``ctx.lineTo`` loop with no heap allocation per point. - -.. rubric:: (e) Incremental traitlet pushes - -anyplotlib uses ``anywidget``'s ``sync=True`` traitlets. Only changed state -fields are serialised into the ``panel_{id}_json`` traitlet on each push. -A pan/zoom event updates only ``center_x``, ``center_y``, and ``zoom`` — the -``image_b64`` blob is unchanged and is not re-transmitted. - ----- - -Python → JS pipeline stages ----------------------------- - -The table below shows typical costs on an Apple M1 Air (from -``tests/benchmarks/baselines.json``): - -+---------------------------------------------------+-------+--------+--------+--------+---------+ -| Stage | 64² | 256² | 512² | 1024² | 2048² | -+===================================================+=======+========+========+========+=========+ -| ``_normalize_image`` (NumPy cast + scale + uint8) | 0.013 | 0.091 | 0.577 | 3.85 | 29.67 | -+---------------------------------------------------+-------+--------+--------+--------+---------+ -| ``_encode_bytes`` (base64) | 0.007 | 0.098 | 0.451 | 2.21 | 11.28 | -+---------------------------------------------------+-------+--------+--------+--------+---------+ -| ``json.dumps(to_state_dict())`` | 0.081 | 0.266 | 0.875 | 3.16 | 12.93 | -+---------------------------------------------------+-------+--------+--------+--------+---------+ -| **``plot.update()`` (full round-trip)** | 0.219 | 0.646 | 2.36 | 9.23 | 36.11 | -+---------------------------------------------------+-------+--------+--------+--------+---------+ - -All timings in **milliseconds (min over 15 calls)**. The ``update()`` row -includes all stages above plus ``_build_colormap_lut`` and traitlet dispatch. - -For a 512² image anyplotlib completes a full Python-side update in **~2.4 ms** - - ----- - -Limitations ------------ - -.. rubric:: 1-D serialisation uses JSON float arrays - -The current ``Plot1D.to_state_dict()`` converts x/y arrays with -``array.tolist()`` before ``json.dumps``. This is the same O(N × bytes-per-float) -cost as Plotly and Bokeh. For 100 000-point line plots the round-trip is -**~7 ms** — acceptable for interactive drag events but not for high-frequency -streaming. - -A future ``uint16``-quantised path (similar to the 2-D uint8 encoding) would -reduce 1-D payload sizes by ~4× and bring the serialisation cost below 2 ms -for 100 k points. - -.. rubric:: 2-D LUT resolution is 8 bit (256 colours) - -The uint8 encoding maps each float to one of 256 palette entries. -For scientific visualisation at display resolution this is indistinguishable -from a full 64-bit render, but histogram-equalisation or very shallow -gradients may show banding if ``vmin`` / ``vmax`` clip a narrow data range. -Use ``display_min`` / ``display_max`` to focus the LUT on the region of -interest. - -.. rubric:: No GPU-side streaming - -anyplotlib does not use ``OffscreenCanvas`` or ``ImageData`` worker threads. -All pixel expansion (LUT → RGBA) happens on the main browser thread via -``_buildLut32``. For arrays larger than ~4 096² this becomes perceptible -(> 16 ms frame budget). The ``--run-slow`` test flag covers those sizes -explicitly. - ----- - -Benchmark gallery ------------------ - -.. toctree:: - :hidden: - - auto_examples/Benchmarks/index - -See the :doc:`auto_examples/Benchmarks/index` gallery for live-timed -comparisons of anyplotlib, matplotlib, Plotly, and Bokeh across a range -of 2-D image sizes and 1-D line lengths. - From 2ef6df015d96d52accab4e7e71efccc928a3eca4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 5 Apr 2026 07:15:42 -0500 Subject: [PATCH 06/11] Enhance documentation: improve event class docstring and update chart section headers for clarity --- Examples/Benchmarks/plot_benchmark_comparison.py | 15 ++++++++++++--- anyplotlib/callbacks.py | 10 ++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Examples/Benchmarks/plot_benchmark_comparison.py b/Examples/Benchmarks/plot_benchmark_comparison.py index 8a2366c..f253597 100644 --- a/Examples/Benchmarks/plot_benchmark_comparison.py +++ b/Examples/Benchmarks/plot_benchmark_comparison.py @@ -434,11 +434,14 @@ def _results_to_array(results, sizes): ]) return np.array(rows, dtype=float) +# sphinx_gallery_end_ignore +#%% # --------------------------------------------------------------------------- -# Chart 1 — 2-D image update (Python pre-render, all four libraries) +# 2-D image update (Python pre-render, all four libraries) # --------------------------------------------------------------------------- +# sphinx_gallery_start_ignore _size_labels_2d = [f"{s}²" for s in _SIZES_2D] _heights_2d = _results_to_array(results_2d, _SIZES_2D) @@ -458,8 +461,9 @@ def _results_to_array(results, sizes): # sphinx_gallery_end_ignore # %% -# 1-D line update comparison (Python pre-render) -# ------------------------------------------------ +# --------------------------------------------------------------------------- +# 1-D line update (Python pre-render, all four libraries) +# --------------------------------------------------------------------------- # sphinx_gallery_start_ignore @@ -501,6 +505,9 @@ def _results_to_array(results, sizes): # a ``UserWarning`` is emitted at import time. Install Playwright # (``pip install playwright && playwright install chromium``) to populate # the JS timing columns. +# +# 2D Image Plotting Costs +# ----------------------- # sphinx_gallery_start_ignore @@ -525,6 +532,8 @@ def _results_to_array(results, sizes): # sphinx_gallery_end_ignore #%% +# Scatter Plotting Costs +# ------------------------- # sphinx_gallery_start_ignore _apl_py_1d = np.array([results_1d["anyplotlib"].get(n, 0.0) or 0.0 diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index e95e621..66ec37a 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -45,10 +45,12 @@ def on_settle(event): @dataclass class Event: """A single interactive event. - event_type: one of on_click / on_changed / on_release / on_key / - on_line_hover / on_line_click - source: the originating Python object (Widget, Plot, or None) - data: full state dict; all keys also accessible as event.x + + :event_type: one of ``on_click`` / ``on_changed`` / ``on_release`` / + ``on_key`` / ``on_line_hover`` / ``on_line_click`` + :source: the originating Python object (Widget, Plot, or None) + :data: full state dict; all keys also accessible as ``event.x`` + For ``on_line_hover`` and ``on_line_click`` events the data dict contains: From 4cbd237da50ff165be15640c3455c78397d15df5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 5 Apr 2026 07:40:29 -0500 Subject: [PATCH 07/11] Refactor: update--> .set_data for more consistent naming scheme. --- .../Benchmarks/plot_benchmark_comparison.py | 6 +-- Examples/Interactive/plot_interactive_fft.py | 2 +- Examples/plot_3d.py | 4 +- Examples/plot_bar.py | 10 ++--- anyplotlib/figure_plots.py | 43 +++++++++++++++---- tests/test_bar.py | 36 ++++++++-------- tests/test_benchmarks_py.py | 10 ++--- tests/test_imshow_params.py | 10 ++--- tests/test_widgets.py | 2 +- 9 files changed, 74 insertions(+), 49 deletions(-) diff --git a/Examples/Benchmarks/plot_benchmark_comparison.py b/Examples/Benchmarks/plot_benchmark_comparison.py index f253597..07114e6 100644 --- a/Examples/Benchmarks/plot_benchmark_comparison.py +++ b/Examples/Benchmarks/plot_benchmark_comparison.py @@ -26,7 +26,7 @@ +---------------+---------------------------------------------------------------+ | Library | What is timed | +===============+===============================================================+ -| anyplotlib | ``plot.update(data)`` — float → uint8 normalise → base64 | +| anyplotlib | ``plot.set_data(data)`` — float → uint8 normalise → base64 | | | encode → LUT rebuild → state-dict assembly → json.dumps → | | | traitlet dispatch to JS renderer. | +---------------+---------------------------------------------------------------+ @@ -228,7 +228,7 @@ def _measure_js_ms_all(pairs, n_warmup=3, n_samples=12): def _make_apl_update(plot, frames, idx): def _fn(): - plot.update(frames[idx[0] % len(frames)]) + plot.set_data(frames[idx[0] % len(frames)]) idx[0] += 1 return _fn @@ -306,7 +306,7 @@ def _fn(): _new_ys_apl = rng.standard_normal(n_pts).cumsum().astype(np.float32) def _make_apl1d(plot, new_y): - def _fn(): plot.update(new_y) + def _fn(): plot.set_data(new_y) return _fn results_1d["anyplotlib"][n_pts] = _timeit_min_ms( diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index 5030de4..20e2773 100644 --- a/Examples/Interactive/plot_interactive_fft.py +++ b/Examples/Interactive/plot_interactive_fft.py @@ -175,7 +175,7 @@ def _roi_released(event): log_mag, freq_x, freq_y = _compute_fft(image, x0, y0, w, h) # Push updated FFT into the right panel - v_fft.update(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/\u00c5") + v_fft.set_data(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/\u00c5") fig diff --git a/Examples/plot_3d.py b/Examples/plot_3d.py index 642bbf1..fb52745 100644 --- a/Examples/plot_3d.py +++ b/Examples/plot_3d.py @@ -63,11 +63,11 @@ # %% # Update the surface data live # ---------------------------- -# Call :meth:`~anyplotlib.figure_plots.Plot3D.update` to replace the geometry +# Call :meth:`~anyplotlib.figure_plots.Plot3D.set_data` to replace the geometry # without recreating the panel. ZZ2 = np.cos(np.sqrt(XX ** 2 + YY ** 2)) -surf.update(XX, YY, ZZ2) +surf.set_data(XX, YY, ZZ2) surf.set_colormap("plasma") surf.set_view(azimuth=30, elevation=40) diff --git a/Examples/plot_bar.py b/Examples/plot_bar.py index 96588aa..05772d0 100644 --- a/Examples/plot_bar.py +++ b/Examples/plot_bar.py @@ -8,7 +8,7 @@ * Vertical and horizontal orientations, per-bar colours, category labels * **Grouped bars** — pass a 2-D *height* array ``(N, G)`` * **Log-scale value axis** — ``log_scale=True`` -* Live data updates via :meth:`~anyplotlib.figure_plots.PlotBar.update` +* Live data updates via :meth:`~anyplotlib.figure_plots.PlotBar.set_data` """ import numpy as np import anyplotlib as vw @@ -117,7 +117,7 @@ # Side-by-side comparison — update data live # ------------------------------------------- # Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one figure. -# Call :meth:`~anyplotlib.figure_plots.PlotBar.update` to swap in Q2 data — +# Call :meth:`~anyplotlib.figure_plots.PlotBar.set_data` to swap in Q2 data — # the value-axis range recalculates automatically. q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float) @@ -134,7 +134,7 @@ all_months, q1, width=0.6, color="#ff7043", show_values=False, y_units="Q2 sales", ) -bar_right.update(q2) # swap in Q2 — axis range recalculates automatically +bar_right.set_data(q2) # swap in Q2 — axis range recalculates automatically fig5 @@ -206,7 +206,7 @@ # ------------------------------------------- # Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one # :func:`~anyplotlib.figure_plots.subplots` figure. Call -# :meth:`~anyplotlib.figure_plots.PlotBar.update` to swap in Q2 data for the +# :meth:`~anyplotlib.figure_plots.PlotBar.set_data` to swap in Q2 data for the # right panel, demonstrating how the axis range re-calculates automatically. quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", @@ -236,7 +236,7 @@ ) # Swap in Q2 data — range is recalculated automatically -bar_right.update(q2) +bar_right.set_data(q2) fig3 diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 9dfb511..b867625 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -698,6 +698,8 @@ def __init__(self, data: np.ndarray, if origin == "lower": data = np.flipud(data) + self._data: np.ndarray = data.astype(float) + x_axis_given = x_axis is not None y_axis_given = y_axis is not None if x_axis is None: @@ -785,9 +787,20 @@ def to_state_dict(self) -> dict: return d # ------------------------------------------------------------------ - # Data update + # Data # ------------------------------------------------------------------ - def update(self, data: np.ndarray, + @property + def data(self) -> np.ndarray: + """The image data in the original user coordinate system (read-only). + + Returns a float64 copy with ``writeable=False``. To replace the + data call :meth:`set_data`. + """ + arr = np.flipud(self._data).copy() if self._origin == "lower" else self._data.copy() + arr.flags.writeable = False + return arr + + def set_data(self, data: np.ndarray, x_axis=None, y_axis=None, units: str | None = None) -> None: """Replace the image data. @@ -804,6 +817,7 @@ def update(self, data: np.ndarray, if self._origin == "lower": data = np.flipud(data) + self._data = data.astype(float) img_u8, vmin, vmax = _normalize_image(data) self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax @@ -1278,9 +1292,9 @@ def __init__(self, data: np.ndarray, allowed=MarkerRegistry._KNOWN_MESH) # ------------------------------------------------------------------ - # Data update + # Data # ------------------------------------------------------------------ - def update(self, data: np.ndarray, + def set_data(self, data: np.ndarray, x_edges=None, y_edges=None, units: str | None = None) -> None: """Replace the mesh data (and optionally the edge arrays).""" data = np.asarray(data) @@ -1535,7 +1549,7 @@ def set_zoom(self, zoom: float) -> None: self._state["zoom"] = float(zoom) self._push() - def update(self, x, y, z) -> None: + def set_data(self, x, y, z) -> None: """Replace the geometry data.""" # Re-run the same logic as __init__ for the stored geom_type geom_type = self._state["geom_type"] @@ -1856,9 +1870,20 @@ def on_primary_click(event): return Line1D(self, None) # ------------------------------------------------------------------ - # Data update + # Data # ------------------------------------------------------------------ - def update(self, data: np.ndarray, x_axis=None, + @property + def data(self) -> np.ndarray: + """The primary line's y-data (read-only). + + Returns a float64 copy with ``writeable=False``. To replace the + data call :meth:`set_data`. + """ + arr = self._state["data"].copy() + arr.flags.writeable = False + return arr + + def set_data(self, data: np.ndarray, x_axis=None, units: str | None = None, y_units: str | None = None) -> None: """Replace the primary line's y-data and optionally its x-axis / units. @@ -3070,9 +3095,9 @@ def to_state_dict(self) -> dict: return d # ------------------------------------------------------------------ - # Data update + # Data # ------------------------------------------------------------------ - def update(self, height, x=None, x_labels=None, *, x_centers=None) -> None: + def set_data(self, height, x=None, x_labels=None, *, x_centers=None) -> None: """Replace bar heights; recalculates the value-axis range automatically. Parameters diff --git a/tests/test_bar.py b/tests/test_bar.py index bc6778a..3f4812b 100644 --- a/tests/test_bar.py +++ b/tests/test_bar.py @@ -14,7 +14,7 @@ * Range / padding calculations * Grouped bars – 2-D height array, group_labels, group_colors * Log scale – log_scale flag, clamping, set_log_scale() - * update() – value replacement and axis recalculation + * set_data() – value replacement and axis recalculation * Display-setting mutations: set_color, set_colors, set_show_values, set_log_scale * _push() contract – state is propagated to the Figure * Layout JSON reflects "bar" kind for PlotBar panels @@ -275,17 +275,17 @@ def test_repr_shows_groups(self): p = ax.bar([0, 1], [[1, 2], [3, 4]]) assert "groups=2" in repr(p) - def test_update_2d_values(self): + def test_set_data_2d_values(self): fig, ax = apl.subplots(1, 1) p = ax.bar(["A", "B"], [[1, 2], [3, 4]]) - p.update([[10, 20], [30, 40]]) + p.set_data([[10, 20], [30, 40]]) assert _state(p)["values"] == pytest.approx(np.array([[10, 20], [30, 40]])) - def test_update_group_count_mismatch_raises(self): + def test_set_data_group_count_mismatch_raises(self): fig, ax = apl.subplots(1, 1) p = ax.bar(["A", "B"], [[1, 2], [3, 4]]) # groups=2 with pytest.raises(ValueError, match="Group count mismatch"): - p.update([[1, 2, 3], [4, 5, 6]]) # 3 groups → error + p.set_data([[1, 2, 3], [4, 5, 6]]) # 3 groups → error # ───────────────────────────────────────────────────────────────────────────── @@ -341,55 +341,55 @@ def test_set_log_scale_push(self): # ───────────────────────────────────────────────────────────────────────────── -# 6. update() — value replacement +# 6. set_data() — value replacement # ───────────────────────────────────────────────────────────────────────────── -class TestPlotBarUpdate: +class TestPlotBarSetData: def test_update_replaces_values(self): p = _make_bar([1, 2, 3]) - p.update([10, 20, 30]) + p.set_data([10, 20, 30]) assert _state(p)["values"] == pytest.approx(np.array([[10.0], [20.0], [30.0]])) def test_update_recalculates_data_max(self): p = _make_bar([1, 2, 3]) - p.update([100, 200, 300]) + p.set_data([100, 200, 300]) assert _state(p)["data_max"] > 300.0 def test_update_recalculates_data_min(self): p = _make_bar([1, 2, 3]) - p.update([-50, -20, -10]) + p.set_data([-50, -20, -10]) assert _state(p)["data_min"] < -50.0 def test_update_with_new_x_centers(self): p = _make_bar([1, 2, 3]) - p.update([4, 5, 6], x_centers=[0.5, 1.5, 2.5]) + p.set_data([4, 5, 6], x_centers=[0.5, 1.5, 2.5]) assert _state(p)["x_centers"] == pytest.approx([0.5, 1.5, 2.5]) def test_update_with_new_x(self): p = _make_bar([1, 2, 3]) - p.update([4, 5, 6], x=[0.5, 1.5, 2.5]) + p.set_data([4, 5, 6], x=[0.5, 1.5, 2.5]) assert _state(p)["x_centers"] == pytest.approx([0.5, 1.5, 2.5]) def test_update_with_new_x_labels(self): p = _make_bar([1, 2, 3], x_labels=["a", "b", "c"]) - p.update([4, 5, 6], x_labels=["x", "y", "z"]) + p.set_data([4, 5, 6], x_labels=["x", "y", "z"]) assert _state(p)["x_labels"] == ["x", "y", "z"] def test_update_preserves_orient(self): p = _make_bar([1, 2, 3], orient="h") - p.update([4, 5, 6]) + p.set_data([4, 5, 6]) assert _state(p)["orient"] == "h" def test_update_preserves_baseline(self): p = _make_bar([1, 2, 3], baseline=2.0) - p.update([10, 20, 30]) + p.set_data([10, 20, 30]) assert _state(p)["baseline"] == pytest.approx(2.0) - def test_update_3d_raises(self): + def test_set_data_3d_raises(self): p = _make_bar([1, 2, 3]) with pytest.raises(ValueError, match="1-D or 2-D"): - p.update(np.zeros((2, 2, 2))) + p.set_data(np.zeros((2, 2, 2))) # ───────────────────────────────────────────────────────────────────────────── @@ -441,7 +441,7 @@ def test_panel_json_contains_kind_bar(self): def test_panel_json_values_after_update(self): fig, ax = apl.subplots(1, 1) p = ax.bar([1, 2, 3]) - p.update([7, 8, 9]) + p.set_data([7, 8, 9]) trait_name = f"panel_{p._id}_json" data = json.loads(getattr(fig, trait_name)) assert data["values"] == pytest.approx(np.array([[7.0], [8.0], [9.0]])) diff --git a/tests/test_benchmarks_py.py b/tests/test_benchmarks_py.py index 7cc1b70..0d6ccca 100644 --- a/tests/test_benchmarks_py.py +++ b/tests/test_benchmarks_py.py @@ -13,7 +13,7 @@ 1. ``_normalize_image(data)`` — NumPy cast + min/max + scale + uint8 2. ``Plot2D._encode_bytes(img_u8)`` — base64.b64encode 3. ``json.dumps(plot.to_state_dict())`` — full end-to-end (2D and 1D) -4. ``plot.update(data)`` — complete Python-side round-trip +4. ``plot.set_data(data)`` — complete Python-side round-trip Workflow -------- @@ -219,15 +219,15 @@ def test_bench_py_serialize_1d(n_pts, update_benchmarks): # --------------------------------------------------------------------------- -# Full plot.update() round-trip (normalize + encode + build_lut + push) +# Full plot.set_data() round-trip (normalize + encode + build_lut + push) # --------------------------------------------------------------------------- @pytest.mark.parametrize( "h,w,is_slow", _IMSHOW_SIZES, ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], ) -def test_bench_py_update_2d(h, w, is_slow, update_benchmarks, run_slow): - """Python: full ``plot.update(data)`` round-trip for a ``{h}×{w}`` image. +def test_bench_py_set_data_2d(h, w, is_slow, update_benchmarks, run_slow): + """Python: full ``plot.set_data(data)`` round-trip for a ``{h}×{w}`` image. Covers the complete Python-side cost of a live data refresh: ``_normalize_image`` + ``_encode_bytes`` + ``_build_colormap_lut`` @@ -245,7 +245,7 @@ def test_bench_py_update_2d(h, w, is_slow, update_benchmarks, run_slow): idx = [0] def _one_update(): - plot.update(frames[idx[0] % len(frames)]) + plot.set_data(frames[idx[0] % len(frames)]) idx[0] += 1 timing = _timeit_ms(stmt=_one_update) diff --git a/tests/test_imshow_params.py b/tests/test_imshow_params.py index 24e91ca..d1055ca 100644 --- a/tests/test_imshow_params.py +++ b/tests/test_imshow_params.py @@ -135,19 +135,19 @@ def test_lower_flips_data(self): assert stored[0, :].max() == 255 # top row contains the global max assert stored[-1, :].min() == 0 # bottom row contains the global min - def test_lower_update_reapplies_flip(self): - """update() with origin='lower' automatically re-flips new data.""" + def test_lower_set_data_reapplies_flip(self): + """set_data() with origin='lower' automatically re-flips new data.""" fig, ax = apl.subplots() v = ax.imshow(DATA, origin="lower") - v.update(DATA) + v.set_data(DATA) stored = _decoded(v) assert stored[0, :].max() == 255 assert stored[-1, :].min() == 0 - def test_lower_update_reverses_new_y_axis(self): + def test_lower_set_data_reverses_new_y_axis(self): fig, ax = apl.subplots() v = ax.imshow(DATA, origin="lower") - v.update(DATA, y_axis=Y) + v.set_data(DATA, y_axis=Y) assert v._state["y_axis"][0] == pytest.approx(40.0) assert v._state["y_axis"][-1] == pytest.approx(10.0) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index e34946b..5453bf4 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -588,7 +588,7 @@ def test_drag_rectangle_updates_fft(self): def on_rect_changed(event): log_mag, freq_x, freq_y = self._compute_fft( img, event.x, event.y, event.w, event.h) - v_fft.update(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/Å") + v_fft.set_data(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/Å") updates.append({"x": event.x, "y": event.y, "w": event.w, "h": event.h}) From d677894cca21377552bc9453100c788be012a228 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 5 Apr 2026 07:48:14 -0500 Subject: [PATCH 08/11] Enhance benchmarks: increase failure ratio threshold for regression tests --- tests/test_benchmarks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index 140d601..5ea3091 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -63,7 +63,7 @@ BASELINES_PATH = pathlib.Path(__file__).parent / "benchmarks" / "baselines.json" # Regression thresholds (ratio relative to stored baseline mean_ms). -FAIL_RATIO = 1.50 # >50 % slower → test failure +FAIL_RATIO = 2.00 # >100 % slower → test failure WARN_RATIO = 1.25 # >25 % slower → warning only # Grid padding added by gridDiv (mirrors figure_esm.js) From 44cfbdf3d2fa5ee7ced7d6175a8f106ea368d3a0 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 5 Apr 2026 07:56:29 -0500 Subject: [PATCH 09/11] Enhance benchmarks: increase failure ratio threshold for regression tests --- tests/test_benchmarks_py.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_benchmarks_py.py b/tests/test_benchmarks_py.py index 0d6ccca..a5261a5 100644 --- a/tests/test_benchmarks_py.py +++ b/tests/test_benchmarks_py.py @@ -50,7 +50,7 @@ BASELINES_PATH = pathlib.Path(__file__).parent / "benchmarks" / "baselines.json" -FAIL_RATIO = 1.50 +FAIL_RATIO = 2.00 WARN_RATIO = 1.25 # timeit settings: REPEATS independent runs of NUMBER executions each. From 39e9f64b2005a60a2b1e339976c1df4ba0afc3db Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 5 Apr 2026 08:06:04 -0500 Subject: [PATCH 10/11] Enhance benchmarks: add --ignore-hardware option to restore full fail/warn behavior --- tests/benchmarks/baselines.json | 10 ++--- tests/conftest.py | 24 +++++++++++ tests/test_benchmarks.py | 74 +++++++++++++++++++++++---------- tests/test_benchmarks_py.py | 59 ++++++++++++++++++-------- 4 files changed, 122 insertions(+), 45 deletions(-) diff --git a/tests/benchmarks/baselines.json b/tests/benchmarks/baselines.json index 7edcc84..606947f 100644 --- a/tests/benchmarks/baselines.json +++ b/tests/benchmarks/baselines.json @@ -55,7 +55,7 @@ "n": 15, "updated_at": "2026-04-04T13:16:00.474681+00:00" }, - "py_update_2d_64x64": { + "py_set_data_2d_64x64": { "min_ms": 0.473, "mean_ms": 0.767, "max_ms": 1.102, @@ -146,28 +146,28 @@ "n": 15, "updated_at": "2026-04-04T13:16:00.020751+00:00" }, - "py_update_2d_256x256": { + "py_set_data_2d_256x256": { "min_ms": 1.593, "mean_ms": 4.177, "max_ms": 10.228, "n": 15, "updated_at": "2026-04-04T13:16:00.592322+00:00" }, - "py_update_2d_512x512": { + "py_set_data_2d_512x512": { "min_ms": 5.114, "mean_ms": 6.513, "max_ms": 7.349, "n": 15, "updated_at": "2026-04-04T13:16:00.715109+00:00" }, - "py_update_2d_1024x1024": { + "py_set_data_2d_1024x1024": { "min_ms": 8.407, "mean_ms": 8.954, "max_ms": 10.195, "n": 15, "updated_at": "2026-04-04T13:16:00.916898+00:00" }, - "py_update_2d_2048x2048": { + "py_set_data_2d_2048x2048": { "min_ms": 31.763, "mean_ms": 34.136, "max_ms": 38.112, diff --git a/tests/conftest.py b/tests/conftest.py index 822cf60..8f7cb8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,6 +46,17 @@ def pytest_addoption(parser): default=False, help="Include slow benchmark scenarios (4096², 8192² images) skipped in fast CI", ) + parser.addoption( + "--ignore-hardware", + action="store_true", + default=False, + help=( + "Treat the current machine as matching the baseline host, restoring " + "full fail/warn behaviour even on different hardware. By default, " + "benchmarks still run and compare on mismatched hardware but " + "regressions are downgraded from failures to warnings." + ), + ) parser.addoption( "--baselines-path", default=None, @@ -302,6 +313,19 @@ def run_slow(request): return request.config.getoption("--run-slow") +@pytest.fixture(scope="session") +def ignore_hardware(request): + """True when --ignore-hardware was passed. + + By default, benchmark comparisons run on every machine but regressions + that would normally cause a *failure* are downgraded to *warnings* when + the current hostname doesn't match ``_meta.host`` in ``baselines.json``. + Pass ``--ignore-hardware`` to restore full fail behaviour regardless of + which machine is running the tests. + """ + return request.config.getoption("--ignore-hardware") + + @pytest.fixture def bench_page(_pw_browser): """Fixture: open a widget in headless Chromium and return the live Page. diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index 5ea3091..40a7060 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -86,7 +86,8 @@ def _save_baselines(data: dict) -> None: def _check_or_update(name: str, timing: dict, update: bool, fail_ratio: float = FAIL_RATIO, - warn_ratio: float = WARN_RATIO) -> None: + warn_ratio: float = WARN_RATIO, + ignore_hardware: bool = False) -> None: """Assert timing is within threshold of stored baseline, or write it. Parameters @@ -99,6 +100,15 @@ def _check_or_update(name: str, timing: dict, update: bool, benchmarks use 2.5× because Playwright mouse-event timing is more variable under OS scheduler load. warn_ratio : ratio above which a warning (not failure) is emitted. + ignore_hardware : when True, treat the current machine as matching the + baseline host and apply full fail/warn behaviour. + + Hardware matching + ----------------- + When the current hostname differs from ``_meta.host`` in the baseline file + the test still runs and compares, but any result that would normally be a + *failure* is downgraded to a *warning*. Pass ``--ignore-hardware`` to + restore full fail behaviour regardless of hostname. """ if timing is None: pytest.skip(f"[{name}] No timing data returned (panel not found?)") @@ -127,18 +137,33 @@ def _check_or_update(name: str, timing: dict, update: bool, f"[{name}] No baseline — run with --update-benchmarks to create one" ) + # Determine whether we're on the same hardware as the baseline. + meta = baselines.get("_meta", {}) + baseline_host = meta.get("host") + current_host = socket.gethostname() + hw_match = ignore_hardware or not baseline_host or (baseline_host == current_host) + hw_note = ( + "" + if hw_match + else f" [different hardware: baseline={baseline_host!r}, current={current_host!r}]" + ) + baseline = baselines[name] ratio = timing["mean_ms"] / baseline["mean_ms"] if ratio > fail_ratio: - pytest.fail( + msg = ( f"[{name}] REGRESSION: mean {timing['mean_ms']:.2f} ms vs " - f"baseline {baseline['mean_ms']:.2f} ms ({ratio:.2f}×)" + f"baseline {baseline['mean_ms']:.2f} ms ({ratio:.2f}×){hw_note}" ) - if ratio > warn_ratio: + if hw_match: + pytest.fail(msg) + else: + warnings.warn(msg, stacklevel=2) + elif ratio > warn_ratio: warnings.warn( f"[{name}] Perf degraded: mean {timing['mean_ms']:.2f} ms vs " - f"baseline {baseline['mean_ms']:.2f} ms ({ratio:.2f}×)", + f"baseline {baseline['mean_ms']:.2f} ms ({ratio:.2f}×){hw_note}", stacklevel=2, ) @@ -163,7 +188,7 @@ def _check_or_update(name: str, timing: dict, update: bool, _IMSHOW_SIZES, ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], ) -def test_bench_imshow(h, w, is_slow, bench_page, update_benchmarks, run_slow): +def test_bench_imshow(h, w, is_slow, bench_page, update_benchmarks, run_slow, ignore_hardware): """Render-time benchmark: imshow with {h}×{w} image data.""" if is_slow and not run_slow: pytest.skip(f"Skipping {h}×{w} in fast CI — pass --run-slow to include") @@ -189,7 +214,8 @@ def test_bench_imshow(h, w, is_slow, bench_page, update_benchmarks, run_slow): timeout=timeout_ms, ) - _check_or_update(f"js_imshow_{h}x{w}", timing, update_benchmarks) + _check_or_update(f"js_imshow_{h}x{w}", timing, update_benchmarks, + ignore_hardware=ignore_hardware) # ── 1D plot benchmarks ──────────────────────────────────────────────────────── @@ -198,7 +224,7 @@ def test_bench_imshow(h, w, is_slow, bench_page, update_benchmarks, run_slow): @pytest.mark.parametrize("n_pts", _PLOT1D_SIZES, ids=[str(n) for n in _PLOT1D_SIZES]) -def test_bench_plot1d(n_pts, bench_page, update_benchmarks): +def test_bench_plot1d(n_pts, bench_page, update_benchmarks, ignore_hardware): """Render-time benchmark: plot1d with {n_pts} points.""" rng = np.random.default_rng(1) fig, ax = apl.subplots(1, 1, figsize=(640, 320)) @@ -215,7 +241,8 @@ def test_bench_plot1d(n_pts, bench_page, update_benchmarks): n_samples=15, ) - _check_or_update(f"js_plot1d_{n_pts}pts", timing, update_benchmarks) + _check_or_update(f"js_plot1d_{n_pts}pts", timing, update_benchmarks, + ignore_hardware=ignore_hardware) # ── pcolormesh benchmarks ───────────────────────────────────────────────────── @@ -224,7 +251,7 @@ def test_bench_plot1d(n_pts, bench_page, update_benchmarks): @pytest.mark.parametrize("n", _MESH_SIZES, ids=[f"{n}x{n}" for n in _MESH_SIZES]) -def test_bench_pcolormesh(n, bench_page, update_benchmarks): +def test_bench_pcolormesh(n, bench_page, update_benchmarks, ignore_hardware): """Render-time benchmark: pcolormesh with {n}×{n} grid.""" rng = np.random.default_rng(2) xe = np.linspace(0.0, 1.0, n + 1) @@ -245,12 +272,13 @@ def test_bench_pcolormesh(n, bench_page, update_benchmarks): n_samples=15, ) - _check_or_update(f"js_pcolormesh_{n}x{n}", timing, update_benchmarks) + _check_or_update(f"js_pcolormesh_{n}x{n}", timing, update_benchmarks, + ignore_hardware=ignore_hardware) # ── 3D surface benchmark ────────────────────────────────────────────────────── -def test_bench_plot3d(bench_page, update_benchmarks): +def test_bench_plot3d(bench_page, update_benchmarks, ignore_hardware): """Render-time benchmark: 3D surface (rotation interaction path).""" x = np.linspace(-2.0, 2.0, 48) y = np.linspace(-2.0, 2.0, 48) @@ -272,13 +300,14 @@ def test_bench_plot3d(bench_page, update_benchmarks): n_samples=15, ) - _check_or_update("js_plot3d_48x48", timing, update_benchmarks) + _check_or_update("js_plot3d_48x48", timing, update_benchmarks, + ignore_hardware=ignore_hardware) # ── bar chart benchmark ─────────────────────────────────────────────────────── @pytest.mark.parametrize("n_bars", [10, 100], ids=["10bars", "100bars"]) -def test_bench_bar(n_bars, bench_page, update_benchmarks): +def test_bench_bar(n_bars, bench_page, update_benchmarks, ignore_hardware): """Render-time benchmark: bar chart with {n_bars} bars.""" rng = np.random.default_rng(3) fig, ax = apl.subplots(1, 1, figsize=(640, 320)) @@ -295,12 +324,13 @@ def test_bench_bar(n_bars, bench_page, update_benchmarks): n_samples=15, ) - _check_or_update(f"js_bar_{n_bars}bars", timing, update_benchmarks) + _check_or_update(f"js_bar_{n_bars}bars", timing, update_benchmarks, + ignore_hardware=ignore_hardware) # ── interaction: 2D pan ─────────────────────────────────────────────────────── -def test_bench_interaction_2d_pan(bench_page, update_benchmarks): +def test_bench_interaction_2d_pan(bench_page, update_benchmarks, ignore_hardware): """Interaction benchmark: 2D pan drag (20 mousemove events on 512² image).""" rng = np.random.default_rng(4) fig, ax = apl.subplots(1, 1, figsize=(512 + _PAD_L + _PAD_R, @@ -343,9 +373,9 @@ def test_bench_interaction_2d_pan(bench_page, update_benchmarks): timing = page.evaluate(f"() => window._aplTiming && window._aplTiming['{panel_id}']") _check_or_update("js_interaction_2d_pan", timing, update_benchmarks, - fail_ratio=2.5, warn_ratio=1.75) + fail_ratio=2.5, warn_ratio=1.75, ignore_hardware=ignore_hardware) -def test_bench_interaction_2d_zoom(bench_page, update_benchmarks): +def test_bench_interaction_2d_zoom(bench_page, update_benchmarks, ignore_hardware): """Interaction benchmark: 2D wheel zoom (20 wheel events on 512² image).""" rng = np.random.default_rng(5) fig, ax = apl.subplots(1, 1, figsize=(512 + _PAD_L + _PAD_R, @@ -376,9 +406,9 @@ def test_bench_interaction_2d_zoom(bench_page, update_benchmarks): timing = page.evaluate(f"() => window._aplTiming && window._aplTiming['{panel_id}']") _check_or_update("js_interaction_2d_zoom", timing, update_benchmarks, - fail_ratio=2.5, warn_ratio=1.75) + fail_ratio=2.5, warn_ratio=1.75, ignore_hardware=ignore_hardware) -def test_bench_interaction_1d_pan(bench_page, update_benchmarks): +def test_bench_interaction_1d_pan(bench_page, update_benchmarks, ignore_hardware): """Interaction benchmark: 1D pan drag (20 mousemove events, 10K points).""" rng = np.random.default_rng(6) pw, ph = 640, 320 @@ -414,6 +444,4 @@ def test_bench_interaction_1d_pan(bench_page, update_benchmarks): timing = page.evaluate(f"() => window._aplTiming && window._aplTiming['{panel_id}']") _check_or_update("js_interaction_1d_pan", timing, update_benchmarks, - fail_ratio=2.5, warn_ratio=1.75) - - + fail_ratio=2.5, warn_ratio=1.75, ignore_hardware=ignore_hardware) diff --git a/tests/test_benchmarks_py.py b/tests/test_benchmarks_py.py index a5261a5..320ccd0 100644 --- a/tests/test_benchmarks_py.py +++ b/tests/test_benchmarks_py.py @@ -86,8 +86,19 @@ def _timeit_ms(stmt, *, number: int = NUMBER, repeats: int = REPEATS) -> dict: } -def _check_or_update(name: str, timing: dict, update: bool) -> None: - """Assert *timing* is within threshold of the stored baseline, or write it.""" +def _check_or_update(name: str, timing: dict, update: bool, + ignore_hardware: bool = False) -> None: + """Assert *timing* is within threshold of the stored baseline, or write it. + + Hardware matching + ----------------- + When the current hostname differs from the one recorded in ``_meta.host`` + the test still runs and compares against the baseline, but any result that + would normally be a *failure* is downgraded to a *warning* instead. This + keeps CI visible without causing spurious failures on different machines. + Pass ``--ignore-hardware`` to restore full fail/warn behaviour regardless + of hostname. + """ baselines = _load_baselines() if update: @@ -106,18 +117,33 @@ def _check_or_update(name: str, timing: dict, update: bool) -> None: f"[{name}] No baseline — run with --update-benchmarks to create one" ) + # Determine whether we're on the same hardware as the baseline. + meta = baselines.get("_meta", {}) + baseline_host = meta.get("host") + current_host = socket.gethostname() + hw_match = ignore_hardware or not baseline_host or (baseline_host == current_host) + hw_note = ( + "" + if hw_match + else f" [different hardware: baseline={baseline_host!r}, current={current_host!r}]" + ) + baseline = baselines[name] ratio = timing["min_ms"] / baseline["min_ms"] if ratio > FAIL_RATIO: - pytest.fail( + msg = ( f"[{name}] REGRESSION: min {timing['min_ms']:.3f} ms vs " - f"baseline {baseline['min_ms']:.3f} ms ({ratio:.2f}×)" + f"baseline {baseline['min_ms']:.3f} ms ({ratio:.2f}×){hw_note}" ) - if ratio > WARN_RATIO: + if hw_match: + pytest.fail(msg) + else: + warnings.warn(msg, stacklevel=2) + elif ratio > WARN_RATIO: warnings.warn( f"[{name}] Perf degraded: min {timing['min_ms']:.3f} ms vs " - f"baseline {baseline['min_ms']:.3f} ms ({ratio:.2f}×)", + f"baseline {baseline['min_ms']:.3f} ms ({ratio:.2f}×){hw_note}", stacklevel=2, ) @@ -147,7 +173,7 @@ def _check_or_update(name: str, timing: dict, update: bool) -> None: "h,w,is_slow", _IMSHOW_SIZES, ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], ) -def test_bench_py_normalize(h, w, is_slow, update_benchmarks, run_slow): +def test_bench_py_normalize(h, w, is_slow, update_benchmarks, run_slow, ignore_hardware): """Python: ``_normalize_image`` for a ``{h}×{w}`` float32 array.""" if is_slow and not run_slow: pytest.skip(f"Skipping {h}x{w} — pass --run-slow to include") @@ -156,7 +182,7 @@ def test_bench_py_normalize(h, w, is_slow, update_benchmarks, run_slow): data = rng.uniform(size=(h, w)).astype(np.float32) timing = _timeit_ms(stmt=lambda: _normalize_image(data)) - _check_or_update(f"py_normalize_{h}x{w}", timing, update_benchmarks) + _check_or_update(f"py_normalize_{h}x{w}", timing, update_benchmarks, ignore_hardware) # --------------------------------------------------------------------------- @@ -167,7 +193,7 @@ def test_bench_py_normalize(h, w, is_slow, update_benchmarks, run_slow): "h,w,is_slow", _IMSHOW_SIZES, ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], ) -def test_bench_py_encode(h, w, is_slow, update_benchmarks, run_slow): +def test_bench_py_encode(h, w, is_slow, update_benchmarks, run_slow, ignore_hardware): """Python: ``_encode_bytes`` (base64) for a ``{h}×{w}`` uint8 array.""" if is_slow and not run_slow: pytest.skip(f"Skipping {h}x{w} — pass --run-slow to include") @@ -176,7 +202,7 @@ def test_bench_py_encode(h, w, is_slow, update_benchmarks, run_slow): img_u8, _, _ = _normalize_image(rng.uniform(size=(h, w)).astype(np.float32)) timing = _timeit_ms(stmt=lambda: Plot2D._encode_bytes(img_u8)) - _check_or_update(f"py_encode_{h}x{w}", timing, update_benchmarks) + _check_or_update(f"py_encode_{h}x{w}", timing, update_benchmarks, ignore_hardware) # --------------------------------------------------------------------------- @@ -187,7 +213,7 @@ def test_bench_py_encode(h, w, is_slow, update_benchmarks, run_slow): "h,w,is_slow", _IMSHOW_SIZES, ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], ) -def test_bench_py_serialize_2d(h, w, is_slow, update_benchmarks, run_slow): +def test_bench_py_serialize_2d(h, w, is_slow, update_benchmarks, run_slow, ignore_hardware): """Python: ``json.dumps(plot.to_state_dict())`` for a ``{h}×{w}`` imshow.""" if is_slow and not run_slow: pytest.skip(f"Skipping {h}x{w} — pass --run-slow to include") @@ -197,7 +223,7 @@ def test_bench_py_serialize_2d(h, w, is_slow, update_benchmarks, run_slow): plot = ax.imshow(rng.uniform(size=(h, w)).astype(np.float32)) timing = _timeit_ms(stmt=lambda: json.dumps(plot.to_state_dict())) - _check_or_update(f"py_serialize_2d_{h}x{w}", timing, update_benchmarks) + _check_or_update(f"py_serialize_2d_{h}x{w}", timing, update_benchmarks, ignore_hardware) # --------------------------------------------------------------------------- @@ -208,14 +234,14 @@ def test_bench_py_serialize_2d(h, w, is_slow, update_benchmarks, run_slow): "n_pts", _PLOT1D_SIZES, ids=[str(n) for n in _PLOT1D_SIZES], ) -def test_bench_py_serialize_1d(n_pts, update_benchmarks): +def test_bench_py_serialize_1d(n_pts, update_benchmarks, ignore_hardware): """Python: ``json.dumps(plot.to_state_dict())`` for a ``{n_pts}``-point 1D plot.""" rng = np.random.default_rng(3) fig, ax = apl.subplots(1, 1, figsize=(640, 320)) plot = ax.plot(np.cumsum(rng.standard_normal(n_pts))) timing = _timeit_ms(stmt=lambda: json.dumps(plot.to_state_dict())) - _check_or_update(f"py_serialize_1d_{n_pts}pts", timing, update_benchmarks) + _check_or_update(f"py_serialize_1d_{n_pts}pts", timing, update_benchmarks, ignore_hardware) # --------------------------------------------------------------------------- @@ -226,7 +252,7 @@ def test_bench_py_serialize_1d(n_pts, update_benchmarks): "h,w,is_slow", _IMSHOW_SIZES, ids=[f"{h}x{w}" for h, w, _ in _IMSHOW_SIZES], ) -def test_bench_py_set_data_2d(h, w, is_slow, update_benchmarks, run_slow): +def test_bench_py_set_data_2d(h, w, is_slow, update_benchmarks, run_slow, ignore_hardware): """Python: full ``plot.set_data(data)`` round-trip for a ``{h}×{w}`` image. Covers the complete Python-side cost of a live data refresh: @@ -249,5 +275,4 @@ def _one_update(): idx[0] += 1 timing = _timeit_ms(stmt=_one_update) - _check_or_update(f"py_update_2d_{h}x{w}", timing, update_benchmarks) - + _check_or_update(f"py_set_data_2d_{h}x{w}", timing, update_benchmarks, ignore_hardware) From 5af14949b14a4c2880da45078c43b42c50f6e7b3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 5 Apr 2026 08:39:37 -0500 Subject: [PATCH 11/11] Remove benchmarks workflow. --- .github/workflows/benchmarks.yml | 78 -------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 .github/workflows/benchmarks.yml diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml deleted file mode 100644 index 15a7c3b..0000000 --- a/.github/workflows/benchmarks.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Benchmarks - -on: - pull_request: - branches: [main] - push: - branches: [main] - workflow_dispatch: {} - -# Cancel in-flight runs for the same PR / branch. -concurrency: - group: benchmarks-${{ github.ref }} - cancel-in-progress: true - -jobs: - bench-py: - name: Python serialisation benchmarks - runs-on: ubuntu-latest - - steps: - - name: Check out HEAD - uses: actions/checkout@v4 - with: - path: head - - # For PRs compare against the target branch; for pushes compare against - # the previous commit. Skipped on the first push to a new branch - # (all-zero before SHA) — benchmark tests will skip automatically. - - name: Check out BASE - if: > - github.event_name == 'pull_request' || - (github.event_name == 'push' && - github.event.before != '0000000000000000000000000000000000000000') - uses: actions/checkout@v4 - with: - ref: >- - ${{ github.event_name == 'pull_request' - && github.base_ref - || github.event.before }} - path: base - continue-on-error: true - - - uses: astral-sh/setup-uv@v5 - - - name: Install base dependencies - if: hashFiles('base/pyproject.toml') != '' - run: cd base && uv sync - - - name: Install head dependencies - run: cd head && uv sync - - # Both steps run on the same runner so only the ratio matters — - # absolute ms differences from different hardware cancel out. - - name: Record base branch timings - if: hashFiles('base/pyproject.toml') != '' - run: | - cd base - uv run pytest tests/test_benchmarks_py.py \ - --update-benchmarks \ - --baselines-path /tmp/ci_baselines.json \ - -v - continue-on-error: true - - - name: Run benchmarks on HEAD - run: | - cd head - uv run pytest tests/test_benchmarks_py.py \ - --baselines-path /tmp/ci_baselines.json \ - -v - - - name: Upload timings - if: always() - uses: actions/upload-artifact@v4 - with: - name: bench-py-${{ github.sha }} - path: /tmp/ci_baselines.json - if-no-files-found: ignore -