plotto 0.4.0 → 0.5.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 +3 -2
- package/package.json +1 -1
- package/src/builder.js +3 -3
- package/src/figure.js +39 -12
- package/src/info.js +12 -8
- package/src/layout.js +17 -6
- package/src/spec.js +2 -1
- package/src/theme.js +1 -0
- package/theme.json +1 -0
package/README.md
CHANGED
|
@@ -197,7 +197,8 @@ One-time setup:
|
|
|
197
197
|
Each release (`main` takes changes only through a reviewed pull request):
|
|
198
198
|
|
|
199
199
|
```bash
|
|
200
|
-
# 1. Merge your changes into main through a pull request
|
|
200
|
+
# 1. Merge your changes into main through a pull request,
|
|
201
|
+
# with a line for each change under "## Unreleased" in CHANGELOG.md
|
|
201
202
|
|
|
202
203
|
# 2. Prepare the release
|
|
203
204
|
git switch main && git pull && source .venv/bin/activate
|
|
@@ -211,7 +212,7 @@ scripts/release.sh tag # answer y
|
|
|
211
212
|
npm view plotto version && pip index versions python-plotto
|
|
212
213
|
```
|
|
213
214
|
|
|
214
|
-
- `scripts/release.sh X.Y.Z` checks that `main` is current
|
|
215
|
+
- `scripts/release.sh X.Y.Z` checks that `main` is current, that the version is new and that `CHANGELOG.md` has changes under "Unreleased". It runs every test and lint. Then, on the branch `release/vX.Y.Z`, it sets the version in `package.json`, `package-lock.json` and `python/plotto/__init__.py`, moves the "Unreleased" changes to a dated `X.Y.Z` section, builds `docs/`, commits and pushes.
|
|
215
216
|
- `scripts/release.sh tag` tags the merged `main` as `vX.Y.Z`, asks before it pushes the tag, and deletes the release branch.
|
|
216
217
|
- The pushed tag runs `.github/workflows/release.yml`, which publishes to PyPI and npm. Follow it in the Actions tab.
|
|
217
218
|
|
package/package.json
CHANGED
package/src/builder.js
CHANGED
|
@@ -109,12 +109,12 @@ class Builder {
|
|
|
109
109
|
// A key/value table about the data or the experiment: info({ location: "hayama" }, { position: "right" }). null removes it.
|
|
110
110
|
info(entries, options = {}) {
|
|
111
111
|
checkKeys(options, GRAMMAR.info, "info()");
|
|
112
|
-
const position = options
|
|
113
|
-
if (!GRAMMAR.infoPosition.includes(position)) fail("info()", "position", position, GRAMMAR.infoPosition);
|
|
112
|
+
const { position } = options;
|
|
113
|
+
if (position !== undefined && !GRAMMAR.infoPosition.includes(position)) fail("info()", "position", position, GRAMMAR.infoPosition);
|
|
114
114
|
if (entries === null || entries === undefined) return this.#with({ info: null });
|
|
115
115
|
const pairs = Array.isArray(entries) ? entries : typeof entries === "object" ? Object.entries(entries) : null;
|
|
116
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 } });
|
|
117
|
+
return this.#with({ info: { entries: pairs.map((pair) => infoEntry(pair)), ...(position ? { position } : {}) } });
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
theme(style = "pastel", options = {}) {
|
package/src/figure.js
CHANGED
|
@@ -16,6 +16,8 @@ import { layerEdges, makeView } from "./views.js";
|
|
|
16
16
|
const NARROW = 560; // px: below this width, a right legend moves to the bottom
|
|
17
17
|
const MIN_HEIGHT = 240; // px: the smallest height of a reflowed figure
|
|
18
18
|
const ASPECT_GROWTH = 1.2; // a theme aspect makes a reflowed figure at most this much taller
|
|
19
|
+
const SHRINK_STEP = 0.05; // a format with "shrink" tries the legend and info text smaller by this share at each step
|
|
20
|
+
const ROOM_TOLERANCE = 0.1; // it keeps the largest text whose panel area is at most this share below the best layout
|
|
19
21
|
|
|
20
22
|
// options: document (the browser or jsdom), width (lay out at this width), domains and hidden (keep a view after a resize).
|
|
21
23
|
export function figure(spec, { document: doc = globalThis.document, width, domains, hidden = new Set() } = {}) {
|
|
@@ -25,27 +27,52 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
|
|
|
25
27
|
const reflowed = width && theme.reflow && !theme.fixedHeight;
|
|
26
28
|
let H = reflowed ? Math.max(MIN_HEIGHT, Math.round((W * theme.height) / theme.width)) : theme.height;
|
|
27
29
|
if (theme.legend === "right" && W < NARROW) theme.legend = "bottom";
|
|
28
|
-
const fonts = fontSizes(theme);
|
|
29
30
|
const grid = facetPanels(ns);
|
|
30
31
|
const kind = { x: axisKind(ns, "x"), y: axisKind(ns, "y") };
|
|
31
32
|
const color = colorScale(ns);
|
|
32
33
|
const ctx = { ns, kind, colorLevels: color.domain ?? [], edges: layerEdges(ns) };
|
|
33
34
|
for (const p of grid.panels) p.views = ns.layers.map((layer, li) => makeView(ctx, layer, p.rows[li], new Set()));
|
|
34
35
|
const home = homeDomains(ns, grid, kind);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
36
|
+
// One layout: the place of the legend and of the info card (right or bottom), and their text size (share k).
|
|
37
|
+
// cut counts what does not show well: legend labels and info keys with an ellipsis, a second legend column,
|
|
38
|
+
// the "+N more" entries of the info card.
|
|
39
|
+
const arrange = (H, where, k) => {
|
|
40
|
+
const base = fontSizes(theme);
|
|
41
|
+
const f = { ...base, legend: Math.round(base.legend * k * 10) / 10 };
|
|
42
|
+
const info = infoModel(ns, f, { W, H, narrow: W < NARROW, place: where.info });
|
|
43
|
+
const above = info?.position === "right" ? info.height + f.base : 0; // the info card sits above a right legend
|
|
44
|
+
const legend = legendModel(ns, color, f, { horizontal: where.legend === "bottom", maxWidth: W * 0.9, maxHeight: H * 0.8 - above });
|
|
45
|
+
const layout = place({ ns, grid, kind, home, legend, info, fonts: f, W, H });
|
|
46
|
+
const items = legend?.items ?? [];
|
|
47
|
+
const cut = items.filter((it) => it.lines.at(-1).endsWith("…")).length + (items.some((it) => !legend.horizontal && it.x > 0) ? 1 : 0) + (info?.cut ?? 0);
|
|
48
|
+
const p = layout.panels[0];
|
|
49
|
+
const usual = where.legend === theme.legend && where.info === ns.info?.position; // the places of the theme and spec
|
|
50
|
+
return { fonts: f, info, legend, layout, k, cut, usual, area: p.innerW * p.innerH };
|
|
41
51
|
};
|
|
42
|
-
|
|
52
|
+
// The layout to draw. A screen plot keeps the places of the theme and the text size. A fixed size (slide, report,
|
|
53
|
+
// a set height) also tries the legend and the info card on the right and at the bottom, unless the spec sets
|
|
54
|
+
// their place; a format with "shrink" (slide) also tries smaller text, down to that share. Of the layouts that cut
|
|
55
|
+
// the least and whose panel area is at most ROOM_TOLERANCE below the best, it keeps the largest text, then the
|
|
56
|
+
// usual places, then the largest area.
|
|
57
|
+
const choose = (H) => {
|
|
58
|
+
const fixed = !theme.fitHeight;
|
|
59
|
+
const legends = fixed && !theme.legendSet && theme.legend === "right" ? ["right", "bottom"] : [theme.legend];
|
|
60
|
+
const infos = fixed && ns.info && !ns.info.set ? ["right", "bottom"] : [ns.info?.position];
|
|
61
|
+
const scales = [];
|
|
62
|
+
for (let k = 1; k >= (theme.shrink ?? 1) - 1e-9; k -= SHRINK_STEP) scales.push(k);
|
|
63
|
+
const all = legends.flatMap((l) => infos.flatMap((i) => scales.map((k) => arrange(H, { legend: l, info: i }, k))));
|
|
64
|
+
const fewest = d3.min(all, (a) => a.cut);
|
|
65
|
+
const pool = all.filter((a) => a.cut === fewest);
|
|
66
|
+
const best = d3.max(pool, (a) => a.area);
|
|
67
|
+
return pool.filter((a) => a.area >= best * (1 - ROOM_TOLERANCE)).sort((a, b) => b.k - a.k || b.usual - a.usual || b.area - a.area)[0];
|
|
68
|
+
};
|
|
69
|
+
let { fonts, info, legend, layout } = choose(H);
|
|
43
70
|
// aspect: when the height follows the width, it changes so that the panels get their shape, up to 1.2 times the
|
|
44
71
|
// usual height; beyond that, the panels get narrower.
|
|
45
72
|
if (theme.aspect && theme.fitHeight) {
|
|
46
73
|
const fit = H + grid.nrow * (layout.room.w * theme.aspect - layout.room.h);
|
|
47
74
|
H = Math.round(Math.min(H * ASPECT_GROWTH, Math.max(MIN_HEIGHT, fit)));
|
|
48
|
-
({ info, legend, layout } =
|
|
75
|
+
({ fonts, info, legend, layout } = choose(H));
|
|
49
76
|
}
|
|
50
77
|
|
|
51
78
|
const svg = svgElement(doc, "svg", {
|
|
@@ -60,7 +87,7 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
|
|
|
60
87
|
svg.append(svgElement(doc, "rect", { width: W, height: H, fill: theme.background }));
|
|
61
88
|
for (const t of layout.texts) svg.append(svgText(doc, t.text, t.attrs));
|
|
62
89
|
if (info) svg.append(drawInfo(doc, info, theme, layout.info));
|
|
63
|
-
for (const s of layout.strips) svg.append(strip(doc, s, theme
|
|
90
|
+
for (const s of layout.strips) svg.append(strip(doc, s, theme));
|
|
64
91
|
const state = { spec, ns, doc, svg, theme, fonts, grid, kind, color, ctx, legend, info, layout, home, W, H };
|
|
65
92
|
state.domains = domains ?? home;
|
|
66
93
|
state.hidden = hidden;
|
|
@@ -199,13 +226,13 @@ function homeDomain(ns, axis, kind, panels) {
|
|
|
199
226
|
return limits ? [limits[0] ?? trained[0], limits[1] ?? trained[1]] : trained;
|
|
200
227
|
}
|
|
201
228
|
|
|
202
|
-
function strip(doc, s, theme
|
|
229
|
+
function strip(doc, s, theme) {
|
|
203
230
|
const g = svgElement(doc, "g", { class: "pt-strip" });
|
|
204
231
|
g.append(svgElement(doc, "rect", { x: s.x, y: s.y, width: s.w, height: s.h, fill: theme.strip }));
|
|
205
232
|
const attrs = s.vertical
|
|
206
233
|
? { transform: `translate(${s.x + s.w / 2},${s.y + s.h / 2}) rotate(90)`, dy: "0.35em" }
|
|
207
234
|
: { x: s.x + s.w / 2, y: s.y + s.h / 2, dy: "0.35em" };
|
|
208
|
-
const text = svgText(doc, s.text, { ...attrs, "text-anchor": "middle", "font-weight": "bold", "font-size":
|
|
235
|
+
const text = svgText(doc, s.text, { ...attrs, "text-anchor": "middle", "font-weight": "bold", "font-size": s.size, fill: theme.ink });
|
|
209
236
|
if (s.text !== String(s.full)) text.append(svgText(doc, s.full, {}, "title"));
|
|
210
237
|
g.append(text);
|
|
211
238
|
return g;
|
package/src/info.js
CHANGED
|
@@ -10,9 +10,9 @@ const MAX_HEIGHT = { right: 0.6, bottom: 0.3 }; // share of the figure height; t
|
|
|
10
10
|
const MAX_COLUMNS = 3; // columns of entries in a bottom card
|
|
11
11
|
|
|
12
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 }) {
|
|
13
|
+
export function infoModel(ns, fonts, { W, H, narrow, place = ns.info?.position }) {
|
|
14
14
|
if (!ns.info) return null;
|
|
15
|
-
const position = narrow ? "bottom" :
|
|
15
|
+
const position = narrow ? "bottom" : place;
|
|
16
16
|
return model(ns, fonts, ns.info.entries, position, W, H * MAX_HEIGHT[position]);
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -45,11 +45,13 @@ function model(ns, fonts, entries, position, W, maxHeight) {
|
|
|
45
45
|
const table = (rows) => {
|
|
46
46
|
let columns;
|
|
47
47
|
if (position === "bottom") {
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
// The most columns (up to 3) whose entries fit on one line each in the width; else one column that wraps.
|
|
49
|
+
const split = (k) => {
|
|
50
|
+
const perColumn = Math.ceil(rows.length / k);
|
|
51
|
+
return d3.range(k).map((j) => column(rows.slice(j * perColumn, (j + 1) * perColumn), Infinity));
|
|
52
|
+
};
|
|
53
|
+
const fit = d3.range(Math.min(MAX_COLUMNS, rows.length), 1, -1).map(split).find((c) => d3.sum(c, (col) => col.width) + colGap * (c.length - 1) <= inner);
|
|
54
|
+
columns = fit ?? [column(rows, inner)];
|
|
53
55
|
} else columns = [column(rows, inner)];
|
|
54
56
|
let x = pad;
|
|
55
57
|
for (const c of columns) (c.x = x), (x += c.width + colGap);
|
|
@@ -62,7 +64,9 @@ function model(ns, fonts, entries, position, W, maxHeight) {
|
|
|
62
64
|
kept--;
|
|
63
65
|
t = table([...all.slice(0, kept), { key: `+${all.length - kept} more`, value: "" }]);
|
|
64
66
|
}
|
|
65
|
-
|
|
67
|
+
// cut: the entries that do not show, plus the keys that end with an ellipsis.
|
|
68
|
+
const keys = t.columns.flatMap((c) => c.rows).filter((r) => r.key !== r.full).length;
|
|
69
|
+
return { position, size, pad, lineH, keyGap, cut: all.length - kept + keys, ...t };
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
// The table as an SVG group at (x, y): keys in the muted color, values in the ink color, on a strip-colored card.
|
package/src/layout.js
CHANGED
|
@@ -19,13 +19,20 @@ export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
|
|
|
19
19
|
const gap = Math.round(s * 0.8);
|
|
20
20
|
const { title, subtitle, caption, x: xTitle, y: yTitle } = ns.labels;
|
|
21
21
|
const at = (position) => (info?.position === position ? info : null);
|
|
22
|
-
const
|
|
22
|
+
const xt = xTitle ? fonts.axisTitle * 1.7 : 0;
|
|
23
|
+
const yt = yTitle ? fonts.axisTitle * 1.7 : 0;
|
|
24
|
+
// The title starts at the left edge of the panels. A screen plot keeps room on the right for the toolbar; a fixed
|
|
25
|
+
// format (copied as a picture) uses the full width, less the y axis title and tick labels on the left.
|
|
26
|
+
const yLabels = d3.max(grid.panels, (p, i) => {
|
|
27
|
+
const t = ticks(kind.y, home[i].y, 5, ns.scales.y?.format);
|
|
28
|
+
return d3.max(t.major, (v) => tw(t.format(v), fonts.tick));
|
|
29
|
+
});
|
|
30
|
+
const left = yt + (yLabels ?? 0) + TICK.size + TICK.padding + 3;
|
|
31
|
+
const headW = W - 2 * pad - (theme.fixedFormat ? left : Math.min(180, W * TOOLBAR));
|
|
23
32
|
const titleLines = title ? wrapText(title, fonts.title, headW, true, metrics) : [];
|
|
24
33
|
const subtitleLines = subtitle ? wrapText(subtitle, fonts.subtitle, headW, false, metrics) : [];
|
|
25
34
|
const head = titleLines.length * fonts.title * 1.25 + subtitleLines.length * fonts.subtitle * 1.4;
|
|
26
35
|
const top = pad + head + (head ? s * 0.7 : 0);
|
|
27
|
-
const xt = xTitle ? fonts.axisTitle * 1.7 : 0;
|
|
28
|
-
const yt = yTitle ? fonts.axisTitle * 1.7 : 0;
|
|
29
36
|
const cap = caption ? fonts.tick * 1.8 : 0;
|
|
30
37
|
const rightW = Math.max(legend && !legend.horizontal ? legend.width : 0, at("right")?.width ?? 0);
|
|
31
38
|
const right = rightW ? rightW + s * 1.5 : 0;
|
|
@@ -138,13 +145,17 @@ export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
|
|
|
138
145
|
showY: showY(p),
|
|
139
146
|
}));
|
|
140
147
|
|
|
148
|
+
// A format with "shrink" makes the strip text smaller (all strips the same) before it cuts a label.
|
|
149
|
+
const stripRoom = (p) => [p.strips.top != null && [p.strips.top, innerW], p.strips.right != null && [p.strips.right, innerH]].filter(Boolean);
|
|
150
|
+
const stripScale = Math.max(theme.shrink ?? 1, Math.min(1, d3.min(grid.panels.flatMap(stripRoom), ([t, w]) => (w - 8) / tw(t, fonts.strip, true)) ?? 1));
|
|
151
|
+
const stripSize = Math.round(fonts.strip * stripScale * 10) / 10;
|
|
141
152
|
const strips = [];
|
|
142
153
|
grid.panels.forEach((p, i) => {
|
|
143
154
|
const b = panels[i];
|
|
144
155
|
const left = b.x + b.ml;
|
|
145
|
-
const label = (text, width) => fitText(text,
|
|
146
|
-
if (p.strips.top != null) strips.push({ x: left, y: b.y - wrapStrip - topStrip, w: innerW, h: strip, text: label(p.strips.top, innerW), full: p.strips.top });
|
|
147
|
-
if (p.strips.right != null) strips.push({ x: left + innerW + 2, y: b.y, w: strip, h: innerH, text: label(p.strips.right, innerH), full: p.strips.right, vertical: true });
|
|
156
|
+
const label = (text, width) => fitText(text, stripSize, width - 8, true, metrics);
|
|
157
|
+
if (p.strips.top != null) strips.push({ x: left, y: b.y - wrapStrip - topStrip, w: innerW, h: strip, text: label(p.strips.top, innerW), full: p.strips.top, size: stripSize });
|
|
158
|
+
if (p.strips.right != null) strips.push({ x: left + innerW + 2, y: b.y, w: strip, h: innerH, text: label(p.strips.right, innerH), full: p.strips.right, vertical: true, size: stripSize });
|
|
148
159
|
});
|
|
149
160
|
|
|
150
161
|
const first = panels[0];
|
package/src/spec.js
CHANGED
|
@@ -7,12 +7,13 @@ export function normalize(spec, overrides = {}) {
|
|
|
7
7
|
if (typeof spec?.spec === "function") spec = spec.spec(); // a builder
|
|
8
8
|
if (spec?.version !== 1) throw new Error("plotto: unknown spec version");
|
|
9
9
|
const theme = resolveTheme({ ...spec.theme, ...overrides });
|
|
10
|
+
theme.legendSet = (overrides.legend ?? spec.theme?.legend) != null; // a legend place from the spec, not the default
|
|
10
11
|
const tables = spec.data.map(toTable);
|
|
11
12
|
const layers = spec.layers.map((l) => resolveLayer(l, spec.mapping ?? {}, tables, theme));
|
|
12
13
|
if (!layers.length) throw new Error("plotto: the plot has no layers. Add one, for example .line() or .point()");
|
|
13
14
|
const facet = resolveFacet(spec.facet);
|
|
14
15
|
const labels = { ...defaultLabels(layers), ...spec.labels };
|
|
15
|
-
const info = spec.info?.entries?.length ? { entries: spec.info.entries, position: spec.info.position ?? "right" } : null;
|
|
16
|
+
const info = spec.info?.entries?.length ? { entries: spec.info.entries, position: spec.info.position ?? "right", set: !!spec.info.position } : null;
|
|
16
17
|
return { tables, layers, facet, scales: spec.scales ?? {}, labels, info, theme };
|
|
17
18
|
}
|
|
18
19
|
|
package/src/theme.js
CHANGED
|
@@ -27,6 +27,7 @@ export function resolveTheme({ style = "pastel", format = "screen", legend = "ri
|
|
|
27
27
|
if (overrides.width != null && overrides.height == null) theme.height = Math.round((overrides.width * f.height) / f.width);
|
|
28
28
|
theme.fixedHeight = overrides.height != null;
|
|
29
29
|
theme.fitHeight = f.reflow && !theme.fixedHeight; // a screen plot: its height may follow the panel aspect
|
|
30
|
+
theme.fixedFormat = !f.reflow; // report, slide: a picture of a fixed size, often copied without its toolbar
|
|
30
31
|
theme.palette = palette(theme, theme.palette);
|
|
31
32
|
return theme;
|
|
32
33
|
}
|