plotto 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,7 @@ p.interactive() # the same plot with zoom, pan, hover and le
26
26
  p.theme("wota") # another style
27
27
  p.color(palette="aizome") # another palette
28
28
  p.save("fig.png") # PNG; .svg for vector files
29
- p.theme("wota", format="slide").save("slide.png") # 1280 × 720 px, large text
29
+ p.theme("wota", format="slide").save("slide.png") # 960 × 600 px, large text
30
30
  ```
31
31
 
32
32
  ```js
@@ -111,6 +111,7 @@ The table is a card on the right of the plot, or below it. A plot narrower than
111
111
  - A legend click hides or shows a series. Two quick clicks on the same entry (within 300 ms) show only that series.
112
112
  - The toolbar shows at the top right when the pointer is on the plot:
113
113
  - copy as PNG;
114
+ - copy for a slide: the current view in the slide format, which pastes into Google Slides at 6.72 × 4.2 in (75% of the slide height), sharp;
114
115
  - download as PNG or SVG;
115
116
  - in interactive mode, also zoom box, pan, zoom in, zoom out and reset.
116
117
  - The output loads d3 and Observable Plot from `cdn.jsdelivr.net`. GitHub, nbviewer, or a page without network access shows the static SVG in the output.
@@ -125,10 +126,11 @@ The table is a card on the right of the plot, or below it. A plot narrower than
125
126
 
126
127
  | Target | Use |
127
128
  |---|---|
128
- | Google Slides | PNG: `p.save("fig.png")`. Google Slides does not accept SVG. |
129
+ | Google Slides | The "Copy for a slide" button, then paste under the slide title: 6.72 × 4.2 in, with room for comments on the right. Or a PNG: `p.save("fig.png")`. Google Slides does not accept SVG. |
129
130
  | PowerPoint, Keynote, documents | SVG: `p.save("fig.svg")`. The SVG uses the installed fonts. |
130
- | A slide layout | `p.theme(style, format="slide")`: 1280 × 720 px with large text |
131
+ | A slide layout | `p.theme(style, format="slide")`: 960 × 600 px with large text |
131
132
  | A report column | `p.theme(style, format="report")`: 640 × 400 px |
133
+ | Square panels | `p.theme(aspect=1)`, for observed-against-predicted plots. Give x and y the same `limits`. |
132
134
  | A given size | `p.save("fig.png", width=1600)` lays the plot out again at 1600 px. |
133
135
  | From the notebook | Use the copy button, which copies the current view as PNG. |
134
136
 
@@ -192,30 +194,27 @@ One-time setup:
192
194
 
193
195
  4. On npmjs.com, open the package `plotto` → Settings → Trusted publishing → GitHub Actions: repository `theoradusz/plotto`, workflow `release.yml`. Later versions then publish from GitHub with no token. The workflow skips a version that is already on npm.
194
196
 
195
- Each release:
197
+ Each release (`main` takes changes only through a reviewed pull request):
196
198
 
197
- `main` takes changes only through a reviewed pull request, so a release has two steps.
199
+ ```bash
200
+ # 1. Merge your changes into main through a pull request,
201
+ # with a line for each change under "## Unreleased" in CHANGELOG.md
198
202
 
199
- 1. On `main`, with no uncommitted changes and the Python environment of the repo active, prepare the release:
203
+ # 2. Prepare the release
204
+ git switch main && git pull && source .venv/bin/activate
205
+ scripts/release.sh 0.3.1
206
+ # → the pull request page opens: create it, get it approved and merged
200
207
 
201
- ```
202
- scripts/release.sh 0.3.0
203
- ```
204
-
205
- The script:
206
- - checks that `main` is up to date with `origin/main` and that the version is new and higher;
207
- - runs `npm test`, `npm run browser-test`, `pytest python/tests`, `ruff` and `black`;
208
- - on a new branch `release/v0.3.0`, sets the version in `package.json`, `package-lock.json` and `python/plotto/__init__.py`, and builds the site in `docs/`;
209
- - commits "release 0.3.0", pushes the branch and opens the pull request page.
210
- 2. Create the pull request, then have it reviewed and merged.
211
- 3. Tag the merged `main`:
208
+ # 3. Publish
209
+ scripts/release.sh tag # answer y
212
210
 
213
- ```
214
- scripts/release.sh tag
215
- ```
211
+ # 4. Check
212
+ npm view plotto version && pip index versions python-plotto
213
+ ```
216
214
 
217
- The script pulls `main` and reads the version. It asks before it tags and pushes `v0.3.0`, then deletes the release branch.
218
- 4. The pushed tag starts the release in the Actions tab. The packages are then at pypi.org/project/python-plotto and npmjs.com/package/plotto.
215
+ - `scripts/release.sh X.Y.Z` checks that `main` is current, that the version is new and that `CHANGELOG.md` has changes under "Unreleased". It runs every test and lint. Then, on the branch `release/vX.Y.Z`, it sets the version in `package.json`, `package-lock.json` and `python/plotto/__init__.py`, moves the "Unreleased" changes to a dated `X.Y.Z` section, builds `docs/`, commits and pushes.
216
+ - `scripts/release.sh tag` tags the merged `main` as `vX.Y.Z`, asks before it pushes the tag, and deletes the release branch.
217
+ - The pushed tag runs `.github/workflows/release.yml`, which publishes to PyPI and npm. Follow it in the Actions tab.
219
218
 
220
219
  To upgrade Observable Plot or d3:
221
220
 
package/grammar.json CHANGED
@@ -46,7 +46,7 @@
46
46
  "grid": ["free"],
47
47
  "free": ["x", "y", "both"],
48
48
  "labels": ["title", "subtitle", "x", "y", "color", "caption"],
49
- "theme": ["format", "width", "height", "size", "legend"],
49
+ "theme": ["format", "width", "height", "size", "legend", "aspect"],
50
50
  "legend": ["right", "bottom", "none"],
51
51
  "info": ["position"],
52
52
  "infoPosition": ["right", "bottom"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotto",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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
@@ -122,6 +122,7 @@ class Builder {
122
122
  if (!(style in THEMES.styles)) fail("theme()", "style", style, Object.keys(THEMES.styles));
123
123
  if (options.format !== undefined && !(options.format in THEMES.formats)) fail("theme()", "format", options.format, Object.keys(THEMES.formats));
124
124
  if (options.legend !== undefined && !GRAMMAR.legend.includes(options.legend)) fail("theme()", "legend", options.legend, GRAMMAR.legend);
125
+ if (options.aspect != null && !(typeof options.aspect === "number" && options.aspect > 0)) throw new Error("theme(): aspect must be a positive number (panel height / width), for example 1 for square panels");
125
126
  return this.#with({ theme: { style, ...options } });
126
127
  }
127
128
 
package/src/copy.js CHANGED
@@ -3,10 +3,17 @@
3
3
  const SCALE = 2; // PNG px per figure px
4
4
 
5
5
  // Copies a PNG to the clipboard; if the browser refuses, downloads it. Returns "Copied" or "Downloaded".
6
- export async function copyPNG(svg) {
7
- const png = rasterize(svg);
6
+ // size: the display size in px of the pasted image. The PNG then has scale times these pixels, and an HTML <img>
7
+ // with this size goes along: Google Slides pastes the HTML at that size (96 px per inch) and keeps the sharp PNG.
8
+ export async function copyPNG(svg, size, scale = SCALE) {
9
+ const png = rasterize(svg, size ? (scale * size.width) / svg.viewBox.baseVal.width : scale);
10
+ const items = { "image/png": png.then(toBlob) };
11
+ if (size) {
12
+ const img = (c) => `<img src="${c.toDataURL("image/png")}" width="${size.width}" height="${size.height}">`;
13
+ items["text/html"] = png.then((c) => new Blob([img(c)], { type: "text/html" }));
14
+ }
8
15
  try {
9
- await navigator.clipboard.write([new ClipboardItem({ "image/png": png.then(toBlob) })]);
16
+ await navigator.clipboard.write([new ClipboardItem(items)]);
10
17
  return "Copied";
11
18
  } catch {
12
19
  save((await png).toDataURL("image/png"), "plot.png");
@@ -22,14 +29,14 @@ export function downloadSVG(svg) {
22
29
  save(dataURL(svg), "plot.svg");
23
30
  }
24
31
 
25
- async function rasterize(svg) {
32
+ async function rasterize(svg, scale = SCALE) {
26
33
  const img = new Image();
27
34
  img.src = dataURL(svg);
28
35
  await img.decode();
29
36
  const { width, height } = svg.viewBox.baseVal;
30
37
  const canvas = document.createElement("canvas");
31
- canvas.width = width * SCALE;
32
- canvas.height = height * SCALE;
38
+ canvas.width = Math.round(width * scale);
39
+ canvas.height = Math.round(height * scale);
33
40
  canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
34
41
  return canvas;
35
42
  }
package/src/figure.js CHANGED
@@ -6,7 +6,7 @@ import { facetPanels } from "./facets.js";
6
6
  import { drawInfo, infoModel } from "./info.js";
7
7
  import { LAYERS } from "./layers.js";
8
8
  import { drawLegend, legendModel } from "./legend.js";
9
- import { place, TICK } from "./layout.js";
9
+ import { LINE, place, TICK } from "./layout.js";
10
10
  import { AXIS_CHANNELS, axisKind, colorScale, ticks, trainDomain } from "./scales.js";
11
11
  import { normalize } from "./spec.js";
12
12
  import { svgElement, svgText } from "./svg.js";
@@ -15,6 +15,9 @@ import { layerEdges, makeView } from "./views.js";
15
15
 
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
+ 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
18
21
 
19
22
  // options: document (the browser or jsdom), width (lay out at this width), domains and hidden (keep a view after a resize).
20
23
  export function figure(spec, { document: doc = globalThis.document, width, domains, hidden = new Set() } = {}) {
@@ -22,9 +25,8 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
22
25
  const theme = ns.theme;
23
26
  const W = Math.round(width ?? theme.width);
24
27
  const reflowed = width && theme.reflow && !theme.fixedHeight;
25
- const H = reflowed ? Math.max(MIN_HEIGHT, Math.round((W * theme.height) / theme.width)) : theme.height;
28
+ let H = reflowed ? Math.max(MIN_HEIGHT, Math.round((W * theme.height) / theme.width)) : theme.height;
26
29
  if (theme.legend === "right" && W < NARROW) theme.legend = "bottom";
27
- const fonts = fontSizes(theme);
28
30
  const grid = facetPanels(ns);
29
31
  const kind = { x: axisKind(ns, "x"), y: axisKind(ns, "y") };
30
32
  const color = colorScale(ns);
@@ -32,10 +34,33 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
32
34
  for (const p of grid.panels) p.views = ns.layers.map((layer, li) => makeView(ctx, layer, p.rows[li], new Set()));
33
35
  const home = homeDomains(ns, grid, kind);
34
36
  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 });
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) => {
46
+ 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
+ }
54
+ };
55
+ let fonts, info, legend, layout;
56
+ ({ fonts, info, legend, layout } = arrange(H));
57
+ // aspect: when the height follows the width, it changes so that the panels get their shape, up to 1.2 times the
58
+ // usual height; beyond that, the panels get narrower.
59
+ if (theme.aspect && theme.fitHeight) {
60
+ const fit = H + grid.nrow * (layout.room.w * theme.aspect - layout.room.h);
61
+ H = Math.round(Math.min(H * ASPECT_GROWTH, Math.max(MIN_HEIGHT, fit)));
62
+ ({ fonts, info, legend, layout } = arrange(H));
63
+ }
39
64
 
40
65
  const svg = svgElement(doc, "svg", {
41
66
  xmlns: "http://www.w3.org/2000/svg",
@@ -106,7 +131,9 @@ function drawPanel(state, p, i) {
106
131
  marks.push(...layers.flatMap((l) => l.marks));
107
132
  if (box.showX) {
108
133
  const rotate = box.rotateX ? { tickRotate: -40, textAnchor: "end" } : {};
109
- marks.push(Plot.axisX(ax.major, { ...axisStyle, ...rotate, anchor: "bottom", tickFormat: ax.format }));
134
+ const wrapped = (v) => box.wrapX.get(String(v))?.join("\n") ?? ax.format(v);
135
+ const wrap = box.wrapX.size ? { tickFormat: wrapped, lineHeight: LINE } : { tickFormat: ax.format };
136
+ marks.push(Plot.axisX(ax.major, { ...axisStyle, ...rotate, anchor: "bottom", ...wrap }));
110
137
  }
111
138
  if (box.showY) marks.push(Plot.axisY(ay.major, { ...axisStyle, anchor: "left", tickFormat: ay.format }));
112
139
  const plot = Plot.plot({
package/src/hover.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // - points (and error bars): the nearest point. It stays picked until another point is clearly nearer.
6
6
  import * as d3 from "d3";
7
7
  import { hoverFormat } from "./scales.js";
8
+ import { boxStats } from "./stats.js";
8
9
 
9
10
  const RADIUS = 40; // px: the largest distance to a point or a line
10
11
  const CLOSE = 10; // px: a point this close wins over the lines
@@ -242,12 +243,7 @@ function boxUnder({ state, sx }, layer, view, c, x) {
242
243
  });
243
244
  if (k === undefined) return null;
244
245
  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;
246
+ const { n, q1, median, q3, low, high, outliers } = boxStats(d3.range(view.n).filter((j) => String(view.cols.x[j]) === key).map((j) => view.cols.y[j]));
251
247
  const f = (v) => fmt(state, "y", v);
252
248
  const rows = [
253
249
  { label: "max (whisker)", value: f(high) },
@@ -255,9 +251,9 @@ function boxUnder({ state, sx }, layer, view, c, x) {
255
251
  { label: "median", value: f(median), strong: true },
256
252
  { label: "q1", value: f(q1) },
257
253
  { label: "min (whisker)", value: f(low) },
258
- { label: "n", value: String(values.length) },
254
+ { label: "n", value: String(n) },
259
255
  ];
260
- if (outliers) rows.push({ label: "outliers", value: String(outliers) });
256
+ if (outliers.length) rows.push({ label: "outliers", value: String(outliers.length) });
261
257
  const x0 = sx.apply(view.cols.x[k]);
262
258
  const color = layer.channels.color?.column ? seriesColor(state, layer, view, c, k) : state.theme.ink;
263
259
  return { color, header: `${view.hover.fields[0].label} ${key}`, rows, marks: [], anchor: { x: x0 + sx.bandwidth, y: null } };
package/src/info.js CHANGED
@@ -62,7 +62,9 @@ function model(ns, fonts, entries, position, W, maxHeight) {
62
62
  kept--;
63
63
  t = table([...all.slice(0, kept), { key: `+${all.length - kept} more`, value: "" }]);
64
64
  }
65
- return { position, size, pad, lineH, keyGap, ...t };
65
+ // cut: the entries that do not show, plus the keys that end with an ellipsis.
66
+ const keys = t.columns.flatMap((c) => c.rows).filter((r) => r.key !== r.full).length;
67
+ return { position, size, pad, lineH, keyGap, cut: all.length - kept + keys, ...t };
66
68
  }
67
69
 
68
70
  // The table as an SVG group at (x, y): keys in the muted color, values in the ink color, on a strip-colored card.
package/src/interact.js CHANGED
@@ -12,6 +12,18 @@ const DRAG_MIN = 5; // px: a smaller drag is a click
12
12
  const MIN_WIDTH = 320; // px
13
13
  const ZOOM_STEP = 0.75; // zoom in keeps 75% of the range
14
14
  const DOUBLE = 300; // ms: two legend clicks within this time are a double-click
15
+ // The pasted size of "Copy for a slide", at 96 px per inch: 6.72 × 4.2 in, 75% of the height of a 16:9 Google slide.
16
+ export const SLIDE_SIZE = { width: 645, height: 403 };
17
+ // PNG px per pasted px: 288 px per inch, and 1935 px wide, under the ~2000 px above which Slides may compress images.
18
+ const SLIDE_SCALE = 3;
19
+
20
+ // The current view (zoom and hidden series) drawn again in the slide format, for "Copy for a slide".
21
+ export function slideFigure(spec, state) {
22
+ const s = typeof spec?.spec === "function" ? spec.spec() : spec;
23
+ const theme = { ...s.theme, format: "slide", width: null, height: null, size: null };
24
+ const domains = state.domains === state.home ? undefined : state.domains;
25
+ return figure({ ...s, theme }, { document: state.doc, domains, hidden: state.hidden });
26
+ }
15
27
 
16
28
  // Returns a controller: { state (the current figure), redraw(), destroy() }.
17
29
  export function draw(spec, el, { interactive = false, width } = {}) {
@@ -42,6 +54,7 @@ export function draw(spec, el, { interactive = false, width } = {}) {
42
54
  const groups = [
43
55
  [
44
56
  { name: "copy", title: "Copy as PNG", action: (b) => copyPNG(ctl.state.svg).then((how) => bar.flash("copy", how)) },
57
+ { name: "slide", title: "Copy for a slide", action: () => copyPNG(slideFigure(spec, ctl.state).svg, SLIDE_SIZE, SLIDE_SCALE).then((how) => bar.flash("slide", how)) },
45
58
  { name: "download", title: "Download", menu: [["PNG", () => downloadPNG(ctl.state.svg)], ["SVG", () => downloadSVG(ctl.state.svg)]] },
46
59
  ],
47
60
  ];
package/src/layers.js CHANGED
@@ -3,6 +3,7 @@
3
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
4
  import * as Plot from "@observablehq/plot";
5
5
  import * as d3 from "d3";
6
+ import { boxStats } from "./stats.js";
6
7
 
7
8
  const DASH = { solid: undefined, dashed: "6,4", dotted: "1.5,3", dashdot: "6,3,1.5,3" };
8
9
  const clip = "frame";
@@ -121,9 +122,28 @@ export const LAYERS = {
121
122
  return [rule, cap("ymin"), cap("ymax")];
122
123
  },
123
124
 
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, clip }),
126
- ],
125
+ // One Tukey box per category, in the series color: a light box from q1 to q3, a bold median, whiskers with caps,
126
+ // and the outliers as small dots (they blend with jittered points drawn on top).
127
+ box: (c) => {
128
+ const [x, y, colors] = [c.v("x"), c.v("y"), c.colors(c.theme.muted)]; // gray without a color mapping
129
+ const boxes = [];
130
+ for (const [key, rows] of d3.group(c.index, (i) => x[i])) {
131
+ const stats = boxStats(rows.map((i) => y[i]));
132
+ if (stats.n) boxes.push({ key, color: Array.isArray(colors) ? colors[rows[0]] : colors, ...stats });
133
+ }
134
+ const outliers = boxes.flatMap((b) => b.outliers.map((v) => ({ key: b.key, v, color: b.color })));
135
+ const inset = (share) => ({ insetLeft: c.bandwidth.x * share, insetRight: c.bandwidth.x * share });
136
+ const line = { x: "key", stroke: "color", strokeWidth: 1.25, clip };
137
+ return [
138
+ Plot.ruleX(boxes, { ...line, y1: "q3", y2: "high" }),
139
+ Plot.ruleX(boxes, { ...line, y1: "low", y2: "q1" }),
140
+ Plot.tickY(boxes, { ...line, y: "high", ...inset(0.38) }),
141
+ Plot.tickY(boxes, { ...line, y: "low", ...inset(0.38) }),
142
+ Plot.barY(boxes, { ...line, y1: "q1", y2: "q3", fill: "color", fillOpacity: c.options.alpha ?? 0.3, ...inset(0.22) }),
143
+ Plot.tickY(boxes, { x: "key", y: "median", stroke: c.theme.ink, strokeWidth: 2.25, ...inset(0.22), clip }),
144
+ Plot.dot(outliers, { x: "key", y: "v", fill: "color", r: c.theme.point, clip }),
145
+ ];
146
+ },
127
147
  };
128
148
 
129
149
  // Overlaid fills are see-through, stacked fills are solid.
package/src/layout.js CHANGED
@@ -6,6 +6,8 @@ import { fitText, textWidth, wrapText } from "./text.js";
6
6
 
7
7
  export const TICK = { size: 4, padding: 3 };
8
8
  const ROTATE = 40; // degrees of rotated category labels
9
+ const WRAP_LINES = 3; // a category label wider than its band wraps to this many lines, before any rotation
10
+ export const LINE = 1.15; // line height of wrapped category labels, in em
9
11
  const TOOLBAR = 0.22; // share of the width kept free of the title, for the toolbar
10
12
 
11
13
  export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
@@ -43,38 +45,83 @@ export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
43
45
  // Two passes: the tick count depends on the panel size, and the margins depend on the tick labels.
44
46
  let innerW = area.w / grid.ncol;
45
47
  let innerH = area.h / grid.nrow;
46
- let ml, mb, count, rotateX;
48
+ let ml, mb, mt, count, rotateX, wrapX;
47
49
  for (let pass = 0; pass < 2; pass++) {
48
50
  count = { x: Math.max(2, innerW / (kind.x === "time" ? 110 : 75)), y: Math.max(2, innerH / 45) };
49
51
  ml = Array(grid.ncol).fill(0);
50
52
  mb = Array(grid.nrow).fill(0);
51
53
  rotateX = false;
54
+ wrapX = new Map();
55
+ mt = 0;
52
56
  let xLabelW = 0;
53
57
  grid.panels.forEach((p, i) => {
54
58
  if (showY(p)) {
55
59
  const t = ticks(kind.y, home[i].y, count.y, ns.scales.y?.format);
56
60
  const w = d3.max(t.major, (v) => tw(t.format(v), fonts.tick)) ?? 0;
57
61
  ml[p.col] = Math.max(ml[p.col], Math.ceil(w + TICK.size + TICK.padding + 3));
62
+ // A tick at the top edge has half its label above the panel: move the panels down to keep it off the subtitle.
63
+ if (p.row === 0 && !wrapStrip && !topStrip && kind.y !== "category") {
64
+ const [lo, hi] = home[i].y;
65
+ const y = (kind.y === "log" ? d3.scaleLog() : d3.scaleLinear()).domain([lo, hi]).range([innerH, 0]);
66
+ const top = d3.max(t.major.filter((v) => +v >= +lo && +v <= +hi));
67
+ if (top !== undefined) mt = Math.max(mt, Math.ceil(fonts.tick * 0.6 - y(top)));
68
+ }
58
69
  }
59
70
  if (kind.x === "category" && showX(p, i)) {
60
71
  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;
72
+ const room = (innerW / (labels.length + 0.2)) * 0.95;
73
+ for (const v of labels) {
74
+ const w = tw(v, fonts.tick);
75
+ xLabelW = Math.max(xLabelW, w);
76
+ if (w <= room) continue;
77
+ // Wrap at spaces first; a word that is still too wide, or a label that needs more lines, rotates them all.
78
+ const lines = wrapText(v, fonts.tick, room, false, metrics, WRAP_LINES);
79
+ if (lines.at(-1).endsWith("…") || d3.max(lines, (l) => tw(l, fonts.tick)) > room) rotateX = true;
80
+ else wrapX.set(String(v), lines);
81
+ }
64
82
  }
65
83
  });
66
- const xLabelH = rotateX ? Math.min(xLabelW, innerH * 0.6) * Math.sin((ROTATE * Math.PI) / 180) + fonts.tick : fonts.tick * 1.2;
84
+ // Rotated labels get 60% of the panel height (at least 10 em); a longer one ends with an ellipsis.
85
+ const rotated = Math.min(xLabelW, Math.max(innerH * 0.6, fonts.tick * 10));
86
+ if (rotateX) {
87
+ wrapX.clear();
88
+ grid.panels.forEach((p, i) => {
89
+ if (kind.x !== "category" || !showX(p, i)) return;
90
+ for (const v of home[i].x) if (tw(v, fonts.tick) > rotated) wrapX.set(String(v), [fitText(v, fonts.tick, rotated, false, metrics)]);
91
+ });
92
+ }
93
+ const xLines = rotateX ? 1 : (d3.max(wrapX.values(), (l) => l.length) ?? 1);
94
+ const xLabelH = rotateX ? rotated * Math.sin((ROTATE * Math.PI) / 180) + fonts.tick : fonts.tick * (1.2 + LINE * (xLines - 1));
95
+ // A rotated label reaches left of its tick: widen the left margin so that the first one is not cut off.
96
+ if (rotateX) {
97
+ grid.panels.forEach((p, i) => {
98
+ if (p.col !== 0 || !showX(p, i)) return;
99
+ const step = innerW / (home[i].x.length + 0.2);
100
+ const first = Math.min(tw(home[i].x[0], fonts.tick), rotated);
101
+ const reach = first * Math.cos((ROTATE * Math.PI) / 180) + fonts.tick * 0.5;
102
+ ml[0] = Math.max(ml[0], Math.ceil(reach - 0.6 * step));
103
+ });
104
+ }
67
105
  grid.panels.forEach((p, i) => {
68
106
  if (showX(p, i)) mb[p.row] = Math.max(mb[p.row], Math.ceil(xLabelH + TICK.size + TICK.padding + 3));
69
107
  });
70
108
  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;
109
+ innerH = (area.h - mt - d3.sum(mb) - grid.nrow * wrapStrip - topStrip - gap * (grid.nrow - 1)) / grid.nrow;
72
110
  }
73
111
  innerW = Math.max(20, innerW);
74
112
  innerH = Math.max(20, innerH);
113
+ // theme aspect (panel height / width): the panels shrink in one direction to get that shape.
114
+ const room = { w: innerW, h: innerH }; // the panel size before the aspect
115
+ if (theme.aspect) {
116
+ if (innerH > innerW * theme.aspect) innerH = innerW * theme.aspect;
117
+ else innerW = innerH / theme.aspect;
118
+ }
119
+ // The space the aspect leaves free goes half on each side, so the panels stay centered.
120
+ const slackY = ((room.h - innerH) * grid.nrow) / 2;
121
+ const slackX = ((room.w - innerW) * grid.ncol) / 2;
75
122
 
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)]);
123
+ const colX = d3.cumsum([area.x + slackX, ...ml.slice(0, -1).map((m) => m + innerW + gap)]);
124
+ const rowY = d3.cumsum([area.y + mt + topStrip + slackY, ...mb.slice(0, -1).map((m) => wrapStrip + innerH + m + gap)]);
78
125
  const panels = grid.panels.map((p, i) => ({
79
126
  x: colX[p.col],
80
127
  y: rowY[p.row] + wrapStrip,
@@ -86,6 +133,7 @@ export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
86
133
  innerH,
87
134
  count,
88
135
  rotateX,
136
+ wrapX,
89
137
  showX: showX(p, i),
90
138
  showY: showY(p),
91
139
  }));
@@ -103,7 +151,7 @@ export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
103
151
  const lastCol = panels.find((b, i) => grid.panels[i].col === grid.ncol - 1) ?? first;
104
152
  const innerLeft = first.x + first.ml;
105
153
  const innerRight = lastCol.x + lastCol.w;
106
- const panelsBottom = area.y + area.h;
154
+ const panelsBottom = d3.max(panels, (b) => b.y + b.h);
107
155
  const texts = [];
108
156
  let y = pad;
109
157
  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);
@@ -111,10 +159,12 @@ export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
111
159
  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
160
  if (yTitle) {
113
161
  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 } });
162
+ texts.push({ text: yTitle, attrs: { transform: `translate(${pad + slackX + fonts.axisTitle * 0.85},${cy}) rotate(-90)`, "text-anchor": "middle", "font-size": fonts.axisTitle, fill: theme.muted } });
115
163
  }
116
164
  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;
165
+ // The right column is at the right edge, or next to the panels when the aspect makes them narrower.
166
+ const beside = innerRight + rightStrip + s * 1.5;
167
+ const rightX = beside < W - pad - rightW - 1 ? beside : W - pad - rightW;
118
168
  const infoAt = !info
119
169
  ? null
120
170
  : info.position === "right"
@@ -126,5 +176,5 @@ export function place({ ns, grid, kind, home, legend, info, fonts, W, H }) {
126
176
  : legend.horizontal
127
177
  ? { x: Math.max(pad, (innerLeft + innerRight - legend.width) / 2), y: H - pad - legend.height }
128
178
  : { 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 };
179
+ return { panels, strips, texts, legend: legendAt, info: infoAt, area, room, rotateX };
130
180
  }
package/src/legend.js CHANGED
@@ -2,11 +2,13 @@
2
2
  import * as d3 from "d3";
3
3
  import { ticks } from "./scales.js";
4
4
  import { svgElement, svgText } from "./svg.js";
5
- import { fitText, textWidth } from "./text.js";
5
+ import { fitText, textWidth, wrapText } from "./text.js";
6
6
 
7
7
  const KEY = 18; // swatch width, px
8
8
  const GAP = 6;
9
9
  const RAMP = { thick: 12, long: 150, slices: 48 };
10
+ const LABEL_LINES = 3; // a long label of a right legend wraps to this many lines
11
+ const LINE = 1.25; // line height of wrapped labels, in em
10
12
  const LINE_LAYERS = ["line", "path", "step", "density", "smooth", "hline", "vline", "abline", "segment", "errorbar"];
11
13
  const FILL_LAYERS = ["bar", "hist", "area", "ribbon", "rect", "tile", "box"];
12
14
 
@@ -31,22 +33,33 @@ export function legendModel(ns, color, fonts, { horizontal, maxWidth, maxHeight
31
33
  const mapped = ns.layers.filter((l) => l.channels.color?.column);
32
34
  const key = mapped.some((l) => FILL_LAYERS.includes(l.type)) ? "square" : mapped.some((l) => LINE_LAYERS.includes(l.type)) ? "line" : "point";
33
35
  const rowH = Math.round(fonts.legend * 1.75);
34
- const maxLabel = Math.max(80, (horizontal ? maxWidth : 220) - KEY - GAP);
36
+ const lineH = fonts.legend * LINE;
37
+ // A right legend keeps a narrow column: its long labels wrap. A bottom legend has one line per label.
38
+ const maxLabel = horizontal ? Math.max(80, maxWidth - KEY - GAP) : Math.max(110, Math.min(200, maxWidth * 0.2));
35
39
  const items = color.domain.map((value) => {
36
- const label = fitText(value, fonts.legend, maxLabel, false, metrics);
37
- return { value, label, color: color.apply(value), w: KEY + GAP + width(label, fonts.legend) };
40
+ const lines = horizontal ? [fitText(value, fonts.legend, maxLabel, false, metrics)] : wrapText(value, fonts.legend, maxLabel, false, metrics, LABEL_LINES);
41
+ const w = KEY + GAP + (d3.max(lines, (l) => width(l, fonts.legend)) ?? 0);
42
+ return { value, label: lines.join(" "), lines, color: color.apply(value), w, h: rowH + (lines.length - 1) * lineH };
38
43
  });
39
44
  if (!horizontal) {
40
45
  // Columns of rows, so that a long legend fits the panel height.
41
- const perColumn = Math.max(1, Math.floor((maxHeight - titleH) / rowH));
42
- const columns = d3.range(Math.ceil(items.length / perColumn)).map((k) => items.slice(k * perColumn, (k + 1) * perColumn));
46
+ const columns = [[]];
47
+ let y = titleH;
48
+ for (const it of items) {
49
+ if (y + it.h > maxHeight && columns.at(-1).length) {
50
+ columns.push([]);
51
+ y = titleH;
52
+ }
53
+ columns.at(-1).push(Object.assign(it, { y }));
54
+ y += it.h;
55
+ }
43
56
  let x = 0;
44
57
  for (const column of columns) {
45
- column.forEach((it, i) => Object.assign(it, { x, y: titleH + i * rowH }));
58
+ for (const it of column) it.x = x;
46
59
  x += (d3.max(column, (it) => it.w) ?? 0) + fonts.legend;
47
60
  }
48
- const rows = Math.min(items.length, perColumn);
49
- return { kind: "swatches", title, titleH, key, rowH, items, width: Math.max(titleW, x - fonts.legend), height: titleH + rows * rowH, horizontal };
61
+ const height = d3.max(columns, (column) => column.at(-1).y + column.at(-1).h) ?? titleH;
62
+ return { kind: "swatches", title, titleH, key, rowH, lineH, items, width: Math.max(titleW, x - fonts.legend), height, horizontal };
50
63
  }
51
64
  let x = 0;
52
65
  let y = titleH;
@@ -56,7 +69,7 @@ export function legendModel(ns, color, fonts, { horizontal, maxWidth, maxHeight
56
69
  x += it.w + fonts.legend * 1.5;
57
70
  }
58
71
  const w = Math.max(titleW, d3.max(items, (it) => it.x + it.w) ?? 0);
59
- return { kind: "swatches", title, titleH, key, rowH, items, width: w, height: y + rowH, horizontal };
72
+ return { kind: "swatches", title, titleH, key, rowH, lineH, items, width: w, height: y + rowH, horizontal };
60
73
  }
61
74
 
62
75
  export function drawLegend(doc, model, theme, fonts, hidden = new Set()) {
@@ -70,14 +83,16 @@ export function drawLegend(doc, model, theme, fonts, hidden = new Set()) {
70
83
  function swatch(doc, model, it, i, theme, off) {
71
84
  const mid = model.rowH / 2;
72
85
  const g = svgElement(doc, "g", { class: "pt-key", "data-index": i, transform: `translate(${it.x},${it.y})`, opacity: off ? 0.35 : 1 });
73
- const hit = svgElement(doc, "rect", { width: it.w, height: model.rowH, fill: "transparent" });
86
+ const hit = svgElement(doc, "rect", { width: it.w, height: it.h, fill: "transparent" });
74
87
  const mark =
75
88
  model.key === "line"
76
89
  ? svgElement(doc, "line", { x1: 1, x2: KEY - 1, y1: mid, y2: mid, stroke: it.color, "stroke-width": Math.max(2.5, theme.line + 1) })
77
90
  : model.key === "point"
78
91
  ? svgElement(doc, "circle", { cx: KEY / 2, cy: mid, r: Math.max(3.5, theme.point + 1), fill: it.color })
79
92
  : svgElement(doc, "rect", { x: 3, y: mid - 6, width: 12, height: 12, fill: it.color });
80
- const label = svgText(doc, it.label, { x: KEY + GAP, y: mid, dy: "0.35em", fill: theme.muted, "text-decoration": off ? "line-through" : null });
93
+ const label = svgElement(doc, "text", { x: KEY + GAP, y: mid, dy: "0.35em", fill: theme.muted, "text-decoration": off ? "line-through" : null });
94
+ it.lines.forEach((line, k) => label.append(k ? svgText(doc, line, { x: KEY + GAP, dy: model.lineH }, "tspan") : doc.createTextNode(line)));
95
+ if (it.lines.at(-1).endsWith("…")) label.append(svgText(doc, it.value, {}, "title"));
81
96
  g.append(hit, mark, label);
82
97
  return g;
83
98
  }
package/src/stats.js CHANGED
@@ -102,6 +102,16 @@ export function bin(cols, n, edges, density) {
102
102
  return out;
103
103
  }
104
104
 
105
+ // Tukey box of one group: quartiles, whiskers at the last values within 1.5 IQR of the box, and the outliers.
106
+ export function boxStats(values) {
107
+ const v = values.filter((y) => y !== null && Number.isFinite(y)).sort(d3.ascending);
108
+ const [q1, median, q3] = [0.25, 0.5, 0.75].map((p) => d3.quantile(v, p));
109
+ const iqr = q3 - q1;
110
+ const low = d3.min(v.filter((y) => y >= q1 - 1.5 * iqr));
111
+ const high = d3.max(v.filter((y) => y <= q3 + 1.5 * iqr));
112
+ return { n: v.length, q1, median, q3, low, high, outliers: v.filter((y) => y < low || y > high) };
113
+ }
114
+
105
115
  // Gaussian kernel density per color, with the Silverman bandwidth, on a grid that covers the data ± 3 bandwidths.
106
116
  export function density(cols, n) {
107
117
  const out = { x: [], y: [], color: [], group: [] };
package/src/theme.js CHANGED
@@ -26,6 +26,7 @@ export function resolveTheme({ style = "pastel", format = "screen", legend = "ri
26
26
  // A set height alone keeps the reflow of the width and fixes the height.
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
+ theme.fitHeight = f.reflow && !theme.fixedHeight; // a screen plot: its height may follow the panel aspect
29
30
  theme.palette = palette(theme, theme.palette);
30
31
  return theme;
31
32
  }
package/src/toolbar.js CHANGED
@@ -10,6 +10,7 @@ const ICONS = {
10
10
  zoomOut: '<path d="M5 12h14"/>',
11
11
  home: '<path d="M4 11l8-7 8 7M6 9.5V20h12V9.5"/>',
12
12
  copy: '<rect x="8.5" y="8.5" width="11" height="11"/><path d="M15.5 8.5V4.5h-11v11h4"/>',
13
+ slide: '<rect x="3.5" y="4.5" width="17" height="11"/><path d="M12 15.5v4M8 19.5h8M7 12.5l3-3 2.5 2 4.5-4"/>',
13
14
  download: '<path d="M12 4v11M7.5 10.5L12 15l4.5-4.5M5 19.5h14"/>',
14
15
  check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
15
16
  };
package/theme.json CHANGED
@@ -139,9 +139,10 @@
139
139
  "reflow": false
140
140
  },
141
141
  "slide": {
142
- "width": 1280,
143
- "height": 720,
144
- "size": 20,
142
+ "width": 960,
143
+ "height": 600,
144
+ "size": 22,
145
+ "shrink": 0.7,
145
146
  "line": 2.5,
146
147
  "point": 4,
147
148
  "reflow": false