diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 56d88d7854..123ecb9d66 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -31,6 +31,23 @@ z-index: 1; } +.vp-doc .plot-figure { + margin: 16px 0; +} + +.vp-doc .plot-figure h2, +.vp-doc .plot-figure h3 { + all: unset; + display: block; +} + +.vp-doc .plot-figure h2 { + line-height: 28px; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; +} + .vp-doc .plot a:hover { text-decoration: initial; } diff --git a/docs/components/PlotRender.js b/docs/components/PlotRender.js index 07b6acf171..be06c57e9c 100644 --- a/docs/components/PlotRender.js +++ b/docs/components/PlotRender.js @@ -212,7 +212,10 @@ export default { } if (typeof document !== "undefined") { const plot = Plot[method](options); - const replace = (el) => el.firstChild.replaceWith(plot); + const replace = (el) => { + while (el.lastChild) el.lastChild.remove(); + el.append(plot); + }; return withDirectives(h("span", [toHyperScript(plot)]), [[{mounted: replace, updated: replace}]]); } return h("span", [Plot[method]({...options, document: new Document()}).toHyperScript()]); diff --git a/docs/features/plots.md b/docs/features/plots.md index 5cf465401d..88b79ff5ac 100644 --- a/docs/features/plots.md +++ b/docs/features/plots.md @@ -248,15 +248,19 @@ When using facets, set the *fx* and *fy* scales’ **round** option to false if ## Other options -If a **caption** is specified, Plot.plot wraps the generated SVG element in an HTML figure element with a figcaption, returning the figure. To specify an HTML caption, the caption can be specified as an HTML element, say using the [`html` tagged template literal](http://github.com/observablehq/htl); otherwise, the specified string represents text that will be escaped as needed. +By default, [plot](#plot) returns an SVG element; however, if the plot includes a title, subtitle, [legend](./legends.md), or caption, plot wraps the SVG element with an HTML figure element. You can also force Plot to generate a figure element by setting the **figure** option to true. + +The **title** & **subtitle** options and the **caption** option accept either a string or an HTML element. If given an HTML element, say using the [`html` tagged template literal](http://github.com/observablehq/htl), the title and subtitle are used as-is while the caption is wrapped in a figcaption element; otherwise, the specified text will be escaped and wrapped in an h2, h3, or figcaption, respectively. :::plot https://observablehq.com/@observablehq/plot-caption ```js Plot.plot({ - caption: "Figure 1. A chart with a caption.", + title: "For charts, an informative title", + subtitle: "Subtitle to follow with additional context", + caption: "Figure 1. A chart with a title, subtitle, and caption.", marks: [ Plot.frame(), - Plot.text(["Hello, world!"], {frameAnchor: "middle"}) + Plot.text(["Titles, subtitles, captions, and annotations assist inter­pretation by telling the reader what’s interesting. Don’t make the reader work to find what you already know."], {lineWidth: 30, frameAnchor: "middle"}) ] }) ``` diff --git a/src/plot.d.ts b/src/plot.d.ts index 48b80a683c..b6019631b1 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -107,6 +107,42 @@ export interface PlotOptions extends ScaleDefaults { */ className?: string; + /** + * The figure title. If present, Plot wraps the generated SVG element in an + * HTML figure element with the title in a h2 element, returning the figure. + * To specify an HTML title, consider using the [`html` tagged template + * literal][1]; otherwise, the specified string represents text that will be + * escaped as needed. + * + * ```js + * Plot.plot({ + * title: html`

