Skip to content

Commit

Permalink
custom tip format
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Aug 25, 2023
1 parent e98ccb0 commit 6743bb5
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 72 deletions.
2 changes: 1 addition & 1 deletion src/channel.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export type ChannelValue =
* object to override the scale that would normally be associated with the
* channel.
*/
export type ChannelValueSpec = ChannelValue | {value: ChannelValue; scale?: Channel["scale"]}; // TODO label
export type ChannelValueSpec = ChannelValue | {value: ChannelValue; label?: string; scale?: Channel["scale"]};

/**
* In some contexts, when specifying a mark channel’s value, you can provide a
Expand Down
4 changes: 2 additions & 2 deletions src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import {registry} from "./scales/index.js";
import {isSymbol, maybeSymbol} from "./symbol.js";
import {maybeReduce} from "./transforms/group.js";

export function createChannel(data, {scale, type, value, filter, hint}, name) {
export function createChannel(data, {scale, type, value, filter, hint, label = labelof(value)}, name) {
if (hint === undefined && typeof value?.transform === "function") hint = value.hint;
return inferChannelScale(name, {
scale,
type,
value: valueof(data, value),
label: labelof(value),
label,
filter,
hint
});
Expand Down
8 changes: 6 additions & 2 deletions src/mark.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
import type {Context} from "./context.js";
import type {Dimensions} from "./dimensions.js";
import type {TipOptions} from "./marks/tip.js";
import type {plot} from "./plot.js";
import type {ScaleFunctions} from "./scales.js";
import type {InitializerFunction, SortOrder, TransformFunction} from "./transforms/basic.js";
Expand All @@ -23,6 +24,9 @@ export type FrameAnchor =
| "bottom-left"
| "left";

/** The pointer mode for the tip; corresponds to pointerX, pointerY, and pointer. */
export type TipPointer = "x" | "y" | "xy";

/**
* A mark’s data; one of:
*
Expand Down Expand Up @@ -275,8 +279,8 @@ export interface MarkOptions {
*/
title?: ChannelValue;

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

/**
* How to clip the mark; one of:
Expand Down
12 changes: 9 additions & 3 deletions src/mark.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {channelDomain, createChannels, valueObject} from "./channel.js";
import {defined} from "./defined.js";
import {maybeFacetAnchor} from "./facet.js";
import {maybeKeyword, maybeNamed, maybeValue} from "./options.js";
import {maybeNamed, maybeValue} from "./options.js";
import {arrayify, isDomainSort, isOptions, keyword, range, singleton} from "./options.js";
import {project} from "./projection.js";
import {maybeClip, styles} from "./style.js";
Expand Down Expand Up @@ -150,15 +150,21 @@ export function composeRender(r1, r2) {
function maybeChannels(channels) {
return Object.fromEntries(
Object.entries(maybeNamed(channels)).map(([name, channel]) => {
channel = maybeValue(channel);
channel = typeof channel === "string" ? {value: channel, label: name} : maybeValue(channel); // for shorthand extra channels, use name as label
if (channel.filter === undefined && channel.scale == null) channel = {...channel, filter: null};
return [name, channel];
})
);
}

function maybeTip(tip) {
return tip === true ? "xy" : tip === false ? null : maybeKeyword(tip, "tip", ["x", "y", "xy"]);
return tip === true
? "xy"
: tip === false || tip == null
? null
: typeof tip === "string"
? keyword(tip, "tip", ["x", "y", "xy"])
: tip; // tip options object
}

export function withTip(options, tip) {
Expand Down
9 changes: 8 additions & 1 deletion src/marks/tip.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {ChannelValueSpec} from "../channel.js";
import type {ChannelName, ChannelValueSpec} from "../channel.js";
import type {Data, FrameAnchor, MarkOptions, RenderableMark} from "../mark.js";
import type {TextStyles} from "./text.js";

Expand Down Expand Up @@ -61,6 +61,13 @@ export interface TipOptions extends MarkOptions, TextStyles {
* the right of the anchor position.
*/
anchor?: FrameAnchor;

