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
Lazy by default. voltcube's main entry stays under 50 KB gzipped; the engine chunk is fetched only when the first pivot mounts.

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.

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:

PropertyWhat the library doesWhen 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');
Note: every delivery shape — including the in-memory { 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:

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' },
]);
Programmatic only — no built-in filter UI. The rendered grid has no column filter dropdowns or search boxes. Filters are driven by your app code: hook up your own search input, date picker, or multi-select chip and call 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;
      },
    },
  },
})
Pick 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.

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.

ModeWhat happensWhen 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.

SurfaceRendererInteraction 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 #

EventPayload
error{ phase: 'bootstrap'; error }

Facade readiness is signalled by awaiting the promise from .mount() (and the load:complete / render:complete events).

Data #

EventPayload
load:start{ source }
load:progress{ loaded, total? }
load:completeDatasetInfo
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 #

EventPayload
query:start{ config }
query:complete{ meta }
query:error{ error, config }

Lazy fetch #

EventPayload
fetch:start{ kind, axis, path }
fetch:complete{ kind, axis, path, childrenLoaded, durationMs }
fetch:error{ kind, axis, path, error }

Render #

EventPayload
render:start{ trigger, visibleRows, visibleColumns }
render:complete{ trigger, visibleRows, visibleColumns, durationMs, cellsRendered }
render:error{ error, trigger }

Interaction · Resource · State · Lifecycle #

EventPayload
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 #

KeyDefaultWhy
Pivot.configure.assets'bundled'Zero-config via your bundler; 'cdn' (jsDelivr) is opt-in.
Pivot.configure.memoryLimitMb512Safe 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}.subtotalsfalseTableau-like, opt-in.
{rows,columns}.grandTotalfalseSame.
{rows,columns}.expandDepthunset → fully expandedEager mode only; set a number to cap the initial depth.
advanced.pageSize200Lazy 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.

One attribute toggles dark mode. Set 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.

VariableDefault (light)Role
--hpt-fontsystem sansBody font stack.
--hpt-font-monosystem monoNumeric / code font.
--hpt-fs-xs … --hpt-fs-display11–18pxType scale steps.
--hpt-bg#ffffffSurface background.
--hpt-bg-elev#ffffffCards, popovers.
--hpt-bg-subtle#fafaf9Hover / zebra.
--hpt-fg#1c1917Primary text.
--hpt-fg-strong#0a0a09Titles / emphasis.
--hpt-fg-muted#78716cSecondary text.
--hpt-fg-subtle#a8a29eHints / captions.
--hpt-border#ebe9e6Default border.
--hpt-border-strong#d6d3d1Section dividers.
--hpt-border-faint#f2f0ecHairlines.
--hpt-accent#2563ebBrand accent.
--hpt-accent-fg#ffffffForeground on accent.
--hpt-radius-sm … xl6–14pxCorner radii.
--hpt-shadow-cardthin hairlineCard elevation.
--hpt-shadow-modalsoft, deepModal / overlay elevation.

Desktop grid tokens #

Grid-specific variables, also set on .hpt-pivot-container.

VariableDefault (light)Role
--border#e4e4e7Cell border.
--border-strong#71717aHeader / sticky border.
--header-bg#fafafaHeader band background.
--row-h-bg#fafafaRow-header gutter background.
--total-bg#fef3c7Grand-total row / column.
--total-fg#78350fGrand-total text.
--subtotal-bg#fef9c3Subtotal row / column.
--subtotal-fginheritSubtotal text.
--text#18181bCell text.
--muted#71717aDim / placeholder text.
--accent#18181bChevron / selection.
--accent-fg#fafafaForeground on accent.
--hpt-tooltip-bgvar(--text)Hover-tooltip background (inverted surface by default; tracks the theme).
--hpt-tooltip-fgvar(--header-bg)Hover-tooltip text.
--hpt-cell-w130pxInner cell width.
--hpt-cell-outer-w130pxOuter cell column width.
--hpt-row-h32pxRow height.
--hpt-row-header-w160pxRow-header column width.
--hpt-row-dim-col-w140pxEach 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 });
});