Skip to content

Commit bd038e7

Browse files
authored
Merge pull request #3210 from perspective-dev/virtual-server-arrow-jamboree
Add `STRUCT` and `LIST` support for Arrow/JSON, fix DuckDB Arrow coercion
2 parents 74ed445 + 80f4303 commit bd038e7

59 files changed

Lines changed: 7124 additions & 1934 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/md/how_to/javascript/events.md‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,31 @@ elem.addEventListener("perspective-global-filter-update", function (event) {
8888
});
8989
```
9090

91+
## Layout events
92+
93+
A multi-panel `<perspective-viewer>` reports changes to its panel _collection_
94+
on two separate channels. They are distinct facts — which panels exist, and
95+
which one is selected — so neither event implies the other.
96+
97+
- `perspective-layout-update` fires when a panel is added to or removed from
98+
the layout. Its `detail.panels` is the placed panel ids in insertion order,
99+
identical to what [`getPanelNames()`](#) returns.
100+
- `perspective-active-panel-update` fires when the active panel changes, with
101+
a `detail.panel` of the new panel's id — or `null` at zero panels.
102+
103+
```javascript
104+
elem.addEventListener("perspective-layout-update", function (event) {
105+
console.log("Panels are now", event.detail.panels);
106+
});
107+
```
108+
109+
Geometry changes — dragging a split divider, reordering tabs — do **not** fire
110+
these events, because they change the layout tree without changing the panel
111+
set. Use `saveWorkspace()` to read the current geometry.
112+
91113
<div class="warning">The <code>workspace-layout-update</code> and
92114
<code>workspace-new-view</code> events from the removed
93-
<code>@perspective-dev/workspace</code> package no longer exist. Use
94-
<code>perspective-config-update</code> and
95-
<code>perspective-global-filter-update</code>.</div>
115+
<code>@perspective-dev/workspace</code> package no longer exist.
116+
<code>perspective-layout-update</code> is the closest replacement for the
117+
former; for per-panel config changes use
118+
<code>perspective-config-update</code>.</div>

‎docs/md/how_to/javascript/virtual_server/duckdb.md‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,60 @@ npm install @perspective-dev/client @perspective-dev/viewer @duckdb/duckdb-wasm
1717

1818
Initialize DuckDB-WASM, load data, and connect it to a Perspective viewer:
1919

20+
`DuckDBHandler` is an optional submodule and is _not_ exported from the package
21+
root, so it must be imported by path. It takes an `AsyncDuckDBConnection` — the
22+
result of `db.connect()` — not the `AsyncDuckDB` itself.
23+
2024
```javascript
21-
import perspective from "@perspective-dev/client";
25+
import perspective, { createMessageHandler } from "@perspective-dev/client";
2226
import "@perspective-dev/viewer";
2327
import * as duckdb from "@duckdb/duckdb-wasm";
28+
import { DuckDBHandler } from "@perspective-dev/client/dist/esm/virtual_servers/duckdb.js";
2429

2530
// Initialize DuckDB-WASM
2631
const DUCKDB_BUNDLES = duckdb.getJsDelivrBundles();
2732
const bundle = await duckdb.selectBundle(DUCKDB_BUNDLES);
28-
const worker = await duckdb.createWorker(bundle.mainWorker);
33+
const worker_url = URL.createObjectURL(
34+
new Blob([`importScripts("${bundle.mainWorker}");`], {
35+
type: "text/javascript",
36+
}),
37+
);
38+
39+
const worker = new Worker(worker_url);
2940
const logger = new duckdb.ConsoleLogger();
3041
const db = new duckdb.AsyncDuckDB(logger, worker);
31-
await db.instantiate(bundle.mainModule);
42+
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
43+
URL.revokeObjectURL(worker_url);
3244

33-
// Load data into DuckDB
45+
// Load data into DuckDB. This pragma is required to match Perspective's
46+
// sort-null semantics.
3447
const conn = await db.connect();
48+
await conn.query(`SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC;`);
3549
await conn.query(`CREATE TABLE my_table AS SELECT * FROM 'data.parquet'`);
3650

3751
// Create a Perspective virtual server backed by DuckDB
38-
const handler = perspective.DuckDBHandler(db);
39-
const messageHandler = perspective.createMessageHandler(handler);
52+
const messageHandler = await createMessageHandler(new DuckDBHandler(conn));
4053

