plotto 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The plotto authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,200 @@
1
- # Temporary Holding Version
1
+ # plotto
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ plotto makes grammar-of-graphics plots for notebooks, web pages, slides and reports. You write a plot in Python or in JavaScript with the same methods. One renderer draws it, so the notebook view, the interactive view and the saved file look the same.
4
+
5
+ **Gallery:** [gallery link](https://theoradusz.github.io/plotto/), with pages for the basics, the plot types, the colors, the scales and legends, a step-by-step grammar tutorial and the API reference. `SPEC.md` is the contract: the spec format, the rules, the layers, the scales and the behavior.
6
+
7
+ <p>
8
+ <img src="docs/img/lines.png" width="49%"> <img src="docs/img/stacked_bars.png" width="49%">
9
+ <img src="docs/img/histogram.png" width="49%"> <img src="docs/img/diverging.png" width="49%">
10
+ <img src="docs/img/facets.png" width="49%"> <img src="docs/img/smooth_1.png" width="49%">
11
+ </p>
12
+
13
+ ```python
14
+ from plotto import plot
15
+
16
+ p = (
17
+ plot(df, x="time", y="value", color="sensor")
18
+ .line()
19
+ .hline(y=0.75, color="red", dash="dashed")
20
+ .facet("indicator", free="y")
21
+ .y(limits=[0, None])
22
+ .labels(title="Water quality", x=None)
23
+ )
24
+ p # drawn at the cell width, with copy and download buttons
25
+ p.interactive() # the same plot with zoom, pan, hover and legend toggle
26
+ p.theme("wota") # another style
27
+ p.color(palette="aizome") # another palette
28
+ p.save("fig.png") # PNG; .svg for vector files
29
+ p.theme("wota", format="slide").save("slide.png") # 1280 × 720 px, large text
30
+ ```
31
+
32
+ ```js
33
+ import { plot, draw } from "plotto";
34
+ draw(plot(rows, { x: "time", y: "value", color: "sensor" }).line().facet("indicator", { free: "y" }), el, { interactive: true });
35
+ ```
36
+
37
+ ## How it works
38
+
39
+ 1. The builder writes a JSON spec: the data columns, the layers, the scales, the facets, the labels and the theme.
40
+ 2. `src/figure.js` computes the statistics, the scales and the layout. Then it draws the title, strips and legend, and Observable Plot draws each panel.
41
+ 3. In the browser, a zoom, a legend click or a resize draws the figure again with the same code. The look does not change.
42
+ 4. For files, Python sends the spec to one Node process (`src/render.mjs`). The process draws the SVG with jsdom and the PNG with resvg.
43
+
44
+ | Part | Files |
45
+ |---|---|
46
+ | Grammar and themes | `grammar.json` (layers, channels, options), `theme.json` (styles, formats) |
47
+ | JS builder and renderer | `src/`. Each file has one job, stated in its header comment. |
48
+ | Python builder and outputs | `python/plotto/` |
49
+ | Examples | `examples/` |
50
+ | Tests | `tests/`, `python/tests/` |
51
+
52
+ ## Install
53
+
54
+ | Where | Install | Import |
55
+ |---|---|---|
56
+ | Python, notebooks | `pip install python-plotto` | `from plotto import plot` |
57
+ | Web pages, React | `npm install plotto` | `import { plot, draw } from "plotto"`; `import { Plot } from "plotto/react"` |
58
+
59
+ - Python 3.12 or later. A notebook plot needs no Node.
60
+ - `save()` (PNG and SVG files) and the static copy in notebook outputs need Node.js 20 or later. On the first use, plotto downloads its renderer with `npx` (the npm package of the same version) and keeps it in the npm cache.
61
+ - The `wota` style uses the M PLUS 1p font. Install the font to use that style.
62
+
63
+ ### Development
64
+
65
+ 1. Clone the repo and run `npm install`.
66
+ 2. Run `pip install -e "python[test]"` in a virtual environment. In a checkout, the package uses `grammar.json`, `theme.json` and `src/` of the repo directly.
67
+
68
+ ## Use
69
+
70
+ ### Data
71
+
72
+ - Use long format: one row per observation, one column per variable.
73
+ - Python takes a pandas DataFrame or a list of dicts. JavaScript takes an array of row objects.
74
+ - A time-zone-aware datetime column shows its local clock time.
75
+ - To set the order of categories, use a pandas categorical column or `x(limits=[...])`.
76
+
77
+ ### Axes and colors
78
+
79
+ | Need | Call |
80
+ |---|---|
81
+ | Axis range | `x(limits=[0, 10])`; `y(limits=[0, None])` keeps the data bound on the `None` side |
82
+ | Category order | `x(limits=["low", "mid", "high"])` |
83
+ | Log axis, time labels | `y(type="log")`; `x(format="%a %H:%M")` |
84
+ | Category colors | `color(palette="sakura")`, or `color(values={"a": "red", "b": "#123456"})` |
85
+ | Number colors | `color(scheme="ai")`; diverging: `color(scheme="aiaka", mid=0)` |
86
+
87
+ Palettes: `pastel` (the default style), `sakura`, `aizome`, `momiji` (inspired by traditional Japanese colors), `wota`, `tableau`. Schemes: `ai`, `sakura`, `matcha`, `yuhi`, `sumi`, `aiaka` (diverging), and every d3 scheme (`viridis`, `magma`, …).
88
+
89
+ ### Notebook
90
+
91
+ - A plot shows at the width of the cell. The text keeps its size, and the height follows the aspect ratio of the format.
92
+ - `.interactive()` adds zoom, pan, hover and the legend toggle.
93
+ - The hover follows the plot type:
94
+ - lines and areas: every series at the nearest x, or only the series under the pointer when there are more than 6;
95
+ - bars: the stack and its total;
96
+ - box: the quartiles;
97
+ - tile: the cell value;
98
+ - points: the nearest point.
99
+ - A click pins the hover label; the next click (or Esc) releases it. A drag starts after 5 px of movement; a double-click resets the view.
100
+ - A legend click hides or shows a series. Two quick clicks on the same entry (within 300 ms) show only that series.
101
+ - The toolbar shows at the top right when the pointer is on the plot:
102
+ - copy as PNG;
103
+ - download as PNG or SVG;
104
+ - in interactive mode, also zoom box, pan, zoom in, zoom out and reset.
105
+ - The output loads d3 and Observable Plot from `cdn.jsdelivr.net`. GitHub, nbviewer, or a page without network access shows the static SVG in the output.
106
+ - Each output holds the data of the plot and the plotto script (about 60 kB). A plot with 20 000 points adds about 2 MB to the notebook.
107
+
108
+ ### Web pages and React
109
+
110
+ - `draw(spec, element, { interactive })` draws into an element at its width, and draws again when the width changes. It returns `{ destroy }`.
111
+ - In React, use `import { Plot } from "plotto/react"`, then `<Plot spec={plot(rows, {...}).line()} interactive />`.
112
+
113
+ ### Slides and reports
114
+
115
+ | Target | Use |
116
+ |---|---|
117
+ | Google Slides | PNG: `p.save("fig.png")`. Google Slides does not accept SVG. |
118
+ | PowerPoint, Keynote, documents | SVG: `p.save("fig.svg")`. The SVG uses the installed fonts. |
119
+ | A slide layout | `p.theme(style, format="slide")`: 1280 × 720 px with large text |
120
+ | A report column | `p.theme(style, format="report")`: 640 × 400 px |
121
+ | A given size | `p.save("fig.png", width=1600)` lays the plot out again at 1600 px. |
122
+ | From the notebook | Use the copy button, which copies the current view as PNG. |
123
+
124
+ ## Examples
125
+
126
+ | File | Content |
127
+ |---|---|
128
+ | `examples/gallery.py` | 28 examples on 4 pages (basics, plot types, colors, scales and legends), which use every feature. The data is made-up, except for two files of real measurements. |
129
+ | `examples/grammar.py` | The grammar tutorial: 14 steps, each with its text, code and result. |
130
+ | `examples/reference.py` | The API reference. The layer methods come from `grammar.json`. |
131
+ | `examples/build_site.py` | Builds the site in `docs/`: one HTML page per gallery page, `grammar.html` and `reference.html` (each with the plotto script inline), and `img/` (the PNG and SVG of each gallery plot). GitHub Pages serves `docs/`. |
132
+ | `examples/showcase.ipynb` | The examples in a notebook. |
133
+ | `examples/web/` | Examples in JavaScript. Run `python3 -m http.server` in the repo, then open `http://localhost:8000/examples/web/`. |
134
+
135
+ To publish the gallery:
136
+
137
+ 1. Run `python examples/build_site.py`.
138
+ 2. Commit `docs/`.
139
+ 3. On GitHub, set Settings → Pages → Source to "Deploy from a branch", with branch `main` and folder `/docs`.
140
+ | `examples/data/` | The datasets. `examples/make_datasets.py` makes the made-up ones. |
141
+
142
+ ## Tests
143
+
144
+ | Command | What it verifies |
145
+ |---|---|
146
+ | `npm test` | Each layer and style (SVG snapshots in `tests/snapshots/`), stacks, bins, smooth fits, domains, facets, legends, reflow, errors |
147
+ | `npm run browser-test` | In headless Chrome: the browser SVG equals the Node SVG. Zoom, Home, the toolbar, hover, legend clicks and their timing, reflow and the static mode work. |
148
+ | `pytest python/tests` | The Python and JS builders make the same spec; data conversion; SVG and PNG files; notebook output |
149
+ | `ruff check . && black --check .` | Python lint |
150
+
151
+ If a change of the look is intentional, run `UPDATE_SNAPSHOTS=1 npm test` and review the SVG diff.
152
+
153
+ ## Maintenance
154
+
155
+ To add a layer type:
156
+
157
+ 1. Add the layer to `grammar.json`: channels, options and required channels.
158
+ 2. Add one entry to `LAYERS` in `src/layers.js`.
159
+ 3. For a statistic, add the computation to `src/stats.js` and a case to `makeView` and `hoverOf` in `src/views.js`.
160
+ 4. Add the method to `src/builder.js` and `python/plotto/plot.py`.
161
+ 5. Add a case to `tests/render.test.js` and to `tests/fixtures/parity.json`.
162
+ 6. Add a row to the layer table in `SPEC.md`.
163
+
164
+ To change a style, a format, a palette or a scheme, edit `theme.json`. A scheme is a list of colors from light to dark; plotto interpolates it in Lab space. To use a new font, add it to `scripts/font_metrics.py`, run the script, and set `font` and `metrics` of the style.
165
+
166
+ ## Release
167
+
168
+ A tag `vX.Y.Z` publishes `python-plotto` to PyPI and `plotto` to npm (`.github/workflows/release.yml`).
169
+
170
+ One-time setup:
171
+
172
+ 1. On pypi.org, create an account. Under "Publishing", add a pending trusted publisher: project `python-plotto`, owner `theoradusz`, repository `plotto`, workflow `release.yml`, environment `pypi`.
173
+ 2. On GitHub, create the environment `pypi` (Settings → Environments).
174
+ 3. On npmjs.com, create an account (with two-factor authentication). Publish the first version from your computer, in the repo:
175
+
176
+ ```
177
+ npm login
178
+ npm test
179
+ npm publish --access public
180
+ ```
181
+
182
+ 4. On npmjs.com, open the package `plotto` → Settings → Trusted publishing → GitHub Actions: repository `theoradusz/plotto`, workflow `release.yml`. Later versions then publish from GitHub with no token. The workflow skips a version that is already on npm.
183
+
184
+ Each release:
185
+
186
+ 1. Set the same version in `package.json` and in `python/plotto/__init__.py` (a test verifies that they agree).
187
+ 2. Commit, then tag and push:
188
+
189
+ ```
190
+ git tag v0.1.0
191
+ git push origin v0.1.0
192
+ ```
193
+
194
+ 3. The Actions tab shows the release. The packages are then at pypi.org/project/python-plotto and npmjs.com/package/plotto.
195
+
196
+ To upgrade Observable Plot or d3:
197
+
198
+ 1. Change the exact version in `package.json`.
199
+ 2. Run `npm install`.
200
+ 3. Run the tests. A snapshot difference shows a change of the look.
package/grammar.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "options": {
3
+ "size": "number",
4
+ "alpha": "number",
5
+ "dash": ["solid", "dashed", "dotted", "dashdot"],
6
+ "jitter": "number",
7
+ "summary": ["mean", "median", "min", "max", "sum"],
8
+ "slope": "number",
9
+ "intercept": "number",
10
+ "position": ["stack", "dodge", "overlay"],
11
+ "bins": "number",
12
+ "density": "boolean",
13
+ "method": ["lm", "loess"],
14
+ "span": "number",
15
+ "band": "boolean"
16
+ },
17
+ "layers": {
18
+ "line": { "channels": ["x", "y", "color", "group", "tooltip"], "options": ["size", "alpha", "dash", "summary"], "requires": ["x", "y"] },
19
+ "path": { "channels": ["x", "y", "color", "group", "tooltip"], "options": ["size", "alpha", "dash"], "requires": ["x", "y"] },
20
+ "step": { "channels": ["x", "y", "color", "group", "tooltip"], "options": ["size", "alpha", "dash"], "requires": ["x", "y"] },
21
+ "point": { "channels": ["x", "y", "color", "tooltip"], "options": ["size", "alpha", "jitter", "summary"], "requires": ["x", "y"] },
22
+ "bar": { "channels": ["x", "y", "color", "tooltip"], "options": ["alpha", "position"], "requires": ["x", "y"] },
23
+ "hist": { "channels": ["x", "color"], "options": ["alpha", "bins", "density", "position"], "requires": ["x"] },
24
+ "density": { "channels": ["x", "color"], "options": ["size", "alpha", "dash"], "requires": ["x"] },
25
+ "area": { "channels": ["x", "y", "color", "group"], "options": ["alpha", "position"], "requires": ["x", "y"] },
26
+ "ribbon": { "channels": ["x", "ymin", "ymax", "color", "group"], "options": ["alpha"], "requires": ["x", "ymin", "ymax"] },
27
+ "smooth": { "channels": ["x", "y", "color"], "options": ["size", "alpha", "dash", "method", "span", "band"], "requires": ["x", "y"] },
28
+ "hline": { "channels": ["y", "color"], "options": ["size", "alpha", "dash"], "requires": ["y"], "inherit": false },
29
+ "vline": { "channels": ["x", "color"], "options": ["size", "alpha", "dash"], "requires": ["x"], "inherit": false },
30
+ "abline": { "channels": ["color"], "options": ["slope", "intercept", "size", "alpha", "dash"], "requires": [], "inherit": false },
31
+ "segment": { "channels": ["x", "y", "xend", "yend", "color"], "options": ["size", "alpha", "dash"], "requires": ["x", "y", "xend", "yend"] },
32
+ "rect": { "channels": ["xmin", "xmax", "ymin", "ymax", "color", "tooltip"], "options": ["alpha"], "requires": ["xmin", "xmax", "ymin", "ymax"] },
33
+ "band": { "channels": ["xmin", "xmax", "ymin", "ymax", "color"], "options": ["alpha"], "requires": [], "inherit": false },
34
+ "tile": { "channels": ["x", "y", "color", "tooltip"], "options": ["alpha"], "requires": ["x", "y", "color"] },
35
+ "text": { "channels": ["x", "y", "label", "color"], "options": ["size", "alpha"], "requires": ["x", "y", "label"] },
36
+ "errorbar": { "channels": ["x", "ymin", "ymax", "color"], "options": ["size", "alpha"], "requires": ["x", "ymin", "ymax"] },
37
+ "box": { "channels": ["x", "y", "color"], "options": ["alpha"], "requires": ["x", "y"] }
38
+ },
39
+ "scales": {
40
+ "x": ["type", "limits", "format"],
41
+ "y": ["type", "limits", "format"],
42
+ "color": ["values", "palette", "scheme", "limits", "mid", "clamp"]
43
+ },
44
+ "scaleTypes": ["linear", "log", "time", "category"],
45
+ "facet": ["free", "ncol"],
46
+ "grid": ["free"],
47
+ "free": ["x", "y", "both"],
48
+ "labels": ["title", "subtitle", "x", "y", "color", "caption"],
49
+ "theme": ["format", "width", "height", "size", "legend"],
50
+ "legend": ["right", "bottom", "none"]
51
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "plotto",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Grammar-of-graphics plots for notebooks, web pages and slides with one renderer.",
5
+ "license": "MIT",
6
+ "homepage": "https://theoradusz.github.io/plotto/",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/theoradusz/plotto.git"
10
+ },
11
+ "keywords": [
12
+ "plot",
13
+ "chart",
14
+ "grammar-of-graphics",
15
+ "ggplot",
16
+ "observable-plot",
17
+ "svg",
18
+ "react"
19
+ ],
20
+ "type": "module",
21
+ "main": "src/index.js",
22
+ "exports": {
23
+ ".": "./src/index.js",
24
+ "./react": "./src/Plot.js",
25
+ "./render": "./src/render.mjs",
26
+ "./theme.json": "./theme.json",
27
+ "./grammar.json": "./grammar.json"
28
+ },
29
+ "bin": {
30
+ "plotto-render": "src/render.mjs"
31
+ },
32
+ "files": [
33
+ "src/",
34
+ "grammar.json",
35
+ "theme.json",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "test": "node --test tests/*.test.js",
41
+ "browser-test": "node tests/browser.mjs",
42
+ "build:python": "node scripts/build_assets.mjs",
43
+ "prepublishOnly": "npm test"
44
+ },
45
+ "dependencies": {
46
+ "@observablehq/plot": "0.6.17",
47
+ "d3": "7.9.0",
48
+ "@resvg/resvg-js": "2.6.2",
49
+ "jsdom": "26.1.0"
50
+ },
51
+ "devDependencies": {
52
+ "esbuild": "0.28.2"
53
+ },
54
+ "peerDependencies": {
55
+ "react": ">=18"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "react": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "engines": {
63
+ "node": ">=20"
64
+ }
65
+ }
package/src/Plot.js ADDED
@@ -0,0 +1,12 @@
1
+ // React wrapper: <Plot spec={plot(rows, {...}).line()} interactive />. draw() follows the container width.
2
+ import { createElement, useEffect, useRef } from "react";
3
+ import { draw } from "./interact.js";
4
+
5
+ export function Plot({ spec, interactive = false, className, style }) {
6
+ const ref = useRef(null);
7
+ useEffect(() => {
8
+ const ctl = draw(spec, ref.current, { interactive });
9
+ return () => ctl.destroy();
10
+ }, [spec, interactive]);
11
+ return createElement("div", { ref, className, style });
12
+ }
package/src/browser.js ADDED
@@ -0,0 +1,6 @@
1
+ // Entry of the notebook bundle (render.mjs bundle()): puts the API on window.plotto.
2
+ import { plot } from "./builder.js";
3
+ import { toSVG } from "./figure.js";
4
+ import { draw } from "./interact.js";
5
+
6
+ globalThis.plotto = { plot, draw, toSVG };
package/src/builder.js ADDED
@@ -0,0 +1,178 @@
1
+ // Chainable plot builder. Each method returns a new builder and never changes the old one.
2
+ // .spec() returns the JSON spec (SPEC.md). python/plotto/plot.py has the same methods and rules.
3
+ import GRAMMAR from "../grammar.json" with { type: "json" };
4
+ import THEMES from "../theme.json" with { type: "json" };
5
+
6
+
7
+ const LAYERS = GRAMMAR.layers;
8
+ const PLOT_CHANNELS = [...new Set(Object.values(LAYERS).flatMap((l) => l.channels))];
9
+
10
+ export function plot(rows, mapping = {}) {
11
+ const data = [toTable(rows, "plot()")];
12
+ for (const [ch, col] of Object.entries(mapping)) {
13
+ if (!PLOT_CHANNELS.includes(ch)) fail("plot()", "channel", ch, PLOT_CHANNELS);
14
+ needColumn(data[0], col, "plot()");
15
+ }
16
+ return new Builder({ data, mapping, layers: [], facet: null, scales: {}, labels: {}, theme: { style: "pastel" } });
17
+ }
18
+
19
+ class Builder {
20
+ #s;
21
+ constructor(state) {
22
+ this.#s = state;
23
+ }
24
+
25
+ #with(changes) {
26
+ return new Builder({ ...this.#s, ...changes });
27
+ }
28
+
29
+ #layer(type, options = {}) {
30
+ const def = LAYERS[type];
31
+ const where = `${type}()`;
32
+ let { data, ...rest } = options;
33
+ let tables = this.#s.data;
34
+ let index = 0;
35
+ if (data !== undefined) {
36
+ tables = [...tables, toTable(data, where)];
37
+ index = tables.length - 1;
38
+ }
39
+ const columns = tables[index].columns;
40
+ const layer = { type, mapping: {}, set: {} };
41
+ if (index) layer.data = index;
42
+ for (const [key, value] of Object.entries(rest)) {
43
+ if (def.channels.includes(key) && typeof value === "string" && value in columns) layer.mapping[key] = value;
44
+ else if (def.channels.includes(key) || def.options.includes(key)) layer.set[key] = constant(key, value, where, columns);
45
+ else fail(where, "option", key, ["data", ...def.channels, ...def.options]);
46
+ }
47
+ return this.#with({ data: tables, layers: [...this.#s.layers, layer] });
48
+ }
49
+
50
+ line(options) { return this.#layer("line", options); }
51
+ step(options) { return this.#layer("step", options); }
52
+ bar(options) { return this.#layer("bar", options); }
53
+ hist(options) { return this.#layer("hist", options); }
54
+ density(options) { return this.#layer("density", options); }
55
+ area(options) { return this.#layer("area", options); }
56
+ ribbon(options) { return this.#layer("ribbon", options); }
57
+ smooth(options) { return this.#layer("smooth", options); }
58
+ path(options) { return this.#layer("path", options); }
59
+ point(options) { return this.#layer("point", options); }
60
+ hline(options) { return this.#layer("hline", options); }
61
+ vline(options) { return this.#layer("vline", options); }
62
+ abline(options) { return this.#layer("abline", options); }
63
+ segment(options) { return this.#layer("segment", options); }
64
+ rect(options) { return this.#layer("rect", options); }
65
+ band(options) { return this.#layer("band", options); }
66
+ tile(options) { return this.#layer("tile", options); }
67
+ text(options) { return this.#layer("text", options); }
68
+ errorbar(options) { return this.#layer("errorbar", options); }
69
+ box(options) { return this.#layer("box", options); }
70
+
71
+ facet(by, options = {}) {
72
+ needColumn(this.#s.data[0], by, "facet()");
73
+ checkKeys(options, GRAMMAR.facet, "facet()");
74
+ checkFree(options.free, "facet()");
75
+ return this.#with({ facet: { wrap: by, free: options.free ?? null, ncol: options.ncol ?? null } });
76
+ }
77
+
78
+ grid({ rows = null, cols = null, ...options } = {}) {
79
+ if (rows === null && cols === null) throw new Error("grid(): set rows, cols or both");
80
+ for (const col of [rows, cols]) if (col !== null) needColumn(this.#s.data[0], col, "grid()");
81
+ checkKeys(options, GRAMMAR.grid, "grid()");
82
+ checkFree(options.free, "grid()");
83
+ return this.#with({ facet: { rows, cols, free: options.free ?? null } });
84
+ }
85
+
86
+ x(options) { return this.#scale("x", options); }
87
+ y(options) { return this.#scale("y", options); }
88
+ color(options) { return this.#scale("color", options); }
89
+
90
+ #scale(name, options = {}) {
91
+ const where = `${name}()`;
92
+ checkKeys(options, GRAMMAR.scales[name], where);
93
+ if (options.type !== undefined && !GRAMMAR.scaleTypes.includes(options.type)) fail(where, "type", options.type, GRAMMAR.scaleTypes);
94
+ if (options.palette !== undefined && !Array.isArray(options.palette) && !(options.palette in THEMES.palettes)) fail(where, "palette", options.palette, Object.keys(THEMES.palettes));
95
+ if (options.mid !== undefined && typeof options.mid !== "number") throw new Error(`${where}: mid must be a number`);
96
+ const scale = { ...options };
97
+ if (scale.limits) {
98
+ if (!Array.isArray(scale.limits)) throw new Error(`${where}: limits must be a list, for example [0, 10] or [0, null]`);
99
+ scale.limits = scale.limits.map((v) => (v === null || v === undefined ? null : toValue(v)));
100
+ }
101
+ return this.#with({ scales: { ...this.#s.scales, [name]: scale } });
102
+ }
103
+
104
+ labels(options = {}) {
105
+ checkKeys(options, GRAMMAR.labels, "labels()");
106
+ return this.#with({ labels: { ...this.#s.labels, ...options } });
107
+ }
108
+
109
+ theme(style = "pastel", options = {}) {
110
+ checkKeys(options, GRAMMAR.theme, "theme()");
111
+ if (!(style in THEMES.styles)) fail("theme()", "style", style, Object.keys(THEMES.styles));
112
+ if (options.format !== undefined && !(options.format in THEMES.formats)) fail("theme()", "format", options.format, Object.keys(THEMES.formats));
113
+ if (options.legend !== undefined && !GRAMMAR.legend.includes(options.legend)) fail("theme()", "legend", options.legend, GRAMMAR.legend);
114
+ return this.#with({ theme: { style, ...options } });
115
+ }
116
+
117
+ // The JSON spec. Each table keeps only the columns that a mapping or a facet uses.
118
+ spec() {
119
+ const s = this.#s;
120
+ const used = s.data.map(() => new Set());
121
+ for (const col of Object.values(s.mapping)) used.forEach((set, i) => col in s.data[i].columns && set.add(col));
122
+ for (const layer of s.layers) for (const col of Object.values(layer.mapping)) used[layer.data ?? 0].add(col);
123
+ for (const col of [s.facet?.wrap, s.facet?.rows, s.facet?.cols]) {
124
+ if (col) used.forEach((set, i) => col in s.data[i].columns && set.add(col));
125
+ }
126
+ const data = s.data.map((t, i) => ({ columns: Object.fromEntries([...used[i]].map((c) => [c, t.columns[c]])) }));
127
+ return { version: 1, data, mapping: s.mapping, layers: s.layers, facet: s.facet, scales: s.scales, labels: s.labels, theme: s.theme };
128
+ }
129
+ }
130
+
131
+ // Rows (array of objects) to columnar data with one type per column: number, time or string.
132
+ function toTable(rows, where) {
133
+ if (!Array.isArray(rows)) throw new Error(`${where}: data must be an array of row objects`);
134
+ const names = [...new Set(rows.flatMap(Object.keys))];
135
+ const columns = {};
136
+ for (const name of names) {
137
+ const raw = rows.map((r) => r[name] ?? null);
138
+ const first = raw.find((v) => v !== null && !Number.isNaN(v));
139
+ const type = first instanceof Date ? "time" : typeof first === "number" ? "number" : "string";
140
+ const values = raw.map((v) => (v === null || Number.isNaN(v) ? null : type === "string" ? String(v) : toValue(v)));
141
+ columns[name] = { type, values };
142
+ }
143
+ return { columns };
144
+ }
145
+
146
+ // Dates become wall-clock epoch ms (the time that the clock shows); floats keep 6 significant digits.
147
+ function toValue(v) {
148
+ if (v instanceof Date) return v.getTime() - v.getTimezoneOffset() * 60000;
149
+ if (typeof v === "number" && !Number.isInteger(v)) return Number(v.toPrecision(6));
150
+ return v;
151
+ }
152
+
153
+ function constant(key, value, where, columns) {
154
+ const kind = GRAMMAR.options[key];
155
+ if (key === "tooltip" || key === "group") throw new Error(`${where}: ${key} must be a column. Columns: ${Object.keys(columns).join(", ")}`);
156
+ if (Array.isArray(kind) && !kind.includes(value)) fail(where, key, value, kind);
157
+ if (kind === "number" && typeof value !== "number") throw new Error(`${where}: ${key} must be a number`);
158
+ if (kind === "boolean" && typeof value !== "boolean") throw new Error(`${where}: ${key} must be true or false`);
159
+ if (key === "color" || key === "label") return value; // spec.js validates the color against the theme
160
+ if (typeof value === "string" && !kind) throw new Error(`${where}: "${value}" is not a column. Columns: ${Object.keys(columns).join(", ")}`);
161
+ return toValue(value);
162
+ }
163
+
164
+ function needColumn(table, col, where) {
165
+ if (!(col in table.columns)) throw new Error(`${where}: "${col}" is not a column. Columns: ${Object.keys(table.columns).join(", ")}`);
166
+ }
167
+
168
+ function checkKeys(options, valid, where) {
169
+ for (const key of Object.keys(options)) if (!valid.includes(key)) fail(where, "option", key, valid);
170
+ }
171
+
172
+ function checkFree(free, where) {
173
+ if (free != null && !GRAMMAR.free.includes(free)) fail(where, "free", free, GRAMMAR.free);
174
+ }
175
+
176
+ function fail(where, what, value, valid) {
177
+ throw new Error(`${where}: unknown ${what} "${value}". Valid: ${valid.join(", ")}`);
178
+ }
package/src/copy.js ADDED
@@ -0,0 +1,56 @@
1
+ // Copy and download the figure as PNG or SVG. The image is the current view, without the hover marks.
2
+ // The SVG loads from a data URL: some hosts (VS Code notebooks) block blob: images.
3
+ const SCALE = 2; // PNG px per figure px
4
+
5
+ // Copies a PNG to the clipboard; if the browser refuses, downloads it. Returns "Copied" or "Downloaded".
6
+ export async function copyPNG(svg) {
7
+ const png = rasterize(svg);
8
+ try {
9
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": png.then(toBlob) })]);
10
+ return "Copied";
11
+ } catch {
12
+ save((await png).toDataURL("image/png"), "plot.png");
13
+ return "Downloaded";
14
+ }
15
+ }
16
+
17
+ export async function downloadPNG(svg) {
18
+ save((await rasterize(svg)).toDataURL("image/png"), "plot.png");
19
+ }
20
+
21
+ export function downloadSVG(svg) {
22
+ save(dataURL(svg), "plot.svg");
23
+ }
24
+
25
+ async function rasterize(svg) {
26
+ const img = new Image();
27
+ img.src = dataURL(svg);
28
+ await img.decode();
29
+ const { width, height } = svg.viewBox.baseVal;
30
+ const canvas = document.createElement("canvas");
31
+ canvas.width = width * SCALE;
32
+ canvas.height = height * SCALE;
33
+ canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
34
+ return canvas;
35
+ }
36
+
37
+ function dataURL(svg) {
38
+ const clone = svg.cloneNode(true);
39
+ clone.querySelector(".pt-overlay")?.remove();
40
+ const { width, height } = svg.viewBox.baseVal;
41
+ clone.setAttribute("width", width);
42
+ clone.setAttribute("height", height);
43
+ const bytes = new TextEncoder().encode(new XMLSerializer().serializeToString(clone));
44
+ let binary = "";
45
+ for (const b of bytes) binary += String.fromCharCode(b);
46
+ return `data:image/svg+xml;base64,${btoa(binary)}`;
47
+ }
48
+
49
+ const toBlob = (canvas) => new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
50
+
51
+ function save(href, name) {
52
+ const a = document.createElement("a");
53
+ a.href = href;
54
+ a.download = name;
55
+ a.click();
56
+ }
package/src/facets.js ADDED
@@ -0,0 +1,54 @@
1
+ // Facets: splits the rows of each layer into panels and places the panels on a grid.
2
+ import { naturalOrder } from "./scales.js";
3
+
4
+ // Returns { kind, nrow, ncol, panels }. Each panel has row, col, labels and rows[layerIndex] (row numbers).
5
+ export function facetPanels(ns) {
6
+ const f = ns.facet;
7
+ if (!f) return { kind: "none", nrow: 1, ncol: 1, panels: [panel(ns, 0, 0, {}, {})] };
8
+ if (f.kind === "wrap") {
9
+ const keys = levels(ns, f.by);
10
+ const ncol = f.ncol ?? wrapColumns(keys.length);
11
+ const panels = keys.map((k, i) => panel(ns, Math.floor(i / ncol), i % ncol, { [f.by]: k }, { top: k }));
12
+ return { kind: "wrap", nrow: Math.ceil(keys.length / ncol), ncol, panels };
13
+ }
14
+ const rowKeys = f.rows ? levels(ns, f.rows) : [null];
15
+ const colKeys = f.cols ? levels(ns, f.cols) : [null];
16
+ const panels = [];
17
+ rowKeys.forEach((rk, r) =>
18
+ colKeys.forEach((ck, c) => {
19
+ const match = {};
20
+ if (f.rows) match[f.rows] = rk;
21
+ if (f.cols) match[f.cols] = ck;
22
+ panels.push(panel(ns, r, c, match, { top: r === 0 ? ck : null, right: c === colKeys.length - 1 ? rk : null }));
23
+ })
24
+ );
25
+ return { kind: "grid", nrow: rowKeys.length, ncol: colKeys.length, panels };
26
+ }
27
+
28
+ // Number of columns of a facet wrap, the same rule as ggplot (grDevices::n2mfrow).
29
+ function wrapColumns(n) {
30
+ if (n <= 3) return n;
31
+ if (n <= 6) return Math.ceil(n / 2);
32
+ if (n <= 12) return Math.ceil(n / 3);
33
+ return Math.ceil(Math.sqrt(n));
34
+ }
35
+
36
+ function levels(ns, column) {
37
+ const col = ns.tables[0].columns[column];
38
+ if (col.levels) return col.levels.filter((v) => col.values.includes(v));
39
+ return [...new Set(col.values.filter((v) => v !== null).map(String))].sort(naturalOrder);
40
+ }
41
+
42
+ // A layer without columns (for example hline(y=0)) has one row in each panel.
43
+ // A layer table without the facet column repeats in each panel, as in ggplot.
44
+ function panel(ns, row, col, match, strips) {
45
+ const rows = ns.layers.map((layer) => {
46
+ const t = ns.tables[layer.table];
47
+ if (!Object.values(layer.channels).some((c) => c.column)) return [0];
48
+ const tests = Object.entries(match).filter(([name]) => name in t.columns);
49
+ const all = Array.from({ length: t.n }, (_, i) => i);
50
+ if (!tests.length) return all;
51
+ return all.filter((i) => tests.every(([name, key]) => String(t.columns[name].values[i]) === key));
52
+ });
53
+ return { row, col, strips, rows };
54
+ }