Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/marks/tip.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <VersionBadge version="0.6.11" pr="1823" />, 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. <VersionBadge version="0.6.15" pr="2074" />

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. <VersionBadge version="0.6.18" pr="1767" /> For example, to render the **title** channel in bold followed by a link:

```js
Plot.tip(data, {
title: "name",
format: {
title: (name) => htl.svg`<tspan><tspan font-weight="bold">${name}</tspan> <a fill="blue" href="/details/${name}">details</a></tspan>`
}
})
```

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
Expand Down
7 changes: 6 additions & 1 deletion src/marks/tip.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*.
Expand Down
26 changes: 25 additions & 1 deletion src/marks/tip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)});
}
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions test/output/tipFormatChannelMarkup.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions test/output/tipFormatTitleMarkup.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions test/plots/tip-format.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Plot from "@observablehq/plot";
import {svg} from "htl";
import {test} from "test/plot";

function tip(
Expand Down Expand Up @@ -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`<tspan><tspan font-weight="bold">${d}</tspan> <a fill="blue" href="https://observablehq.com/plot/">docs</a></tspan>`} // prettier-ignore
}
);
});

test(async function tipFormatChannelMarkup() {
return tip([{value: 1}], {
channels: {Name: ["Bob"]},
format: {Name: (d) => svg`<tspan fill="red">${d}</tspan>`}
});
});