Skip to content

Commit 0bcd9f1

Browse files
committed
tooltip
1 parent 0e83884 commit 0bcd9f1

12 files changed

+11692
-4
lines changed

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 current projection, if any. */
1215
projection?: GeoStreamWrapper;
1316
}

src/context.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {createProjection} from "./projection.js";
33

44
export function createContext(options = {}, dimensions) {
55
const {document = typeof window !== "undefined" ? window.document : undefined} = options;
6-
return {document, projection: createProjection(options, dimensions)};
6+
const context = {document, projection: createProjection(options, dimensions)};
7+
context.ownerSVGElement = creator("svg").call(document.documentElement);
8+
return context;
79
}
810

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

src/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export * from "./marks/rect.js";
3131
export * from "./marks/rule.js";
3232
export * from "./marks/text.js";
3333
export * from "./marks/tick.js";
34+
export * from "./marks/tooltip.js";
3435
export * from "./marks/tree.js";
3536
export * from "./marks/vector.js";
3637
export * from "./options.js";

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export {Rect, rect, rectX, rectY} from "./marks/rect.js";
2424
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
2525
export {Text, text, textX, textY} from "./marks/text.js";
2626
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
27+
export {Tooltip, tooltip} from "./marks/tooltip.js";
2728
export {tree, cluster} from "./marks/tree.js";
2829
export {Vector, vector, vectorX, vectorY, spike} from "./marks/vector.js";
2930
export {valueof, column, identity} from "./options.js";

