Skip to content

Repository files navigation

nbimplot

Jupyter-native, ImPlot-powered plotting for very large arrays.

PyPI npm GitHub Demo

nbimplot is built around three constraints:

  • notebook cell rendering only (no native windows, no side process)
  • binary transfer (numpy -> bytes -> wasm heap)
  • WASM-owned interaction, state, and LOD

The runtime is strict: ImPlot + WASM + WebGL2 are required.

Project note: nbimplot was vibe coded with Codex, then hardened with tests, packaging checks, and runtime validation.

nbimplot now has two surfaces:

  • nbimplot: Python/Jupyter notebook package
  • @nbimplot/web: standalone browser package for web apps

Public web demo:

AI/agent-readable docs:

Upstream Libraries

nbimplot is built on top of these upstream projects:

Thanks

Special thanks to the ImPlot and Dear ImGui maintainers and contributors for building and maintaining the core libraries that make nbimplot possible.

Install

python -m pip install -U nbimplot

Minimum recommended widget/runtime stack:

python -m pip install -U "nbimplot>=0.1.13" "anywidget>=0.9.21" ipywidgets jupyterlab_widgets

Compatibility

  • Python >=3.10
  • Jupyter widget stack: anywidget, ipywidgets, traitlets
  • Frontend: JupyterLab/Notebook with widget manager enabled
  • Browser/GPU: WebGL2 required
  • Runtime mode: strict WASM + ImPlot only (no JS renderer fallback)

Quick Start

import numpy as np
import nbimplot as ip

x = np.linspace(0, 100, 1_000_000, dtype=np.float32)
y = np.sin(x)

p = ip.Plot(width=900, height=450, title="Signal")
h = p.line("mid", y, x=x)
p.show()

# Update in place, then redraw
h.set_data((0.8 * y).astype(np.float32), x=x)
p.render()

Arrays Or DataFrame Columns

The same methods accept NumPy arrays, pandas/polars Series, or dataframe-like objects with column selectors. There is no separate *_df() API:

import pandas as pd

df = pd.DataFrame({"time": x, "mid": y, "vwap": y2, "returns": returns})
df_next = df.assign(mid=(0.8 * df["mid"]).astype("float32"))

p = ip.Plot(width=900, height=450, title="DataFrame Columns")
h = p.line("mid", df, x="time", y="mid")
p.line("vwap", df, x="time", y="vwap")
p.histogram("returns", df, y="returns", bins=80)
p.show()

h.set_data(df_next, x="time", y="mid")
p.render()

Column extraction is a Python input-normalization step only. The frontend still receives contiguous float32 binary buffers, and the WASM core still owns LOD and rendering.

Notebook Cell Renderer

Plot, Subplots, AlignedPlots, and Dashboard implement a Jupyter widget MIME renderer. Put the object as the last expression in a notebook cell to render it directly:

p = ip.Plot(width=900, height=450, title="Cell renderer")
p.line("mid", y, x=x)
p

Use p.show() when you want to display the plot before the last line of a cell, or when the final expression is a printout, table, or another object.

Direct Web App Usage

Use @nbimplot/web when you want ImPlot/WASM rendering in a normal browser application without Jupyter:

npm install /path/to/nbimplot/packages/web

After publishing the web package to npm:

npm install @nbimplot/web
import { createPlot } from "@nbimplot/web";

const plot = await createPlot("#plot", {
  width: 900,
  height: 450,
  title: "Signal",
});

const x = new Float32Array(1_000_000);
const y = new Float32Array(x.length);
for (let i = 0; i < y.length; i += 1) {
  x[i] = i * 0.001;
  y[i] = Math.sin(x[i]);
}

const h = plot.line("mid", y, { x });
plot.render();

h.setData(y, { x });
plot.dispose();

The web package lives in packages/web. See docs/WEB.md and packages/web/examples/plain/index.html.

The repository also includes a full Next.js examples gallery that can run locally or as a static GitHub Pages site:

npm install
npm run dev -- --hostname 0.0.0.0 --port 3001

The gallery lazy-loads examples as their cards approach the viewport and releases offscreen canvases to keep active WebGL contexts bounded.