41-
// Connect a viewer
54+
// Connect a viewer. Table ids are database-qualified, so a table created as
55+
// `my_table` is hosted as `memory.my_table`.
4256
const client = await perspective.worker(messageHandler);
43-
const table = await client.open_table("my_table");
44-
document.getElementById("viewer").load(table);
57+
const viewer = document.getElementById("viewer");
58+
viewer.load(client);
59+
viewer.restore({ table: "memory.my_table" });
60+
```
61+
62+
<div class="warning">In the browser, <code>DuckDBHandler</code> resolves
63+
Perspective's WASM module from the registered
64+
<code>&lt;perspective-viewer&gt;</code> custom element, so it cannot be
65+
constructed until that element has been defined. Off-browser, pass the module
66+
explicitly as the second constructor argument.</div>
67+
68+
Perspective never intercepts your SQL — it only discovers what `SHOW ALL
69+
TABLES` reports — so DuckDB's own remote-data features are available directly:
70+
71+
```javascript
72+
await conn.query(`CREATE SECRET (TYPE s3, KEY_ID '...', SECRET '...', REGION 'us-east-1')`);
73+
await conn.query(`CREATE TABLE trades AS SELECT * FROM read_parquet('s3://bucket/trades/*.parquet')`);
4574
```
4675

4776
## Examples

‎docs/md/how_to/python/table_data.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,56 @@ with open("data.arrow", "rb") as f:
3636
table = perspective.table(f.read())
3737
```
3838

39+
### Nested columns
40+
41+
Perspective's data model is flat, so Arrow `struct` and `list` columns are
42+
normalized on ingest.
43+
44+
A `struct` column is hoisted into one dotted column per leaf, recursively. A
45+
null parent nulls every descendant leaf:
46+
47+
```python
48+
arrow_table = pa.table({
49+
"id": pa.array([1, 2], type=pa.int64()),
50+
"s": pa.array([{"a": 10}, {"a": 20}], type=pa.struct([("a", pa.int64())])),
51+
})
52+
53+
# Schema is `{"id": "integer", "s.a": "integer"}`
54+
table = perspective.table(arrow_table)
55+
```
56+
57+
Because the flattened names are ordinary columns, a `Table` created from an
58+
explicit schema accepts nested updates with no further configuration:
59+
60+
```python
61+
table = perspective.table({"id": "integer", "s.a": "integer"})
62+
table.update(arrow_table)
63+
```
64+
65+
A `list` column is controlled by the `list_flatten` argument:
66+
67+
- `"zip"` (default) expands a row into one row per list element, repeating
68+
its non-list siblings. An empty or null list yields a single row with a
69+
null in that column, rather than dropping the row. When a row has more than
70+
one list column, their non-empty lengths must match.
71+
- `"cartesian"` expands a row into the product of its list columns' lengths,
72+
with an empty or null list counting as a single null element.
73+
- `"stringify"` encodes each list as a JSON array in a single string column,
74+
leaving the row count unchanged.
75+
76+
```python
77+
arrow_table = pa.table({
78+
"x": pa.array([1, 2], type=pa.int64()),
79+
"y": pa.array([[10, 20], [30]], type=pa.list_(pa.int64())),
80+
})
81+
82+
# `{"x": [1, 1, 2], "y": [10, 20, 30]}`
83+
perspective.table(arrow_table)
84+
85+
# `{"x": [1, 2], "y": ["[10,20]", "[30]"]}`
86+
perspective.table(arrow_table, list_flatten="stringify")
87+
```
88+
3989
## Polars
4090

4191
```python

‎docs/md/how_to/python/virtual_server/duckdb.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ const table = await websocket.open_table("my_table");
4848
document.getElementById("viewer").load(table);
4949
```
5050

51+
## Window functions
52+
53+
Window columns are DuckDB's own functions, under their DuckDB names — the
54+
advertised name is emitted into the `OVER` clause verbatim.
55+
56+
| | |
57+
| --- | --- |
58+
| Aggregating | `sum` `avg` `count` `min` `max` `product` `median` |
59+
| Deviation / variance | `stddev_samp` `stddev_pop` `var_samp` `var_pop` |
60+
| Navigation | `first_value` `last_value` `nth_value` `lag` `lead` |
61+
| Ranking | `row_number` `rank` `dense_rank` `percent_rank` `cume_dist` `ntile` |
62+
| Perspective's own | `diff` `rate` |
63+
5164
## Examples
5265

