From 0735c12ebee2bf78cfc7589caed20e7b68828def Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Sun, 12 Jul 2026 17:35:31 -0700 Subject: [PATCH 1/2] Allow tip format functions to return DOM nodes for rich text A custom format function for the title channel or any other channel may now return a DOM node, such as an SVG tspan or anchor element, instead of a string. The returned node is appended directly to the tip rather than being wrapped in a text node, allowing styled and hyperlinked rich text. Plain string formats are unchanged and still rendered as text, so this adds no HTML injection surface. Fixes #1767 --- docs/marks/tip.md | 13 ++++++++++++ src/marks/tip.d.ts | 7 ++++++- src/marks/tip.js | 26 +++++++++++++++++++++++- test/output/tipFormatChannelMarkup.svg | 28 ++++++++++++++++++++++++++ test/output/tipFormatTitleMarkup.svg | 28 ++++++++++++++++++++++++++ test/plots/tip-format.ts | 18 +++++++++++++++++ 6 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 test/output/tipFormatChannelMarkup.svg create mode 100644 test/output/tipFormatTitleMarkup.svg diff --git a/docs/marks/tip.md b/docs/marks/tip.md index 9ea3c7fe37..5d9d34ca30 100644 --- a/docs/marks/tip.md +++ b/docs/marks/tip.md @@ -135,6 +135,19 @@ Plot.rectY(olympians, Plot.binX({y: "sum"}, {x: "weight", y: (d) => d.sex === "m The order and formatting of channels in the tip can be customized with the **format** option , which accepts a key-value object mapping channel names to formats. Each [format](../features/formats.md) can be a string (for number or time formats), a function that receives the value as input and returns a string, true to use the default format, and null or false to suppress. The order of channels in the tip follows their order in the format object followed by any additional channels. When using the **title** channel, the **format** option may be specified as a string or a function; the given format will then apply to the **title** channel. +A format function may also return a DOM node instead of a string, such as an SVG tspan or anchor element (say generated with [Hypertext Literal](https://github.com/observablehq/htl)); the returned node is appended directly to the tip, allowing styled and hyperlinked rich text. For example, to render the **title** channel in bold followed by a link: + +```js +Plot.tip(data, { + title: "name", + format: { + title: (name) => htl.svg`${name} details` + } +}) +``` + +Only DOM nodes returned by a format function are treated as rich content; plain strings are always rendered as text, so this does not introduce any HTML injection. Because a returned node is appended as-is rather than measured, the **lineWidth** and **textOverflow** options do not apply to it. + A channel’s label can be specified alongside its value as a {value, label} object; if a channel label is not specified, the associated scale’s label is used, if any; if there is no associated scale, or if the scale has no label, the channel name is used instead. :::plot defer https://observablehq.com/@observablehq/plot-tip-format diff --git a/src/marks/tip.d.ts b/src/marks/tip.d.ts index 7faf45b759..bdc74c15ff 100644 --- a/src/marks/tip.d.ts +++ b/src/marks/tip.d.ts @@ -93,10 +93,15 @@ export interface TipOptions extends MarkOptions, TextStyles { * - a [d3-time-format][2] string for temporal scales * - a function passed a channel *value* and *index*, returning a string * + * A format function may also return a DOM node (such as an SVG tspan or anchor + * element, say generated with Hypertext Literal); the returned node is appended + * directly to the tip, allowing styled and hyperlinked rich text. Nodes are not + * measured, so the **lineWidth** and **textOverflow** options do not apply. + * * [1]: https://d3js.org/d3-time * [2]: https://d3js.org/d3-time-format */ -export type TipFormat = string | ((d: any, i: number) => string); +export type TipFormat = string | ((d: any, i: number) => string | Node); /** * Returns a new tip mark for the given *data* and *options*. diff --git a/src/marks/tip.js b/src/marks/tip.js index a23bedb994..8857a49376 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -157,7 +157,11 @@ export class Tip extends Mark { this.setAttribute("stroke", "none"); // iteratively render each channel value const lines = T[i]; - if (typeof lines === "string") { + if (isNode(lines)) { + // A format function may return a DOM node (e.g., an SVG tspan + // or anchor) to display styled or hyperlinked rich text. + renderLine(that, {value: lines}); + } else if (typeof lines === "string") { for (const line of mark.splitLines(lines)) { renderLine(that, {value: mark.clipLine(line)}); } @@ -182,6 +186,20 @@ export class Tip extends Mark { function renderLine(selection, {label, value, color, opacity}) { (label ??= ""), (value ??= ""); const swatch = color != null || opacity != null; + + // If the value is a DOM node — such as an SVG tspan or anchor returned by + // a custom format function — append it directly rather than wrapping it + // in a text node. This allows styled and hyperlinked rich text in the + // tip. Such values are appended as-is and hence are not measured, so the + // lineWidth and textOverflow options do not apply. + if (isNode(value)) { + const line = selection.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`).text("\u200b"); // zwsp for double-click + if (label) line.append("tspan").attr("font-weight", "bold").text(label); + line.append(() => value); + if (swatch) line.append("tspan").text(" ■").attr("fill", color).attr("fill-opacity", opacity).style("user-select", "none"); // prettier-ignore + return; + } + let title; let w = lineWidth * 100; const [j] = cut(label, w, widthof, ee); @@ -268,6 +286,12 @@ export function tip(data, {x, y, ...options} = {}) { return new Tip(data, {...options, x, y}); } +// Returns true if the given value is a DOM node, such as an SVG element +// returned by a custom format function for rich-text tip content. +function isNode(value) { + return value != null && typeof value === "object" && typeof value.nodeType === "number"; +} + function getLineOffset(anchor, length, lineHeight) { return /^top(?:-|$)/.test(anchor) ? 0.94 - lineHeight diff --git a/test/output/tipFormatChannelMarkup.svg b/test/output/tipFormatChannelMarkup.svg new file mode 100644 index 0000000000..538df7403d --- /dev/null +++ b/test/output/tipFormatChannelMarkup.svg @@ -0,0 +1,28 @@ + + + + + 0 + + + + + NameBobx 0 + + + \ No newline at end of file diff --git a/test/output/tipFormatTitleMarkup.svg b/test/output/tipFormatTitleMarkup.svg new file mode 100644 index 0000000000..2b6222762d --- /dev/null +++ b/test/output/tipFormatTitleMarkup.svg @@ -0,0 +1,28 @@ + + + + + 0 + + + + + Plot docs + + + \ No newline at end of file diff --git a/test/plots/tip-format.ts b/test/plots/tip-format.ts index 2f2e4aec40..cf2f628ed6 100644 --- a/test/plots/tip-format.ts +++ b/test/plots/tip-format.ts @@ -1,4 +1,5 @@ import * as Plot from "@observablehq/plot"; +import {svg} from "htl"; import {test} from "test/plot"; function tip( @@ -120,3 +121,20 @@ test(async function tipFormatTitleFormatShorthand() { test(async function tipFormatTitlePrimitive() { return tip(["hello\nworld"], {x: 0}); }); + +test(async function tipFormatTitleMarkup() { + return tip( + {length: 1}, + { + title: ["Plot"], + format: {title: (d) => svg`${d} docs`} // prettier-ignore + } + ); +}); + +test(async function tipFormatChannelMarkup() { + return tip([{value: 1}], { + channels: {Name: ["Bob"]}, + format: {Name: (d) => svg`${d}`} + }); +}); From 49525db3b9e769301f57d66d34fb342cf187a594 Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Tue, 28 Jul 2026 19:35:01 -0700 Subject: [PATCH 2/2] Address review: space after channel label, simpler node test Apply the suggested wording in docs/marks/tip.md and use the ownerDocument test for DOM nodes, matching createTitleElement in plot.js. The node path now adds a space between the channel label and the node, as the string path already did; the channel markup snapshot is updated accordingly. Node rendering moves into its own renderNodeLine so a node value is only tested once rather than again inside renderLine. --- docs/marks/tip.md | 4 +-- src/marks/tip.js | 40 ++++++++++++-------------- test/output/tipFormatChannelMarkup.svg | 2 +- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/docs/marks/tip.md b/docs/marks/tip.md index 5d9d34ca30..9893207758 100644 --- a/docs/marks/tip.md +++ b/docs/marks/tip.md @@ -135,7 +135,7 @@ Plot.rectY(olympians, Plot.binX({y: "sum"}, {x: "weight", y: (d) => d.sex === "m The order and formatting of channels in the tip can be customized with the **format** option , which accepts a key-value object mapping channel names to formats. Each [format](../features/formats.md) can be a string (for number or time formats), a function that receives the value as input and returns a string, true to use the default format, and null or false to suppress. The order of channels in the tip follows their order in the format object followed by any additional channels. When using the **title** channel, the **format** option may be specified as a string or a function; the given format will then apply to the **title** channel. -A format function may also return a DOM node instead of a string, such as an SVG tspan or anchor element (say generated with [Hypertext Literal](https://github.com/observablehq/htl)); the returned node is appended directly to the tip, allowing styled and hyperlinked rich text. For example, to render the **title** channel in bold followed by a link: +A format function may also return a DOM node, such as an SVG tspan or anchor element (say generated with [Hypertext Literal](https://github.com/observablehq/htl)); the returned node is appended directly to the tip, allowing styled and hyperlinked rich text. For example, to render the **title** channel in bold followed by a link: ```js Plot.tip(data, { @@ -146,7 +146,7 @@ Plot.tip(data, { }) ``` -Only DOM nodes returned by a format function are treated as rich content; plain strings are always rendered as text, so this does not introduce any HTML injection. Because a returned node is appended as-is rather than measured, the **lineWidth** and **textOverflow** options do not apply to it. +When returning nodes, the **lineWidth** and **textOverflow** options are ignored. A channel’s label can be specified alongside its value as a {value, label} object; if a channel label is not specified, the associated scale’s label is used, if any; if there is no associated scale, or if the scale has no label, the channel name is used instead. diff --git a/src/marks/tip.js b/src/marks/tip.js index 8857a49376..7c806fc01c 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -157,10 +157,10 @@ export class Tip extends Mark { this.setAttribute("stroke", "none"); // iteratively render each channel value const lines = T[i]; - if (isNode(lines)) { + if (lines?.ownerDocument) { // A format function may return a DOM node (e.g., an SVG tspan // or anchor) to display styled or hyperlinked rich text. - renderLine(that, {value: lines}); + renderNodeLine(that, {value: lines}); } else if (typeof lines === "string") { for (const line of mark.splitLines(lines)) { renderLine(that, {value: mark.clipLine(line)}); @@ -184,22 +184,10 @@ export class Tip extends Mark { // exact text metrics and translate the text as needed once we know the // tip’s orientation (anchor). function renderLine(selection, {label, value, color, opacity}) { + if (value?.ownerDocument) return renderNodeLine(selection, {label, value, color, opacity}); (label ??= ""), (value ??= ""); const swatch = color != null || opacity != null; - // If the value is a DOM node — such as an SVG tspan or anchor returned by - // a custom format function — append it directly rather than wrapping it - // in a text node. This allows styled and hyperlinked rich text in the - // tip. Such values are appended as-is and hence are not measured, so the - // lineWidth and textOverflow options do not apply. - if (isNode(value)) { - const line = selection.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`).text("\u200b"); // zwsp for double-click - if (label) line.append("tspan").attr("font-weight", "bold").text(label); - line.append(() => value); - if (swatch) line.append("tspan").text(" ■").attr("fill", color).attr("fill-opacity", opacity).style("user-select", "none"); // prettier-ignore - return; - } - let title; let w = lineWidth * 100; const [j] = cut(label, w, widthof, ee); @@ -224,6 +212,22 @@ export class Tip extends Mark { if (title) line.append("title").text(title); } + // Renders a single line whose value is a DOM node, such as an SVG tspan or + // anchor returned by a custom format function. The node is appended + // directly rather than wrapped in a text node, allowing styled and + // hyperlinked rich text. Such values are appended as-is and hence are not + // measured, so the lineWidth and textOverflow options are ignored. + function renderNodeLine(selection, {label, value, color, opacity}) { + const swatch = color != null || opacity != null; + const line = selection.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`).text("\u200b"); // zwsp for double-click + if (label) { + line.append("tspan").attr("font-weight", "bold").text(label); + line.append(() => document.createTextNode(" ")); + } + line.append(() => value); + if (swatch) line.append("tspan").text(" ■").attr("fill", color).attr("fill-opacity", opacity).style("user-select", "none"); // prettier-ignore + } + // Only after the plot is attached to the page can we compute the exact text // metrics needed to determine the tip size and orientation (anchor). function postrender() { @@ -286,12 +290,6 @@ export function tip(data, {x, y, ...options} = {}) { return new Tip(data, {...options, x, y}); } -// Returns true if the given value is a DOM node, such as an SVG element -// returned by a custom format function for rich-text tip content. -function isNode(value) { - return value != null && typeof value === "object" && typeof value.nodeType === "number"; -} - function getLineOffset(anchor, length, lineHeight) { return /^top(?:-|$)/.test(anchor) ? 0.94 - lineHeight diff --git a/test/output/tipFormatChannelMarkup.svg b/test/output/tipFormatChannelMarkup.svg index 538df7403d..9784fd50b4 100644 --- a/test/output/tipFormatChannelMarkup.svg +++ b/test/output/tipFormatChannelMarkup.svg @@ -22,7 +22,7 @@ - NameBobx 0 + Name Bobx 0 \ No newline at end of file