From 6743bb5584b610f1fc3df46f4e9686156f59c033 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 22 Aug 2023 10:41:05 -0700 Subject: [PATCH] custom tip format --- src/channel.d.ts | 2 +- src/channel.js | 4 +- src/mark.d.ts | 8 ++- src/mark.js | 12 +++- src/marks/tip.d.ts | 9 ++- src/marks/tip.js | 171 ++++++++++++++++++++++++++++++--------------- src/plot.js | 16 +++-- 7 files changed, 150 insertions(+), 72 deletions(-) diff --git a/src/channel.d.ts b/src/channel.d.ts index e88bb77c2a..51819817a6 100644 --- a/src/channel.d.ts +++ b/src/channel.d.ts @@ -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 diff --git a/src/channel.js b/src/channel.js index 38edcc3e2a..4fb46c3448 100644 --- a/src/channel.js +++ b/src/channel.js @@ -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 }); diff --git a/src/mark.d.ts b/src/mark.d.ts index 01983a3ff8..54f14276ca 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -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"; @@ -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: * @@ -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: diff --git a/src/mark.js b/src/mark.js index 4cd3d854ab..6d70880140 100644 --- a/src/mark.js +++ b/src/mark.js @@ -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"; @@ -150,7 +150,7 @@ 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]; }) @@ -158,7 +158,13 @@ function maybeChannels(channels) { } 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) { diff --git a/src/marks/tip.d.ts b/src/marks/tip.d.ts index 5e8a114903..2e8e611fa3 100644 --- a/src/marks/tip.d.ts +++ b/src/marks/tip.d.ts @@ -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"; @@ -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)}; } /** diff --git a/src/marks/tip.js b/src/marks/tip.js index 78a75cdfb5..c8bb93c068 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -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"; @@ -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"; @@ -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 = {}) { @@ -42,6 +42,7 @@ export class Tip extends Mark { lineHeight = 1, lineWidth = 20, frameAnchor, + format, textAnchor = "start", textOverflow, textPadding = 8, @@ -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; @@ -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) @@ -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); + } } }) ) @@ -188,19 +189,20 @@ 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; @@ -208,7 +210,7 @@ export class Tip extends Mark { } } 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); @@ -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); } diff --git a/src/plot.js b/src/plot.js index 11d83282f9..ebc4ed610b 100644 --- a/src/plot.js +++ b/src/plot.js @@ -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; @@ -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;