Jupyter-native, ImPlot-powered plotting for very large arrays.
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:
nbimplot is built on top of these upstream projects:
- Dear ImGui: https://github.com/ocornut/imgui
- ImPlot: https://github.com/epezent/implot
Special thanks to the ImPlot and Dear ImGui maintainers and contributors for
building and maintaining the core libraries that make nbimplot possible.
python -m pip install -U nbimplotMinimum recommended widget/runtime stack:
python -m pip install -U "nbimplot>=0.1.13" "anywidget>=0.9.21" ipywidgets jupyterlab_widgets- 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)
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()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.
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)
pUse 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.
Use @nbimplot/web when you want ImPlot/WASM rendering in a normal browser
application without Jupyter:
npm install /path/to/nbimplot/packages/webAfter publishing the web package to npm:
npm install @nbimplot/webimport { 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 3001The 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 buildGitHub Pages should be configured once as:
- Source:
Deploy from a branch - Branch:
gh-pages - Folder:
/root
- 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
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()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:
xandymust be 1D, finite, and equal length.xmust 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"andy="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/xAxisselects 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 }).
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.
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.
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.
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]));
});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()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()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()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()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.
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 });Implemented plot/primitive APIs include:
line,stream_linelinesscatter,bubbles,stairs,stems,digitalbars,bar_groups,bars_h,shadederror_bars,error_bars_hinf_lines,vlines,hlineshistogram,histogram2d,heatmap,image,pie_chartcandlestick,ohlc,quiver,contour,waterfall,spectrogramtext,annotation,dummytag_x,tag_y,colormap_slider,colormap_button,colormap_selectordrag_line_x,drag_line_y,drag_point,drag_rectdrag_drop_plot,drag_drop_axis,drag_drop_legend
For a broader cookbook, see docs/EXAMPLES.md.
Search-focused guides:
docs/FAST_JUPYTER_PLOTTING.mddocs/MILLION_POINT_NOTEBOOK_PLOTTING.mddocs/WEBAPP_INTEGRATION.mddocs/POSITIONING.md
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()
notebooks/nbimplot_examples.ipynbnotebooks/nbimplot_api_gallery.ipynbnotebooks/nbimplot_benchmarks.ipynbnotebooks/nbimplot_colab_complete.ipynb(Colab-ready complete examples)
Open the Colab notebook directly:
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 cleanThen 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"))Strict mode requires WebGL2.
!!document.createElement("canvas").getContext("webgl2")If this is false, run from a local desktop browser session with GPU
acceleration enabled.
- 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.
Prerequisites:
- Emscripten SDK (
emcmake,emcc) - CMake >= 3.20
- Dear ImGui sources (
imgui): https://github.com/ocornut/imgui - ImPlot sources (
implot): https://github.com/epezent/implot
Build:
scripts/build_wasm.shExpected outputs in nbimplot/wasm/:
nbimplot_wasm.jsnbimplot_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.shIf you use vendored deps:
NBIMPLOT_WITH_IMPLOT=ON scripts/build_wasm.sh- 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