Skip to content

Commit 60be56d

Browse files
authored
tip mark + pointer interaction (#1527)
* tip mark * render transform! * pointer interaction * port mbostock/tooltip fixes * improved tip & pointer * simplify * better facets; stable anchor * px, py; crosshairs * crosshairs composite mark * crosshair singular * transpose facets and marks * prefer top-left * only pass index.f[xyi] if faceted * [xy][12]; don’t apply stroke as text fill * tip + hexbin test * optimize faceting by swapping transforms * renderTransform instead of _render * only one pointer across facets * suppress other facets when sticky * prevent duplicate ARIA when faceting * isolate state per-pointer * fix crash with one-dimensional tip * if px, default x to null; same for py * tidier crosshair options * use channel label if available * only separating space if named * crosshair initializer fixes * tidier crosshair options * remove to-do * tip + dodge test * cleaner facet translate * crosshair text using channel alias * preTtier * fix transform for [xy][12] * p[xy] precedence * pointer comments * tip textAnchor * more tip options * more tip options, comments * bandwidth offset * fix for multi-facet, multi-pointer * fix dimensions * tip side anchors * tipped helper * raster nearest * color swatch; fix f[xy]; no tip aesthetic channels * multi-line, summary ariaLabel * tidier formatting * tidier crosshair * project p[xy], too * centroid test * geoCentroid test * shorthand extra channels * no pointer-specific state * revert Mark interface change * remove dead code
1 parent 9959ca2 commit 60be56d

File tree

110 files changed

+25590
-9471
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

110 files changed

+25590
-9471
lines changed

src/channel.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {registry} from "./scales/index.js";
55
import {isSymbol, maybeSymbol} from "./symbol.js";
66
import {maybeReduce} from "./transforms/group.js";
77

8-
// TODO Type coercion?
98
export function createChannel(data, {scale, type, value, filter, hint}, name) {
9+
if (hint === undefined && typeof value?.transform === "function") hint = value.hint;
1010
return inferChannelScale(name, {
1111
scale,
1212
type,
@@ -160,3 +160,10 @@ function ascendingGroup([ak, av], [bk, bv]) {
160160
function descendingGroup([ak, av], [bk, bv]) {
161161
return descendingDefined(av, bv) || ascendingDefined(ak, bk);
162162
}
163+
164+
export function getSource(channels, key) {
165+
let channel = channels[key];
166+
if (!channel) return;
167+
while (channel.source) channel = channel.source;
168+
return channel.source === null ? null : channel;
169+
}

src/context.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export interface Context {
88
*/
99
document: Document;
1010

11+
/** The current owner SVG element. */
12+
ownerSVGElement: SVGSVGElement;
13+
1114
/** The Plot’s (typically generated) class name, for custom styles. */
1215
className: string;
1316

src/context.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import {creator, select} from "d3";
2-
import {createProjection} from "./projection.js";
32

4-
export function createContext(options = {}, dimensions, className) {
3+
export function createContext(options = {}) {
54
const {document = typeof window !== "undefined" ? window.document : undefined} = options;
6-
return {document, className, projection: createProjection(options, dimensions)};
5+
return {document};
76
}
87

98
export function create(name, {document}) {

src/facet.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function facetGroups(data, {fx, fy}) {
6262
);
6363
}
6464

65-
export function facetTranslate(fx, fy, {marginTop, marginLeft}) {
65+
export function facetTranslator(fx, fy, {marginTop, marginLeft}) {
6666
return fx && fy
6767
? ({x, y}) => `translate(${fx(x) - marginLeft},${fy(y) - marginTop})`
6868
: fx

src/index.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from "./curve.js";
44
export * from "./dimensions.js";
55
export * from "./format.js";
66
export * from "./inset.js";
7+
export * from "./interactions/pointer.js";
78
export * from "./interval.js";
89
export * from "./legends.js";
910
export * from "./mark.js";
@@ -16,6 +17,7 @@ export * from "./marks/bar.js";
1617
export * from "./marks/box.js";
1718
export * from "./marks/cell.js";
1819
export * from "./marks/contour.js";
20+
export * from "./marks/crosshair.js";
1921
export * from "./marks/delaunay.js";
2022
export * from "./marks/density.js";
2123
export * from "./marks/dot.js";
@@ -31,6 +33,7 @@ export * from "./marks/rect.js";
3133
export * from "./marks/rule.js";
3234
export * from "./marks/text.js";
3335
export * from "./marks/tick.js";
36+
export * from "./marks/tip.js";
3437
export * from "./marks/tree.js";
3538
export * from "./marks/vector.js";
3639
export * from "./options.js";

src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export {BarX, BarY, barX, barY} from "./marks/bar.js";
88
export {boxX, boxY} from "./marks/box.js";
99
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
1010
export {Contour, contour} from "./marks/contour.js";
11+
export {crosshair, crosshairX, crosshairY} from "./marks/crosshair.js";
1112
export {delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh} from "./marks/delaunay.js";
1213
export {Density, density} from "./marks/density.js";
1314
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
@@ -24,6 +25,7 @@ export {Rect, rect, rectX, rectY} from "./marks/rect.js";
2425
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
2526
export {Text, text, textX, textY} from "./marks/text.js";
2627
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
28+
export {Tip, tip} from "./marks/tip.js";
2729
export {tree, cluster} from "./marks/tree.js";
2830
export {Vector, vector, vectorX, vectorY, spike} from "./marks/vector.js";
2931
export {valueof, column, identity, indexOf} from "./options.js";
@@ -39,6 +41,7 @@ export {window, windowX, windowY} from "./transforms/window.js";
3941
export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
4042
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
4143
export {treeNode, treeLink} from "./transforms/tree.js";
44+
export {pointer, pointerX, pointerY} from "./interactions/pointer.js";
4245
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";
4346
export {scale} from "./scales.js";
4447
export {legend} from "./legends.js";

src/interactions/pointer.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type {Rendered} from "../transforms/basic.js";
2+
3+
/** TODO */
4+
export interface PointerOptions {
5+
/** TODO */
6+
maxRadius?: number;
7+
}
8+
9+
/** TODO */
10+
export function pointer<T>(options: T & PointerOptions): Rendered<T>;
11+
12+
/** TODO */
13+
export function pointerX<T>(options: T & PointerOptions): Rendered<T>;
14+
15+
/** TODO */
16+
export function pointerY<T>(options: T & PointerOptions): Rendered<T>;

src/interactions/pointer.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import {pointer as pointof} from "d3";
2+
import {applyFrameAnchor} from "../style.js";
3+
4+
const states = new WeakMap();
5+
6+
function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, ...options} = {}) {
7+
maxRadius = +maxRadius;
8+
// When px or py is used, register an extra channel that the pointer
9+
// interaction can use to control which point is focused; this allows pointing
10+
// to function independently of where the downstream mark (e.g., a tip) is
11+
// displayed. Also default x or y to null to disable maybeTuple etc.
12+
if (px != null) (x ??= null), (channels = {...channels, px: {value: px, scale: "x"}});
13+
if (py != null) (y ??= null), (channels = {...channels, py: {value: py, scale: "y"}});
14+
return {
15+
x,
16+
y,
17+
channels,
18+
...options,
19+
render(index, scales, values, dimensions, context) {
20+
const mark = this;
21+
const svg = context.ownerSVGElement;
22+
23+
// Isolate state per-pointer, per-plot; if the pointer is reused by
24+
// multiple marks, they will share the same state (e.g., sticky modality).
25+
let state = states.get(svg);
26+
if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: []}));
27+
28+
// This serves as a unique identifier of the rendered mark per-plot; it is
29+
// used to record the currently-rendered elements (state.roots) so that we
30+
// can tell when a rendered element is clicked on.
31+
let renderIndex = state.renders.push(render) - 1;
32+
33+
// For faceting, we want to compute the local coordinates of each point,
34+
// which means subtracting out the facet translation, if any. (It’s
35+
// tempting to do this using the local coordinates in SVG, but that’s
36+
// complicated by mark-specific transforms such as dx and dy.) Also, since
37+
// band scales return the upper bound of the band, we have to offset by
38+
// half the bandwidth.
39+
const {x, y, fx, fy} = scales;
40+
let tx = fx ? fx(index.fx) - dimensions.marginLeft : 0;
41+
let ty = fy ? fy(index.fy) - dimensions.marginTop : 0;
42+
if (x?.bandwidth) tx += x.bandwidth() / 2;
43+
if (y?.bandwidth) ty += y.bandwidth() / 2;
44+
45+
// For faceting, we also need to record the closest point per facet per
46+
// mark (!), since each facet has its own pointer event listeners; we only
47+
// want the closest point across facets to be visible.
48+
const faceted = index.fi != null;
49+
let facetState;
50+
if (faceted) {
51+
let facetStates = state.facetStates;
52+
if (!facetStates) state.facetStates = facetStates = new Map();
53+
facetState = facetStates.get(mark);
54+
if (!facetState) facetStates.set(mark, (facetState = new Map()));
55+
}
56+
57+
// The order of precedence when determining the point position is: px &
58+
// py; the middle of x1 & y1 and x2 & y2; or lastly x & y. If any
59+
// dimension is unspecified, we fallback to the frame anchor.
60+
const {x: X, y: Y, x1: X1, y1: Y1, x2: X2, y2: Y2, px: PX, py: PY} = values;
61+
const [cx, cy] = applyFrameAnchor(this, dimensions);
62+
const px = PX ? (i) => PX[i] : X2 ? (i) => (X1[i] + X2[i]) / 2 : X ? (i) => X[i] : () => cx;
63+
const py = PY ? (i) => PY[i] : Y2 ? (i) => (Y1[i] + Y2[i]) / 2 : Y ? (i) => Y[i] : () => cy;
64+
65+
let i; // currently focused index
66+
let g; // currently rendered mark
67+
let f; // current animation frame
68+
69+
// When faceting, if more than one pointer would be visible, only show
70+
// this one if it is the closest. We defer rendering using an animation
71+
// frame to allow all pointer events to be received before deciding which
72+
// mark to render; although when hiding, we render immediately.
73+
function update(ii, ri) {
74+
if (faceted) {
75+
if (f) f = cancelAnimationFrame(f);
76+
if (ii == null) facetState.delete(index.fi);
77+
else {
78+
facetState.set(index.fi, ri);
79+
f = requestAnimationFrame(() => {
80+
f = null;
81+
for (const r of facetState.values()) {
82+
if (r < ri) {
83+
ii = null;
84+
break;
85+
}
86+
}
87+
render(ii);
88+
});
89+
return;
90+
}
91+
}
92+
render(ii);
93+
}
94+
95+
function render(ii) {
96+
if (i === ii) return; // the tooltip hasn’t moved
97+
i = ii;
98+
const I = i == null ? [] : [i];
99+
if (faceted) (I.fx = index.fx), (I.fy = index.fy), (I.fi = index.fi);
100+
const r = mark.render(I, scales, values, dimensions, context);
101+
if (g) {
102+
// When faceting, preserve swapped mark and facet transforms; also
103+
// remove ARIA attributes since these are promoted to the parent. This
104+
// is perhaps brittle in that it depends on how Plot renders facets,
105+
// but it produces a cleaner and more accessible SVG structure.
106+
if (faceted) {
107+
const p = g.parentNode;
108+
const ft = g.getAttribute("transform");
109+
const mt = r.getAttribute("transform");
110+
ft ? r.setAttribute("transform", ft) : r.removeAttribute("transform");
111+
mt ? p.setAttribute("transform", mt) : p.removeAttribute("transform");
112+
r.removeAttribute("aria-label");
113+
r.removeAttribute("aria-description");
114+
r.removeAttribute("aria-hidden");
115+
}
116+
g.replaceWith(r);
117+
}
118+
state.roots[renderIndex] = r;
119+
return (g = r);
120+
}
121+
122+
function pointermove(event) {
123+
if (state.sticky || (event.pointerType === "mouse" && event.buttons === 1)) return; // dragging
124+
let [xp, yp] = pointof(event);
125+
(xp -= tx), (yp -= ty); // correct for facets and band scales
126+
let ii = null;
127+
let ri = maxRadius * maxRadius;
128+
for (const j of index) {
129+
const dx = kx * (px(j) - xp);
130+
const dy = ky * (py(j) - yp);
131+
const rj = dx * dx + dy * dy;
132+
if (rj <= ri) (ii = j), (ri = rj);
133+
}
134+
update(ii, ri);
135+
}
136+
137+
function pointerdown(event) {
138+
if (event.pointerType !== "mouse") return;
139+
if (i == null) return; // not pointing
140+
if (state.sticky && state.roots.some((r) => r?.contains(event.target))) return; // stay sticky
141+
if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); // clear all pointers
142+
else state.sticky = true;
143+
event.stopImmediatePropagation(); // suppress other pointers
144+
}
145+
146+
function pointerleave(event) {
147+
if (event.pointerType !== "mouse") return;
148+
if (!state.sticky) update(null);
149+
}
150+
151+
// We listen to the svg element; listening to the window instead would let
152+
// us receive pointer events from farther away, but would also make it
153+
// hard to know when to remove the listeners. (Using a mutation observer
154+
// to watch the entire document is likely too expensive.)
155+
svg.addEventListener("pointerenter", pointermove);
156+
svg.addEventListener("pointermove", pointermove);
157+
svg.addEventListener("pointerdown", pointerdown);
158+
svg.addEventListener("pointerleave", pointerleave);
159+
160+
return render(null);
161+
}
162+
};
163+
}
164+
165+
export function pointer(options) {
166+
return pointerK(1, 1, options);
167+
}
168+
169+
export function pointerX(options) {
170+
return pointerK(1, 0.01, options);
171+
}
172+
173+
export function pointerY(options) {
174+
return pointerK(0.01, 1, options);
175+
}

src/mark.d.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {ChannelDomainSort, Channels, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
1+
import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
22
import type {Context} from "./context.js";
33
import type {Dimensions} from "./dimensions.js";
44
import type {plot} from "./plot.js";
@@ -126,6 +126,9 @@ export interface MarkOptions {
126126
/** A custom mark initializer. */
127127
initializer?: InitializerFunction;
128128

129+
/** A custom render transform. */
130+
render?: RenderFunction;
131+
129132
/**
130133
* The horizontal facet position channel, for mark-level faceting, bound to
131134
* the *fx* scale.
@@ -445,7 +448,7 @@ export interface MarkOptions {
445448
* An object defining additional custom channels. This meta option may be used
446449
* by an **initializer** to declare extra channels.
447450
*/
448-
channels?: Channels;
451+
channels?: Record<string, Channel | ChannelValue>;
449452
}
450453

451454
/** The abstract base class for Mark implementations. */

0 commit comments

Comments
 (0)