This is a fancy title`, + * marks: … + * }) + * ``` + * + * [1]: https://github.com/observablehq/htl + */ + title?: string | Node | null; + + /** + * The figure subtitle. If present, Plot wraps the generated SVG element in an + * HTML figure element with the subtitle in a h3 element, returning the + * figure. To specify an HTML subtitle, consider using the [`html` tagged + * template literal][1]; otherwise, the specified string represents text that + * will be escaped as needed. + * + * ```js + * Plot.plot({ + * subtitle: html`This is a fancy subtitle`, + * marks: … + * }) + * ``` + * + * [1]: https://github.com/observablehq/htl + */ + subtitle?: string | Node | null; + /** * The figure caption. If present, Plot wraps the generated SVG element in an * HTML figure element with a figcaption, returning the figure. To specify an @@ -125,6 +161,14 @@ export interface PlotOptions extends ScaleDefaults { */ caption?: string | Node | null; + /** + * Whether to wrap the generated SVG element with an HTML figure element. By + * default, this is determined by the presence of non-chart elements such as + * legends, title, subtitle, and caption; if false, these non-chart element + * options are ignored. + */ + figure?: boolean; + /** * The [aria-label attribute][1] on the SVG root. * diff --git a/src/plot.js b/src/plot.js index 2cb8d92202..a659b2a924 100644 --- a/src/plot.js +++ b/src/plot.js @@ -20,7 +20,7 @@ import {initializer} from "./transforms/basic.js"; import {consumeWarnings, warn} from "./warnings.js"; export function plot(options = {}) { - const {facet, style, caption, ariaLabel, ariaDescription} = options; + const {facet, style, title, subtitle, caption, ariaLabel, ariaDescription} = options; // className for inline styles const className = maybeClassName(options.className); @@ -320,18 +320,17 @@ export function plot(options = {}) { } } - // Wrap the plot in a figure with a caption, if desired. + // Wrap the plot in a figure, if needed. const legends = createLegends(scaleDescriptors, context, options); - if (caption != null || legends.length > 0) { + const {figure: figured = title != null || subtitle != null || caption != null || legends.length > 0} = options; + if (figured) { figure = document.createElement("figure"); - figure.style.maxWidth = "initial"; - for (const legend of legends) figure.appendChild(legend); - figure.appendChild(svg); - if (caption != null) { - const figcaption = document.createElement("figcaption"); - figcaption.appendChild(caption?.ownerDocument ? caption : document.createTextNode(caption)); - figure.appendChild(figcaption); - } + figure.className = `${className}-figure`; + figure.style.maxWidth = "initial"; // avoid Observable default style + if (title != null) figure.append(createTitleElement(document, title, "h2")); + if (subtitle != null) figure.append(createTitleElement(document, subtitle, "h3")); + figure.append(...legends, svg); + if (caption != null) figure.append(createFigcaption(document, caption)); } figure.scale = exposeScales(scaleDescriptors); @@ -354,6 +353,19 @@ export function plot(options = {}) { return figure; } +function createTitleElement(document, contents, tag) { + if (contents.ownerDocument) return contents; + const e = document.createElement(tag); + e.append(document.createTextNode(contents)); + return e; +} + +function createFigcaption(document, caption) { + const e = document.createElement("figcaption"); + e.append(caption.ownerDocument ? caption : document.createTextNode(caption)); + return e; +} + function plotThis({marks = [], ...options} = {}) { return plot({...options, marks: [...marks, this]}); } diff --git a/test/output/athletesSortNationality.html b/test/output/athletesSortNationality.html index d371b42a1e..15732dce20 100644 --- a/test/output/athletesSortNationality.html +++ b/test/output/athletesSortNationality.html @@ -1,4 +1,4 @@ -
+
+ + Adelie + + Chinstrap + + Gentoo +
+ + + + + + + + + + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/titleHtml.html b/test/output/titleHtml.html new file mode 100644 index 0000000000..81d78a1c95 --- /dev/null +++ b/test/output/titleHtml.html @@ -0,0 +1,384 @@ +
+

A fancy title about penguins

+ A fancy subtitle + + + + + + + + + + + + 3,000 + 3,500 + 4,000 + 4,500 + 5,000 + 5,500 + 6,000 + + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/trafficHorizon.html b/test/output/trafficHorizon.html index a05e049014..14013e3bb2 100644 --- a/test/output/trafficHorizon.html +++ b/test/output/trafficHorizon.html @@ -1,4 +1,4 @@ -
+