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..07114e6 --- /dev/null +++ b/Examples/Benchmarks/plot_benchmark_comparison.py @@ -0,0 +1,559 @@ +""" +Plot Update Comparison +====================== + +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:: + + 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 Python measurement covers +------------------------------------- + ++---------------+---------------------------------------------------------------+ +| Library | What is timed | ++===============+===============================================================+ +| anyplotlib | ``plot.set_data(data)`` — float → uint8 normalise → base64 | +| | encode → LUT rebuild → state-dict assembly → json.dumps → | +| | traitlet dispatch to JS renderer. | ++---------------+---------------------------------------------------------------+ +| 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 | +| | 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 full JSON document patch that Bokeh.js receives. Canvas | +| | render is additional. | ++---------------+---------------------------------------------------------------+ + +""" +# sphinx_gallery_start_ignore +from __future__ import annotations + +import pathlib +import tempfile +import timeit +import warnings + +import matplotlib +matplotlib.use("Agg") # must be set before pyplot import — used for ipympl measurement +import matplotlib.pyplot as plt +import numpy as np + +# --------------------------------------------------------------------------- +# 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 +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) + + +# 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] +_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", "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} + +# --------------------------------------------------------------------------- +# 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) + _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.set_data(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) + ) + + # ── 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 + _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["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: + _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: + _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.set_data(new_y) + return _fn + + results_1d["anyplotlib"][n_pts] = _timeit_min_ms( + _make_apl1d(_plot_apl1, _new_ys_apl) + ) + + # ── 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) + + def _make_mpl1d(line, canvas, new_y): + def _fn(): + line.set_ydata(new_y) + canvas.draw() + return _fn + + results_1d["ipympl"][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 + +# --------------------------------------------------------------------------- +# 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", + "ipympl": "#E64A19", + "plotly": "#7B1FA2", + "bokeh": "#2E7D32", +} + +# Short legend labels shown inside the anyplotlib bar chart. +_LABELS = { + "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 _results_to_array(results, sizes): + """Build a (N_sizes, N_libs) float array. + + 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) + +# sphinx_gallery_end_ignore + +#%% +# --------------------------------------------------------------------------- +# 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) + +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 +# sphinx_gallery_end_ignore + +# %% +# --------------------------------------------------------------------------- +# 1-D line update (Python pre-render, all four libraries) +# --------------------------------------------------------------------------- + +# 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 +# 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. +# +# 2D Image Plotting Costs +# ----------------------- + +# 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 + +#%% +# Scatter Plotting Costs +# ------------------------- + +# 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/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 6433f00..05772d0 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.set_data` """ 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.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) +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.set_data(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"] @@ -70,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", @@ -100,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_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/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: 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..33e12fa 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -82,7 +82,74 @@ function render({ model, el }) { return arr[lo]+t*(arr[lo+1]-arr[lo]); } - // ── outer DOM ──────────────────────────────────────────────────────────── + // ── 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 + // 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 +274,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 +338,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 +362,10 @@ function render({ model, el }) { statusBar.style.cssText = '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;'; @@ -319,6 +390,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,18 +401,31 @@ 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: {}, 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) @@ -578,6 +663,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,15 +1116,45 @@ 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); 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 || {}; @@ -1317,9 +1433,25 @@ 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; + + // ── 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||''; @@ -1460,13 +1592,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, @@ -1551,7 +1685,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||[]; @@ -1606,9 +1741,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; @@ -1683,7 +1819,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; @@ -1714,10 +1851,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 ──────────────────────────────────────────────── @@ -1796,7 +1936,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--){ @@ -2154,7 +2296,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,{ @@ -2179,7 +2321,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'; @@ -2385,7 +2527,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--){ @@ -2418,7 +2561,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]; @@ -2663,39 +2807,72 @@ 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 }; } } function drawBar(p) { const st = p.state; if (!st) return; + _recordFrame(p); const { pw, ph, plotCtx: ctx } = p; const r = _plotRect1d(pw, ph); @@ -2703,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(); + } } } @@ -2738,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(); @@ -2779,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)); + } } } } @@ -2802,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(); + } } } @@ -2821,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'; @@ -2841,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; @@ -2877,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(); @@ -2895,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 @@ -2967,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 @@ -2975,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'; @@ -2997,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'; }); @@ -3005,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, }); }); @@ -3100,6 +3420,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/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 0f25f04..b867625 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. @@ -427,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 @@ -466,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 @@ -644,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: @@ -731,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. @@ -750,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 @@ -1224,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) @@ -1336,48 +1404,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() @@ -1479,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"] @@ -1497,29 +1567,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 +1798,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 +1837,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 @@ -1784,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. @@ -1812,7 +1909,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 +1917,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 +1931,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 +1996,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), @@ -2825,78 +2923,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": [], } @@ -2916,27 +3095,51 @@ def to_state_dict(self) -> dict: return d # ------------------------------------------------------------------ - # Data update + # Data # ------------------------------------------------------------------ - 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 set_data(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: @@ -2961,6 +3164,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 # ------------------------------------------------------------------ @@ -3113,6 +3331,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/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/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/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 3851b3f..7d87b5d 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: auto_examples/Benchmarks/plot_benchmark_comparison
+ :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/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/baselines/bar_basic.png b/tests/baselines/bar_basic.png
new file mode 100644
index 0000000..fb77ddf
Binary files /dev/null and b/tests/baselines/bar_basic.png differ
diff --git a/tests/benchmarks/baselines.json b/tests/benchmarks/baselines.json
new file mode 100644
index 0000000..606947f
--- /dev/null
+++ b/tests/benchmarks/baselines.json
@@ -0,0 +1,321 @@
+{
+ "_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-04T13:16:01.566327+00:00",
+ "host": "Carters-MacBook-Air.local"
+ },
+ "py_normalize_64x64": {
+ "min_ms": 0.013,
+ "mean_ms": 0.037,
+ "max_ms": 0.134,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:58.956545+00:00"
+ },
+ "py_encode_64x64": {
+ "min_ms": 0.006,
+ "mean_ms": 0.007,
+ "max_ms": 0.011,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.287130+00:00"
+ },
+ "py_serialize_2d_64x64": {
+ "min_ms": 0.074,
+ "mean_ms": 0.075,
+ "max_ms": 0.078,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.653130+00:00"
+ },
+ "py_serialize_1d_100pts": {
+ "min_ms": 0.014,
+ "mean_ms": 0.027,
+ "max_ms": 0.071,
+ "n": 15,
+ "updated_at": "2026-04-04T13:16:00.033677+00:00"
+ },
+ "py_serialize_1d_1000pts": {
+ "min_ms": 0.072,
+ "mean_ms": 0.13,
+ "max_ms": 0.204,
+ "n": 15,
+ "updated_at": "2026-04-04T13:16:00.039841+00:00"
+ },
+ "py_serialize_1d_10000pts": {
+ "min_ms": 0.658,
+ "mean_ms": 0.808,
+ "max_ms": 1.267,
+ "n": 15,
+ "updated_at": "2026-04-04T13:16:00.058898+00:00"
+ },
+ "py_serialize_1d_100000pts": {
+ "min_ms": 16.038,
+ "mean_ms": 22.742,
+ "max_ms": 34.052,
+ "n": 15,
+ "updated_at": "2026-04-04T13:16:00.474681+00:00"
+ },
+ "py_set_data_2d_64x64": {
+ "min_ms": 0.473,
+ "mean_ms": 0.767,
+ "max_ms": 1.102,
+ "n": 15,
+ "updated_at": "2026-04-04T13:16:00.499970+00:00"
+ },
+ "py_normalize_256x256": {
+ "min_ms": 0.084,
+ "mean_ms": 0.102,
+ "max_ms": 0.128,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:58.960018+00:00"
+ },
+ "py_normalize_512x512": {
+ "min_ms": 0.569,
+ "mean_ms": 0.683,
+ "max_ms": 0.829,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:58.973101+00:00"
+ },
+ "py_normalize_1024x1024": {
+ "min_ms": 2.941,
+ "mean_ms": 3.642,
+ "max_ms": 4.4,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.035741+00:00"
+ },
+ "py_normalize_2048x2048": {
+ "min_ms": 12.626,
+ "mean_ms": 14.284,
+ "max_ms": 17.004,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.283335+00:00"
+ },
+ "py_encode_256x256": {
+ "min_ms": 0.107,
+ "mean_ms": 0.115,
+ "max_ms": 0.127,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.290355+00:00"
+ },
+ "py_encode_512x512": {
+ "min_ms": 0.394,
+ "mean_ms": 0.434,
+ "max_ms": 0.485,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.299651+00:00"
+ },
+ "py_encode_1024x1024": {
+ "min_ms": 1.563,
+ "mean_ms": 1.688,
+ "max_ms": 1.786,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.335735+00:00"
+ },
+ "py_encode_2048x2048": {
+ "min_ms": 8.085,
+ "mean_ms": 9.094,
+ "max_ms": 10.714,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.516158+00:00"
+ },
+ "py_serialize_2d_256x256": {
+ "min_ms": 0.25,
+ "mean_ms": 0.253,
+ "max_ms": 0.256,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.659835+00:00"
+ },
+ "py_serialize_2d_512x512": {
+ "min_ms": 0.747,
+ "mean_ms": 0.771,
+ "max_ms": 0.834,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.676620+00:00"
+ },
+ "py_serialize_2d_1024x1024": {
+ "min_ms": 2.803,
+ "mean_ms": 3.032,
+ "max_ms": 3.296,
+ "n": 15,
+ "updated_at": "2026-04-04T13:15:59.737870+00:00"
+ },
+ "py_serialize_2d_2048x2048": {
+ "min_ms": 11.427,
+ "mean_ms": 14.907,
+ "max_ms": 23.513,
+ "n": 15,
+ "updated_at": "2026-04-04T13:16:00.020751+00:00"
+ },
+ "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_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_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_set_data_2d_2048x2048": {
+ "min_ms": 31.763,
+ "mean_ms": 34.136,
+ "max_ms": 38.112,
+ "n": 15,
+ "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 a445f35..8f7cb8d 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,41 @@ 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",
+ )
+ 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,
+ 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")
@@ -40,6 +76,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)
# ---------------------------------------------------------------------------
@@ -213,3 +296,155 @@ 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(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.
+
+ 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);
+ })
+ """
+ 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_bar.py b/tests/test_bar.py
index 5ff3396..3f4812b 100644
--- a/tests/test_bar.py
+++ b/tests/test_bar.py
@@ -5,18 +5,20 @@
Tests for the bar chart (PlotBar) functionality.
Covers:
- * Construction – default and explicit arguments
+ * Construction – default and explicit arguments (matplotlib-aligned API)
* State dict contents and data integrity
* Orientation (vertical / horizontal)
- * Colour options: single colour and per-bar colours
- * Bar-width, baseline, and show_values flags
- * x_labels and x_centers
+ * Colour options: single colour, per-bar colours, group colours
+ * Bar-width, baseline/bottom, and show_values flags
+ * x (positions or category labels) and x_labels
* Range / padding calculations
- * update() – value replacement and axis recalculation
- * Display-setting mutations: set_color, set_colors, set_show_values
+ * Grouped bars – 2-D height array, group_labels, group_colors
+ * Log scale – log_scale flag, clamping, set_log_scale()
+ * 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
- * Callback API: on_click, on_changed, disconnect
+ * Callback API: on_click (incl. group_index/group_value), on_changed, disconnect
* Edge cases: single bar, negative values, all-equal values, large N
* Validation errors for bad inputs
* repr()
@@ -38,7 +40,7 @@
# ─────────────────────────────────────────────────────────────────────────────
def _make_bar(values=None, **kwargs) -> 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,71 +213,187 @@ 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_set_data_2d_values(self):
+ fig, ax = apl.subplots(1, 1)
+ p = ax.bar(["A", "B"], [[1, 2], [3, 4]])
+ p.set_data([[10, 20], [30, 40]])
+ assert _state(p)["values"] == pytest.approx(np.array([[10, 20], [30, 40]]))
+
+ 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.set_data([[1, 2, 3], [4, 5, 6]]) # 3 groups → error
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# 5. Log scale
# ─────────────────────────────────────────────────────────────────────────────
-class TestPlotBarUpdate:
+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. set_data() — value replacement
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestPlotBarSetData:
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])
+ 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.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_2d_raises(self):
+ def test_set_data_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.set_data(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"
@@ -291,10 +441,10 @@ 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([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_benchmarks.py b/tests/test_benchmarks.py
new file mode 100644
index 0000000..40a7060
--- /dev/null
+++ b/tests/test_benchmarks.py
@@ -0,0 +1,447 @@
+"""
+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 = 2.00 # >100 % 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,
+ fail_ratio: float = FAIL_RATIO,
+ warn_ratio: float = WARN_RATIO,
+ ignore_hardware: bool = False) -> 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.
+ 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?)")
+
+ 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"
+ )
+
+ # 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:
+ msg = (
+ f"[{name}] REGRESSION: mean {timing['mean_ms']:.2f} ms vs "
+ f"baseline {baseline['mean_ms']:.2f} ms ({ratio:.2f}×){hw_note}"
+ )
+ 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}×){hw_note}",
+ 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, 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")
+
+ 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,
+ ignore_hardware=ignore_hardware)
+
+
+# ── 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, 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))
+ 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,
+ ignore_hardware=ignore_hardware)
+
+
+# ── 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, 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)
+ 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,
+ ignore_hardware=ignore_hardware)
+
+
+# ── 3D surface benchmark ──────────────────────────────────────────────────────
+
+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)
+ 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,
+ 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, 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))
+ 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,
+ ignore_hardware=ignore_hardware)
+
+
+# ── interaction: 2D pan ───────────────────────────────────────────────────────
+
+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,
+ 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,
+ fail_ratio=2.5, warn_ratio=1.75, ignore_hardware=ignore_hardware)
+
+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,
+ 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,
+ fail_ratio=2.5, warn_ratio=1.75, ignore_hardware=ignore_hardware)
+
+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
+ 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,
+ 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
new file mode 100644
index 0000000..320ccd0
--- /dev/null
+++ b/tests/test_benchmarks_py.py
@@ -0,0 +1,278 @@
+"""
+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.set_data(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.5`` (50 % slower).
+"""
+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 = 2.00
+WARN_RATIO = 1.25
+
+# 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,
+ 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:
+ 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"
+ )
+
+ # 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:
+ msg = (
+ f"[{name}] REGRESSION: min {timing['min_ms']:.3f} ms vs "
+ f"baseline {baseline['min_ms']:.3f} ms ({ratio:.2f}×){hw_note}"
+ )
+ 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}×){hw_note}",
+ 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, 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")
+
+ 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, ignore_hardware)
+
+
+# ---------------------------------------------------------------------------
+# 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, 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")
+
+ 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, ignore_hardware)
+
+
+# ---------------------------------------------------------------------------
+# 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, 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")
+
+ 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, ignore_hardware)
+
+
+# ---------------------------------------------------------------------------
+# 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, 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, ignore_hardware)
+
+
+# ---------------------------------------------------------------------------
+# 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_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:
+ ``_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.set_data(frames[idx[0] % len(frames)])
+ idx[0] += 1
+
+ timing = _timeit_ms(stmt=_one_update)
+ _check_or_update(f"py_set_data_2d_{h}x{w}", timing, update_benchmarks, ignore_hardware)
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_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):
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})