/**
* How channel values are formatted for display. If a format is a string, it
* is interpreted as a (UTC) time format for temporal channels, and otherwise
* a number format.
*/
format?: {[name in ChannelName]?: string | ((d: any, i: number) => string)};
}

/**
Expand Down
171 changes: 114 additions & 57 deletions src/marks/tip.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {select} from "d3";
import {select, format as numberFormat, utcFormat} from "d3";
import {getSource} from "../channel.js";
import {create} from "../context.js";
import {defined} from "../defined.js";
Expand All @@ -7,7 +7,7 @@ import {anchorX, anchorY} from "../interactions/pointer.js";
import {Mark} from "../mark.js";
import {maybeAnchor, maybeFrameAnchor, maybeTuple, number, string} from "../options.js";
import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, impliedString} from "../style.js";
import {identity, isIterable, isTextual} from "../options.js";
import {identity, isIterable, isTemporal, isTextual} from "../options.js";
import {inferTickFormat} from "./axis.js";
import {applyIndirectTextStyles, defaultWidth, ellipsis, monospaceWidth} from "./text.js";
import {cut, clipper, splitter, maybeTextOverflow} from "./text.js";
Expand All @@ -18,8 +18,8 @@ const defaults = {
stroke: "currentColor"
};

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

export class Tip extends Mark {
constructor(data, options = {}) {
Expand All @@ -42,6 +42,7 @@ export class Tip extends Mark {
lineHeight = 1,
lineWidth = 20,
frameAnchor,
format,
textAnchor = "start",
textOverflow,
textPadding = 8,
Expand Down Expand Up @@ -82,6 +83,7 @@ export class Tip extends Mark {
for (const key in defaults) if (key in this.channels) this[key] = defaults[key]; // apply default even if channel
this.splitLines = splitter(this);
this.clipLine = clipper(this);
this.format = {...format}; // defensive copy before mutate; also promote nullish to empty
}
render(index, scales, values, dimensions, context) {
const mark = this;
Expand Down Expand Up @@ -114,41 +116,33 @@ export class Tip extends Mark {
const widthof = monospace ? monospaceWidth : defaultWidth;
const ee = widthof(ellipsis);

// We borrow the scale’s tick format for facet channels; this is safe for
// ordinal scales (but not continuous scales where the display value may
// need higher precision), and generally better than the default format.
const formatFx = fx && inferTickFormat(fx);
const formatFy = fy && inferTickFormat(fy);

function* format(sources, i) {
if ("title" in sources) {
const text = sources.title.value[i];
for (const line of mark.splitLines(formatDefault(text))) {
yield {name: "", value: mark.clipLine(line)};
}
return;
// Promote shorthand string formats to functions. Note: mutates this.format,
// but that should be safe since we made a defensive copy.
for (const key in this.format) {
const format = this.format[key];
if (typeof format === "string") {
const value = key in sources ? sources[key].value : key in scales ? scales[key].domain() : [];
this.format[key] = (isTemporal(value) ? utcFormat : numberFormat)(format);
}
for (const key in sources) {
if (key === "x1" && "x2" in sources) continue;
if (key === "y1" && "y2" in sources) continue;
const channel = sources[key];
const value = channel.value[i];
if (!defined(value) && channel.scale == null) continue;
if (key === "x2" && "x1" in sources) {
yield {name: formatPairLabel(scales, sources.x1, channel, "x"), value: formatPair(sources.x1, channel, i)};
} else if (key === "y2" && "y1" in sources) {
yield {name: formatPairLabel(scales, sources.y1, channel, "y"), value: formatPair(sources.y1, channel, i)};
} else {
const scale = channel.scale;
const line = {name: formatLabel(scales, channel, key), value: formatDefault(value)};
if (scale === "color" || scale === "opacity") line[scale] = values[key][i];
yield line;
}
}
if (index.fi != null && fx) yield {name: String(fx.label ?? "fx"), value: formatFx(index.fx)};
if (index.fi != null && fy) yield {name: String(fy.label ?? "fy"), value: formatFy(index.fy)};
}

// Borrow the scale tick format for facet channels; this is generally better
// than the default format (and safe for ordinal scales). Note: mutates
// this.format, but that should be safe since we made a defensive copy.
if (index.fi != null) {
const {fx, fy} = scales;
if (fx && this.format.fx === undefined) this.format.fx = inferTickFormat(fx, fx.domain());
if (fy && this.format.fy === undefined) this.format.fy = inferTickFormat(fy, fy.domain());
}

// Determine the appropriate formatter.
const format =
"title" in sources // if there is a title channel
? formatTitle // display the title as-is
: index.fi == null // if this mark is not faceted
? formatChannels // display name-value pairs for channels
: formatFacetedChannels; // same, plus facets

// We don’t call applyChannelStyles because we only use the channels to
// derive the content of the tip, not its aesthetics.
const g = create("svg:g", context)
Expand All @@ -172,12 +166,19 @@ export class Tip extends Mark {
this.setAttribute("fill-opacity", 1);
this.setAttribute("stroke", "none");
// iteratively render each channel value
const names = new Set();
for (const line of format(sources, i)) {
const name = line.name;
if (name && names.has(name)) continue;
else names.add(name);
renderLine(that, line);
const lines = format.call(mark, i, index, sources, scales, values);
if (typeof lines === "string") {
for (const line of mark.splitLines(lines)) {
renderLine(that, {value: mark.clipLine(line)});
}
} else {
const labels = new Set();
for (const line of lines) {
const {label = ""} = line;
if (label && labels.has(label)) continue;
else labels.add(label);
renderLine(that, line);
}
}
})
)
Expand All @@ -188,27 +189,28 @@ export class Tip extends Mark {
// just the initial layout of the text; in postrender we will compute the
// exact text metrics and translate the text as needed once we know the
// tip’s orientation (anchor).
function renderLine(selection, {name, value, color, opacity}) {
function renderLine(selection, {label, value, color, opacity}) {
(label ??= ""), (value ??= "");
const swatch = color != null || opacity != null;
let title;
let w = lineWidth * 100;
const [j] = cut(name, w, widthof, ee);
const [j] = cut(label, w, widthof, ee);
if (j >= 0) {
// name is truncated
name = name.slice(0, j).trimEnd() + ellipsis;
// label is truncated
label = label.slice(0, j).trimEnd() + ellipsis;
title = value.trim();
value = "";
} else {
if (name || (!value && !swatch)) value = " " + value;
const [k] = cut(value, w - widthof(name), widthof, ee);
if (label || (!value && !swatch)) value = " " + value;
const [k] = cut(value, w - widthof(label), widthof, ee);
if (k >= 0) {
// value is truncated
value = value.slice(0, k).trimEnd() + ellipsis;
title = value.trim();
}
}
const line = selection.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`).text("\u200b"); // zwsp for double-click
if (name) line.append("tspan").attr("font-weight", "bold").text(name);
if (label) line.append("tspan").attr("font-weight", "bold").text(label);
if (value) line.append(() => document.createTextNode(value));
if (swatch) line.append("tspan").text(" ■").attr("fill", color).attr("fill-opacity", opacity).style("user-select", "none"); // prettier-ignore
if (title) line.append("title").text(title);
Expand Down Expand Up @@ -332,18 +334,73 @@ function getSources({channels}) {
return sources;
}

function formatPair(c1, c2, i) {
function formatTitle(i, index, {title}) {
const format = this.format?.title;
return format === null ? [] : (format ?? formatDefault)(title.value[i]);
}

function* formatChannels(i, index, channels, scales, values) {
for (const key in channels) {
if (key === "x1" && "x2" in channels) continue;
if (key === "y1" && "y2" in channels) continue;
const channel = channels[key];
if (key === "x2" && "x1" in channels) {
const format = this.format?.x; // TODO x1, x2?
if (format === null) continue;
yield {
label: formatPairLabel(scales, channels, "x"),
value: formatPair(format ?? formatDefault, channels.x1, channel, i)
};
} else if (key === "y2" && "y1" in channels) {
const format = this.format?.y; // TODO y1, y2?
if (format === null) continue;
yield {
label: formatPairLabel(scales, channels, "y"),
value: formatPair(format ?? formatDefault, channels.y1, channel, i)
};
} else {
const format = this.format?.[key];
if (format === null) continue;
const value = channel.value[i];
const scale = channel.scale;
if (!defined(value) && scale == null) continue;
yield {
label: formatLabel(scales, channels, key),
value: (format ?? formatDefault)(value),
color: scale === "color" ? values[key][i] : null,
opacity: scale === "opacity" ? values[key][i] : null
};
}
}
}

function* formatFacetedChannels(i, index, channels, scales, values) {
yield* formatChannels.call(this, i, index, channels, scales, values);
for (const key of ["fx", "fy"]) {
if (!scales[key]) return;
const format = this.format?.[key];
if (format === null) continue;
yield {
label: formatLabel(scales, channels, key),
value: (format ?? formatDefault)(index[key])
};
}
}

function formatPair(formatValue, c1, c2, i) {
return c2.hint?.length // e.g., stackY’s y1 and y2
? `${formatDefault(c2.value[i] - c1.value[i])}`
: `${formatDefault(c1.value[i])}${formatDefault(c2.value[i])}`;
? `${formatValue(c2.value[i] - c1.value[i])}`
: `${formatValue(c1.value[i])}${formatValue(c2.value[i])}`;
}

function formatPairLabel(scales, c1, c2, defaultLabel) {
const l1 = formatLabel(scales, c1, defaultLabel);
const l2 = formatLabel(scales, c2, defaultLabel);
function formatPairLabel(scales, channels, key) {
const l1 = formatLabel(scales, channels, `${key}1`, key);
const l2 = formatLabel(scales, channels, `${key}2`, key);
return l1 === l2 ? l1 : `${l1}${l2}`;
}

function formatLabel(scales, c, defaultLabel) {
return String(scales[c.scale]?.label ?? c?.label ?? defaultLabel);
function formatLabel(scales, channels, key, defaultLabel = key) {
const channel = channels[key];
const scale = scales[channel?.scale ?? key];
return String(scale?.label ?? channel?.label ?? defaultLabel);
}
16 changes: 10 additions & 6 deletions src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export function plot(options = {}) {
// Compute value objects, applying scales and projection as needed.
for (const [mark, state] of stateByMark) {
state.values = mark.scale(state.channels, scales, context);
state.values.data = state.data; // expose transformed data for advanced usage
}

const {width, height} = dimensions;
Expand Down Expand Up @@ -523,12 +524,15 @@ function derive(mark, options = {}) {
function inferTips(marks) {
const tips = [];
for (const mark of marks) {
const t = mark.tip;
if (t) {
const p = t === "x" ? pointerX : t === "y" ? pointerY : pointer;
const options = p(derive(mark)); // TODO tip options?
options.title = null; // prevent implicit title for primitive data
tips.push(tip(mark.data, options));
let tipOptions = mark.tip;
if (tipOptions) {
if (tipOptions === true) tipOptions = {};
else if (typeof tipOptions === "string") tipOptions = {pointer: tipOptions};
let {pointer: p} = tipOptions;
p = /^x$/i.test(p) ? pointerX : /^y$/i.test(p) ? pointerY : pointer; // TODO validate?
tipOptions = p(derive(mark, tipOptions));
tipOptions.title = null; // prevent implicit title for primitive data
tips.push(tip(mark.data, tipOptions));
}
}
return tips;
Expand Down

0 comments on commit 6743bb5

Please sign in to comment.