src/marks/dot.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class Dot extends Mark {
6969
const {x: X, y: Y, r: R, rotate: A, symbol: S} = channels;
7070
const {r, rotate, symbol} = this;
7171
const [cx, cy] = applyFrameAnchor(this, dimensions);
72-
const circle = this.symbol === symbolCircle;
72+
const circle = symbol === symbolCircle;
7373
const size = R ? undefined : r * r * Math.PI;
7474
if (negative(r)) index = [];
7575
return create("svg:g", context)

src/marks/tooltip.d.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type {ChannelValueSpec} from "../channel.js";
2+
import type {Data, FrameAnchor, MarkOptions, RenderableMark} from "../mark.js";
3+
4+
/** Options for the tooltip mark. */
5+
export interface TooltipOptions extends MarkOptions {
6+
/**
7+
* The horizontal position channel specifying the tooltip’s anchor, typically
8+
* bound to the *x* scale.
9+
*/
10+
x?: ChannelValueSpec;
11+
12+
/**
13+
* The vertical position channel specifying the tooltip’s anchor, typically
14+
* bound to the *y* scale.
15+
*/
16+
y?: ChannelValueSpec;
17+
18+
/**
19+
* The frame anchor specifies defaults for **x** and **y** based on the plot’s
20+
* frame; it may be one of the four sides (*top*, *right*, *bottom*, *left*),
21+
* one of the four corners (*top-left*, *top-right*, *bottom-right*,
22+
* *bottom-left*), or the *middle* of the frame. For example, for tooltips
23+
* distributed horizontally at the top of the frame:
24+
*
25+
* ```js
26+
* Plot.tooltip(data, {x: "date", frameAnchor: "top"})
27+
* ```
28+
*/
29+
frameAnchor?: FrameAnchor;
30+
}
31+
32+
/**
33+
* Returns a new tooltip mark for the given *data* and *options*.
34+
*
35+
* If either **x** or **y** is not specified, the default is determined by the
36+
* **frameAnchor** option. If none of **x**, **y**, and **frameAnchor** are
37+
* specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*,
38+
* *y₁*], [*x₂*, *y₂*], …] such that **x** = [*x₀*, *x₁*, *x₂*, …] and **y** =
39+
* [*y₀*, *y₁*, *y₂*, …].
40+
*/
41+
export function tooltip(data?: Data, options?: TooltipOptions): Tooltip;
42+
43+
/** The tooltip mark. */
44+
export class Tooltip extends RenderableMark {}

src/marks/tooltip.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {pointer, select} from "d3";
2+
import {Mark} from "../mark.js";
3+
import {maybeFrameAnchor, maybeTuple} from "../options.js";
4+
import {applyFrameAnchor} from "../style.js";
5+
6+
export class Tooltip extends Mark {
7+
constructor(data, options = {}) {
8+
const {x, y, maxRadius = 40, frameAnchor} = options;
9+
super(
10+
data,
11+
{
12+
x: {value: x, scale: "x", optional: true},
13+
y: {value: y, scale: "y", optional: true}
14+
},
15+
options
16+
);
17+
this.frameAnchor = maybeFrameAnchor(frameAnchor);
18+
this.indexesBySvg = new WeakMap();
19+
this.maxRadius = +maxRadius;
20+
}
21+
render(index, {x, y, fx, fy}, {x: X, y: Y, channels}, dimensions, context) {
22+
// TODO
23+
// - ✅ Get local coordinates of the pointer
24+
// - ✅ Register one pointermove listener per plot
25+
// - ✅ Find the closest point in screen (scaled) coordinates
26+
// - ✅ Find the closest point across all facets
27+
// - ✅ Limit the search radius
28+
// - ✅ Suppress the tooltip on pointerdown
29+
// - ✅ Display unscaled values in a tooltip
30+
// - ✅ Handle x or y not existing; respect frameAnchor
31+
// - ✅ Handle fx or fy not existing (for the entire plot)
32+
// - Handle faceting being disabled for this mark (facet: null)
33+
// - Tooltips for rect, with configurable anchor point
34+
// - Tooltips for line, searching first on x and then on y, and vice versa
35+
// - Tooltips for area… matching between topline and baseline?
36+
// - Tooltips for link and arrow, treating {x1, y1} and {x2, y2} as distinct points
37+
// - Tooltips for vector, but matching the end of the vector instead of the start?
38+
// - Tooltips for tick and rule?
39+
// - Tooltips for bar and cell?
40+
// - Tooltips for geo and contour?
41+
// - [nice to have] Handle multiple dots in the same position (e.g., click to cycle)?
42+
// - Remove the red dot for testing purposes
43+
const [cx, cy] = applyFrameAnchor(this, dimensions);
44+
const {maxRadius} = this;
45+
const {marginLeft, marginTop} = dimensions;
46+
const svg = context.ownerSVGElement;
47+
let indexes = this.indexesBySvg.get(svg);
48+
if (indexes) return void indexes.push(index);
49+
this.indexesBySvg.set(svg, (indexes = [index]));
50+
const dot = select(svg)
51+
.on("pointermove", (event) => {
52+
let i, xi, yi, fxi, fyi;
53+
if (event.buttons === 0) {
54+
const [xp, yp] = pointer(event);
55+
let ri = maxRadius * maxRadius;
56+
for (const index of indexes) {
57+
const fxj = index.fx;
58+
const fyj = index.fy;
59+
const oxj = fx ? fx(fxj) - marginLeft : 0;
60+
const oyj = fy ? fy(fyj) - marginTop : 0;
61+
for (const j of index) {
62+
const xj = (X ? X[j] : cx) + oxj;
63+
const yj = (Y ? Y[j] : cy) + oyj;
64+
const dx = xj - xp;
65+
const dy = yj - yp;
66+
const rj = dx * dx + dy * dy;
67+
if (rj <= ri) (i = j), (ri = rj), (xi = xj), (yi = yj), (fxi = fxj), (fyi = fyj);
68+
}
69+
}
70+
}
71+
if (i === undefined) {
72+
dot.attr("display", "none");
73+
} else {
74+
dot.attr("display", "inline");
75+
dot.attr("transform", `translate(${xi},${yi})`);
76+
const text = [];
77+
if (x) text.push(`${x.label ?? "x"} = ${channels.x.value[i]}`);
78+
if (y) text.push(`${y.label ?? "y"} = ${channels.y.value[i]}`);
79+
if (fx) text.push(`${fx.label ?? "fx"} = ${fxi}`);
80+
if (fy) text.push(`${fy.label ?? "fy"} = ${fyi}`);
81+
title.text(text.join("\n"));
82+
}
83+
})
84+
.on("pointerdown pointerleave", () => dot.attr("display", "none"))
85+
.append("g")
86+
.attr("display", "none")
87+
.attr("pointer-events", "all")
88+
.attr("fill", "none")
89+
.call((g) => g.append("circle").attr("r", maxRadius).attr("fill", "none"))
90+
.call((g) => g.append("circle").attr("r", 4.5).attr("stroke", "red").attr("stroke-width", 1.5));
91+
const title = dot.append("title");
92+
return null;
93+
}
94+
}
95+
96+
export function tooltip(data, {x, y, ...options} = {}) {
97+
if (options.frameAnchor === undefined) [x, y] = maybeTuple(x, y);
98+
return new Tooltip(data, {...options, x, y});
99+
}

src/plot.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export function plot(options = {}) {
204204

205205
const {width, height} = dimensions;
206206

207-
const svg = create("svg", context)
207+
const svg = select(context.ownerSVGElement)
208208
.attr("class", className)
209209
.attr("fill", "currentColor")
210210
.attr("font-family", "system-ui, sans-serif")
@@ -260,7 +260,9 @@ export function plot(options = {}) {
260260
index = indexes[facetStateByMark.has(mark) ? f.i : 0];
261261
index = mark.filter(index, channels, values);
262262
if (index.length === 0) continue;
263-
index.fi = f.i; // TODO cleaner way of exposing the current facet index?
263+
index.fx = f.x;
264+
index.fy = f.y;
265+
index.fi = f.i;
264266
}
265267
const node = mark.render(index, scales, values, subdimensions, context);
266268
if (node == null) continue;

0 commit comments

Comments
 (0)