Perspective v5.0.0 · perspective-dev perspective · Discussion #3205

· GitHub

12 min read Original article ↗

TL;DR:

  • <perspective-viewer> is now a workspace. Multi-panel dashboards, drag-and-drop docking and master/detail cross-filtering are core features.
  • Virtual Servers for DuckDB, ClickHouse and Polars. These are not data-ingest; Virtual Servers completely replace the built-in query engine entirely with push-down SQL, driven by <perspective-viewer> and read via Apache Arrow IPC.
  • New charting package, @perspective-dev/viewer-charts — GPU-rendered, context pooled, multi-threaded, with twelve chart types (and maps!), plus loads of new configuration options.
  • PerspectiveWidget is now an AnyWidget — one pip install, no labextensions, identical behavior in JupyterLab, VSCode, Colab, Marimo — and via new Emscripten wheels, JupyterLite.
  • New engine features: reactive Client::join, group_rollup_mode, coalesce, the gmv aggregate, page_to_disk disk-backed tables, and a batch of hot-path performance work.
  • New platforms: Perspective now publishes PYPI wheels for arm64 and emscripten (pyodide/jupyterlite), plus a WebAssembly Memory64 (wasm64) build of the engine for 16GB in-browser heaps.
  • Deprecated packages: @perspective-dev/viewer-d3fc, @perspective-dev/viewer-openlayers and @perspective-dev/workspace will be deprecated.

See the Migration Notes at the end for everything that changed incompatibly since v4.0.0.


<perspective-viewer> is now a Workspace

A multi-panel perspective-viewer workspace

