Skip to content

Commit 3198c31

Browse files
authored
Merge pull request #4 from CSSFrancis/benchmark
Add Benchmark Tests
2 parents ea7082c + 5af1494 commit 3198c31

25 files changed

Lines changed: 3288 additions & 413 deletions

Examples/Benchmarks/README.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Benchmarks
2+
----------
3+
4+
Timing comparisons for the Python-side data-push pipeline in anyplotlib,
5+
matplotlib, Plotly, and Bokeh. All measurements capture only the
6+
**Python serialisation cost** — the bottleneck in a live Jupyter session
7+
where new data must be encoded and dispatched to the browser on every frame.
8+

Examples/Benchmarks/plot_benchmark_comparison.py

Lines changed: 559 additions & 0 deletions
Large diffs are not rendered by default.

Examples/Interactive/plot_interactive_fft.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def _roi_released(event):
175175
log_mag, freq_x, freq_y = _compute_fft(image, x0, y0, w, h)
176176

177177
# Push updated FFT into the right panel
178-
v_fft.update(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/\u00c5")
178+
v_fft.set_data(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/\u00c5")
179179

180180

181181
fig

Examples/plot_3d.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,11 @@
6363
# %%
6464
# Update the surface data live
6565
# ----------------------------
66-
# Call :meth:`~anyplotlib.figure_plots.Plot3D.update` to replace the geometry
66+
# Call :meth:`~anyplotlib.figure_plots.Plot3D.set_data` to replace the geometry
6767
# without recreating the panel.
6868

6969
ZZ2 = np.cos(np.sqrt(XX ** 2 + YY ** 2))
70-
surf.update(XX, YY, ZZ2)
70+
surf.set_data(XX, YY, ZZ2)
7171
surf.set_colormap("plasma")
7272
surf.set_view(azimuth=30, elevation=40)
7373

Examples/plot_bar.py

Lines changed: 148 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,159 @@
22
Bar Chart
33
=========
44
5-
Demonstrate :meth:`~anyplotlib.figure_plots.Axes.bar` with vertical and
6-
horizontal orientations, per-bar colours, category labels, and live data
7-
updates via :meth:`~anyplotlib.figure_plots.PlotBar.update`.
5+
Demonstrate :meth:`~anyplotlib.figure_plots.Axes.bar` with:
86
9-
Three separate figures are shown:
10-
11-
1. **Vertical bar chart** – monthly sales data with a uniform colour.
12-
2. **Horizontal bar chart** – ranked items with per-bar colours and value
13-
labels.
14-
3. **Side-by-side comparison** – two panels sharing the same figure; one
15-
panel updates its data to show a different quarter.
7+
* **Matplotlib-aligned API** — ``ax.bar(x, height, width, bottom, …)``
8+
* Vertical and horizontal orientations, per-bar colours, category labels
9+
* **Grouped bars** — pass a 2-D *height* array ``(N, G)``
10+
* **Log-scale value axis** — ``log_scale=True``
11+
* Live data updates via :meth:`~anyplotlib.figure_plots.PlotBar.set_data`
1612
"""
1713
import numpy as np
1814
import anyplotlib as vw
1915

2016
rng = np.random.default_rng(7)
2117

18+
# ── 1. Vertical bar chart — monthly sales ────────────────────────────────────
19+
# The first positional argument is now *x* (positions or labels), matching
20+
# ``matplotlib.pyplot.bar(x, height, width=0.8, bottom=0.0, ...)``.
21+
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
22+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
23+
sales = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78],
24+
dtype=float)
25+
26+
fig1, ax1 = vw.subplots(1, 1, figsize=(640, 340))
27+
bar1 = ax1.bar(
28+
months, # x — category strings become x_labels automatically
29+
sales, # height
30+
width=0.6,
31+
color="#4fc3f7",
32+
show_values=True,
33+
units="Month",
34+
y_units="Units sold",
35+
)
36+
fig1
37+
38+
# %%
39+
# Horizontal bar chart — ranked items
40+
# -------------------------------------
41+
# Set ``orient="h"`` for a horizontal layout. Pass a list of CSS colours
42+
# to ``colors`` to give each bar its own colour.
43+
44+
categories = ["NumPy", "SciPy", "Matplotlib", "Pandas", "Scikit-learn",
45+
"PyTorch", "TensorFlow", "JAX", "Polars", "Dask"]
46+
scores = np.array([95, 88, 91, 87, 83, 79, 76, 72, 68, 65], dtype=float)
47+
48+
palette = [
49+
"#ef5350", "#ec407a", "#ab47bc", "#7e57c2", "#42a5f5",
50+
"#26c6da", "#26a69a", "#66bb6a", "#d4e157", "#ffa726",
51+
]
52+
53+
fig2, ax2 = vw.subplots(1, 1, figsize=(540, 400))
54+
bar2 = ax2.bar(
55+
categories,
56+
scores,
57+
orient="h",
58+
colors=palette,
59+
width=0.65,
60+
show_values=True,
61+
y_units="Popularity score",
62+
)
63+
fig2
64+
65+
# %%
66+
# Grouped bar chart — quarterly comparison
67+
# -----------------------------------------
68+
# Pass a 2-D *height* array of shape ``(N, G)`` to draw *G* bars side by
69+
# side for each category. Provide ``group_labels`` to show a legend and
70+
# ``group_colors`` to customise each group's colour.
71+
72+
quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
73+
q_data = np.array([
74+
[42, 58, 51], # Jan — Q1, Q2, Q3
75+
[55, 61, 59], # Feb
76+
[48, 70, 65], # Mar
77+
[63, 75, 71], # Apr
78+
[71, 69, 80], # May
79+
[68, 83, 77], # Jun
80+
], dtype=float) # shape (6, 3) → 6 categories, 3 groups
81+
82+
fig3, ax3 = vw.subplots(1, 1, figsize=(680, 340))
83+
bar3 = ax3.bar(
84+
quarters,
85+
q_data,
86+
width=0.8,
87+
group_labels=["Q1", "Q2", "Q3"],
88+
group_colors=["#4fc3f7", "#ff7043", "#66bb6a"],
89+
show_values=False,
90+
y_units="Sales",
91+
)
92+
fig3
93+
94+
# %%
95+
# Log-scale value axis
96+
# ---------------------
97+
# Set ``log_scale=True`` for a logarithmic value axis. Non-positive values
98+
# are clamped to ``1e-10`` — no error is raised. Tick marks are placed at
99+
# each decade (10⁰, 10¹, 10², …) with faint minor gridlines at 2×, 3×, 5×
100+
# multiples.
101+
102+
log_labels = ["A", "B", "C", "D", "E"]
103+
log_vals = np.array([1, 10, 100, 1_000, 10_000], dtype=float)
104+
105+
fig4, ax4 = vw.subplots(1, 1, figsize=(500, 340))
106+
bar4 = ax4.bar(
107+
log_labels,
108+
log_vals,
109+
log_scale=True,
110+
color="#ab47bc",
111+
show_values=True,
112+
y_units="Count (log scale)",
113+
)
114+
fig4
115+
116+
# %%
117+
# Side-by-side comparison — update data live
118+
# -------------------------------------------
119+
# Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one figure.
120+
# Call :meth:`~anyplotlib.figure_plots.PlotBar.set_data` to swap in Q2 data —
121+
# the value-axis range recalculates automatically.
122+
123+
q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float)
124+
q2 = np.array([58, 61, 70, 75, 69, 83, 90, 88, 77, 64, 71, 95], dtype=float)
125+
all_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
126+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
127+
128+
fig5, (ax_left, ax_right) = vw.subplots(1, 2, figsize=(820, 320))
129+
bar_left = ax_left.bar(
130+
all_months, q1, width=0.6,
131+
color="#4fc3f7", show_values=False, y_units="Q1 sales",
132+
)
133+
bar_right = ax_right.bar(
134+
all_months, q1, width=0.6,
135+
color="#ff7043", show_values=False, y_units="Q2 sales",
136+
)
137+
bar_right.set_data(q2) # swap in Q2 — axis range recalculates automatically
138+
139+
fig5
140+
141+
# %%
142+
# Mutate colours, annotations, and scale at runtime
143+
# --------------------------------------------------
144+
# :meth:`~anyplotlib.figure_plots.PlotBar.set_color` repaints all bars,
145+
# :meth:`~anyplotlib.figure_plots.PlotBar.set_show_values` toggles labels,
146+
# :meth:`~anyplotlib.figure_plots.PlotBar.set_log_scale` switches the
147+
# value-axis between linear and logarithmic.
148+
149+
bar1.set_color("#ff7043")
150+
bar1.set_show_values(False)
151+
fig1
152+
153+
import numpy as np
154+
import anyplotlib as vw
155+
156+
rng = np.random.default_rng(7)
157+
22158
# ── 1. Vertical bar chart — monthly sales ────────────────────────────────────
23159
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
24160
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
@@ -70,7 +206,7 @@
70206
# -------------------------------------------
71207
# Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one
72208
# :func:`~anyplotlib.figure_plots.subplots` figure. Call
73-
# :meth:`~anyplotlib.figure_plots.PlotBar.update` to swap in Q2 data for the
209+
# :meth:`~anyplotlib.figure_plots.PlotBar.set_data` to swap in Q2 data for the
74210
# right panel, demonstrating how the axis range re-calculates automatically.
75211

76212
quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
@@ -100,7 +236,7 @@
100236
)
101237

102238
# Swap in Q2 data — range is recalculated automatically
103-
bar_right.update(q2)
239+
bar_right.set_data(q2)
104240

105241
fig3
106242

anyplotlib/FIGURE_ESM.md

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -294,30 +294,64 @@ Writes to `model.event_json` + `save_changes()`.
294294

295295
---
296296

297-
### Bar chart (lines 2343–2697)
297+
### Bar chart (lines 2803–2970)
298298

299299
State fields:
300300
```
301-
st.values, st.x_centers, st.x_labels
302-
st.bar_color, st.bar_colors, st.bar_width
301+
st.values [[g0,g1,...], ...] always 2-D (N×G) list; G=1 for ungrouped
302+
st.groups int — number of bar groups per category slot (≥1)
303+
st.x_centers, st.x_labels
304+
st.bar_color, st.bar_colors (ungrouped: per-bar colours)
305+
st.group_colors list[str], length G — colour per group; overrides bar_color
306+
st.group_labels list[str], length G — legend labels (shown when groups > 1)
307+
st.bar_width fraction of slot occupied by all bars in the slot (0–1)
303308
st.orient 'v' (default) | 'h'
304-
st.baseline value axis zero line
305-
st.data_min/max current visible value-axis range — modified by zoom/pan
309+
st.baseline value-axis root; skipped for log scale
310+
st.log_scale bool — logarithmic value axis; non-positive values clamped to 1e-10
311+
st.data_min/max current visible value-axis range
306312
st.x_axis, st.view_x0/x1 widget coordinate system (category axis)
307313
st.overlay_widgets
308314
```
309315

310316
| Function | Lines | Purpose |
311317
|----------|-------|---------|
312-
| `_barGeom(st,r)` | 2347 | Per-bar geometry: slot/bar px, `xToPx`/`yToPx`, baseline px |
313-
| `drawBar(p)` | 2376 | **Main bar render**: grid, bars (clipped), value labels, axis, ticks; calls `drawOverlay1d` |
314-
| `_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` |
315-
316-
#### Bar zoom/pan model
317-
Unlike 1D (which zooms `view_x0/x1`), bar zooms and pans the **value axis** by
318-
modifying `st.data_min`/`st.data_max` directly. `view_x0/x1` stays fixed at
319-
0/1 so overlay widgets (vlines, hlines) keep correct positions throughout.
320-
`origDataMin/Max` are captured on first interaction (JS closure) for 'r' reset.
318+
| `_barGeom(st,r)` | ~2808 | Per-bar geometry: slot/group pixel sizes, `xToPx`/`yToPx`, `groupOffsetPx(g)`, `getVal(i,g)`, log-scale coordinate mappers, `basePx` |
319+
| `drawBar(p)` | ~2870 | **Main bar render**: log/linear grid, grouped bars (clipped), value labels, axis borders, log/linear ticks, group legend |
320+
| `_attachEventsBar(p)` | ~2977 | **Full interaction**: widget drag, hover/tooltip (shows group label), `on_click` (emits `bar_index`, `group_index`, `value`, `group_value`), keyboard |
321+
322+
#### `_barGeom` — grouped geometry
323+
324+
For *G* groups per category and bar-width fraction *w*:
325+
```
326+
slotPx = (r.w or r.h) / n — pixel width of one category slot
327+
barPx = slotPx * w / G — pixel width of a single bar
328+
groupOffsetPx(g) = (g - (G-1)/2) * barPx — centre offset for group g
329+
```
330+
`getVal(i, g)` reads from `st.values[i][g]` (2-D) or legacy `st.values[i]`
331+
(scalar) so old 1-D state still renders correctly.
332+
333+
#### Log scale
334+
335+
When `st.log_scale` is true `yToPx`/`xToPx` use `Math.log10` internally:
336+
```js
337+
lv = Math.log10(Math.max(1e-10, v))
338+
py = r.y + r.h - ((lv - lMin) / (lMax - lMin)) * r.h
339+
```
340+
Grid lines: faint minor lines at 2×, 3×, 5× per decade; full-opacity major
341+
lines at each power of 10. Tick labels use superscript notation (`10^N`).
342+
343+
#### Bar zoom/pan model (unchanged)
344+
Unlike 1D (which zooms `view_x0/x1`), bar zooms/pans the **value axis** by
345+
modifying `st.data_min`/`st.data_max` directly. `view_x0/x1` stays fixed
346+
at 0/1 so overlay widgets keep correct positions throughout.
347+
348+
#### `on_click` event payload
349+
```js
350+
{ bar_index, group_index, value, group_value, x_center, x_label }
351+
```
352+
`group_index` is always 0 for ungrouped charts. `group_value` equals
353+
`value` (alias for convenience).
354+
321355

322356
---
323357

anyplotlib/_repr_utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,12 @@ def _widget_px(widget) -> tuple[int, int]:
136136
const _anyCbs = [];
137137
return {{
138138
get(key) {{ return _data[key]; }},
139-
set(key, val) {{ _data[key] = val; }},
139+
set(key, val) {{
140+
_data[key] = val;
141+
const ev = 'change:' + key;
142+
if (_cbs[ev]) for (const cb of [..._cbs[ev]]) try {{ cb({{ new: val }}); }} catch(_) {{}}
143+
for (const cb of [..._anyCbs]) try {{ cb(); }} catch(_) {{}}
144+
}},
140145
save_changes() {{
141146
for (const [ev, cbs] of Object.entries(_cbs))
142147
for (const cb of cbs) try {{ cb({{ new: _data[ev.slice(7)] }}); }} catch(_) {{}}

anyplotlib/callbacks.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ def on_settle(event):
4545
@dataclass
4646
class Event:
4747
"""A single interactive event.
48-
event_type: one of on_click / on_changed / on_release / on_key /
49-
on_line_hover / on_line_click
50-
source: the originating Python object (Widget, Plot, or None)
51-
data: full state dict; all keys also accessible as event.x
48+
49+
:event_type: one of ``on_click`` / ``on_changed`` / ``on_release`` /
50+
``on_key`` / ``on_line_hover`` / ``on_line_click``
51+
:source: the originating Python object (Widget, Plot, or None)
52+
:data: full state dict; all keys also accessible as ``event.x``
53+
5254
5355
For ``on_line_hover`` and ``on_line_click`` events the data dict
5456
contains:

anyplotlib/figure.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,13 @@ class Figure(anywidget.AnyWidget):
6767
subplots : Recommended factory for creating Figure and Axes grid.
6868
"""
6969

70-
layout_json = traitlets.Unicode("{}").tag(sync=True)
71-
fig_width = traitlets.Int(640).tag(sync=True)
72-
fig_height = traitlets.Int(480).tag(sync=True)
70+
layout_json = traitlets.Unicode("{}").tag(sync=True)
71+
fig_width = traitlets.Int(640).tag(sync=True)
72+
fig_height = traitlets.Int(480).tag(sync=True)
7373
# Bidirectional JS event bus: JS writes interaction events here, Python reads them.
74-
event_json = traitlets.Unicode("{}").tag(sync=True)
74+
event_json = traitlets.Unicode("{}").tag(sync=True)
75+
# When True the JS renderer shows a per-panel FPS / frame-time overlay.
76+
display_stats = traitlets.Bool(False).tag(sync=True)
7577
_esm = _ESM_SOURCE
7678
# Static CSS injected by anywidget alongside _esm.
7779
# .apl-scale-wrap — outer container; width:100% means it always fills
@@ -108,7 +110,8 @@ class Figure(anywidget.AnyWidget):
108110

109111
def __init__(self, nrows=1, ncols=1, figsize=(640, 480),
110112
width_ratios=None, height_ratios=None,
111-
sharex=False, sharey=False, **kwargs):
113+
sharex=False, sharey=False,
114+
display_stats=False, **kwargs):
112115
super().__init__(**kwargs)
113116
self._nrows = nrows
114117
self._ncols = ncols
@@ -119,8 +122,9 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480),
119122
self._axes_map: dict = {}
120123
self._plots_map: dict = {}
121124
with self.hold_trait_notifications():
122-
self.fig_width = figsize[0]
123-
self.fig_height = figsize[1]
125+
self.fig_width = figsize[0]
126+
self.fig_height = figsize[1]
127+
self.display_stats = display_stats
124128
self._push_layout()
125129

126130
# ── subplot creation ──────────────────────────────────────────────────────
@@ -342,7 +346,8 @@ def subplots(nrows=1, ncols=1, *,
342346
figsize=(640, 480),
343347
width_ratios=None,
344348
height_ratios=None,
345-
gridspec_kw=None):
349+
gridspec_kw=None,
350+
display_stats=False):
346351
"""Create a :class:`Figure` and a grid of :class:`~anyplotlib.figure_plots.Axes`.
347352
348353
Mirrors :func:`matplotlib.pyplot.subplots`.
@@ -392,6 +397,7 @@ def subplots(nrows=1, ncols=1, *,
392397
nrows=nrows, ncols=ncols, figsize=figsize,
393398
width_ratios=width_ratios, height_ratios=height_ratios,
394399
sharex=sharex, sharey=sharey,
400+
display_stats=display_stats,
395401
)
396402
# Build the GridSpec from the Figure's own stored ratios so there is
397403
# exactly one source of truth.

0 commit comments

Comments
 (0)