Skip to content
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

custom tip format #1823

Merged
merged 9 commits into from
Aug 26, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
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]?: boolean | string | ((d: any, i: number) => string)};
}

/**
Expand Down
177 changes: 120 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 All @@ -90,7 +92,6 @@ export class Tip extends Mark {
const {anchor, monospace, lineHeight, lineWidth} = this;
const {textPadding: r, pointerSize: m, pathFilter} = this;
const {marginTop, marginLeft} = dimensions;
const sources = getSources(values);

// The anchor position is the middle of x1 & y1 and x2 & y2, if available,
// or x & y; the former is considered more specific because it’s how we
Expand All @@ -114,39 +115,35 @@ 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);
// If there’s a title channel, display that as-is; otherwise, show multiple
// channels as name-value pairs.
let sources, format;
if ("title" in values) {
sources = values.channels;
format = formatTitle;
} else {
sources = getSourceChannels.call(this, index, values, scales);
format = formatChannels;

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, and materialize default
// formats. Note: this mutates this.format, but that should be safe since
// we made a defensive copy.
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;
const format = this.format[key];
if (typeof format === "string") {
const value = sources[key]?.value ?? scales[key]?.domain() ?? [];
this.format[key] = (isTemporal(value) ? utcFormat : numberFormat)(format);
} else if (format === undefined || format === true) {
// Borrow the scale’s tick format for facet channels; this is
// generally better than the default (and safe for ordinal scales).
if (key === "fx" || key === "fy") {
const scale = scales[key];
this.format[key] = inferTickFormat(scale, scale.domain());
} else {
this.format[key] = formatDefault;
}
}
}
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)};
}

// We don’t call applyChannelStyles because we only use the channels to
Expand All @@ -172,12 +169,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 +192,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 @@ -322,28 +327,86 @@ function getPath(anchor, m, r, width, height) {
}
}

function getSources({channels}) {
function getSourceChannels(index, {channels}, scales) {
const {facet, format} = this;
const sources = {};
// Prioritize channels with explicit formats, in the given order.
for (const key in format) {
if (format[key] === null || format[key] === false) continue;
if (key === "fx" || key === "fy") sources[key] = true;
else {
const source = getSource(channels, key);
if (source) sources[key] = source;
}
}
// Then fallback to all other (non-ignored) channels.
for (const key in channels) {
if (ignoreChannels.has(key)) continue;
if (key in sources || key in format || ignoreChannels.has(key)) continue;
const source = getSource(channels, key);
if (source) sources[key] = source;
}
// And lastly facet channels, but only if this mark is faceted.
if (facet) {
if (scales.fx && !("fx" in format)) sources.fx = true;
if (scales.fy && !("fy" in format)) sources.fy = true;
}
return sources;
}

function formatPair(c1, c2, i) {
function formatTitle(i, index, {title}) {
return formatDefault(title.value[i], i);
}

function* formatChannels(i, index, channels, scales, values) {
for (const key in channels) {
if (key === "fx" || key === "fy") {
yield {
label: formatLabel(scales, channels, key),
value: this.format[key](index[key], i)
};
continue;
}
if (key === "x1" && "x2" in channels) continue;
if (key === "y1" && "y2" in channels) continue;
const channel = channels[key];
if (key === "x2" && "x1" in channels) {
yield {
label: formatPairLabel(scales, channels, "x"),
value: formatPair(this.format.x, channels.x1, channel, i)
mbostock marked this conversation as resolved.
Show resolved Hide resolved
};
} else if (key === "y2" && "y1" in channels) {
yield {
label: formatPairLabel(scales, channels, "y"),
value: formatPair(this.format.y, channels.y1, channel, i)
};
} else {
const value = channel.value[i];
const scale = channel.scale;
if (!defined(value) && scale == null) continue;
yield {
label: formatLabel(scales, channels, key),
value: this.format[key](value, i),
color: scale === "color" ? values[key][i] : null,
opacity: scale === "opacity" ? values[key][i] : null
};
}
}
}

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);
}
Loading