voltcube API reference
Everything you need to mount, control, and theme voltcube — the fluent builder, the
handle returned from .mount(), the event surface, and the CSS tokens.
Install #
The package ships ESM-only. Bundle it with Vite, webpack, esbuild, or Rollup.
npm install voltcube
Quick start #
import { Pivot } from 'voltcube';
const handle = await new Pivot()
.data({ type: 'csv', url: '/sales.csv' })
.rows(['Region', 'Category'])
.columns(['Quarter'])
.values([{ field: 'Sales', agg: 'sum' }])
.mount('#chart');
Pivot.configure() #
Static, app-wide. Optional. Call before new Pivot().
Pivot.configure({
assets?: 'cdn' | 'bundled'
| { baseUrl: string }
| { mainModule: string; mainWorker: string }, // default 'bundled'
memoryLimitMb?: number, // default 512
debugMode?: boolean, // verbose engine logs
});
assets defaults to 'bundled' — zero-config asset discovery
through your own bundler graph (the engine + worker resolve via
import.meta.url). 'cdn' is opt-in and pulls the engine assets
from jsDelivr. Pass { baseUrl } to self-host from a directory, or
{ mainModule, mainWorker } to point at explicit URLs.
Every new Pivot().mount() boots its own dedicated worker with its own
memoryLimitMb budget — instances do not share a worker
or its memory budget, so multiple pivots on one page each pay their own worker cost.
Pivot.prewarm() is the one exception: it boots a
worker ahead of time and the next mount() adopts it. Calling
configure() after the first worker has booted throws — configuration is
locked at that point.
Pivot.prewarm() #
Static. Optional. Cold-start mitigation.
Pivot.prewarm(): Promise<void>;
Boots a worker (DuckDB-WASM init included) before any mount() call, so a
later new Pivot()....mount() on the page can adopt the already-booted
worker instead of paying the cold-start cost inline. Typical use: call it
fire-and-forget at module load.
- Overlapping calls share one in-flight boot; idempotent while pending.
- Single-use adoption — the next
mount()consumes the prewarmed worker. Aprewarm()call issued after that hand-off starts a fresh boot. - Locks configuration the same way
mount()does. - If the boot fails, module state resets so the next
prewarm()ormount()retries cleanly rather than inheriting a dead promise. If youawait/.catch()the returned promise yourself you still see the rejection; a bare fire-and-forget call is protected from surfacing as an unhandled rejection.
Pivot.prewarm(); // fire-and-forget at module load
Builder methods #
Synchronous, chainable, return this. Nothing runs until .mount().
.data(spec) #
Discriminated union, keyed by the property name (url, text,
data, loader) — never by string sniffing. Four source
formats × four delivery shapes; pick whichever lines up with how the data already
sits in your app.
type DataSpec =
| { type: 'csv'; url: string | URL; schema?: SchemaHint }
| { type: 'csv'; text: string; schema?: SchemaHint }
| { type: 'csv'; data: File | Blob | ArrayBuffer; schema?: SchemaHint }
| { type: 'csv'; loader: (signal) => Promise<string | Blob | ArrayBuffer> }
| { type: 'json'; url: string | URL; schema?: SchemaHint }
| { type: 'json'; text: string; schema?: SchemaHint }
| { type: 'json'; data: Record<string, unknown>[]; schema?: SchemaHint }
| { type: 'json'; loader: (signal) => Promise<unknown[]> }
| { type: 'arrow'; url: string | URL }
| { type: 'arrow'; data: ArrowTable | ArrayBuffer }
| { type: 'arrow'; loader: (signal) => Promise<ArrowTable | ArrayBuffer> }
| { type: 'parquet'; url: string | URL }
| { type: 'parquet'; data: Blob | ArrayBuffer }
| { type: 'parquet'; loader: (signal) => Promise<Blob | ArrayBuffer> };
The four delivery shapes resolve to one of four library behaviors:
| Property | What the library does | When to pick it |
|---|---|---|
url |
Fetches from the URL. CSV and Parquet stream natively through the engine — large files don't have to fit in JS memory. | Static files, public endpoints, anything reachable by a GET. |
text |
Registers the string as a virtual file directly in the engine — no encoding roundtrip. | You already fetched the source elsewhere (auth wrapper, SDK, server template) and have a string in hand. |
data |
Bytes (ArrayBuffer, Blob, File) or an in-memory shape native to the format (JSON array, Arrow table). Registered directly. |
File-picker uploads, prefetched bytes, pipelines that already produced an Arrow table. |
loader |
Async escape hatch — you own the fetch. Receives an AbortSignal so abort + retry work. |
Auth-wrapped requests, SDK-wrapped calls, anywhere you need control over headers / retries / streaming. |
schema — per-field load-time overrides #
The optional schema on a CSV or JSON source coerces individual columns at
load time — applied once as a SELECT * REPLACE (…) so every
downstream stage (auto-formatting, date bucketing, sorting, eager + lazy) sees a
type-correct column for free.
type SchemaHint = Record<string, SchemaOverride>;
type SchemaOverride = ColumnType | ColumnTypeOverride;
interface ColumnTypeOverride {
type?: ColumnType; // post-inference CAST to this logical type
epochUnit?: 'ms' | 's'; // integer epoch → real TIMESTAMP at load
}
The headline case: a column holding a Unix-epoch integer isn't a date
until you say so. Declaring epochUnit converts it to a real
TIMESTAMP, so it renders as a date and buckets like one:
// `order_ts` is epoch milliseconds (e.g. 1609718400000).
new Pivot()
.data({ type: 'csv', url: '/events.csv', schema: { order_ts: { epochUnit: 'ms' } } })
.rows([{ field: 'order_ts', bucket: 'month' }])
.values([{ field: 'revenue', agg: 'sum' }])
.mount('#app');
- String shorthand —
{ amount: 'number' }≡{ amount: { type: 'number' } }. The coercion is a post-inferenceCAST: it changes the logical type but can't recover information the reader already discarded (forcing a numeric ZIP to'string'yields'1234', not'01234'). - epochUnit —
'ms'and's'both route through a TZ-naive, UTC-stable conversion, so the two units yield identical wall-clock values regardless of the viewer's timezone. ISO date strings (e.g.2021-01-04) need no override — the engine already infers them as dates. - Off-whitelist values (an unknown
typeor anepochUnitother than'ms'/'s') are rejected at load with aPivotConfigError.
{ type: 'json', data: array } form — is safe to pass directly; the library serialises array input to transferable bytes so it crosses the worker boundary without a structured-clone hazard.
.rows(dims) #
.rows(dims: string[] | DimensionSpec[]): this
Dimensions that drive the row axis — outermost group first. String shorthand
expands to { field: <string> }; use the object form when you need a
time-bucket (bucket: 'month') or a custom binning width.
// Single dim — flat row list.
.rows(['Region'])
// Multi-dim hierarchy — Region groups, Category nests under each.
.rows(['Region', 'Category'])
// Object form — bucket a date dim into months.
.rows([
{ field: 'Region' },
{ field: 'OrderDate', bucket: 'month' },
])
.columns(dims) #
.columns(dims: string[] | DimensionSpec[]): this
Same shape as .rows — dimensions that fan across the column axis.
Skip the call (or pass []) for a row-only summary; the renderer collapses
the column header to a single value column.
// Single column dim.
.columns(['Quarter'])
// Nested columns — Year outer, Quarter inner.
.columns(['Year', 'Quarter'])
Measure Names — splice the measure labels onto an axis #
When you have more than one measure, voltcube fans them across the bottom of the
column header by default. Drop the Measure Names sentinel into either array
to splice the measure labels into the hierarchy at the position you choose — same
concept as Tableau's "Measure Names" or Excel's Σ Values chip. Two
equivalent forms ship:
- The literal string
'Measure Names'— zero imports, easy to read in config files. - The exported symbol
MN— collision-proof when a real schema column happens to share the name.
import { Pivot, MN } from 'voltcube';
// String form
.rows(['Region', 'Measure Names', 'Category'])
// Symbol form — same effect, collision-proof
.rows(['Region', MN, 'Category'])
The sentinel works in .columns([…]) exactly the same way — its position
decides which axis carries the measure labels and where in the header stack they sit.
.values(specs) #
.values(specs: ValueSpec[]): this
type ValueSpec =
| { field: string; agg: Agg; as?: string } // AggregatedValueSpec
| { as: string; expr: string }; // CalculatedValueSpec
Eight aggregations supported: sum, avg, min,
max, count, count_distinct, median,
stddev.
CalculatedValueSpec composes prior aliases with the four arithmetic
operators, parentheses, and the function whitelist (NULLIF,
COALESCE). Example: { as: 'Margin', expr: 'Profit / NULLIF(Sales, 0)' }.
.filter(filters) #
.filter(filters: FilterSpec[]): this
Array-of-objects. Each call replaces the active filter set — it does not append. Values are parameterised end-to-end, so user-supplied strings and numbers cannot break out of the value slot.
interface FilterSpec { field: string; op: FilterOp; value?: unknown };
type FilterOp =
| 'eq' | 'neq' | 'in' | 'not_in'
| 'gt' | 'gte' | 'lt' | 'lte'
| 'between' | 'contains'
| 'is_null' | 'is_not_null';
await handle.filter([
{ field: 'Region', op: 'in', value: ['East', 'West'] },
{ field: 'OrderDate', op: 'gte', value: '2024-01-01' },
{ field: 'Sales', op: 'between', value: [100, 10000] },
{ field: 'Customer', op: 'contains', value: 'Acme' },
{ field: 'DiscountPct', op: 'is_null' },
]);
handle.filter([…]).
.sort(sorts) #
.sort(sorts: SortSpec[]): this
Array-of-objects in priority order. Each call replaces the active sort — it does not append. Sort by a dim field, by a measure alias, or by a calculated value's alias.
interface SortSpec { field: string; direction: 'asc' | 'desc' };
// Single-key sort.
await handle.sort([{ field: 'Sales', direction: 'desc' }]);
// Multi-key with tiebreaker priority — Region first, ties broken by Sales desc.
await handle.sort([
{ field: 'Region', direction: 'asc' },
{ field: 'Sales', direction: 'desc' },
]);
// Clear the active sort.
await handle.sort([]);
Sort also has a click cycle on the rendered headers — Click, Shift+Click for multi-key, Esc to clear. See Behavior model → Interactive sort for the full interaction model.
.options(opts) #
All keys optional. Merges deeply with prior .options() calls (and with
handle.update({ options })): plain nested objects (rows,
columns, values, formatting,
advanced) merge key-by-key recursively; arrays (like
subtotals: number[]) and non-plain values replace wholesale; a key
explicitly set to undefined in the patch is ignored, not applied as a clear.
interface PivotOptionsSpec {
mode?: 'auto' | 'eager' | 'lazy'; // default 'auto'
surface?: 'desktop' | 'mobile'; // default 'desktop'
measurePlacement?: 'hidden'
| { axis: 'rows' | 'columns'; index: number };
rows?: AxisOptions;
columns?: AxisOptions;
values?: Record<string, FieldConfig>; // keyed by measure alias
formatting?: FormattingConfig; // GLOBAL display defaults (see Formatting)
advanced?: {
pageSize?: number; // default 200
maxAxisGroupEstimate?: number; // auto eager/lazy probe threshold
};
}
interface FormattingConfig {
nullDisplay?: string; // GLOBAL empty-cell text (default: '')
invalidDisplay?: string; // GLOBAL invalid-cell text (default: 'Invalid')
}
interface AxisOptions {
subtotals?: boolean | number[];
grandTotal?: boolean;
expandDepth?: number; // default: unset → fully expanded (eager)
fields?: Record<string, FieldConfig>;
}
FieldConfig #
Unified config used for both dims and measures. Keyed by measure alias under options.values and by dim field name under options.{rows,columns}.fields.
interface FieldConfig {
format?: NumberFormat | DateFormat;
valueFormat?: (ctx: ValueFormatContext) => string;
// dim-only sugar (ignored on measure entries)
bucket?: 'day' | 'week' | 'month' | 'quarter' | 'year'
| { width: number };
sort?: 'asc' | 'desc';
}
sort: 'asc' | 'desc' sets an author default that the runtime click cycle overrides per-field.
For format and valueFormat, see the dedicated formatting reference below.
Formatting #
Two complementary surfaces. format is the structural option — pre-computed
once during reshape, cached, and shared across every cell of the same field. Use it
for currency, percentage, compact notation, decimal precision, and dates.
valueFormat is the per-cell escape hatch — runs at render time for cells
where you need conditional logic (different glyph for totals, prefix for negatives,
etc.). The renderer memoises valueFormat by (rowId, colId, alias)
so pure scroll never recomputes it.
Empty / invalid display strings (nullDisplay /
invalidDisplay) can be set per field on the format
rule, or globally via options.formatting. Resolution is
per-field → global → built-in default ('' for
empty, 'Invalid' for invalid).
Number formatter — NumberFormat #
Inspired by Intl.NumberFormat; voltcube adds prefix, suffix, nullDisplay, and invalidDisplay.
interface NumberFormat {
style?: 'decimal' | 'currency' | 'percent' | 'compact';
minimumFractionDigits?: number;
maximumFractionDigits?: number;
currency?: string; // ISO 4217, required when style = 'currency'
locale?: string; // BCP 47, e.g. 'en-US', 'de-DE', 'ja-JP'
prefix?: string; // arbitrary text prepended to the formatted value
suffix?: string; // arbitrary text appended
nullDisplay?: string; // EMPTY text: null / undefined / blank string (default: '')
invalidDisplay?: string; // INVALID text: non-numeric string, NaN, ±Infinity (default: 'Invalid')
}
Three display states. A cell is empty
(null / undefined / blank or whitespace-only
string → nullDisplay, default ''), invalid
(present but uncoercible — a non-numeric string, NaN, or a
non-finite number → invalidDisplay, default 'Invalid'),
or valid (formats normally). A blank string is empty,
never 0. Force-cast columns (schema: { amount: 'number' })
coerce a dirty source cell to NULL at load time, so it
surfaces as empty rather than failing the load.
.options({
values: {
sum_sales: {
// USD, no decimals: $12,450
format: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 },
},
avg_margin: {
// Margin as a percent with one decimal: 12.3%
format: { style: 'percent', maximumFractionDigits: 1 },
},
total_orders: {
// Compact notation: 1.2K, 4.5M
format: { style: 'compact' },
},
sum_inventory: {
// Decimal with prefix/suffix and a null fallback.
format: {
style: 'decimal',
maximumFractionDigits: 2,
suffix: ' units',
nullDisplay: '—',
},
},
profit_ratio: {
// German locale: 1.234,56
format: { style: 'decimal', locale: 'de-DE' },
},
},
})
Date formatter — DateFormat #
Two modes on the same surface. pattern emits a fixed,
locale-independent shape (hyphen-joined). dateStyle /
timeStyle defer to Intl.DateTimeFormat for
locale-aware output. The engine auto-applies
{ pattern: 'DD-MM-YYYY' } to Arrow Date32 /
Date64 / Timestamp columns when no explicit
format is set — set one to override.
interface DateFormat {
// Locale-aware (Intl.DateTimeFormat)
dateStyle?: 'full' | 'long' | 'medium' | 'short';
timeStyle?: 'full' | 'long' | 'medium' | 'short';
// Locale-independent, hyphen-separated. Wins over dateStyle if both set.
pattern?: 'DD-MM-YYYY' | 'YYYY-MM-DD' | 'MM-DD-YYYY';
locale?: string; // numeral system + dateStyle locale
timeZone?: string; // IANA name; applies to both modes
nullDisplay?: string; // EMPTY text: null / undefined / blank string (default: '')
invalidDisplay?: string; // INVALID text: unparseable date (default: 'Invalid')
}
Default behaviour. Every Arrow date / timestamp column
you load picks up { pattern: 'DD-MM-YYYY' } automatically.
Example output: 15-03-2024. No configuration needed.
Non-date columns get no auto-default and pass through as String(value)
unless you provide a rule.
.options({
rows: {
fields: {
// Auto-default kicks in — no rule needed → "15-03-2024"
OrderDate: {},
// Override to ISO → "2024-03-15"
ShipDate: { format: { pattern: 'YYYY-MM-DD' } },
// Locale-aware override → "Jan 14, 2025"
InvoiceDate: { format: { dateStyle: 'medium', locale: 'en-US' } },
// Pattern + explicit timezone — renders the LOCAL date in NY
EventTs: { format: { pattern: 'DD-MM-YYYY', timeZone: 'America/New_York' } },
// Full Intl style with time component
ProcessedAt: { format: { dateStyle: 'full', timeStyle: 'medium', timeZone: 'America/Los_Angeles' } },
},
},
})
Precedence:
pattern wins over dateStyle / timeStyle
on the same rule. Any explicit user rule entirely replaces the engine
auto-default for that field (no partial merging — set everything you
want kept). The __measure__ pseudo-dim is always excluded
from formatting; its labels are display strings, not values.
Date cells follow the same three states as numbers: empty
(null / undefined / blank → nullDisplay,
default '') and invalid (an unparseable
date → invalidDisplay, default 'Invalid').
Per-cell escape hatch — valueFormat #
interface ValueFormatContext {
raw: number | string | null; // engine-produced value
formatted: string; // default Intl pre-format
field: string; // dim field name OR measure alias
rowId: string;
colId: string;
isTotal: boolean; // grand-total / subtotal row or cell
isDim: boolean; // dim header vs measure cell
}
.options({
values: {
sum_sales: {
format: { style: 'currency', currency: 'USD' },
valueFormat: ({ raw, formatted, isTotal }) => {
if (raw === null) return '—';
if (isTotal) return `★ ${formatted}`; // flag totals
if ((raw as number) < 0) return `(${formatted.replace('-', '')})`;
return formatted;
},
},
},
})
format over valueFormat when you can.
format is pre-computed once and cached for every cell of the field;
valueFormat runs main-thread per visible cell at render time. The renderer
memoises by (rowId, colId, alias) so pure scroll doesn't recompute, but
format is still meaningfully cheaper at scale. Use valueFormat
when you need cell-conditional logic (totals, sign-based, glyph injection); use
format for everything else.
.on(event, handler) #
Same shape as the handle, returns this at builder time and an unsubscribe
function at handle time. Pre-mount handlers fire after mount in registration order.
.on('render:complete', ({ durationMs }) => console.log(durationMs));
.on('*', ({ name, payload }) => telemetry.log(name, payload));
.state(saved) #
.state(saved: SerializedPivotState): this
Restore a snapshot captured earlier with handle.getState() —
rows / columns / values / filters / sort / totals / formatting / measure placement, plus the
live expansion (expanded + collapsed paths, lossless on eager AND lazy) and column /
row-gutter widths. The snapshot is view-config only: it excludes the data
binding, so re-supply .data(...) on the fresh builder. It also restores
rows/columns/values, so you need not re-declare them. Call .state() only with a
real snapshot — an empty object throws.
const saved = localStorage.getItem('pivot:view');
const builder = new Pivot().data({ type: 'csv', url: '/sales.csv' }); // host re-supplies data
if (saved) builder.state(JSON.parse(saved)); // restores everything else
await builder.mount('#chart');
.mount(target) — terminal #
.mount(target: string | HTMLElement): Promise<PivotHandle>
Resolves the target, loads the data, runs the first query, and mounts the grid.
Behavior model #
How the library behaves at runtime — what users see when they interact, how queries materialise, which renderer paints the result. None of this is something you call; it's the model behind the methods above.
Interactive sort #
The rendered headers are interactive — every dim header, measure cell, and col-dim label is clickable for sort. You don't need to wire anything; the facade routes click events through handle.sort(…) automatically.
- Click a header — replaces the active sort with that key. Measures cycle
desc → asc → cleared(biggest first); dim headers cycleasc → desc → cleared(A→Z first). - Shift+Click a header — adds the key as a tiebreaker (lowest priority). Shift+Click an active key cycles it in place; a third Shift+Click removes only that key (siblings survive).
- Tab to a header, Enter or Space to cycle, Esc to clear all. Shift+Enter / Shift+Space behave like Shift+Click for accessibility parity.
The Measure Names pseudo-dim label renders but is intentionally not sortable — it's a render-layer splice marker, not a schema column, and would be rejected by the SQL guard.
Programmatic pivot.sort([…]) / handle.sort([…]) remains the path for headless contexts (telemetry, tests); the cycle UI and the API resolve to the same engine config.
// Multi-key tiebreaker — programmatic equivalent of
// Click Region, Shift+Click Category, Shift+Click Sales.
await handle.sort([
{ field: 'Region', direction: 'asc' },
{ field: 'Category', direction: 'asc' },
{ field: 'Sales', direction: 'desc' },
]);
Loading modes — eager vs lazy #
options.mode controls how voltcube materialises the hierarchy from your dataset.
The default 'auto' runs one cheap COUNT probe at mount time and picks the right strategy for your data — you usually don't need to set this explicitly.
| Mode | What happens | When it wins |
|---|---|---|
'eager' |
Engine runs one query, materialises the full hierarchy in memory, and serves every expand / collapse from the cached result. Expansion toggles never re-query — they re-render from the cache. | Small to mid-sized result sets — under ~5,000 visible groups. Best interaction feel: expansion is instant (single-frame), sorts re-paint immediately. |
'lazy' |
Engine fetches the top level only on mount, then issues an additional reduced query each time the user expands a parent. Children stream in async; the renderer paints a chevron spinner until they arrive. Pagination of options.advanced.pageSize rows per fetch keeps each round-trip bounded.
|
Large or high-cardinality datasets — millions of rows, deep hierarchies, or many column dimensions. Initial paint stays fast; memory stays bounded because un-expanded subtrees are never materialised. |
'auto' (default) |
Probes the dataset with a single COUNT at mount time and picks eager below ~5,000 groups, lazy above.
|
Almost always. Override only when you know the cardinality up-front and want to skip the probe. |
Lazy fetches emit typed fetch:start / fetch:complete / fetch:error events (see Events · Lazy fetch) so you can show drilldown spinners or push telemetry. Sort and filter work identically in either mode — the cycle and the spec are the same; the engine just chooses when to re-issue SQL vs reuse the cache.
// Force lazy when you know the result is large — skips the probe round-trip.
await new Pivot()
.data({ type: 'parquet', url: '/sales-50m.parquet' })
.rows(['Region', 'Country', 'City'])
.columns(['Quarter'])
.values([{ field: 'Sales', agg: 'sum' }])
.options({ mode: 'lazy', advanced: { pageSize: 500 } })
.mount('#chart');
Surface — desktop vs mobile #
options.surface chooses which renderer voltcube mounts. It defaults to
'desktop'; set 'mobile' to mount the drill-card surface. You
choose the surface explicitly — there is no runtime viewport auto-pick. To switch
surfaces after mount, call handle.options({ surface }), which tears down
the active renderer and remounts the other one against the same result.
| Surface | Renderer | Interaction model |
|---|---|---|
'desktop' |
Pivot grid — a two-axis virtualised <div> grid with sticky row + column headers, chevron expand / collapse on row-dim and col-dim cells, the sort cycle described above, and a recycling cell pool tuned for 60 fps scroll on tens of thousands of cells.
|
Click / Shift+Click on headers; chevron toggles expand-collapse; horizontal + vertical scroll; keyboard navigation (Tab / Enter / Esc). |
'mobile' |
Drill cards — vertically-stacked summary cards, one per row leaf. Each card surfaces the row's KPIs at a glance; tapping a card opens a full-screen drill modal that lazy-loads the per-column breakdown. | Tap a card to drill, swipe to scroll, modal close button to return. No horizontal scroll — the columns live inside the modal, not in the main flow. |
Both surfaces consume the same PivotResult from the same worker — there's no second engine. Loading mode (options.mode) is independent of surface; you can run 'lazy' on mobile or eager on desktop. The two options compose freely.
// Pin mobile explicitly (e.g. inside a phone-frame demo or a known mobile-only host)
await new Pivot()
.data({ type: 'csv', url: '/sales.csv' })
.rows(['Region', 'Country'])
.values([{ field: 'Sales', agg: 'sum' }])
.options({ surface: 'mobile' })
.mount('#chart');
// Switch surfaces after mount — same handle, same result + expansion state.
await handle.options({ surface: 'desktop' });
PivotHandle #
Returned from .mount(). Every mutator triggers re-query + re-render automatically.
interface PivotHandle {
update(partial: DeepPartial<PivotSpec>): Promise<void>;
data(spec: DataSpec): Promise<void>;
rows(dims): Promise<void>;
columns(dims): Promise<void>;
values(specs): Promise<void>;
filter(filters): Promise<void>;
sort(sorts): Promise<void>;
options(opts): Promise<void>;
refresh(): Promise<void>;
snapshot(): PivotResult | null;
getState(): SerializedPivotState;
getSchema(): Promise<ColumnSchema[]>;
datasetInfo: DatasetInfo | null;
on(event, handler): () => void; // returns unsubscribe
destroy(): Promise<void>;
}
getState() returns a JSON-cloneable snapshot of the current view — config
(rows/columns/values/filters/sort/totals/formatting), live expansion (eager AND lazy),
and column / row-gutter widths — with the data binding deliberately excluded. Pair it with
the builder's .state(saved) to restore. Subscribe to
state:change to persist automatically as the user sorts, expands, or resizes
(bursts are debounced into one emission). The library never touches storage — you choose
where the snapshot lives.
destroy() terminates this instance's own worker (each mount()
has its own — see Pivot.configure()) and is idempotent: calling
it again is a safe no-op that resolves immediately. Any other handle method
called after destruction rejects fast with "Pivot instance has been destroyed" rather
than hanging. A mount() call that itself fails cleans up any worker it had
already booted.
Events #
Phase-ordered. Every event has a typed payload via PivotEventMap.
Bootstrap #
| Event | Payload |
|---|---|
error | { phase: 'bootstrap'; error } |
Facade readiness is signalled by awaiting the promise from
.mount() (and the load:complete / render:complete events).
Data #
| Event | Payload |
|---|---|
load:start | { source } |
load:progress | { loaded, total? } |
load:complete | DatasetInfo |
load:error | { error } |
schema:ready | { columns } |
load:progress streams during url / File /
Blob loads (~1 MB / 100 ms throttled); total is
omitted when the response has no known length. Pre-buffered sources
(text, in-memory data) emit one final report instead of a
stream. load:complete's DatasetInfo carries an optional
coercion map (Record<string, { nullRows, totalRows }>),
populated only when .data({ schema }) overrides types — nullRows
counts every row NULL after the cast, including ones that were already NULL before
coercion ran.
Query #
| Event | Payload |
|---|---|
query:start | { config } |
query:complete | { meta } |
query:error | { error, config } |
Lazy fetch #
| Event | Payload |
|---|---|
fetch:start | { kind, axis, path } |
fetch:complete | { kind, axis, path, childrenLoaded, durationMs } |
fetch:error | { kind, axis, path, error } |
Render #
| Event | Payload |
|---|---|
render:start | { trigger, visibleRows, visibleColumns } |
render:complete | { trigger, visibleRows, visibleColumns, durationMs, cellsRendered } |
render:error | { error, trigger } |
Interaction · Resource · State · Lifecycle #
| Event | Payload |
|---|---|
cell:click | { rowId, colId, alias, raw, formatted, isTotal, event } |
header:click | { axis, field?, alias?, event } |
node:expand | { axis, path, mode } |
node:collapse | { axis, path } |
memory:warning | { usedMb, limitMb } |
memory:limit | { usedMb, limitMb, query? } |
worker:error | { error, recoverable } |
state:change | { state: SerializedPivotState } — debounced after a user sort / expand / resize |
destroy | {} |
memory:warning fires once when usage crosses ≥80% of that instance's
memoryLimitMb (checked after each query/load, no background polling) and
re-arms after usage drops back below the threshold. memory:limit fires at
or beyond the limit, or on an OOM-shaped engine error. A declared
surface:change event exists in the type map but is not wired to anything
today — nothing emits it, so don't rely on it.
Smart defaults #
| Key | Default | Why |
|---|---|---|
Pivot.configure.assets | 'bundled' | Zero-config via your bundler; 'cdn' (jsDelivr) is opt-in. |
Pivot.configure.memoryLimitMb | 512 | Safe default for in-browser engines. |
options.mode | 'auto' | COUNT probe — eager <5k groups, lazy beyond. |
options.surface | 'desktop' | Desktop grid; set 'mobile' for drill cards. |
{rows,columns}.subtotals | false | Tableau-like, opt-in. |
{rows,columns}.grandTotal | false | Same. |
{rows,columns}.expandDepth | unset → fully expanded | Eager mode only; set a number to cap the initial depth. |
advanced.pageSize | 200 | Lazy fetch page size. |
FieldConfig.format (measure) | { style: 'decimal', maximumFractionDigits: 2 } | Sane numeric default. |
FieldConfig.format (date dim) | { pattern: 'DD-MM-YYYY' } | Auto-applied to Arrow Date32 / Date64 / Timestamp columns. Locale-independent. |
Theming · CSS variables #
Every renderer token is scoped to .hpt-pivot-container — the boundary the
library attaches inside your host element. Tokens never leak to your page's
:root and your CSS cannot accidentally cascade into the grid. Override
any variable to restyle voltcube without forking it.
data-theme="dark" on <html>, the container itself, or
any ancestor. The grid, the mobile surface, and the design tokens all flip together.
Toggle dark mode #
// Anywhere in your app
document.documentElement.setAttribute('data-theme', 'dark');
// Or per-pivot, on the container element after mount
container.setAttribute('data-theme', 'dark');
Shared design tokens #
The full --hpt-* token set. Set any of these on
.hpt-pivot-container to restyle the pivot. The desktop grid additionally
reads the grid-specific tokens below.
| Variable | Default (light) | Role |
|---|---|---|
--hpt-font | system sans | Body font stack. |
--hpt-font-mono | system mono | Numeric / code font. |
--hpt-fs-xs … --hpt-fs-display | 11–18px | Type scale steps. |
--hpt-bg | #ffffff | Surface background. |
--hpt-bg-elev | #ffffff | Cards, popovers. |
--hpt-bg-subtle | #fafaf9 | Hover / zebra. |
--hpt-fg | #1c1917 | Primary text. |
--hpt-fg-strong | #0a0a09 | Titles / emphasis. |
--hpt-fg-muted | #78716c | Secondary text. |
--hpt-fg-subtle | #a8a29e | Hints / captions. |
--hpt-border | #ebe9e6 | Default border. |
--hpt-border-strong | #d6d3d1 | Section dividers. |
--hpt-border-faint | #f2f0ec | Hairlines. |
--hpt-accent | #2563eb | Brand accent. |
--hpt-accent-fg | #ffffff | Foreground on accent. |
--hpt-radius-sm … xl | 6–14px | Corner radii. |
--hpt-shadow-card | thin hairline | Card elevation. |
--hpt-shadow-modal | soft, deep | Modal / overlay elevation. |
Desktop grid tokens #
Grid-specific variables, also set on .hpt-pivot-container.
| Variable | Default (light) | Role |
|---|---|---|
--border | #e4e4e7 | Cell border. |
--border-strong | #71717a | Header / sticky border. |
--header-bg | #fafafa | Header band background. |
--row-h-bg | #fafafa | Row-header gutter background. |
--total-bg | #fef3c7 | Grand-total row / column. |
--total-fg | #78350f | Grand-total text. |
--subtotal-bg | #fef9c3 | Subtotal row / column. |
--subtotal-fg | inherit | Subtotal text. |
--text | #18181b | Cell text. |
--muted | #71717a | Dim / placeholder text. |
--accent | #18181b | Chevron / selection. |
--accent-fg | #fafafa | Foreground on accent. |
--hpt-tooltip-bg | var(--text) | Hover-tooltip background (inverted surface by default; tracks the theme). |
--hpt-tooltip-fg | var(--header-bg) | Hover-tooltip text. |
--hpt-cell-w | 130px | Inner cell width. |
--hpt-cell-outer-w | 130px | Outer cell column width. |
--hpt-row-h | 32px | Row height. |
--hpt-row-header-w | 160px | Row-header column width. |
--hpt-row-dim-col-w | 140px | Each row-dim column. |
Custom theme — full example #
Mount the pivot, then override any token on its container. The cascade flows through
because tokens live on .hpt-pivot-container, not :root.
/* In your stylesheet */
.hpt-pivot-container {
--hpt-accent: #e11d48; /* rose */
--hpt-radius-lg: 14px;
--header-bg: #fff1f2;
--total-bg: #ffe4e6;
--total-fg: #9f1239;
}
/* Dark-mode override (page-wide) */
[data-theme='dark'] .hpt-pivot-container {
--hpt-accent: #fb7185;
--header-bg: #1a1014;
--total-bg: #3a1620;
}
Error taxonomy #
import {
PivotError,
PivotConfigError,
PivotLoadError,
PivotQueryError,
PivotMemoryError,
PivotStaleResultError,
PivotAbortedError,
} from 'voltcube';
For async facade operations (.mount() and the
PivotHandle.* mutators, which round-trip through the worker),
instanceof PivotError does not reliably hold — the error
is structured-cloned across the worker boundary and arrives as a plain
Error. Its .name (e.g. 'PivotQueryError',
'PivotConfigError') and .message are preserved, so
discriminate on error.name in those catch blocks.
instanceof is reliable only for synchronously-thrown config-validation
errors (bad spec caught before any worker call).
Worked examples #
CSV from upstream workflow #
const csvText = await myAuthClient.fetchReport('superstore');
await new Pivot()
.data({ type: 'csv', text: csvText })
.rows(['Region', 'Category'])
.columns(['Quarter'])
.values([
{ field: 'Sales', agg: 'sum', as: 'totalSales' },
{ field: 'Profit', agg: 'sum', as: 'totalProfit' },
])
.mount('#chart');
Auth-wrapped loader #
.data({
type: 'parquet',
loader: async (signal) => {
const res = await sdk.fetchReport({ id: 'q4-2025', signal });
return res.body;
},
})
Save & restore the view #
const KEY = 'pivot:view';
const DATA = { type: 'csv', url: '/sales.csv' };
// First load — persist on every user-driven change (debounced).
await new Pivot()
.data(DATA)
.rows(['Region']).columns(['Category'])
.values([{ field: 'Sales', agg: 'sum' }])
.on('state:change', ({ state }) => localStorage.setItem(KEY, JSON.stringify(state)))
.mount('#chart');
// Later session — re-bind data, restore everything else.
const saved = localStorage.getItem(KEY);
const builder = new Pivot().data(DATA);
if (saved) builder.state(JSON.parse(saved));
await builder.mount('#chart');
End-to-end perf trace #
let queryStart = 0;
handle.on('query:start', () => { queryStart = performance.now(); });
handle.on('render:complete', ({ durationMs, trigger }) => {
const total = performance.now() - queryStart;
telemetry.timing('pivot.e2e', total, { trigger });
});