From 0735c12ebee2bf78cfc7589caed20e7b68828def Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Sun, 12 Jul 2026 17:35:31 -0700 Subject: [PATCH] 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}`} + }); +});