Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Examples/Benchmarks/README.rst
Original file line number Diff line number Diff line change
@@ -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.

559 changes: 559 additions & 0 deletions Examples/Benchmarks/plot_benchmark_comparison.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Examples/Interactive/plot_interactive_fft.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Examples/plot_3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
160 changes: 148 additions & 12 deletions Examples/plot_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -100,7 +236,7 @@
)

# Swap in Q2 data — range is recalculated automatically
bar_right.update(q2)
bar_right.set_data(q2)

fig3

Expand Down
62 changes: 48 additions & 14 deletions anyplotlib/FIGURE_ESM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).


---

Expand Down
7 changes: 6 additions & 1 deletion anyplotlib/_repr_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(_) {{}}
Expand Down
10 changes: 6 additions & 4 deletions anyplotlib/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 14 additions & 8 deletions anyplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading