Skip to content

Commit 6743bb5

Browse files
committed
custom tip format
1 parent e98ccb0 commit 6743bb5

File tree

7 files changed

+150
-72
lines changed

7 files changed

+150
-72
lines changed

src/channel.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export type ChannelValue =
145145
* object to override the scale that would normally be associated with the
146146
* channel.
147147
*/
148-
export type ChannelValueSpec = ChannelValue | {value: ChannelValue; scale?: Channel["scale"]}; // TODO label
148+
export type ChannelValueSpec = ChannelValue | {value: ChannelValue; label?: string; scale?: Channel["scale"]};
149149

150150
/**
151151
* In some contexts, when specifying a mark channel’s value, you can provide a

src/channel.js

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

8-
export function createChannel(data, {scale, type, value, filter, hint}, name) {
8+
export function createChannel(data, {scale, type, value, filter, hint, label = labelof(value)}, name) {
99
if (hint === undefined && typeof value?.transform === "function") hint = value.hint;
1010
return inferChannelScale(name, {
1111
scale,
1212
type,
1313
value: valueof(data, value),
14-
label: labelof(value),
14+
label,
1515
filter,
1616
hint
1717
});

src/mark.d.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
22
import type {Context} from "./context.js";
33
import type {Dimensions} from "./dimensions.js";
4+
import type {TipOptions} from "./marks/tip.js";
45
import type {plot} from "./plot.js";
56
import type {ScaleFunctions} from "./scales.js";
67
import type {InitializerFunction, SortOrder, TransformFunction} from "./transforms/basic.js";
@@ -23,6 +24,9 @@ export type FrameAnchor =
2324
| "bottom-left"
2425
| "left";
2526

27+
/** The pointer mode for the tip; corresponds to pointerX, pointerY, and pointer. */
28+
export type TipPointer = "x" | "y" | "xy";
29+
2630
/**
2731
* A mark’s data; one of:
2832
*
@@ -275,8 +279,8 @@ export interface MarkOptions {
275279
*/
276280
title?: ChannelValue;
277281

278-
/** Whether to generate a tooltip for this mark. */
279-
tip?: boolean | "x" | "y" | "xy";
282+
/** Whether to generate a tooltip for this mark, and any tip options. */
283+
tip?: boolean | TipPointer | (TipOptions & {pointer?: TipPointer});
280284

281285
/**
282286
* How to clip the mark; one of:

src/mark.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {channelDomain, createChannels, valueObject} from "./channel.js";
22
import {defined} from "./defined.js";
33
import {maybeFacetAnchor} from "./facet.js";
4-
import {maybeKeyword, maybeNamed, maybeValue} from "./options.js";
4+
import {maybeNamed, maybeValue} from "./options.js";
55
import {arrayify, isDomainSort, isOptions, keyword, range, singleton} from "./options.js";
66
import {project} from "./projection.js";
77
import {maybeClip, styles} from "./style.js";
@@ -150,15 +150,21 @@ export function composeRender(r1, r2) {
150150
function maybeChannels(channels) {
151151
return Object.fromEntries(
152152
Object.entries(maybeNamed(channels)).map(([name, channel]) => {
153-
channel = maybeValue(channel);
153+
channel = typeof channel === "string" ? {value: channel, label: name} : maybeValue(channel); // for shorthand extra channels, use name as label
154154
if (channel.filter === undefined && channel.scale == null) channel = {...channel, filter: null};
155155
return [name, channel];
156156
})
157157
);
158158
}
159159

160160
function maybeTip(tip) {
161-
return tip === true ? "xy" : tip === false ? null : maybeKeyword(tip, "tip", ["x", "y", "xy"]);
161+
return tip === true
162+
? "xy"
163+
: tip === false || tip == null
164+
? null
165+
: typeof tip === "string"
166+
? keyword(tip, "tip", ["x", "y", "xy"])
167+
: tip; // tip options object
162168
}
163169

164170
export function withTip(options, tip) {

src/marks/tip.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {ChannelValueSpec} from "../channel.js";
1+
import type {ChannelName, ChannelValueSpec} from "../channel.js";
22
import type {Data, FrameAnchor, MarkOptions, RenderableMark} from "../mark.js";
33
import type {TextStyles} from "./text.js";
44

@@ -61,6 +61,13 @@ export interface TipOptions extends MarkOptions, TextStyles {
6161
* the right of the anchor position.
6262
*/
6363
anchor?: FrameAnchor;
64+
65+
/**
66+
* How channel values are formatted for display. If a format is a string, it
67+
* is interpreted as a (UTC) time format for temporal channels, and otherwise
68+
* a number format.
69+
*/
70+
format?: {[name in ChannelName]?: string | ((d: any, i: number) => string)};
6471
}
6572

