plotto 0.0.0-stage → 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.
@@ -0,0 +1,364 @@
1
+ // Browser output: draws the figure into an element, at the element width, with a toolbar.
2
+ // interactive adds zoom, pan, hover and the legend toggle. A zoom or a resize draws again with the same code,
3
+ // so the look does not change.
4
+ import { copyPNG, downloadPNG, downloadSVG } from "./copy.js";
5
+ import { drawPanels, figure } from "./figure.js";
6
+ import { pick } from "./hover.js";
7
+ import { svgElement } from "./svg.js";
8
+ import { headerColors } from "./theme.js";
9
+ import { installStyles, themeVars, toolbar } from "./toolbar.js";
10
+
11
+ const DRAG_MIN = 5; // px: a smaller drag is a click
12
+ const MIN_WIDTH = 320; // px
13
+ const ZOOM_STEP = 0.75; // zoom in keeps 75% of the range
14
+ const DOUBLE = 300; // ms: two legend clicks within this time are a double-click
15
+
16
+ // Returns a controller: { state (the current figure), redraw(), destroy() }.
17
+ export function draw(spec, el, { interactive = false, width } = {}) {
18
+ const doc = el.ownerDocument;
19
+ installStyles(doc);
20
+ const root = doc.createElement("div");
21
+ root.className = "pt-root";
22
+ const tip = Object.assign(doc.createElement("div"), { className: "pt-tip" });
23
+ const ctl = { state: null, mode: "zoom" };
24
+ let observer = null;
25
+
26
+ const render = (w) => {
27
+ const keep = ctl.state && { domains: ctl.state.domains === ctl.state.home ? undefined : ctl.state.domains, hidden: ctl.state.hidden };
28
+ const state = figure(spec, { document: doc, width: w, ...keep });
29
+ const old = ctl.state?.svg;
30
+ state.overlay = state.svg.appendChild(svgElement(doc, "g", { class: "pt-overlay", "pointer-events": "none" }));
31
+ if (!state.theme.reflow) {
32
+ state.svg.setAttribute("width", "100%");
33
+ state.svg.removeAttribute("height");
34
+ root.style.maxWidth = `${state.W}px`;
35
+ }
36
+ old ? old.replaceWith(state.svg) : root.prepend(state.svg);
37
+ ctl.state = state;
38
+ themeVars(root, state.theme);
39
+ bar.setSize(state.theme.reflow ? state.W : Math.min(state.W, el.clientWidth || state.W));
40
+ };
41
+
42
+ const groups = [
43
+ [
44
+ { name: "copy", title: "Copy as PNG", action: (b) => copyPNG(ctl.state.svg).then((how) => bar.flash("copy", how)) },
45
+ { name: "download", title: "Download", menu: [["PNG", () => downloadPNG(ctl.state.svg)], ["SVG", () => downloadSVG(ctl.state.svg)]] },
46
+ ],
47
+ ];
48
+ if (interactive) {
49
+ groups.unshift(
50
+ [
51
+ { name: "zoom", title: "Zoom: drag a box", action: () => setMode("zoom") },
52
+ { name: "pan", title: "Pan: drag the view", action: () => setMode("pan") },
53
+ ],
54
+ [
55
+ { name: "zoomIn", title: "Zoom in", action: () => step(ctl, ZOOM_STEP) },
56
+ { name: "zoomOut", title: "Zoom out", action: () => step(ctl, 1 / ZOOM_STEP) },
57
+ { name: "home", title: "Reset view (double-click)", action: () => home(ctl) },
58
+ ],
59
+ );
60
+ }
61
+ const bar = toolbar(doc, groups);
62
+ root.append(bar.el, tip);
63
+ const setMode = (mode) => {
64
+ ctl.mode = mode;
65
+ bar.button("zoom").classList.toggle("pt-on", mode === "zoom");
66
+ bar.button("pan").classList.toggle("pt-on", mode === "pan");
67
+ root.style.cursor = mode === "pan" ? "grab" : "";
68
+ };
69
+
70
+ try {
71
+ const first = width ?? (el.clientWidth ? Math.max(MIN_WIDTH, el.clientWidth) : undefined);
72
+ render(first);
73
+ el.replaceChildren(root);
74
+ } catch (e) {
75
+ const box = Object.assign(doc.createElement("div"), { className: "pt-error", textContent: `plotto: ${e.message}` });
76
+ el.prepend(box);
77
+ throw e;
78
+ }
79
+ if (interactive) {
80
+ setMode("zoom");
81
+ attach(ctl, root, tip);
82
+ }
83
+
84
+ // Reflow: lay out again when the element width changes (100 ms after the last change).
85
+ if (ctl.state.theme.reflow && width === undefined && typeof ResizeObserver !== "undefined") {
86
+ let timer;
87
+ observer = new ResizeObserver(() => {
88
+ clearTimeout(timer);
89
+ timer = setTimeout(() => {
90
+ const w = Math.max(MIN_WIDTH, Math.round(el.clientWidth));
91
+ if (w && w !== ctl.state.W) render(w);
92
+ }, 100);
93
+ });
94
+ observer.observe(el);
95
+ }
96
+ ctl.redraw = () => drawPanels(ctl.state);
97
+ ctl.resize = (w) => render(Math.max(MIN_WIDTH, w));
98
+ ctl.destroy = () => observer?.disconnect();
99
+ return ctl;
100
+ }
101
+
102
+ function attach(ctl, root, tip) {
103
+ let drag = null;
104
+ let frame = null;
105
+ const svg = () => ctl.state.svg;
106
+
107
+ // Figure px of a pointer event, and the screen px per figure px.
108
+ const at = (e) => {
109
+ const r = svg().getBoundingClientRect();
110
+ const k = ctl.state.W / r.width;
111
+ return [(e.clientX - r.left) * k, (e.clientY - r.top) * k, k];
112
+ };
113
+ const panelAt = (x, y) => ctl.state.layout.panels.findIndex((b) => x >= b.x + b.ml && x <= b.x + b.ml + b.innerW && y >= b.y && y <= b.y + b.innerH);
114
+
115
+ // A press in a panel starts a gesture. It becomes a drag (zoom box or pan) after DRAG_MIN px of movement;
116
+ // a release before that is a click: it pins the hover label, or unpins it.
117
+ let pinned = false;
118
+ root.addEventListener("pointerdown", (e) => {
119
+ if (e.button !== 0 || !svg().contains(e.target)) return;
120
+ const [x, y] = at(e);
121
+ const i = panelAt(x, y);
122
+ if (i < 0) return;
123
+ e.preventDefault();
124
+ root.focus({ preventScroll: true }); // for Esc
125
+ drag = { i, x, y, domains: ctl.state.domains, moving: false, id: e.pointerId };
126
+ });
127
+
128
+ root.addEventListener("pointermove", (e) => {
129
+ const [x, y, k] = at(e);
130
+ if (drag && !drag.moving && Math.hypot(x - drag.x, y - drag.y) >= DRAG_MIN) {
131
+ drag.moving = true;
132
+ root.setPointerCapture(drag.id);
133
+ unpin();
134
+ hideHover();
135
+ if (ctl.mode === "pan") root.style.cursor = "grabbing";
136
+ }
137
+ if (!drag?.moving) {
138
+ if (!pinned && !frame) frame = requestAnimationFrame(() => ((frame = null), showHover(x, y, k)));
139
+ return;
140
+ }
141
+ if (ctl.mode === "pan") return pan(ctl, drag, x, y);
142
+ const b = ctl.state.layout.panels[drag.i];
143
+ const clampX = (v) => Math.max(b.x + b.ml, Math.min(b.x + b.ml + b.innerW, v));
144
+ const clampY = (v) => Math.max(b.y, Math.min(b.y + b.innerH, v));
145
+ const [x0, x1] = [drag.x, clampX(x)].sort((a, c) => a - c);
146
+ const [y0, y1] = [drag.y, clampY(y)].sort((a, c) => a - c);
147
+ // A flat drag zooms x only (a full-height band); a narrow drag zooms y only (a full-width band).
148
+ const flat = y1 - y0 < DRAG_MIN || ctl.state.kind.y === "category";
149
+ const narrow = x1 - x0 < DRAG_MIN || ctl.state.kind.x === "category";
150
+ if (flat && narrow) return ctl.state.overlay.replaceChildren();
151
+ const rect = { x: narrow ? b.x + b.ml : x0, y: flat ? b.y : y0, width: narrow ? b.innerW : x1 - x0, height: flat ? b.innerH : y1 - y0 };
152
+ const accent = ctl.state.theme.accent;
153
+ ctl.state.overlay.replaceChildren(svgElement(ctl.state.doc, "rect", { ...rect, fill: accent, "fill-opacity": 0.1, stroke: accent, "stroke-opacity": 0.7, "stroke-width": 1 }));
154
+ drag.box = { x0, x1, y0, y1 };
155
+ });
156
+
157
+ root.addEventListener("pointerup", (e) => {
158
+ if (drag?.moving && ctl.mode === "zoom" && drag.box) zoom(ctl, drag.i, drag.box);
159
+ else if (drag && !drag.moving) {
160
+ // A click pins the label of what is under the pointer; the next click releases it.
161
+ const [x, y, k] = at(e);
162
+ if (pinned) {
163
+ unpin();
164
+ showHover(x, y, k);
165
+ } else {
166
+ showHover(x, y, k);
167
+ if (tip.style.display === "block") pin();
168
+ }
169
+ }
170
+ if (ctl.mode === "pan") root.style.cursor = "grab";
171
+ if (!pinned) ctl.state.overlay.replaceChildren();
172
+ drag = null;
173
+ });
174
+
175
+ root.addEventListener("pointerleave", () => !drag && !pinned && hideHover());
176
+ root.addEventListener("keydown", (e) => e.key === "Escape" && (unpin(), hideHover()));
177
+ root.tabIndex = -1;
178
+
179
+ // Zoom buttons, Home and legend clicks draw the panels again: the label then goes.
180
+ ctl.clearHover = () => (unpin(), hideHover());
181
+
182
+ function pin() {
183
+ pinned = true;
184
+ tip.classList.add("pt-pinned");
185
+ }
186
+ function unpin() {
187
+ pinned = false;
188
+ tip.classList.remove("pt-pinned");
189
+ }
190
+
191
+ // Pointer capture retargets click events to the root, so find the element under the pointer.
192
+ const under = (e) => ctl.state.doc.elementFromPoint(e.clientX, e.clientY);
193
+ const keyAt = (e) => under(e)?.closest?.(".pt-key");
194
+
195
+ root.addEventListener("dblclick", (e) => {
196
+ const [x, y] = at(e);
197
+ if (panelAt(x, y) < 0) return;
198
+ unpin();
199
+ hideHover();
200
+ home(ctl);
201
+ });
202
+
203
+ // Legend: a click hides or shows a value at once. Two clicks on the same entry within DOUBLE ms
204
+ // (a quick double-click) show only that value instead; a slower second click just toggles it back.
205
+ let last = { value: undefined, time: 0, before: null };
206
+ root.addEventListener("click", (e) => {
207
+ const key = keyAt(e);
208
+ if (!key) return;
209
+ const value = ctl.state.legend.items[Number(key.dataset.index)].value;
210
+ ctl.clearHover();
211
+ const now = performance.now();
212
+ if (value === last.value && now - last.time < DOUBLE) {
213
+ ctl.state.hidden = last.before;
214
+ last = { value: undefined, time: 0, before: null };
215
+ return isolate(ctl, value);
216
+ }
217
+ last = { value, time: now, before: ctl.state.hidden };
218
+ const hidden = new Set(ctl.state.hidden);
219
+ hidden.has(value) ? hidden.delete(value) : hidden.add(value);
220
+ ctl.state.hidden = hidden;
221
+ drawPanels(ctl.state);
222
+ });
223
+
224
+ let previous = null;
225
+ function showHover(x, y, k) {
226
+ const state = ctl.state;
227
+ const i = panelAt(x, y);
228
+ const b = i < 0 ? null : state.layout.panels[i];
229
+ const hit = b ? pick(state, i, x - b.x, y - b.y, previous) : null;
230
+ previous = hit;
231
+ if (!hit) return hideHover();
232
+ const doc = state.doc;
233
+ const marks = [];
234
+ if (hit.crosshair !== undefined) {
235
+ const cx = b.x + hit.crosshair;
236
+ marks.push(svgElement(doc, "line", { x1: cx, x2: cx, y1: b.y, y2: b.y + b.innerH, stroke: state.theme.muted, "stroke-opacity": 0.55, "stroke-width": 1 }));
237
+ }
238
+ for (const m of hit.marks) {
239
+ if (m.rect) marks.push(svgElement(doc, "rect", { x: b.x + m.rect.x, y: b.y + m.rect.y, width: m.rect.width, height: m.rect.height, fill: "none", stroke: m.color, "stroke-width": 1.5 }));
240
+ else marks.push(svgElement(doc, "circle", { cx: b.x + m.x, cy: b.y + m.y, r: state.theme.point + 1.5, fill: m.color, stroke: state.theme.background, "stroke-width": 1.5 }));
241
+ }
242
+ state.overlay.replaceChildren(...marks);
243
+ const head = headerColors(hit.color);
244
+ tip.style.setProperty("--pt-c", head.bg);
245
+ tip.style.setProperty("--pt-on-c", head.fg);
246
+ tip.style.font = `${state.fonts.hover}px ${state.theme.font}`;
247
+ tip.replaceChildren(...tipContent(doc, hit));
248
+ tip.style.display = "block";
249
+ // The box follows the pointer (not the noisy data): right of the anchor, or left of it near the right edge.
250
+ const ax = (b.x + hit.anchor.x) / k;
251
+ const ay = y / k;
252
+ const width = root.clientWidth;
253
+ const height = root.clientHeight;
254
+ const left = ax + 16 + tip.offsetWidth > width ? ax - 16 - tip.offsetWidth : ax + 16;
255
+ const top = Math.min(Math.max(0, ay - 18), Math.max(0, height - tip.offsetHeight));
256
+ tip.style.left = `${Math.max(0, left)}px`;
257
+ tip.style.top = `${top}px`;
258
+ }
259
+
260
+ function hideHover() {
261
+ tip.style.display = "none";
262
+ if (!drag) ctl.state.overlay.replaceChildren();
263
+ }
264
+ }
265
+
266
+ function tipContent(doc, hit) {
267
+ const el = (tag, className, text) => Object.assign(doc.createElement(tag), { className, textContent: text ?? "" });
268
+ const nodes = [];
269
+ if (hit.header) nodes.push(el("div", "pt-head", hit.header));
270
+ const rows = el("div", "pt-rows");
271
+ for (const r of hit.rows) {
272
+ const swatch = el("span", "pt-sw");
273
+ if (r.swatch) swatch.style.background = r.swatch;
274
+ const cells = [swatch, el("span", "pt-k", r.label), el("span", "pt-v", r.value)];
275
+ if (r.strong) cells.forEach((c) => c.classList.add("pt-b"));
276
+ if (r.total) cells.forEach((c) => c.classList.add("pt-t"));
277
+ rows.append(...cells);
278
+ }
279
+ if (hit.note) rows.append(el("span", "pt-more", hit.note));
280
+ nodes.push(rows);
281
+ return nodes;
282
+ }
283
+
284
+ function home(ctl) {
285
+ ctl.clearHover?.();
286
+ ctl.state.domains = ctl.state.home;
287
+ drawPanels(ctl.state);
288
+ }
289
+
290
+ // Legend double-click: show only this value; when it is already the only one, show all.
291
+ function isolate(ctl, value) {
292
+ const all = ctl.state.legend.items.map((it) => it.value);
293
+ const alone = ctl.state.hidden.size === all.length - 1 && !ctl.state.hidden.has(value);
294
+ ctl.state.hidden = alone ? new Set() : new Set(all.filter((v) => v !== value));
295
+ drawPanels(ctl.state);
296
+ }
297
+
298
+ // Zoom on panel i to the dragged box (figure px). A shared axis zooms in every panel that shares it.
299
+ function zoom(ctl, i, { x0, x1, y0, y1 }) {
300
+ const state = ctl.state;
301
+ const b = state.layout.panels[i];
302
+ const plot = state.panels[i].plot;
303
+ const next = state.domains.map((d) => ({ ...d }));
304
+ const apply = (axis, lo, hi) => {
305
+ if (state.kind[axis] === "category" || hi - lo < DRAG_MIN) return;
306
+ const scale = plot.scale(axis);
307
+ const offset = axis === "x" ? b.x : b.y;
308
+ const d = [scale.invert(lo - offset), scale.invert(hi - offset)].map(Number).sort((a, c) => a - c);
309
+ shared(state, axis, i).forEach((j) => (next[j][axis] = d));
310
+ };
311
+ apply("x", x0, x1);
312
+ apply("y", y0, y1);
313
+ state.domains = next;
314
+ drawPanels(state);
315
+ }
316
+
317
+ // Zoom in or out around the center of each panel: factor < 1 zooms in.
318
+ function step(ctl, factor) {
319
+ ctl.clearHover?.();
320
+ const state = ctl.state;
321
+ state.domains = state.domains.map((d) => {
322
+ const out = { ...d };
323
+ for (const axis of ["x", "y"]) {
324
+ const k = state.kind[axis];
325
+ if (k === "category") continue;
326
+ const f = k === "log" ? Math.log10 : (v) => v;
327
+ const g = k === "log" ? (v) => 10 ** v : (v) => v;
328
+ const [lo, hi] = d[axis].map((v) => f(+v));
329
+ const mid = (lo + hi) / 2;
330
+ out[axis] = [g(mid - ((hi - lo) / 2) * factor), g(mid + ((hi - lo) / 2) * factor)];
331
+ }
332
+ return out;
333
+ });
334
+ drawPanels(state);
335
+ }
336
+
337
+ function pan(ctl, drag, x, y) {
338
+ const state = ctl.state;
339
+ const b = state.layout.panels[drag.i];
340
+ const plot = state.panels[drag.i].plot;
341
+ const next = drag.domains.map((d) => ({ ...d }));
342
+ for (const [axis, from, to, offset] of [["x", drag.x, x, b.x], ["y", drag.y, y, b.y]]) {
343
+ if (state.kind[axis] === "category") continue;
344
+ const scale = plot.scale(axis);
345
+ const log = state.kind[axis] === "log";
346
+ const f = (v) => (log ? Math.log10(v) : v);
347
+ const delta = f(+scale.invert(from - offset)) - f(+scale.invert(to - offset));
348
+ const d = drag.domains[drag.i][axis].map((v) => (log ? 10 ** (f(v) + delta) : +v + delta));
349
+ shared(state, axis, drag.i).forEach((j) => (next[j][axis] = d));
350
+ }
351
+ state.domains = next;
352
+ if (!state.frame) state.frame = requestAnimationFrame(() => ((state.frame = null), drawPanels(state)));
353
+ }
354
+
355
+ // The panels that share the axis of panel i (all panels, unless the axis is free).
356
+ function shared(state, axis, i) {
357
+ const free = state.ns.facet?.free?.[axis];
358
+ const p = state.grid.panels[i];
359
+ return state.grid.panels.flatMap((q, j) => {
360
+ if (!free) return [j];
361
+ if (state.grid.kind !== "grid") return j === i ? [j] : [];
362
+ return (axis === "x" ? q.col === p.col : q.row === p.row) ? [j] : [];
363
+ });
364
+ }
package/src/layers.js ADDED
@@ -0,0 +1,159 @@
1
+ // Layer table: each layer type returns the Observable Plot marks of its view in one panel.
2
+ // The context `c` (made by figure.js) gives the view columns: c.v("x"), c.colors(fallback), c.groups().
3
+ // To add a layer type: add one entry here, one entry in grammar.json and, for a statistic, a case in views.js.
4
+ import * as Plot from "@observablehq/plot";
5
+ import * as d3 from "d3";
6
+
7
+ const DASH = { solid: undefined, dashed: "6,4", dotted: "1.5,3", dashdot: "6,3,1.5,3" };
8
+ const clip = "frame";
9
+
10
+ const strokeStyle = (c, fallback) => ({
11
+ stroke: c.colors(fallback),
12
+ strokeWidth: c.options.size ?? c.theme.line,
13
+ strokeOpacity: c.options.alpha ?? 1,
14
+ strokeDasharray: DASH[c.options.dash],
15
+ strokeLinejoin: "round",
16
+ strokeLinecap: "round",
17
+ });
18
+
19
+ const fillStyle = (c, fallback, alpha = 1) => ({ fill: c.colors(fallback), fillOpacity: c.options.alpha ?? alpha });
20
+ const first = (c) => c.theme.palette[0];
21
+
22
+ const lineOf = (curve) => (c) => [Plot.line(c.index, { x: c.v("x"), y: c.v("y"), z: c.groups(), curve, ...strokeStyle(c, first(c)), clip })];
23
+
24
+ export const LAYERS = {
25
+ line: (c) => [Plot.line(c.index, { x: c.v("x"), y: c.v("y"), z: c.groups(), sort: { channel: "x" }, ...strokeStyle(c, first(c)), clip })],
26
+
27
+ path: lineOf("linear"),
28
+
29
+ step: (c) => [Plot.line(c.index, { x: c.v("x"), y: c.v("y"), z: c.groups(), sort: { channel: "x" }, curve: "step-after", ...strokeStyle(c, first(c)), clip })],
30
+
31
+ point: (c) => {
32
+ const options = { x: jittered(c), y: c.v("y"), r: c.options.size ?? c.theme.point, ...fillStyle(c, first(c)), clip };
33
+ if (c.options.jitter && c.kind.x === "category") options.render = jitterRender(c.options.jitter);
34
+ return [Plot.dot(c.index, options)];
35
+ },
36
+
37
+ bar: (c) => {
38
+ const fill = { ...fillStyle(c, first(c), stackAlpha(c, 1)), clip };
39
+ if (c.kind.x !== "category") return [Plot.rect(c.index, { x1: c.v("xmin"), x2: c.v("xmax"), y1: c.v("ymin"), y2: c.v("ymax"), ...fill })];
40
+ const slots = c.v("slots")[0] ?? 1;
41
+ if (slots === 1) return [Plot.barY(c.index, { x: c.v("x"), y1: c.v("ymin"), y2: c.v("ymax"), ...fill })];
42
+ // Dodge: one bar mark per slot, narrowed with insets to its share of the band.
43
+ // Channel arrays align with the data array, so each slot takes its own subset of every array.
44
+ const slice = c.bandwidth.x / slots;
45
+ const at = (values, rows) => (Array.isArray(values) ? rows.map((i) => values[i]) : values);
46
+ return d3.range(slots).map((k) => {
47
+ const rows = c.index.filter((i) => c.v("slot")[i] === k);
48
+ const channels = { x: at(c.v("x"), rows), y1: at(c.v("ymin"), rows), y2: at(c.v("ymax"), rows), fill: at(fill.fill, rows) };
49
+ return Plot.barY(d3.range(rows.length), { ...fill, ...channels, insetLeft: k * slice + 0.5, insetRight: (slots - 1 - k) * slice + 0.5 });
50
+ });
51
+ },
52
+
53
+ hist: (c) => [Plot.rect(c.index, { x1: c.v("xmin"), x2: c.v("xmax"), y1: c.v("ymin"), y2: c.v("ymax"), insetLeft: 0.5, insetRight: 0.5, ...fillStyle(c, first(c), stackAlpha(c)), clip })],
54
+
55
+ density: (c) => [
56
+ Plot.areaY(c.index, { x: c.v("x"), y: c.v("y"), z: c.groups(), fill: c.colors(first(c)), fillOpacity: 0.15, clip }),
57
+ Plot.line(c.index, { x: c.v("x"), y: c.v("y"), z: c.groups(), ...strokeStyle(c, first(c)), clip }),
58
+ ],
59
+
60
+ area: (c) => [
61
+ Plot.areaY(c.index, { x: c.v("x"), y1: c.v("ymin"), y2: c.v("ymax"), z: c.groups(), ...fillStyle(c, first(c), stackAlpha(c, 0.8)), clip }),
62
+ ],
63
+
64
+ ribbon: (c) => [Plot.areaY(c.index, { x: c.v("x"), y1: c.v("ymin"), y2: c.v("ymax"), z: c.groups(), ...fillStyle(c, first(c), 0.2), clip })],
65
+
66
+ smooth: (c) => {
67
+ const marks = [];
68
+ if (c.v("ymin").some((v) => v !== null)) {
69
+ marks.push(Plot.areaY(c.index, { x: c.v("x"), y1: c.v("ymin"), y2: c.v("ymax"), z: c.groups(), fill: c.colors(c.theme.muted), fillOpacity: 0.15, clip }));
70
+ }
71
+ marks.push(Plot.line(c.index, { x: c.v("x"), y: c.v("y"), z: c.groups(), ...strokeStyle(c, c.theme.accent), clip }));
72
+ return marks;
73
+ },
74
+
75
+ hline: (c) => [Plot.ruleY(c.index, { y: c.v("y"), ...strokeStyle(c, c.theme.muted), strokeWidth: c.options.size ?? 1, clip })],
76
+
77
+ vline: (c) => [Plot.ruleX(c.index, { x: c.v("x"), ...strokeStyle(c, c.theme.muted), strokeWidth: c.options.size ?? 1, clip })],
78
+
79
+ abline: (c) => {
80
+ const [x0, x1] = c.domain.x.map(Number);
81
+ const { slope = 1, intercept = 0 } = c.options;
82
+ return [Plot.line([0, 1], { x: [x0, x1], y: [intercept + slope * x0, intercept + slope * x1], ...strokeStyle(c, c.theme.muted), strokeWidth: c.options.size ?? 1, clip })];
83
+ },
84
+
85
+ segment: (c) => [Plot.link(c.index, { x1: c.v("x"), y1: c.v("y"), x2: c.v("xend"), y2: c.v("yend"), ...strokeStyle(c, c.theme.ink), clip })],
86
+
87
+ rect: (c) => [Plot.rect(c.index, { x1: c.v("xmin"), x2: c.v("xmax"), y1: c.v("ymin"), y2: c.v("ymax"), ...fillStyle(c, first(c)), clip })],
88
+
89
+ // A band without a bound spans the full panel on that side.
90
+ band: (c) => {
91
+ const edge = (ch, axis, i) => c.v(ch) ?? c.index.map(() => c.domain[axis][i]);
92
+ const box = { x1: edge("xmin", "x", 0), x2: edge("xmax", "x", 1), y1: edge("ymin", "y", 0), y2: edge("ymax", "y", 1) };
93
+ return [Plot.rect(c.index, { ...box, ...fillStyle(c, c.theme.muted, 0.12), clip })];
94
+ },
95
+
96
+ tile: (c) => {
97
+ const fill = fillStyle(c, first(c));
98
+ const [cx, cy] = [c.kind.x === "category", c.kind.y === "category"];
99
+ if (cx && cy) return [Plot.cell(c.index, { x: c.v("x"), y: c.v("y"), ...fill, inset: 0.5, clip })];
100
+ const [x1, x2] = cx ? [] : spread(c.v("x"));
101
+ const [y1, y2] = cy ? [] : spread(c.v("y"));
102
+ if (cx) return [Plot.barY(c.index, { x: c.v("x"), y1, y2, ...fill, clip })];
103
+ if (cy) return [Plot.barX(c.index, { y: c.v("y"), x1, x2, ...fill, insetTop: 0.5, insetBottom: 0.5, clip })];
104
+ return [Plot.rect(c.index, { x1, x2, y1, y2, ...fill, clip })];
105
+ },
106
+
107
+ text: (c) => [
108
+ Plot.text(c.index, { x: c.v("x"), y: c.v("y"), text: c.v("label"), fontSize: c.options.size ?? c.fonts.tick, ...fillStyle(c, c.theme.ink), clip }),
109
+ ],
110
+
111
+ errorbar: (c) => {
112
+ const style = { ...strokeStyle(c, c.theme.ink), strokeWidth: c.options.size ?? 1.2 };
113
+ const rule = Plot.ruleX(c.index, { x: c.v("x"), y1: c.v("ymin"), y2: c.v("ymax"), ...style, clip });
114
+ if (c.kind.x === "category") {
115
+ const inset = c.bandwidth.x * 0.35;
116
+ const cap = (ch) => Plot.tickY(c.index, { x: c.v("x"), y: c.v(ch), ...style, insetLeft: inset, insetRight: inset, clip });
117
+ return [rule, cap("ymin"), cap("ymax")];
118
+ }
119
+ const [x1, x2] = spread(c.v("x"), 0.25);
120
+ const cap = (ch) => Plot.link(c.index, { x1, x2, y1: c.v(ch), y2: c.v(ch), ...style, clip });
121
+ return [rule, cap("ymin"), cap("ymax")];
122
+ },
123
+
124
+ box: (c) => [
125
+ Plot.boxY(c.index, { x: c.v("x"), y: c.v("y"), fill: c.colors(c.theme.strip), fillOpacity: c.options.alpha ?? 1, stroke: c.theme.ink, strokeWidth: 1, r: c.theme.point }),
126
+ ],
127
+ };
128
+
129
+ // Overlaid fills are see-through, stacked fills are solid.
130
+ function stackAlpha(c, solid = 0.9) {
131
+ return c.options.position === "overlay" ? 0.45 : solid;
132
+ }
133
+
134
+ // Intervals of a fraction of the data resolution (the smallest step between values) around each value.
135
+ function spread(values, fraction = 0.5) {
136
+ const nums = values.map(Number);
137
+ const sorted = [...new Set(nums)].sort((a, b) => a - b);
138
+ const step = d3.min(d3.pairs(sorted), ([a, b]) => b - a) ?? 1;
139
+ const back = values[0] instanceof Date ? (v) => new Date(v) : (v) => v;
140
+ return [nums.map((v) => back(v - step * fraction)), nums.map((v) => back(v + step * fraction))];
141
+ }
142
+
143
+ // Seeded jitter, so that each redraw and each output put the points at the same place.
144
+ function jittered(c) {
145
+ const x = c.v("x");
146
+ if (!c.options.jitter || c.kind.x === "category") return x;
147
+ const random = d3.randomUniform.source(d3.randomLcg(42))(-1, 1);
148
+ const [lo] = spread(x, c.options.jitter);
149
+ return x.map((v, i) => +v + (+v - +lo[i]) * random()).map((v) => (x[0] instanceof Date ? new Date(v) : v));
150
+ }
151
+
152
+ function jitterRender(fraction) {
153
+ return (index, scales, values, dimensions, context, next) => {
154
+ const random = d3.randomUniform.source(d3.randomLcg(42))(-1, 1);
155
+ const step = scales.scales.x?.bandwidth ?? 20;
156
+ const x = Float64Array.from(values.x, (v) => v + random() * step * fraction * 0.5);
157
+ return next(index, scales, { ...values, x }, dimensions, context);
158
+ };
159
+ }
package/src/layout.js ADDED
@@ -0,0 +1,130 @@
1
+ // Layout: the position and size of the title, info table, panels, strips, axis titles, caption and legend, in px of the figure.
2
+ // Text sizes come from text.js, so Node and the browser get the same layout.
3
+ import * as d3 from "d3";
4
+ import { ticks } from "./scales.js";
5
+ import { fitText, textWidth, wrapText } from "./text.js";
6
+
7
+ export const TICK = { size: 4, padding: 3 };
8
+ const ROTATE = 40; // degrees of rotated category labels
9
+ const TOOLBAR = 0.22; // share of the width kept free of the title, for the toolbar
10
+
11
+ export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
12
+ const theme = ns.theme;
13
+ const metrics = theme.metrics;
14
+ const tw = (t, size, bold) => textWidth(t, size, bold, metrics);
15
+ const s = fonts.base;
16
+ const pad = Math.round(s * 0.9);
17
+ const gap = Math.round(s * 0.8);
18
+ const { title, subtitle, caption, x: xTitle, y: yTitle } = ns.labels;
19
+ const at = (position) => (info?.position === position ? info : null);
20
+ const headW = W - 2 * pad - Math.min(180, W * TOOLBAR);
21
+ const titleLines = title ? wrapText(title, fonts.title, headW, true, metrics) : [];
22
+ const subtitleLines = subtitle ? wrapText(subtitle, fonts.subtitle, headW, false, metrics) : [];
23
+ const head = titleLines.length * fonts.title * 1.25 + subtitleLines.length * fonts.subtitle * 1.4;
24
+ const top = pad + head + (head ? s * 0.7 : 0);
25
+ const xt = xTitle ? fonts.axisTitle * 1.7 : 0;
26
+ const yt = yTitle ? fonts.axisTitle * 1.7 : 0;
27
+ const cap = caption ? fonts.tick * 1.8 : 0;
28
+ const rightW = Math.max(legend && !legend.horizontal ? legend.width : 0, at("right")?.width ?? 0);
29
+ const right = rightW ? rightW + s * 1.5 : 0;
30
+ const bottom = legend?.horizontal ? legend.height + s * 0.8 : 0;
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 };
33
+
34
+ const strip = grid.kind === "none" ? 0 : Math.round(fonts.strip * 2);
35
+ const wrapStrip = grid.kind === "wrap" ? strip : 0;
36
+ const topStrip = grid.kind === "grid" && ns.facet.cols ? strip + 2 : 0;
37
+ const rightStrip = grid.kind === "grid" && ns.facet.rows ? strip + 2 : 0;
38
+ const free = ns.facet?.free ?? { x: false, y: false };
39
+ const n = grid.panels.length;
40
+ const showY = (p) => free.y || p.col === 0;
41
+ const showX = (p, i) => free.x || (grid.kind === "wrap" ? i + grid.ncol >= n : p.row === grid.nrow - 1);
42
+
43
+ // Two passes: the tick count depends on the panel size, and the margins depend on the tick labels.
44
+ let innerW = area.w / grid.ncol;
45
+ let innerH = area.h / grid.nrow;
46
+ let ml, mb, count, rotateX;
47
+ for (let pass = 0; pass < 2; pass++) {
48
+ count = { x: Math.max(2, innerW / (kind.x === "time" ? 110 : 75)), y: Math.max(2, innerH / 45) };
49
+ ml = Array(grid.ncol).fill(0);
50
+ mb = Array(grid.nrow).fill(0);
51
+ rotateX = false;
52
+ let xLabelW = 0;
53
+ grid.panels.forEach((p, i) => {
54
+ if (showY(p)) {
55
+ const t = ticks(kind.y, home[i].y, count.y, ns.scales.y?.format);
56
+ const w = d3.max(t.major, (v) => tw(t.format(v), fonts.tick)) ?? 0;
57
+ ml[p.col] = Math.max(ml[p.col], Math.ceil(w + TICK.size + TICK.padding + 3));
58
+ }
59
+ if (kind.x === "category" && showX(p, i)) {
60
+ const labels = home[i].x;
61
+ const w = d3.max(labels, (v) => tw(v, fonts.tick)) ?? 0;
62
+ xLabelW = Math.max(xLabelW, w);
63
+ if (w > (innerW / (labels.length + 0.2)) * 0.95) rotateX = true;
64
+ }
65
+ });
66
+ const xLabelH = rotateX ? Math.min(xLabelW, innerH * 0.6) * Math.sin((ROTATE * Math.PI) / 180) + fonts.tick : fonts.tick * 1.2;
67
+ grid.panels.forEach((p, i) => {
68
+ if (showX(p, i)) mb[p.row] = Math.max(mb[p.row], Math.ceil(xLabelH + TICK.size + TICK.padding + 3));
69
+ });
70
+ innerW = (area.w - d3.sum(ml) - gap * (grid.ncol - 1) - rightStrip) / grid.ncol;
71
+ innerH = (area.h - d3.sum(mb) - grid.nrow * wrapStrip - topStrip - gap * (grid.nrow - 1)) / grid.nrow;
72
+ }
73
+ innerW = Math.max(20, innerW);
74
+ innerH = Math.max(20, innerH);
75
+
76
+ const colX = d3.cumsum([area.x, ...ml.slice(0, -1).map((m) => m + innerW + gap)]);
77
+ const rowY = d3.cumsum([area.y + topStrip, ...mb.slice(0, -1).map((m) => wrapStrip + innerH + m + gap)]);
78
+ const panels = grid.panels.map((p, i) => ({
79
+ x: colX[p.col],
80
+ y: rowY[p.row] + wrapStrip,
81
+ w: ml[p.col] + innerW,
82
+ h: innerH + mb[p.row],
83
+ ml: ml[p.col],
84
+ mb: mb[p.row],
85
+ innerW,
86
+ innerH,
87
+ count,
88
+ rotateX,
89
+ showX: showX(p, i),
90
+ showY: showY(p),
91
+ }));
92
+
93
+ const strips = [];
94
+ grid.panels.forEach((p, i) => {
95
+ const b = panels[i];
96
+ const left = b.x + b.ml;
97
+ const label = (text, width) => fitText(text, fonts.strip, width - 8, true, metrics);
98
+ 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 });
99
+ 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 });
100
+ });
101
+
102
+ const first = panels[0];
103
+ const lastCol = panels.find((b, i) => grid.panels[i].col === grid.ncol - 1) ?? first;
104
+ const innerLeft = first.x + first.ml;
105
+ const innerRight = lastCol.x + lastCol.w;
106
+ const panelsBottom = area.y + area.h;
107
+ const texts = [];
108
+ let y = pad;
109
+ for (const line of titleLines) texts.push({ text: line, attrs: { x: innerLeft, y: (y += fonts.title * 1.05), "font-size": fonts.title, "font-weight": "bold", fill: theme.ink, "text-anchor": "start" } }), (y += fonts.title * 0.2);
110
+ for (const line of subtitleLines) texts.push({ text: line, attrs: { x: innerLeft, y: (y += fonts.subtitle * 1.3), "font-size": fonts.subtitle, fill: theme.muted, "text-anchor": "start" } });
111
+ if (xTitle) texts.push({ text: xTitle, attrs: { x: (innerLeft + innerRight) / 2, y: panelsBottom + fonts.axisTitle * 1.35, "text-anchor": "middle", "font-size": fonts.axisTitle, fill: theme.muted } });
112
+ if (yTitle) {
113
+ const cy = area.y + (area.h - d3.sum(mb)) / 2;
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 } });
115
+ }
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;
124
+ const legendAt = !legend
125
+ ? null
126
+ : legend.horizontal
127
+ ? { x: Math.max(pad, (innerLeft + innerRight - legend.width) / 2), y: H - pad - legend.height }
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 };
130
+ }