For GitHub Pages, the workflow in .github/workflows/pages.yml builds with:

NEXT_PUBLIC_BASE_PATH=/nbimplot npm run build

GitHub Pages should be configured once as:

  • Source: Deploy from a branch
  • Branch: gh-pages
  • Folder: /root

Interaction Defaults

  • initial X/Y view auto-fits to available data
  • double-click inside plot area resets view (autoscale)
  • right-drag box zoom, wheel zoom, drag pan, legend toggle
  • hover, click, drag-tool, selection, view-change, and performance callbacks are exposed from the WASM/ImPlot event path

Core API

import numpy as np
import nbimplot as ip

x = np.linspace(0, 10_000, 200_000, dtype=np.float32)
y = np.random.randn(x.size).astype(np.float32).cumsum()

p = ip.Plot(width=1000, height=420, title="Core API")
h = p.line("price", y, x=x, color="#22c55e", line_weight=2.0, marker="none")
p.set_plot_flags(no_legend=False, no_menus=False, no_box_select=False)
p.set_colormap("Viridis")
p.show()

# Later update
h.set_data((y * 1.01).astype(np.float32), x=x)
p.render()

Explicit X Data

Line plots accept explicit x coordinates on both public surfaces:

h = p.line("signal", y, x=x)
h.set_data(y_new, x=x_new)
const h = plot.line("signal", y, { x });
h.setData(yNew, { x: xNew });

Rules:

  • x and y must be 1D, finite, and equal length.
  • x must be sorted in non-decreasing order so the WASM LOD path can binary-search the visible range.
  • With a dataframe-like positional argument, x="column" and y="column" select columns; there is no separate dataframe API.
  • If a custom-x line keeps the same length, h.set_data(y_new) / h.setData(yNew) preserves the existing x buffer.
  • x_axis / xAxis selects the ImPlot axis slot (x1, x2, x3); it is not the x-data argument.
  • Streaming supports explicit x chunks: h.append(y_chunk, x=x_chunk) / h.append(yChunk, { x: xChunk }).

Batch Lines, Datetime, And Categories

Use lines(...) to upload several line series through one notebook widget message. This reduces Python-to-browser overhead when creating dashboards with many related signals:

p = ip.Plot(width=1100, height=420, title="Batch Lines")
handles = p.lines(
    {
        "mid": {"x": ts, "y": mid},
        "vwap": {"x": ts, "y": vwap, "color": "#b74b2b"},
    },
    line_weight=1.5,
)
p.show()
const handles = plot.lines({
  mid: { x: timestamps, y: mid },
  vwap: { x: timestamps, y: vwap, color: "#b74b2b" },
});

numpy.datetime64, pandas datetime columns, Python datetime values, and JavaScript Date values are normalized to ImPlot time axes automatically. Categorical x values are converted to integer tick locations with labels:

p = ip.Plot(width=900, height=360, title="Datetime + Categories")
p.line("sessions", df, x="timestamp", y="latency")
p.scatter("rank", np.array([4, 7, 3], dtype=np.float32), x=["A", "B", "C"])
p.show()
plot.line("sessions", latency, { x: dates });
plot.scatter("rank", new Float32Array([4, 7, 3]), { x: ["A", "B", "C"] });

For high-frequency modern timestamps, prefer relative numeric seconds when sub-second precision matters because the hot rendering path stores float32.

Themes And Standalone HTML

The C++/WASM layer owns the theme presets. Available presets are nbimplot, notebook, publication, finance, lab, and dark-terminal:

p.set_theme("finance")
p.set_colormap("Viridis")
plot.setTheme("publication");
plot.setColormap("Plasma");

Notebook plots can export a standalone HTML file that reloads the same data through @nbimplot/web and the WASM/ImPlot core:

p.export_html("signal.html")
const html = plot.exportHTML({ title: "Signal Export" });

The HTML export is for sharing a fixed plot state. It still requires browser WebGL2 and WASM asset loading; it is not a static SVG/PNG fallback.

PNG Export

Notebook widgets can request a browser-side PNG download of the current canvas:

