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 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 and the version is new. 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`, builds `docs/`, commits and pushes.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotto",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Grammar-of-graphics plots for notebooks, web pages and slides with one renderer.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://theoradusz.github.io/plotto/",
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.position ?? "right";
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
- const horizontal = theme.legend === "bottom";
36
- const arrange = (H) => {
37
- const info = infoModel(ns, fonts, { W, H, narrow: W < NARROW });
38
- const above = info?.position === "right" ? info.height + fonts.base : 0; // the info card sits above a right legend
39
- const legend = legendModel(ns, color, fonts, { horizontal, maxWidth: W * 0.9, maxHeight: H * 0.8 - above });
40
- return { info, legend, layout: place({ ns, grid, kind, home, legend, info, fonts, W, H }) };
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
- let { info, legend, layout } = arrange(H);
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 } = arrange(H));
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, fonts));
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, fonts) {
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": fonts.strip, fill: theme.ink });
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" : ns.info.position;
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
- 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));
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
- return { position, size, pad, lineH, keyGap, ...t };
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 headW = W - 2 * pad - Math.min(180, W * TOOLBAR);
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, fonts.strip, width - 8, true, metrics);
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
  }
package/theme.json CHANGED
@@ -142,6 +142,7 @@
142
142
  "width": 960,
143
143
  "height": 600,
144
144
  "size": 22,
145
+ "shrink": 0.7,
145
146
  "line": 2.5,
146
147
  "point": 4,
147
148
  "reflow": false