plotto 0.0.0-stage → 0.3.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 +21 -0
- package/README.md +223 -2
- package/grammar.json +53 -0
- package/package.json +63 -4
- package/src/Plot.js +12 -0
- package/src/browser.js +6 -0
- package/src/builder.js +198 -0
- package/src/copy.js +56 -0
- package/src/facets.js +54 -0
- package/src/figure.js +199 -0
- package/src/hover.js +296 -0
- package/src/index.js +4 -0
- package/src/info.js +83 -0
- package/src/interact.js +364 -0
- package/src/layers.js +159 -0
- package/src/layout.js +130 -0
- package/src/legend.js +104 -0
- package/src/metrics.js +2 -0
- package/src/render.mjs +80 -0
- package/src/scales.js +159 -0
- package/src/spec.js +64 -0
- package/src/stats.js +187 -0
- package/src/svg.js +14 -0
- package/src/text.js +44 -0
- package/src/theme.js +93 -0
- package/src/toolbar.js +126 -0
- package/src/views.js +84 -0
- package/theme.json +150 -0
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,224 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plotto
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
### Info table
|
|
90
|
+
|
|
91
|
+
Put facts about the data or the experiment in a table instead of a long subtitle:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
p.info({"data point": 15, "location": "hayama", "experiment": "cleaning with chlorine"})
|
|
95
|
+
p.info(entries, position="bottom") # "right" (default) or "bottom"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The table is a card on the right of the plot, or below it. A plot narrower than 560 px puts it below. `SPEC.md` §7 has the rules.
|
|
99
|
+
|
|
100
|
+
### Notebook
|
|
101
|
+
|
|
102
|
+
- A plot shows at the width of the cell. The text keeps its size, and the height follows the aspect ratio of the format.
|
|
103
|
+
- `.interactive()` adds zoom, pan, hover and the legend toggle.
|
|
104
|
+
- The hover follows the plot type:
|
|
105
|
+
- lines and areas: every series at the nearest x, or only the series under the pointer when there are more than 6;
|
|
106
|
+
- bars: the stack and its total;
|
|
107
|
+
- box: the quartiles;
|
|
108
|
+
- tile: the cell value;
|
|
109
|
+
- points: the nearest point.
|
|
110
|
+
- 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.
|
|
111
|
+
- A legend click hides or shows a series. Two quick clicks on the same entry (within 300 ms) show only that series.
|
|
112
|
+
- The toolbar shows at the top right when the pointer is on the plot:
|
|
113
|
+
- copy as PNG;
|
|
114
|
+
- download as PNG or SVG;
|
|
115
|
+
- in interactive mode, also zoom box, pan, zoom in, zoom out and reset.
|
|
116
|
+
- 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.
|
|
117
|
+
- 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.
|
|
118
|
+
|
|
119
|
+
### Web pages and React
|
|
120
|
+
|
|
121
|
+
- `draw(spec, element, { interactive })` draws into an element at its width, and draws again when the width changes. It returns `{ destroy }`.
|
|
122
|
+
- In React, use `import { Plot } from "plotto/react"`, then `<Plot spec={plot(rows, {...}).line()} interactive />`.
|
|
123
|
+
|
|
124
|
+
### Slides and reports
|
|
125
|
+
|
|
126
|
+
| Target | Use |
|
|
127
|
+
|---|---|
|
|
128
|
+
| Google Slides | PNG: `p.save("fig.png")`. Google Slides does not accept SVG. |
|
|
129
|
+
| PowerPoint, Keynote, documents | SVG: `p.save("fig.svg")`. The SVG uses the installed fonts. |
|
|
130
|
+
| A slide layout | `p.theme(style, format="slide")`: 1280 × 720 px with large text |
|
|
131
|
+
| A report column | `p.theme(style, format="report")`: 640 × 400 px |
|
|
132
|
+
| A given size | `p.save("fig.png", width=1600)` lays the plot out again at 1600 px. |
|
|
133
|
+
| From the notebook | Use the copy button, which copies the current view as PNG. |
|
|
134
|
+
|
|
135
|
+
## Examples
|
|
136
|
+
|
|
137
|
+
| File | Content |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `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. |
|
|
140
|
+
| `examples/grammar.py` | The grammar tutorial: 14 steps, each with its text, code and result. |
|
|
141
|
+
| `examples/reference.py` | The API reference. The layer methods come from `grammar.json`. |
|
|
142
|
+
| `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/`. |
|
|
143
|
+
| `examples/showcase.ipynb` | The examples in a notebook. |
|
|
144
|
+
| `examples/web/` | Examples in JavaScript. Run `python3 -m http.server` in the repo, then open `http://localhost:8000/examples/web/`. |
|
|
145
|
+
|
|
146
|
+
To publish the gallery:
|
|
147
|
+
|
|
148
|
+
1. Run `python examples/build_site.py`.
|
|
149
|
+
2. Commit `docs/`.
|
|
150
|
+
3. On GitHub, set Settings → Pages → Source to "Deploy from a branch", with branch `main` and folder `/docs`.
|
|
151
|
+
| `examples/data/` | The datasets. `examples/make_datasets.py` makes the made-up ones. |
|
|
152
|
+
|
|
153
|
+
## Tests
|
|
154
|
+
|
|
155
|
+
| Command | What it verifies |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `npm test` | Each layer and style (SVG snapshots in `tests/snapshots/`), stacks, bins, smooth fits, domains, facets, legends, reflow, errors |
|
|
158
|
+
| `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. |
|
|
159
|
+
| `pytest python/tests` | The Python and JS builders make the same spec; data conversion; SVG and PNG files; notebook output |
|
|
160
|
+
| `ruff check . && black --check .` | Python lint |
|
|
161
|
+
|
|
162
|
+
If a change of the look is intentional, run `UPDATE_SNAPSHOTS=1 npm test` and review the SVG diff.
|
|
163
|
+
|
|
164
|
+
## Maintenance
|
|
165
|
+
|
|
166
|
+
To add a layer type:
|
|
167
|
+
|
|
168
|
+
1. Add the layer to `grammar.json`: channels, options and required channels.
|
|
169
|
+
2. Add one entry to `LAYERS` in `src/layers.js`.
|
|
170
|
+
3. For a statistic, add the computation to `src/stats.js` and a case to `makeView` and `hoverOf` in `src/views.js`.
|
|
171
|
+
4. Add the method to `src/builder.js` and `python/plotto/plot.py`.
|
|
172
|
+
5. Add a case to `tests/render.test.js` and to `tests/fixtures/parity.json`.
|
|
173
|
+
6. Add a row to the layer table in `SPEC.md`.
|
|
174
|
+
|
|
175
|
+
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.
|
|
176
|
+
|
|
177
|
+
## Release
|
|
178
|
+
|
|
179
|
+
A tag `vX.Y.Z` publishes `python-plotto` to PyPI and `plotto` to npm (`.github/workflows/release.yml`).
|
|
180
|
+
|
|
181
|
+
One-time setup:
|
|
182
|
+
|
|
183
|
+
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`.
|
|
184
|
+
2. On GitHub, create the environment `pypi` (Settings → Environments).
|
|
185
|
+
3. On npmjs.com, create an account (with two-factor authentication). Publish the first version from your computer, in the repo:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
npm login
|
|
189
|
+
npm test
|
|
190
|
+
npm publish --access public
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
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.
|
|
194
|
+
|
|
195
|
+
Each release:
|
|
196
|
+
|
|
197
|
+
`main` takes changes only through a reviewed pull request, so a release has two steps.
|
|
198
|
+
|
|
199
|
+
1. On `main`, with no uncommitted changes and the Python environment of the repo active, prepare the release:
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
scripts/release.sh 0.3.0
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The script:
|
|
206
|
+
- checks that `main` is up to date with `origin/main` and that the version is new and higher;
|
|
207
|
+
- runs `npm test`, `npm run browser-test`, `pytest python/tests`, `ruff` and `black`;
|
|
208
|
+
- on a new branch `release/v0.3.0`, sets the version in `package.json`, `package-lock.json` and `python/plotto/__init__.py`, and builds the site in `docs/`;
|
|
209
|
+
- commits "release 0.3.0", pushes the branch and opens the pull request page.
|
|
210
|
+
2. Create the pull request, then have it reviewed and merged.
|
|
211
|
+
3. Tag the merged `main`:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
scripts/release.sh tag
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The script pulls `main` and reads the version. It asks before it tags and pushes `v0.3.0`, then deletes the release branch.
|
|
218
|
+
4. The pushed tag starts the release in the Actions tab. The packages are then at pypi.org/project/python-plotto and npmjs.com/package/plotto.
|
|
219
|
+
|
|
220
|
+
To upgrade Observable Plot or d3:
|
|
221
|
+
|
|
222
|
+
1. Change the exact version in `package.json`.
|
|
223
|
+
2. Run `npm install`.
|
|
224
|
+
3. Run the tests. A snapshot difference shows a change of the look.
|
package/grammar.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
"info": ["position"],
|
|
52
|
+
"infoPosition": ["right", "bottom"]
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plotto",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.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
package/src/builder.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
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: {}, info: null, 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
|
+
// A key/value table about the data or the experiment: info({ location: "hayama" }, { position: "right" }). null removes it.
|
|
110
|
+
info(entries, options = {}) {
|
|
111
|
+
checkKeys(options, GRAMMAR.info, "info()");
|
|
112
|
+
const position = options.position ?? "right";
|
|
113
|
+
if (!GRAMMAR.infoPosition.includes(position)) fail("info()", "position", position, GRAMMAR.infoPosition);
|
|
114
|
+
if (entries === null || entries === undefined) return this.#with({ info: null });
|
|
115
|
+
const pairs = Array.isArray(entries) ? entries : typeof entries === "object" ? Object.entries(entries) : null;
|
|
116
|
+
if (!pairs) throw new Error("info(): entries must be an object or a list of [key, value] pairs");
|
|
117
|
+
return this.#with({ info: { entries: pairs.map((pair) => infoEntry(pair)), position } });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
theme(style = "pastel", options = {}) {
|
|
121
|
+
checkKeys(options, GRAMMAR.theme, "theme()");
|
|
122
|
+
if (!(style in THEMES.styles)) fail("theme()", "style", style, Object.keys(THEMES.styles));
|
|
123
|
+
if (options.format !== undefined && !(options.format in THEMES.formats)) fail("theme()", "format", options.format, Object.keys(THEMES.formats));
|
|
124
|
+
if (options.legend !== undefined && !GRAMMAR.legend.includes(options.legend)) fail("theme()", "legend", options.legend, GRAMMAR.legend);
|
|
125
|
+
return this.#with({ theme: { style, ...options } });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The JSON spec. Each table keeps only the columns that a mapping or a facet uses.
|
|
129
|
+
spec() {
|
|
130
|
+
const s = this.#s;
|
|
131
|
+
const used = s.data.map(() => new Set());
|
|
132
|
+
for (const col of Object.values(s.mapping)) used.forEach((set, i) => col in s.data[i].columns && set.add(col));
|
|
133
|
+
for (const layer of s.layers) for (const col of Object.values(layer.mapping)) used[layer.data ?? 0].add(col);
|
|
134
|
+
for (const col of [s.facet?.wrap, s.facet?.rows, s.facet?.cols]) {
|
|
135
|
+
if (col) used.forEach((set, i) => col in s.data[i].columns && set.add(col));
|
|
136
|
+
}
|
|
137
|
+
const data = s.data.map((t, i) => ({ columns: Object.fromEntries([...used[i]].map((c) => [c, t.columns[c]])) }));
|
|
138
|
+
return { version: 1, data, mapping: s.mapping, layers: s.layers, facet: s.facet, scales: s.scales, labels: s.labels, ...(s.info ? { info: s.info } : {}), theme: s.theme };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Rows (array of objects) to columnar data with one type per column: number, time or string.
|
|
143
|
+
function toTable(rows, where) {
|
|
144
|
+
if (!Array.isArray(rows)) throw new Error(`${where}: data must be an array of row objects`);
|
|
145
|
+
const names = [...new Set(rows.flatMap(Object.keys))];
|
|
146
|
+
const columns = {};
|
|
147
|
+
for (const name of names) {
|
|
148
|
+
const raw = rows.map((r) => r[name] ?? null);
|
|
149
|
+
const first = raw.find((v) => v !== null && !Number.isNaN(v));
|
|
150
|
+
const type = first instanceof Date ? "time" : typeof first === "number" ? "number" : "string";
|
|
151
|
+
const values = raw.map((v) => (v === null || Number.isNaN(v) ? null : type === "string" ? String(v) : toValue(v)));
|
|
152
|
+
columns[name] = { type, values };
|
|
153
|
+
}
|
|
154
|
+
return { columns };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Dates become wall-clock epoch ms (the time that the clock shows); floats keep 6 significant digits.
|
|
158
|
+
function toValue(v) {
|
|
159
|
+
if (v instanceof Date) return v.getTime() - v.getTimezoneOffset() * 60000;
|
|
160
|
+
if (typeof v === "number" && !Number.isInteger(v)) return Number(v.toPrecision(6));
|
|
161
|
+
return v;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function constant(key, value, where, columns) {
|
|
165
|
+
const kind = GRAMMAR.options[key];
|
|
166
|
+
if (key === "tooltip" || key === "group") throw new Error(`${where}: ${key} must be a column. Columns: ${Object.keys(columns).join(", ")}`);
|
|
167
|
+
if (Array.isArray(kind) && !kind.includes(value)) fail(where, key, value, kind);
|
|
168
|
+
if (kind === "number" && typeof value !== "number") throw new Error(`${where}: ${key} must be a number`);
|
|
169
|
+
if (kind === "boolean" && typeof value !== "boolean") throw new Error(`${where}: ${key} must be true or false`);
|
|
170
|
+
if (key === "color" || key === "label") return value; // spec.js validates the color against the theme
|
|
171
|
+
if (typeof value === "string" && !kind) throw new Error(`${where}: "${value}" is not a column. Columns: ${Object.keys(columns).join(", ")}`);
|
|
172
|
+
return toValue(value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// One [key, value] entry of info(). Numbers keep 6 significant digits; the renderer writes the value as text.
|
|
176
|
+
function infoEntry(pair) {
|
|
177
|
+
if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string") throw new Error("info(): each entry must be a [key, value] pair with a string key");
|
|
178
|
+
const [key, value] = pair;
|
|
179
|
+
if (value === null || value === undefined || (typeof value === "number" && Number.isNaN(value))) return [key, null];
|
|
180
|
+
if (!["string", "number", "boolean"].includes(typeof value)) throw new Error(`info(): the value of "${key}" must be a string, a number, a boolean or null. Format other values as a string`);
|
|
181
|
+
return [key, toValue(value)];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function needColumn(table, col, where) {
|
|
185
|
+
if (!(col in table.columns)) throw new Error(`${where}: "${col}" is not a column. Columns: ${Object.keys(table.columns).join(", ")}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function checkKeys(options, valid, where) {
|
|
189
|
+
for (const key of Object.keys(options)) if (!valid.includes(key)) fail(where, "option", key, valid);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function checkFree(free, where) {
|
|
193
|
+
if (free != null && !GRAMMAR.free.includes(free)) fail(where, "free", free, GRAMMAR.free);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function fail(where, what, value, valid) {
|
|
197
|
+
throw new Error(`${where}: unknown ${what} "${value}". Valid: ${valid.join(", ")}`);
|
|
198
|
+
}
|
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
|
+
}
|