p.export_png("nbimplot-signal.png")
p.copy_png_to_clipboard()

Web apps can download directly or keep the image for app-specific workflows:

await plot.downloadPNG("nbimplot-signal.png");
const dataUrl = plot.toDataURL("image/png");
const blob = await plot.toBlob("image/png");
await plot.copy_png_to_clipboard();

Export redraws the existing WASM/ImPlot canvas immediately before reading pixels; it does not use a JavaScript plotting fallback.

Interaction Callbacks

selected = {}

def on_hover(plot, event):
    print(event["series_name"], event["index"], event["x"], event["y"])

def on_click(plot, event):
    print("clicked", event["button"], event["x"], event["y"])

def on_select(plot, event):
    selected["event"] = event
    exact = plot.indices_for_selection(event)
    print({series_id: idx.size for series_id, idx in exact.items()})

p.on_hover(on_hover)
p.on_click(on_click)
p.on_select(on_select)

Selection helper workflow:

bounds = p.selection_bounds(selected["event"])
indices = p.indices_for_selection(selected["event"], series="signal")
p.highlight_selection(selected["event"], series="signal", name="picked")
csv_text = p.export_csv_selection(selected["event"], series="signal")

Selection callbacks include the ImPlot selection rectangle and per-series x-index ranges computed in WASM. indices_for_selection(...) applies the y bounds only when requested and returns exact NumPy index arrays.

Standalone web apps expose the same interaction layer:

plot.onHover((event) => console.log(event.seriesName, event.index, event.x, event.y));
plot.onClick((event) => console.log(event.button, event.x, event.y));
plot.onSelection((event) => {
  const exact = plot.indicesForSelection(event);
  console.log([...exact.entries()].map(([token, indices]) => [token, indices.length]));
});

Common Examples

1) Line + Streaming

import numpy as np
import nbimplot as ip

p = ip.Plot(width=1000, height=380, title="Streaming")
h = p.stream_line("ticks", capacity=200_000, initial=np.zeros(1000, dtype=np.float32))
p.show()

chunk = np.random.randn(20_000).astype(np.float32)
h.append(chunk)
p.render()

2) Scatter / Bars / Histogram

import numpy as np
import nbimplot as ip

rng = np.random.default_rng(7)
x = rng.normal(0, 1, 4000).astype(np.float32)
y = (0.5 * x + 0.2 * rng.normal(size=x.size)).astype(np.float32)

p = ip.Plot(width=1100, height=420, title="Stat Plots")
p.scatter("cloud", y, x=x, size=2.0)
p.vlines("cuts", np.array([-1.0, 1.0], dtype=np.float32))
p.hlines("zero", np.array([0.0], dtype=np.float32))
p.show()

p2 = ip.Plot(width=1100, height=360, title="Histogram")
p2.histogram("x-dist", x, bins=60)
p2.show()

3) Heatmap / Histogram2D / Image

import numpy as np
import nbimplot as ip

rng = np.random.default_rng(0)
z = rng.normal(size=(50, 80)).astype(np.float32)

p = ip.Plot(width=1100, height=420, title="Heatmap")
p.set_colormap("Plasma")
p.heatmap(
    "z",
    z,
    label_fmt="",  # empty format disables cell text
    show_colorbar=True,
    colorbar_label="Intensity",
    colorbar_format="%.3f",
)
p.show()

x = rng.normal(size=200_000).astype(np.float32)
y = (0.3 * x + rng.normal(size=x.size)).astype(np.float32)
p2 = ip.Plot(width=1100, height=420, title="Histogram2D")
p2.histogram2d(
    "h2d",
    x,
    y,
    x_bins=100,
    y_bins=80,
    label_fmt="",
    show_colorbar=True,
    colorbar_label="Count",
)
p2.show()

4) Subplots

import numpy as np
import nbimplot as ip

sp = ip.Subplots(
    2,
    2,
    title="Dashboard",
    width=1100,
    height=760,
    link_rows=True,
    link_cols=True,
    share_items=True,
)

