diff --git a/CHANGELOG.md b/CHANGELOG.md index cf8b8525cd..777743c7c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Observable Plot - Changelog +## 0.4.2 + +*Not yet released. These are forthcoming changes in the main branch.* + +Plot now supports [interaction marks](./README.md#interactions)! An interaction mark defines an interactive selection represented as a subset of the mark’s data. For example, the [brush mark](./README.md#brush) allows rectangular selection by clicking and dragging; you can use a brush to select points of interest from a scatterplot and show them in a table. The interactive selection is exposed as *plot*.value. When the selection changes during interaction, the plot emits *input* events. This allows plots to be [Observable views](https://observablehq.com/@observablehq/introduction-to-views), but you can also [listen to *input* events](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) directly. + ## 0.4.1 Released February 17, 2022. diff --git a/README.md b/README.md index 345ceb7ae3..81a85590bf 100644 --- a/README.md +++ b/README.md @@ -1227,6 +1227,38 @@ Plot.vector(wind, {x: "longitude", y: "latitude", length: "speed", rotate: "dire Returns a new vector with the given *data* and *options*. If neither the **x** nor **y** options are specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] such that **x** = [*x₀*, *x₁*, *x₂*, …] and **y** = [*y₀*, *y₁*, *y₂*, …]. +## Interactions + +Interactions are special marks that handle user input and define interactive selections. When a plot has an interaction mark, the returned *plot*.value represents the current selection as an array subset of the interaction mark’s data. As the user modifies the selection through interaction with the plot, *input* events are emitted. This design is compatible with [Observable’s viewof operator](https://observablehq.com/@observablehq/introduction-to-views), but you can also listen to *input* events directly via the [EventTarget interface](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget). + +### Brush + +[Source](./src/marks/brush.js) · [Examples](https://observablehq.com/@observablehq/plot-brush) · Selects points within a single contiguous rectangular region, such as nearby dots in a scatterplot. + +#### Plot.brush(*data*, *options*) + +```js +Plot.brush(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"}) +``` + +Returns a new brush with the given *data* and *options*. If neither the **x** nor **y** options are specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] such that **x** = [*x₀*, *x₁*, *x₂*, …] and **y** = [*y₀*, *y₁*, *y₂*, …]. + +#### Plot.brushX(*data*, *options*) + +```js +Plot.brushX(penguins, {x: "culmen_depth_mm"}) +``` + +Equivalent to [Plot.brush](#plotbrushdata-options) except that if the **x** option is not specified, it defaults to the identity function and assumes that *data* = [*x₀*, *x₁*, *x₂*, …]. + +#### Plot.brushY(*data*, *options*) + +```js +Plot.brushY(penguins, {y: "culmen_length_mm"}) +``` + +Equivalent to [Plot.brush](#plotbrushdata-options) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …]. + ## Decorations Decorations are static marks that do not represent data. Currently this includes only [Plot.frame](#frame), although internally Plot’s axes are implemented as decorations and may in the future be exposed here for more flexible configuration. diff --git a/src/index.js b/src/index.js index 8ce1218383..5cda607e4e 100644 --- a/src/index.js +++ b/src/index.js @@ -3,17 +3,20 @@ export {Area, area, areaX, areaY} from "./marks/area.js"; export {Arrow, arrow} from "./marks/arrow.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; export {boxX, boxY} from "./marks/box.js"; +export {Brush, brush, brushX, brushY} from "./marks/brush.js"; export {Cell, cell, cellX, cellY} from "./marks/cell.js"; export {Dot, dot, dotX, dotY} from "./marks/dot.js"; export {Frame, frame} from "./marks/frame.js"; export {Image, image} from "./marks/image.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; export {Link, link} from "./marks/link.js"; +export {Pointer, pointer, pointerX, pointerY} from "./marks/pointer.js"; export {Rect, rect, rectX, rectY} from "./marks/rect.js"; export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; export {Text, text, textX, textY} from "./marks/text.js"; export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; export {Vector, vector} from "./marks/vector.js"; +export {selection} from "./selection.js"; export {valueof} from "./options.js"; export {filter, reverse, sort, shuffle} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; diff --git a/src/marks/brush.js b/src/marks/brush.js new file mode 100644 index 0000000000..68a6afe7ef --- /dev/null +++ b/src/marks/brush.js @@ -0,0 +1,87 @@ +import {brush as brusher, brushX as brusherX, brushY as brusherY, create, select} from "d3"; +import {identity, maybeTuple} from "../options.js"; +import {Mark} from "../plot.js"; +import {selection, selectionEquals} from "../selection.js"; +import {applyDirectStyles, applyIndirectStyles} from "../style.js"; + +const defaults = { + ariaLabel: "brush", + fill: "#777", + fillOpacity: 0.3, + stroke: "#fff" +}; + +export class Brush extends Mark { + constructor(data, {x, y, ...options} = {}) { + super( + data, + [ + {name: "x", value: x, scale: "x", optional: true}, + {name: "y", value: y, scale: "y", optional: true} + ], + options, + defaults + ); + this.activeElement = null; + } + render(index, {x, y}, {x: X, y: Y}, dimensions) { + const {ariaLabel, ariaDescription, ariaHidden, ...options} = this; + const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions; + const brush = this; + const g = create("svg:g") + .call(applyIndirectStyles, {ariaLabel, ariaDescription, ariaHidden}, dimensions) + .call((X && Y ? brusher : X ? brusherX : brusherY)() + .extent([[marginLeft, marginTop], [width - marginRight, height - marginBottom]]) + .on("start brush end", function(event) { + const {type, selection: extent} = event; + // For faceting, when starting a brush in a new facet, clear the + // brush and selection on the old facet. In the future, we might + // allow independent brushes across facets by disabling this? + if (type === "start" && brush.activeElement !== this) { + if (brush.activeElement !== null) { + select(brush.activeElement).call(event.target.clear, event); + brush.activeElement[selection] = null; + } + brush.activeElement = this; + } + let S = null; + if (extent) { + S = index; + if (X) { + let [x0, x1] = Y ? [extent[0][0], extent[1][0]] : extent; + if (x.bandwidth) x0 -= x.bandwidth(); + S = S.filter(i => x0 <= X[i] && X[i] <= x1); + } + if (Y) { + let [y0, y1] = X ? [extent[0][1], extent[1][1]] : extent; + if (y.bandwidth) y0 -= y.bandwidth(); + S = S.filter(i => y0 <= Y[i] && Y[i] <= y1); + } + } + if (!selectionEquals(this[selection], S)) { + this[selection] = S; + this.dispatchEvent(new Event("input", {bubbles: true})); + } + })) + .call(g => g.selectAll(".selection") + .attr("shape-rendering", null) // reset d3-brush + .call(applyIndirectStyles, options, dimensions) + .call(applyDirectStyles, options)) + .node(); + g[selection] = null; + return g; + } +} + +export function brush(data, {x, y, ...options} = {}) { + ([x, y] = maybeTuple(x, y)); + return new Brush(data, {...options, x, y}); +} + +export function brushX(data, {x = identity, ...options} = {}) { + return new Brush(data, {...options, x}); +} + +export function brushY(data, {y = identity, ...options} = {}) { + return new Brush(data, {...options, y}); +} diff --git a/src/marks/pointer.js b/src/marks/pointer.js new file mode 100644 index 0000000000..aa0d345de1 --- /dev/null +++ b/src/marks/pointer.js @@ -0,0 +1,248 @@ +import {create, namespaces, pointer as pointerof, quickselect, union} from "d3"; +import {identity, maybeTuple} from "../options.js"; +import {Mark} from "../plot.js"; +import {selection} from "../selection.js"; +import {applyDirectStyles, applyIndirectStyles} from "../style.js"; + +const defaults = { + ariaLabel: "pointer", + fill: "none", + stroke: "#3b5fc0", + strokeWidth: 1.5 +}; + +export class Pointer extends Mark { + constructor(data, { + x, + y, + n = 1, + r = isFinite(n) ? 120 : 20, + mode = "auto", + ...options + } = {}) { + super( + data, + [ + {name: "x", value: x, scale: "x", optional: true}, + {name: "y", value: y, scale: "y", optional: true} + ], + options, + defaults + ); + this.n = +n; + this.r = +r; + this.mode = maybeMode(mode, x, y); + } + render(index, {x, y}, {x: X, y: Y}, dimensions) { + const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions; + const {mode, n, r} = this; + const r2 = r * r; // the squared radius; to determine points in proximity to the pointer + const down = new Set(); // the set of pointers that are currently down + let C = []; // a sparse index from index[i] to an svg:circle element + let P = null; // the persistent selection; a subset of index, or null + + const g = create("svg:g"); + + const parent = g.append("g") + .call(applyIndirectStyles, this, dimensions) + .call(applyDirectStyles, this) + .node(); + + // Note that point scales also expose a bandwidth function, but that always + // returns zero. SVG will not render a stroked rect with zero width or + // height, so we’ll render these as lines instead. + const bx = x?.bandwidth?.(); + const by = y?.bandwidth?.(); + + // The visual representation of the logical selection depends on which + // channels are available (x, y, or both) and whether the corresponding + // scales are band scales. + const createElement = X && Y + ? (bx && by ? i => element("rect", {x: X[i], y: Y[i], width: bx, height: by}) + : bx ? i => element("line", {x1: X[i], x2: X[i] + bx, y1: Y[i], y2: Y[i]}) + : by ? i => element("line", {x1: X[i], x2: X[i], y1: Y[i], y2: Y[i] + by}) + : i => element("circle", {cx: X[i], cy: Y[i], r: 4})) + : X ? (bx ? i => element("rect", {x: X[i], y: marginTop, width: bx, height: height - marginBottom - marginTop}) + : i => element("line", {x1: X[i], x2: X[i], y1: marginTop, y2: height - marginBottom})) + : (by ? i => element("rect", {x: marginLeft, y: Y[i], width: width - marginRight - marginLeft, height: by}) + : i => element("line", {y1: Y[i], y2: Y[i], x1: marginLeft, x2: width - marginRight})); + + // Renders the given logical selection S, a subset of index. Applies + // copy-on-write to the array of elements C. Returns true if the selection + // changed, and false otherwise. + function render(S) { + const SC = []; + let changed = false; + + // Enter (append) the newly-selected elements. The order of elements is + // arbitrary, with the most recently selected datum on top. + S.forEach(i => { + let c = C[i]; + if (!c) { + c = createElement(i); + parent.appendChild(c); + changed = true; + } + SC[i] = c; + }); + + // Exit (remove) the no-longer-selected elements. + C.forEach((c, i) => { + if (!SC[i]) { + c.remove(); + changed = true; + } + }); + + if (changed) C = SC; + return changed; + } + + // Selects the given logical selection S, a subset of index, or null if + // there is no selection. + function select(S) { + if (S === null) render([]); + else if (!render(S)) return; + node[selection] = S; + node.dispatchEvent(new Event("input", {bubbles: true})); + } + + g.append("rect") + .attr("fill", "none") + .attr("pointer-events", "all") + .attr("width", width + marginLeft + marginRight) + .attr("height", height + marginTop + marginBottom) + .on("pointerdown pointerover pointermove", event => { + + // On pointerdown, initiate a new persistent selection, P, or extend + // the existing persistent selection if the shift key is down; then + // add to P for as long as the pointer remains down. If there is no + // existing persistent selection on pointerdown, initialize P to the + // empty selection rather than the points near the pointer such that + // you can clear the persistent selection with a pointerdown followed + // by a pointerup. (See below.) + if (event.type === "pointerdown") { + const nop = !P; + down.add(event.pointerId); + if (nop || !event.shiftKey) P = []; + if (!nop && !event.shiftKey) return select(P); + } + + // If any pointer is down, only consider pointers that are down. + if (P && !down.has(event.pointerId)) return; + + // Adjust the pointer to account for band scales; for band scales, the + // data is mapped to the start of the band (e.g., a bar’s left edge). + let [mx, my] = pointerof(event); + if (x.bandwidth) mx -= x.bandwidth() / 2; + if (y.bandwidth) my -= y.bandwidth() / 2; + + // Compute the current selection, S: the subset of index that is + // logically selected. Normally this should be an in-order subset of + // index, but it isn’t here because quickselect will reorder in-place + // if the n option is used! + let S = index; + switch (mode) { + case "xy": { + if (r < Infinity) { + S = S.filter(i => { + const dx = X[i] - mx, dy = Y[i] - my; + return dx * dx + dy * dy <= r2; + }); + } + if (S.length > n) { + S = S.slice(); + quickselect(S, n, undefined, undefined, (i, j) => { + const ix = X[i] - mx, iy = Y[i] - my; + const jx = X[j] - mx, jy = Y[j] - my; + return (ix * ix + iy * iy) - (jx * jx + jy * jy); + }); + S = S.slice(0, n); + } + break; + } + case "x": { + if (r < Infinity) { + const [x0, x1] = [mx - r, mx + r]; + S = S.filter(i => x0 <= X[i] && X[i] <= x1); + } + if (S.length > n) { + S = S.slice(); + quickselect(S, n, undefined, undefined, (i, j) => { + const ix = X[i] - mx; + const jx = X[j] - mx; + return ix * ix - jx * jx; + }); + S = S.slice(0, n); + } + break; + } + case "y": { + if (r < Infinity) { + const [y0, y1] = [my - r, my + r]; + S = S.filter(i => y0 <= Y[i] && Y[i] <= y1); + } + if (S.length > n) { + S = S.slice(); + quickselect(S, n, undefined, undefined, (i, j) => { + const iy = Y[i] - my; + const jy = Y[j] - my; + return iy * iy - jy * jy; + }); + S = S.slice(0, n); + } + break; + } + } + + // If there is a persistent selection, add the new selection to the + // persistent selection; otherwise just use the current selection. + select(P ? (P = Array.from(union(P, S))) : S); + }) + .on("pointerup", event => { + // On pointerup, if the selection is empty, clear the persistent to + // selection to allow the ephemeral selection on subsequent hover. + if (P && !P.length) select(P = null); + down.delete(event.pointerId); + }) + .on("pointerout", () => { + // On pointerout, if there is no persistent selection, clear the + // ephemeral selection. + if (!P) select(null); + }); + + const node = g.node(); + node[selection] = null; + return node; + } +} + +function maybeMode(mode = "auto", x, y) { + switch (mode = `${mode}`.toLowerCase()) { + case "auto": mode = y == null ? "x" : x == null ? "y" : "xy"; break; + case "x": case "y": case "xy": break; + default: throw new Error(`invalid mode: ${mode}`); + } + if (/^x/.test(mode) && x == null) throw new Error("missing channel: x"); + if (/y$/.test(mode) && y == null) throw new Error("missing channel: y"); + return mode; +} + +function element(name, attrs) { + const e = document.createElementNS(namespaces.svg, name); + for (const key in attrs) e.setAttribute(key, attrs[key]); + return e; +} + +export function pointer(data, {x, y, ...options} = {}) { + ([x, y] = maybeTuple(x, y)); + return new Pointer(data, {...options, x, y}); +} + +export function pointerX(data, {mode = "x", x = identity, ...options} = {}) { + return new Pointer(data, {...options, mode, x}); +} + +export function pointerY(data, {mode = "y", y = identity, ...options} = {}) { + return new Pointer(data, {...options, mode, y}); +} diff --git a/src/plot.js b/src/plot.js index d619b6bd9d..879aa23725 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,11 +1,12 @@ -import {create, cross, difference, groups, InternMap, select} from "d3"; +import {create, cross, difference, groups, InternMap, select, union} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {Channel, channelSort} from "./channel.js"; import {defined} from "./defined.js"; import {Dimensions} from "./dimensions.js"; import {Legends, exposeLegends} from "./legends.js"; -import {arrayify, isOptions, keyword, range, first, second, where} from "./options.js"; +import {arrayify, isOptions, keyword, range, first, second, where, take} from "./options.js"; import {Scales, ScaleFunctions, autoScaleRange, applyScales, exposeScales} from "./scales.js"; +import {selection} from "./selection.js"; import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js"; import {basic} from "./transforms/basic.js"; import {consumeWarnings} from "./warnings.js"; @@ -96,14 +97,23 @@ export function plot(options = {}) { .call(applyInlineStyles, style) .node(); + let initialValue; for (const mark of marks) { const channels = markChannels.get(mark) ?? []; const values = applyScales(channels, scales); let index = markIndex.get(mark); if (mark.filter != null) index = mark.filter(index, channels, values); const node = mark.render(index, scales, values, dimensions, axes); - if (node != null) svg.appendChild(node); - } + if (node != null) { + if (node[selection] !== undefined) { + initialValue = markValue(mark, node[selection]); + node.addEventListener("input", () => { + figure.value = markValue(mark, node[selection]); + }); + } + svg.appendChild(node); + } + } // Wrap the plot in a figure with a caption, if desired. let figure = svg; @@ -121,6 +131,7 @@ export function plot(options = {}) { figure.scale = exposeScales(scaleDescriptors); figure.legend = exposeLegends(scaleDescriptors, options); + figure.value = initialValue; const w = consumeWarnings(); if (w > 0) { @@ -206,6 +217,10 @@ function markify(mark) { return mark instanceof Mark ? mark : new Render(mark); } +function markValue(mark, selection) { + return selection === null ? mark.data : take(mark.data, selection); +} + class Render extends Mark { constructor(render) { super(); @@ -281,7 +296,7 @@ class Facet extends Mark { return {index, channels: [...channels, ...subchannels]}; } render(I, scales, _, dimensions, axes) { - const {marks, marksChannels, marksIndexByFacet} = this; + const {data, channels, marks, marksChannels, marksIndexByFacet} = this; const {fx, fy} = scales; const fyDomain = fy && fy.domain(); const fxDomain = fx && fx.domain(); @@ -289,7 +304,8 @@ class Facet extends Mark { const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()}; const subdimensions = {...dimensions, ...fxMargins, ...fyMargins}; const marksValues = marksChannels.map(channels => applyScales(channels, scales)); - return create("svg:g") + let selectionByFacet; + const parent = create("svg:g") .call(g => { if (fy && axes.y) { const axis1 = axes.y, axis2 = nolabel(axis1); @@ -335,10 +351,25 @@ class Facet extends Mark { let index = marksFacetIndex[i]; if (mark.filter != null) index = mark.filter(index, marksChannels[i], values); const node = mark.render(index, scales, values, subdimensions); - if (node != null) this.appendChild(node); + if (node != null) { + if (node[selection] !== undefined) { + if (marks[i].data !== data) throw new Error("selection must use facet data"); + if (selectionByFacet === undefined) selectionByFacet = facetMap(channels); + selectionByFacet.set(key, node[selection]); + node.addEventListener("input", () => { + selectionByFacet.set(key, node[selection]); + parent[selection] = facetSelection(selectionByFacet); + }); + } + this.appendChild(node); + } } })) .node(); + if (selectionByFacet !== undefined) { + parent[selection] = facetSelection(selectionByFacet); + } + return parent; } } @@ -381,6 +412,20 @@ function facetTranslate(fx, fy) { : ky => `translate(0,${fy(ky)})`; } +// If multiple facets define a selection, then the overall selection is the +// union of the defined selections. As with non-faceted plots, we assume that +// only a single mark is defining the selection; if multiple marks define a +// selection, generally speaking the last one wins, although the behavior is not +// explicitly defined. +function facetSelection(selectionByFacet) { + let selection = null; + for (const value of selectionByFacet.values()) { + if (value === null) continue; + selection = selection === null ? value : union(selection, value); + } + return selection; +} + function facetMap(channels) { return new (channels.length > 1 ? FacetMap2 : FacetMap); } @@ -398,6 +443,9 @@ class FacetMap { set(key, value) { return this._.set(key, value), this; } + values() { + return this._.values(); + } } // A Map-like interface that supports paired keys. @@ -416,4 +464,9 @@ class FacetMap2 extends FacetMap { else super.set(key1, new InternMap([[key2, value]])); return this; } + *values() { + for (const map of this._.values()) { + yield* map.values(); + } + } } diff --git a/src/selection.js b/src/selection.js new file mode 100644 index 0000000000..228a22743c --- /dev/null +++ b/src/selection.js @@ -0,0 +1,18 @@ +// This symbol is used by interactive marks to define which data are selected. A +// node returned by mark.render may expose a selection as node[selection], whose +// value may be an array of numbers (e.g., [0, 1, 2, …]) representing an +// in-order subset of the rendered index, or null if the selection is undefined. +// The selection can be updated during interaction by emitting an input event. +export const selection = Symbol("selection"); + +// Given two (possibly null, possibly an index, but not undefined) selections, +// returns true if the two represent the same selection, and false otherwise. +// This assumes that the selection is a in-order subset of the original index. +export function selectionEquals(s1, s2) { + if (s1 === s2) return true; + if (s1 == null || s2 == null) return false; + const n = s1.length; + if (n !== s2.length) return false; + for (let i = 0; i < n; ++i) if (s1[i] !== s2[i]) return false; + return true; +} diff --git a/test/output/aaplVolumeBrushX.svg b/test/output/aaplVolumeBrushX.svg new file mode 100644 index 0000000000..ebed1fb95a --- /dev/null +++ b/test/output/aaplVolumeBrushX.svg @@ -0,0 +1,106 @@ + + + + + + 0 + + + + 2 + + + + 4 + + + + 6 + + + + 8 + + + + 10 + + + + 12 + + + + 14 + + + + 16 + ↑ Frequency (%) + + + + 7.0 + + + 7.2 + + + 7.4 + + + 7.6 + + + 7.8 + + + 8.0 + + + 8.2 + + + 8.4 + Trade volume (log₁₀) → + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/aaplVolumePointerX.svg b/test/output/aaplVolumePointerX.svg new file mode 100644 index 0000000000..a9c619ca80 --- /dev/null +++ b/test/output/aaplVolumePointerX.svg @@ -0,0 +1,104 @@ + + + + + + 0 + + + + 2 + + + + 4 + + + + 6 + + + + 8 + + + + 10 + + + + 12 + + + + 14 + + + + 16 + ↑ Frequency (%) + + + + 7.0 + + + 7.2 + + + 7.4 + + + 7.6 + + + 7.8 + + + 8.0 + + + 8.2 + + + 8.4 + Trade volume (log₁₀) → + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/gistempAnomalyBrush.html b/test/output/gistempAnomalyBrush.html new file mode 100644 index 0000000000..9f33ad8da9 --- /dev/null +++ b/test/output/gistempAnomalyBrush.html @@ -0,0 +1,1742 @@ + + + + + + −0.6 + + + + −0.4 + + + + −0.2 + + + + +0.0 + + + + +0.2 + + + + +0.4 + + + + +0.6 + + + + +0.8 + + + + +1.0 + + + + +1.2 + ↑ Temperature anomaly (°C) + + + + 1880 + + + 1900 + + + 1920 + + + 1940 + + + 1960 + + + 1980 + + + 2000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1644 \ No newline at end of file diff --git a/test/output/gistempAnomalyPointer.html b/test/output/gistempAnomalyPointer.html new file mode 100644 index 0000000000..f86c9ef973 --- /dev/null +++ b/test/output/gistempAnomalyPointer.html @@ -0,0 +1,1734 @@ + + + + + + −0.6 + + + + −0.4 + + + + −0.2 + + + + +0.0 + + + + +0.2 + + + + +0.4 + + + + +0.6 + + + + +0.8 + + + + +1.0 + + + + +1.2 + ↑ Temperature anomaly (°C) + + + + 1880 + + + 1900 + + + 1920 + + + 1940 + + + 1960 + + + 1980 + + + 2000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1644 \ No newline at end of file diff --git a/test/output/gistempDecadePointer.html b/test/output/gistempDecadePointer.html new file mode 100644 index 0000000000..b084827ab2 --- /dev/null +++ b/test/output/gistempDecadePointer.html @@ -0,0 +1,1755 @@ + + + + + + −0.6 + + + + −0.4 + + + + −0.2 + + + + +0.0 + + + + +0.2 + + + + +0.4 + + + + +0.6 + + + + +0.8 + + + + +1.0 + + + + +1.2 + ↑ Temperature anomaly (°C) + + + + 1880 + + + 1890 + + + 1900 + + + 1910 + + + 1920 + + + 1930 + + + 1940 + + + 1950 + + + 1960 + + + 1970 + + + 1980 + + + 1990 + + + 2000 + + + 2010 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1644 \ No newline at end of file diff --git a/test/output/letterFrequencyPointer.svg b/test/output/letterFrequencyPointer.svg new file mode 100644 index 0000000000..481b689005 --- /dev/null +++ b/test/output/letterFrequencyPointer.svg @@ -0,0 +1,161 @@ + + + + + Z + + + Q + + + X + + + J + + + K + + + V + + + B + + + P + + + Y + + + G + + + F + + + W + + + M + + + U + + + C + + + L + + + D + + + R + + + H + + + S + + + N + + + I + + + O + + + A + + + T + + + E + + + + + + 0 + + + + 2 + + + + 4 + + + + 6 + + + + 8 + + + + 10 + + + + 12 + Frequency (%) → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/penguinCulmenBrush.html b/test/output/penguinCulmenBrush.html new file mode 100644 index 0000000000..59a710f28f --- /dev/null +++ b/test/output/penguinCulmenBrush.html @@ -0,0 +1,3036 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + species + + + + FEMALE + + + MALE + + + + sex + + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + ↑ culmen_length_mm + + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + + + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + + + + + + + 15 + + + + 20 + + + + + + + + 15 + + + + 20 + + + + + + + + 15 + + + + 20 + + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 344 \ No newline at end of file diff --git a/test/output/penguinCulmenBrushX.html b/test/output/penguinCulmenBrushX.html new file mode 100644 index 0000000000..e499d3780b --- /dev/null +++ b/test/output/penguinCulmenBrushX.html @@ -0,0 +1,2988 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + species + + + + FEMALE + + + MALE + + + + sex + + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + ↑ culmen_length_mm + + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + + + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + + + + + + + 15 + + + + 20 + + + + + + + + 15 + + + + 20 + + + + + + + + 15 + + + + 20 + + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 344 \ No newline at end of file diff --git a/test/plots/aapl-volume-brush-x.js b/test/plots/aapl-volume-brush-x.js new file mode 100644 index 0000000000..17c2e9a581 --- /dev/null +++ b/test/plots/aapl-volume-brush-x.js @@ -0,0 +1,21 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const data = await d3.csv("data/aapl.csv", d3.autoType); + return Plot.plot({ + x: { + round: true, + label: "Trade volume (log₁₀) →" + }, + y: { + grid: true, + percent: true + }, + marks: [ + Plot.rectY(data, Plot.binX({y: "proportion"}, {x: d => Math.log10(d.Volume)})), + Plot.ruleY([0]), + Plot.brushX(data, {x: d => Math.log10(d.Volume)}) + ] + }); +} diff --git a/test/plots/aapl-volume-pointer-x.js b/test/plots/aapl-volume-pointer-x.js new file mode 100644 index 0000000000..3db7366eb6 --- /dev/null +++ b/test/plots/aapl-volume-pointer-x.js @@ -0,0 +1,21 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const data = await d3.csv("data/aapl.csv", d3.autoType); + return Plot.plot({ + x: { + round: true, + label: "Trade volume (log₁₀) →" + }, + y: { + grid: true, + percent: true + }, + marks: [ + Plot.rectY(data, Plot.binX({y: "proportion"}, {x: d => Math.log10(d.Volume)})), + Plot.ruleY([0]), + Plot.pointerX(data, {x: d => Math.log10(d.Volume)}) + ] + }); +} diff --git a/test/plots/gistemp-anomaly-brush.js b/test/plots/gistemp-anomaly-brush.js new file mode 100644 index 0000000000..cad771c8a7 --- /dev/null +++ b/test/plots/gistemp-anomaly-brush.js @@ -0,0 +1,27 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {html} from "htl"; + +export default async function() { + const data = await d3.csv("data/gistemp.csv", d3.autoType); + const plot = Plot.plot({ + y: { + label: "↑ Temperature anomaly (°C)", + tickFormat: "+f", + grid: true + }, + color: { + type: "diverging", + reverse: true + }, + marks: [ + Plot.ruleY([0]), + Plot.dot(data, {x: "Date", y: "Anomaly", stroke: "Anomaly"}), + Plot.brush(data, {x: "Date", y: "Anomaly"}) + ] + }); + const output = html``; + plot.oninput = () => output.value = plot.value.length; + plot.oninput(); + return html`${plot}${output}`; +} diff --git a/test/plots/gistemp-anomaly-pointer.js b/test/plots/gistemp-anomaly-pointer.js new file mode 100644 index 0000000000..82480f948d --- /dev/null +++ b/test/plots/gistemp-anomaly-pointer.js @@ -0,0 +1,27 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {html} from "htl"; + +export default async function() { + const data = await d3.csv("data/gistemp.csv", d3.autoType); + const plot = Plot.plot({ + y: { + label: "↑ Temperature anomaly (°C)", + tickFormat: "+f", + grid: true + }, + color: { + type: "diverging", + reverse: true + }, + marks: [ + Plot.ruleY([0]), + Plot.dot(data, {x: "Date", y: "Anomaly", stroke: "Anomaly"}), + Plot.pointer(data, {x: "Date", y: "Anomaly", n: 5}) + ] + }); + const output = html``; + plot.oninput = () => output.value = plot.value.length; + plot.oninput(); + return html`${plot}${output}`; +} diff --git a/test/plots/gistemp-decade-pointer.js b/test/plots/gistemp-decade-pointer.js new file mode 100644 index 0000000000..33dcca6456 --- /dev/null +++ b/test/plots/gistemp-decade-pointer.js @@ -0,0 +1,30 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {html} from "htl"; + +export default async function() { + const data = await d3.csv("data/gistemp.csv", d3.autoType); + const plot = Plot.plot({ + x: { + type: "band" + }, + y: { + label: "↑ Temperature anomaly (°C)", + tickFormat: "+f", + grid: true + }, + color: { + type: "diverging", + reverse: true + }, + marks: [ + Plot.ruleY([0]), + Plot.tickY(data, {x: d => `${Math.floor(d.Date.getUTCFullYear() / 10)}0`, y: "Anomaly", stroke: "Anomaly"}), + Plot.pointer(data, {x: d => `${Math.floor(d.Date.getUTCFullYear() / 10)}0`, y: "Anomaly", n: 5}) + ] + }); + const output = html``; + plot.oninput = () => output.value = plot.value.length; + plot.oninput(); + return html`${plot}${output}`; +} diff --git a/test/plots/index.js b/test/plots/index.js index a42afdbae2..c1ac4b972f 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -5,6 +5,8 @@ export {default as aaplClose} from "./aapl-close.js"; export {default as aaplCloseUntyped} from "./aapl-close-untyped.js"; export {default as aaplMonthly} from "./aapl-monthly.js"; export {default as aaplVolume} from "./aapl-volume.js"; +export {default as aaplVolumeBrushX} from "./aapl-volume-brush-x.js"; +export {default as aaplVolumePointerX} from "./aapl-volume-pointer-x.js"; export {default as aaplVolumeRect} from "./aapl-volume-rect.js"; export {default as anscombeQuartet} from "./anscombe-quartet.js"; export {default as athletesBinsColors} from "./athletes-bins-colors.js"; @@ -51,8 +53,11 @@ export {default as footballCoverage} from "./football-coverage.js"; export {default as fruitSales} from "./fruit-sales.js"; export {default as fruitSalesDate} from "./fruit-sales-date.js"; export {default as gistempAnomaly} from "./gistemp-anomaly.js"; +export {default as gistempAnomalyBrush} from "./gistemp-anomaly-brush.js"; export {default as gistempAnomalyMoving} from "./gistemp-anomaly-moving.js"; +export {default as gistempAnomalyPointer} from "./gistemp-anomaly-pointer.js"; export {default as gistempAnomalyTransform} from "./gistemp-anomaly-transform.js"; +export {default as gistempDecadePointer} from "./gistemp-decade-pointer.js"; export {default as googleTrendsRidgeline} from "./google-trends-ridgeline.js"; export {default as gridChoropleth} from "./grid-choropleth.js"; export {default as hadcrutWarmingStripes} from "./hadcrut-warming-stripes.js"; @@ -70,6 +75,7 @@ export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; export {default as letterFrequencyDot} from "./letter-frequency-dot.js"; export {default as letterFrequencyLollipop} from "./letter-frequency-lollipop.js"; +export {default as letterFrequencyPointer} from "./letter-frequency-pointer.js"; export {default as letterFrequencyWheel} from "./letter-frequency-wheel.js"; export {default as logDegenerate} from "./log-degenerate.js"; export {default as metroInequality} from "./metro-inequality.js"; @@ -94,6 +100,8 @@ export {default as musicRevenue} from "./music-revenue.js"; export {default as ordinalBar} from "./ordinal-bar.js"; export {default as penguinCulmen} from "./penguin-culmen.js"; export {default as penguinCulmenArray} from "./penguin-culmen-array.js"; +export {default as penguinCulmenBrush} from "./penguin-culmen-brush.js"; +export {default as penguinCulmenBrushX} from "./penguin-culmen-brush-x.js"; export {default as penguinIslandUnknown} from "./penguin-island-unknown.js"; export {default as penguinMass} from "./penguin-mass.js"; export {default as penguinMassSex} from "./penguin-mass-sex.js"; diff --git a/test/plots/letter-frequency-pointer.js b/test/plots/letter-frequency-pointer.js new file mode 100644 index 0000000000..d68f97ee2b --- /dev/null +++ b/test/plots/letter-frequency-pointer.js @@ -0,0 +1,24 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const alphabet = await d3.csv("data/alphabet.csv", d3.autoType); + return Plot.plot({ + ariaLabel: "letter-frequency chart", + ariaDescription: "A horizontal bar chart showing the relative frequency of letters in the English language.", + x: { + label: "Frequency (%) →", + transform: x => x * 100, + grid: true + }, + y: { + label: null + }, + marks: [ + Plot.barX(alphabet, {x: "frequency", y: "letter", ariaLabel: (f => d => `${d.letter} ${f(d.frequency)}`)(d3.format(".1%")), sort: {y: "x"}}), + Plot.ruleX([0]), + Plot.pointerY(alphabet, {y: "letter"}) + ], + height: 580 + }); +} diff --git a/test/plots/penguin-culmen-brush-x.js b/test/plots/penguin-culmen-brush-x.js new file mode 100644 index 0000000000..09dd023e4d --- /dev/null +++ b/test/plots/penguin-culmen-brush-x.js @@ -0,0 +1,38 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {html} from "htl"; + +export default async function() { + const data = await d3.csv("data/penguins.csv", d3.autoType); + const plot = Plot.plot({ + height: 600, + grid: true, + facet: { + data, + x: "sex", + y: "species", + marginRight: 80 + }, + marks: [ + Plot.frame(), + Plot.dot(data, { + facet: "exclude", + x: "culmen_depth_mm", + y: "culmen_length_mm", + r: 2, + fill: "#ddd" + }), + Plot.dot(data, { + x: "culmen_depth_mm", + y: "culmen_length_mm" + }), + Plot.brushX(data, { + x: "culmen_depth_mm" + }) + ] + }); + const output = html``; + plot.oninput = () => output.value = plot.value.length; + plot.oninput(); + return html`${plot}${output}`; +} diff --git a/test/plots/penguin-culmen-brush.js b/test/plots/penguin-culmen-brush.js new file mode 100644 index 0000000000..4f08af205e --- /dev/null +++ b/test/plots/penguin-culmen-brush.js @@ -0,0 +1,39 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {html} from "htl"; + +export default async function() { + const data = await d3.csv("data/penguins.csv", d3.autoType); + const plot = Plot.plot({ + height: 600, + grid: true, + facet: { + data, + x: "sex", + y: "species", + marginRight: 80 + }, + marks: [ + Plot.frame(), + Plot.dot(data, { + facet: "exclude", + x: "culmen_depth_mm", + y: "culmen_length_mm", + r: 2, + fill: "#ddd" + }), + Plot.dot(data, { + x: "culmen_depth_mm", + y: "culmen_length_mm" + }), + Plot.brush(data, { + x: "culmen_depth_mm", + y: "culmen_length_mm" + }) + ] + }); + const output = html``; + plot.oninput = () => output.value = plot.value.length; + plot.oninput(); + return html`${plot}${output}`; +}