The @perspective-dev/workspace package has been deprecated, and its functionality is now built directly into <perspective-viewer> itself, rather than as a separate lumino-based TypeScript wrapper (#3199). Every <perspective-viewer> element is now a workspace, with panels that can be split, stacked into tabs, resized, duplicated, and closed via the UI or the API:

  • Multi-panel layouts — panels are arranged by the regular-layout engine (splits, tab stacks, drag-to-dock, drag-to-resize) inside the viewer element.

  • Master/detail global filters — toggling a panel to Master puts its plugin into row-selection mode; selections contribute filter clauses to an element-level global filter set, applied as a transient overlay to every detail panel's view (never written into their saved configs). A global filter bar displays and edits the active clauses.

  • Panel APIaddPanel(config), removePanel(id), getPanelNames(), getActivePanel(), setActivePanel(id), plus a panel option on the existing single-viewer methods (save, restore, getView, getTable, reset, export, …) which target the active panel by default.

  • Whole-element persistencesave() and restore() still emit and accept the classic single-panel ViewerConfig (existing round-trips are backwards-compatible, and restore({panel}) upserts — restoring to a panel name that doesn't exist creates it). The new saveWorkspace() / restoreWorkspace() pair captures the entire element — layout tree, per-panel configs, active panel, masters and global filters — in one token:

    {
        "version": "...",
        "active": "PANEL_ID_0",
        "layout": { "...": "regular-layout tree" },
        "panels": { "PANEL_ID_0": { "...": "per-panel ViewerConfig" } },
        "global_filters": [["State", "==", "Texas"]],
        "masters": ["PANEL_ID_1"]
    }
  • Eventsperspective-click and perspective-select details gain a panel field identifying the originating panel, and a new perspective-global-filter-update event fires when the global filter set changes.

See the migration notes below for @perspective-dev/workspace migration notes.

Viewers bind Clients, not Tables

In v5.0.0, load() takes a Client, which the element binds to exclusively, and the Table is named by a new table field in ViewerConfig:

const viewer = document.createElement("perspective-viewer");
const worker = await perspective.worker();

await worker.table("x\n1", { name: "table_one" });
await viewer.load(worker);
await viewer.restore({ table: "table_one", columns: ["x"] });

Calling load() with a Client does not render (no Table is selected yet); restore() selects both the Table and its config (guaranteeing one atomic render once both are called and the element is mounted). load(table) still works as a legacy convenience — it is internally equivalent to load(await table.get_client()) followed by restore({table: await table.get_name()}) — but new code should pass a Client.

One footgun to be aware of: Tables constructed without a name get a random one, which will be captured by save() and won't match on the next page load — always name your tables (though you can capture the name with get_name()).

New @perspective-dev/viewer-charts charting package

@perspective-dev/viewer-d3fc has been replaced by @perspective-dev/viewer-charts — a ground-up rewrite that renders Perspective views on the GPU with custom GLSL shaders, a shared buffer pool for GPU memory reuse, and zero-copy Arrow data transfer to a Web Worker host (#3160, #3166, #3176).

  • Twelve chart types: X/Y Scatter, X/Y Line, Y Bar, X Bar, Y Line, Y Scatter, Y Area, Treemap, Sunburst, Candlestick, OHLC and Heatmap — plus new Density and Maps types which deprecate @perspective-dev/viewer-openlayers.
  • Multithreaded — the chart engine runs in a Web Worker with its own Client instance, taking Arrow slices directly from the engine without ever touching the main thread. An optional blit mode (default) copies frames to 2D Canvas instances to sidestep the browser's WebGL context limit, so dozens of charts can share one page.
  • Fast. All chart types are now super fast — so fast the Render Warning thresholds have been raised to 1,000,000 rows.
  • Facetingsplit_by groups can render as a grid of sub-plots with shared axes and zoom routing between facets (#3168).
  • Per-column series config — each Y column can independently choose chart_type ∈ {Bar, Line, Scatter, Area} and a stack override, closing some of the oldest feature requests in the tracker (#2938, #2390, #2331).
  • Zoom everywhere — all chart types support locked/unlocked zooming (even on categorical axes) and auto-fit, category axis support when a non-numeric column is placed on an X, Y or X/Y domain axis, and an interpolate option for Line charts (#3182).

A new plugin configuration tab

A new Plugin API (#3176) which lets plugins describe their configuration programmatically to the <perspective-viewer> host: plugin_config_schema and column_config_schema declare a plugin's options, and a generated plugin-configuration tab will show up in the viewer's settings panel. Plugins also no longer implement save() (This is a breaking change for custom plugin authors — see migration notes).

Notebooks: AnyWidget, VSCode and JupyterLite

PerspectiveWidget's dual-package ipywidgets + JupyterLab labextension architecture has been replaced with a single AnyWidget-based widget shipped entirely from the perspective-python wheel (#3192). The widget now runs identically in JupyterLab, classic Jupyter Notebook, VSCode notebooks, Colab, Marimo, etc. — with no per-host labextension install:

pip install "perspective-python[jupyter]"

You can also install the optional @perspective-dev/jupyterlab, which is slimmed down to a renderers-only labextension (the CSV/JSON/Arrow "Open With → Perspective" file viewers).

New Python architecture support

Perspective's Python distribution also gained two new architectures:

  • Linux arm64/aarch64 wheels (#3092), for Graviton servers and Linux-on-Apple-Silicon containers.
  • Emscripten wheels per PEP 783 (#3191) — perspective-python now installs in Pyodide, which combined with the AnyWidget port means PerspectiveWidget works in JupyterLite — a full Perspective notebook experience with no server.

Virtual Servers: DuckDB, ClickHouse and Polars

Perspective 5.0.0 ships the Virtual Server API — implement a small handler interface and <perspective-viewer> will drive your query engine instead of Perspective's own, translating every ViewerConfig option (group_bys, filters, sorts, expressions) into queries against the backing store. Three implementations are included for Python, and two for JavaScript (Polars is Python-only):

  • DuckDB (#3062) DEMO

  • ClickHouse (#3119) — JavaScript and Python implementations that translate Perspective queries into ClickHouse SQL, sharing a generic SQL model in Rust with the DuckDB backend.

  • Polars (#3125) — a Python implementation over Polars dataframes, with a test suite mirroring the DuckDB one.

While none of these servers support real-time updates, they should otherwise by fully-featured under <perspective-viewer> - except, you will see e.g. DuckDB's aggregates, DuckDB's expression syntax, etc. in the UI, rather than perspective-server's.

C++ Engine

Performance

Lots of performance improvements, some highlights:

  • Bunch of small algo improvements + SIMD-vectorized aggregation loops (with -fopenmp-simd, and -msimd128/-mrelaxed-simd for WASM) #3141.
  • View registration and CSV loading were overhauled in #3165 — bulk-load fast paths and zero-copy column aliasing between Table and View replace per-column deep clones.
  • The same PR adds View::with_typed_arrays, a new zero-copy JS API that hands your callback TypedArray views directly over the Arrow buffers (with an optional float32 downcast mode for half-memory GPU uploads) — this is the API the new charts package is built on.

Client::join

Client::join creates read-only, reactive joined tables in JavaScript, Python and Rust (#3143):

const left = await worker.table(data);
const right = await worker.table(other_data);
const options = { join_type: "outer", name: "Joined Table" };
const joined_table = await worker.join(left, right, "Row ID", options);

Joins support inner (default), left and outer types, an optional right_on for differently-named key columns, correct duplicate (cross-product) handling with left-precedence natural order for visual stability — and they're streaming: updates to either source Table automatically recompute the join and propagate to any derived View. Joins can be chained, and a Table created by join behaves like any other (minus writes) — including in a <perspective-viewer>.

group_rollup_mode

The per-query leaves_only serialization flag has been replaced by group_rollup_mode, a first-class ViewConfig property with three variants (#3132, #3135): "rollup" (the classic hierarchical tree), "flat" (leaf rows only — the datagrid hides tree expanders and renders flat column headers), and "total" (only the grand-total row, aggregated across all rows, no group_by required). It's a UI dropdown in the settings panel, works on virtual servers, and applies to every serialization method and plugin, not just to_columns.

page_to_disk mode, new aggregates and functions

  • page_to_disk is a new Client.table(...) option that backs a Table's master columns with disk-resident storage instead of heap, allowing datasets larger than addressable memory (#3185) — with a runtime-appropriate backend on every platform: mmap for Python/Rust, OPFS in the browser, and node:fs for Node.js.
  • coalesce added to ExprTK (#3172).
  • gmv ("gross market value") is a new aggregate for group_by/split_by trees (#3180): a leaf is a plain signed sum, while a parent is the sum over its immediate children of |sum(child_subtree)| — the natural aggregate for netted long/short position books. It recursively recalculates per depth, so it is noticeably slower than sum; use it wisely.

Memory64 (wasm64) builds

@perspective-dev/server now publishes a WebAssembly Memory64 build, perspective-server.memory64.wasm, alongside the standard wasm32 binary (#3203) — raising the in-browser engine's heap ceiling from 4GB to 16GB. init_server now accepts both binaries at once lazily; the engine feature-detects Memory64 support in the host and downloads only the binary it selects:

perspective.init_server({
    wasm32: () => fetch(SERVER_WASM),
    wasm64: () => fetch(SERVER_WASM64),
});

Registering only the wasm32 binary (as in prior versions, which remains supported) opts out entirely, while the stock CDN and Node.js entrypoints register both, so capable hosts upgrade automatically (and hosts without Memory64 support fall back to wasm32).

Datagrid

The datagrid has added label-bar mode for numeric columns, which overlays the formatted value on top of the proportional bar fill using mix-blend-mode: difference for contrast against the fill color (#3156):

label-bar datagrid mode

Also added SELECT_ROW_TREE, a tree-aware row-selection edit mode that tracks selections by tree identity rather than flat row index — the machinery behind the workspace's master/detail filtering.

Themes

Theming was redesigned in #3146: all exported CSS variables were renamed to a consistent --psp-{module}--{component?}--{property} format, and all icon SVGs now carry correct intrinsic dimensions so they can be swapped for arbitrarily-sized replacements. The workspace merger added per-panel themes (#3107, #3150), and a new "Botanical" and "Phosphor" theme (#3135).


Migration Notes (v4.0.0v5.0.0)

<perspective-viewer>.load() takes a Client

load(table) still works as a legacy path, but the primary signature is now load(client), with the Table named by the new table field of ViewerConfig in save()/restore():

// Before (4.0)
const table = await worker.table(data);
await viewer.load(table);
await viewer.restore(config);

// After (5.0)
await worker.table(data, { name: "my_table" });
await viewer.load(worker);
await viewer.restore({ ...config, table: "my_table" });

A Table constructed without a name is assigned a random one, which save() will capture — and which will not exist after a page reload, breaking restore(). Duplicate Table names in the engine now error instead of silently overwriting each other.

@perspective-dev/workspace is removed

The package is deprecated and its functionality moved into <perspective-viewer> itself. Migrating:

  • Replace <perspective-workspace> with <perspective-viewer>; addTable(), removeTables() and the tables property are gone — host your Tables on a Client (they already have a name) and load() the client.
  • Workspace-style save/restore is viewer.saveWorkspace() / viewer.restoreWorkspace(); per-panel configs use save({panel}) / restore(config, {panel}). restore() to an unknown panel name creates it (upsert). Old workspace layout JSON is not the same format — panels is keyed by panel id with per-panel ViewerConfig values and layout is a regular-layout tree; plan a one-time conversion for persisted layouts.
  • The selectable attribute master/detail mechanism is replaced by the SELECT_ROW_TREE edit mode and the Master/Detail toggle; master state is captured in the masters field of the workspace config.
  • Events: workspace-layout-update and workspace-new-view are replace by perspective-config-update (which now also fires for layout changes), perspective-global-filter-update (detail: Filter[]), and the new panel field on perspective-click/perspective-select details.

Options-dict method signatures

Several <perspective-viewer> methods were update to options-dict signatures as part of the workspace merge (#3201): methods that previously took positional arguments (e.g. getSelection, getEditPort, export, download, copy, reset) now take an options object which may include a panel key, and eject(options) is client-scoped. Plain no-argument calls behave as before against the active panel.

Replace leaves_only with group_rollup_mode

The per-query leaves_only parameter on to_columns/to_rows/to_ndjson is removed. Set group_rollup_mode: "flat" in ViewConfig instead (or "total" for just the grand-total row; "rollup" is the default hierarchical behavior).

Replace @perspective-dev/viewer-d3fc and @perspective-dev/viewer-openlayers with @perspective-dev/viewer-charts

viewer-d3fc is deleted. @perspective-dev/viewer-charts is a compatible superset of its predecessor's API (same chart types and more), but it is a new package name with new internals — update your imports and bundler config. Note the Render Warning thresholds are now 1,000,000 rows, so warnings you previously suppressed may simply be gone.

@perspective-dev/viewer-openlayers is deprecated, the Maps and Density chart types in @perspective-dev/viewer-charts replace it.

Custom plugins and themes API

The condensed plugin API breaks any custom plugin implementation: the small metadata methods are consolidated into a single plugin_static_config call, plugin_config_schema / column_config_schema declare configuration to the viewer (which renders the new plugin-config tab for you), and save() is removed — plugin state is now managed viewer-side. See the API docs for these methods.

All exported CSS variables were renamed to the --psp-{module}--{component?}--{property} convention,

Jupyter does not need the old labextension install

PerspectiveWidget ships entirely inside the perspective-python wheel via AnyWidget. Uninstall any @perspective-dev/jupyterlab labextension plumbing from your environments and Dockerfiles and just pip install "perspective-python[jupyter]". @perspective-dev/jupyterlab is now an optional, renderers-only extension (the "Open With → Perspective" file viewers) — install it only if you want that feature.