SPEC.md: rewrite Pivot page description to cover named layouts, depth control, selection mode, inspector filtering, and layout persistence. docs/perspective-pivot.md: new file documenting all discovered Perspective v4.4.0 APIs — viewer/plugin/view methods, selection modes, set_depth mechanism, perspective-click event shape, full state save/restore pattern, and common pitfalls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
Perspective Pivot — Technical Reference
Version tested: @perspective-dev v4.4.0 (client, viewer, viewer-datagrid, viewer-d3fc), loaded from CDN.
This document captures everything learned about controlling Perspective programmatically. The official docs are incomplete for some of these APIs — treat this as a ground-truth supplement.
Loading from CDN
const [{ default: perspective }] = await Promise.all([
import('https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.4.0/dist/cdn/perspective.js'),
import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/cdn/perspective-viewer.js'),
import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'),
import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'),
])
Stylesheet:
<link rel="stylesheet" crossorigin="anonymous"
href="https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/css/themes.css" />
Core Objects
perspective — the module default export
.worker() — creates a Web Worker instance
worker
.table(rows, opts) — creates a named Table; returns the Table object
.open_table(name) — re-opens a previously created named table
table
.view(config) — creates a View (filtered/grouped projection)
.update(rows) — incremental row upsert/insert
view
.to_json() — returns rows as array of objects
.set_depth(n) — sets expansion depth for all grouped rows (see below)
.delete() — frees the view; always call when done
viewer (the <perspective-viewer> DOM element)
.load(worker) — attaches the worker to the viewer
.save() — returns full viewer config as plain object
.restore(config) — applies a config object to the viewer
.flush() — forces viewer to synchronize (limited effect on plugin state)
.getPlugin() — returns the active plugin element (e.g. datagrid)
.getView() — returns the current View object
.toggleConfig() — shows/hides the settings panel
plugin (datagrid element, from viewer.getPlugin())
.save() — returns plugin-specific state: { columns, scroll_lock, edit_mode }
.restore(config) — applies plugin-specific state
.draw(view) — redraws the plugin against the given View
viewer.save() — Config Shape
{
table: "source_name",
plugin: "datagrid", // or "d3_y_bar", etc.
plugin_config: { ... }, // NOT reliably populated — use plugin.save() instead
group_by: ["field1"],
split_by: ["field2"],
columns: ["Amount"],
filter: [["field", "op", "value"]],
sort: [["field", "asc"]],
expressions: { "ExprName": "// formula\n..." },
settings: false, // whether the config panel is open
}
Important: plugin_config in viewer.save() is NOT reliably populated in v4.4.0. Use plugin.save() separately to capture plugin state.
plugin.save() — Plugin State Shape (datagrid)
{
columns: {}, // per-column formatting overrides
scroll_lock: false,
edit_mode: "SELECT_REGION" // see valid values below
}
Selection Modes (edit_mode)
Valid values for the datagrid plugin's edit_mode field:
| Value | Button label | Behavior |
|---|---|---|
READ_ONLY |
Read-Only | No selection highlight |
SELECT_ROW |
Select Row | Highlights full rows |
SELECT_COLUMN |
Select Column | Highlights full columns |
SELECT_REGION |
Select Region | Highlights clicked cell region |
EDIT |
Edit | Enables cell editing |
The built-in button in the viewer toolbar cycles through these in order.
Setting the default:
// After viewer.restore(...), set it directly on the plugin:
const plugin = await viewer.getPlugin()
await plugin.restore({ edit_mode: 'SELECT_REGION' })
Setting via viewer.restore({ plugin_config: { edit_mode: ... } }) does NOT reliably work in v4.4.0.
Expand/Collapse Row Depth
Controls how many levels of the group_by hierarchy are expanded. This is the only working mechanism found in v4.4.0:
const view = await viewer.getView()
await view.set_depth(depth) // 0 = collapse all, 1 = expand one level, etc.
const plugin = await viewer.getPlugin()
await plugin.draw(view) // required — viewer does not redraw automatically
What does NOT work:
viewer.restore({ plugin_config: { expand_depth: d } })— silently ignoredview.set_depth(d)alone — view state changes but display doesn't updateview.set_depth(d)+viewer.flush()— still no visual updateplugin.restore({ expand_depth: d })— "Unknown" field, ignored
The plugin.draw(view) call is required to make the datagrid re-render after set_depth.
Saving and Restoring Full State
To capture complete state (viewer + plugin + expand depth):
async function captureConfig(viewer, expandDepth) {
const plugin = await viewer.getPlugin()
const [viewerConfig, pluginConfig] = await Promise.all([viewer.save(), plugin.save()])
return { ...viewerConfig, plugin_config: pluginConfig, expand_depth: expandDepth }
}
To restore:
async function restoreConfig(viewer, config, applyDepth) {
await viewer.restore(config)
if (config.plugin_config) {
const plugin = await viewer.getPlugin()
await plugin.restore(config.plugin_config)
}
if (config.expand_depth != null) {
await applyDepth(viewer, config.expand_depth)
}
await viewer.flush()
}
async function applyDepth(viewer, depth) {
const view = await viewer.getView()
await view.set_depth(depth)
const plugin = await viewer.getPlugin()
await plugin.draw(view)
}
The perspective-click Event
Fires when the user clicks a cell. The event detail:
viewer.addEventListener('perspective-click', async (e) => {
const { row, column_names, config } = e.detail
// row — aggregated values for the clicked cell (keyed by "split|metric" format)
// column_names — array of metric column names clicked
// config — { filter: [[field, op, value], ...] }
// filter includes:
// - group_by coordinate filters (field == value, one per group_by level)
// - split_by coordinate filters (field == value, one per split_by field)
// - user-set filters (any op)
})
__ROW_PATH__ in row contains the group_by path as an array.
The config.filter array is the reliable way to get cell coordinates. Do not try to zip __ROW_PATH__ with group_by — the filter approach handles all cases including partial paths.
Filtering Rows for a Clicked Cell
The click event's filter array can be applied to the underlying table via a new View, which correctly evaluates expression/computed columns (unlike filtering raw JS rows):
const config = await viewer.save()
const view = await table.view({
filter: eventFilters,
expressions: config.expressions || [],
})
const rows = await view.to_json()
await view.delete()
// Strip expression columns from results (they're computed, not source fields)
const exprNames = new Set(Object.keys(config.expressions || {}))
const clean = rows.map(r =>
Object.fromEntries(Object.entries(r).filter(([k]) => !exprNames.has(k)))
)
Why not filter raw JS rows? Expression columns (computed in Perspective) don't exist in the source data. filterRowsByConfig on raw rows will skip those filters, returning all rows for the group rather than the specific cell.
Guard against no group_by: Without group_by, the filter array has no coordinate filters and the view query returns the entire table (slow). Check first:
const config = await viewer.save()
if ((config.group_by || []).length === 0) return // no hierarchy — skip inspector
Viewer Methods (full list, v4.4.0)
From Object.getOwnPropertyNames(Object.getPrototypeOf(viewer)):
constructor, __destroy_into_raw, free, __get_model, connectedCallback, copy, delete, download, eject, export, flush, getAllPlugins, getClient, getEditPort, getPlugin, getRenderStats, getSelection, getTable, getView, getViewConfig, load, openColumnSettings, reset, resetError, resetThemes, resize, restore, restyleElement, save, setAutoPause, setAutoSize, setSelection, setThrottle, toggleColumnSettings, toggleConfig
Plugin Methods (datagrid, full list)
From Object.getOwnPropertyNames(Object.getPrototypeOf(plugin)):
constructor, connectedCallback, disconnectedCallback, activate, name, category, select_mode, min_config_columns, config_column_names, group_rollups, priority, can_render_column_styles, column_style_controls, draw, update, render, resize, clear, save, restore, restyle, delete
View Methods (full list)
From Object.getOwnPropertyNames(Object.getPrototypeOf(view)) — includes set_depth, expand, collapse, to_json, to_csv, to_arrow, schema, num_rows, num_columns, delete, and others.
settings Panel
The settings key in viewer.restore() controls whether the config panel (gear icon) is open:
// Hide on load:
await viewer.restore({ table: "name", settings: false, plugin_config: DEFAULT_PLUGIN_CONFIG })
// Toggle programmatically:
viewer.toggleConfig()
The settings state is saved by viewer.save() and restored on viewer.restore(), so it persists across layout saves automatically.
Incremental Updates
To update the table data without a full reload:
table.update(newRows) // upserts by index (or by index_col if specified at table creation)
The viewer re-renders automatically after table.update().
Common Pitfalls
plugin_configinviewer.restore()is unreliable. Always set plugin state viaplugin.restore()separately afterviewer.restore().view.set_depth()requiresplugin.draw(view). The viewer won't redraw automatically.- Expression columns don't exist in raw data. Filter via a Perspective View (
table.view({ filter, expressions })), not against raw JS rows. - Always
await view.delete()after using a temporary view, or you'll leak worker memory. - Named tables:
worker.table(rows, { name: 'foo' })— the name is used by the viewer'stableconfig key. Re-open withworker.open_table('foo').