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})