6673
/**

src/marks/tip.js

Lines changed: 114 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {select} from "d3";
1+
import {select, format as numberFormat, utcFormat} from "d3";
22
import {getSource} from "../channel.js";
33
import {create} from "../context.js";
44
import {defined} from "../defined.js";
@@ -7,7 +7,7 @@ import {anchorX, anchorY} from "../interactions/pointer.js";
77
import {Mark} from "../mark.js";
88
import {maybeAnchor, maybeFrameAnchor, maybeTuple, number, string} from "../options.js";
99
import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, impliedString} from "../style.js";
10-
import {identity, isIterable, isTextual} from "../options.js";
10+
import {identity, isIterable, isTemporal, isTextual} from "../options.js";
1111
import {inferTickFormat} from "./axis.js";
1212
import {applyIndirectTextStyles, defaultWidth, ellipsis, monospaceWidth} from "./text.js";
1313
import {cut, clipper, splitter, maybeTextOverflow} from "./text.js";
@@ -18,8 +18,8 @@ const defaults = {
1818
stroke: "currentColor"
1919
};
2020

21-
// These channels are not displayed in the tip; TODO allow customization.
22-
const ignoreChannels = new Set(["geometry", "href", "src", "ariaLabel"]);
21+
// These channels are not displayed in the default tip; see formatChannels.
22+
const ignoreChannels = new Set(["geometry", "href", "src", "ariaLabel", "scales"]);
2323

2424
export class Tip extends Mark {
2525
constructor(data, options = {}) {
@@ -42,6 +42,7 @@ export class Tip extends Mark {
4242
lineHeight = 1,
4343
lineWidth = 20,
4444
frameAnchor,
45+
format,
4546
textAnchor = "start",
4647
textOverflow,
4748
textPadding = 8,
@@ -82,6 +83,7 @@ export class Tip extends Mark {
8283
for (const key in defaults) if (key in this.channels) this[key] = defaults[key]; // apply default even if channel
8384
this.splitLines = splitter(this);
8485
this.clipLine = clipper(this);
86+
this.format = {...format}; // defensive copy before mutate; also promote nullish to empty
8587
}
8688
render(index, scales, values, dimensions, context) {
8789
const mark = this;
@@ -114,41 +116,33 @@ export class Tip extends Mark {
114116
const widthof = monospace ? monospaceWidth : defaultWidth;
115117
const ee = widthof(ellipsis);
116118

117-
// We borrow the scale’s tick format for facet channels; this is safe for
118-
// ordinal scales (but not continuous scales where the display value may
119-
// need higher precision), and generally better than the default format.
120-
const formatFx = fx && inferTickFormat(fx);
121-
const formatFy = fy && inferTickFormat(fy);
122-
123-
function* format(sources, i) {
124-
if ("title" in sources) {
125-
const text = sources.title.value[i];
126-
for (const line of mark.splitLines(formatDefault(text))) {
127-
yield {name: "", value: mark.clipLine(line)};
128-
}
129-
return;
119+
// Promote shorthand string formats to functions. Note: mutates this.format,
120+
// but that should be safe since we made a defensive copy.
121+
for (const key in this.format) {
122+
const format = this.format[key];
123+
if (typeof format === "string") {
124+
const value = key in sources ? sources[key].value : key in scales ? scales[key].domain() : [];
125+
this.format[key] = (isTemporal(value) ? utcFormat : numberFormat)(format);
130126
}
131-
for (const key in sources) {
132-
if (key === "x1" && "x2" in sources) continue;
133-
if (key === "y1" && "y2" in sources) continue;
134-
const channel = sources[key];
135-
const value = channel.value[i];
136-
if (!defined(value) && channel.scale == null) continue;
137-
if (key === "x2" && "x1" in sources) {
138-
yield {name: formatPairLabel(scales, sources.x1, channel, "x"), value: formatPair(sources.x1, channel, i)};
139-
} else if (key === "y2" && "y1" in sources) {
140-
yield {name: formatPairLabel(scales, sources.y1, channel, "y"), value: formatPair(sources.y1, channel, i)};
141-
} else {
142-
const scale = channel.scale;
143-
const line = {name: formatLabel(scales, channel, key), value: formatDefault(value)};
144-
if (scale === "color" || scale === "opacity") line[scale] = values[key][i];
145-
yield line;
146-
}
147-
}
148-
if (index.fi != null && fx) yield {name: String(fx.label ?? "fx"), value: formatFx(index.fx)};
149-
if (index.fi != null && fy) yield {name: String(fy.label ?? "fy"), value: formatFy(index.fy)};
150127
}
151128

129+
// Borrow the scale tick format for facet channels; this is generally better
130+
// than the default format (and safe for ordinal scales). Note: mutates
131+
// this.format, but that should be safe since we made a defensive copy.
132+
if (index.fi != null) {
133+
const {fx, fy} = scales;
134+
if (fx && this.format.fx === undefined) this.format.fx = inferTickFormat(fx, fx.domain());
135+
if (fy && this.format.fy === undefined) this.format.fy = inferTickFormat(fy, fy.domain());
136+
}
137+
138+
// Determine the appropriate formatter.
139+
const format =
140+
"title" in sources // if there is a title channel
141+
? formatTitle // display the title as-is
142+
: index.fi == null // if this mark is not faceted
143+
? formatChannels // display name-value pairs for channels
144+
: formatFacetedChannels; // same, plus facets
145+
152146
// We don’t call applyChannelStyles because we only use the channels to
153147
// derive the content of the tip, not its aesthetics.
154148
const g = create("svg:g", context)
@@ -172,12 +166,19 @@ export class Tip extends Mark {
172166
this.setAttribute("fill-opacity", 1);
173167
this.setAttribute("stroke", "none");
174168
// iteratively render each channel value
175-
const names = new Set();
176-
for (const line of format(sources, i)) {
177-
const name = line.name;
178-
if (name && names.has(name)) continue;
179-
else names.add(name);
180-
renderLine(that, line);
169+
const lines = format.call(mark, i, index, sources, scales, values);
170+
if (typeof lines === "string") {
171+
for (const line of mark.splitLines(lines)) {
172+
renderLine(that, {value: mark.clipLine(line)});
173+
}
174+
} else {
175+
const labels = new Set();
176+
for (const line of lines) {
177+
const {label = ""} = line;
178+
if (label && labels.has(label)) continue;
179+
else labels.add(label);
180+
renderLine(that, line);
181+
}
181182
}
182183
})
183184
)
@@ -188,27 +189,28 @@ export class Tip extends Mark {
188189
// just the initial layout of the text; in postrender we will compute the
189190
// exact text metrics and translate the text as needed once we know the
190191
// tip’s orientation (anchor).
191-
function renderLine(selection, {name, value, color, opacity}) {
192+
function renderLine(selection, {label, value, color, opacity}) {
193+
(label ??= ""), (value ??= "");
192194
const swatch = color != null || opacity != null;
193195
let title;
194196
let w = lineWidth * 100;
195-
const [j] = cut(name, w, widthof, ee);
197+
const [j] = cut(label, w, widthof, ee);
196198
if (j >= 0) {
197-
// name is truncated
198-
name = name.slice(0, j).trimEnd() + ellipsis;
199+
// label is truncated
200+
label = label.slice(0, j).trimEnd() + ellipsis;
199201
title = value.trim();
200202
value = "";
201203
} else {
202-
if (name || (!value && !swatch)) value = " " + value;
203-
const [k] = cut(value, w - widthof(name), widthof, ee);
204+
if (label || (!value && !swatch)) value = " " + value;
205+
const [k] = cut(value, w - widthof(label), widthof, ee);
204206
if (k >= 0) {
205207
// value is truncated
206208
value = value.slice(0, k).trimEnd() + ellipsis;
207209
title = value.trim();
208210
}
209211
}
210212
const line = selection.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`).text("\u200b"); // zwsp for double-click
211-
if (name) line.append("tspan").attr("font-weight", "bold").text(name);
213+
if (label) line.append("tspan").attr("font-weight", "bold").text(label);
212214
if (value) line.append(() => document.createTextNode(value));
213215
if (swatch) line.append("tspan").text(" ■").attr("fill", color).attr("fill-opacity", opacity).style("user-select", "none"); // prettier-ignore
214216
if (title) line.append("title").text(title);
@@ -332,18 +334,73 @@ function getSources({channels}) {
332334
return sources;
333335
}
334336

335-
function formatPair(c1, c2, i) {
337+
function formatTitle(i, index, {title}) {
338+
const format = this.format?.title;
339+
return format === null ? [] : (format ?? formatDefault)(title.value[i]);
340+
}
341+
342+
function* formatChannels(i, index, channels, scales, values) {
343+
for (const key in channels) {
344+
if (key === "x1" && "x2" in channels) continue;
345+
if (key === "y1" && "y2" in channels) continue;
346+
const channel = channels[key];
347+
if (key === "x2" && "x1" in channels) {
348+
const format = this.format?.x; // TODO x1, x2?
349+
if (format === null) continue;
350+
yield {
351+
label: formatPairLabel(scales, channels, "x"),
352+
value: formatPair(format ?? formatDefault, channels.x1, channel, i)
353+
};
354+
} else if (key === "y2" && "y1" in channels) {
355+
const format = this.format?.y; // TODO y1, y2?
356+
if (format === null) continue;
357+
yield {
358+
label: formatPairLabel(scales, channels, "y"),
359+
value: formatPair(format ?? formatDefault, channels.y1, channel, i)
360+
};
361+
} else {
362+
const format = this.format?.[key];
363+
if (format === null) continue;
364+
const value = channel.value[i];
365+
const scale = channel.scale;
366+
if (!defined(value) && scale == null) continue;
367+
yield {
368+
label: formatLabel(scales, channels, key),
369+
value: (format ?? formatDefault)(value),
370+
color: scale === "color" ? values[key][i] : null,
371+
opacity: scale === "opacity" ? values[key][i] : null
372+
};
373+
}
374+
}
375+
}
376+
377+
function* formatFacetedChannels(i, index, channels, scales, values) {
378+
yield* formatChannels.call(this, i, index, channels, scales, values);
379+
for (const key of ["fx", "fy"]) {
380+
if (!scales[key]) return;
381+
const format = this.format?.[key];
382+
if (format === null) continue;
383+
yield {
384+
label: formatLabel(scales, channels, key),
385+
value: (format ?? formatDefault)(index[key])
386+
};
387+
}
388+
}
389+
390+
function formatPair(formatValue, c1, c2, i) {
336391
return c2.hint?.length // e.g., stackY’s y1 and y2
337-
? `${formatDefault(c2.value[i] - c1.value[i])}`
338-
: `${formatDefault(c1.value[i])}${formatDefault(c2.value[i])}`;
392+
? `${formatValue(c2.value[i] - c1.value[i])}`
393+
: `${formatValue(c1.value[i])}${formatValue(c2.value[i])}`;
339394
}
340395

341-
function formatPairLabel(scales, c1, c2, defaultLabel) {
342-
const l1 = formatLabel(scales, c1, defaultLabel);
343-
const l2 = formatLabel(scales, c2, defaultLabel);
396+
function formatPairLabel(scales, channels, key) {
397+
const l1 = formatLabel(scales, channels, `${key}1`, key);
398+
const l2 = formatLabel(scales, channels, `${key}2`, key);
344399
return l1 === l2 ? l1 : `${l1}${l2}`;
345400
}
346401

347-
function formatLabel(scales, c, defaultLabel) {
348-
return String(scales[c.scale]?.label ?? c?.label ?? defaultLabel);
402+
function formatLabel(scales, channels, key, defaultLabel = key) {
403+
const channel = channels[key];
404+
const scale = scales[channel?.scale ?? key];
405+
return String(scale?.label ?? channel?.label ?? defaultLabel);
349406
}

src/plot.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ export function plot(options = {}) {
239239
// Compute value objects, applying scales and projection as needed.
240240
for (const [mark, state] of stateByMark) {
241241
state.values = mark.scale(state.channels, scales, context);
242+
state.values.data = state.data; // expose transformed data for advanced usage
242243
}
243244

244245
const {width, height} = dimensions;
@@ -523,12 +524,15 @@ function derive(mark, options = {}) {
523524
function inferTips(marks) {
524525
const tips = [];
525526
for (const mark of marks) {
526-
const t = mark.tip;
527-
if (t) {
528-
const p = t === "x" ? pointerX : t === "y" ? pointerY : pointer;
529-
const options = p(derive(mark)); // TODO tip options?
530-
options.title = null; // prevent implicit title for primitive data
531-
tips.push(tip(mark.data, options));
527+
let tipOptions = mark.tip;
528+
if (tipOptions) {
529+
if (tipOptions === true) tipOptions = {};
530+
else if (typeof tipOptions === "string") tipOptions = {pointer: tipOptions};
531+
let {pointer: p} = tipOptions;
532+
p = /^x$/i.test(p) ? pointerX : /^y$/i.test(p) ? pointerY : pointer; // TODO validate?
533+
tipOptions = p(derive(mark, tipOptions));
534+
tipOptions.title = null; // prevent implicit title for primitive data
535+
tips.push(tip(mark.data, tipOptions));
532536
}
533537
}
534538
return tips;

0 commit comments

Comments
 (0)