t = np.linspace(0, 30, 4000, dtype=np.float32)
sp.subplot(0, 0).line("sin", np.sin(t))
sp.subplot(0, 1).scatter("noise", np.random.randn(3000).astype(np.float32))
sp.subplot(1, 0).bars("bars", np.abs(np.random.randn(120)).astype(np.float32))
sp.subplot(1, 1).histogram("hist", np.random.randn(20_000).astype(np.float32), bins=50)
sp.show()

5) Specialty Scientific And Financial Plots

import numpy as np
import nbimplot as ip

rng = np.random.default_rng(22)
x = np.arange(120, dtype=np.float32)
close = (100 + rng.normal(0, 1, x.size).cumsum()).astype(np.float32)
open_ = np.r_[close[0], close[:-1]].astype(np.float32)
high = (np.maximum(open_, close) + rng.random(x.size) * 1.8).astype(np.float32)
low = (np.minimum(open_, close) - rng.random(x.size) * 1.8).astype(np.float32)

p = ip.Plot(width=1100, height=420, title="Finance")
p.set_theme("finance")
p.candlestick("candles", x=x, open=open_, high=high, low=low, close=close)
p.ohlc("ohlc", x=x, open=open_, high=high, low=low, close=close)
p.show()
grid = np.linspace(-3, 3, 80, dtype=np.float32)
xx, yy = np.meshgrid(grid, grid)
z = np.sin(xx * yy).astype(np.float32)

p = ip.Plot(width=1100, height=420, title="Scientific")
p.set_colormap("Viridis")
p.contour("contour", z, levels=np.linspace(-1, 1, 9, dtype=np.float32))
p.quiver("field", xx[::8, ::8].ravel(), yy[::8, ::8].ravel(),
         -yy[::8, ::8].ravel(), xx[::8, ::8].ravel(), scale=0.08, normalize=True)
p.waterfall("waterfall", z[::4], scale=0.18)
p.spectrogram("spectrogram", z, label_fmt="", show_colorbar=True)
p.show()

These specialty methods use ImPlot/WASM draw paths. Candles, OHLC, quiver, and contour lines use ImPlot coordinate transforms and draw-list item integration; spectrograms reuse ImPlot heatmaps.

6) Dashboard, State, Theme, and Linked Crosshair

t = np.linspace(0, 30, 4000, dtype=np.float32)
dash = ip.Dashboard(2, 2, title="Realtime Desk", link_x=True, theme="nbimplot")
dash.set_linked_crosshair("desk", axis="x")
dash.subplot(0, 0).line("a", np.sin(t), x=t)
dash.subplot(0, 1).line("b", np.cos(t), x=t)
dash.show()

state = dash.get_state(include_data=True)
json_text = dash.export_json_state(include_data=True)
dash.set_state(state)
dash.set_theme("notebook")
dash.export_csv_selection({"x_min": 0, "x_max": 1, "y_min": -1, "y_max": 1})
dash.copy_png_to_clipboard()
plot.setTheme("nbimplot");
plot.setLinkedCrosshair("desk", { axis: "x" });
const bounds = plot.selectionBounds(selection);
plot.highlightSelection(selection, handle, { name: "picked" });
const csv = plot.exportCSVSelection(selection, handle);
const state = plot.getState({ includeData: true });
plot.setState(state);
const json = plot.exportJSONState({ includeData: true });

Plot and Primitive Coverage

Implemented plot/primitive APIs include:

  • line, stream_line
  • lines
  • scatter, bubbles, stairs, stems, digital
  • bars, bar_groups, bars_h, shaded
  • error_bars, error_bars_h
  • inf_lines, vlines, hlines
  • histogram, histogram2d, heatmap, image, pie_chart
  • candlestick, ohlc, quiver, contour, waterfall, spectrogram
  • text, annotation, dummy
  • tag_x, tag_y, colormap_slider, colormap_button, colormap_selector
  • drag_line_x, drag_line_y, drag_point, drag_rect
  • drag_drop_plot, drag_drop_axis, drag_drop_legend

For a broader cookbook, see docs/EXAMPLES.md.

