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/LICENSE +21 -0
- package/README.md +223 -2
- package/grammar.json +53 -0
- package/package.json +63 -4
- package/src/Plot.js +12 -0
- package/src/browser.js +6 -0
- package/src/builder.js +198 -0
- package/src/copy.js +56 -0
- package/src/facets.js +54 -0
- package/src/figure.js +199 -0
- package/src/hover.js +296 -0
- package/src/index.js +4 -0
- package/src/info.js +83 -0
- package/src/interact.js +364 -0
- package/src/layers.js +159 -0
- package/src/layout.js +130 -0
- package/src/legend.js +104 -0
- package/src/metrics.js +2 -0
- package/src/render.mjs +80 -0
- package/src/scales.js +159 -0
- package/src/spec.js +64 -0
- package/src/stats.js +187 -0
- package/src/svg.js +14 -0
- package/src/text.js +44 -0
- package/src/theme.js +93 -0
- package/src/toolbar.js +126 -0
- package/src/views.js +84 -0
- package/theme.json +150 -0
package/src/legend.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Legend: color swatches (a string column) or a color ramp (a number or time column), as SVG.
|
|
2
|
+
import * as d3 from "d3";
|
|
3
|
+
import { ticks } from "./scales.js";
|
|
4
|
+
import { svgElement, svgText } from "./svg.js";
|
|
5
|
+
import { fitText, textWidth } from "./text.js";
|
|
6
|
+
|
|
7
|
+
const KEY = 18; // swatch width, px
|
|
8
|
+
const GAP = 6;
|
|
9
|
+
const RAMP = { thick: 12, long: 150, slices: 48 };
|
|
10
|
+
const LINE_LAYERS = ["line", "path", "step", "density", "smooth", "hline", "vline", "abline", "segment", "errorbar"];
|
|
11
|
+
const FILL_LAYERS = ["bar", "hist", "area", "ribbon", "rect", "tile", "box"];
|
|
12
|
+
|
|
13
|
+
// The legend model with its size. horizontal: below the plot. maxWidth, maxHeight: the space for it.
|
|
14
|
+
export function legendModel(ns, color, fonts, { horizontal, maxWidth, maxHeight }) {
|
|
15
|
+
if (color.kind === "none" || ns.theme.legend === "none") return null;
|
|
16
|
+
const metrics = ns.theme.metrics;
|
|
17
|
+
const width = (t, size, bold) => textWidth(t, size, bold, metrics);
|
|
18
|
+
const title = ns.labels.color === null ? null : (ns.labels.color ?? color.column);
|
|
19
|
+
const titleH = title ? fonts.legend * 1.8 : 0;
|
|
20
|
+
const titleW = title ? width(title, fonts.legend + 1, true) : 0;
|
|
21
|
+
|
|
22
|
+
if (color.kind === "continuous") {
|
|
23
|
+
const t = ticks(color.type === "time" ? "time" : "linear", color.domain, 4);
|
|
24
|
+
const labels = t.major.map(t.format);
|
|
25
|
+
const labelW = d3.max(labels, (l) => width(l, fonts.legend)) ?? 0;
|
|
26
|
+
const long = Math.min(RAMP.long, horizontal ? maxWidth : maxHeight - titleH - 4);
|
|
27
|
+
const [w, h] = horizontal ? [long, RAMP.thick + fonts.legend * 1.6] : [RAMP.thick + GAP + labelW, long];
|
|
28
|
+
return { kind: "ramp", title, titleH, color, long, ticks: t.major, labels, width: Math.max(titleW, w), height: titleH + h, horizontal };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const mapped = ns.layers.filter((l) => l.channels.color?.column);
|
|
32
|
+
const key = mapped.some((l) => FILL_LAYERS.includes(l.type)) ? "square" : mapped.some((l) => LINE_LAYERS.includes(l.type)) ? "line" : "point";
|
|
33
|
+
const rowH = Math.round(fonts.legend * 1.75);
|
|
34
|
+
const maxLabel = Math.max(80, (horizontal ? maxWidth : 220) - KEY - GAP);
|
|
35
|
+
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) };
|
|
38
|
+
});
|
|
39
|
+
if (!horizontal) {
|
|
40
|
+
// 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));
|
|
43
|
+
let x = 0;
|
|
44
|
+
for (const column of columns) {
|
|
45
|
+
column.forEach((it, i) => Object.assign(it, { x, y: titleH + i * rowH }));
|
|
46
|
+
x += (d3.max(column, (it) => it.w) ?? 0) + fonts.legend;
|
|
47
|
+
}
|
|
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 };
|
|
50
|
+
}
|
|
51
|
+
let x = 0;
|
|
52
|
+
let y = titleH;
|
|
53
|
+
for (const it of items) {
|
|
54
|
+
if (x > 0 && x + it.w > maxWidth) [x, y] = [0, y + rowH];
|
|
55
|
+
Object.assign(it, { x, y });
|
|
56
|
+
x += it.w + fonts.legend * 1.5;
|
|
57
|
+
}
|
|
58
|
+
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 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function drawLegend(doc, model, theme, fonts, hidden = new Set()) {
|
|
63
|
+
const g = svgElement(doc, "g", { class: "pt-legend", "font-size": fonts.legend });
|
|
64
|
+
if (model.title) g.append(svgText(doc, model.title, { y: fonts.legend, "font-weight": "bold", "font-size": fonts.legend + 1, fill: theme.ink }));
|
|
65
|
+
if (model.kind === "ramp") drawRamp(doc, g, model, theme, fonts);
|
|
66
|
+
else model.items.forEach((it, i) => g.append(swatch(doc, model, it, i, theme, hidden.has(it.value))));
|
|
67
|
+
return g;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function swatch(doc, model, it, i, theme, off) {
|
|
71
|
+
const mid = model.rowH / 2;
|
|
72
|
+
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" });
|
|
74
|
+
const mark =
|
|
75
|
+
model.key === "line"
|
|
76
|
+
? 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
|
+
: model.key === "point"
|
|
78
|
+
? svgElement(doc, "circle", { cx: KEY / 2, cy: mid, r: Math.max(3.5, theme.point + 1), fill: it.color })
|
|
79
|
+
: 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 });
|
|
81
|
+
g.append(hit, mark, label);
|
|
82
|
+
return g;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function drawRamp(doc, g, model, theme, fonts) {
|
|
86
|
+
const { color, horizontal, long } = model;
|
|
87
|
+
const [lo, hi] = color.domain.map(Number);
|
|
88
|
+
const at = (v) => (Number(v) - lo) / (hi - lo || 1); // 0..1 along the ramp
|
|
89
|
+
const top = model.titleH;
|
|
90
|
+
for (let i = 0; i < RAMP.slices; i++) {
|
|
91
|
+
const t0 = i / RAMP.slices;
|
|
92
|
+
const size = long / RAMP.slices + 0.5; // overlap a little, so no gaps show
|
|
93
|
+
const fill = color.ramp(horizontal ? t0 : 1 - t0);
|
|
94
|
+
const rect = horizontal ? { x: t0 * long, y: top, width: size, height: RAMP.thick } : { x: 0, y: top + t0 * long, width: RAMP.thick, height: size };
|
|
95
|
+
g.append(svgElement(doc, "rect", { ...rect, fill }));
|
|
96
|
+
}
|
|
97
|
+
model.ticks.forEach((v, i) => {
|
|
98
|
+
const p = at(v) * long;
|
|
99
|
+
const attrs = horizontal
|
|
100
|
+
? { x: p, y: top + RAMP.thick + fonts.legend * 1.2, "text-anchor": "middle" }
|
|
101
|
+
: { x: RAMP.thick + GAP, y: top + long - p, dy: "0.35em" };
|
|
102
|
+
g.append(svgText(doc, model.labels[i], { ...attrs, fill: theme.muted }));
|
|
103
|
+
});
|
|
104
|
+
}
|
package/src/metrics.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generated by scripts/font_metrics.py. Advance widths in em of the characters 32-126, per theme font.
|
|
2
|
+
export const METRICS = {"arial": {"regular": [0.278, 0.278, 0.355, 0.556, 0.556, 0.889, 0.667, 0.191, 0.333, 0.333, 0.389, 0.584, 0.278, 0.333, 0.278, 0.278, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.278, 0.278, 0.584, 0.584, 0.584, 0.556, 1.015, 0.667, 0.667, 0.722, 0.722, 0.667, 0.611, 0.778, 0.722, 0.278, 0.5, 0.667, 0.556, 0.833, 0.722, 0.778, 0.667, 0.778, 0.722, 0.667, 0.611, 0.722, 0.667, 0.944, 0.667, 0.667, 0.611, 0.278, 0.278, 0.278, 0.469, 0.556, 0.333, 0.556, 0.556, 0.5, 0.556, 0.556, 0.278, 0.556, 0.556, 0.222, 0.222, 0.5, 0.222, 0.833, 0.556, 0.556, 0.556, 0.556, 0.333, 0.5, 0.278, 0.556, 0.5, 0.722, 0.5, 0.5, 0.5, 0.334, 0.26, 0.334, 0.584], "bold": [0.278, 0.333, 0.474, 0.556, 0.556, 0.889, 0.722, 0.238, 0.333, 0.333, 0.389, 0.584, 0.278, 0.333, 0.278, 0.278, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.333, 0.333, 0.584, 0.584, 0.584, 0.611, 0.975, 0.722, 0.722, 0.722, 0.722, 0.667, 0.611, 0.778, 0.722, 0.278, 0.556, 0.722, 0.611, 0.833, 0.722, 0.778, 0.667, 0.778, 0.722, 0.667, 0.611, 0.722, 0.667, 0.944, 0.667, 0.667, 0.611, 0.333, 0.278, 0.333, 0.584, 0.556, 0.333, 0.556, 0.611, 0.556, 0.611, 0.556, 0.333, 0.611, 0.611, 0.278, 0.278, 0.556, 0.278, 0.889, 0.611, 0.611, 0.611, 0.611, 0.389, 0.556, 0.333, 0.611, 0.556, 0.778, 0.556, 0.556, 0.5, 0.389, 0.28, 0.389, 0.584]}, "mplus": {"regular": [0.29, 0.363, 0.4, 0.678, 0.605, 0.847, 0.712, 0.237, 0.385, 0.385, 0.527, 0.733, 0.304, 0.457, 0.283, 0.507, 0.62, 0.62, 0.62, 0.62, 0.62, 0.62, 0.62, 0.62, 0.62, 0.62, 0.363, 0.384, 0.667, 0.753, 0.667, 0.611, 0.823, 0.693, 0.598, 0.695, 0.682, 0.587, 0.572, 0.745, 0.71, 0.317, 0.527, 0.633, 0.585, 0.857, 0.713, 0.793, 0.6, 0.785, 0.62, 0.573, 0.653, 0.683, 0.693, 0.953, 0.65, 0.67, 0.637, 0.47, 0.507, 0.47, 0.653, 0.583, 0.278, 0.548, 0.578, 0.522, 0.578, 0.552, 0.522, 0.573, 0.573, 0.307, 0.347, 0.562, 0.307, 0.805, 0.568, 0.57, 0.573, 0.573, 0.453, 0.497, 0.512, 0.558, 0.557, 0.81, 0.553, 0.557, 0.547, 0.513, 0.343, 0.513, 0.69], "bold": [0.304, 0.425, 0.478, 0.692, 0.625, 0.901, 0.753, 0.291, 0.426, 0.426, 0.536, 0.735, 0.351, 0.471, 0.338, 0.546, 0.64, 0.64, 0.64, 0.64, 0.64, 0.64, 0.64, 0.64, 0.64, 0.64, 0.418, 0.431, 0.681, 0.755, 0.681, 0.628, 0.818, 0.715, 0.62, 0.703, 0.696, 0.601, 0.586, 0.756, 0.735, 0.351, 0.558, 0.678, 0.6, 0.878, 0.735, 0.798, 0.628, 0.798, 0.651, 0.608, 0.668, 0.705, 0.715, 0.971, 0.678, 0.698, 0.651, 0.501, 0.546, 0.501, 0.695, 0.609, 0.336, 0.576, 0.606, 0.54, 0.606, 0.57, 0.543, 0.601, 0.605, 0.335, 0.378, 0.603, 0.335, 0.843, 0.6, 0.585, 0.601, 0.601, 0.485, 0.525, 0.533, 0.59, 0.581, 0.831, 0.585, 0.585, 0.568, 0.535, 0.381, 0.535, 0.705]}};
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Static export in Node: spec → SVG (jsdom) → PNG (resvg). The same figure code runs in the browser.
|
|
3
|
+
// node src/render.mjs spec.json out.svg|out.png [scale] one file
|
|
4
|
+
// plotto-render --serve (the npm bin)
|
|
5
|
+
// node src/render.mjs --serve JSON lines on stdin/stdout (python/plotto/output.py)
|
|
6
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
7
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { JSDOM } from "jsdom";
|
|
11
|
+
import { toSVG } from "./figure.js";
|
|
12
|
+
import { firstFamily, resolveTheme } from "./theme.js";
|
|
13
|
+
|
|
14
|
+
const { document } = new JSDOM("").window;
|
|
15
|
+
|
|
16
|
+
export function renderSVG(spec) {
|
|
17
|
+
return toSVG(spec, { document });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderPNG(spec, scale = 2) {
|
|
21
|
+
const svg = renderSVG(spec);
|
|
22
|
+
const font = firstFamily(resolveTheme(spec.theme).font);
|
|
23
|
+
const resvg = new Resvg(svg, { fitTo: { mode: "zoom", value: scale }, font: { loadSystemFonts: true, defaultFontFamily: font } });
|
|
24
|
+
return resvg.render().asPng();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The browser bundle: src/ as one ES module that sets window.plotto.
|
|
28
|
+
// cdn = true loads d3 and Plot from the CDN (notebook outputs); cdn = false puts them in the bundle (offline pages, tests).
|
|
29
|
+
// format "iife" gives a classic script, which also loads from a file:// page.
|
|
30
|
+
const bundleCache = {};
|
|
31
|
+
export async function bundle({ cdn = true, format = "esm" } = {}) {
|
|
32
|
+
const key = `${cdn}-${format}`;
|
|
33
|
+
if (bundleCache[key]) return bundleCache[key];
|
|
34
|
+
const esbuild = await import("esbuild");
|
|
35
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url)));
|
|
36
|
+
const urls = {
|
|
37
|
+
d3: `https://cdn.jsdelivr.net/npm/d3@${pkg.dependencies.d3}/+esm`,
|
|
38
|
+
"@observablehq/plot": `https://cdn.jsdelivr.net/npm/@observablehq/plot@${pkg.dependencies["@observablehq/plot"]}/+esm`,
|
|
39
|
+
};
|
|
40
|
+
const fromCDN = {
|
|
41
|
+
name: "cdn",
|
|
42
|
+
setup(build) {
|
|
43
|
+
build.onResolve({ filter: /^(d3|@observablehq\/plot)$/ }, (args) => ({ path: urls[args.path], external: true }));
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const result = await esbuild.build({
|
|
47
|
+
entryPoints: [fileURLToPath(new URL("./browser.js", import.meta.url))],
|
|
48
|
+
bundle: true,
|
|
49
|
+
write: false,
|
|
50
|
+
format: cdn ? "esm" : format,
|
|
51
|
+
minify: true,
|
|
52
|
+
plugins: cdn ? [fromCDN] : [],
|
|
53
|
+
});
|
|
54
|
+
return (bundleCache[key] = result.outputFiles[0].text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function handle(request) {
|
|
58
|
+
if (request.op === "svg") return { svg: renderSVG(request.spec) };
|
|
59
|
+
if (request.op === "png") return { png: Buffer.from(renderPNG(request.spec, request.scale)).toString("base64") };
|
|
60
|
+
if (request.op === "bundle") return { js: await bundle({ cdn: request.cdn ?? true, format: request.format }) };
|
|
61
|
+
throw new Error(`unknown op "${request.op}"`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Run as a command (directly, or through the npm bin link, which is a symlink to this file).
|
|
65
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
66
|
+
const [arg, out, scale] = process.argv.slice(2);
|
|
67
|
+
if (arg === "--serve") {
|
|
68
|
+
for await (const line of createInterface({ input: process.stdin })) {
|
|
69
|
+
const request = JSON.parse(line);
|
|
70
|
+
try {
|
|
71
|
+
process.stdout.write(JSON.stringify({ id: request.id, ...(await handle(request)) }) + "\n");
|
|
72
|
+
} catch (e) {
|
|
73
|
+
process.stdout.write(JSON.stringify({ id: request.id, error: e.message }) + "\n");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
const spec = JSON.parse(readFileSync(arg, "utf8"));
|
|
78
|
+
writeFileSync(out, out.endsWith(".png") ? renderPNG(spec, Number(scale ?? 2)) : renderSVG(spec));
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/scales.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Scales: axis types, domains, ticks, tick labels and the color scale.
|
|
2
|
+
// Time values are wall-clock epoch ms, drawn with UTC functions, so the labels show the clock time of the data.
|
|
3
|
+
import * as d3 from "d3";
|
|
4
|
+
import { colorOf, interpolator, palette } from "./theme.js";
|
|
5
|
+
|
|
6
|
+
export const AXIS_CHANNELS = { x: ["x", "xmin", "xmax", "xend"], y: ["y", "ymin", "ymax", "yend"] };
|
|
7
|
+
const EXPAND = 0.05; // 5% space on each side of a continuous axis, as in ggplot
|
|
8
|
+
const DAY = 86_400_000;
|
|
9
|
+
|
|
10
|
+
// "linear", "log", "time" or "category": the scale type, or else the type of the first mapped column.
|
|
11
|
+
export function axisKind(ns, axis) {
|
|
12
|
+
const type = ns.scales[axis]?.type;
|
|
13
|
+
if (type) return type;
|
|
14
|
+
for (const layer of ns.layers) {
|
|
15
|
+
for (const ch of AXIS_CHANNELS[axis]) {
|
|
16
|
+
const col = layer.channels[ch]?.column;
|
|
17
|
+
if (!col) continue;
|
|
18
|
+
const t = ns.tables[layer.table].columns[col].type;
|
|
19
|
+
return t === "time" ? "time" : t === "string" ? "category" : "linear";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return "linear";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The values of one channel for the given rows of the layer table; a constant repeats for each row.
|
|
26
|
+
export function channelValues(ns, layer, ch, rows) {
|
|
27
|
+
const c = layer.channels[ch];
|
|
28
|
+
if (!c) return undefined;
|
|
29
|
+
if ("value" in c) return rows.map(() => c.value);
|
|
30
|
+
const values = ns.tables[layer.table].columns[c.column].values;
|
|
31
|
+
return rows.map((i) => values[i]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The domain of one axis over the views of a set of panels. Continuous domains get the 5% expansion.
|
|
35
|
+
export function trainDomain(ns, axis, kind, panels) {
|
|
36
|
+
const values = [];
|
|
37
|
+
for (const p of panels) {
|
|
38
|
+
for (const view of p.views) {
|
|
39
|
+
for (const ch of AXIS_CHANNELS[axis]) {
|
|
40
|
+
const v = view.cols[ch];
|
|
41
|
+
if (v) for (const x of v) if (x !== null && x !== undefined && (kind === "category" || Number.isFinite(x))) values.push(x);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (kind === "category") return categories(ns, axis, values);
|
|
46
|
+
const domain = d3.extent(values.filter((v) => kind !== "log" || v > 0));
|
|
47
|
+
if (domain[0] === undefined) return kind === "log" ? [1, 10] : [0, 1];
|
|
48
|
+
return expand(domain, kind);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function categories(ns, axis, values) {
|
|
52
|
+
const levels = ns.layers.map((l) => l.channels[axis]?.column && ns.tables[l.table].columns[l.channels[axis].column].levels).find(Boolean);
|
|
53
|
+
const seen = new Set(values.map(String));
|
|
54
|
+
return levels ? levels.filter((v) => seen.has(v)) : [...seen].sort(naturalOrder);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function naturalOrder(a, b) {
|
|
58
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function expand([lo, hi], kind) {
|
|
62
|
+
if (kind === "log") {
|
|
63
|
+
const [a, b] = expand([Math.log10(lo), Math.log10(hi)], "linear");
|
|
64
|
+
return [10 ** a, 10 ** b];
|
|
65
|
+
}
|
|
66
|
+
const pad = hi > lo ? (hi - lo) * EXPAND : kind === "time" ? 3_600_000 : Math.abs(lo) * EXPAND || 1;
|
|
67
|
+
return [lo - pad, hi + pad];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Major ticks, minor ticks (midpoints, as ggplot) and the label format of one axis.
|
|
71
|
+
export function ticks(kind, domain, count, format) {
|
|
72
|
+
if (kind === "category") return { major: domain, minor: [], format: String };
|
|
73
|
+
const [lo, hi] = domain;
|
|
74
|
+
if (kind === "time") {
|
|
75
|
+
const major = d3.scaleUtc().domain([new Date(lo), new Date(hi)]).ticks(count);
|
|
76
|
+
const pattern = format ?? timePattern(major, hi - lo);
|
|
77
|
+
return { major, minor: midpoints(major, domain, (d) => +d).map((t) => new Date(t)), format: d3.utcFormat(pattern) };
|
|
78
|
+
}
|
|
79
|
+
if (kind === "log") {
|
|
80
|
+
const decades = d3.range(Math.ceil(Math.log10(lo)), Math.floor(Math.log10(hi)) + 1).map((e) => 10 ** e);
|
|
81
|
+
// Less than two decades in view: 1, 2 and 5 times the powers of ten.
|
|
82
|
+
const major = decades.length >= 2 ? decades : d3.scaleLog().domain(domain).ticks(count).filter((v) => [1, 2, 5].includes(Math.round(v / 10 ** Math.floor(Math.log10(v) + 1e-9))));
|
|
83
|
+
const minor = decades.flatMap((d) => d3.range(2, 10).map((k) => (d / 10) * k)).filter((v) => v > lo && v < hi);
|
|
84
|
+
return { major, minor, format: d3.format(format ?? "~g") };
|
|
85
|
+
}
|
|
86
|
+
const major = d3.ticks(lo, hi, count);
|
|
87
|
+
return { major, minor: midpoints(major, domain, Number), format: format ? d3.format(format) : d3.tickFormat(lo, hi, count) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function midpoints(major, [lo, hi], num) {
|
|
91
|
+
if (major.length < 2) return [];
|
|
92
|
+
const step = num(major[1]) - num(major[0]);
|
|
93
|
+
const out = [num(major[0]) - step / 2, ...major.map((t) => num(t) + step / 2)];
|
|
94
|
+
return out.filter((v) => v > lo && v < hi);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// One-line date labels: the shortest pattern that still shows the tick step.
|
|
98
|
+
function timePattern(major, span) {
|
|
99
|
+
const step = major.length >= 2 ? major[1] - major[0] : span;
|
|
100
|
+
if (step >= 28 * DAY) return "%Y-%m";
|
|
101
|
+
if (step >= DAY) return "%m-%d";
|
|
102
|
+
if (step >= 60_000) return span > DAY ? "%m-%d %H:%M" : "%H:%M";
|
|
103
|
+
return "%H:%M:%S";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Hover label formats: 4 significant digits in decimal notation (with thousands separators), and short dates.
|
|
107
|
+
const significant = d3.format(",.4~r");
|
|
108
|
+
export const hoverFormat = {
|
|
109
|
+
number: (v) => (v === null || v === undefined ? "–" : Math.abs(v) >= 1e15 || (v !== 0 && Math.abs(v) < 1e-4) ? d3.format(".3~e")(v) : significant(v)),
|
|
110
|
+
time: d3.utcFormat("%m-%d %H:%M"),
|
|
111
|
+
string: (v) => (v === null || v === undefined ? "–" : String(v)),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// The color scale of the plot: "none", "ordinal" (a string column) or "continuous" (a number or time column).
|
|
115
|
+
// apply(value) → color; ramp(t) → the color at t ∈ [0, 1] of the legend ramp.
|
|
116
|
+
export function colorScale(ns) {
|
|
117
|
+
const theme = ns.theme;
|
|
118
|
+
const opts = ns.scales.color ?? {};
|
|
119
|
+
const mapped = ns.layers.filter((l) => l.channels.color?.column);
|
|
120
|
+
if (!mapped.length) return { kind: "none" };
|
|
121
|
+
const column = mapped[0].channels.color.column;
|
|
122
|
+
const other = mapped.find((l) => l.channels.color.column !== column);
|
|
123
|
+
if (other) throw new Error(`plotto: color maps "${column}" and "${other.channels.color.column}". Map color to one column only`);
|
|
124
|
+
const cols = mapped.map((l) => ns.tables[l.table].columns[column]);
|
|
125
|
+
const type = cols[0].type;
|
|
126
|
+
const all = cols.flatMap((c) => c.values).filter((v) => v !== null);
|
|
127
|
+
const resolve = (c) => colorOf(theme, c) ?? c;
|
|
128
|
+
|
|
129
|
+
if (type === "string") {
|
|
130
|
+
const levels = cols.find((c) => c.levels)?.levels;
|
|
131
|
+
const seen = new Set(all);
|
|
132
|
+
const domain = opts.limits ?? (levels ? levels.filter((v) => seen.has(v)) : [...seen].sort(naturalOrder));
|
|
133
|
+
const colors = opts.palette ? palette(theme, opts.palette) : theme.palette;
|
|
134
|
+
const values = opts.values;
|
|
135
|
+
const pick = Array.isArray(values)
|
|
136
|
+
? (v, i) => resolve(values[i % values.length])
|
|
137
|
+
: values
|
|
138
|
+
? (v, i) => resolve(values[v] ?? colors[i % colors.length])
|
|
139
|
+
: (v, i) => colors[i % colors.length];
|
|
140
|
+
const map = new Map(domain.map((v, i) => [v, pick(v, i)]));
|
|
141
|
+
return { kind: "ordinal", column, domain, apply: (v) => map.get(v) ?? theme.muted };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const [lo, hi] = limitsOf(opts.limits, d3.extent(all));
|
|
145
|
+
const interpolate = interpolator(theme, opts.values ?? opts.scheme ?? theme.scheme);
|
|
146
|
+
// mid: a diverging scale, with mid at the center of the scheme.
|
|
147
|
+
const mid = opts.mid;
|
|
148
|
+
const t = (v) => (mid === undefined ? (v - lo) / (hi - lo || 1) : v < mid ? 0.5 * ((v - lo) / (mid - lo || 1)) : 0.5 + 0.5 * ((v - mid) / (hi - mid || 1)));
|
|
149
|
+
const inside = (v) => v >= lo && v <= hi;
|
|
150
|
+
const apply = (v) => (v === null ? theme.muted : opts.clamp || inside(v) ? interpolate(Math.max(0, Math.min(1, t(v)))) : theme.muted);
|
|
151
|
+
const ramp = (u) => apply(lo + u * (hi - lo));
|
|
152
|
+
return { kind: "continuous", column, domain: [lo, hi], type, interpolate, apply, ramp };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Limits with null bounds: a null bound takes the data bound.
|
|
156
|
+
export function limitsOf(limits, data) {
|
|
157
|
+
if (!limits) return data;
|
|
158
|
+
return [limits[0] ?? data[0], limits[1] ?? data[1]];
|
|
159
|
+
}
|
package/src/spec.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Validates a spec and adds the defaults. This is the only file that knows the defaults.
|
|
2
|
+
// Output: tables, layers with resolved channels ({column} or {value}), facet, scales, labels, info and theme.
|
|
3
|
+
import GRAMMAR from "../grammar.json" with { type: "json" };
|
|
4
|
+
import { colorOf, colorTokens, resolveTheme } from "./theme.js";
|
|
5
|
+
|
|
6
|
+
export function normalize(spec, overrides = {}) {
|
|
7
|
+
if (typeof spec?.spec === "function") spec = spec.spec(); // a builder
|
|
8
|
+
if (spec?.version !== 1) throw new Error("plotto: unknown spec version");
|
|
9
|
+
const theme = resolveTheme({ ...spec.theme, ...overrides });
|
|
10
|
+
const tables = spec.data.map(toTable);
|
|
11
|
+
const layers = spec.layers.map((l) => resolveLayer(l, spec.mapping ?? {}, tables, theme));
|
|
12
|
+
if (!layers.length) throw new Error("plotto: the plot has no layers. Add one, for example .line() or .point()");
|
|
13
|
+
const facet = resolveFacet(spec.facet);
|
|
14
|
+
const labels = { ...defaultLabels(layers), ...spec.labels };
|
|
15
|
+
const info = spec.info?.entries?.length ? { entries: spec.info.entries, position: spec.info.position ?? "right" } : null;
|
|
16
|
+
return { tables, layers, facet, scales: spec.scales ?? {}, labels, info, theme };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toTable({ columns }) {
|
|
20
|
+
const names = Object.keys(columns);
|
|
21
|
+
const n = names.length ? columns[names[0]].values.length : 1;
|
|
22
|
+
return { columns, n };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveLayer(layer, plotMapping, tables, theme) {
|
|
26
|
+
const def = GRAMMAR.layers[layer.type];
|
|
27
|
+
if (!def) throw new Error(`plotto: unknown layer type "${layer.type}". Valid: ${Object.keys(GRAMMAR.layers).join(", ")}`);
|
|
28
|
+
const table = layer.data ?? 0;
|
|
29
|
+
const columns = tables[table].columns;
|
|
30
|
+
const channels = {};
|
|
31
|
+
for (const ch of def.channels) {
|
|
32
|
+
if (layer.mapping?.[ch] !== undefined) channels[ch] = { column: layer.mapping[ch] };
|
|
33
|
+
else if (layer.set?.[ch] !== undefined) channels[ch] = { value: layer.set[ch] };
|
|
34
|
+
else if (def.inherit !== false && plotMapping[ch] in columns) channels[ch] = { column: plotMapping[ch] };
|
|
35
|
+
}
|
|
36
|
+
const missing = def.requires.filter((ch) => !channels[ch]);
|
|
37
|
+
if (missing.length) throw new Error(`${layer.type}(): set ${missing.join(" and ")}, as a column name or a value`);
|
|
38
|
+
if (channels.color && "value" in channels.color) {
|
|
39
|
+
const color = colorOf(theme, channels.color.value);
|
|
40
|
+
if (!color) throw new Error(`${layer.type}(): "${channels.color.value}" is not a column or a color. Theme colors: ${colorTokens(theme).join(", ")}`);
|
|
41
|
+
channels.color = { value: color };
|
|
42
|
+
}
|
|
43
|
+
const options = {};
|
|
44
|
+
for (const opt of def.options) if (layer.set?.[opt] !== undefined) options[opt] = layer.set[opt];
|
|
45
|
+
return { type: layer.type, table, channels, options };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveFacet(facet) {
|
|
49
|
+
if (!facet) return null;
|
|
50
|
+
const free = { x: ["x", "both"].includes(facet.free), y: ["y", "both"].includes(facet.free) };
|
|
51
|
+
if (facet.wrap) return { kind: "wrap", by: facet.wrap, ncol: facet.ncol ?? null, free };
|
|
52
|
+
return { kind: "grid", rows: facet.rows ?? null, cols: facet.cols ?? null, free };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Axis and legend titles default to the first mapped column name. Stat layers name their computed y.
|
|
56
|
+
function defaultLabels(layers) {
|
|
57
|
+
const first = (names) => {
|
|
58
|
+
for (const l of layers) for (const n of names) if (l.channels[n]?.column) return l.channels[n].column;
|
|
59
|
+
return undefined;
|
|
60
|
+
};
|
|
61
|
+
const stat = layers.find((l) => l.type === "hist" || l.type === "density");
|
|
62
|
+
const y = first(["y", "ymin", "yend"]) ?? (stat ? (stat.type === "density" || stat.options.density ? "density" : "count") : undefined);
|
|
63
|
+
return { x: first(["x", "xmin", "xend"]), y, color: first(["color"]) };
|
|
64
|
+
}
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Statistics of the stat layers: summary, stack and dodge, bins, density and smooth.
|
|
2
|
+
// Input and output are columns: { x: [...], y: [...], color: [...], ... }. Times are epoch ms numbers.
|
|
3
|
+
import * as d3 from "d3";
|
|
4
|
+
|
|
5
|
+
const GRID = 80; // points of a smooth or density curve
|
|
6
|
+
|
|
7
|
+
// Rows grouped by the values of the given columns, in first-seen order: [{ key: [...], idx: [...] }].
|
|
8
|
+
export function groupRows(cols, names, n) {
|
|
9
|
+
const groups = new Map();
|
|
10
|
+
for (let i = 0; i < n; i++) {
|
|
11
|
+
const key = names.map((k) => cols[k]?.[i] ?? null);
|
|
12
|
+
const id = JSON.stringify(key);
|
|
13
|
+
if (!groups.has(id)) groups.set(id, { key, idx: [] });
|
|
14
|
+
groups.get(id).idx.push(i);
|
|
15
|
+
}
|
|
16
|
+
return [...groups.values()];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// One row per (x, color, group): y is the summary of the group values.
|
|
20
|
+
export function summarize(cols, n, fn) {
|
|
21
|
+
const reduce = { mean: d3.mean, median: d3.median, min: d3.min, max: d3.max, sum: d3.sum }[fn];
|
|
22
|
+
const out = { x: [], y: [], color: [], group: [] };
|
|
23
|
+
for (const { key, idx } of groupRows(cols, ["x", "color", "group"], n)) {
|
|
24
|
+
if (key[0] === null) continue;
|
|
25
|
+
out.x.push(key[0]);
|
|
26
|
+
out.y.push(reduce(idx, (i) => cols.y[i]));
|
|
27
|
+
out.color.push(key[1]);
|
|
28
|
+
out.group.push(key[2]);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Stacks y within each x, in the order of the color levels: positive values up, negative values down.
|
|
34
|
+
// overlay: no stack (ymin = 0). dodge: each color gets a slot k of nk at the same x.
|
|
35
|
+
export function position(cols, n, mode, colorLevels) {
|
|
36
|
+
const order = (i) => (cols.color ? colorLevels.indexOf(cols.color[i]) : 0);
|
|
37
|
+
const present = cols.color ? colorLevels.filter((v) => cols.color.includes(v)) : [null];
|
|
38
|
+
const out = { ...cols, ymin: new Array(n), ymax: new Array(n), value: cols.y.slice(), slot: new Array(n), slots: new Array(n) };
|
|
39
|
+
// Dodge stacks within (x, color), so that each color has its own column of bars.
|
|
40
|
+
for (const { idx } of groupRows(cols, mode === "dodge" ? ["x", "color"] : ["x"], n)) {
|
|
41
|
+
idx.sort((a, b) => order(a) - order(b));
|
|
42
|
+
let up = 0;
|
|
43
|
+
let down = 0;
|
|
44
|
+
idx.forEach((i, k) => {
|
|
45
|
+
const v = cols.y[i] ?? 0;
|
|
46
|
+
if (mode !== "overlay") {
|
|
47
|
+
const base = v >= 0 ? up : down;
|
|
48
|
+
[out.ymin[i], out.ymax[i]] = [base, base + v];
|
|
49
|
+
if (v >= 0) up += v;
|
|
50
|
+
else down += v;
|
|
51
|
+
} else [out.ymin[i], out.ymax[i]] = [0, v];
|
|
52
|
+
out.slot[i] = mode === "dodge" && cols.color ? present.indexOf(cols.color[i]) : 0;
|
|
53
|
+
out.slots[i] = mode === "dodge" ? present.length : 1;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
out.y = out.ymax;
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// x intervals for bars on a continuous x: 90% of the data resolution, split into the dodge slots.
|
|
61
|
+
export function barIntervals(cols, n) {
|
|
62
|
+
const xs = [...new Set(cols.x.filter((v) => v !== null))].sort((a, b) => a - b);
|
|
63
|
+
const step = d3.min(d3.pairs(xs), ([a, b]) => b - a) ?? 1;
|
|
64
|
+
const w = step * 0.9;
|
|
65
|
+
const xmin = [];
|
|
66
|
+
const xmax = [];
|
|
67
|
+
for (let i = 0; i < n; i++) {
|
|
68
|
+
const slice = w / cols.slots[i];
|
|
69
|
+
xmin.push(cols.x[i] - w / 2 + cols.slot[i] * slice);
|
|
70
|
+
xmax.push(cols.x[i] - w / 2 + (cols.slot[i] + 1) * slice);
|
|
71
|
+
}
|
|
72
|
+
return { ...cols, xmin, xmax };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Shared bin edges for all panels and colors: about `bins` bins on nice values.
|
|
76
|
+
export function binEdges(values, bins) {
|
|
77
|
+
const finite = values.filter((v) => v !== null && Number.isFinite(v));
|
|
78
|
+
const [lo, hi] = d3.extent(finite);
|
|
79
|
+
if (lo === undefined) return [0, 1];
|
|
80
|
+
if (lo === hi) return [lo - 0.5, hi + 0.5];
|
|
81
|
+
const edges = d3.ticks(...d3.nice(lo, hi, bins), bins);
|
|
82
|
+
if (edges[0] > lo) edges.unshift(edges[0] - (edges[1] - edges[0]));
|
|
83
|
+
if (edges.at(-1) < hi) edges.push(edges.at(-1) + (edges[1] - edges[0]));
|
|
84
|
+
return edges;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Counts (or densities) per bin and color: one row per (bin, color), with x = the bin center.
|
|
88
|
+
export function bin(cols, n, edges, density) {
|
|
89
|
+
const out = { x: [], xmin: [], xmax: [], y: [], color: [] };
|
|
90
|
+
const binner = d3.bin().domain([edges[0], edges.at(-1)]).thresholds(edges.slice(1, -1));
|
|
91
|
+
for (const { key, idx } of groupRows(cols, ["color"], n)) {
|
|
92
|
+
const values = idx.map((i) => cols.x[i]).filter((v) => v !== null && Number.isFinite(v));
|
|
93
|
+
for (const b of binner(values)) {
|
|
94
|
+
const count = b.length;
|
|
95
|
+
out.x.push((b.x0 + b.x1) / 2);
|
|
96
|
+
out.xmin.push(b.x0);
|
|
97
|
+
out.xmax.push(b.x1);
|
|
98
|
+
out.y.push(density ? count / (values.length * (b.x1 - b.x0) || 1) : count);
|
|
99
|
+
out.color.push(key[0]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Gaussian kernel density per color, with the Silverman bandwidth, on a grid that covers the data ± 3 bandwidths.
|
|
106
|
+
export function density(cols, n) {
|
|
107
|
+
const out = { x: [], y: [], color: [], group: [] };
|
|
108
|
+
for (const { key, idx } of groupRows(cols, ["color"], n)) {
|
|
109
|
+
const v = idx.map((i) => cols.x[i]).filter((x) => x !== null && Number.isFinite(x));
|
|
110
|
+
if (v.length < 2) continue;
|
|
111
|
+
const sd = d3.deviation(v);
|
|
112
|
+
const iqr = d3.quantile(v, 0.75) - d3.quantile(v, 0.25);
|
|
113
|
+
const bw = 0.9 * Math.min(sd, iqr / 1.34 || sd) * v.length ** -0.2 || 1;
|
|
114
|
+
const [lo, hi] = d3.extent(v);
|
|
115
|
+
for (const x of d3.range(GRID).map((k) => lo - 3 * bw + ((hi - lo + 6 * bw) * k) / (GRID - 1))) {
|
|
116
|
+
const y = d3.sum(v, (xi) => Math.exp(-0.5 * ((x - xi) / bw) ** 2)) / (v.length * bw * Math.sqrt(2 * Math.PI));
|
|
117
|
+
out.x.push(x);
|
|
118
|
+
out.y.push(y);
|
|
119
|
+
out.color.push(key[0]);
|
|
120
|
+
out.group.push(key[0]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A smooth curve per color: "lm" (least squares, with a 95% confidence band) or "loess" (local linear, tricube weights).
|
|
127
|
+
export function smooth(cols, n, { method = "lm", span = 0.75, band = true }) {
|
|
128
|
+
const out = { x: [], y: [], ymin: [], ymax: [], color: [], group: [] };
|
|
129
|
+
for (const { key, idx } of groupRows(cols, ["color"], n)) {
|
|
130
|
+
const pts = idx.map((i) => [cols.x[i], cols.y[i]]).filter(([x, y]) => x !== null && y !== null && Number.isFinite(x) && Number.isFinite(y));
|
|
131
|
+
if (pts.length < 3) continue;
|
|
132
|
+
pts.sort((a, b) => a[0] - b[0]);
|
|
133
|
+
const [lo, hi] = [pts[0][0], pts.at(-1)[0]];
|
|
134
|
+
const grid = d3.range(GRID).map((k) => lo + ((hi - lo) * k) / (GRID - 1));
|
|
135
|
+
const fit = method === "loess" ? loess(pts, grid, span) : linear(pts, grid);
|
|
136
|
+
fit.forEach(({ x, y, se }) => {
|
|
137
|
+
const t = tQuantile(pts.length - 2);
|
|
138
|
+
out.x.push(x);
|
|
139
|
+
out.y.push(y);
|
|
140
|
+
out.ymin.push(band && se !== undefined ? y - t * se : null);
|
|
141
|
+
out.ymax.push(band && se !== undefined ? y + t * se : null);
|
|
142
|
+
out.color.push(key[0]);
|
|
143
|
+
out.group.push(key[0]);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function linear(pts, grid) {
|
|
150
|
+
const n = pts.length;
|
|
151
|
+
const mx = d3.mean(pts, (p) => p[0]);
|
|
152
|
+
const my = d3.mean(pts, (p) => p[1]);
|
|
153
|
+
const sxx = d3.sum(pts, (p) => (p[0] - mx) ** 2) || 1;
|
|
154
|
+
const b = d3.sum(pts, (p) => (p[0] - mx) * (p[1] - my)) / sxx;
|
|
155
|
+
const a = my - b * mx;
|
|
156
|
+
const s = Math.sqrt(d3.sum(pts, (p) => (p[1] - a - b * p[0]) ** 2) / Math.max(1, n - 2));
|
|
157
|
+
return grid.map((x) => ({ x, y: a + b * x, se: s * Math.sqrt(1 / n + (x - mx) ** 2 / sxx) }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function loess(pts, grid, span) {
|
|
161
|
+
const k = Math.max(3, Math.ceil(span * pts.length));
|
|
162
|
+
return grid.map((x) => {
|
|
163
|
+
const near = pts.map(([px, py]) => [Math.abs(px - x), px, py]).sort((a, b) => a[0] - b[0]).slice(0, k);
|
|
164
|
+
const dmax = near.at(-1)[0] || 1;
|
|
165
|
+
let [sw, swx, swy, swxx, swxy] = [0, 0, 0, 0, 0];
|
|
166
|
+
for (const [d, px, py] of near) {
|
|
167
|
+
const w = (1 - (d / dmax) ** 3) ** 3;
|
|
168
|
+
sw += w;
|
|
169
|
+
swx += w * px;
|
|
170
|
+
swy += w * py;
|
|
171
|
+
swxx += w * px * px;
|
|
172
|
+
swxy += w * px * py;
|
|
173
|
+
}
|
|
174
|
+
const det = sw * swxx - swx * swx;
|
|
175
|
+
const b = Math.abs(det) < 1e-12 ? 0 : (sw * swxy - swx * swy) / det;
|
|
176
|
+
const a = (swy - b * swx) / sw;
|
|
177
|
+
return { x, y: a + b * x };
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 97.5% quantile of Student's t (two-sided 95%), from a short table.
|
|
182
|
+
function tQuantile(df) {
|
|
183
|
+
const table = [12.71, 4.3, 3.18, 2.78, 2.57, 2.45, 2.36, 2.31, 2.26, 2.23, 2.2, 2.18, 2.16, 2.14, 2.13, 2.12, 2.11, 2.1, 2.09, 2.09];
|
|
184
|
+
if (df < 1) return NaN;
|
|
185
|
+
if (df <= table.length) return table[df - 1];
|
|
186
|
+
return df <= 30 ? 2.05 : df <= 60 ? 2.0 : 1.96;
|
|
187
|
+
}
|
package/src/svg.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SVG element helpers. All elements come from the given document (the browser or jsdom).
|
|
2
|
+
export const SVG_NS = "http://www.w3.org/2000/svg";
|
|
3
|
+
|
|
4
|
+
export function svgElement(doc, tag, attrs = {}) {
|
|
5
|
+
const el = doc.createElementNS(SVG_NS, tag);
|
|
6
|
+
for (const [k, v] of Object.entries(attrs)) if (v !== undefined && v !== null) el.setAttribute(k, String(v));
|
|
7
|
+
return el;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function svgText(doc, text, attrs, tag = "text") {
|
|
11
|
+
const el = svgElement(doc, tag, attrs);
|
|
12
|
+
el.textContent = text;
|
|
13
|
+
return el;
|
|
14
|
+
}
|
package/src/text.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Text width estimate from the font tables in metrics.js. Node and the browser get the same numbers,
|
|
2
|
+
// so the layout (margins, legend, strips) is the same in every output.
|
|
3
|
+
import { METRICS } from "./metrics.js";
|
|
4
|
+
|
|
5
|
+
const CJK_START = 0x2e80; // CJK, kana and full-width forms are one em wide.
|
|
6
|
+
|
|
7
|
+
export function textWidth(text, size, bold = false, metrics = "arial") {
|
|
8
|
+
const table = METRICS[metrics][bold ? "bold" : "regular"];
|
|
9
|
+
let em = 0;
|
|
10
|
+
for (const ch of String(text)) {
|
|
11
|
+
const code = ch.codePointAt(0);
|
|
12
|
+
if (code >= 32 && code < 127) em += table[code - 32];
|
|
13
|
+
else em += code >= CJK_START ? 1 : 0.6;
|
|
14
|
+
}
|
|
15
|
+
return em * size;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The text cut with an ellipsis so that it fits in maxWidth.
|
|
19
|
+
export function fitText(text, size, maxWidth, bold, metrics) {
|
|
20
|
+
text = String(text);
|
|
21
|
+
if (textWidth(text, size, bold, metrics) <= maxWidth) return text;
|
|
22
|
+
const chars = [...text];
|
|
23
|
+
while (chars.length > 1 && textWidth(chars.join("") + "…", size, bold, metrics) > maxWidth) chars.pop();
|
|
24
|
+
return chars.join("") + "…";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Greedy word wrap into at most maxLines lines; the last line gets an ellipsis if the text is longer.
|
|
28
|
+
export function wrapText(text, size, maxWidth, bold, metrics, maxLines = 2) {
|
|
29
|
+
const words = String(text).split(/\s+/);
|
|
30
|
+
const lines = [];
|
|
31
|
+
let line = "";
|
|
32
|
+
for (const word of words) {
|
|
33
|
+
const next = line ? `${line} ${word}` : word;
|
|
34
|
+
if (line && textWidth(next, size, bold, metrics) > maxWidth) {
|
|
35
|
+
lines.push(line);
|
|
36
|
+
line = word;
|
|
37
|
+
} else line = next;
|
|
38
|
+
}
|
|
39
|
+
lines.push(line);
|
|
40
|
+
if (lines.length <= maxLines) return lines;
|
|
41
|
+
const kept = lines.slice(0, maxLines);
|
|
42
|
+
kept[maxLines - 1] = fitText(lines.slice(maxLines - 1).join(" "), size, maxWidth, bold, metrics);
|
|
43
|
+
return kept;
|
|
44
|
+
}
|