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 +1 -1
- package/src/builder.js +3 -3
- package/src/figure.js +38 -25
- package/src/info.js +9 -7
- package/src/layout.js +17 -6
- package/src/spec.js +2 -1
- package/src/theme.js +1 -0
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,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
|
|
20
|
-
const
|
|
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
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 } =
|
|
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
|
|
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
|
|
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":
|
|
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" :
|
|
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);
|
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
|
}
|