diff --git a/Cargo.lock b/Cargo.lock index 6ace847b0a..7f69dd128c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,9 +300,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" @@ -970,9 +970,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -985,9 +985,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -995,15 +995,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1012,15 +1012,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -1029,21 +1029,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1053,7 +1053,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2630,9 +2629,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2973,16 +2972,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap 2.14.0", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -3479,11 +3478,11 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 0.7.14", + "winnow 1.0.4", ] [[package]] @@ -4181,6 +4180,12 @@ version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -4275,3 +4280,9 @@ dependencies = [ "quote", "syn 2.0.111", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/docs/md/FAQ.md b/docs/md/FAQ.md index 2174498cbb..adeeafffac 100644 --- a/docs/md/FAQ.md +++ b/docs/md/FAQ.md @@ -137,14 +137,31 @@ which is too small). See ### Can I do rolling sums or cumulative calculations? -Not in Perspective's built-in engine, but as an alternative, DuckDB supports -[rolling and cumulative sums via `WINDOW` functions](https://duckdb.org/docs/stable/sql/functions/window_functions), -and DuckDB now has -[native Perspective Virtual Server support](./explanation/virtual_servers.md) -which allows arbitrary DuckDB queries (as a `TABLE` or `VIEW`) to be -`` `Table`s. +Yes — use [Window Columns](./explanation/view/config/windows.md), the +`windows` property of a `View` config. These are ordered, partitioned rolling +computations analogous to SQL window functions, declared per-`View` like +expression columns: - @@ -191,21 +208,43 @@ Apply two filters on the same date column for a range. [#3100](https://github.com/perspective-dev/perspective/discussions/3100), [#2023](https://github.com/perspective-dev/perspective/issues/2023)_ --> -## JupyterLab +## Notebooks + +### `PerspectiveWidget` is not loading -### `PerspectiveWidget` is not loading in JupyterLab +`PerspectiveWidget` is an [AnyWidget](https://anywidget.dev), shipped entirely +inside the `perspective-python` wheel. There is no separate JupyterLab +extension to install or version-match for the widget, so +`jupyter labextension list` is not where to look. -See the [`PerspectiveWidget` guide](./how_to/python/jupyterlab.md) for full -setup details. Ensure the JupyterLab extension version matches your -`perspective-python` version. Make sure you are using a compatible JupyterLab -for your Perspective version (JupyterLab 4+ currently). +Install the `jupyter` extra, which pulls in `anywidget`: + +```bash +pip install "perspective-python[jupyter]" +``` -Check that the extension is enabled with `jupyter labextension list`. +Then restart the kernel — and, for JupyterLab, reload the browser page. See +the [`PerspectiveWidget` guide](./how_to/python/jupyterlab.md). +### Does `PerspectiveWidget` work outside JupyterLab? + +Yes. Because the widget is an AnyWidget bundled into the wheel rather than a +JupyterLab labextension, it runs in any AnyWidget-compatible host — +JupyterLab, classic Jupyter Notebook, **VSCode notebooks**, Google Colab and +Marimo — with no per-host install step. + +The separate `@perspective-dev/jupyterlab` package is now _optional_ and +provides only the "Open With → Perspective" file renderers for `csv`, `json` +and `arrow` files in JupyterLab. + + + ## Memory and Performance ### Perspective has a memory leak @@ -446,9 +485,10 @@ update failures. ### Can I export the viewer to HTML, PNG or PDF? -HTML and PNG exports are available via `viewer.export("html")` and -`viewer.export("png")`, respectively. For PDF, render the viewer and use browser -or headless browser screenshot capabilities. +HTML export is available via `viewer.export({ method: "html" })`. For an +image, use `{ method: "plugin" }`, which asks the plugin to render itself — +this produces a PNG for chart plugins (and text for the datagrid). For PDF, +render the viewer and use browser or headless browser screenshot capabilities. @@ -552,12 +599,16 @@ server-side data processing without any UI. Use ### Can I use Perspective in Pyodide? -There is an emscripten wheel -[published via Releases](https://github.com/perspective-dev/perspective/releases), -but it must be downloaded and hosted manually and is only built for specific -pyodide versions. +Yes. Perspective publishes Emscripten wheels to PyPI under +[PEP 783](https://peps.python.org/pep-0783/), so `perspective-python` can be +installed by Pyodide's own package resolution — there is no need to download +and host a wheel yourself. - ### How do I handle row selection events? diff --git a/docs/md/SUMMARY.md b/docs/md/SUMMARY.md index a6948f642c..3c3a57c90b 100644 --- a/docs/md/SUMMARY.md +++ b/docs/md/SUMMARY.md @@ -21,6 +21,7 @@ - [Grouping and Pivots](./explanation/view/config/grouping_and_pivots.md) - [Selection and Ordering](./explanation/view/config/selection_and_ordering.md) - [`expressions`](./explanation/view/config/expressions.md) + - [Window Columns](./explanation/view/config/windows.md) - [Advanced View Operations](./explanation/view/advanced.md) - [`Join`](./explanation/join.md) - [Join Types](./explanation/join/join_types.md) @@ -43,6 +44,7 @@ - [Saving and restoring UI state](./how_to/javascript/save_restore.md) - [Listening for events](./how_to/javascript/events.md) - [Plugin render limits](./how_to/javascript/plugin_settings.md) + - [Configuring the LLM agent](./how_to/javascript/agent.md) - [Virtual Servers](./how_to/javascript/virtual_server.md) - [DuckDB](./how_to/javascript/virtual_server/duckdb.md) - [ClickHouse](./how_to/javascript/virtual_server/clickhouse.md) diff --git a/docs/md/explanation/python.md b/docs/md/explanation/python.md index daf2237e20..eaab1809ce 100644 --- a/docs/md/explanation/python.md +++ b/docs/md/explanation/python.md @@ -60,18 +60,23 @@ via the API), this runtime mode allows large datasets without the need to copy them entirely to the Browser, at the expense of network latency on UI interaction/API calls. -### Jupyterlab - -`PerspectiveWidget` is a JupyterLab widget that implements the same API as -``, allows running such a viewer in -[JupyterLab](https://jupyterlab.readthedocs.io/en/stable/) in either server or -client (via WebAssembly) mode. `PerspectiveWidget` is compatible with Jupyterlab -3 and Jupyter Notebook 6 via a -[prebuilt extension](https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html#prebuilt-extensions). -To use it, simply install `perspective-python` and the extensions should be -available. - -`perspective-python`'s JupyterLab extension also provides convenient builtin -viewers for `csv`, `json`, or `arrow` files. Simply right-click on a file with -this extension and choose the appropriate `Perpective` option from the context -menu. +### Notebooks + +`PerspectiveWidget` is an [AnyWidget](https://anywidget.dev) that implements +the same API as ``, and runs such a viewer in either +server or client (via WebAssembly) mode. + +The widget is bundled entirely inside the `perspective-python` wheel, so +there is no per-host extension to install. It runs identically in +[JupyterLab](https://jupyterlab.readthedocs.io/en/stable/), classic Jupyter +Notebook, VSCode notebooks, Google Colab and Marimo. Install the `jupyter` +extra to pull in `anywidget`: + +```bash +pip install "perspective-python[jupyter]" +``` + +Separately, the _optional_ `@perspective-dev/jupyterlab` package provides +convenient builtin viewers for `csv`, `json`, or `arrow` files in JupyterLab. +With it installed, right-click a file of one of these types and choose the +appropriate `Perspective` option from the context menu. diff --git a/docs/md/explanation/table/schema.md b/docs/md/explanation/table/schema.md index 2e363429a2..fc8829df37 100644 --- a/docs/md/explanation/table/schema.md +++ b/docs/md/explanation/table/schema.md @@ -15,6 +15,36 @@ dictionary to the `Client::table` method, or by passing _data_ to this method from which the schema is _inferred_ (if CSV or JSON format) or inherited (if Arrow). +## Arrow type mapping + +Perspective's six column types are narrower than Arrow's type system, so Arrow +input is mapped on ingest: + +| Arrow type | Perspective type | +| --- | --- | +| `int8`, `int16`, `int32`, `int64`, `uint8`, `uint16`, `uint32`, `uint64` | `integer` | +| `float`, `double` | `float` | +| `decimal`, `decimal128` | `float` | +| `bool` | `boolean` | +| `date32`, `date64` | `date` | +| `timestamp` | `datetime` | +| `time32`, `time64` | `integer` | +| `utf8`, `large_utf8`, `binary`, `dictionary`, `list`, `null` | `string` | + +Two mappings are worth calling out: + +- Arrow `decimal` columns become `float` — a `DECIMAL` value of `3.14` reads + as `3.14`, not as its unscaled integer representation. +- Arrow `time32`/`time64` (a time-of-day with no date component) becomes + `integer`, not `datetime`. Use a `timestamp` column for a true `datetime`. + +Arrow types not listed above — including `decimal256` and the nested types — +are rejected with an error rather than silently coerced. + +Arrow input is fully validated before its buffers are read. A malformed IPC +payload — bad offsets, out-of-range dictionary indices, inconsistent chunk +lengths — is rejected with an error rather than producing corrupt data. + ## Type inference When passing CSV or JSON data to the `Client::table` constructor, the type of diff --git a/docs/md/explanation/view/advanced.md b/docs/md/explanation/view/advanced.md index 8d7266ffec..a5631e6ca9 100644 --- a/docs/md/explanation/view/advanced.md +++ b/docs/md/explanation/view/advanced.md @@ -184,17 +184,18 @@ tables across clients. ## Flattening a View into a Table -In Javascript, a [`Table`] can be constructed on a [`Table::view`] instance, -which will return a new [`Table`] based on the [`Table::view`]'s dataset, and -all future updates that affect the [`Table::view`] will be forwarded to the new -[`Table`]. This is particularly useful for implementing a -[Client/Server Replicated](server.md#clientserver-replicated) design, by -serializing the `View` to an arrow and setting up an `on_update` callback. +A [`Table`] can be constructed on a [`Table::view`] instance, which will return +a new [`Table`] based on the [`Table::view`]'s dataset, and all future updates +that affect the [`Table::view`] will be forwarded to the new [`Table`]. This is +particularly useful for implementing a +[Client/Server Replicated](../architecture/client_server.md) design, as it +handles the `View` serialization and `on_update` forwarding for you. This +pattern is available in JavaScript, Python and Rust.
```javascript -const worker1 = perspective.worker(); +const worker = await perspective.worker(); const table = await worker.table(data); const view = await table.view({ filter: [["State", "==", "Texas"]] }); const table2 = await worker.table(view); @@ -205,14 +206,9 @@ table.update([{ State: "Texas", City: "Austin" }]);
```python -table = perspective.Table(data); +table = client.table(data) view = table.view(filter=[["State", "==", "Texas"]]) -table2 = perspective.Table(view.to_arrow()); - -def updater(port, delta): - table2.update(delta) - -view.on_update(updater, mode="Row") +table2 = client.table(view) table.update([{"State": "Texas", "City": "Austin"}]) ``` diff --git a/docs/md/explanation/view/config/expressions.md b/docs/md/explanation/view/config/expressions.md index e32dd12f7f..c362ddf0e6 100644 --- a/docs/md/explanation/view/config/expressions.md +++ b/docs/md/explanation/view/config/expressions.md @@ -147,3 +147,10 @@ to the nearest multiple of 10. | `random()` | Random float between 0.0 and 1.0 | | `col(name)` | Look up a column by string name at runtime | | `vlookup(col, key)` | Look up a value in another column by row key | + +## See also + +Expressions are row-local — each output cell is computed from that row's +values alone. For calculations which span rows, such as moving averages, +cumulative sums or period-over-period differences, see +[Window Columns](./windows.md). diff --git a/docs/md/explanation/view/config/windows.md b/docs/md/explanation/view/config/windows.md new file mode 100644 index 0000000000..597faf728a --- /dev/null +++ b/docs/md/explanation/view/config/windows.md @@ -0,0 +1,183 @@ +# Window Columns + +The `windows` property declares _ordered, partitioned rolling computations_ +over the rows of a `Table` — moving averages, cumulative sums, +period-over-period differences — analogous to SQL window functions. + +Window Columns are declared per-`View`, keyed by output alias, exactly as +[`expressions`](./expressions.md) are: + +
+ +```javascript +const view = await table.view({ + columns: ["10-tick avg Sales"], + windows: { + "10-tick avg Sales": { + column: "Sales", + aggregate: "avg", + rows: 10, + }, + }, +}); +``` + +
+
+ +```python +view = table.view( + columns=["10-tick avg Sales"], + windows={ + "10-tick avg Sales": { + "column": "Sales", + "aggregate": "avg", + "rows": 10, + } + }, +) +``` + +
+ +Each window produces a new column which may be used anywhere a `Table` column +can — in `columns`, `filter`, `sort`, `group_by`, and so on. An alias must not +collide with a `Table` column, an expression alias, or another window's key. + +Window Columns update incrementally as the `Table` updates, including rows +_outside_ an update batch whose window frames were affected by it. + +## Spec fields + +| Field | Type | Description | +| --- | --- | --- | +| `column` | `string` | The input column — a `Table` column or an expression alias from the same config | +| `aggregate` | `string` | The window function to apply (see below) | +| `partition_by` | `string[]` | Columns whose distinct value tuples partition the rows; omitted partitions the whole `Table` as one group | +| `order_by` | `[string, "asc" \| "desc"]` | The column which orders each partition, and its direction | +| `rows` | `integer` | Frame of the N rows preceding each row, plus the row itself | +| `range` | `number` | Frame of rows whose `order_by` value lies within `range` of each row's | +| `cumulative` | `true` | Frame of all rows from the partition start through each row | +| `offset` | `integer` | Row offset for `lag`/`lead` (default `1`) | +| `alpha` | `number` | Smoothing factor in `(0, 1]` for `ema` | + +`rows`, `range` and `cumulative` are **mutually exclusive** — supplying more +than one is an error. `range` requires a numeric or temporal `order_by`. + +
order_by orders rows within the window +frame only. It does not reorder the View — that is what the +view-level sort +property does.
+ +## Aggregates + +| Aggregate | Description | Result type | +| --- | --- | --- | +| `sum`, `avg` | Rolling sum and mean over the frame | `float` | +| `stddev`, `var` | Rolling standard deviation and variance | `float` | +| `count` | Number of non-null values in the frame | `integer` | +| `min`, `max` | Smallest and largest value in the frame | input type | +| `lag`, `lead` | Value `offset` rows behind or ahead | input type | +| `diff` | This row's value minus the value `offset` rows behind | `float` | +| `rate` | Rate of change across the frame | `float` | +| `ema` | Exponential moving average, smoothed by `alpha` | `float` | + +`sum`, `avg`, `stddev`, `var`, `diff`, `rate` and `ema` require a numeric +input column. + +### Frame compatibility + +- `sum`, `avg`, `count`, `min`, `max`, `stddev` and `var` accept any frame. +- `lag`, `lead`, `diff` and `ema` are frame-independent — they are computed + from row offsets rather than a frame. +- **`rate` requires a `range` frame**, and is invalid with `rows` or + `cumulative`. + +
The first and last window +aggregates are declared in the type definitions but are not yet +implemented by the engine; a View which uses them will be +rejected.
+ +## Examples + +### Moving average over a fixed row count + +A 10-tick moving average, over the whole table in its natural order: + +```json +{ + "columns": ["10-tick avg Sales"], + "windows": { + "10-tick avg Sales": { + "column": "Sales", + "aggregate": "avg", + "rows": 10 + } + } +} +``` + +### Moving average over a time range + +A 5-second moving average, framing rows by their `Order Date` rather than by +count: + +```json +{ + "columns": ["5s avg Sales"], + "windows": { + "5s avg Sales": { + "column": "Sales", + "aggregate": "avg", + "order_by": ["Order Date", "asc"], + "range": 5000 + } + } +} +``` + +### Cumulative sum + +A running total from the start of each partition: + +```json +{ + "columns": ["Cumulative Sales"], + "windows": { + "Cumulative Sales": { + "column": "Sales", + "aggregate": "sum", + "order_by": ["Order Date", "asc"], + "cumulative": true + } + } +} +``` + +### Period-over-period change, per group + +`partition_by` restarts the window at each new `Region`, so each region's +first row has no predecessor to difference against: + +```json +{ + "columns": ["Region", "Sales", "Sales Δ"], + "windows": { + "Sales Δ": { + "column": "Sales", + "aggregate": "diff", + "partition_by": ["Region"], + "order_by": ["Order Date", "asc"] + } + } +} +``` + +## Support + +Window Columns are implemented by Perspective's built-in engine, by the +DuckDB, ClickHouse and Polars +[Virtual Servers](../../virtual_servers.md), and by the +`` UI. Virtual Servers advertise support through their +_features_ declaration, so the UI control is hidden for backends which do not +implement it. diff --git a/docs/md/how_to/javascript/agent.md b/docs/md/how_to/javascript/agent.md new file mode 100644 index 0000000000..44a90cfedd --- /dev/null +++ b/docs/md/how_to/javascript/agent.md @@ -0,0 +1,135 @@ +# Configuring the LLM agent + +`` ships with an embedded LLM agent which drives the viewer +through its public API — reading the schema, writing the `ViewerConfig`, +choosing a plugin, authoring ExprTK expressions and managing panels. It is +**opt-in**: the **Chat** tab in the settings sidebar stays hidden, and no +network request is ever made, until you call +`HTMLPerspectiveViewerElement::agentConfig`. + +```javascript +import { providers } from "@perspective-dev/viewer"; + +const viewer = document.querySelector("perspective-viewer"); +viewer.agentConfig({ + ...providers.anthropic, + apiKey: "sk-ant-...", +}); +``` + +## Connecting to a model + +The agent core connects via OpenAI chat-completions conventional API, over +primitive connection fields. Exactly one of `url` or `engine` is required: + +- `url` — a full chat-completions endpoint. Any OpenAI-compatible service works: + the Anthropic and Gemini compatibility endpoints, OpenRouter, LM Studio, + Ollama, llama.cpp, vLLM, or your own proxy. +- `engine` — an in-page engine object exposing + `chat.completions.create(request)`, e.g. + [WebLLM](https://github.com/mlc-ai/web-llm)'s `MLCEngine`. Mutually exclusive + with `url`. + +The remaining connection fields are `headers`, `apiKey` (sugar for an +`Authorization: Bearer` header), `model`, and `name`. The `providers` export +supplies presets for the common ones — `anthropic`, `gemini`, `openai`, +`openrouter`, `lmstudio` and `ollama` — and spread order is override order: + +```javascript +viewer.agentConfig({ + ...providers.anthropic, + apiKey: "sk-ant-...", + model: "claude-haiku-4-5", // overrides the preset's default +}); +``` + +Local servers usually need their CORS opt-in enabled first: +[LM Studio](https://lmstudio.ai/) has a setting in its developer server panel, +and Ollama reads `OLLAMA_ORIGINS`. + +> **A key in `agentConfig` is a key in the browser tab.** It is sent directly to +> the provider from the page, which is fine for local development and internal +> tools, but for anything shared you should point `url` at a proxy you control +> and keep the credential on the server. + +Tool-calling quality varies more than general chat quality does. Frontier models +handle the viewer's tool surface reliably; among local models, recent Qwen and +Llama instruct builds are the ones to try first. + +### In-page engines + +An `engine` runs the model in the tab, so no prompt and no data leave the +machine and no key is involved. [WebLLM ](https://github.com/mlc-ai/web-llm) for +example: + +```javascript +import * as webllm from "@mlc-ai/web-llm"; + +const engine = await webllm.CreateMLCEngine( + "Hermes-3-Llama-3.1-8B-q4f16_1-MLC", + { initProgressCallback: (x) => console.log(x.text) }, + { context_window_size: 16384 }, +); + +viewer.agentConfig({ + name: "webllm", + engine, + systemRole: "user", +}); +``` + +## The documentation bundle + +`` publishes a metadata bundle at +`dist/docs/perspective-docs.json` containing a searchable corpus of the +Perspective documentation plus generated JSON schemas for the viewer's config +types. Passing it as `docs` is optional, but without it the agent will not be +very capable — it is what lets the agent look things up rather than guess: + +```javascript +import docs from "@perspective-dev/viewer/dist/docs/perspective-docs.json" with { type: "json" }; + +viewer.agentConfig({ ...providers.anthropic, apiKey: "sk-ant-...", docs }); + +// ... or ... + +viewer.agentConfig({ + ...providers.anthropic, + apiKey: "sk-ant-...", + docs: fetch( + "node_modules/@perspective-dev/viewer/dist/docs/perspective-docs.json", + ), +}); +``` + +Without it the agent still works: `search_docs` searches an empty corpus and the +tool parameter schemas degrade to permissive objects. The practical difference +is how often a weaker model invents a field name that doesn't exist, or writes +an ExprTK expression against syntax Perspective doesn't have. + +### Telling the agent about your data + +The agent learns column names and types from `get_schema`, but not what they +_mean_ — that `Discount` is a ratio rather than a percent, or that a negative +`Profit` is a return rather than an error. Add those notes as extra corpus +entries: + +```javascript +import bundle from "@perspective-dev/viewer/dist/docs/perspective-docs.json" with { type: "json" }; + +const DATASET_DOCS = [ + { + title: "Superstore columns", + text: "`Discount` is a ratio in [0, 1], not a percent. `Profit` is net of `Discount` and is negative for returns.", + }, +]; + +viewer.agentConfig({ + ...providers.anthropic, + apiKey: "sk-ant-...", + docs: { ...bundle, chunks: [...bundle.chunks, ...DATASET_DOCS] }, +}); +``` + +An inline `[{title?, text}]` array may also be passed as `docs` on its own, when +you have host notes but no packaged bundle. \ No newline at end of file diff --git a/docs/md/how_to/javascript/events.md b/docs/md/how_to/javascript/events.md index 93aee17694..6bbfae084e 100644 --- a/docs/md/how_to/javascript/events.md +++ b/docs/md/how_to/javascript/events.md @@ -22,19 +22,74 @@ elem.addEventListener("perspective-config-update", function (event) { ## Click events Whenever a ``'s grid or chart is clicked, a -`perspective-click` DOM event is fired containing a detail object with `config`, -`column_names`, and `row`. +`perspective-click` DOM event is fired containing a detail object with +`config`, `column_names`, `row` and `panel`. The `config` object contains an array of `filters` that can be applied to a `` through the use of `restore()` updating it to show the filtered subset of data. -The `column_names` property contains an array of matching columns, and the `row` -property returns the associated row data. +The `column_names` property contains an array of matching columns, the `row` +property returns the associated row data, and `panel` identifies the panel +which fired the event in a multi-panel viewer. ```javascript elem.addEventListener("perspective-click", function (event) { - var config = event.detail.config; - elem.restore(config); + const { config, panel } = event.detail; + elem.restore(config, { panel }); }); ``` + +## Selection events + +`perspective-select` fires when a plugin's selection changes. Its detail is a +`PerspectiveSelectDetail`, exported from `@perspective-dev/viewer`: + +| Field | Type | Description | +| --- | --- | --- | +| `selected` | `boolean` | Whether anything is currently selected | +| `row` | `object` | The associated row data | +| `column_names` | `string[]` | Matching column names | +| `removeConfigs` | `ViewConfigUpdate[]` | Configs whose filters should be _removed_ | +| `insertConfigs` | `ViewConfigUpdate[]` | Configs whose filters should be _applied_ | +| `panel` | `string?` | The originating panel, in a multi-panel viewer | + +`removeConfigs` is applied first, then `insertConfigs`. The +`removeFilters` and `insertFilters` getters flatten each to a plain `Filter[]`. + +```javascript +import { PerspectiveSelectDetail } from "@perspective-dev/viewer"; + +elem.addEventListener("perspective-select", function (event) { + const { insertFilters, removeFilters } = event.detail; + console.log("apply", insertFilters, "clear", removeFilters); +}); +``` + +
The detail.config field on +perspective-select was replaced by insertConfigs and +removeConfigs. Without an explicit removeConfigs, +a filter on a column outside the source's group_by, +split_by or filter cannot be cleared.
+ +## Global filter events + +In a multi-panel viewer, panels toggled to _Master_ contribute filter clauses +to an element-level global filter set, which is applied as a transient overlay +to every _detail_ panel (and never written into their saved configs). + +- `perspective-global-filter` fires on a master panel's selection. +- `perspective-global-filter-update` fires whenever the global filter set + changes, with a `Filter[]` detail. + +```javascript +elem.addEventListener("perspective-global-filter-update", function (event) { + console.log("Global filters are now", event.detail); +}); +``` + +
The workspace-layout-update and +workspace-new-view events from the removed +@perspective-dev/workspace package no longer exist. Use +perspective-config-update and +perspective-global-filter-update.
diff --git a/docs/md/how_to/javascript/save_restore.md b/docs/md/how_to/javascript/save_restore.md index 2868e33257..9d44a41d4d 100644 --- a/docs/md/how_to/javascript/save_restore.md +++ b/docs/md/how_to/javascript/save_restore.md @@ -48,11 +48,12 @@ await elem.restore(JSON.stringify(json_token)); Using the JSON format, every facet of a ``'s configuration can be manipulated from JavaScript using the `restore()` method. The valid structure of properties is described via the -[`ViewerConfig`](https://github.com/perspective-dev/perspective/blob/ebced4caa/rust/perspective-viewer/src/ts/viewer.ts#L16) +[`ViewerConfigUpdate`](https://github.com/perspective-dev/perspective/blob/master/rust/perspective-viewer/src/ts/ts-rs/ViewerConfigUpdate.ts) and embedded -[`ViewConfig`](https://github.com/perspective-dev/perspective/blob/ebced4caa19435a2a57d4687be7e428a4efc759b/packages/perspective/index.d.ts#L140) -type declarations, and [`View`](view.md) chapter of the documentation which has -several interactive examples for each `ViewConfig` property. +[`ViewConfigUpdate`](https://github.com/perspective-dev/perspective/blob/master/rust/perspective-js/src/ts/ts-rs/ViewConfigUpdate.ts) +type declarations (both generated from the Rust definitions), and the +[`View`](../../explanation/view.md) chapter of the documentation which has +several examples for each `ViewConfig` property. ```javascript // Set the plugin (will also update `columns` to plugin-defaults) diff --git a/docs/md/how_to/javascript/theming.md b/docs/md/how_to/javascript/theming.md index ebfe66b9fd..6f76d6dd2a 100644 --- a/docs/md/how_to/javascript/theming.md +++ b/docs/md/how_to/javascript/theming.md @@ -12,9 +12,11 @@ import "@perspective-dev/viewer/dist/css/pro-dark.css"; // Other themes import "@perspective-dev/viewer/dist/css/solarized.css"; -import "@perspective-dev/viewer/dist/css/solarized-dark.css"; import "@perspective-dev/viewer/dist/css/monokai.css"; import "@perspective-dev/viewer/dist/css/vaporwave.css"; + +// ... + ``` Alternatively, you may use `themes.css`, which bundles all default themes @@ -70,8 +72,9 @@ await viewer.restore({ theme: "Pro Dark" }); The best way to write a new theme is to [fork and modify an existing theme](https://github.com/perspective-dev/perspective/tree/master/rust/perspective-viewer/src/themes), -which are _just_ collections of regular CSS variables (no preprocessor is -required, though Perspective's own themes use one). `` is +which are _just_ collections of regular CSS variables — Perspective's own +themes are plain `.css` files, with no preprocessor involved. +`` is not "themed" by default and will lack icons and label text in addition to colors and fonts, so starting from an empty theme forces you to define _every_ theme-able variable to get a functional UI. diff --git a/docs/md/how_to/javascript/viewer.md b/docs/md/how_to/javascript/viewer.md index 7b80755cef..df204c062e 100644 --- a/docs/md/how_to/javascript/viewer.md +++ b/docs/md/how_to/javascript/viewer.md @@ -13,12 +13,38 @@ Component will be available in any standard HTML on your site. A simple example: import perspective from "@perspective-dev/client"; import "@perspective-dev/viewer"; + const viewer = document.getElementById("view1"); const worker = await perspective.worker(); - const table = await worker.table(data); - document.getElementById("view1").load(table); + await worker.table(data, { name: "my_table" }); + + await viewer.load(worker); + await viewer.restore({ table: "my_table" }); ``` +`load()` binds the viewer to a `Client`, and `restore()` selects which of that +client's `Table`s to show via the `table` field. Because `load()` alone +selects no table, it does not render — the pair guarantees exactly one atomic +render. + +Passing a `Table` directly is still supported as a legacy shorthand, + +```javascript +await viewer.load(table); +``` + +... which is internally equivalent to: + +```javascript +await viewer.load(await table.get_client()); +await viewer.restore({ table: await table.get_name() }); +``` + +
Always give your Table a name. +When name is omitted a random one is assigned, so the +table field in a token from save() will not match +the table after a page reload, and restore() will fail.
+ ## Attributes `` can be configured via HTML attributes or JavaScript @@ -41,8 +67,12 @@ The viewer provides an interactive side panel with: `sort`, and `filter` fields. - **New Column** button - opens an expression editor for creating computed columns via the [expression language](../../explanation/view/config/expressions.md). -- **Plugin selector** - switch between visualization plugins such as Datagrid, - X/Y Line, X/Y Scatter, Treemap, Sunburst, and Heatmap. +- **Plugin selector** - switch between the visualization plugins registered on + the page. `@perspective-dev/viewer-datagrid` provides `Datagrid`; + `@perspective-dev/viewer-charts` provides `X Bar`, `Y Bar`, `Y Line`, + `Y Scatter`, `Y Area`, `X/Y Scatter`, `X/Y Line`, `Density`, `Treemap`, + `Sunburst`, `Heatmap`, `Candlestick`, `OHLC`, `Map Scatter`, `Map Line` and + `Map Density`. - **Theme** selector - toggle between available themes. - **Export** - download the current view as CSV or Arrow. - **Copy** - copy the current view to the clipboard. @@ -50,13 +80,72 @@ The viewer provides an interactive side panel with: ## Methods -Key methods on the `` element: +A `` hosts one or more _panels_. Methods which address a +single panel take an options-dict with an optional `panel` id, defaulting to +the _active_ panel — e.g. `await viewer.save({ panel: "PANEL_ID_0" })`. + +### Binding + +| Method | Description | +|---|---| +| `load(client)` | Bind a `Client` (or, legacy, a `Table`) to the viewer | +| `eject(options?)` | Remove a `Client` and dispose every panel bound to it | +| `delete()` | Release the element's resources | +| `getClient(options?)` | Get a bound `Client` | +| `getTable(options?)` | Get a panel's `Table` | +| `getView(options?)` | Get a panel's `View` | +| `getViewConfig(options?)` | Get a panel's `ViewConfig` | + +### Configuration + +| Method | Description | +|---|---| +| `save(options?)` | Serialize one panel's configuration | +| `restore(config, options?)` | Apply a configuration to one panel | +| `saveWorkspace()` | Serialize the whole element — every panel, plus layout and global filters | +| `restoreWorkspace(config)` | Restore a whole-element configuration | +| `reset(all?, options?)` | Reset configuration (pass `true` to also reset expressions) | +| `resetError()` | Clear the error overlay | + +### Panels + +| Method | Description | +|---|---| +| `addPanel(config)` | Add a panel, returning its generated id | +| `removePanel(id)` | Remove a panel | +| `getPanelNames()` | List panel ids | +| `getActivePanel()` / `setActivePanel(id)` | Get or set the active panel | + +### Output + +| Method | Description | +|---|---| +| `export(options?)` | Export a panel — see the export methods below | +| `download(options?)` | Export and download as a file | +| `copy(options?)` | Copy a panel to the clipboard | + +`export()`, `download()` and `copy()` all take a `method`, one of `"csv"`, +`"json"`, `"ndjson"` or `"arrow"` — each with `-all` and `-selected` variants +(e.g. `"csv-selected"`) — plus `"html"`, `"json-config"`, and `"plugin"`. +The `"plugin"` method asks the plugin to render itself, which produces a PNG +for charts and text for the datagrid. +| `getSelection(options?)` / `setSelection(...)` | Get or set the selected region | +| `getEditPort(options?)` | Get a panel's edit port | +| `getRenderStats(options?)` | Get render timing statistics | + +### Rendering and chrome | Method | Description | |---|---| -| `load(table)` | Bind a `Table` to the viewer | -| `restore(config)` | Apply a saved configuration object | -| `save()` | Serialize the current configuration | -| `reset(all)` | Reset configuration (pass `true` to also reset expressions) | -| `getTable()` | Get the bound `Table` | | `flush()` | Wait for any pending UI updates to complete | +| `resize(options?)` | Redraw, optionally at a `{dimensions: {width, height}}` size hint | +| `setAutoSize(bool)` / `setAutoPause(bool)` / `setThrottle(ms)` | Render policy | +| `toggleConfig(force?)` | Toggle the settings sidebar | +| `toggleColumnSettings(...)` | Toggle the column settings sidebar | +| `resetThemes(themes?)` | Re-detect or explicitly set available themes | +| `restyleElement()` | Re-read CSS and repaint | +| `getPlugin(name?)` / `getAllPlugins()` | Look up registered plugins | + +See [Saving and restoring UI state](./save_restore.md) for the `save`/`restore` +formats and the panel selector, and +[Plugin render limits](./plugin_settings.md) for `getPlugin`. diff --git a/docs/md/how_to/python/jupyterlab.md b/docs/md/how_to/python/jupyterlab.md index 33f6aea33b..4db0ca2b3b 100644 --- a/docs/md/how_to/python/jupyterlab.md +++ b/docs/md/how_to/python/jupyterlab.md @@ -1,11 +1,33 @@ -# `PerspectiveWidget` for JupyterLab +# `PerspectiveWidget` for notebooks Building on top of the API provided by `perspective.Table`, the -`PerspectiveWidget` is a JupyterLab plugin that offers the entire functionality -of Perspective within the Jupyter environment. It supports the same API -semantics of ``, along with the additional data types -supported by `perspective.Table`. `PerspectiveWidget` takes keyword arguments -for the managed `View`: +`PerspectiveWidget` offers the entire functionality of Perspective within a +notebook environment. It supports the same API semantics of +``, along with the additional data types supported by +`perspective.Table`. + +## Installation + +`PerspectiveWidget` is an [AnyWidget](https://anywidget.dev), shipped as a +prebuilt bundle inside the `perspective-python` wheel. There is no separate +labextension to install or version-match — install the `jupyter` extra, which +adds the `anywidget` dependency: + +```bash +pip install "perspective-python[jupyter]" +``` + +The same wheel works in JupyterLab, classic Jupyter Notebook, VSCode +notebooks, Google Colab and Marimo. + +
The @perspective-dev/jupyterlab package is +now optional and no longer ships the widget. It provides only the +"Open With → Perspective" file renderers for csv, +json and arrow files in JupyterLab.
+ +## Usage + +`PerspectiveWidget` takes keyword arguments for the managed `View`: ```python from perspective.widget import PerspectiveWidget @@ -51,11 +73,16 @@ PerspectiveWidget(table) Element, and has similar `save()` and `restore()` methods that serialize/deserialize UI state for the widget. - +The optional `@perspective-dev/jupyterlab` package exposes a JS-only +`mimerender-extension`. This lets you view `csv`, `json`, and `arrow` files +directly from the JupyterLab file browser — right-click one of these files and +choose `Open With → Perspective`. + +```bash +jupyter labextension install @perspective-dev/jupyterlab +``` + +This package is independent of `PerspectiveWidget`; install it only if you +want the file renderers. diff --git a/docs/md/how_to/python/virtual_server/clickhouse.md b/docs/md/how_to/python/virtual_server/clickhouse.md index 3078d2be6e..4efd4f3403 100644 --- a/docs/md/how_to/python/virtual_server/clickhouse.md +++ b/docs/md/how_to/python/virtual_server/clickhouse.md @@ -21,7 +21,7 @@ Create a server that exposes ClickHouse tables to browser clients: import clickhouse_connect import tornado.web import tornado.ioloop -from perspective import ClickhouseVirtualServer +from perspective.virtual_servers.clickhouse import ClickhouseVirtualServer from perspective.handlers.tornado import PerspectiveTornadoHandler # Connect to ClickHouse diff --git a/docs/md/how_to/python/virtual_server/custom.md b/docs/md/how_to/python/virtual_server/custom.md index c7c66e2c4d..20dc1e071b 100644 --- a/docs/md/how_to/python/virtual_server/custom.md +++ b/docs/md/how_to/python/virtual_server/custom.md @@ -1,17 +1,26 @@ # Implementing a custom Virtual Server You can connect Perspective to any data source by subclassing -`VirtualServerHandler` and wrapping it with `VirtualServer`. +`VirtualServerHandler`, wrapping it in a `VirtualServer`, and exposing that +via a small _session factory_ object which the WebSocket handlers use to give +each connected client its own session. For background on virtual servers, see the [Virtual Servers overview](../../../explanation/virtual_servers.md). -## Example +## The handler + +`VirtualServerHandler` is imported from `perspective.virtual_servers`. Only +`get_hosted_tables`, `table_schema`, `table_size`, `table_make_view`, +`view_delete` and `view_get_data` are required; the rest have defaults. ```python -from perspective import VirtualServerHandler, VirtualServer +from perspective.virtual_servers import VirtualServerHandler + +class MyHandler(VirtualServerHandler): + def __init__(self, db): + self.db = db -class MyModel(VirtualServerHandler): def get_features(self): return { "group_by": True, @@ -36,29 +45,76 @@ class MyModel(VirtualServerHandler): def table_size(self, table_name): return 1000 - def table_make_view(self, table_name, view_id, config): - # Translate `config` (group_by, sort, filter, etc.) into a - # query against your data source. Store the query keyed by - # `view_id` for later data retrieval. + def table_make_view(self, table_name, view_name, config): + # Translate `config` (group_by, sort, filter, etc.) into a query + # against your data source. Store the query keyed by `view_name` + # for later data retrieval. pass - def view_delete(self, view_id): - # Clean up resources for this view + def view_delete(self, view_name): + # Clean up resources for this view. The UI does this automatically, + # and can recover if a view dies early. pass - def view_get_data(self, view_id, start_row, end_row, start_col, end_col, ctx): - # Execute the stored query with the given row/column window. - # Push results via `ctx`. + def view_get_data(self, view_name, config, viewport, data): + # Serialize the rectangular slice `viewport` of the temporary table + # `view_name` into `data`, a push-only `VirtualDataSlice`. Once a + # type has been pushed for a column name it must not change. pass ``` -The `VirtualServer` instance can then be passed to a Tornado, Starlette, or +### Optional methods + +| Method | Default | Purpose | +| --- | --- | --- | +| `get_features()` | `columns` only | Which UI controls to enable — see [Features declaration](../../../explanation/virtual_servers.md#features-declaration) | +| `view_schema(view_name, config)` | `table_schema` | Schema of a temporary table, when it differs from its source | +| `view_size(view_name)` | `table_size` | Row count of a temporary table, when it differs from its source | +| `table_validate_expression(view_name, expression)` | allow all | Type-check an expression column; enabled by `"expressions"` in `get_features` | +| `view_get_min_max(view_name, column_name, config)` | unsupported | Column bounds as a `(min, max)` tuple — required for gradient and sparkbar column styles | + +## The session factory + +The WebSocket handlers call `new_session(callback)` once per connection, so +the object passed as `perspective_server` must provide it. Wrap your handler +in a `perspective.VirtualServer` — which owns the protocol — and return one +session per client: + +```python +import perspective + +class MyVirtualSession: + def __init__(self, callback, db): + self.session = perspective.VirtualServer(MyHandler(db)) + self.callback = callback + + def handle_request(self, msg): + self.callback(self.session.handle_request(msg)) + + +class MyVirtualServer: + def __init__(self, db): + self.db = db + + def new_session(self, callback): + return MyVirtualSession(callback, self.db) +``` + +## Serving it + +A `MyVirtualServer` instance can then be passed to a Tornado, Starlette or AIOHTTP handler just like a regular `Server`: ```python from perspective.handlers.tornado import PerspectiveTornadoHandler app = tornado.web.Application([ - (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": VirtualServer(MyModel)}), + (r"/websocket", PerspectiveTornadoHandler, { + "perspective_server": MyVirtualServer(db), + }), ]) ``` + +The built-in [DuckDB](./duckdb.md), [ClickHouse](./clickhouse.md) and +[Polars](./polars.md) implementations all follow exactly this shape and are +worth reading as complete references. diff --git a/docs/md/how_to/python/virtual_server/duckdb.md b/docs/md/how_to/python/virtual_server/duckdb.md index 8a040ede30..dd8a08f75e 100644 --- a/docs/md/how_to/python/virtual_server/duckdb.md +++ b/docs/md/how_to/python/virtual_server/duckdb.md @@ -21,7 +21,7 @@ Create a server that exposes a DuckDB database to browser clients: import duckdb import tornado.web import tornado.ioloop -from perspective import DuckDBVirtualServer +from perspective.virtual_servers.duckdb import DuckDBVirtualServer from perspective.handlers.tornado import PerspectiveTornadoHandler # Create DuckDB connection and load data diff --git a/docs/md/how_to/python/websocket.md b/docs/md/how_to/python/websocket.md index e7e932cf0e..8873e4a2c1 100644 --- a/docs/md/how_to/python/websocket.md +++ b/docs/md/how_to/python/websocket.md @@ -28,7 +28,7 @@ will be assigned randomly and the `Client` must look this up with ## Client/Server Replicated Mode Using Tornado and -[`PerspectiveTornadoHandler`](python.md#perspectivetornadohandler), as well as +[`PerspectiveTornadoHandler`](../../explanation/python.md#whats-included), as well as `Perspective`'s JavaScript library, we can set up "distributed" Perspective instances that allows multiple browser `perspective-viewer` clients to read from a common `perspective-python` server, as in the diff --git a/examples/blocks/src/agent/README.md b/examples/blocks/src/agent/README.md new file mode 100644 index 0000000000..6504fd235e --- /dev/null +++ b/examples/blocks/src/agent/README.md @@ -0,0 +1,29 @@ +# Agent + +The `` chat sidebar, configured through a simple provider +form. The viewer's embedded agent (`viewer.agentConfig({...})`) drives the view +through its tool surface — schema inspection, view configuration, plugin +selection and ExprTK expression authoring — against any of these providers: + +- **OpenAI-compatible** — any local or remote server speaking + `/chat/completions`, e.g. [LM Studio](https://lmstudio.ai)'s developer server + (`http://localhost:1234/v1`, **enable CORS** in its server settings), Ollama + (`http://localhost:11434/v1`, set `OLLAMA_ORIGINS`), llama.cpp or vLLM. + Tool-calling quality is model-dependent — Qwen and Llama 3.1+ instruct models + work well. +- **Anthropic** — `claude-opus-5` by default; requires an API key, which is sent + directly from the browser (Anthropic's CORS opt-in) — suitable for local + development; use a `baseUrl` proxy for anything shared. +- **Gemini** — `gemini-2.5-flash` by default; requires an API key, sent directly + from the browser (the Gemini API is CORS-enabled) — same local-development + caveat. +- **WebLLM** — runs the model in this tab on WebGPU, so no prompt or data leaves + the machine and no key is needed. Selecting it lazily imports + [`@mlc-ai/web-llm`](https://github.com/mlc-ai/web-llm) and downloads model + weights (gigabytes on a cold cache; the browser caches them afterwards). Only + the **Hermes** family is offered — see the caveat below. + +CORS-friendly hosted services that speak the OpenAI wire format (e.g. +[Groq](https://groq.com) at `https://api.groq.com/openai/v1`, or +[OpenRouter](https://openrouter.ai) at `https://openrouter.ai/api/v1`) work +through the **OpenAI-compatible** provider — just set the base URL and key. diff --git a/examples/blocks/src/agent/index.html b/examples/blocks/src/agent/index.html new file mode 100644 index 0000000000..e1e2e1aa7d --- /dev/null +++ b/examples/blocks/src/agent/index.html @@ -0,0 +1,454 @@ + + + + + + + + + + + +
+ +
+ + + diff --git a/packages/viewer-charts/src/ts/plugin/charts.ts b/packages/viewer-charts/src/ts/plugin/charts.ts index 26efda69e2..96126b837b 100644 --- a/packages/viewer-charts/src/ts/plugin/charts.ts +++ b/packages/viewer-charts/src/ts/plugin/charts.ts @@ -44,6 +44,33 @@ export interface ChartTypeConfig { */ applicable_plugin_fields: readonly PluginConfigField[]; + /** + * What `group_by` DRAWS in this chart, e.g. `"X Axis"` for the + * Y-series charts. Omitted where the field has no visual role of + * its own and is a plain aggregation key — the X/Y and map charts, + * whose axes both come from `columns`. + * + * The `group_by` counterpart of `initial.names`: one declaration + * says what every view field draws, so the settings UI's labels and + * the agent's `list_plugins` contract read the same source instead + * of restating the mapping. + */ + group_by_role?: string; + + /** What `split_by` DRAWS in this chart. See {@link group_by_role}. */ + split_by_role?: string; + + /** + * Set on the charts that CONNECT their points in row order, so the + * view's row order shows up in the drawing: without a `sort` the + * line follows the table's natural order, which reads as a tangle + * unless the rows already arrive ordered along the X axis. The point + * charts (scatter, density) are unaffected, and a pre-ordered table + * needs no `sort` — which is why this is declared per chart rather + * than inferred from an empty `sort`. + */ + connects_row_order?: boolean; + /** * Per-chart-type overrides for `DEFAULT_PLUGIN_CONFIG`. Used when a * field's sensible default differs by chart family — currently @@ -187,6 +214,9 @@ function make( | "max_columns" | "default_chart_type" | "plugin_field_defaults" + | "group_by_role" + | "split_by_role" + | "connects_row_order" > >, ): ChartTypeConfig { @@ -205,30 +235,52 @@ function make( ...(overrides?.plugin_field_defaults ? { plugin_field_defaults: overrides.plugin_field_defaults } : {}), + ...(overrides?.group_by_role + ? { group_by_role: overrides.group_by_role } + : {}), + ...(overrides?.split_by_role + ? { split_by_role: overrides.split_by_role } + : {}), + ...(overrides?.connects_row_order + ? { connects_row_order: overrides.connects_row_order } + : {}), }; } const FIN_NAMES = ["Open", "Close", "High", "Low", "Tooltip"]; const HIER_NAMES = ["Size", "Color", "Tooltip"]; +const SERIES_Y_ROLES = { group_by_role: "X Axis", split_by_role: "Series" }; +const SERIES_X_ROLES = { group_by_role: "Y Axis", split_by_role: "Series" }; +const CART_ROLES = {}; +const HIER_ROLES = { group_by_role: "Hierarchy", split_by_role: "Facets" }; +const HEATMAP_ROLES = { group_by_role: "X Axis", split_by_role: "Y Axis" }; +const FIN_ROLES = { group_by_role: "X Axis", split_by_role: "Series" }; +const MAP_ROLES = {}; + const CHARTS: ChartTypeConfig[] = [ make("X Bar", "x-bar", SERIES, SELECT, 1, X_AXIS, SERIES_FIELDS, { + ...SERIES_X_ROLES, default_chart_type: "bar", plugin_field_defaults: ZERO_ANCHORED_DEFAULTS, }), make("Y Bar", "y-bar", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { + ...SERIES_Y_ROLES, default_chart_type: "bar", plugin_field_defaults: ZERO_ANCHORED_DEFAULTS, }), make("Y Line", "y-line", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { + ...SERIES_Y_ROLES, default_chart_type: "line", plugin_field_defaults: SERIES_DEFAULTS, }), make("Y Scatter", "y-scatter", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { + ...SERIES_Y_ROLES, default_chart_type: "scatter", plugin_field_defaults: SERIES_DEFAULTS, }), make("Y Area", "y-area", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { + ...SERIES_Y_ROLES, default_chart_type: "area", plugin_field_defaults: ZERO_ANCHORED_DEFAULTS, }), @@ -240,6 +292,7 @@ const CHARTS: ChartTypeConfig[] = [ 2, ["X Axis", "Y Axis", "Color", "Size", "Label", "Tooltip"], CARTESIAN_FIELDS, + { ...CART_ROLES }, ), make( "X/Y Line", @@ -249,6 +302,7 @@ const CHARTS: ChartTypeConfig[] = [ 2, ["X Axis", "Y Axis", "Tooltip"], CARTESIAN_FIELDS, + { ...CART_ROLES, connects_row_order: true }, ), make( "Density", @@ -258,15 +312,24 @@ const CHARTS: ChartTypeConfig[] = [ 2, ["X Axis", "Y Axis", "Color", "Tooltip"], DENSITY_FIELDS, + { ...CART_ROLES }, ), - make("Treemap", "treemap", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS), - make("Sunburst", "sunburst", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS), - make("Heatmap", "heatmap", HIER, SELECT, 1, ["Color"], HEATMAP_FIELDS), + make("Treemap", "treemap", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS, { + ...HIER_ROLES, + }), + make("Sunburst", "sunburst", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS, { + ...HIER_ROLES, + }), + make("Heatmap", "heatmap", HIER, SELECT, 1, ["Color"], HEATMAP_FIELDS, { + ...HEATMAP_ROLES, + }), make("Candlestick", "candlestick", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, { + ...FIN_ROLES, default_chart_type: "candlestick", plugin_field_defaults: SERIES_DEFAULTS, }), make("OHLC", "ohlc", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, { + ...FIN_ROLES, default_chart_type: "ohlc", plugin_field_defaults: SERIES_DEFAULTS, }), @@ -278,6 +341,7 @@ const CHARTS: ChartTypeConfig[] = [ 2, ["Longitude", "Latitude", "Color", "Size", "Label", "Tooltip"], MAP_SCATTER_FIELDS, + { ...MAP_ROLES }, ), make( "Map Line", @@ -287,6 +351,7 @@ const CHARTS: ChartTypeConfig[] = [ 2, ["Longitude", "Latitude", "Tooltip"], MAP_LINE_FIELDS, + { ...MAP_ROLES, connects_row_order: true }, ), make( "Map Density", @@ -296,6 +361,7 @@ const CHARTS: ChartTypeConfig[] = [ 2, ["Longitude", "Latitude", "Color", "Tooltip"], MAP_DENSITY_FIELDS, + { ...MAP_ROLES }, ), ]; diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index ad0049ddb9..8b95d39af4 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -442,6 +442,9 @@ export class HTMLPerspectiveViewerWebGLPluginElement max_cells: this._chartType.max_cells, max_columns: this._chartType.max_columns, group_rollup_modes: ["flat"], + group_by_role: this._chartType.group_by_role, + split_by_role: this._chartType.split_by_role, + connects_row_order: !!this._chartType.connects_row_order, priority: 0, can_render_column_styles: !!this._chartType.default_chart_type || diff --git a/packages/viewer-charts/test/ts/alt-axis.spec.ts b/packages/viewer-charts/test/ts/alt-axis.spec.ts index 770dfc73d7..9616ac8e99 100644 --- a/packages/viewer-charts/test/ts/alt-axis.spec.ts +++ b/packages/viewer-charts/test/ts/alt-axis.spec.ts @@ -10,36 +10,6 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -/** - * `alt_axis` primary-domain regressions (.plan/ALT_AXIS_DOMAIN_PLAN.md). - * - * Two independent bugs share one symptom fixture — a tiny column - * (`Discount`, sums to single digits per Category) next to a huge one - * (`Sales`, sums to ~10K) with `Sales` pinned to the alt axis: - * - * A. `domain_mode` defaults to `"expand"`, and its accumulators were - * keyed by axis SIDE only. A `columns_config`-only change arrives - * as `plugin.update()` (no `resetExpandedDomain`), so after pinning - * `Sales` to alt the primary accumulator retained Sales's extent — - * the primary axis never excluded the departed column and the - * Discount bars rendered invisibly small. Fixed by the per-aggregate - * axis-partition signature on the accumulators. - * - * B. A one-shot `restore` (`columns` + `columns_config` together) - * dropped `columns_config` entirely: the host calls - * `plugin.restore` before the first draw builds the renderer, and - * the old forwarding was `this._renderer?.setColumnsConfig(...)` — - * a silent no-op pre-renderer. Fixed by storing `_columnsConfig` - * on the element and shipping it in the `InitMsg` handshake. - * - * Assertions are reference-normalized plot-pixel counts: the two-step - * `domain_mode: "fit"` flow renders this fixture CORRECTLY on both - * sides of the fixes (verified by screenshot 2026-08-01), so each test - * demands its variant paint approximately as many plot pixels as that - * reference. Both bugs cut the visible glyph area roughly in half - * (Discount bars collapse to invisibility), far outside the tolerance. - */ - import type { Page } from "@playwright/test"; import { expect, test } from "@perspective-dev/test"; import { calibratePlotBaseline, gotoBasic, restoreChart } from "./helpers"; @@ -82,11 +52,6 @@ test.describe("alt_axis primary-domain exclusion", () => { page, }) => { const reference = await referencePixels(page); - - // Fresh page: single restore carrying columns + columns_config - // together, exactly as a saved workspace loads. Pre-fix B the - // per-column config never reached the worker — no alt axis, - // Discount invisible under Sales's unioned domain. await gotoBasic(page); await restoreChart(page, { ...BASE_CONFIG, @@ -101,38 +66,25 @@ test.describe("alt_axis primary-domain exclusion", () => { page, }) => { const reference = await referencePixels(page); - - // Fresh page: same two-step flow as the reference but under the - // DEFAULT `domain_mode: "expand"`. Pre-fix A the primary - // accumulator retained Sales's pre-flip extent, so Discount - // stayed invisible even though the alt axis was correct. await gotoBasic(page); await restoreChart(page, BASE_CONFIG as never); await page.waitForTimeout(SETTLE_MS); await restoreChart(page, { columns_config: ALT_ON_SALES } as never); await page.waitForTimeout(SETTLE_MS); - expectNearReference(await calibratePlotBaseline(page), reference); }); test("same-partition restore keeps the render stable", async ({ page }) => { - // Guard against an over-eager signature: re-sending an - // IDENTICAL columns_config must not perturb the render (the - // signature matches, the accumulators survive, domains are - // unchanged). const reference = await referencePixels(page); - await gotoBasic(page); await restoreChart(page, BASE_CONFIG as never); await page.waitForTimeout(SETTLE_MS); await restoreChart(page, { columns_config: ALT_ON_SALES } as never); await page.waitForTimeout(SETTLE_MS); const before = await calibratePlotBaseline(page); - await restoreChart(page, { columns_config: ALT_ON_SALES } as never); await page.waitForTimeout(SETTLE_MS); const after = await calibratePlotBaseline(page); - expect(after).toBeGreaterThan(before * 0.95); expect(after).toBeLessThan(before * 1.05); expectNearReference(after, reference); diff --git a/packages/viewer-charts/test/ts/domain-mode.spec.ts b/packages/viewer-charts/test/ts/domain-mode.spec.ts index d066caeb5b..51d59f97ea 100644 --- a/packages/viewer-charts/test/ts/domain-mode.spec.ts +++ b/packages/viewer-charts/test/ts/domain-mode.spec.ts @@ -10,25 +10,6 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -/** - * `plugin_config.domain_mode` axis-scope contract - * (`.plan/DOMAIN_MODE_PLAN.md`): - * - * - Cartesian charts (X/Y Scatter, X/Y Line): `"expand"` applies to - * BOTH axes (+ color/size scales). - * - Band charts (Y Bar / Y Line / Y Scatter / Y Area / X Bar): - * `"expand"` applies to the VALUE axis ONLY — the category axis - * (numeric/datetime `group_by`) always fits the current data, so a - * streaming time axis releases departed categories. - * - * The accumulators only live across DATA updates — any `plugin.draw` - * (view-config change) resets them — so every scenario drives - * `table.remove` on the indexed fixture table via `window.__TEST_WORKER__` - * and waits for the update-redraw (debounced + throttled) by polling - * pixel counts. SwiftShader is deterministic; thresholds are loose only - * to absorb AA. - */ - import type { Page } from "@playwright/test"; import { test, expect } from "@perspective-dev/test"; import { @@ -172,16 +153,10 @@ test.describe("domain_mode axis scope", () => { } as never); await waitOneFrame(page); - // Full data spans the plot; the right side is populated. expect( await pollPixels(page, RIGHT, (n) => n > POPULATED_MIN), ).toBeGreaterThan(POPULATED_MIN); - // Keep only the earliest dates. Under the `"expand"` DEFAULT the - // value axis may retain its extent, but the category (X) axis - // must REFIT — the surviving line spreads across the full plot - // width instead of compressing into the historical range's - // leading sliver. await removeLaterDates(page, 30); expect( await pollPixels(page, RIGHT, (n) => n > POPULATED_MIN), @@ -191,10 +166,6 @@ test.describe("domain_mode axis scope", () => { test("Y Line: the value axis retains its extent under expand, refits under fit", async ({ page, }) => { - // One row per day (sums = values): a small ±10 wiggle plus a - // SYMMETRIC ±1000 extreme pair MID-SERIES (so the spike's x - // position falls inside the sampled band, not at the plot edge). - // The symmetric domain centers zero MID-plot. await reshapeFixture( page, dates(40).map((d, i) => ({ @@ -212,21 +183,15 @@ test.describe("domain_mode axis scope", () => { } as never); await waitOneFrame(page); - // The +1000 spike reaches the top of the plot. expect( await pollPixels(page, TOP, (n) => n > EMPTY_MAX), ).toBeGreaterThan(EMPTY_MAX); - // Drop the ±1000 extremes: survivors are ±10. Under `"expand"` - // (default) the Y axis RETAINS [-1000, 1000], so the line hugs - // zero MID-plot and the top band empties. await removeExtremes(page, "Profit", 20); expect(await pollPixels(page, TOP, (n) => n < EMPTY_MAX)).toBeLessThan( EMPTY_MAX, ); - // `"fit"` recomputes: the surviving ±10 extent stretches to the - // plot and the line reaches the top band again. await restoreChart(page, { plugin_config: { domain_mode: "fit" }, } as never); @@ -249,9 +214,6 @@ test.describe("domain_mode axis scope", () => { await pollPixels(page, BOTTOM, (n) => n > POPULATED_MIN), ).toBeGreaterThan(POPULATED_MIN); - // Keep only the earliest dates (rendered from the top). The - // category axis is Y here — it must refit so the surviving bars - // spread down the full plot height. await removeLaterDates(page, 30); expect( await pollPixels(page, BOTTOM, (n) => n > POPULATED_MIN), @@ -261,12 +223,6 @@ test.describe("domain_mode axis scope", () => { test("X/Y Scatter: BOTH axes retain their extent under expand", async ({ page, }) => { - // `Sales` spread uniformly over [0, 975]; `Profit` is 0 for every - // point except two low-`Sales` ±500 anchors that stretch the Y - // domain, parking the point mass MID-plot (inside the band's - // y-range — a value AT the domain extreme renders at the plot - // edge, outside it). Rows with `Sales > 500` are the removable - // extremes; the anchors survive. await reshapeFixture( page, dates(40).map((d, i) => ({ @@ -286,19 +242,15 @@ test.describe("domain_mode axis scope", () => { await pollPixels(page, RIGHT, (n) => n > EMPTY_MAX), ).toBeGreaterThan(EMPTY_MAX); - // Drop the high-Sales rows. Cartesian `"expand"` (default) keeps - // the X extent, so survivors stay clustered at the left and the - // right side EMPTIES — the axis did not refit. await removeExtremes(page, "Sales", 500); expect( await pollPixels(page, RIGHT, (n) => n < EMPTY_MAX), ).toBeLessThan(EMPTY_MAX); - // Control: `"fit"` refits X and the survivors spread back into - // the right side. await restoreChart(page, { plugin_config: { domain_mode: "fit" }, } as never); + expect( await pollPixels(page, RIGHT, (n) => n > EMPTY_MAX), ).toBeGreaterThan(EMPTY_MAX); diff --git a/packages/viewer-charts/test/ts/glyph-z-order.spec.ts b/packages/viewer-charts/test/ts/glyph-z-order.spec.ts index 686f3ec09d..19289e1afd 100644 --- a/packages/viewer-charts/test/ts/glyph-z-order.spec.ts +++ b/packages/viewer-charts/test/ts/glyph-z-order.spec.ts @@ -10,32 +10,6 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -/** - * Mixed-glyph Z-order = `columns` declaration order - * (.plan/GLYPH_Z_ORDER_PLAN.md). Legacy behavior painted a FIXED type - * sequence (areas → bars → lines → scatter) regardless of declaration - * order; the fix paints glyph runs in ascending `aggIdx` — later - * columns on top. - * - * Fixture: two constant expression columns, `avg`-aggregated so every - * category lands exactly at the constant — a bar column at 100 and a - * line column at 50. With `include_zero` (Y Bar default) the bars span - * the full value range, so the horizontal line at 50 crosses EVERY bar - * body, and the gaps between bars show the bare line. - * - * Assertion (palette- and coordinate-free): on the visible GL canvas - * (glyph fragments only — gridlines/chrome are separate canvases, and - * glyph pixels are the only `alpha > 0` pixels), the row with the most - * OPAQUE pixels is the line's center row (the line spans the whole - * plot; bars alone cover only the band fraction). The dominant-color - * share of that row discriminates the stack: - * - * - line on top → the whole row is line-colored → share ≈ 1.0 - * - bars on top → the row alternates bar-color runs (inside bodies) - * with line-color runs (gaps) → share ≈ the band fraction, well - * under 0.85. - */ - import type { Page } from "@playwright/test"; import { expect, test } from "@perspective-dev/test"; import { gotoBasic, restoreChart, waitOneFrame } from "./helpers"; @@ -159,19 +133,11 @@ async function renderAndMeasure( test.describe("Mixed-glyph Z-order follows columns order", () => { test("bar declared after line occludes it", async ({ page }) => { - // `columns: [line, bar]` — the bar column is declared later, so - // bars must paint OVER the line inside their bodies, leaving - // the line visible only in the gaps. Pre-fix the line always - // painted on top (fixed type order) and the row is uniformly - // line-colored. const share = await renderAndMeasure(page, ["l50", "b100"]); expect(share).toBeLessThan(0.85); }); test("line declared after bar stays on top", async ({ page }) => { - // `columns: [bar, line]` — declaration order agrees with the - // legacy fixed order; the line crosses every bar uncovered. - // Guards the run path against over-occluding. const share = await renderAndMeasure(page, ["b100", "l50"]); expect(share).toBeGreaterThan(0.9); }); diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts index a348c35d01..abd381327e 100644 --- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts +++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts @@ -126,6 +126,12 @@ export class HTMLPerspectiveViewerDatagridPluginElement category: "Basic", select_mode: "toggle", config_column_names: ["Columns"], + + // The datagrid's pivots are structural rather than spatial: + // `group_by` nests rows into an expandable tree, `split_by` + // repeats the column set once per split value. + // group_by_role: "Row Groups", + // split_by_role: "Column Groups", group_rollup_modes: ["rollup", "flat", "total"], // Higher priority than the chart plugins so the Datagrid is // loaded by default. @@ -138,7 +144,7 @@ export class HTMLPerspectiveViewerDatagridPluginElement const fields = []; fields.push({ kind: "Enum", - key: "edit_mode", + key: "edit_mode" satisfies keyof DatagridPluginConfig, default: "READ_ONLY", variants: [ { value: "EDIT", label: "Edit" }, @@ -152,7 +158,7 @@ export class HTMLPerspectiveViewerDatagridPluginElement fields.push({ kind: "Bool", - key: "scroll_lock", + key: "scroll_lock" satisfies keyof DatagridPluginConfig, default: false, }); diff --git a/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts b/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts index 5bd6125531..b0a7800a09 100644 --- a/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts +++ b/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { ColumnType } from "@perspective-dev/client"; -import type { DatagridPluginElement } from "../types.js"; +import type { ColumnConfig, DatagridPluginElement } from "../types.js"; interface ViewerConfigLike { group_by?: string[]; @@ -61,7 +61,7 @@ export default function column_config_schema( fields.push({ kind: "Enum", - key: "number_fg_mode", + key: "number_fg_mode" satisfies keyof ColumnConfig, default: "color", variants: [ { value: "disabled", label: "Disabled" }, @@ -75,8 +75,8 @@ export default function column_config_schema( if (fg_mode !== "disabled") { fields.push({ kind: "ColorRange", - key_pos: "pos_fg_color", - key_neg: "neg_fg_color", + key_pos: "pos_fg_color" satisfies keyof ColumnConfig, + key_neg: "neg_fg_color" satisfies keyof ColumnConfig, default_pos: pos_fg, default_neg: neg_fg, is_gradient: false, @@ -86,7 +86,7 @@ export default function column_config_schema( if (fg_mode === "bar" || fg_mode === "label-bar") { fields.push({ kind: "Number", - key: "fg_gradient", + key: "fg_gradient" satisfies keyof ColumnConfig, default: column_stats?.abs_max ?? 0, include: true, }); @@ -94,7 +94,7 @@ export default function column_config_schema( fields.push({ kind: "Enum", - key: "number_bg_mode", + key: "number_bg_mode" satisfies keyof ColumnConfig, default: "disabled", variants: [ { value: "disabled", label: "Disabled" }, @@ -108,8 +108,8 @@ export default function column_config_schema( if (bg_mode !== "disabled") { fields.push({ kind: "ColorRange", - key_pos: "pos_bg_color", - key_neg: "neg_bg_color", + key_pos: "pos_bg_color" satisfies keyof ColumnConfig, + key_neg: "neg_bg_color" satisfies keyof ColumnConfig, default_pos: pos_bg, default_neg: neg_bg, is_gradient: bg_mode === "gradient" || bg_mode === "pulse", @@ -119,7 +119,7 @@ export default function column_config_schema( if (bg_mode === "gradient") { fields.push({ kind: "Number", - key: "bg_gradient", + key: "bg_gradient" satisfies keyof ColumnConfig, include: true, default: column_stats?.abs_max ?? 0, }); @@ -131,7 +131,7 @@ export default function column_config_schema( fields.push({ kind: "Enum", - key: "datetime_color_mode", + key: "datetime_color_mode" satisfies keyof ColumnConfig, default: "none", variants: [ { value: "none", label: "None" }, @@ -146,7 +146,7 @@ export default function column_config_schema( if (dt_mode !== "none") { fields.push({ kind: "Color", - key: "color", + key: "color" satisfies keyof ColumnConfig, default: this.model!._color[0], }); } @@ -155,7 +155,7 @@ export default function column_config_schema( fields.push({ kind: "Enum", - key: "string_color_mode", + key: "string_color_mode" satisfies keyof ColumnConfig, default: "none", variants: [ { value: "none", label: "None" }, @@ -169,7 +169,7 @@ export default function column_config_schema( if (str_mode !== "none") { fields.push({ kind: "Color", - key: "color", + key: "color" satisfies keyof ColumnConfig, default: this.model!._color[0], }); } diff --git a/packages/viewer-datagrid/src/ts/types.ts b/packages/viewer-datagrid/src/ts/types.ts index 8f9af4714a..f7556853ed 100644 --- a/packages/viewer-datagrid/src/ts/types.ts +++ b/packages/viewer-datagrid/src/ts/types.ts @@ -20,7 +20,9 @@ import type { ViewConfigUpdate, } from "@perspective-dev/client"; import type { + DateFormatConfig, HTMLPerspectiveViewerElement, + NumberFormatConfig, ViewerConfig, } from "@perspective-dev/viewer"; import type { RegularTableElement } from "regular-table"; @@ -45,6 +47,12 @@ export function get_psp_type( } // Edit mode for the datagrid +/** + * Datagrid cell interaction mode (`plugin_config.edit_mode`): + * `"READ_ONLY"` (default), `"EDIT"` (cells editable, writing back to the + * `Table` - requires an editable table), or the `"SELECT_*"` modes which + * emit selection events instead of editing. + */ export type EditMode = | "READ_ONLY" | "EDIT" @@ -89,67 +97,125 @@ export interface SelectedPosition { content?: string; } -// Column configuration values from viewer +/** + * Datagrid per-column style configuration - one value of the + * `columns_config` map of a `ViewerConfigUpdate` when the Datagrid plugin + * is active. Valid keys depend on the column's type; the authoritative, + * value-dependent declaration is `column_config_schema()` (surfaced at + * runtime via the agent's `get_style_schema` tool and the Style tab). + */ export interface ColumnConfig { + /** String / datetime columns: the applied color (CSS color). */ color?: string; + + /** Numeric columns: positive-value foreground color (CSS color). */ pos_fg_color?: string; + + /** Numeric columns: negative-value foreground color (CSS color). */ neg_fg_color?: string; + + /** Numeric columns: positive-value background color (CSS color). */ pos_bg_color?: string; + + /** Numeric columns: negative-value background color (CSS color). */ neg_bg_color?: string; + + /** + * Numeric columns: the absolute value at which bar/gradient + * foreground modes reach full scale. + */ fg_gradient?: number; + + /** + * Numeric columns: the absolute value at which gradient background + * mode reaches full scale. + */ bg_gradient?: number; + + /** + * Numeric columns: foreground treatment - `"color"` (default, + * colored text), `"bar"` (proportional bar), `"label-bar"` (bar with + * label) or `"disabled"`. + */ number_fg_mode?: string; + + /** + * Numeric columns: background treatment - `"disabled"` (default), + * `"color"` (solid fill) or `"gradient"` (fill intensity scaled to + * the value). + */ number_bg_mode?: string; + + /** + * String columns: color mode (`"foreground"`, `"background"` or + * `"series"`), paired with `color`. + */ string_color_mode?: string; + + /** + * Datetime columns: color mode (`"foreground"` or `"background"`), + * paired with `color`. + */ datetime_color_mode?: string; + fixed?: number; + + /** + * Group-by rollup depth override for this column when the view is + * pivoted in `Rollup` mode. + */ aggregate_depth?: number; + + /** Pixel width override, written when a user drag-resizes a column. */ column_size_override?: number; + + /** String columns: display format, e.g. `"link"`, `"image"`, `"bold"`. */ format?: string; + + /** Datetime columns: display format preset or custom fields. */ date_format?: DateFormatConfig; - number_format?: NumberFormatConfig; -} -// Date format configuration for column styling -export interface DateFormatConfig { - format?: "custom" | string; - timeZone?: string; - dateStyle?: "short" | "medium" | "long" | "full" | "disabled"; - timeStyle?: "short" | "medium" | "long" | "full" | "disabled"; - second?: "numeric" | "2-digit" | "disabled"; - minute?: "numeric" | "2-digit" | "disabled"; - hour?: "numeric" | "2-digit" | "disabled"; - day?: "numeric" | "2-digit" | "disabled"; - weekday?: "narrow" | "short" | "long" | "disabled"; - month?: "numeric" | "2-digit" | "narrow" | "short" | "long" | "disabled"; - year?: "numeric" | "2-digit" | "disabled"; - hour12?: boolean; - fractionalSecondDigits?: 1 | 2 | 3; + /** + * Numeric columns: `Intl.NumberFormat`-style options controlling + * digits, notation, currency, etc. + */ + number_format?: NumberFormatConfig; } -// Number format configuration for column styling -export interface NumberFormatConfig { - style?: "decimal" | "currency" | "percent" | "unit"; - minimumFractionDigits?: number; - maximumFractionDigits?: number; - minimumIntegerDigits?: number; - minimumSignificantDigits?: number; - maximumSignificantDigits?: number; - currency?: string; - currencyDisplay?: "code" | "symbol" | "narrowSymbol" | "name"; - notation?: "standard" | "scientific" | "engineering" | "compact"; - compactDisplay?: "short" | "long"; - useGrouping?: boolean; -} +// The format-object types are the VIEWER's exported contract - its +// `createNumberFormatter` / `createDatetimeFormatter` consume them and its +// style editors write them - so they are imported, not redefined. +export type { + NumberFormatConfig, + DateFormatConfig, +} from "@perspective-dev/viewer"; export type ColumnsConfig = Record; -// Plugin save state +/** + * Datagrid plugin-level configuration - the `plugin_config` slot of a + * `ViewerConfigUpdate` when the Datagrid plugin is active (the + * `save()`/`restore()` token). + */ export interface DatagridPluginConfig { + /** + * Per-column state keyed by column name, e.g. + * `{ "Sales": { "column_size_override": 180 } }`. + */ columns?: ColumnsConfig; + + /** Legacy alias for `edit_mode: "EDIT"`. */ editable?: boolean; + + /** + * When `true`, the Datagrid keeps its scroll position pinned during + * data updates instead of following appended rows. + */ scroll_lock?: boolean; + + /** Cell interaction mode - see {@link EditMode}. */ edit_mode?: EditMode; + column_size_override?: Record; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd96806aca..d22e66bfa8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -959,6 +959,9 @@ importers: prettier: specifier: 'catalog:' version: 3.6.2 + ts-json-schema-generator: + specifier: ^2.4.0 + version: 2.9.0 typedoc: specifier: 'catalog:' version: 0.28.14(typescript@5.9.3) @@ -2750,6 +2753,10 @@ packages: resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} engines: {node: '>=20'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3292,6 +3299,10 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -3889,6 +3900,10 @@ packages: lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3986,6 +4001,10 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} @@ -4051,6 +4070,10 @@ packages: normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + npm-run-all@4.1.5: resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} engines: {node: '>= 4'} @@ -4173,6 +4196,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-type@3.0.0: resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} engines: {node: '>=4'} @@ -4450,6 +4477,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -4739,6 +4770,11 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-json-schema-generator@2.9.0: + resolution: {integrity: sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==} + engines: {node: '>=22.0.0'} + hasBin: true + tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} @@ -7366,6 +7402,8 @@ snapshots: commander@14.0.1: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@3.0.2: @@ -8009,6 +8047,12 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@7.1.7: dependencies: fs.realpath: 1.0.0 @@ -8618,6 +8662,8 @@ snapshots: dependencies: tslib: 2.8.1 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -8700,6 +8746,8 @@ snapshots: minipass@7.1.2: {} + minipass@7.1.3: {} + minizlib@3.1.0: dependencies: minipass: 7.1.2 @@ -8750,6 +8798,8 @@ snapshots: semver: 5.7.2 validate-npm-package-license: 3.0.4 + normalize-path@3.0.0: {} + npm-run-all@4.1.5: dependencies: ansi-styles: 3.2.1 @@ -8896,6 +8946,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-type@3.0.0: dependencies: pify: 3.0.0 @@ -9218,6 +9273,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sanitize-html@2.12.1: @@ -9553,6 +9610,17 @@ snapshots: dependencies: typescript: 5.9.3 + ts-json-schema-generator@2.9.0: + dependencies: + '@types/json-schema': 7.0.15 + commander: 14.0.3 + glob: 13.0.6 + json5: 2.2.3 + normalize-path: 3.0.0 + safe-stable-stringify: 2.5.0 + tslib: 2.8.1 + typescript: 5.9.3 + tslib@1.14.1: {} tslib@2.8.1: {} diff --git a/rust/metadata/main.rs b/rust/metadata/main.rs index b3145d96f3..2b10e8d4d6 100644 --- a/rust/metadata/main.rs +++ b/rust/metadata/main.rs @@ -38,25 +38,49 @@ use perspective_client::{ }; use perspective_js::TypedArrayWindow; use perspective_viewer::config::{ - ClientOptions, ExportMethod, ExportOptions, GetClientOptions, GetTableOptions, PanelOptions, - PluginStaticConfig, ViewerConfig, ViewerConfigUpdate, WorkspaceConfig, WorkspaceConfigUpdate, + ClientOptions, CustomNumberFormatConfig, DatetimeColorMode, DatetimeFormatType, ExportMethod, + ExportOptions, FormatMode, GetClientOptions, GetTableOptions, Notation, NumberFormatStyle, + PanelOptions, PluginStaticConfig, RestoreOptions, StringColorMode, ViewerConfig, + ViewerConfigInitial, ViewerConfigUpdate, WorkspaceConfig, WorkspaceConfigUpdate, }; use ts_rs::TS; pub fn generate_type_bindings_viewer() -> Result<(), Box> { let path = std::env::current_dir()?.join("../perspective-viewer/src/ts/ts-rs"); + + // The directory is 100% generated: wipe before export so types removed + // from the export graph cannot linger as stale orphans (the + // `ColumnConfigValues.ts` class of confusion). + if path.exists() { + fs::remove_dir_all(&path)?; + } + + fs::create_dir_all(&path)?; ViewerConfigUpdate::export_all_to(&path)?; + ViewerConfigInitial::export_all_to(&path)?; ViewerConfig::::export_all_to(&path)?; WorkspaceConfig::export_all_to(&path)?; WorkspaceConfigUpdate::export_all_to(&path)?; ExportMethod::export_all_to(&path)?; PanelOptions::export_all_to(&path)?; + RestoreOptions::export_all_to(&path)?; ClientOptions::export_all_to(&path)?; ExportOptions::export_all_to(&path)?; GetTableOptions::export_all_to(&path)?; GetClientOptions::export_all_to(&path)?; PluginStaticConfig::export_all_to(&path)?; OnUpdateData::export_all_to(&path)?; + + // The column-format wire types (`columns_config` values): the + // flattened style/notation families export separately (ts-rs cannot + // flatten `Option`) and are re-composed in `column-format.ts`. + CustomNumberFormatConfig::export_all_to(&path)?; + NumberFormatStyle::export_all_to(&path)?; + Notation::export_all_to(&path)?; + DatetimeFormatType::export_all_to(&path)?; + StringColorMode::export_all_to(&path)?; + DatetimeColorMode::export_all_to(&path)?; + FormatMode::export_all_to(&path)?; Ok(()) } diff --git a/rust/perspective-client/src/rust/config/view_config.rs b/rust/perspective-client/src/rust/config/view_config.rs index da430fc5ab..c2bad83513 100644 --- a/rust/perspective-client/src/rust/config/view_config.rs +++ b/rust/perspective-client/src/rust/config/view_config.rs @@ -177,6 +177,12 @@ pub struct ViewConfigUpdate { /// name and a string sort direction. When `column-pivots` are applied, /// the additional sort directions `"col asc"` and `"col desc"` will /// determine the order of pivot columns groups. + /// + /// `sort` is the ONLY thing that orders a `View`'s rows — without it + /// they keep the `Table`'s natural (insertion) order, which any + /// consumer reading rows sequentially will reflect. Not to be + /// confused with a window column's `order_by`, which orders rows + /// WITHIN a window frame and does not reorder the `View`. #[serde(skip_serializing_if = "Option::is_none")] #[serde(default)] #[ts(optional)] @@ -213,6 +219,13 @@ pub struct ViewConfigUpdate { /// Perspective provides a selection of aggregate functions that can be /// applied to columns in the `View` constructor using a dictionary of /// column name to aggregate function name. + /// + /// An aggregate also determines the column's RESULT TYPE, which need + /// not match the input: `"count"` yields an `integer` whatever it + /// counts, so a `date` column left on the default `"count"` is an + /// `integer` in the resulting `View` — no longer a date. Set an + /// aggregate that preserves the type (e.g. `"any"`, `"last"`) when + /// the original type matters, such as a date used as a chart axis. #[serde(skip_serializing_if = "Option::is_none")] #[serde(default)] #[ts(optional)] diff --git a/rust/perspective-client/src/rust/config/windows.rs b/rust/perspective-client/src/rust/config/windows.rs index 4f30b932ea..464470eed7 100644 --- a/rust/perspective-client/src/rust/config/windows.rs +++ b/rust/perspective-client/src/rust/config/windows.rs @@ -133,6 +133,8 @@ struct RawWindowSpec { partition_by: Vec, /// The `Table` column which orders each partition, and the direction. + /// This orders rows WITHIN the window frame only — it does not + /// reorder the `View`, which is what the view-level `sort` does. #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] order_by: Option, diff --git a/rust/perspective-viewer/Cargo.toml b/rust/perspective-viewer/Cargo.toml index ccc1571c8f..cceb40bb68 100644 --- a/rust/perspective-viewer/Cargo.toml +++ b/rust/perspective-viewer/Cargo.toml @@ -34,9 +34,10 @@ path = "src/rust/lib.rs" [features] external-bootstrap = [] metadata = [] -default = [] +default = ["llm-agent"] talc-allocator = ["perspective-js/talc-allocator"] trace-allocator = ["perspective-js/trace-allocator"] +llm-agent = [] [build-dependencies] lightningcss = "1.0.0-alpha.71" @@ -121,6 +122,8 @@ web-sys.version = "0.3.85" # Browser stdlib bindings web-sys.features = [ + "AbortController", + "AbortSignal", "Blob", "Clipboard", "CssStyleDeclaration", @@ -142,6 +145,7 @@ web-sys.features = [ "FontFaceSet", "FontFaceSetIterator", "FontFaceSetIteratorResult", + "Headers", "HtmlCollection", "HtmlElement", "HtmlTextAreaElement", @@ -158,6 +162,14 @@ web-sys.features = [ "Performance", "PerformanceMark", "Range", + "ReadableStream", + "ReadableStreamDefaultReader", + "ReadableStreamReadResult", + "Request", + "RequestInit", + "Response", + "TextDecoder", + "TextDecodeOptions", "Selection", "ShadowRoot", "ShadowRootMode", diff --git a/rust/perspective-viewer/build.mjs b/rust/perspective-viewer/build.mjs index 21e6ee74bb..f8a330b6fc 100644 --- a/rust/perspective-viewer/build.mjs +++ b/rust/perspective-viewer/build.mjs @@ -22,7 +22,12 @@ import { bundleAsync as bundleAsyncCss, } from "lightningcss"; import { compress } from "pro_self_extracting_wasm"; -import { get_host, inlineUrlVisitor, resolveNPM } from "./tools.mjs"; +import { + buildDocsCorpus, + get_host, + inlineUrlVisitor, + resolveNPM, +} from "./tools.mjs"; const IS_DEBUG = !!process.env.PSP_DEBUG || process.argv.indexOf("--debug") >= 0; @@ -45,6 +50,13 @@ export async function build_all() { ); } + // The agent metadata bundle: `search_docs` corpus + tool parameter + // schemas (needs the freshly-emitted `.d.ts` / ts-rs output). + const docs_stats = await buildDocsCorpus(); + console.log( + `docs corpus: ${docs_stats.chunks} chunks / ${docs_stats.bytes} bytes from ${docs_stats.files} files`, + ); + // JavaScript const BUILD = [ // WASM assets inlined into a single monolithic `.js` file. No special diff --git a/rust/perspective-viewer/package.json b/rust/perspective-viewer/package.json index f47651b27a..f64cc97d8f 100644 --- a/rust/perspective-viewer/package.json +++ b/rust/perspective-viewer/package.json @@ -74,6 +74,7 @@ "@types/node": "catalog:", "@types/react-dom": "catalog:", "prettier": "catalog:", + "ts-json-schema-generator": "^2.4.0", "typedoc": "catalog:", "typescript": "catalog:", "zx": "catalog:" diff --git a/rust/perspective-viewer/src/css/_viewer_bundle.css b/rust/perspective-viewer/src/css/_viewer_bundle.css index 2f51c5ad51..a2914f49bc 100644 --- a/rust/perspective-viewer/src/css/_viewer_bundle.css +++ b/rust/perspective-viewer/src/css/_viewer_bundle.css @@ -35,6 +35,7 @@ @import "render-warning.css"; @import "status-bar.css"; +@import "form/mirrored-textarea.css"; @import "form/code-editor.css"; @import "form/debug.css"; diff --git a/rust/perspective-viewer/src/css/column-selector.css b/rust/perspective-viewer/src/css/column-selector.css index 54fbf2f156..80de0cc873 100644 --- a/rust/perspective-viewer/src/css/column-selector.css +++ b/rust/perspective-viewer/src/css/column-selector.css @@ -181,6 +181,14 @@ border-color: var(--psp--color); } + /* Composed inside a `.pivot-column` pill (the window editor's slots), + the row's `min-width: auto` floor would hold it at content width and + overflow the container-stretched pill - collapse-to-0 requires the + definite-width chain to pass through. */ + .pivot-column > .column-selector-column { + min-width: 0; + } + .column-selector-column { display: flex; flex-direction: row-reverse; diff --git a/rust/perspective-viewer/src/css/config-selector.css b/rust/perspective-viewer/src/css/config-selector.css index 65a210a943..31d29b966b 100644 --- a/rust/perspective-viewer/src/css/config-selector.css +++ b/rust/perspective-viewer/src/css/config-selector.css @@ -201,12 +201,46 @@ pointer-events: none; } + /* `--psp-label--pivot--content` is set inline by the pivot slot + * when the ACTIVE plugin declares what the field draws (a `Y Line` + * groups by its X axis; a `Treemap` by its hierarchy). The slot + * emits an intl var reference rather than words, so these labels + * localize like any other. + * + * The role leads and the field's own name follows as a secondary + * label, so the reader learns what this slot DRAWS without losing + * which config field it IS - and a plugin that declares no role + * shows just the field name, exactly as before. The slot sets + * `--psp-label--pivot-secondary--display` as a presence flag, + * since CSS cannot test whether a custom property is set. */ #group_by label.pivot-selector-label:before { - content: var(--psp-label--group-by--content, "Group By"); + content: var( + --psp-label--pivot--content, + var(--psp-label--group-by--content, "Group By") + ); + } + + #group_by label.pivot-selector-label:after { + content: "(" var(--psp-label--group-by--content, "Group By") ")"; } #split_by label.pivot-selector-label:before { - content: var(--psp-label--split-by--content, "Split By"); + content: var( + --psp-label--pivot--content, + var(--psp-label--split-by--content, "Split By") + ); + } + + #split_by label.pivot-selector-label:after { + content: "(" var(--psp-label--split-by--content, "Split By") ")"; + } + + #group_by label.pivot-selector-label:after, + #split_by label.pivot-selector-label:after { + display: var(--psp-label--pivot-secondary--display, none); + margin-left: 6px; + opacity: 0.6; + font-size: 0.9em; } #sort label.pivot-selector-label:before { diff --git a/rust/perspective-viewer/src/css/form/code-editor.css b/rust/perspective-viewer/src/css/form/code-editor.css index 4052ac55af..4cf7b08df9 100644 --- a/rust/perspective-viewer/src/css/form/code-editor.css +++ b/rust/perspective-viewer/src/css/form/code-editor.css @@ -23,10 +23,9 @@ height: 100px; } - #editor-inner { + /* `position`/`display` come from `.mirrored-textarea`. */ + .editor-inner { overflow: hidden; - position: relative; - display: flex; flex: 1 1 auto; } diff --git a/rust/perspective-viewer/src/css/form/mirrored-textarea.css b/rust/perspective-viewer/src/css/form/mirrored-textarea.css new file mode 100644 index 0000000000..5632dc5d93 --- /dev/null +++ b/rust/perspective-viewer/src/css/form/mirrored-textarea.css @@ -0,0 +1,81 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ Copyright (c) 2017, the Perspective Authors. ┃ + * ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ + * ┃ This file is part of the Perspective library, distributed under the terms ┃ + * ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +/* Geometry for `components/form/mirrored_textarea.rs`: an invisible + * `