plotto 0.4.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotto",
3
- "version": "0.4.1",
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,8 +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 RIGHT_SHARE = 0.25; // a format with "shrink" keeps the right column (legend, info card) to this share of the width
20
- const SHRINK_STEP = 0.05; // and makes their text smaller by this share of its size at each step
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
21
21
 
22
22
  // options: document (the browser or jsdom), width (lay out at this width), domains and hidden (keep a view after a resize).
23
23
  export function figure(spec, { document: doc = globalThis.document, width, domains, hidden = new Set() } = {}) {
@@ -33,33 +33,46 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
33
33
  const ctx = { ns, kind, colorLevels: color.domain ?? [], edges: layerEdges(ns) };
34
34
  for (const p of grid.panels) p.views = ns.layers.map((layer, li) => makeView(ctx, layer, p.rows[li], new Set()));
35
35
  const home = homeDomains(ns, grid, kind);
36
- const horizontal = theme.legend === "bottom";
37
- // The legend and the info card fit: a right column of at most RIGHT_SHARE of the width, one legend column,
38
- // no cut legend label, no cut info key and no "+N more" row. A format with "shrink" (slide) makes their text smaller until they
39
- // fit, down to that share of the size; the other formats keep the size.
40
- const fits = (info, legend) => {
41
- const right = Math.max(legend && !legend.horizontal ? legend.width : 0, info?.position === "right" ? info.width : 0);
42
- const cut = legend?.items?.some((it) => it.lines.at(-1).endsWith("…") || (!legend.horizontal && it.x > 0));
43
- return right <= W * RIGHT_SHARE && !cut && !info?.cut;
44
- };
45
- const arrange = (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) => {
46
40
  const base = fontSizes(theme);
47
- for (let k = 1; ; k -= SHRINK_STEP) {
48
- const f = { ...base, legend: Math.round(base.legend * k * 10) / 10 };
49
- const info = infoModel(ns, f, { W, H, narrow: W < NARROW });
50
- const above = info?.position === "right" ? info.height + f.base : 0; // the info card sits above a right legend
51
- const legend = legendModel(ns, color, f, { horizontal, maxWidth: W * 0.9, maxHeight: H * 0.8 - above });
52
- if (fits(info, legend) || k - SHRINK_STEP < (theme.shrink ?? 1) - 1e-9) return { fonts: f, info, legend, layout: place({ ns, grid, kind, home, legend, info, fonts: f, W, H }) };
53
- }
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 };
51
+ };
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];
54
68
  };
55
- let fonts, info, legend, layout;
56
- ({ fonts, info, legend, layout } = arrange(H));
69
+ let { fonts, info, legend, layout } = choose(H);
57
70
  // aspect: when the height follows the width, it changes so that the panels get their shape, up to 1.2 times the
58
71
  // usual height; beyond that, the panels get narrower.
59
72
  if (theme.aspect && theme.fitHeight) {
60
73
  const fit = H + grid.nrow * (layout.room.w * theme.aspect - layout.room.h);
61
74
  H = Math.round(Math.min(H * ASPECT_GROWTH, Math.max(MIN_HEIGHT, fit)));
62
- ({ fonts, info, legend, layout } = arrange(H));
75
+ ({ fonts, info, legend, layout } = choose(H));
63
76
  }
64
77
 
65
78
  const svg = svgElement(doc, "svg", {
@@ -74,7 +87,7 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
74
87
  svg.append(svgElement(doc, "rect", { width: W, height: H, fill: theme.background }));
75
88
  for (const t of layout.texts) svg.append(svgText(doc, t.text, t.attrs));
76
89
  if (info) svg.append(drawInfo(doc, info, theme, layout.info));
77
- 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));
78
91
  const state = { spec, ns, doc, svg, theme, fonts, grid, kind, color, ctx, legend, info, layout, home, W, H };
79
92
  state.domains = domains ?? home;
80
93
  state.hidden = hidden;
@@ -213,13 +226,13 @@ function homeDomain(ns, axis, kind, panels) {
213
226
  return limits ? [limits[0] ?? trained[0], limits[1] ?? trained[1]] : trained;
214
227
  }
215
228
 
216
- function strip(doc, s, theme, fonts) {
229
+ function strip(doc, s, theme) {
217
230
  const g = svgElement(doc, "g", { class: "pt-strip" });
218
231
  g.append(svgElement(doc, "rect", { x: s.x, y: s.y, width: s.w, height: s.h, fill: theme.strip }));
219
232
  const attrs = s.vertical
220
233
  ? { transform: `translate(${s.x + s.w / 2},${s.y + s.h / 2}) rotate(90)`, dy: "0.35em" }
221
234
  : { x: s.x + s.w / 2, y: s.y + s.h / 2, dy: "0.35em" };
222
- 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 });
223
236
  if (s.text !== String(s.full)) text.append(svgText(doc, s.full, {}, "title"));
224
237
  g.append(text);
225
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);
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
  }