Search-focused guides:

  • docs/FAST_JUPYTER_PLOTTING.md
  • docs/MILLION_POINT_NOTEBOOK_PLOTTING.md
  • docs/WEBAPP_INTEGRATION.md
  • docs/POSITIONING.md

View, Axes, and Performance Controls

  • p.set_view(x_min, x_max, y_min, y_max)
  • p.autoscale()
  • p.set_axis_scale(x="linear|log", y="linear|log")
  • p.set_axis_state("x2|x3|y2|y3", enabled=True|False, scale="linear|log|time")
  • p.set_secondary_axes(x2=..., x3=..., y2=..., y3=...)
  • p.set_time_axis("x1|x2|x3|y1|y2|y3")
  • p.set_axis_label(...), p.set_axis_format(...)
  • p.set_axis_ticks(...), p.clear_axis_ticks(...)
  • p.set_axis_limits_constraints(...), p.set_axis_zoom_constraints(...), p.set_axis_link(...)
  • p.hide_next_item()
  • p.on_perf_stats(callback, interval_ms=500)
  • p.on_view_change(callback)
  • p.on_tool_change(callback)
  • p.on_selection_change(callback)
  • p.on_select(callback)
  • p.on_hover(callback)
  • p.on_click(callback)
  • p.indices_for_selection(selection, series=None)
  • p.selection_bounds(selection)
  • p.highlight_selection(selection, series=None)
  • p.export_csv_selection(selection, series=None)
  • p.get_state(include_data=False) / p.set_state(state)
  • p.export_json_state(include_data=False)
  • p.export_html("plot.html")
  • p.set_theme("nbimplot")
  • p.set_linked_crosshair("group", axis="x|y|xy")
  • p.export_png(filename="nbimplot.png")
  • p.copy_png_to_clipboard()

Example Notebooks

  • notebooks/nbimplot_examples.ipynb
  • notebooks/nbimplot_api_gallery.ipynb
  • notebooks/nbimplot_benchmarks.ipynb
  • notebooks/nbimplot_colab_complete.ipynb (Colab-ready complete examples)

Open the Colab notebook directly:

Open In Colab

Troubleshooting

Failed to load model class 'AnyModel' from module 'anywidget'

This is usually a server-kernel env mismatch or stale lab assets.

python -m pip install -U "nbimplot>=0.1.13" "anywidget>=0.9.21" ipywidgets jupyterlab_widgets
jupyter lab clean

Then restart the full JupyterLab server.

Quick verification:

import nbimplot as ip, anywidget, sys
print("python:", sys.executable)
print("anywidget:", anywidget.__version__)
print("has Plot:", hasattr(ip, "Plot"))

Unable to enable ImPlot in the WASM core or WebGL context errors

Strict mode requires WebGL2.

!!document.createElement("canvas").getContext("webgl2")

If this is false, run from a local desktop browser session with GPU acceleration enabled.

Known Limitations

  • Rendering requires WebGL2-capable browser/runtime.
  • Strict mode is enforced; non-ImPlot or non-WASM fallback is disabled.
  • Headless/browser-restricted environments may fail to create GL context.

Build WASM Core

Prerequisites:

Build:

scripts/build_wasm.sh

Expected outputs in nbimplot/wasm/:

  • nbimplot_wasm.js
  • nbimplot_wasm.wasm

Build explicitly with local ImGui/ImPlot sources:

NBIMPLOT_WITH_IMPLOT=ON \
NBIMPLOT_IMGUI_DIR=/path/to/imgui \
NBIMPLOT_IMPLOT_DIR=/path/to/implot \
scripts/build_wasm.sh

If you use vendored deps:

NBIMPLOT_WITH_IMPLOT=ON scripts/build_wasm.sh

Performance Model

  • raw path when visible points <= 3 * pixel_width
  • min/max LOD when visible points > 3 * pixel_width
  • LOD computed in WASM, complexity scales with screen pixels
  • large line series maintain a reusable multiresolution LOD pyramid so pan/zoom recomputes buckets from cached summaries instead of rescanning every raw point

About

Jupyter-native, ImPlot-powered plotting for very large arrays.

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages