plotto 0.3.1 → 0.4.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/README.md +20 -22
- package/grammar.json +1 -1
- package/package.json +1 -1
- package/src/builder.js +1 -0
- package/src/copy.js +13 -6
- package/src/figure.js +20 -7
- package/src/hover.js +4 -8
- package/src/interact.js +13 -0
- package/src/layers.js +23 -3
- package/src/layout.js +62 -12
- package/src/legend.js +27 -12
- package/src/stats.js +10 -0
- package/src/theme.js +1 -0
- package/src/toolbar.js +1 -0
- package/theme.json +3 -3
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") #
|
|
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")`:
|
|
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,26 @@ 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
|
-
|
|
199
|
+
```bash
|
|
200
|
+
# 1. Merge your changes into main through a pull request
|
|
198
201
|
|
|
199
|
-
|
|
202
|
+
# 2. Prepare the release
|
|
203
|
+
git switch main && git pull && source .venv/bin/activate
|
|
204
|
+
scripts/release.sh 0.3.1
|
|
205
|
+
# → the pull request page opens: create it, get it approved and merged
|
|
200
206
|
|
|
201
|
-
|
|
202
|
-
|
|
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`:
|
|
207
|
+
# 3. Publish
|
|
208
|
+
scripts/release.sh tag # answer y
|
|
212
209
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
210
|
+
# 4. Check
|
|
211
|
+
npm view plotto version && pip index versions python-plotto
|
|
212
|
+
```
|
|
216
213
|
|
|
217
|
-
|
|
218
|
-
|
|
214
|
+
- `scripts/release.sh X.Y.Z` checks that `main` is current and the version is new. 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`, builds `docs/`, commits and pushes.
|
|
215
|
+
- `scripts/release.sh tag` tags the merged `main` as `vX.Y.Z`, asks before it pushes the tag, and deletes the release branch.
|
|
216
|
+
- The pushed tag runs `.github/workflows/release.yml`, which publishes to PyPI and npm. Follow it in the Actions tab.
|
|
219
217
|
|
|
220
218
|
To upgrade Observable Plot or d3:
|
|
221
219
|
|
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
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
|
-
|
|
7
|
-
|
|
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(
|
|
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 *
|
|
32
|
-
canvas.height = height *
|
|
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,7 @@ 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
|
|
18
19
|
|
|
19
20
|
// options: document (the browser or jsdom), width (lay out at this width), domains and hidden (keep a view after a resize).
|
|
20
21
|
export function figure(spec, { document: doc = globalThis.document, width, domains, hidden = new Set() } = {}) {
|
|
@@ -22,7 +23,7 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
|
|
|
22
23
|
const theme = ns.theme;
|
|
23
24
|
const W = Math.round(width ?? theme.width);
|
|
24
25
|
const reflowed = width && theme.reflow && !theme.fixedHeight;
|
|
25
|
-
|
|
26
|
+
let H = reflowed ? Math.max(MIN_HEIGHT, Math.round((W * theme.height) / theme.width)) : theme.height;
|
|
26
27
|
if (theme.legend === "right" && W < NARROW) theme.legend = "bottom";
|
|
27
28
|
const fonts = fontSizes(theme);
|
|
28
29
|
const grid = facetPanels(ns);
|
|
@@ -32,10 +33,20 @@ export function figure(spec, { document: doc = globalThis.document, width, domai
|
|
|
32
33
|
for (const p of grid.panels) p.views = ns.layers.map((layer, li) => makeView(ctx, layer, p.rows[li], new Set()));
|
|
33
34
|
const home = homeDomains(ns, grid, kind);
|
|
34
35
|
const horizontal = theme.legend === "bottom";
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
const arrange = (H) => {
|
|
37
|
+
const info = infoModel(ns, fonts, { W, H, narrow: W < NARROW });
|
|
38
|
+
const above = info?.position === "right" ? info.height + fonts.base : 0; // the info card sits above a right legend
|
|
39
|
+
const legend = legendModel(ns, color, fonts, { horizontal, maxWidth: W * 0.9, maxHeight: H * 0.8 - above });
|
|
40
|
+
return { info, legend, layout: place({ ns, grid, kind, home, legend, info, fonts, W, H }) };
|
|
41
|
+
};
|
|
42
|
+
let { info, legend, layout } = arrange(H);
|
|
43
|
+
// aspect: when the height follows the width, it changes so that the panels get their shape, up to 1.2 times the
|
|
44
|
+
// usual height; beyond that, the panels get narrower.
|
|
45
|
+
if (theme.aspect && theme.fitHeight) {
|
|
46
|
+
const fit = H + grid.nrow * (layout.room.w * theme.aspect - layout.room.h);
|
|
47
|
+
H = Math.round(Math.min(H * ASPECT_GROWTH, Math.max(MIN_HEIGHT, fit)));
|
|
48
|
+
({ info, legend, layout } = arrange(H));
|
|
49
|
+
}
|
|
39
50
|
|
|
40
51
|
const svg = svgElement(doc, "svg", {
|
|
41
52
|
xmlns: "http://www.w3.org/2000/svg",
|
|
@@ -106,7 +117,9 @@ function drawPanel(state, p, i) {
|
|
|
106
117
|
marks.push(...layers.flatMap((l) => l.marks));
|
|
107
118
|
if (box.showX) {
|
|
108
119
|
const rotate = box.rotateX ? { tickRotate: -40, textAnchor: "end" } : {};
|
|
109
|
-
|
|
120
|
+
const wrapped = (v) => box.wrapX.get(String(v))?.join("\n") ?? ax.format(v);
|
|
121
|
+
const wrap = box.wrapX.size ? { tickFormat: wrapped, lineHeight: LINE } : { tickFormat: ax.format };
|
|
122
|
+
marks.push(Plot.axisX(ax.major, { ...axisStyle, ...rotate, anchor: "bottom", ...wrap }));
|
|
110
123
|
}
|
|
111
124
|
if (box.showY) marks.push(Plot.axisY(ay.major, { ...axisStyle, anchor: "left", tickFormat: ay.format }));
|
|
112
125
|
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
|
|
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(
|
|
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/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:
|
|
125
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
37
|
-
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
return { kind: "swatches", title, titleH, key, rowH, items, width: Math.max(titleW, x - fonts.legend), height
|
|
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:
|
|
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 =
|
|
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
|
};
|