From 7b4d5ed9377a77dddaedc0c6eb65b563d1baa609 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 22 Aug 2023 10:41:05 -0700 Subject: [PATCH 1/9] 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..d6c969dc07 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], i); +} + +function* formatChannels(i, index, channels, scales, values) { + for (const key in channels) { + if (key === "x1" && "x2" in channels) continue; + if (key === "y1" && "y2" in channels) continue; + const channel = channels[key]; + if (key === "x2" && "x1" in channels) { + const format = this.format?.x; // TODO x1, x2? + if (format === null) continue; + yield { + label: formatPairLabel(scales, channels, "x"), + value: formatPair(format ?? formatDefault, channels.x1, channel, i) + }; + } else if (key === "y2" && "y1" in channels) { + const format = this.format?.y; // TODO y1, y2? + if (format === null) continue; + yield { + label: formatPairLabel(scales, channels, "y"), + value: formatPair(format ?? formatDefault, channels.y1, channel, i) + }; + } else { + const format = this.format?.[key]; + if (format === null) continue; + const value = channel.value[i]; + const scale = channel.scale; + if (!defined(value) && scale == null) continue; + yield { + label: formatLabel(scales, channels, key), + value: (format ?? formatDefault)(value, i), + color: scale === "color" ? values[key][i] : null, + opacity: scale === "opacity" ? values[key][i] : null + }; + } + } +} + +function* formatFacetedChannels(i, index, channels, scales, values) { + yield* formatChannels.call(this, i, index, channels, scales, values); + for (const key of ["fx", "fy"]) { + if (!scales[key]) return; + const format = this.format?.[key]; + if (format === null) continue; + yield { + label: formatLabel(scales, channels, key), + value: (format ?? formatDefault)(index[key], i) + }; + } +} + +function formatPair(formatValue, c1, c2, i) { return c2.hint?.length // e.g., stackY’s y1 and y2 - ? `${formatDefault(c2.value[i] - c1.value[i])}` - : `${formatDefault(c1.value[i])}–${formatDefault(c2.value[i])}`; + ? `${formatValue(c2.value[i] - c1.value[i], i)}` + : `${formatValue(c1.value[i], i)}–${formatValue(c2.value[i], i)}`; } -function formatPairLabel(scales, c1, c2, defaultLabel) { - const l1 = formatLabel(scales, c1, defaultLabel); - const l2 = formatLabel(scales, c2, defaultLabel); +function formatPairLabel(scales, channels, key) { + const l1 = formatLabel(scales, channels, `${key}1`, key); + const l2 = formatLabel(scales, channels, `${key}2`, key); return l1 === l2 ? l1 : `${l1}–${l2}`; } -function formatLabel(scales, c, defaultLabel) { - return String(scales[c.scale]?.label ?? c?.label ?? defaultLabel); +function formatLabel(scales, channels, key, defaultLabel = key) { + const channel = channels[key]; + const scale = scales[channel?.scale ?? key]; + return String(scale?.label ?? channel?.label ?? defaultLabel); } 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; From 04bd08e0a6f452e0fb2b1752056a43407755eb80 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 25 Aug 2023 09:17:17 -0700 Subject: [PATCH 2/9] format order; materialize defaults; fix facets --- src/marks/tip.d.ts | 2 +- src/marks/tip.js | 108 ++++++++++++++++++++++++--------------------- src/plot.js | 5 ++- 3 files changed, 62 insertions(+), 53 deletions(-) diff --git a/src/marks/tip.d.ts b/src/marks/tip.d.ts index 2e8e611fa3..81cac9b1e8 100644 --- a/src/marks/tip.d.ts +++ b/src/marks/tip.d.ts @@ -67,7 +67,7 @@ export interface TipOptions extends MarkOptions, TextStyles { * 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)}; + format?: {[name in ChannelName]?: boolean | string | ((d: any, i: number) => string)}; } /** diff --git a/src/marks/tip.js b/src/marks/tip.js index d6c969dc07..0a271e1dd9 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -92,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 @@ -116,33 +115,37 @@ export class Tip extends Mark { const widthof = monospace ? monospaceWidth : defaultWidth; const ee = widthof(ellipsis); - // 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); - } - } + // 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; - // 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()); + // 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) { + 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; + } + } + } } - // 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) @@ -324,49 +327,65 @@ 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 formatTitle(i, index, {title}) { - const format = this.format?.title; - return format === null ? [] : (format ?? formatDefault)(title.value[i], i); + 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) { - 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) + value: formatPair(this.format.x, 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) + value: formatPair(this.format.y, channels.y1, channel, i) }; } else { - const format = this.format?.[key]; - if (format === null) continue; const value = channel.value[i]; const scale = channel.scale; if (!defined(value) && scale == null) continue; yield { label: formatLabel(scales, channels, key), - value: (format ?? formatDefault)(value, i), + value: this.format[key](value, i), color: scale === "color" ? values[key][i] : null, opacity: scale === "opacity" ? values[key][i] : null }; @@ -374,19 +393,6 @@ function* formatChannels(i, index, channels, scales, values) { } } -function* formatFacetedChannels(i, index, channels, scales, values) { - yield* formatChannels.call(this, i, index, channels, scales, values); - for (const key of ["fx", "fy"]) { - if (!scales[key]) return; - const format = this.format?.[key]; - if (format === null) continue; - yield { - label: formatLabel(scales, channels, key), - value: (format ?? formatDefault)(index[key], i) - }; - } -} - function formatPair(formatValue, c1, c2, i) { return c2.hint?.length // e.g., stackY’s y1 and y2 ? `${formatValue(c2.value[i] - c1.value[i], i)}` diff --git a/src/plot.js b/src/plot.js index ebc4ed610b..509354563d 100644 --- a/src/plot.js +++ b/src/plot.js @@ -532,7 +532,10 @@ function inferTips(marks) { 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)); + 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; From 09f9182bbb7d166fb7c6f661c848e2c275e08597 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 25 Aug 2023 09:40:06 -0700 Subject: [PATCH 3/9] revert data values, for now --- src/plot.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plot.js b/src/plot.js index 509354563d..a5001e14ca 100644 --- a/src/plot.js +++ b/src/plot.js @@ -239,7 +239,6 @@ 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; From 4d4f598f158a22e015083a8b4e169807a5215403 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 25 Aug 2023 12:39:27 -0700 Subject: [PATCH 4/9] fix default tip pointer --- src/mark.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/mark.js b/src/mark.js index 6d70880140..d7daa06ae1 100644 --- a/src/mark.js +++ b/src/mark.js @@ -2,7 +2,7 @@ import {channelDomain, createChannels, valueObject} from "./channel.js"; import {defined} from "./defined.js"; import {maybeFacetAnchor} from "./facet.js"; import {maybeNamed, maybeValue} from "./options.js"; -import {arrayify, isDomainSort, isOptions, keyword, range, singleton} 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"; @@ -167,6 +167,10 @@ function maybeTip(tip) { : 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; } From 8ff080df2563cf485d80bd6f54ea44840d4cd073 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 25 Aug 2023 12:51:46 -0700 Subject: [PATCH 5/9] fix paired channels --- src/marks/tip.js | 71 ++++++++++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/src/marks/tip.js b/src/marks/tip.js index 0a271e1dd9..69d76369da 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -124,26 +124,6 @@ export class Tip extends Mark { } else { sources = getSourceChannels.call(this, index, values, scales); format = formatChannels; - - // 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) { - 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; - } - } - } } // We don’t call applyChannelStyles because we only use the channels to @@ -327,29 +307,68 @@ function getPath(anchor, m, r, width, height) { } } +// Note: mutates this.format! 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); + const value = format[key]; + if (value === null || value === false) { + // Promote x and y null shorthand for paired channels (in order). + if (key === "x" || key === "y") { + const key1 = `${key}1`; + const key2 = `${key}2`; + if (!(key1 in format)) format[key1] = null; + if (!(key2 in format)) format[key2] = null; + } + } else if (key === "fx" || key === "fy") { + sources[key] = true; + } else { + let source = getSource(channels, key); if (source) sources[key] = source; + // Promote x and y non-null shorthand for paired channels (in order). + if (key === "x" || key === "y") { + const key1 = `${key}1`; + const key2 = `${key}2`; + if (!(key1 in format) && (source = getSource(channels, key1))) (format[key1] = value), (sources[key1] = source); + if (!(key2 in format) && (source = getSource(channels, key2))) (format[key2] = value), (sources[key2] = source); + } } } + // Then fallback to all other (non-ignored) channels. for (const key in channels) { 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; } + + // 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) { + // 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; + } + } + } + return sources; } @@ -372,12 +391,12 @@ function* formatChannels(i, index, channels, scales, values) { if (key === "x2" && "x1" in channels) { yield { label: formatPairLabel(scales, channels, "x"), - value: formatPair(this.format.x, channels.x1, channel, i) + 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.y, channels.y1, channel, i) + value: formatPair(this.format.y2, channels.y1, channel, i) }; } else { const value = channel.value[i]; From aa5c268866643a9c408ad28557b6b96c52dea374 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 25 Aug 2023 13:42:25 -0700 Subject: [PATCH 6/9] fix paired channel order --- src/marks/tip.js | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/marks/tip.js b/src/marks/tip.js index 69d76369da..d074fb37f6 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -122,7 +122,7 @@ export class Tip extends Mark { sources = values.channels; format = formatTitle; } else { - sources = getSourceChannels.call(this, index, values, scales); + sources = getSourceChannels.call(this, values, scales); format = formatChannels; } @@ -308,33 +308,25 @@ function getPath(anchor, m, r, width, height) { } // Note: mutates this.format! -function getSourceChannels(index, {channels}, scales) { - const {facet, format} = this; +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) { - // Promote x and y null shorthand for paired channels (in order). - if (key === "x" || key === "y") { - const key1 = `${key}1`; - const key2 = `${key}2`; - if (!(key1 in format)) format[key1] = null; - if (!(key2 in format)) format[key2] = null; - } + continue; } else if (key === "fx" || key === "fy") { sources[key] = true; } else { - let source = getSource(channels, key); + const source = getSource(channels, key); if (source) sources[key] = source; - // Promote x and y non-null shorthand for paired channels (in order). - if (key === "x" || key === "y") { - const key1 = `${key}1`; - const key2 = `${key}2`; - if (!(key1 in format) && (source = getSource(channels, key1))) (format[key1] = value), (sources[key1] = source); - if (!(key2 in format) && (source = getSource(channels, key2))) (format[key2] = value), (sources[key2] = source); - } } } @@ -346,7 +338,7 @@ function getSourceChannels(index, {channels}, scales) { } // And lastly facet channels, but only if this mark is faceted. - if (facet) { + if (this.facet) { if (scales.fx && !("fx" in format)) sources.fx = true; if (scales.fy && !("fy" in format)) sources.fy = true; } @@ -372,6 +364,18 @@ function getSourceChannels(index, {channels}, scales) { return sources; } +// 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); } From c9ef20976cae16fcef3545b01243afa162571c5f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 25 Aug 2023 17:04:52 -0700 Subject: [PATCH 7/9] fix crash with inferred tick format --- src/marks/tip.js | 12 +- src/time.js | 17 +++ test/output/tipFormatChannels.svg | 28 +++++ test/output/tipFormatFacet.svg | 54 +++++++++ test/output/tipFormatFacetFalse.svg | 54 +++++++++ test/output/tipFormatFacetFormat.svg | 54 +++++++++ .../output/tipFormatFacetFormatDefaultDay.svg | 54 +++++++++ .../tipFormatFacetFormatDefaultHour.svg | 54 +++++++++ .../tipFormatFacetFormatDefaultYear.svg | 54 +++++++++ test/output/tipFormatFacetLabel.svg | 57 +++++++++ test/output/tipFormatFunction.svg | 28 +++++ test/output/tipFormatNull.svg | 28 +++++ test/output/tipFormatPaired.svg | 48 ++++++++ test/output/tipFormatPairedFormat.svg | 48 ++++++++ test/output/tipFormatPairedLabel.svg | 48 ++++++++ test/output/tipFormatPairedLabelChannel.svg | 48 ++++++++ test/output/tipFormatPairedLabelScale.svg | 51 ++++++++ test/output/tipFormatPairedPartial.svg | 48 ++++++++ test/output/tipFormatPriority1.svg | 28 +++++ test/output/tipFormatPriority2.svg | 28 +++++ test/output/tipFormatPriorityDefault.svg | 28 +++++ test/output/tipFormatPriorityPaired.svg | 48 ++++++++ test/output/tipFormatPriorityPaired2.svg | 48 ++++++++ test/output/tipFormatStringDate.svg | 28 +++++ test/output/tipFormatStringNumber.svg | 28 +++++ test/output/tipFormatTitleExplicit.svg | 28 +++++ test/output/tipFormatTitleIgnoreFormat.svg | 28 +++++ test/output/tipFormatTitlePrimitive.svg | 28 +++++ test/plots/index.ts | 1 + test/plots/tip-format.ts | 113 ++++++++++++++++++ 30 files changed, 1211 insertions(+), 8 deletions(-) create mode 100644 test/output/tipFormatChannels.svg create mode 100644 test/output/tipFormatFacet.svg create mode 100644 test/output/tipFormatFacetFalse.svg create mode 100644 test/output/tipFormatFacetFormat.svg create mode 100644 test/output/tipFormatFacetFormatDefaultDay.svg create mode 100644 test/output/tipFormatFacetFormatDefaultHour.svg create mode 100644 test/output/tipFormatFacetFormatDefaultYear.svg create mode 100644 test/output/tipFormatFacetLabel.svg create mode 100644 test/output/tipFormatFunction.svg create mode 100644 test/output/tipFormatNull.svg create mode 100644 test/output/tipFormatPaired.svg create mode 100644 test/output/tipFormatPairedFormat.svg create mode 100644 test/output/tipFormatPairedLabel.svg create mode 100644 test/output/tipFormatPairedLabelChannel.svg create mode 100644 test/output/tipFormatPairedLabelScale.svg create mode 100644 test/output/tipFormatPairedPartial.svg create mode 100644 test/output/tipFormatPriority1.svg create mode 100644 test/output/tipFormatPriority2.svg create mode 100644 test/output/tipFormatPriorityDefault.svg create mode 100644 test/output/tipFormatPriorityPaired.svg create mode 100644 test/output/tipFormatPriorityPaired2.svg create mode 100644 test/output/tipFormatStringDate.svg create mode 100644 test/output/tipFormatStringNumber.svg create mode 100644 test/output/tipFormatTitleExplicit.svg create mode 100644 test/output/tipFormatTitleIgnoreFormat.svg create mode 100644 test/output/tipFormatTitlePrimitive.svg create mode 100644 test/plots/tip-format.ts diff --git a/src/marks/tip.js b/src/marks/tip.js index d074fb37f6..75f06e45d1 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -350,14 +350,10 @@ function getSourceChannels({channels}, scales) { 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; - } + // 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; } } 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..dfc16a90d3 --- /dev/null +++ b/test/output/tipFormatFacetFormatDefaultHour.svg @@ -0,0 +1,54 @@ + + + + + Jan 18 PM + + + 9 PM + + + + + + + + + + + + + 0 + + + 0 + + + + + + + x 0fx 2001-01-01T20:00 + + + + + + x 0fx 2001-01-01T21: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..d5311ccba3 --- /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}); +} + +export async function tipFormatFacetFormatDefaultHour() { + return tip({length: 2}, {fx: [new Date("2001-01-01T12:00"), new Date("2001-01-01T13:00")]}, {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}); +} From 3b445ee703fd9ab78104722347b6505809afa440 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 25 Aug 2023 17:05:27 -0700 Subject: [PATCH 8/9] pReTTier --- test/plots/tip-format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plots/tip-format.ts b/test/plots/tip-format.ts index d5311ccba3..86a2467fc6 100644 --- a/test/plots/tip-format.ts +++ b/test/plots/tip-format.ts @@ -21,7 +21,7 @@ export async function tipFormatFacetFalse() { } 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}); + 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() { From effbaeb346a17fff59a73846e908ba1d78bc0eb1 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 25 Aug 2023 17:10:00 -0700 Subject: [PATCH 9/9] oops, time zones! --- test/output/tipFormatFacetFormatDefaultHour.svg | 8 ++++---- test/plots/tip-format.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/output/tipFormatFacetFormatDefaultHour.svg b/test/output/tipFormatFacetFormatDefaultHour.svg index dfc16a90d3..a738110d2e 100644 --- a/test/output/tipFormatFacetFormatDefaultHour.svg +++ b/test/output/tipFormatFacetFormatDefaultHour.svg @@ -15,10 +15,10 @@ - Jan 18 PM + Jan 112 PM - 9 PM + 1 PM @@ -41,13 +41,13 @@ - x 0fx 2001-01-01T20:00 + x 0fx 2001-01-01T12:00 - x 0fx 2001-01-01T21:00 + x 0fx 2001-01-01T13:00 diff --git a/test/plots/tip-format.ts b/test/plots/tip-format.ts index 86a2467fc6..f928f4fcf8 100644 --- a/test/plots/tip-format.ts +++ b/test/plots/tip-format.ts @@ -25,7 +25,7 @@ export async function tipFormatFacetFormat() { } export async function tipFormatFacetFormatDefaultHour() { - return tip({length: 2}, {fx: [new Date("2001-01-01T12:00"), new Date("2001-01-01T13:00")]}, {height: 110}); + return tip({length: 2}, {fx: [new Date("2001-01-01T12:00Z"), new Date("2001-01-01T13:00Z")]}, {height: 110}); } export async function tipFormatFacetFormatDefaultDay() {