Skip to content

custom tip format #1823

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 26, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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], 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, i),
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], i)
};
}
}

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], i)}`
: `${formatValue(c1.value[i], i)}–${formatValue(c2.value[i], 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