plotto 0.1.0 → 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/README.md +29 -5
- package/grammar.json +3 -1
- package/package.json +1 -1
- package/src/builder.js +22 -2
- package/src/figure.js +8 -4
- package/src/info.js +83 -0
- package/src/layout.js +16 -6
- package/src/spec.js +3 -2
package/README.md
CHANGED
|
@@ -86,6 +86,17 @@ draw(plot(rows, { x: "time", y: "value", color: "sensor" }).line().facet("indica
|
|
|
86
86
|
|
|
87
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
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
|
+
|
|
89
100
|
### Notebook
|
|
90
101
|
|
|
91
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.
|
|
@@ -183,15 +194,28 @@ One-time setup:
|
|
|
183
194
|
|
|
184
195
|
Each release:
|
|
185
196
|
|
|
186
|
-
|
|
187
|
-
|
|
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`:
|
|
188
212
|
|
|
189
213
|
```
|
|
190
|
-
|
|
191
|
-
git push origin v0.1.0
|
|
214
|
+
scripts/release.sh tag
|
|
192
215
|
```
|
|
193
216
|
|
|
194
|
-
|
|
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.
|
|
195
219
|
|
|
196
220
|
To upgrade Observable Plot or d3:
|
|
197
221
|
|
package/grammar.json
CHANGED
|
@@ -47,5 +47,7 @@
|
|
|
47
47
|
"free": ["x", "y", "both"],
|
|
48
48
|
"labels": ["title", "subtitle", "x", "y", "color", "caption"],
|
|
49
49
|
"theme": ["format", "width", "height", "size", "legend"],
|
|
50
|
-
"legend": ["right", "bottom", "none"]
|
|
50
|
+
"legend": ["right", "bottom", "none"],
|
|
51
|
+
"info": ["position"],
|
|
52
|
+
"infoPosition": ["right", "bottom"]
|
|
51
53
|
}
|
package/package.json
CHANGED
package/src/builder.js
CHANGED
|
@@ -13,7 +13,7 @@ export function plot(rows, mapping = {}) {
|
|
|
13
13
|
if (!PLOT_CHANNELS.includes(ch)) fail("plot()", "channel", ch, PLOT_CHANNELS);
|
|
14
14
|
needColumn(data[0], col, "plot()");
|
|
15
15
|
}
|
|
16
|
-
return new Builder({ data, mapping, layers: [], facet: null, scales: {}, labels: {}, theme: { style: "pastel" } });
|
|
16
|
+
return new Builder({ data, mapping, layers: [], facet: null, scales: {}, labels: {}, info: null, theme: { style: "pastel" } });
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
class Builder {
|
|
@@ -106,6 +106,17 @@ class Builder {
|
|
|
106
106
|
return this.#with({ labels: { ...this.#s.labels, ...options } });
|
|
107
107
|
}
|
|
108
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
|
+
|
|
109
120
|
theme(style = "pastel", options = {}) {
|
|
110
121
|
checkKeys(options, GRAMMAR.theme, "theme()");
|
|
111
122
|
if (!(style in THEMES.styles)) fail("theme()", "style", style, Object.keys(THEMES.styles));
|
|
@@ -124,7 +135,7 @@ class Builder {
|
|
|
124
135
|
if (col) used.forEach((set, i) => col in s.data[i].columns && set.add(col));
|
|
125
136
|
}
|
|
126
137
|
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 };
|
|
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 };
|
|
128
139
|
}
|
|
129
140
|
}
|
|
130
141
|
|
|
@@ -161,6 +172,15 @@ function constant(key, value, where, columns) {
|
|
|
161
172
|
return toValue(value);
|
|
162
173
|
}
|
|
163
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
|
+
|
|
164
184
|
function needColumn(table, col, where) {
|
|
165
185
|
if (!(col in table.columns)) throw new Error(`${where}: "${col}" is not a column. Columns: ${Object.keys(table.columns).join(", ")}`);
|
|
166
186
|
}
|
package/src/figure.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Figure: computes the views, scales and layout, then draws the title, strips, legend and panels as one SVG.
|
|
1
|
+
// Figure: computes the views, scales and layout, then draws the title, info table, strips, legend and panels as one SVG.
|
|
2
2
|
// Observable Plot draws each panel. drawPanels() draws them again after a zoom or a legend click.
|
|
3
3
|
import * as Plot from "@observablehq/plot";
|
|
4
4
|
import * as d3 from "d3";
|
|
5
5
|
import { facetPanels } from "./facets.js";
|
|
6
|
+
import { drawInfo, infoModel } from "./info.js";
|
|
6
7
|
import { LAYERS } from "./layers.js";
|
|
7
8
|
import { drawLegend, legendModel } from "./legend.js";
|
|
8
9
|
import { place, TICK } from "./layout.js";
|
|
@@ -31,8 +32,10 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
|
|
|
31
32
|
for (const p of grid.panels) p.views = ns.layers.map((layer, li) => makeView(ctx, layer, p.rows[li], new Set()));
|
|
32
33
|
const home = homeDomains(ns, grid, kind);
|
|
33
34
|
const horizontal = theme.legend === "bottom";
|
|
34
|
-
const
|
|
35
|
-
const
|
|
35
|
+
const info = infoModel(ns, fonts, { W, H, narrow: W < NARROW });
|
|
36
|
+
const above = info?.position === "right" ? info.height + fonts.base : 0; // the info card sits above a right legend
|
|
37
|
+
const legend = legendModel(ns, color, fonts, { horizontal, maxWidth: W * 0.9, maxHeight: H * 0.8 - above });
|
|
38
|
+
const layout = place({ ns, grid, kind, home, legend, info, fonts, W, H });
|
|
36
39
|
|
|
37
40
|
const svg = svgElement(doc, "svg", {
|
|
38
41
|
xmlns: "http://www.w3.org/2000/svg",
|
|
@@ -45,8 +48,9 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
|
|
|
45
48
|
});
|
|
46
49
|
svg.append(svgElement(doc, "rect", { width: W, height: H, fill: theme.background }));
|
|
47
50
|
for (const t of layout.texts) svg.append(svgText(doc, t.text, t.attrs));
|
|
51
|
+
if (info) svg.append(drawInfo(doc, info, theme, layout.info));
|
|
48
52
|
for (const s of layout.strips) svg.append(strip(doc, s, theme, fonts));
|
|
49
|
-
const state = { spec, ns, doc, svg, theme, fonts, grid, kind, color, ctx, legend, layout, home, W, H };
|
|
53
|
+
const state = { spec, ns, doc, svg, theme, fonts, grid, kind, color, ctx, legend, info, layout, home, W, H };
|
|
50
54
|
state.domains = domains ?? home;
|
|
51
55
|
state.hidden = hidden;
|
|
52
56
|
state.panelLayer = svg.appendChild(svgElement(doc, "g", { class: "pt-panels" }));
|
package/src/info.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Info table: the key/value entries of info(), as a card in the right column or below the plot.
|
|
2
|
+
import * as d3 from "d3";
|
|
3
|
+
import { svgElement, svgText } from "./svg.js";
|
|
4
|
+
import { fitText, textWidth, wrapText } from "./text.js";
|
|
5
|
+
|
|
6
|
+
const VALUE_LINES = 3; // a long value wraps to this many lines
|
|
7
|
+
const KEY_SHARE = 0.45; // the key column takes at most this share of the table width
|
|
8
|
+
const MAX_WIDTH = { right: 0.3, bottom: 0.9 }; // share of the figure width
|
|
9
|
+
const MAX_HEIGHT = { right: 0.6, bottom: 0.3 }; // share of the figure height; the rest of the entries become "+N more"
|
|
10
|
+
const MAX_COLUMNS = 3; // columns of entries in a bottom card
|
|
11
|
+
|
|
12
|
+
// The info model with its position and size. narrow: a right placement moves to the bottom.
|
|
13
|
+
export function infoModel(ns, fonts, { W, H, narrow }) {
|
|
14
|
+
if (!ns.info) return null;
|
|
15
|
+
const position = narrow ? "bottom" : ns.info.position;
|
|
16
|
+
return model(ns, fonts, ns.info.entries, position, W, H * MAX_HEIGHT[position]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function model(ns, fonts, entries, position, W, maxHeight) {
|
|
20
|
+
const metrics = ns.theme.metrics;
|
|
21
|
+
const size = fonts.legend;
|
|
22
|
+
const pad = Math.round(size * 0.8);
|
|
23
|
+
const lineH = size * 1.35;
|
|
24
|
+
const keyGap = size;
|
|
25
|
+
const colGap = size * 2;
|
|
26
|
+
const all = entries.map(([key, value]) => ({ key: String(key), value: value === null ? "–" : String(value) }));
|
|
27
|
+
const width = (t) => textWidth(t, size, false, metrics);
|
|
28
|
+
|
|
29
|
+
// One column of rows, at most maxWidth wide: the keys on the left, the values wrapped on the right.
|
|
30
|
+
const column = (items, maxWidth) => {
|
|
31
|
+
const keyW = Math.min(d3.max(items, (r) => width(r.key)) ?? 0, maxWidth * KEY_SHARE);
|
|
32
|
+
const valueMax = Math.max(size * 3, maxWidth - keyW - keyGap);
|
|
33
|
+
let y = 0;
|
|
34
|
+
const laid = items.map((r) => {
|
|
35
|
+
const lines = wrapText(r.value, size, valueMax, false, metrics, VALUE_LINES);
|
|
36
|
+
const row = { key: fitText(r.key, size, keyW, false, metrics), full: r.key, lines, y };
|
|
37
|
+
y += lines.length * lineH;
|
|
38
|
+
return row;
|
|
39
|
+
});
|
|
40
|
+
const valueW = d3.max(laid, (r) => d3.max(r.lines, width)) ?? 0;
|
|
41
|
+
return { rows: laid, keyW, width: keyW + keyGap + valueW, height: y };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const inner = W * MAX_WIDTH[position] - 2 * pad;
|
|
45
|
+
const table = (rows) => {
|
|
46
|
+
let columns;
|
|
47
|
+
if (position === "bottom") {
|
|
48
|
+
const natural = d3.max(rows, (r) => width(r.key) + keyGap + width(r.value)) ?? 0;
|
|
49
|
+
const k = Math.max(1, Math.min(MAX_COLUMNS, rows.length, Math.floor((inner + colGap) / (natural + colGap))));
|
|
50
|
+
const perColumn = Math.ceil(rows.length / k);
|
|
51
|
+
const colW = (inner - colGap * (k - 1)) / k;
|
|
52
|
+
columns = d3.range(k).map((j) => column(rows.slice(j * perColumn, (j + 1) * perColumn), colW));
|
|
53
|
+
} else columns = [column(rows, inner)];
|
|
54
|
+
let x = pad;
|
|
55
|
+
for (const c of columns) (c.x = x), (x += c.width + colGap);
|
|
56
|
+
return { columns, width: x - colGap + pad, height: (d3.max(columns, (c) => c.height) ?? 0) + 2 * pad };
|
|
57
|
+
};
|
|
58
|
+
// Drop entries from the end until the table fits; the last row tells how many are not shown.
|
|
59
|
+
let kept = all.length;
|
|
60
|
+
let t = table(all);
|
|
61
|
+
while (t.height > maxHeight && kept > 1) {
|
|
62
|
+
kept--;
|
|
63
|
+
t = table([...all.slice(0, kept), { key: `+${all.length - kept} more`, value: "" }]);
|
|
64
|
+
}
|
|
65
|
+
return { position, size, pad, lineH, keyGap, ...t };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The table as an SVG group at (x, y): keys in the muted color, values in the ink color, on a strip-colored card.
|
|
69
|
+
export function drawInfo(doc, model, theme, { x, y }) {
|
|
70
|
+
const g = svgElement(doc, "g", { class: "pt-info", transform: `translate(${x},${y})`, "font-size": model.size });
|
|
71
|
+
g.append(svgElement(doc, "rect", { width: model.width, height: model.height, fill: theme.strip }));
|
|
72
|
+
const baseline = model.lineH * 0.75;
|
|
73
|
+
for (const c of model.columns) {
|
|
74
|
+
for (const r of c.rows) {
|
|
75
|
+
const top = model.pad + r.y + baseline;
|
|
76
|
+
const key = svgText(doc, r.key, { x: c.x, y: top, fill: theme.muted, "text-anchor": "start" });
|
|
77
|
+
if (r.key !== r.full) key.append(svgText(doc, r.full, {}, "title"));
|
|
78
|
+
g.append(key);
|
|
79
|
+
r.lines.forEach((line, i) => g.append(svgText(doc, line, { x: c.x + c.keyW + model.keyGap, y: top + i * model.lineH, fill: theme.ink, "text-anchor": "start" })));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return g;
|
|
83
|
+
}
|
package/src/layout.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Layout: the position and size of the title, panels, strips, axis titles, caption and legend, in px of the figure.
|
|
1
|
+
// Layout: the position and size of the title, info table, panels, strips, axis titles, caption and legend, in px of the figure.
|
|
2
2
|
// Text sizes come from text.js, so Node and the browser get the same layout.
|
|
3
3
|
import * as d3 from "d3";
|
|
4
4
|
import { ticks } from "./scales.js";
|
|
@@ -8,7 +8,7 @@ export const TICK = { size: 4, padding: 3 };
|
|
|
8
8
|
const ROTATE = 40; // degrees of rotated category labels
|
|
9
9
|
const TOOLBAR = 0.22; // share of the width kept free of the title, for the toolbar
|
|
10
10
|
|
|
11
|
-
export function place({ ns, grid, kind, home, legend, fonts, W, H }) {
|
|
11
|
+
export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
|
|
12
12
|
const theme = ns.theme;
|
|
13
13
|
const metrics = theme.metrics;
|
|
14
14
|
const tw = (t, size, bold) => textWidth(t, size, bold, metrics);
|
|
@@ -16,6 +16,7 @@ export function place({ ns, grid, kind, home, legend, fonts, W, H }) {
|
|
|
16
16
|
const pad = Math.round(s * 0.9);
|
|
17
17
|
const gap = Math.round(s * 0.8);
|
|
18
18
|
const { title, subtitle, caption, x: xTitle, y: yTitle } = ns.labels;
|
|
19
|
+
const at = (position) => (info?.position === position ? info : null);
|
|
19
20
|
const headW = W - 2 * pad - Math.min(180, W * TOOLBAR);
|
|
20
21
|
const titleLines = title ? wrapText(title, fonts.title, headW, true, metrics) : [];
|
|
21
22
|
const subtitleLines = subtitle ? wrapText(subtitle, fonts.subtitle, headW, false, metrics) : [];
|
|
@@ -24,9 +25,11 @@ export function place({ ns, grid, kind, home, legend, fonts, W, H }) {
|
|
|
24
25
|
const xt = xTitle ? fonts.axisTitle * 1.7 : 0;
|
|
25
26
|
const yt = yTitle ? fonts.axisTitle * 1.7 : 0;
|
|
26
27
|
const cap = caption ? fonts.tick * 1.8 : 0;
|
|
27
|
-
const
|
|
28
|
+
const rightW = Math.max(legend && !legend.horizontal ? legend.width : 0, at("right")?.width ?? 0);
|
|
29
|
+
const right = rightW ? rightW + s * 1.5 : 0;
|
|
28
30
|
const bottom = legend?.horizontal ? legend.height + s * 0.8 : 0;
|
|
29
|
-
const
|
|
31
|
+
const below = at("bottom") ? at("bottom").height + gap : 0;
|
|
32
|
+
const area = { x: pad + yt, y: top, w: W - 2 * pad - yt - right, h: H - top - pad - xt - bottom - cap - below };
|
|
30
33
|
|
|
31
34
|
const strip = grid.kind === "none" ? 0 : Math.round(fonts.strip * 2);
|
|
32
35
|
const wrapStrip = grid.kind === "wrap" ? strip : 0;
|
|
@@ -111,10 +114,17 @@ export function place({ ns, grid, kind, home, legend, fonts, W, H }) {
|
|
|
111
114
|
texts.push({ text: yTitle, attrs: { transform: `translate(${pad + fonts.axisTitle * 0.85},${cy}) rotate(-90)`, "text-anchor": "middle", "font-size": fonts.axisTitle, fill: theme.muted } });
|
|
112
115
|
}
|
|
113
116
|
if (caption) texts.push({ text: fitText(caption, fonts.tick, W - 2 * pad, false, metrics), attrs: { x: innerLeft, y: H - pad - (bottom ? bottom : 0) + fonts.tick * 0.2, "font-size": fonts.tick, fill: theme.muted, "text-anchor": "start" } });
|
|
117
|
+
const rightX = W - pad - rightW;
|
|
118
|
+
const infoAt = !info
|
|
119
|
+
? null
|
|
120
|
+
: info.position === "right"
|
|
121
|
+
? { x: rightX, y: area.y }
|
|
122
|
+
: { x: Math.max(pad, Math.min(innerLeft, W - pad - info.width)), y: panelsBottom + xt + gap / 2 };
|
|
123
|
+
const above = at("right") ? info.height + s : 0;
|
|
114
124
|
const legendAt = !legend
|
|
115
125
|
? null
|
|
116
126
|
: legend.horizontal
|
|
117
127
|
? { x: Math.max(pad, (innerLeft + innerRight - legend.width) / 2), y: H - pad - legend.height }
|
|
118
|
-
: { x:
|
|
119
|
-
return { panels, strips, texts, legend: legendAt, area, rotateX };
|
|
128
|
+
: { x: rightX, y: area.y + above + Math.max(0, (area.h - d3.sum(mb) - above - legend.height) / 2) };
|
|
129
|
+
return { panels, strips, texts, legend: legendAt, info: infoAt, area, rotateX };
|
|
120
130
|
}
|
package/src/spec.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Validates a spec and adds the defaults. This is the only file that knows the defaults.
|
|
2
|
-
// Output: tables, layers with resolved channels ({column} or {value}), facet, scales, labels and theme.
|
|
2
|
+
// Output: tables, layers with resolved channels ({column} or {value}), facet, scales, labels, info and theme.
|
|
3
3
|
import GRAMMAR from "../grammar.json" with { type: "json" };
|
|
4
4
|
import { colorOf, colorTokens, resolveTheme } from "./theme.js";
|
|
5
5
|
|
|
@@ -12,7 +12,8 @@ export function normalize(spec, overrides = {}) {
|
|
|
12
12
|
if (!layers.length) throw new Error("plotto: the plot has no layers. Add one, for example .line() or .point()");
|
|
13
13
|
const facet = resolveFacet(spec.facet);
|
|
14
14
|
const labels = { ...defaultLabels(layers), ...spec.labels };
|
|
15
|
-
|
|
15
|
+
const info = spec.info?.entries?.length ? { entries: spec.info.entries, position: spec.info.position ?? "right" } : null;
|
|
16
|
+
return { tables, layers, facet, scales: spec.scales ?? {}, labels, info, theme };
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
function toTable({ columns }) {
|