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 @@ + + + + + + + 0 + + + + + Name BobValue 1x 0 + + + \ 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 @@ + + + + + a + + + b + + + + + + + + + + + + + 0 + + + 0 + + + + + + + x 0fx a + + + + + + x 0fx b + + + + \ 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 @@ + + + + + a + + + b + + + + + + + + + + + + + 0 + + + 0 + + + + + + + x 0 + + + + + + x 0 + + + + \ 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 @@ + + + + + Jan1 + + + 2 + + + + + + + + + + + + + 0 + + + 0 + + + + + + + fx Jan 1x 0 + + + + + + fx Jan 2x 0 + + + + \ 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 @@ + + + + + Jan1 + + + 2 + + + + + + + + + + + + + 0 + + + 0 + + + + + + + x 0fx 2001-01-01 + + + + + + x 0fx 2001-01-02 + + + + \ 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 @@ + + + + + Jan 112 PM + + + 1 PM + + + + + + + + + + + + + 0 + + + 0 + + + + + + + x 0fx 2001-01-01T12:00 + + + + + + x 0fx 2001-01-01T13:00 + + + + \ 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 @@ + + + + + 2001 + + + 2002 + + + + + + + + + + + + + 0 + + + 0 + + + + + + + x 0fx 2001 + + + + + + x 0fx 2002 + + + + \ 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 @@ + + + + + 2001 + + + 2002 + + + + Year + + + + + + + + + + + + 0 + + + 0 + + + + + + + x 0Year 2001 + + + + + + x 0Year 2002 + + + + \ 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 @@ + + + + + + + 0 + + + + + x 0.00 + + + \ 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 @@ + + + + + + + 0 + + + + + Value 1 + + + \ 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 @@ + + + + + + + + + + + + + + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + + + x 0–1 + + + \ 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 @@ + + + + + + + + + + + + + + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + + + low–high 0.00–1.00 + + + \ 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 @@ + + + + + + + + + + + + + + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + + + low–high 0–1 + + + \ 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 @@ + + + + + + + + + + + + + + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + + + Low–High 0–1 + + + \ 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 @@ + + + + + + + + + + + + + + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + Intensity → + + + + + Intensity 0–1 + + + \ 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 @@ + + + + + + + + + + + + + + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + + + high 1 + + + \ 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 @@ + + + + + + + 0 + + + + + x 0a Ab B + + + \ 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 @@ + + + + + + + 0 + + + + + b Ba Ax 0 + + + \ 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 @@ + + + + + + + 0 + + + + + a Ab Bx 0 + + + \ 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 @@ + + + + + + + + + + + + + + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + + + low–high 0–1fill 0 + + + \ 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 @@ + + + + + + + + + + + + + + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + + + fill 0low–high 0–1 + + + \ 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 @@ + + + + + + + 2001 + + + + + x January 1, 2001 + + + \ 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 @@ + + + + + + + 0 + + + + + x 0.00 + + + \ 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 @@ + + + + + + + 0 + + + + + ​2010-01-01 + + + \ 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 @@ + + + + + + + 0 + + + + + ​0 + + + \ 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 @@ + + + + + + + 0 + + + + + ​hello​world + + + \ 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}); +}