Skip to content

Commit ba940c1

Browse files
committed
more tip options, comments
1 parent 2765758 commit ba940c1

File tree

1 file changed

+41
-23
lines changed

1 file changed

+41
-23
lines changed

src/marks/tip.js

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ export class Tip extends Mark {
3434
lineHeight = 1,
3535
lineWidth = 20,
3636
frameAnchor,
37-
textAnchor = "start"
37+
textAnchor = "start",
38+
textPadding = 8,
39+
pointerSize = 12,
40+
pathFilter = "drop-shadow(0 3px 4px rgba(0,0,0,0.2))"
3841
} = options;
3942
super(
4043
data,
@@ -53,8 +56,9 @@ export class Tip extends Mark {
5356
this.previousAnchor = this.anchor ?? "top-left";
5457
this.frameAnchor = maybeFrameAnchor(frameAnchor);
5558
this.textAnchor = impliedString(textAnchor, "middle");
56-
this.textPadding = 8; // TODO option
57-
this.pointerSize = 12; // TODO option
59+
this.textPadding = +textPadding;
60+
this.pointerSize = +pointerSize;
61+
this.pathFilter = string(pathFilter);
5862
this.lineHeight = +lineHeight;
5963
this.lineWidth = +lineWidth;
6064
this.monospace = !!monospace;
@@ -63,21 +67,25 @@ export class Tip extends Mark {
6367
this.fontStyle = string(fontStyle);
6468
this.fontVariant = string(fontVariant);
6569
this.fontWeight = string(fontWeight);
66-
this.imageFilter = "drop-shadow(0 3px 4px rgba(0,0,0,0.2))"; // TODO option
6770
}
6871
render(index, scales, channels, dimensions, context) {
6972
const mark = this;
7073
const {x, y, fx, fy} = scales;
7174
const {ownerSVGElement: svg, document} = context;
72-
const {anchor, monospace, lineHeight, lineWidth, textPadding: r, pointerSize: m} = this;
75+
const {anchor, monospace, lineHeight, lineWidth} = this;
76+
const {textPadding: r, pointerSize: m, pathFilter} = this;
7377
const {marginTop, marginLeft} = dimensions;
7478

7579
// The anchor position is the middle of x1 & y1 and x2 & y2, if available,
7680
// or x & y; the former is considered more specific because it’s how we
7781
// disable the implicit stack and interval transforms. If any dimension is
78-
// unspecified, we fallback to the frame anchor.
82+
// unspecified, we fallback to the frame anchor. We also need to know the
83+
// facet offsets to detect when the tip would draw outside the plot, and
84+
// thus we need to change the orientation.
7985
const {x: X, y: Y, x1: X1, y1: Y1, x2: X2, y2: Y2, channels: sources} = channels;
8086
const [cx, cy] = applyFrameAnchor(this, dimensions);
87+
const ox = fx ? fx(index.fx) - marginLeft : 0;
88+
const oy = fy ? fy(index.fy) - marginTop : 0;
8189
const px = X2 ? (i) => (X1[i] + X2[i]) / 2 : X ? (i) => X[i] : () => cx;
8290
const py = Y2 ? (i) => (Y1[i] + Y2[i]) / 2 : Y ? (i) => Y[i] : () => cy;
8391

@@ -105,13 +113,15 @@ export class Tip extends Mark {
105113
.attr("transform", (i) => `translate(${px(i)},${py(i)})`)
106114
.call(applyDirectStyles, this)
107115
.call(applyChannelStyles, this, channels)
108-
.call((g) => g.append("path"))
116+
.call((g) => g.append("path").attr("filter", pathFilter))
109117
.call((g) =>
110118
g.append("text").each(function (i) {
111119
const that = select(this);
120+
// prevent style inheritance (from path)
112121
this.setAttribute("fill", "currentColor");
113122
this.setAttribute("fill-opacity", 1);
114123
this.setAttribute("stroke", "none");
124+
// iteratively render each channel value
115125
for (const key in sources) {
116126
const channel = getSource(sources, key);
117127
if (!channel) continue; // e.g., dodgeY’s y
@@ -123,8 +133,8 @@ export class Tip extends Mark {
123133
renderLine(
124134
that,
125135
scales[channel.scale]?.label ?? channel.label ?? key,
126-
channel2
127-
? channel2.hint?.length
136+
channel2 // e.g., binX’s x1 and x2
137+
? channel2.hint?.length // e.g., stackY’s y1 and y2
128138
? `${formatDefault(value2 - value1)}`
129139
: `${formatDefault(value1)}${formatDefault(value2)}`
130140
: formatDefault(value1)
@@ -137,7 +147,12 @@ export class Tip extends Mark {
137147
)
138148
);
139149

140-
function renderLine(that, name, value) {
150+
// Renders a single line (a name-value pair) to the tip, truncating the text
151+
// as needed, and adding a title if the text is truncated. Note that this is
152+
// just the initial layout of the text; in postrender we will compute the
153+
// exact text metrics and translate the text as needed once we know the
154+
// tip’s orientation (anchor).
155+
function renderLine(selection, name, value) {
141156
let title;
142157
let w = lineWidth * 100;
143158
const [j] = cut(name, w, widthof, ee);
@@ -155,23 +170,23 @@ export class Tip extends Mark {
155170
title = value.trim();
156171
}
157172
}
158-
const line = that.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`);
173+
const line = selection.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`);
159174
line.append("tspan").attr("font-weight", "bold").text(name);
160175
if (value) line.append(() => document.createTextNode(value));
161176
if (title) line.append("title").text(title);
162177
}
163178

179+
// Only after the plot is attached to the page can we compute the exact text
180+
// metrics needed to determine the tip size and orientation (anchor).
164181
function postrender() {
165182
const {width, height} = svg.getBBox();
166-
const ox = fx ? fx(index.fx) - marginLeft : 0;
167-
const oy = fy ? fy(index.fy) - marginTop : 0;
168183
g.selectChildren().each(function (i) {
169-
const x = px(i) + ox;
170-
const y = py(i) + oy;
171184
const {x: tx, width: w, height: h} = this.getBBox();
172-
let a = anchor;
185+
let a = anchor; // use the specified anchor, if any
173186
if (a === undefined) {
174-
a = mark.previousAnchor;
187+
a = mark.previousAnchor; // favor the previous anchor, if it fits
188+
const x = px(i) + ox;
189+
const y = py(i) + oy;
175190
const fitLeft = x + w + r * 2 < width;
176191
const fitRight = x - w - r * 2 > 0;
177192
const fitTop = y + h + m + r * 2 + 7 < height;
@@ -180,19 +195,22 @@ export class Tip extends Mark {
180195
const ay = (/^top-/.test(a) ? fitTop || !fitBottom : fitTop && !fitBottom) ? "top" : "bottom";
181196
a = mark.previousAnchor = `${ay}-${ax}`;
182197
}
183-
const path = this.firstChild;
184-
const text = this.lastChild;
198+
const path = this.firstChild; // note: assumes exactly two children!
199+
const text = this.lastChild; // note: assumes exactly two children!
185200
path.setAttribute("d", getPath(a, m, r, w, h));
186201
if (tx) for (const t of text.childNodes) t.setAttribute("x", -tx);
187202
text.setAttribute("y", `${+getLineOffset(a, text.childNodes.length, lineHeight).toFixed(6)}em`);
188203
text.setAttribute("transform", getTextTransform(a, m, r, w, h));
189204
});
190205
}
191206

192-
// Wait until the Plot is inserted into the page so that we can use getBBox
193-
// to compute the exact text dimensions. Perhaps this could be done
194-
// synchronously; getting the dimensions of the SVG is easy, and although
195-
// accurate text metrics are hard, we could use approximate heuristics.
207+
// Wait until the plot is inserted into the page so that we can use getBBox
208+
// to compute the exact text dimensions. If the SVG is already connected, as
209+
// when the pointer interaction triggers the re-render, use a faster
210+
// microtask instead of an animation frame; if this SSR (e.g., JSDOM), skip
211+
// this step. Perhaps this could be done synchronously; getting the
212+
// dimensions of the SVG is easy, and although accurate text metrics are
213+
// hard, we could use approximate heuristics.
196214
if (svg.isConnected) Promise.resolve().then(postrender);
197215
else if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(postrender);
198216

0 commit comments

Comments
 (0)