5366
- [Python DuckDB example](https://github.com/perspective-dev/perspective/tree/master/examples/python-duckdb-virtual)

‎packages/react/README.md‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# `@perspective-dev/react`
2+
3+
[![npm](https://img.shields.io/npm/v/@perspective-dev/react.svg?style=for-the-badge)](https://www.npmjs.com/package/@perspective-dev/react)
4+
5+
React bindings for [Perspective](https://perspective-dev.github.io/), an
6+
interactive analytics and data visualization component for large, real-time
7+
and streaming datasets. This package wraps the
8+
[`<perspective-viewer>`](https://perspective-dev.github.io/viewer/modules/perspective-viewer.html)
9+
Custom Element in an idiomatic, declarative React component,
10+
`<PerspectiveViewer>`, which manages the element's imperative
11+
`load()`/`restore()`/`delete()` lifecycle for you.
12+
13+
## Installation
14+
15+
```bash
16+
npm install @perspective-dev/react
17+
```
18+
19+
`@perspective-dev/client` and `@perspective-dev/viewer` are installed as
20+
dependencies, but you'll also want at least one plugin package for the
21+
visualizations themselves:
22+
23+
```bash
24+
npm install @perspective-dev/viewer-datagrid @perspective-dev/viewer-charts
25+
```
26+
27+
## Setup
28+
29+
Perspective's engine and UI are WebAssembly binaries which must be initialized
30+
once, before the first `<PerspectiveViewer>` renders. Plugins register
31+
themselves via import side effects. See the
32+
[User Guide's bundling section](https://perspective-dev.github.io/guide/how_to/javascript/importing.html)
33+
for bundler configuration details.
34+
35+
```tsx
36+
import perspective from "@perspective-dev/client";
37+
import perspective_viewer from "@perspective-dev/viewer";
38+
import "@perspective-dev/viewer-datagrid";
39+
import "@perspective-dev/viewer-charts";
40+
import "@perspective-dev/viewer/dist/css/themes.css";
41+
42+
import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm";
43+
import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm";
44+
45+
await Promise.all([
46+
perspective.init_server(fetch(SERVER_WASM)),
47+
perspective_viewer.init_client(fetch(CLIENT_WASM)),
48+
]);
49+
```
50+
51+
## Usage
52+
53+
Create a `Table` (here in a Web Worker `Client`) and pass it — or a `Promise`
54+
of it — to `<PerspectiveViewer>`:
55+
56+
```tsx
57+
import * as React from "react";
58+
import { PerspectiveViewer } from "@perspective-dev/react";
59+
60+
const WORKER = await perspective.worker();
61+
62+
const TABLE = WORKER.table(
63+
fetch("superstore.lz4.arrow").then((resp) => resp.arrayBuffer()),
64+
{ name: "superstore" },
65+
);
66+
67+
const App: React.FC = () => (
68+
<PerspectiveViewer
69+
client={TABLE}
70+
config={{ group_by: ["State"], plugin: "Y Bar" }}
71+
/>
72+
);
73+
```
74+
75+
## Props
76+
77+
| Prop | Type | Description |
78+
| :--------------- | :------------------------------------------------------------ | :-------------------------------------------------------------- |
79+
| `client` | `Client \| Table \| Promise<Client> \| Promise<Table>` | Data source. When `undefined`, the viewer `eject()`s. |
80+
| `config` | `ViewerConfigUpdate \| WorkspaceConfigUpdate` | Declarative viewer state, applied via `restore()`. |
81+
| `onConfigUpdate` | `(config: ViewerConfigUpdate) => void` | Called when the user reconfigures the viewer through its UI. |
82+
| `onClick` | `(detail: PerspectiveClickEventDetail) => void` | Called when the user clicks a datapoint. |
83+
| `onSelect` | `(detail: PerspectiveSelectEventDetail) => void` | Called when the user selects (or deselects) a datapoint or row. |
84+
85+
A subset of standard HTML attributes — `className`, `id`, `style`, `hidden`,
86+
`slot`, `tabIndex` and `title` — is forwarded to the underlying element.
87+
88+
### `client`
89+
90+
The viewer's data source, forwarded to
91+
[`viewer.load()`](https://perspective-dev.github.io/viewer/modules/perspective-viewer.html)
92+
whenever it changes:
93+
94+
- A `Table` (or `Promise<Table>`) displays that table directly.
95+
- A `Client` (e.g. from `perspective.worker()` or a WebSocket connection to a
96+
remote server) connects the viewer to every table hosted by that client;
97+
the table each panel displays is chosen by `config` or interactively by
98+
the user.
99+
- `undefined` ejects the viewer, returning it to an unloaded state without
100+
unmounting it.
101+
102+
The component does not take ownership of the `Table` — delete it yourself
103+
when it is no longer needed (e.g. `table.delete({ lazy: true })`).
104+
105+
### `config`
106+
107+
Declarative viewer state — group-bys, splits, filters, sorts, expressions,
108+
plugin and plugin config — applied with `restore()` whenever it (or `client`)
109+
changes. A config with a `panels` property is treated as a multi-panel
110+
workspace layout and applied with `restoreWorkspace()` instead. Configs are
111+
compared structurally, so passing a fresh-but-equal object literal on each
112+
render does not re-apply.
113+
114+
Combine `config` with `onConfigUpdate` to make the viewer a controlled
115+
component — store the user's latest configuration in state (or persist it) and
116+
pass it back down:
117+
118+
```tsx
119+
const App: React.FC = () => {
120+
const [config, setConfig] = React.useState<pspViewer.ViewerConfigUpdate>({
121+
group_by: ["Category"],
122+
});
123+
124+
return (
125+
<PerspectiveViewer
126+
client={TABLE}
127+
config={config}
128+
onConfigUpdate={setConfig}
129+
/>
130+
);
131+
};
132+
```
133+
134+
## Lifecycle
135+
136+
On unmount, the component calls the element's `delete()` method, freeing the
137+
viewer's WebAssembly resources. Tables and clients are created outside the
138+
component and are yours to manage; a `Table` passed as `client` survives
139+
unmount and can be shown again by a later mount.
140+
141+
## See also
142+
143+
- [`react-example`](https://github.com/perspective-dev/perspective/tree/master/examples/react-example)
144+
— a complete bundler-configured project using this package, including a
145+
multi-panel workspace config.
146+
- [Perspective User Guide](https://perspective-dev.github.io/guide/)
147+
- [`<perspective-viewer>` API documentation](https://perspective-dev.github.io/viewer/modules/perspective-viewer.html)
148+
- [`@perspective-dev/client` API documentation](https://perspective-dev.github.io/browser/modules/src_ts_perspective.browser.ts.html)

‎packages/react/src/index.tsx‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,60 @@
1111
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
1212

1313
/**
14+
* React bindings for [Perspective](https://perspective-dev.github.io/).
15+
*
16+
* This module exports {@link PerspectiveViewer}, a declarative React wrapper
17+
* for the `<perspective-viewer>` Custom Element. The component manages the
18+
* element's imperative lifecycle — `load()` and `restore()` in response to
19+
* prop changes, `delete()` on unmount — and exposes the element's Custom
20+
* Events as React-style callback props.
21+
*
22+
* Perspective's WebAssembly engine and UI must be initialized (and at least
23+
* one plugin package imported) before the first `<PerspectiveViewer>`
24+
* renders:
25+
*
26+
* ```tsx
27+
* import perspective from "@perspective-dev/client";
28+
* import perspective_viewer from "@perspective-dev/viewer";
29+
* import "@perspective-dev/viewer-datagrid";
30+
* import "@perspective-dev/viewer-charts";
31+
*
32+
* import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm";
33+
* import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm";
34+
*
35+
* await Promise.all([
36+
* perspective.init_server(fetch(SERVER_WASM)),
37+
* perspective_viewer.init_client(fetch(CLIENT_WASM)),
38+
* ]);
39+
* ```
40+
*
41+
* Then pass a `Table` (or `Client`, or a `Promise` of either) and an optional
42+
* config:
43+
*
44+
* ```tsx
45+
* import { PerspectiveViewer } from "@perspective-dev/react";
46+
*
47+
* const WORKER = await perspective.worker();
48+
* const TABLE = WORKER.table(
49+
* fetch("superstore.lz4.arrow").then((resp) => resp.arrayBuffer()),
50+
* { name: "superstore" },
51+
* );
52+
*
53+
* const App: React.FC = () => (
54+
* <PerspectiveViewer
55+
* client={TABLE}
56+
* config={{ group_by: ["State"], plugin: "Y Bar" }}
57+
* />
58+
* );
59+
* ```
1460
*
1561
* # See Also
1662
*
17-
* [`react-example`](https://github.com/perspective-dev/perspective/tree/master/examples/react-example)
18-
* project from the Perspective GitHub repo.
63+
* - [`react-example`](https://github.com/perspective-dev/perspective/tree/master/examples/react-example)
64+
* project from the Perspective GitHub repo, a complete bundler-configured
65+
* application including a multi-panel workspace config.
66+
* - [Perspective User Guide](https://perspective-dev.github.io/guide/)
67+
* - [`<perspective-viewer>` API documentation](https://perspective-dev.github.io/viewer/modules/perspective-viewer.html)
1968
*
2069
* @module
2170
*/

0 commit comments

Comments
 (0)