diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5cda57a5e9..2271044fca 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -107,7 +107,8 @@ export default defineConfig({ {text: "Tick", link: "/marks/tick"}, {text: "Tip", link: "/marks/tip"}, {text: "Tree", link: "/marks/tree"}, - {text: "Vector", link: "/marks/vector"} + {text: "Vector", link: "/marks/vector"}, + {text: "Waffle", link: "/marks/waffle"} ] }, { diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md new file mode 100644 index 0000000000..e57faaa81f --- /dev/null +++ b/docs/marks/waffle.md @@ -0,0 +1,170 @@ + + +# Waffle mark + +The **waffle mark** is similar to the [bar mark](./bar.md) in that it displays a quantity (or quantitative extent) for a given category; but unlike a bar, a waffle is subdivided into square cells that allow easier counting. Waffles are useful for reading exact quantities. How quickly can you count the pears 🍐 below? How many more apples 🍎 are there than bananas 🍌? + +:::plot +```js +Plot.waffleY([212, 207, 315, 11], {x: ["apples", "bananas", "oranges", "pears"]}).plot({height: 420}) +``` +::: + +The waffle mark is often used with the [group transform](../transforms/group.md) to compute counts. The chart below compares the number of female and male athletes in the 2012 Olympics. + +:::plot +```js +Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sex"})).plot({x: {label: null}}) +``` +::: + +:::info +Waffles are rendered using SVG patterns, making them more performant than alternatives such as the [dot mark](./dot.md) for rendering many points. +::: + +The **unit** option determines the quantity each waffle cell represents; it defaults to one. The unit may be set to a value greater than one for large quantities, or less than one (but greater than zero) for small fractional quantities. Try changing the unit below to see its effect. + +

+ + Unit: + + + + + + + + +

+ +:::plot +```js +Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fx: "date_of_birth", unit})).plot({fx: {interval: "5 years", label: null}}) +``` +::: + +:::tip +Use [faceting](../features/facets.md) as an alternative to supplying an ordinal channel (_i.e._, *fx* instead of *x* for a vertical waffleY). The facet scale’s **interval** option then allows grouping by a quantitative or temporal variable, such as the athlete’s year of birth in the chart below. +::: + +While waffles typically represent integer quantities, say to count people or days, they can also encode fractional values with a partial first or last cell. Set the **round** option to true to disable partial cells, or to Math.ceil or Math.floor to round up or down. + +Like bars, waffles can be [stacked](../transforms/stack.md), and implicitly apply the stack transform when only a single quantitative channel is supplied. + +:::plot +```js +Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fill: "sex", sort: "sex", fx: "weight", unit: 10})).plot({fx: {interval: 10}, color: {legend: true}}) +``` +::: + +Waffles can also be used to highlight a proportion of the whole. The chart below recreates a graphic of survey responses from [“Teens in Syria”](https://www.economist.com/graphic-detail/2015/08/19/teens-in-syria) by _The Economist_ (August 19, 2015); positive responses are in orange, while negative responses are in gray. The **rx** option is used to produce circles instead of squares. + +:::plot +```js +Plot.plot({ + axis: null, + label: null, + height: 260, + marginTop: 20, + marginBottom: 70, + title: "Subdued", + subtitle: "Of 120 surveyed Syrian teenagers:", + marks: [ + Plot.axisFx({lineWidth: 10, anchor: "bottom", dy: 20}), + Plot.waffleY({length: 1}, {y: 120, fillOpacity: 0.4, rx: "100%"}), + Plot.waffleY(survey, {fx: "question", y: "yes", rx: "100%", fill: "orange"}), + Plot.text(survey, {fx: "question", text: (d) => (d.yes / 120).toLocaleString("en-US", {style: "percent"}), frameAnchor: "bottom", lineAnchor: "top", dy: 6, fill: "orange", fontSize: 24, fontWeight: "bold"}) + ] +}) +``` +::: + +The waffle mark comes in two orientations: waffleY extends vertically↑, while waffleX extends horizontally→. The waffle mark automatically determines the appropriate number of cells per row or per column (depending on orientation) such that the cells are square, don’t overlap, and are consistent with position scales. + +

+ +

+ +:::plot +```js +Plot.waffleX([apples], {y: ["apples"]}).plot({height: 240}) +``` +::: + +:::info +The number of rows in the waffle above is guaranteed to be an integer, but it might not be a multiple or factor of the *x*-axis tick interval. For example, the waffle might have 15 rows while the *x*-axis shows ticks every 100 units. +::: +:::tip +While you can’t control the number of rows (or columns) directly, you can affect it via the **padding** option on the corresponding band scale. Padding defaults to 0.1; a higher value may produce more rows, while a lower (or zero) value may produce fewer rows. +::: + +## Waffle options + +For required channels, see the [bar mark](./bar.md). The waffle mark supports the [standard mark options](../features/marks.md), including [insets](../features/marks.md#insets) and [rounded corners](../features/marks.md#rounded-corners). The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise. + +## waffleX(*data*, *options*) {#waffleX} + +```js +Plot.waffleX(olympians, Plot.groupY({x: "count"}, {y: "sport"})) +``` + +Returns a new horizontal→ waffle with the given *data* and *options*. The following channels are required: + +* **x1** - the starting horizontal position; bound to the *x* scale +* **x2** - the ending horizontal position; bound to the *x* scale + +The following optional channels are supported: + +* **y** - the vertical position; bound to the *y* scale, which must be *band* + +If neither the **x1** nor **x2** option is specified, the **x** option may be specified as shorthand to apply an implicit [stackX transform](../transforms/stack.md); this is the typical configuration for a horizontal waffle chart with columns aligned at *x* = 0. If the **x** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **x2** as identity and **y** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to waffleX to make a quick sequential waffle chart. If the **y** channel is not specified, the column will span the full vertical extent of the plot (or facet). + +If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each *x* to produce *x1*, and *interval*.offset(*x1*) is invoked for each *x1* to produce *x2*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). + +## waffleY(*data*, *options*) {#waffleY} + +```js +Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sport"})) +``` + +Returns a new vertical↑ waffle with the given *data* and *options*. The following channels are required: + +* **y1** - the starting vertical position; bound to the *y* scale +* **y2** - the ending vertical position; bound to the *y* scale + +The following optional channels are supported: + +* **x** - the horizontal position; bound to the *x* scale, which must be *band* + +If neither the **y1** nor **y2** option is specified, the **y** option may be specified as shorthand to apply an implicit [stackY transform](../transforms/stack.md); this is the typical configuration for a vertical waffle chart with columns aligned at *y* = 0. If the **y** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **y2** as identity and **x** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to waffleY to make a quick sequential waffle chart. If the **x** channel is not specified, the column will span the full horizontal extent of the plot (or facet). + +If an **interval** is specified, such as d3.utcDay, **y1** and **y2** can be derived from **y**: *interval*.floor(*y*) is invoked for each *y* to produce *y1*, and *interval*.offset(*y1*) is invoked for each *y1* to produce *y2*. If the interval is specified as a number *n*, *y1* and *y2* are taken as the two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). diff --git a/src/index.d.ts b/src/index.d.ts index dcaa949da8..a83f0f3715 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -38,6 +38,7 @@ export * from "./marks/tick.js"; export * from "./marks/tip.js"; export * from "./marks/tree.js"; export * from "./marks/vector.js"; +export * from "./marks/waffle.js"; export * from "./options.js"; export * from "./plot.js"; export * from "./projection.js"; diff --git a/src/index.js b/src/index.js index 4cca38b84f..a95fdbc035 100644 --- a/src/index.js +++ b/src/index.js @@ -38,6 +38,7 @@ export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; export {Tip, tip} from "./marks/tip.js"; export {tree, cluster} from "./marks/tree.js"; export {Vector, vector, vectorX, vectorY, spike} from "./marks/vector.js"; +export {WaffleX, WaffleY, waffleX, waffleY} from "./marks/waffle.js"; export {valueof, column, identity, indexOf} from "./options.js"; export {filter, reverse, sort, shuffle, basic as transform, initializer} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; diff --git a/src/marks/bar.d.ts b/src/marks/bar.d.ts index 9cb69b46de..65af451b9b 100644 --- a/src/marks/bar.d.ts +++ b/src/marks/bar.d.ts @@ -170,6 +170,7 @@ export function barX(data?: Data, options?: BarXOptions): BarX; * ```js * Plot.barY(alphabet, {y: "frequency", x: "letter"}) * ``` + * * If neither **y1** nor **y2** nor **interval** is specified, an implicit * stackY transform is applied and **y** defaults to the identity function, * assuming that *data* = [*y₀*, *y₁*, *y₂*, …]. Otherwise if an **interval** is diff --git a/src/marks/bar.js b/src/marks/bar.js index a17453730a..686c018460 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -8,8 +8,12 @@ import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; import {applyRoundedRect, rectInsets, rectRadii} from "./rect.js"; +const barDefaults = { + ariaLabel: "bar" +}; + export class AbstractBar extends Mark { - constructor(data, channels, options = {}, defaults) { + constructor(data, channels, options = {}, defaults = barDefaults) { super(data, channels, options, defaults); rectInsets(this, options); rectRadii(this, options); @@ -81,12 +85,8 @@ function add(a, b) { : a + b; } -const defaults = { - ariaLabel: "bar" -}; - export class BarX extends AbstractBar { - constructor(data, options = {}) { + constructor(data, options = {}, defaults) { const {x1, x2, y} = options; super( data, @@ -115,7 +115,7 @@ export class BarX extends AbstractBar { } export class BarY extends AbstractBar { - constructor(data, options = {}) { + constructor(data, options = {}, defaults) { const {x, y1, y2} = options; super( data, diff --git a/src/marks/waffle.d.ts b/src/marks/waffle.d.ts new file mode 100644 index 0000000000..2d32f04712 --- /dev/null +++ b/src/marks/waffle.d.ts @@ -0,0 +1,90 @@ +import type {Data, RenderableMark} from "../mark.js"; +import type {BarXOptions, BarYOptions} from "./bar.js"; + +/** Options for the waffleX and waffleY mark. */ +interface WaffleOptions { + /** The quantity each cell represents; defaults to 1. */ + unit?: number; + /** The gap in pixels between cells; defaults to 1. */ + gap?: number; + /** If true, round to integers to avoid partial cells. */ + round?: boolean | ((value: number) => number); +} + +/** Options for the waffleX mark. */ +export interface WaffleXOptions extends BarXOptions, WaffleOptions {} + +/** Options for the waffleY mark. */ +export interface WaffleYOptions extends BarYOptions, WaffleOptions {} + +/** + * Returns a new vertical waffle mark for the given *data* and *options*; the + * required *y* values should be quantitative, and the optional *x* values + * should be ordinal. For example, for a vertical waffle chart of Olympic + * athletes by sport: + * + * ```js + * Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sport"})) + * ``` + * + * If neither **y1** nor **y2** nor **interval** is specified, an implicit + * stackY transform is applied and **y** defaults to the identity function, + * assuming that *data* = [*y₀*, *y₁*, *y₂*, …]. Otherwise if an **interval** is + * specified, then **y1** and **y2** are derived from **y**, representing the + * lower and upper bound of the containing interval, respectively. Otherwise, if + * only one of **y1** or **y2** is specified, the other defaults to **y**, which + * defaults to zero. + * + * The optional **x** ordinal channel specifies the horizontal position; it is + * typically bound to the *x* scale, which must be a *band* scale. If the **x** + * channel is not specified, the waffle will span the horizontal extent of the + * plot’s frame. Because a waffle represents a discrete number of square cells, + * it may not use all of the available bandwidth. + * + * If *options* is undefined, then **x** defaults to the zero-based index of + * *data* [0, 1, 2, …], allowing a quick waffle chart from an array of numbers: + * + * ```js + * Plot.waffleY([4, 9, 24, 46, 66, 7]) + * ``` + */ +export function waffleY(data?: Data, options?: WaffleYOptions): WaffleY; + +/** + * Returns a new horizonta waffle mark for the given *data* and *options*; the + * required *x* values should be quantitative, and the optional *y* values + * should be ordinal. For example, for a horizontal waffle chart of Olympic + * athletes by sport: + * + * ```js + * Plot.waffleX(olympians, Plot.groupY({x: "count"}, {y: "sport"})) + * ``` + * + * If neither **x1** nor **x2** nor **interval** is specified, an implicit + * stackX transform is applied and **x** defaults to the identity function, + * assuming that *data* = [*x₀*, *x₁*, *x₂*, …]. Otherwise if an **interval** is + * specified, then **x1** and **x2** are derived from **x**, representing the + * lower and upper bound of the containing interval, respectively. Otherwise, if + * only one of **x1** or **x2** is specified, the other defaults to **x**, which + * defaults to zero. + * + * The optional **y** ordinal channel specifies the vertical position; it is + * typically bound to the *y* scale, which must be a *band* scale. If the **y** + * channel is not specified, the waffle will span the vertical extent of the + * plot’s frame. Because a waffle represents a discrete number of square cells, + * it may not use all of the available bandwidth. + * + * If *options* is undefined, then **y** defaults to the zero-based index of + * *data* [0, 1, 2, …], allowing a quick waffle chart from an array of numbers: + * + * ```js + * Plot.waffleX([4, 9, 24, 46, 66, 7]) + * ``` + */ +export function waffleX(data?: Data, options?: WaffleXOptions): WaffleX; + +/** The waffleX mark. */ +export class WaffleX extends RenderableMark {} + +/** The waffleY mark. */ +export class WaffleY extends RenderableMark {} diff --git a/src/marks/waffle.js b/src/marks/waffle.js new file mode 100644 index 0000000000..358b721fb9 --- /dev/null +++ b/src/marks/waffle.js @@ -0,0 +1,203 @@ +import {extent, namespaces} from "d3"; +import {create} from "../context.js"; +import {composeRender} from "../mark.js"; +import {hasXY, identity, indexOf} from "../options.js"; +import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, getPatternId} from "../style.js"; +import {template} from "../template.js"; +import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; +import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; +import {maybeStackX, maybeStackY} from "../transforms/stack.js"; +import {BarX, BarY} from "./bar.js"; + +const waffleDefaults = { + ariaLabel: "waffle" +}; + +export class WaffleX extends BarX { + constructor(data, {unit = 1, gap = 1, round, render, ...options} = {}) { + super(data, {...options, render: composeRender(render, waffleRender("x"))}, waffleDefaults); + this.unit = Math.max(0, unit); + this.gap = +gap; + this.round = maybeRound(round); + } +} + +export class WaffleY extends BarY { + constructor(data, {unit = 1, gap = 1, round, render, ...options} = {}) { + super(data, {...options, render: composeRender(render, waffleRender("y"))}, waffleDefaults); + this.unit = Math.max(0, unit); + this.gap = +gap; + this.round = maybeRound(round); + } +} + +function waffleRender(y) { + return function (index, scales, values, dimensions, context) { + const {unit, gap, rx, ry, round} = this; + const {document} = context; + const Y1 = values.channels[`${y}1`].value; + const Y2 = values.channels[`${y}2`].value; + + // We might not use all the available bandwidth if the cells don’t fit evenly. + const barwidth = this[y === "y" ? "_width" : "_height"](scales, values, dimensions); + const barx = this[y === "y" ? "_x" : "_y"](scales, values, dimensions); + + // The length of a unit along y in pixels. + const scale = unit * scaleof(scales.scales[y]); + + // The number of cells on each row of the waffle. + const columns = Math.max(1, Math.floor(Math.sqrt(barwidth / scale))); + + // The outer size of each square cell, in pixels, including the gap. + const cx = Math.min(barwidth, scale * columns); + const cy = scale * columns; + + // TODO insets? + const transform = y === "y" ? ([x, y]) => [x * cx, -y * cy] : ([x, y]) => [y * cy, x * cx]; + const tx = (barwidth - columns * cx) / 2; + const x0 = typeof barx === "function" ? (i) => barx(i) + tx : barx + tx; + const y0 = scales[y](0); + + // Create a base pattern with shared attributes for cloning. + const patternId = getPatternId(); + const basePattern = document.createElementNS(namespaces.svg, "pattern"); + basePattern.setAttribute("width", y === "y" ? cx : cy); + basePattern.setAttribute("height", y === "y" ? cy : cx); + basePattern.setAttribute("patternUnits", "userSpaceOnUse"); + const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect")); + basePatternRect.setAttribute("x", gap / 2); + basePatternRect.setAttribute("y", gap / 2); + basePatternRect.setAttribute("width", (y === "y" ? cx : cy) - gap); + basePatternRect.setAttribute("height", (y === "y" ? cy : cx) - gap); + if (rx != null) basePatternRect.setAttribute("rx", rx); + if (ry != null) basePatternRect.setAttribute("ry", ry); + + return create("svg:g", context) + .call(applyIndirectStyles, this, dimensions, context) + .call(this._transform, this, scales) + .call((g) => + g + .selectAll() + .data(index) + .enter() + .append(() => basePattern.cloneNode(true)) + .attr("id", (i) => `${patternId}-${i}`) + .select("rect") + .call(applyDirectStyles, this) + .call(applyChannelStyles, this, values) + ) + .call((g) => + g + .selectAll() + .data(index) + .enter() + .append("path") + .attr("transform", y === "y" ? template`translate(${x0},${y0})` : template`translate(${y0},${x0})`) + .attr( + "d", + (i) => + `M${wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), columns) + .map(transform) + .join("L")}Z` + ) + .attr("fill", (i) => `url(#${patternId}-${i})`) + .attr("stroke", this.stroke == null ? null : (i) => `url(#${patternId}-${i})`) + ) + .node(); + }; +} + +// A waffle is a approximately rectangular shape, but may have one or two corner +// cuts if the starting or ending value is not an even multiple of the number of +// columns (the width of the waffle in cells). We can represent any waffle by +// 8 points; below is a waffle of five columns representing the interval 2–11: +// +// 1-0 +// |•7-------6 +// |• • • • •| +// 2---3• • •| +// 4-----5 +// +// Note that points 0 and 1 always have the same y-value, points 1 and 2 have +// the same x-value, and so on, so we don’t need to materialize the x- and y- +// values of all points. Also note that we can’t use the already-projected y- +// values because these assume that y-values are distributed linearly along y +// rather than wrapping around in columns. +// +// The corner points may be coincident. If the ending value is an even multiple +// of the number of columns, say representing the interval 2–10, then points 6, +// 7, and 0 are the same. +// +// 1-----0/7/6 +// |• • • • •| +// 2---3• • •| +// 4-----5 +// +// Likewise if the starting value is an even multiple, say representing the +// interval 0–10, points 2–4 are coincident. +// +// 1-----0/7/6 +// |• • • • •| +// |• • • • •| +// 4/3/2-----5 +// +// Waffles can also represent fractional intervals (e.g., 2.4–10.1). These +// require additional corner cuts, so the implementation below generates a few +// more points. +function wafflePoints(i1, i2, columns) { + if (i1 < 0 || i2 < 0) { + const k = Math.ceil(-Math.min(i1, i2) / columns); // shift negative to positive + return wafflePoints(i1 + k * columns, i2 + k * columns, columns).map(([x, y]) => [x, y - k]); + } + if (i2 < i1) { + return wafflePoints(i2, i1, columns); + } + return [ + [0, Math.ceil(i1 / columns)], + [Math.floor(i1 % columns), Math.ceil(i1 / columns)], + [Math.floor(i1 % columns), Math.floor(i1 / columns) + (i1 % 1)], + [Math.ceil(i1 % columns), Math.floor(i1 / columns) + (i1 % 1)], + ...(i1 % columns > columns - 1 + ? [] + : [ + [Math.ceil(i1 % columns), Math.floor(i1 / columns)], + [columns, Math.floor(i1 / columns)] + ]), + [columns, Math.floor(i2 / columns)], + [Math.ceil(i2 % columns), Math.floor(i2 / columns)], + [Math.ceil(i2 % columns), Math.floor(i2 / columns) + (i2 % 1)], + [Math.floor(i2 % columns), Math.floor(i2 / columns) + (i2 % 1)], + ...(i2 % columns < 1 + ? [] + : [ + [Math.floor(i2 % columns), Math.ceil(i2 / columns)], + [0, Math.ceil(i2 / columns)] + ]) + ]; +} + +function maybeRound(round) { + if (round === undefined || round === false) return Number; + if (round === true) return Math.round; + if (typeof round !== "function") throw new Error(`invalid round: ${round}`); + return round; +} + +function scaleof({domain, range}) { + return spread(range) / spread(domain); +} + +function spread(domain) { + const [min, max] = extent(domain); + return max - min; +} + +export function waffleX(data, options = {}) { + if (!hasXY(options)) options = {...options, y: indexOf, x2: identity}; + return new WaffleX(data, maybeStackX(maybeIntervalX(maybeIdentityX(options)))); +} + +export function waffleY(data, options = {}) { + if (!hasXY(options)) options = {...options, x: indexOf, y2: identity}; + return new WaffleY(data, maybeStackY(maybeIntervalY(maybeIdentityY(options)))); +} diff --git a/src/style.js b/src/style.js index deeb66346f..f984750bf4 100644 --- a/src/style.js +++ b/src/style.js @@ -9,11 +9,16 @@ import {warn} from "./warnings.js"; export const offset = (typeof window !== "undefined" ? window.devicePixelRatio > 1 : typeof it === "undefined") ? 0 : 0.5; // prettier-ignore let nextClipId = 0; +let nextPatternId = 0; export function getClipId() { return `plot-clip-${++nextClipId}`; } +export function getPatternId() { + return `plot-pattern-${++nextPatternId}`; +} + export function styles( mark, { diff --git a/test/output/waffleRound.svg b/test/output/waffleRound.svg new file mode 100644 index 0000000000..8dd553d469 --- /dev/null +++ b/test/output/waffleRound.svg @@ -0,0 +1,102 @@ + + + + + −60 + −40 + −20 + 0 + 20 + 40 + 60 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleShorthand.svg b/test/output/waffleShorthand.svg new file mode 100644 index 0000000000..2c942bbba9 --- /dev/null +++ b/test/output/waffleShorthand.svg @@ -0,0 +1,102 @@ + + + + + −60 + −40 + −20 + 0 + 20 + 40 + 60 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleSquished.svg b/test/output/waffleSquished.svg new file mode 100644 index 0000000000..aeff76873d --- /dev/null +++ b/test/output/waffleSquished.svg @@ -0,0 +1,54 @@ + + + + + 0 + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleStroke.svg b/test/output/waffleStroke.svg new file mode 100644 index 0000000000..ff8bc9a54a --- /dev/null +++ b/test/output/waffleStroke.svg @@ -0,0 +1,102 @@ + + + + + −60 + −40 + −20 + 0 + 20 + 40 + 60 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleStrokeMixed.svg b/test/output/waffleStrokeMixed.svg new file mode 100644 index 0000000000..fceae2f5af --- /dev/null +++ b/test/output/waffleStrokeMixed.svg @@ -0,0 +1,109 @@ + + + + + −6 + −4 + −2 + 0 + 2 + 4 + 6 + 8 + 10 + + + + A + B + C + D + E + F + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleStrokeNegative.svg b/test/output/waffleStrokeNegative.svg new file mode 100644 index 0000000000..8e03565a10 --- /dev/null +++ b/test/output/waffleStrokeNegative.svg @@ -0,0 +1,165 @@ + + + + + −10 + −9 + −8 + −7 + −6 + −5 + −4 + −3 + −2 + −1 + 0 + + + + A + B + C + D + E + F + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleStrokePositive.svg b/test/output/waffleStrokePositive.svg new file mode 100644 index 0000000000..cb2ab9cd2b --- /dev/null +++ b/test/output/waffleStrokePositive.svg @@ -0,0 +1,165 @@ + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + + + + A + B + C + D + E + F + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleX.svg b/test/output/waffleX.svg new file mode 100644 index 0000000000..7ab8f53e3b --- /dev/null +++ b/test/output/waffleX.svg @@ -0,0 +1,76 @@ + + + + + Infants <1 + Children <11 + Teens 12-17 + Adults 18+ + Elderly 65+ + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + + + Frequency (thousands) → + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleXStacked.svg b/test/output/waffleXStacked.svg new file mode 100644 index 0000000000..15b8312d1c --- /dev/null +++ b/test/output/waffleXStacked.svg @@ -0,0 +1,68 @@ + + + + + 0 + 10 + 20 + 30 + 40 + 50 + 60 + 70 + 80 + 90 + + + Frequency (thousands) → + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg new file mode 100644 index 0000000000..5bdf4e4117 --- /dev/null +++ b/test/output/waffleY.svg @@ -0,0 +1,94 @@ + + + + + Infants <1 + Children <11 + Teens 12-17 + Adults 18+ + Elderly 65+ + + + + 0 + 2 + 4 + 6 + 8 + 10 + 12 + 14 + 16 + 18 + 20 + 22 + 24 + 26 + 28 + 30 + + + ↑ Frequency (thousands) + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleYGrouped.svg b/test/output/waffleYGrouped.svg new file mode 100644 index 0000000000..0e9b4ec8f7 --- /dev/null +++ b/test/output/waffleYGrouped.svg @@ -0,0 +1,224 @@ + + + + + 0 + 200 + 400 + 600 + 800 + 1,000 + 1,200 + 1,400 + 1,600 + 1,800 + 2,000 + 2,200 + + + ↑ Frequency + + + + aquatics + archery + athletics + badminton + basketball + boxing + canoe + cycling + equestrian + fencing + football + golf + gymnastics + handball + hockey + judo + modern pentathlon + rowing + rugby sevens + sailing + shooting + table tennis + taekwondo + tennis + triathlon + volleyball + weightlifting + wrestling + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleYStacked.html b/test/output/waffleYStacked.html new file mode 100644 index 0000000000..71dbdad274 --- /dev/null +++ b/test/output/waffleYStacked.html @@ -0,0 +1,106 @@ +
+
+ + + Infants <1 + + Children <11 + + Teens 12-17 + + Adults 18+ + + Elderly 65+ +
+ + + + 0 + 10 + 20 + 30 + 40 + 50 + 60 + 70 + 80 + 90 + + + ↑ Frequency (thousands) + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plot.js b/test/plot.js index 83fd9301ae..20c1664841 100644 --- a/test/plot.js +++ b/test/plot.js @@ -18,6 +18,7 @@ for (const [name, plot] of Object.entries(plots)) { reindexStyle(root); reindexMarker(root); reindexClip(root); + reindexPattern(root); let expected; let actual = beautify.html(root.outerHTML.replaceAll(" ", "\xa0"), { indent_size: 2, @@ -108,6 +109,23 @@ function reindexClip(root) { } } +function reindexPattern(root) { + let index = 0; + const map = new Map(); + for (const node of root.querySelectorAll("[id^=plot-pattern-]")) { + let id = node.getAttribute("id"); + if (map.has(id)) id = map.get(id); + else map.set(id, (id = `plot-pattern-${++index}`)); + node.setAttribute("id", id); + } + for (const key of ["fill", "stroke"]) { + for (const node of root.querySelectorAll(`[${key}]`)) { + let id = node.getAttribute(key).slice(5, -1); + if (map.has(id)) node.setAttribute(key, `url(#${map.get(id)})`); + } + } +} + const imageRe = /data:image\/png;base64,[^"]+/g; function stripImages(string) { diff --git a/test/plots/index.ts b/test/plots/index.ts index a5d272dc46..a06ffb1b0c 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -338,6 +338,7 @@ export * from "./var-color.js"; export * from "./vector-field.js"; export * from "./vector-frame.js"; export * from "./volcano.js"; +export * from "./waffle.js"; export * from "./walmarts-decades.js"; export * from "./walmarts-density-unprojected.js"; export * from "./walmarts-density.js"; diff --git a/test/plots/waffle.ts b/test/plots/waffle.ts new file mode 100644 index 0000000000..cbd24a1bad --- /dev/null +++ b/test/plots/waffle.ts @@ -0,0 +1,238 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +const demographics = d3.csvParse( + `group,label,freq +Infants <1,0-1,16467 +Children <11,1-11,30098 +Teens 12-17,12-17,20354 +Adults 18+,18+,12456 +Elderly 65+,65+,12456`, + d3.autoType +); + +export function waffleSquished() { + return Plot.waffleX([10]).plot(); +} + +export function waffleShorthand() { + return Plot.plot({ + y: {inset: 12}, + marks: [ + Plot.waffleY([4, 9, 24, 46, 66, 7], {fill: "currentColor"}), + Plot.waffleY([-4, -9, -24, -46, -66, -7], {fill: "red"}) + ] + }); +} + +export function waffleStroke() { + return Plot.plot({ + y: {inset: 12}, + marks: [ + Plot.waffleY([4, 9, 24, 46, 66, 7], {fill: "currentColor", stroke: "red", gap: 0}), + Plot.waffleY([-4, -9, -24, -46, -66, -7], {fill: "red", stroke: "currentColor", gap: 0}) + ] + }); +} + +export function waffleRound() { + return Plot.plot({ + y: {inset: 12}, + marks: [ + Plot.waffleY([4, 9, 24, 46, 66, 7], {fill: "currentColor", rx: "100%"}), + Plot.waffleY([-4, -9, -24, -46, -66, -7], {fill: "red", rx: "100%"}) + ] + }); +} + +export function waffleStrokeMixed() { + return Plot.plot({ + y: {insetBottom: 16}, + marks: [ + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: [-1.1, -2.2, -3.3, -4.4, -5.5, -6.6], + y2: [2.3, 4.5, 6.7, 7.8, 9.1, 10.2], + unit: 0.2, + fill: "currentColor", + stroke: "red" + } + ), + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: [2.3, 4.5, 6.7, 7.8, 9.1, 10.2], + y2: [-1.1, -2.2, -3.3, -4.4, -5.5, -6.6], + unit: 0.2, + gap: 10, + fill: "red" + } + ), + Plot.ruleY([0]) + ] + }); +} + +export function waffleStrokeNegative() { + return Plot.plot({ + x: {axis: "top"}, + marks: [ + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: 0, + y2: [-1.1, -2.2, -3.3, -4.4, -5.5, -6.6], + unit: 0.2, + fillOpacity: 0.4 + } + ), + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: [-1.1, -2.2, -3.3, -4.4, -5.5, -6.6], + y2: [-2.3, -4.5, -6.7, -7.8, -9.1, -10.2], + unit: 0.2, + fill: "currentColor", + stroke: "red" + } + ), + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: [-1.1, -2.2, -3.3, -4.4, -5.5, -6.6], + y2: 0, + gap: 10, + unit: 0.2, + fillOpacity: 0.4 + } + ), + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: [-2.3, -4.5, -6.7, -7.8, -9.1, -10.2], + y2: [-1.1, -2.2, -3.3, -4.4, -5.5, -6.6], + unit: 0.2, + gap: 10, + fill: "red" + } + ), + Plot.ruleY([0]) + ] + }); +} + +export function waffleStrokePositive() { + return Plot.plot({ + marks: [ + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: 0, + y2: [1.1, 2.2, 3.3, 4.4, 5.5, 6.6], + unit: 0.2, + fillOpacity: 0.4 + } + ), + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: [1.1, 2.2, 3.3, 4.4, 5.5, 6.6], + y2: [2.3, 4.5, 6.7, 7.8, 9.1, 10.2], + unit: 0.2, + fill: "currentColor", + stroke: "red" + } + ), + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: [1.1, 2.2, 3.3, 4.4, 5.5, 6.6], + y2: 0, + gap: 10, + unit: 0.2, + fillOpacity: 0.4 + } + ), + Plot.waffleY( + {length: 6}, + { + x: ["A", "B", "C", "D", "E", "F"], + y1: [2.3, 4.5, 6.7, 7.8, 9.1, 10.2], + y2: [1.1, 2.2, 3.3, 4.4, 5.5, 6.6], + unit: 0.2, + gap: 10, + fill: "red" + } + ), + Plot.ruleY([0]) + ] + }); +} + +export function waffleX() { + return Plot.plot({ + marginLeft: 80, + y: {label: null}, + color: {scheme: "cool"}, + marks: [ + Plot.axisX({label: "Frequency (thousands)", tickFormat: (d) => d / 1000}), + Plot.waffleX(demographics, {y: "group", fill: "group", x: "freq", unit: 100, sort: {y: null, color: null}}), + Plot.ruleX([0]) + ] + }); +} + +export function waffleXStacked() { + return Plot.plot({ + height: 240, + color: {scheme: "cool"}, + marks: [ + Plot.axisX({label: "Frequency (thousands)", tickFormat: (d) => d / 1000}), + Plot.waffleX(demographics, {fill: "group", x: "freq", unit: 100, sort: {color: null}}), + Plot.ruleX([0]) + ] + }); +} + +export function waffleY() { + return Plot.plot({ + x: {label: null}, + color: {scheme: "cool"}, + marks: [ + Plot.axisY({label: "Frequency (thousands)", tickFormat: (d) => d / 1000}), + Plot.waffleY(demographics, {x: "group", fill: "group", y: "freq", unit: 100, sort: {x: null, color: null}}), + Plot.ruleY([0]) + ] + }); +} + +export function waffleYStacked() { + return Plot.plot({ + y: {insetTop: 10}, + color: {scheme: "cool", legend: true}, + marks: [ + Plot.axisY({label: "Frequency (thousands)", tickFormat: (d) => d / 1000}), + Plot.waffleY(demographics, {fill: "group", y: "freq", unit: 100, sort: {color: null}}), + Plot.ruleY([0]) + ] + }); +} + +export async function waffleYGrouped() { + const athletes = await d3.csv("data/athletes.csv", d3.autoType); + return Plot.plot({ + marginBottom: 100, + x: {tickRotate: -90, label: null}, + marks: [Plot.waffleY(athletes, Plot.groupX({y: "count"}, {x: "sport", unit: 10})), Plot.ruleY([0])] + }); +}