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.
package/src/facets.js ADDED
@@ -0,0 +1,54 @@
1
+ // Facets: splits the rows of each layer into panels and places the panels on a grid.
2
+ import { naturalOrder } from "./scales.js";
3
+
4
+ // Returns { kind, nrow, ncol, panels }. Each panel has row, col, labels and rows[layerIndex] (row numbers).
5
+ export function facetPanels(ns) {
6
+ const f = ns.facet;
7
+ if (!f) return { kind: "none", nrow: 1, ncol: 1, panels: [panel(ns, 0, 0, {}, {})] };
8
+ if (f.kind === "wrap") {
9
+ const keys = levels(ns, f.by);
10
+ const ncol = f.ncol ?? wrapColumns(keys.length);
11
+ const panels = keys.map((k, i) => panel(ns, Math.floor(i / ncol), i % ncol, { [f.by]: k }, { top: k }));
12
+ return { kind: "wrap", nrow: Math.ceil(keys.length / ncol), ncol, panels };
13
+ }
14
+ const rowKeys = f.rows ? levels(ns, f.rows) : [null];
15
+ const colKeys = f.cols ? levels(ns, f.cols) : [null];
16
+ const panels = [];
17
+ rowKeys.forEach((rk, r) =>
18
+ colKeys.forEach((ck, c) => {
19
+ const match = {};
20
+ if (f.rows) match[f.rows] = rk;
21
+ if (f.cols) match[f.cols] = ck;
22
+ panels.push(panel(ns, r, c, match, { top: r === 0 ? ck : null, right: c === colKeys.length - 1 ? rk : null }));
23
+ })
24
+ );
25
+ return { kind: "grid", nrow: rowKeys.length, ncol: colKeys.length, panels };
26
+ }
27
+
28
+ // Number of columns of a facet wrap, the same rule as ggplot (grDevices::n2mfrow).
29
+ function wrapColumns(n) {
30
+ if (n <= 3) return n;
31
+ if (n <= 6) return Math.ceil(n / 2);
32
+ if (n <= 12) return Math.ceil(n / 3);
33
+ return Math.ceil(Math.sqrt(n));
34
+ }
35
+
36
+ function levels(ns, column) {
37
+ const col = ns.tables[0].columns[column];
38
+ if (col.levels) return col.levels.filter((v) => col.values.includes(v));
39
+ return [...new Set(col.values.filter((v) => v !== null).map(String))].sort(naturalOrder);
40
+ }
41
+
42
+ // A layer without columns (for example hline(y=0)) has one row in each panel.
43
+ // A layer table without the facet column repeats in each panel, as in ggplot.
44
+ function panel(ns, row, col, match, strips) {
45
+ const rows = ns.layers.map((layer) => {
46
+ const t = ns.tables[layer.table];
47
+ if (!Object.values(layer.channels).some((c) => c.column)) return [0];
48
+ const tests = Object.entries(match).filter(([name]) => name in t.columns);
49
+ const all = Array.from({ length: t.n }, (_, i) => i);
50
+ if (!tests.length) return all;
51
+ return all.filter((i) => tests.every(([name, key]) => String(t.columns[name].values[i]) === key));
52
+ });
53
+ return { row, col, strips, rows };
54
+ }
package/src/figure.js ADDED
@@ -0,0 +1,199 @@
1
+ // Figure: computes the views, scales and layout, then draws the title, info table, strips, legend and panels as one SVG.
2
+ // Observable Plot draws each panel. drawPanels() draws them again after a zoom or a legend click.
3
+ import * as Plot from "@observablehq/plot";
4
+ import * as d3 from "d3";
5
+ import { facetPanels } from "./facets.js";
6
+ import { drawInfo, infoModel } from "./info.js";
7
+ import { LAYERS } from "./layers.js";
8
+ import { drawLegend, legendModel } from "./legend.js";
9
+ import { place, TICK } from "./layout.js";
10
+ import { AXIS_CHANNELS, axisKind, colorScale, ticks, trainDomain } from "./scales.js";
11
+ import { normalize } from "./spec.js";
12
+ import { svgElement, svgText } from "./svg.js";
13
+ import { fontSizes } from "./theme.js";
14
+ import { layerEdges, makeView } from "./views.js";
15
+
16
+ const NARROW = 560; // px: below this width, a right legend moves to the bottom
17
+ const MIN_HEIGHT = 240; // px: the smallest height of a reflowed figure
18
+
19
+ // options: document (the browser or jsdom), width (lay out at this width), domains and hidden (keep a view after a resize).
20
+ export function figure(spec, { document: doc = globalThis.document, width, domains, hidden = new Set() } = {}) {
21
+ const ns = normalize(spec);
22
+ const theme = ns.theme;
23
+ const W = Math.round(width ?? theme.width);
24
+ const reflowed = width && theme.reflow && !theme.fixedHeight;
25
+ const H = reflowed ? Math.max(MIN_HEIGHT, Math.round((W * theme.height) / theme.width)) : theme.height;
26
+ if (theme.legend === "right" && W < NARROW) theme.legend = "bottom";
27
+ const fonts = fontSizes(theme);
28
+ const grid = facetPanels(ns);
29
+ const kind = { x: axisKind(ns, "x"), y: axisKind(ns, "y") };
30
+ const color = colorScale(ns);
31
+ const ctx = { ns, kind, colorLevels: color.domain ?? [], edges: layerEdges(ns) };
32
+ for (const p of grid.panels) p.views = ns.layers.map((layer, li) => makeView(ctx, layer, p.rows[li], new Set()));
33
+ const home = homeDomains(ns, grid, kind);
34
+ const horizontal = theme.legend === "bottom";
35
+ const info = infoModel(ns, fonts, { W, H, narrow: W < NARROW });
36
+ const above = info?.position === "right" ? info.height + fonts.base : 0; // the info card sits above a right legend
37
+ const legend = legendModel(ns, color, fonts, { horizontal, maxWidth: W * 0.9, maxHeight: H * 0.8 - above });
38
+ const layout = place({ ns, grid, kind, home, legend, info, fonts, W, H });
39
+
40
+ const svg = svgElement(doc, "svg", {
41
+ xmlns: "http://www.w3.org/2000/svg",
42
+ width: W,
43
+ height: H,
44
+ viewBox: `0 0 ${W} ${H}`,
45
+ "font-family": theme.font,
46
+ "font-size": fonts.base,
47
+ style: `color:${theme.ink}`,
48
+ });
49
+ svg.append(svgElement(doc, "rect", { width: W, height: H, fill: theme.background }));
50
+ for (const t of layout.texts) svg.append(svgText(doc, t.text, t.attrs));
51
+ if (info) svg.append(drawInfo(doc, info, theme, layout.info));
52
+ for (const s of layout.strips) svg.append(strip(doc, s, theme, fonts));
53
+ const state = { spec, ns, doc, svg, theme, fonts, grid, kind, color, ctx, legend, info, layout, home, W, H };
54
+ state.domains = domains ?? home;
55
+ state.hidden = hidden;
56
+ state.panelLayer = svg.appendChild(svgElement(doc, "g", { class: "pt-panels" }));
57
+ if (legend) state.legendG = svg.appendChild(placedLegend(state));
58
+ drawPanels(state);
59
+ return state;
60
+ }
61
+
62
+ export function toSVG(spec, options) {
63
+ return figure(spec, options).svg.outerHTML;
64
+ }
65
+
66
+ // Draws every panel with state.domains and state.hidden (the legend values that are switched off).
67
+ export function drawPanels(state) {
68
+ const { ns, grid, ctx, hidden } = state;
69
+ if (hidden.size || state.filtered) {
70
+ for (const p of grid.panels) p.views = ns.layers.map((layer, li) => makeView(ctx, layer, p.rows[li], hidden));
71
+ state.filtered = hidden.size > 0;
72
+ }
73
+ state.panelLayer.replaceChildren();
74
+ state.panels = grid.panels.map((p, i) => drawPanel(state, p, i));
75
+ if (state.legendG) state.legendG.replaceWith((state.legendG = placedLegend(state)));
76
+ }
77
+
78
+ function placedLegend(state) {
79
+ const g = drawLegend(state.doc, state.legend, state.theme, state.fonts, state.hidden);
80
+ g.setAttribute("transform", `translate(${state.layout.legend.x},${state.layout.legend.y})`);
81
+ return g;
82
+ }
83
+
84
+ function drawPanel(state, p, i) {
85
+ const { ns, doc, theme, fonts, kind, color } = state;
86
+ const box = state.layout.panels[i];
87
+ const domain = { x: plotDomain(kind.x, state.domains[i].x), y: plotDomain(kind.y, state.domains[i].y) };
88
+ const bandwidth = { x: kind.x === "category" ? (box.innerW / (domain.x.length + 0.2)) * 0.8 : 0 };
89
+ const layers = ns.layers.map((layer, li) => {
90
+ const view = p.views[li];
91
+ const c = context({ view, layer, kind, color, theme, fonts, domain, bandwidth });
92
+ return { layer, view, c, marks: view.n ? LAYERS[layer.type](c) : [] };
93
+ });
94
+ const axis = (k) => {
95
+ const t = ticks(kind[k], state.domains[i][k], box.count[k], ns.scales[k]?.format);
96
+ const inside = (v) => kind[k] === "category" || (+v >= +domain[k][0] && +v <= +domain[k][1]);
97
+ return { major: t.major.filter(inside), minor: t.minor, format: t.format };
98
+ };
99
+ const ax = axis("x");
100
+ const ay = axis("y");
101
+ const gridStyle = (minor) => ({ stroke: minor ? theme.gridMinor : theme.grid, strokeOpacity: 1, strokeWidth: minor ? 0.6 : 0.9 });
102
+ const axisStyle = { tickSize: TICK.size, tickPadding: TICK.padding, fontSize: fonts.tick, stroke: theme.tick, fill: theme.muted, label: null };
103
+ const marks = [];
104
+ if (kind.x !== "category") marks.push(Plot.gridX(ax.minor, gridStyle(true)), Plot.gridX(ax.major, gridStyle(false)));
105
+ if (kind.y !== "category") marks.push(Plot.gridY(ay.minor, gridStyle(true)), Plot.gridY(ay.major, gridStyle(false)));
106
+ marks.push(...layers.flatMap((l) => l.marks));
107
+ if (box.showX) {
108
+ const rotate = box.rotateX ? { tickRotate: -40, textAnchor: "end" } : {};
109
+ marks.push(Plot.axisX(ax.major, { ...axisStyle, ...rotate, anchor: "bottom", tickFormat: ax.format }));
110
+ }
111
+ if (box.showY) marks.push(Plot.axisY(ay.major, { ...axisStyle, anchor: "left", tickFormat: ay.format }));
112
+ const plot = Plot.plot({
113
+ document: doc,
114
+ width: box.w,
115
+ height: box.h,
116
+ marginLeft: box.ml,
117
+ marginRight: 0,
118
+ marginTop: 0,
119
+ marginBottom: box.mb,
120
+ x: scaleOptions(kind.x, domain.x),
121
+ y: scaleOptions(kind.y, domain.y),
122
+ color: { type: "identity" },
123
+ marks,
124
+ });
125
+ const g = svgElement(doc, "g", { class: "pt-panel", "data-panel": i, transform: `translate(${box.x},${box.y})`, fill: "currentColor", "text-anchor": "middle" });
126
+ for (const child of [...plot.childNodes]) if (child.nodeName.toLowerCase() !== "style") g.append(child);
127
+ state.panelLayer.append(g);
128
+ return { g, plot, box, layers };
129
+ }
130
+
131
+ // Channel accessors of one layer view in one panel, for layers.js. Time values become Date objects.
132
+ function context({ view, layer, kind, color, theme, fonts, domain, bandwidth }) {
133
+ const axisOf = (ch) => (AXIS_CHANNELS.x.includes(ch) ? "x" : AXIS_CHANNELS.y.includes(ch) ? "y" : null);
134
+ const cache = {};
135
+ const v = (ch) => {
136
+ if (ch in cache) return cache[ch];
137
+ let values = view.cols[ch];
138
+ if (values && kind[axisOf(ch)] === "time") values = values.map((t) => (t === null ? null : new Date(t)));
139
+ return (cache[ch] = values);
140
+ };
141
+ const colorCh = layer.channels.color;
142
+ return {
143
+ index: d3.range(view.n),
144
+ v,
145
+ options: layer.options,
146
+ colors: (fallback) => (!colorCh ? fallback : "value" in colorCh ? colorCh.value : v("color").map(color.apply)),
147
+ groups: () => v("group") ?? (colorCh?.column ? v("color") : undefined),
148
+ kind,
149
+ theme,
150
+ fonts,
151
+ domain,
152
+ bandwidth,
153
+ };
154
+ }
155
+
156
+ function plotDomain(kind, d) {
157
+ return kind === "time" ? d.map((t) => new Date(t)) : d;
158
+ }
159
+
160
+ function scaleOptions(kind, domain) {
161
+ const type = { linear: "linear", log: "log", time: "utc", category: "band" }[kind];
162
+ return { type, domain, axis: null, ...(kind === "category" ? { padding: 0.2, round: false } : {}) };
163
+ }
164
+
165
+ // The first view of each panel. A free axis has one domain per panel (wrap) or per row or column (grid).
166
+ function homeDomains(ns, grid, kind) {
167
+ const free = ns.facet?.free ?? { x: false, y: false };
168
+ const groupOf = (axis, p, i) => (!free[axis] ? "all" : grid.kind === "grid" ? (axis === "x" ? `c${p.col}` : `r${p.row}`) : `p${i}`);
169
+ const domains = grid.panels.map(() => ({}));
170
+ for (const axis of ["x", "y"]) {
171
+ const groups = d3.group(grid.panels.map((p, i) => [p, i]), ([p, i]) => groupOf(axis, p, i));
172
+ for (const members of groups.values()) {
173
+ const d = homeDomain(ns, axis, kind[axis], members.map(([p]) => p));
174
+ for (const [, i] of members) domains[i][axis] = d;
175
+ }
176
+ }
177
+ return domains;
178
+ }
179
+
180
+ // Set limits win; a null bound (or no limits) takes the trained bound, with its 5% space.
181
+ // On a category axis, the limits are the categories and their order.
182
+ function homeDomain(ns, axis, kind, panels) {
183
+ const limits = ns.scales[axis]?.limits;
184
+ if (limits && (kind === "category" || limits.every((v) => v !== null))) return limits;
185
+ const trained = trainDomain(ns, axis, kind, panels);
186
+ return limits ? [limits[0] ?? trained[0], limits[1] ?? trained[1]] : trained;
187
+ }
188
+
189
+ function strip(doc, s, theme, fonts) {
190
+ const g = svgElement(doc, "g", { class: "pt-strip" });
191
+ g.append(svgElement(doc, "rect", { x: s.x, y: s.y, width: s.w, height: s.h, fill: theme.strip }));
192
+ const attrs = s.vertical
193
+ ? { transform: `translate(${s.x + s.w / 2},${s.y + s.h / 2}) rotate(90)`, dy: "0.35em" }
194
+ : { x: s.x + s.w / 2, y: s.y + s.h / 2, dy: "0.35em" };
195
+ const text = svgText(doc, s.text, { ...attrs, "text-anchor": "middle", "font-weight": "bold", "font-size": fonts.strip, fill: theme.ink });
196
+ if (s.text !== String(s.full)) text.append(svgText(doc, s.full, {}, "title"));
197
+ g.append(text);
198
+ return g;
199
+ }
package/src/hover.js ADDED
@@ -0,0 +1,296 @@
1
+ // Hover: finds what is under the pointer in one panel, and the content of its label box. One mode per kind of layer:
2
+ // - lines and areas (line, step, area, density, smooth, ribbon): every series at the nearest x, with a crosshair;
3
+ // - bars and bins: the whole stack (or dodge group) at that x, with the total of a stack;
4
+ // - box: the quartiles of the box; tile: the value of the cell;
5
+ // - points (and error bars): the nearest point. It stays picked until another point is clearly nearer.
6
+ import * as d3 from "d3";
7
+ import { hoverFormat } from "./scales.js";
8
+
9
+ const RADIUS = 40; // px: the largest distance to a point or a line
10
+ const CLOSE = 10; // px: a point this close wins over the lines
11
+ const SNAP = 6; // px: series whose nearest x is this close to the snapped x share the label
12
+ const KEEP = 4; // px: a new point must be this much nearer to replace the picked one
13
+ const MAX_ROWS = 6; // more series than this at one x: the label shows only the series under the pointer
14
+ const UNIFIED = ["line", "step", "area", "density", "smooth", "ribbon"];
15
+ const SHAPES = ["bar", "hist", "tile", "box"];
16
+ const POINTS = ["point", "path", "errorbar"];
17
+
18
+ // (x, y): px inside panel i. previous: the last hit (for hysteresis). Returns a hit or null.
19
+ export function pick(state, i, x, y, previous) {
20
+ const panel = state.panels[i];
21
+ const sx = panel.plot.scale("x");
22
+ const sy = panel.plot.scale("y");
23
+ const px = (v) => sx.apply(v) + (sx.bandwidth ?? 0) / 2;
24
+ const py = (v) => sy.apply(v) + (sy.bandwidth ?? 0) / 2;
25
+ const ctx = { state, sx, sy, px, py };
26
+ const box = boxUnderAny(ctx, panel, x);
27
+ if (box) return { ...box, panel: i };
28
+ const point = nearestPoint(ctx, panel, x, y, previous?.panel === i ? previous : null);
29
+ if (point && point.d <= CLOSE) return { ...pointHit(ctx, point), panel: i };
30
+ const lines = unified(ctx, panel, x, y, previous?.panel === i ? previous : null);
31
+ if (lines) return { ...lines, panel: i };
32
+ const shape = shapeUnder(ctx, panel, x, y);
33
+ if (shape) return { ...shape, panel: i };
34
+ if (point) return { ...pointHit(ctx, point), panel: i };
35
+ return null;
36
+ }
37
+
38
+ function seriesColor(state, layer, view, c, k) {
39
+ const ch = layer.channels.color;
40
+ if (!ch) return layer.type === "smooth" ? state.theme.accent : state.theme.palette[0];
41
+ return "value" in ch ? ch.value : state.color.apply(view.cols.color[k]);
42
+ }
43
+
44
+ function fmt(state, axis, v) {
45
+ if (v === null || v === undefined || Number.isNaN(v)) return "–";
46
+ if (axis && state.kind[axis] === "time") return timeFormat(state, axis)(v);
47
+ if (axis && state.kind[axis] === "category") return String(v);
48
+ return typeof v === "number" ? hoverFormat.number(v) : String(v);
49
+ }
50
+
51
+ // The time format of the hover label follows the data: months, days, minutes or seconds.
52
+ function timeFormat(state, axis) {
53
+ state.timeFormats ??= {};
54
+ if (state.timeFormats[axis]) return state.timeFormats[axis];
55
+ const values = state.grid.panels.flatMap((p) => p.views.flatMap((v) => v.cols[axis] ?? [])).filter((t) => t !== null);
56
+ const all = (test) => values.every((t) => test(new Date(t)));
57
+ const pattern = all((d) => d.getUTCDate() === 1 && d.getUTCHours() === 0 && d.getUTCMinutes() === 0)
58
+ ? "%Y-%m"
59
+ : all((d) => d.getUTCHours() === 0 && d.getUTCMinutes() === 0)
60
+ ? "%Y-%m-%d"
61
+ : all((d) => d.getUTCSeconds() === 0)
62
+ ? "%m-%d %H:%M"
63
+ : "%m-%d %H:%M:%S";
64
+ return (state.timeFormats[axis] = d3.utcFormat(pattern));
65
+ }
66
+
67
+ function fieldText(state, f, k) {
68
+ return f.values2 ? `${fmt(state, f.axis, f.values[k])} – ${fmt(state, f.axis, f.values2[k])}` : fmt(state, f.axis, f.values[k]);
69
+ }
70
+
71
+ // --- points ---------------------------------------------------------------------------------
72
+
73
+ function nearestPoint({ px, py }, panel, x, y, previous) {
74
+ let best = null;
75
+ let kept = null;
76
+ for (const { layer, view, c } of panel.layers) {
77
+ if (!POINTS.includes(layer.type) || !view.hover || !view.n) continue;
78
+ const { hx, hy } = view.hover;
79
+ for (let k = 0; k < view.n; k++) {
80
+ if (hx[k] === null || hy[k] === null) continue;
81
+ const d = Math.hypot(px(hx[k]) - x, py(hy[k]) - y);
82
+ if (d > RADIUS) continue;
83
+ const hit = { layer, view, c, k, d, x: px(hx[k]), y: py(hy[k]) };
84
+ if (!best || d < best.d) best = hit;
85
+ if (previous?.layer === layer && previous.k === k) kept = hit;
86
+ }
87
+ }
88
+ return kept && best && best.d > kept.d - KEEP ? kept : best;
89
+ }
90
+
91
+ function pointHit({ state }, { layer, view, c, k, x, y, d }) {
92
+ const color = seriesColor(state, layer, view, c, k);
93
+ const name = layer.channels.color?.column ? fmt(state, null, view.cols.color[k]) : null;
94
+ // A row that repeats the header (x and color map the same column) is left out.
95
+ const rows = view.hover.fields.map((f) => ({ label: f.label, value: fieldText(state, f, k) })).filter((r) => r.value !== name);
96
+ return { layer, k, d, color, header: name, rows, marks: [{ x, y, color }], anchor: { x, y } };
97
+ }
98
+
99
+ // --- lines and areas: every series at the nearest x -------------------------------------------
100
+
101
+ function unified({ state, px, py }, panel, x, y, previous) {
102
+ const candidates = [];
103
+ for (const { layer, view, c } of panel.layers) {
104
+ if (!UNIFIED.includes(layer.type) || !view.hover || !view.n) continue;
105
+ const { hx, hy } = view.hover;
106
+ const series = c.groups() ?? [];
107
+ const nearest = new Map(); // series → candidate
108
+ for (let k = 0; k < view.n; k++) {
109
+ if (hx[k] === null || hy[k] === null) continue;
110
+ const dx = Math.abs(px(hx[k]) - x);
111
+ const key = series[k] ?? "";
112
+ if (dx <= RADIUS && (!nearest.has(key) || dx < nearest.get(key).dx)) nearest.set(key, { layer, view, c, k, dx, series: key });
113
+ }
114
+ candidates.push(...nearest.values());
115
+ }
116
+ if (!candidates.length) return null;
117
+ const snap = d3.least(candidates, (m) => m.dx);
118
+ const snapX = px(snap.view.hover.hx[snap.k]);
119
+ const members = candidates.filter((m) => Math.abs(px(m.view.hover.hx[m.k]) - snapX) <= SNAP);
120
+ for (const m of members) m.y = py(m.view.hover.hy[m.k]);
121
+ // The series under the pointer. It stays picked until another series is clearly nearer (noisy, crossing lines).
122
+ let strong = d3.least(members, (m) => Math.abs(m.y - y));
123
+ const kept = previous?.series !== undefined && members.find((m) => m.layer === previous.layer && m.series === previous.series);
124
+ if (kept && Math.abs(kept.y - y) < Math.abs(strong.y - y) + KEEP) strong = kept;
125
+ const name = (m) =>
126
+ m.layer.channels.color?.column ? fmt(state, null, m.view.cols.color[m.k]) : m.series !== "" ? String(m.series) : (m.view.hover.fields[1]?.label ?? m.layer.type);
127
+ const color = (m) => seriesColor(state, m.layer, m.view, m.c, m.k);
128
+ const xField = snap.view.hover.fields[0];
129
+ const xText = fmt(state, "x", snap.view.hover.hx[snap.k]);
130
+ const base = { crosshair: snapX, anchor: { x: snapX, y }, layer: strong.layer, series: strong.series };
131
+
132
+ // Many series: only the one under the pointer, like a single line; the others are counted.
133
+ if (members.length === 1 || members.length > MAX_ROWS) {
134
+ const rows = [{ label: xField?.label ?? "x", value: xText }, { label: strong.view.hover.fields[1]?.label ?? "y", value: unifiedValue(state, strong.layer, strong.view, strong.k) }];
135
+ const others = members.length - 1;
136
+ return { ...base, color: color(strong), header: name(strong), rows, note: others ? `${others} other series at this x` : null, marks: [{ x: snapX, y: strong.y, color: color(strong) }] };
137
+ }
138
+
139
+ // A few series: one row each, in the legend order; the one under the pointer in bold.
140
+ const order = seriesOrder(state);
141
+ members.sort((a, b) => order(a) - order(b) || a.y - b.y);
142
+ const rows = members.map((m) => ({ swatch: color(m), label: name(m), value: unifiedValue(state, m.layer, m.view, m.k), strong: m === strong }));
143
+ return {
144
+ ...base,
145
+ color: state.theme.ink,
146
+ header: xField ? `${xField.label} ${xText}` : xText,
147
+ rows,
148
+ marks: members.map((m) => ({ x: snapX, y: m.y, color: color(m) })),
149
+ };
150
+ }
151
+
152
+ // A sort key of a series: the legend position of its color, its color value (a number), or the plot order.
153
+ function seriesOrder(state) {
154
+ const { color } = state;
155
+ return (m) => {
156
+ const v = m.layer.channels.color?.column ? m.view.cols.color[m.k] : null;
157
+ if (v === null) return state.ns.layers.indexOf(m.layer);
158
+ if (color.kind === "ordinal") return color.domain.indexOf(v);
159
+ return typeof v === "number" ? v : 0;
160
+ };
161
+ }
162
+
163
+ function unifiedValue(state, layer, view, k) {
164
+ const f = view.hover.fields;
165
+ if (layer.type === "smooth" && f[2]) return `${fieldText(state, f[1], k)} (${fieldText(state, f[2], k)})`;
166
+ return fieldText(state, f[1], k);
167
+ }
168
+
169
+ // --- bars, bins, tiles and boxes ------------------------------------------------------------
170
+
171
+ // A box wins over the points drawn with it (a box with jittered points).
172
+ function boxUnderAny(ctx, panel, x) {
173
+ for (const { layer, view, c } of panel.layers) if (layer.type === "box" && view.n) return boxUnder(ctx, layer, view, c, x);
174
+ return null;
175
+ }
176
+
177
+ // Bars and bins: the column under the pointer (gaps between columns included), the segment under the pointer
178
+ // or else the top one. Tiles: the cell under the pointer (band padding included).
179
+ function shapeUnder(ctx, panel, x, y) {
180
+ const { state, sx, sy } = ctx;
181
+ for (const { layer, view, c } of panel.layers) {
182
+ if (!SHAPES.includes(layer.type) || layer.type === "box" || !view.n) continue;
183
+ const tile = layer.type === "tile";
184
+ let best = null;
185
+ for (let k = 0; k < view.n; k++) {
186
+ const [x0, x1] = shapeX(layer, view, k, sx);
187
+ // Tiles: the cell itself plus its padding. Bars: the nearest column, up to one bar width away (gaps included).
188
+ const dx = Math.abs((x0 + x1) / 2 - x);
189
+ if (tile ? x < x0 - pad(sx, x1 - x0) || x > x1 + pad(sx, x1 - x0) : dx > x1 - x0) continue;
190
+ const [ya, yb] = shapeY(layer, view, k, sy);
191
+ const [y0, y1] = [Math.min(ya, yb), Math.max(ya, yb)];
192
+ const padY = tile ? pad(sy, y1 - y0) : 0;
193
+ const inside = y >= y0 - padY && y <= y1 + padY;
194
+ if (tile && !inside) continue;
195
+ // The nearest column first; in it, the segment under the pointer, or else the top segment.
196
+ const score = Math.round(dx) * 1e6 + (inside ? 0 : 1e5 + y0);
197
+ if (!best || score < best.score) best = { k, score, rect: { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } };
198
+ }
199
+ if (!best) continue;
200
+ const { k, rect } = best;
201
+ const color = seriesColor(state, layer, view, c, k);
202
+ if (tile) {
203
+ const label = state.ns.labels.color ?? layer.channels.color?.column ?? "value";
204
+ const value = layer.channels.color?.column ? fmt(state, null, view.cols.color[k]) : null;
205
+ const rows = view.hover.fields.filter((f) => f.label !== label).map((f) => ({ label: f.label, value: fieldText(state, f, k) }));
206
+ return { color, header: value !== null ? `${label} ${value}` : null, rows, marks: [{ rect, color: state.theme.ink }], anchor: { x: rect.x + rect.width, y } };
207
+ }
208
+ return stackHit(state, layer, view, c, k, rect);
209
+ }
210
+ return null;
211
+ }
212
+
213
+ // Half of the free space around a band (category) cell, in px.
214
+ function pad(scale, size) {
215
+ return scale.bandwidth !== undefined ? (scale.step - scale.bandwidth) / 2 || size * 0.125 : 0.5;
216
+ }
217
+
218
+ // All segments at the x of segment k: one row per color, the hovered one in bold, and the total of a stack.
219
+ function stackHit(state, layer, view, c, k, rect) {
220
+ const sameX = layer.type === "hist" ? (j) => view.cols.xmin[j] === view.cols.xmin[k] : (j) => String(view.cols.x[j]) === String(view.cols.x[k]);
221
+ const at = d3.range(view.n).filter(sameX);
222
+ const colored = !!layer.channels.color?.column;
223
+ const rows = at
224
+ .sort((a, b) => (colored && state.color.kind === "ordinal" ? state.color.domain.indexOf(view.cols.color[a]) - state.color.domain.indexOf(view.cols.color[b]) : view.cols.ymax[b] - view.cols.ymax[a]))
225
+ .map((j) => ({
226
+ swatch: seriesColor(state, layer, view, c, j),
227
+ label: colored ? fmt(state, null, view.cols.color[j]) : view.hover.fields[1].label,
228
+ value: fmt(state, null, view.cols.value[j]),
229
+ strong: j === k && at.length > 1,
230
+ }));
231
+ const position = layer.options.position ?? "stack";
232
+ if (position === "stack" && at.length > 1) rows.push({ label: "total", value: fmt(state, null, d3.sum(at, (j) => view.cols.value[j])), strong: false, total: true });
233
+ const xf = view.hover.fields[0];
234
+ return { color: seriesColor(state, layer, view, c, k), header: `${xf.label} ${fieldText(state, xf, k)}`, rows, marks: [{ rect, color: state.theme.ink }], anchor: { x: rect.x + rect.width, y: rect.y } };
235
+ }
236
+
237
+ function boxUnder({ state, sx }, layer, view, c, x) {
238
+ if (sx.bandwidth === undefined) return null;
239
+ const k = d3.range(view.n).find((j) => {
240
+ const x0 = sx.apply(view.cols.x[j]);
241
+ return x >= x0 && x <= x0 + sx.bandwidth;
242
+ });
243
+ if (k === undefined) return null;
244
+ const key = String(view.cols.x[k]);
245
+ const values = d3.range(view.n).filter((j) => String(view.cols.x[j]) === key).map((j) => view.cols.y[j]).filter((v) => v !== null).sort(d3.ascending);
246
+ const [q1, median, q3] = [0.25, 0.5, 0.75].map((p) => d3.quantile(values, p));
247
+ const iqr = q3 - q1;
248
+ const low = d3.min(values.filter((v) => v >= q1 - 1.5 * iqr));
249
+ const high = d3.max(values.filter((v) => v <= q3 + 1.5 * iqr));
250
+ const outliers = values.filter((v) => v < low || v > high).length;
251
+ const f = (v) => fmt(state, "y", v);
252
+ const rows = [
253
+ { label: "max (whisker)", value: f(high) },
254
+ { label: "q3", value: f(q3) },
255
+ { label: "median", value: f(median), strong: true },
256
+ { label: "q1", value: f(q1) },
257
+ { label: "min (whisker)", value: f(low) },
258
+ { label: "n", value: String(values.length) },
259
+ ];
260
+ if (outliers) rows.push({ label: "outliers", value: String(outliers) });
261
+ const x0 = sx.apply(view.cols.x[k]);
262
+ const color = layer.channels.color?.column ? seriesColor(state, layer, view, c, k) : state.theme.ink;
263
+ return { color, header: `${view.hover.fields[0].label} ${key}`, rows, marks: [], anchor: { x: x0 + sx.bandwidth, y: null } };
264
+ }
265
+
266
+ function shapeX(layer, view, k, sx) {
267
+ if (view.cols.xmin && layer.type !== "tile") return [sx.apply(view.cols.xmin[k]), sx.apply(view.cols.xmax[k])];
268
+ if (sx.bandwidth !== undefined) {
269
+ const slots = view.cols.slots?.[k] ?? 1;
270
+ const slice = sx.bandwidth / slots;
271
+ const x0 = sx.apply(view.cols.x[k]) + (view.cols.slot?.[k] ?? 0) * slice;
272
+ return [x0, x0 + slice];
273
+ }
274
+ const half = halfStep(view.cols.x, sx);
275
+ const cx = sx.apply(view.cols.x[k]);
276
+ return [cx - half, cx + half];
277
+ }
278
+
279
+ function shapeY(layer, view, k, sy) {
280
+ if (layer.type === "tile") {
281
+ if (sy.bandwidth !== undefined) return [sy.apply(view.cols.y[k]), sy.apply(view.cols.y[k]) + sy.bandwidth];
282
+ const half = halfStep(view.cols.y, sy);
283
+ const cy = sy.apply(view.cols.y[k]);
284
+ return [cy - half, cy + half];
285
+ }
286
+ return [sy.apply(view.cols.ymin[k]), sy.apply(view.cols.ymax[k])];
287
+ }
288
+
289
+ // Half of the smallest step between values, in px.
290
+ function halfStep(values, scale) {
291
+ const sorted = [...new Set(values.map(Number))].sort((a, b) => a - b);
292
+ let step = Infinity;
293
+ for (let i = 1; i < sorted.length; i++) step = Math.min(step, sorted[i] - sorted[i - 1]);
294
+ if (!Number.isFinite(step)) return 6;
295
+ return Math.abs(scale.apply(sorted[0] + step) - scale.apply(sorted[0])) / 2;
296
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // Public API: plot (the builder), draw (browser output), toSVG (static SVG string).
2
+ export { plot } from "./builder.js";
3
+ export { draw } from "./interact.js";
4
+ export { toSVG } from "./figure.js";
package/src/info.js ADDED
@@ -0,0 +1,83 @@
1
+ // Info table: the key/value entries of info(), as a card in the right column or below the plot.
2
+ import * as d3 from "d3";
3
+ import { svgElement, svgText } from "./svg.js";
4
+ import { fitText, textWidth, wrapText } from "./text.js";
5
+
6
+ const VALUE_LINES = 3; // a long value wraps to this many lines
7
+ const KEY_SHARE = 0.45; // the key column takes at most this share of the table width
8
+ const MAX_WIDTH = { right: 0.3, bottom: 0.9 }; // share of the figure width
9
+ const MAX_HEIGHT = { right: 0.6, bottom: 0.3 }; // share of the figure height; the rest of the entries become "+N more"
10
+ const MAX_COLUMNS = 3; // columns of entries in a bottom card
11
+
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 }) {
14
+ if (!ns.info) return null;
15
+ const position = narrow ? "bottom" : ns.info.position;
16
+ return model(ns, fonts, ns.info.entries, position, W, H * MAX_HEIGHT[position]);
17
+ }
18
+
19
+ function model(ns, fonts, entries, position, W, maxHeight) {
20
+ const metrics = ns.theme.metrics;
21
+ const size = fonts.legend;
22
+ const pad = Math.round(size * 0.8);
23
+ const lineH = size * 1.35;
24
+ const keyGap = size;
25
+ const colGap = size * 2;
26
+ const all = entries.map(([key, value]) => ({ key: String(key), value: value === null ? "–" : String(value) }));
27
+ const width = (t) => textWidth(t, size, false, metrics);
28
+
29
+ // One column of rows, at most maxWidth wide: the keys on the left, the values wrapped on the right.
30
+ const column = (items, maxWidth) => {
31
+ const keyW = Math.min(d3.max(items, (r) => width(r.key)) ?? 0, maxWidth * KEY_SHARE);
32
+ const valueMax = Math.max(size * 3, maxWidth - keyW - keyGap);
33
+ let y = 0;
34
+ const laid = items.map((r) => {
35
+ const lines = wrapText(r.value, size, valueMax, false, metrics, VALUE_LINES);
36
+ const row = { key: fitText(r.key, size, keyW, false, metrics), full: r.key, lines, y };
37
+ y += lines.length * lineH;
38
+ return row;
39
+ });
40
+ const valueW = d3.max(laid, (r) => d3.max(r.lines, width)) ?? 0;
41
+ return { rows: laid, keyW, width: keyW + keyGap + valueW, height: y };
42
+ };
43
+
44
+ const inner = W * MAX_WIDTH[position] - 2 * pad;
45
+ const table = (rows) => {
46
+ let columns;
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));
53
+ } else columns = [column(rows, inner)];
54
+ let x = pad;
55
+ for (const c of columns) (c.x = x), (x += c.width + colGap);
56
+ return { columns, width: x - colGap + pad, height: (d3.max(columns, (c) => c.height) ?? 0) + 2 * pad };
57
+ };
58
+ // Drop entries from the end until the table fits; the last row tells how many are not shown.
59
+ let kept = all.length;
60
+ let t = table(all);
61
+ while (t.height > maxHeight && kept > 1) {
62
+ kept--;
63
+ t = table([...all.slice(0, kept), { key: `+${all.length - kept} more`, value: "" }]);
64
+ }
65
+ return { position, size, pad, lineH, keyGap, ...t };
66
+ }
67
+
68
+ // The table as an SVG group at (x, y): keys in the muted color, values in the ink color, on a strip-colored card.
69
+ export function drawInfo(doc, model, theme, { x, y }) {
70
+ const g = svgElement(doc, "g", { class: "pt-info", transform: `translate(${x},${y})`, "font-size": model.size });
71
+ g.append(svgElement(doc, "rect", { width: model.width, height: model.height, fill: theme.strip }));
72
+ const baseline = model.lineH * 0.75;
73
+ for (const c of model.columns) {
74
+ for (const r of c.rows) {
75
+ const top = model.pad + r.y + baseline;
76
+ const key = svgText(doc, r.key, { x: c.x, y: top, fill: theme.muted, "text-anchor": "start" });
77
+ if (r.key !== r.full) key.append(svgText(doc, r.full, {}, "title"));
78
+ g.append(key);
79
+ r.lines.forEach((line, i) => g.append(svgText(doc, line, { x: c.x + c.keyW + model.keyGap, y: top + i * model.lineH, fill: theme.ink, "text-anchor": "start" })));
80
+ }
81
+ }
82
+ return g;
83
+ }