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..d7daa06ae1 100644
--- a/src/mark.js
+++ b/src/mark.js
@@ -1,8 +1,8 @@
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 {arrayify, isDomainSort, isOptions, keyword, range, singleton} from "./options.js";
+import {maybeNamed, maybeValue} from "./options.js";
+import {arrayify, isDomainSort, isObject, isOptions, keyword, range, singleton} from "./options.js";
import {project} from "./projection.js";
import {maybeClip, styles} from "./style.js";
import {basic, initializer} from "./transforms/basic.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,9 +158,19 @@ 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) {
- return options?.tip === true ? {...options, tip} : options;
+export function withTip(options, pointer) {
+ return options?.tip === true
+ ? {...options, tip: pointer}
+ : isObject(options?.tip) && options.tip.pointer === undefined
+ ? {...options, tip: {...options.tip, pointer}}
+ : options;
}
diff --git a/src/marks/tip.d.ts b/src/marks/tip.d.ts
index 5e8a114903..81cac9b1e8 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]?: boolean | string | ((d: any, i: number) => string)};
}
/**
diff --git a/src/marks/tip.js b/src/marks/tip.js
index 78a75cdfb5..75f06e45d1 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;
@@ -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
@@ -114,39 +115,15 @@ 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;
- }
- 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)};
+ // 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, values, scales);
+ format = formatChannels;
}
// We don’t call applyChannelStyles because we only use the channels to
@@ -172,12 +149,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 +172,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 +193,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);
@@ -322,28 +307,125 @@ function getPath(anchor, m, r, width, height) {
}
}
-function getSources({channels}) {
+// Note: mutates this.format!
+function getSourceChannels({channels}, scales) {
const sources = {};
+
+ // Promote x and y shorthand for paired channels (in order).
+ let format = this.format;
+ format = maybeExpandPairedFormat(format, channels, "x");
+ format = maybeExpandPairedFormat(format, channels, "y");
+ this.format = format;
+
+ // Prioritize channels with explicit formats, in the given order.
+ for (const key in format) {
+ const value = format[key];
+ if (value === null || value === false) {
+ continue;
+ } else 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 (this.facet) {
+ if (scales.fx && !("fx" in format)) sources.fx = true;
+ if (scales.fy && !("fy" in format)) sources.fy = true;
+ }
+
+ // Promote shorthand string formats, and materialize default formats.
+ for (const key in sources) {
+ 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) {
+ // For ordinal scales, the inferred tick format can be more concise, such
+ // as only showing the year for yearly data.
+ const scale = scales[key];
+ this.format[key] = scale?.bandwidth ? inferTickFormat(scale, scale.domain()) : formatDefault;
+ }
+ }
+
return sources;
}
-function formatPair(c1, c2, i) {
+// Promote x and y shorthand for paired channels, while preserving order.
+function maybeExpandPairedFormat(format, channels, key) {
+ if (!(key in format)) return format;
+ const key1 = `${key}1`;
+ const key2 = `${key}2`;
+ if ((key1 in format || !(key1 in channels)) && (key2 in format || !(key2 in channels))) return format;
+ const entries = Object.entries(format);
+ const value = format[key];
+ entries.splice(entries.findIndex(([name]) => name === key) + 1, 0, [key1, value], [key2, value]);
+ return Object.fromEntries(entries);
+}
+
+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.x2, channels.x1, channel, i)
+ };
+ } else if (key === "y2" && "y1" in channels) {
+ yield {
+ label: formatPairLabel(scales, channels, "y"),
+ value: formatPair(this.format.y2, 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);
}
diff --git a/src/plot.js b/src/plot.js
index 11d83282f9..a5001e14ca 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -523,12 +523,18 @@ 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
+ const t = tip(mark.data, tipOptions);
+ t.facet = mark.facet; // inherit facet settings
+ t.facetAnchor = mark.facetAnchor; // inherit facet settings
+ tips.push(t);
}
}
return tips;
diff --git a/src/time.js b/src/time.js
index fcfccc8980..5197a09f52 100644
--- a/src/time.js
+++ b/src/time.js
@@ -193,6 +193,23 @@ export function generalizeTimeInterval(interval, n) {
function formatTimeInterval(name, type, anchor) {
const format = type === "time" ? timeFormat : utcFormat;
+ // For tips and legends, use a format that doesn’t require context.
+ if (anchor == null) {
+ return format(
+ name === "year"
+ ? "%Y"
+ : name === "month"
+ ? "%Y-%m"
+ : name === "day"
+ ? "%Y-%m-%d"
+ : name === "hour" || name === "minute"
+ ? "%Y-%m-%dT%H:%M"
+ : name === "second"
+ ? "%Y-%m-%dT%H:%M:%S"
+ : "%Y-%m-%dT%H:%M:%S.%L"
+ );
+ }
+ // Otherwise, assume that this is for axis ticks.
const template = getTimeTemplate(anchor);
switch (name) {
case "millisecond":
diff --git a/test/output/tipFormatChannels.svg b/test/output/tipFormatChannels.svg
new file mode 100644
index 0000000000..52911a1d06
--- /dev/null
+++ b/test/output/tipFormatChannels.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatFacet.svg b/test/output/tipFormatFacet.svg
new file mode 100644
index 0000000000..5e21df079f
--- /dev/null
+++ b/test/output/tipFormatFacet.svg
@@ -0,0 +1,54 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatFacetFalse.svg b/test/output/tipFormatFacetFalse.svg
new file mode 100644
index 0000000000..3da72c8651
--- /dev/null
+++ b/test/output/tipFormatFacetFalse.svg
@@ -0,0 +1,54 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatFacetFormat.svg b/test/output/tipFormatFacetFormat.svg
new file mode 100644
index 0000000000..7032700158
--- /dev/null
+++ b/test/output/tipFormatFacetFormat.svg
@@ -0,0 +1,54 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatFacetFormatDefaultDay.svg b/test/output/tipFormatFacetFormatDefaultDay.svg
new file mode 100644
index 0000000000..4219ef96bb
--- /dev/null
+++ b/test/output/tipFormatFacetFormatDefaultDay.svg
@@ -0,0 +1,54 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatFacetFormatDefaultHour.svg b/test/output/tipFormatFacetFormatDefaultHour.svg
new file mode 100644
index 0000000000..a738110d2e
--- /dev/null
+++ b/test/output/tipFormatFacetFormatDefaultHour.svg
@@ -0,0 +1,54 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatFacetFormatDefaultYear.svg b/test/output/tipFormatFacetFormatDefaultYear.svg
new file mode 100644
index 0000000000..e552cc77c0
--- /dev/null
+++ b/test/output/tipFormatFacetFormatDefaultYear.svg
@@ -0,0 +1,54 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatFacetLabel.svg b/test/output/tipFormatFacetLabel.svg
new file mode 100644
index 0000000000..3dbd7398af
--- /dev/null
+++ b/test/output/tipFormatFacetLabel.svg
@@ -0,0 +1,57 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatFunction.svg b/test/output/tipFormatFunction.svg
new file mode 100644
index 0000000000..459db77db4
--- /dev/null
+++ b/test/output/tipFormatFunction.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatNull.svg b/test/output/tipFormatNull.svg
new file mode 100644
index 0000000000..708435c998
--- /dev/null
+++ b/test/output/tipFormatNull.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPaired.svg b/test/output/tipFormatPaired.svg
new file mode 100644
index 0000000000..f3aa6d950c
--- /dev/null
+++ b/test/output/tipFormatPaired.svg
@@ -0,0 +1,48 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPairedFormat.svg b/test/output/tipFormatPairedFormat.svg
new file mode 100644
index 0000000000..b4468ccbcd
--- /dev/null
+++ b/test/output/tipFormatPairedFormat.svg
@@ -0,0 +1,48 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPairedLabel.svg b/test/output/tipFormatPairedLabel.svg
new file mode 100644
index 0000000000..b52d24f3a7
--- /dev/null
+++ b/test/output/tipFormatPairedLabel.svg
@@ -0,0 +1,48 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPairedLabelChannel.svg b/test/output/tipFormatPairedLabelChannel.svg
new file mode 100644
index 0000000000..81139cf20d
--- /dev/null
+++ b/test/output/tipFormatPairedLabelChannel.svg
@@ -0,0 +1,48 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPairedLabelScale.svg b/test/output/tipFormatPairedLabelScale.svg
new file mode 100644
index 0000000000..b05b54858e
--- /dev/null
+++ b/test/output/tipFormatPairedLabelScale.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPairedPartial.svg b/test/output/tipFormatPairedPartial.svg
new file mode 100644
index 0000000000..03f483385c
--- /dev/null
+++ b/test/output/tipFormatPairedPartial.svg
@@ -0,0 +1,48 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPriority1.svg b/test/output/tipFormatPriority1.svg
new file mode 100644
index 0000000000..54d55fbd5e
--- /dev/null
+++ b/test/output/tipFormatPriority1.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPriority2.svg b/test/output/tipFormatPriority2.svg
new file mode 100644
index 0000000000..44f54dbd6e
--- /dev/null
+++ b/test/output/tipFormatPriority2.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPriorityDefault.svg b/test/output/tipFormatPriorityDefault.svg
new file mode 100644
index 0000000000..ad8254f3fa
--- /dev/null
+++ b/test/output/tipFormatPriorityDefault.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPriorityPaired.svg b/test/output/tipFormatPriorityPaired.svg
new file mode 100644
index 0000000000..a56f2f8db5
--- /dev/null
+++ b/test/output/tipFormatPriorityPaired.svg
@@ -0,0 +1,48 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatPriorityPaired2.svg b/test/output/tipFormatPriorityPaired2.svg
new file mode 100644
index 0000000000..707a2e5a2a
--- /dev/null
+++ b/test/output/tipFormatPriorityPaired2.svg
@@ -0,0 +1,48 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatStringDate.svg b/test/output/tipFormatStringDate.svg
new file mode 100644
index 0000000000..d2cdca9cd8
--- /dev/null
+++ b/test/output/tipFormatStringDate.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatStringNumber.svg b/test/output/tipFormatStringNumber.svg
new file mode 100644
index 0000000000..459db77db4
--- /dev/null
+++ b/test/output/tipFormatStringNumber.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatTitleExplicit.svg b/test/output/tipFormatTitleExplicit.svg
new file mode 100644
index 0000000000..b9d71d980c
--- /dev/null
+++ b/test/output/tipFormatTitleExplicit.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatTitleIgnoreFormat.svg b/test/output/tipFormatTitleIgnoreFormat.svg
new file mode 100644
index 0000000000..a1b6d42f3e
--- /dev/null
+++ b/test/output/tipFormatTitleIgnoreFormat.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/tipFormatTitlePrimitive.svg b/test/output/tipFormatTitlePrimitive.svg
new file mode 100644
index 0000000000..9603faf748
--- /dev/null
+++ b/test/output/tipFormatTitlePrimitive.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/plots/index.ts b/test/plots/index.ts
index 747dad015d..9fc03ef56f 100644
--- a/test/plots/index.ts
+++ b/test/plots/index.ts
@@ -292,6 +292,7 @@ export * from "./text-overflow.js";
export * from "./this-is-just-to-say.js";
export * from "./time-axis.js";
export * from "./tip.js";
+export * from "./tip-format.js";
export * from "./title.js";
export * from "./traffic-horizon.js";
export * from "./travelers-covid-drop.js";
diff --git a/test/plots/tip-format.ts b/test/plots/tip-format.ts
new file mode 100644
index 0000000000..f928f4fcf8
--- /dev/null
+++ b/test/plots/tip-format.ts
@@ -0,0 +1,113 @@
+import * as Plot from "@observablehq/plot";
+
+function tip(
+ data: Plot.Data,
+ {x = 0, frameAnchor = "bottom", anchor = "bottom", ...tipOptions}: Plot.TipOptions = {},
+ {height = 90, ...plotOptions}: Plot.PlotOptions = {}
+) {
+ return Plot.tip(data, {x, frameAnchor, anchor, ...tipOptions}).plot({height, ...plotOptions});
+}
+
+export async function tipFormatChannels() {
+ return tip([{value: 1}], {channels: {Name: ["Bob"], Value: "value"}});
+}
+
+export async function tipFormatFacet() {
+ return tip({length: 2}, {fx: ["a", "b"]}, {height: 110});
+}
+
+export async function tipFormatFacetFalse() {
+ return tip({length: 1}, {facet: false}, {marks: [Plot.ruleX({length: 2}, {fx: ["a", "b"]})], height: 110});
+}
+
+export async function tipFormatFacetFormat() {
+ return tip({length: 2}, {fx: [new Date("2001-01-01"), new Date("2001-01-02")], format: {fx: "%b %-d"}}, {height: 110}); // prettier-ignore
+}
+
+export async function tipFormatFacetFormatDefaultHour() {
+ return tip({length: 2}, {fx: [new Date("2001-01-01T12:00Z"), new Date("2001-01-01T13:00Z")]}, {height: 110});
+}
+
+export async function tipFormatFacetFormatDefaultDay() {
+ return tip({length: 2}, {fx: [new Date("2001-01-01"), new Date("2001-01-02")]}, {height: 110});
+}
+
+export async function tipFormatFacetFormatDefaultYear() {
+ return tip({length: 2}, {fx: [new Date("2001-01-01"), new Date("2002-01-01")]}, {height: 110});
+}
+
+export async function tipFormatFacetLabel() {
+ return tip({length: 2}, {fx: [new Date("2001-01-01"), new Date("2002-01-01")]}, {fx: {label: "Year"}, height: 110});
+}
+
+export async function tipFormatFunction() {
+ return tip({length: 1}, {format: {x: (d) => d.toFixed(2)}});
+}
+
+export async function tipFormatNull() {
+ return tip([{value: 1}], {channels: {Value: "value"}, format: {x: null}});
+}
+
+export async function tipFormatPaired() {
+ return tip({length: 1}, {x1: 0, x2: 1});
+}
+
+export async function tipFormatPairedFormat() {
+ return tip([{low: 0, high: 1}], {x1: "low", x2: "high", format: {x: ".2f"}});
+}
+
+export async function tipFormatPairedLabel() {
+ return tip([{low: 0, high: 1}], {x1: "low", x2: "high"});
+}
+
+export async function tipFormatPairedLabelChannel() {
+ return tip({length: 1}, {x1: Object.assign([0], {label: "Low"}), x2: Object.assign([1], {label: "High"})});
+}
+
+export async function tipFormatPairedLabelScale() {
+ return tip({length: 1}, {x1: 0, x2: 1}, {x: {label: "Intensity"}});
+}
+
+export async function tipFormatPairedPartial() {
+ return tip([{low: 0, high: 1}], {x1: "low", x2: "high", format: {x1: null}});
+}
+
+export async function tipFormatPriority1() {
+ return tip({length: 1}, {channels: {a: ["A"], b: ["B"]}, format: {x: true}});
+}
+
+export async function tipFormatPriority2() {
+ return tip({length: 1}, {channels: {a: ["A"], b: ["B"]}, format: {b: true} as any});
+}
+
+export async function tipFormatPriorityDefault() {
+ return tip({length: 1}, {channels: {a: ["A"], b: ["B"]}, format: {}});
+}
+
+export async function tipFormatPriorityPaired() {
+ return tip([{low: 0, high: 1}], {fill: [0], x1: "low", x2: "high", format: {x: true}});
+}
+
+export async function tipFormatPriorityPaired2() {
+ return tip([{low: 0, high: 1}], {fill: [0], x1: "low", x2: "high", format: {fill: true}});
+}
+
+export async function tipFormatStringDate() {
+ return tip({length: 1}, {x: new Date("2001-01-01"), format: {x: "%B %-d, %Y"}});
+}
+
+export async function tipFormatStringNumber() {
+ return tip({length: 1}, {format: {x: ".2f"}});
+}
+
+export async function tipFormatTitleExplicit() {
+ return tip({length: 1}, {title: [new Date("2010-01-01")]});
+}
+
+export async function tipFormatTitleIgnoreFormat() {
+ return tip({length: 1}, {title: [0], format: {title: ".2f"}});
+}
+
+export async function tipFormatTitlePrimitive() {
+ return tip(["hello\nworld"], {x: 0});
+}