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