From 8109fda1e255845755350768de3b0f322f7029ec Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 26 Mar 2024 20:24:29 -0700 Subject: [PATCH 01/22] checkpoint waffle --- src/index.js | 1 + src/marks/waffle.js | 153 +++++++++++++++++++++++++++++++++++++++++++ test/plots/index.ts | 1 + test/plots/waffle.ts | 22 +++++++ 4 files changed, 177 insertions(+) create mode 100644 src/marks/waffle.js create mode 100644 test/plots/waffle.ts diff --git a/src/index.js b/src/index.js index 9fde7ce2d5..3e833db352 100644 --- a/src/index.js +++ b/src/index.js @@ -30,6 +30,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 {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/waffle.js b/src/marks/waffle.js new file mode 100644 index 0000000000..eac55cd7df --- /dev/null +++ b/src/marks/waffle.js @@ -0,0 +1,153 @@ +import {template} from "../template.js"; +import {create} from "../context.js"; +import {Mark} from "../mark.js"; +import {hasXY, identity, indexOf} from "../options.js"; +import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, getClipId} from "../style.js"; +import {maybeIdentityY} from "../transforms/identity.js"; +import {maybeIntervalY} from "../transforms/interval.js"; +import {maybeStackY} from "../transforms/stack.js"; + +const defaults = { + ariaLabel: "waffle" +}; + +let nextWaffleId = 0; + +export class WaffleY extends Mark { + constructor(data, options = {}) { + const {x, y1, y2} = options; + super( + data, + { + y1: {value: y1, scale: "y", type: "linear"}, + y2: {value: y2, scale: "y", type: "linear"}, + x: {value: x, scale: "x", type: "band", optional: true} + }, + options, + defaults + ); + } + render(index, scales, channels, dimensions, context) { + // TODO rx, ry affects each waffle cell + // TODO recycle patterns that share the same fill color + const patternId = `plot-waffle-${++nextWaffleId}`; + + // The “density” of the y-scale, in pixels per unit; the number of waffle + // cells must correspond to the same number of units along the y-scale. + // TODO multiples (e.g., each cell in the waffle represents one thousand) + const density = Math.abs(scales.y(0) - scales.y(1)); + + // The width of the waffle, in cells. This must be an integer. TODO We need + // to compute this automatically, but how? + const columns = 10; + + // The outer width of each waffle cell, in pixels, including the gap. + const cellwidth = density * columns; + + // The gap between adjacent cells, in pixels. + const cellgap = 1; + + // The available bandwidth; we might not use all the available space if the + // waffle cells don’t fit evenly. + const bandwidth = this._width(scales, channels, dimensions); + + // A waffle is generally a rectangular shape, but may have one or two corner + // cuts if the number of units in the starting or ending value of the waffle + // is not an even multiple of the number of columns (the width of the waffle + // in cells). We can represent any waffle by seven points. Below is a waffle + // of five columns representing the interval 2–11: + // + // 0-1 + // 6-------7█| + // |█ █ █ █ █| + // |█ █ █3---2 + // 5-----4 + // + // 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. + // + // 6/7/0-----1 + // |█ █ █ █ █| + // |█ █ █3---2 + // 5-----4 + // + // Likewise if the starting value is an even multiple, say representing the + // interval 0–10, points 2–4 are coincident. + // + // 6/7/0-----1 + // |█ █ █ █ █| + // |█ █ █ █ █| + // 5-----2/3/4 + // + // Waffles can also represent fractional intervals (e.g., 2.4–10.1), but we + // haven’t implemented that yet. + + const {marginLeft, marginBottom, height} = dimensions; + const F = channels.fill; + const Y1 = channels.channels.y1.value; + const Y2 = channels.channels.y2.value; + const {y} = scales; + + const x0 = (i) => marginLeft + (columns - (Y2[i] % columns)) * cellwidth; + const y0 = (i) => y(Math.ceil(Y2[i] / columns) * columns); + const x1 = () => marginLeft + columns * cellwidth; + const y2 = (i) => y(Math.ceil(Y1[i] / columns) * columns); + const x3 = (i) => marginLeft + (columns - (Y1[i] % columns)) * cellwidth; + const y4 = (i) => y(Math.floor(Y1[i] / columns) * columns); + const x5 = () => marginLeft + 0; + const y6 = (i) => y(Math.floor(Y2[i] / columns) * columns); + const x7 = (i) => marginLeft + (columns - (Y2[i] % columns)) * cellwidth; + + return create("svg:g", context) + .call(applyIndirectStyles, this, dimensions, context) + .call(this._transform, this, scales) + .call((g) => + g + .selectAll() + .data(index) + .enter() + .call((g) => + g + .append("pattern") + .attr("id", (i) => `${patternId}-${i}`) + .attr("width", cellwidth) + .attr("height", cellwidth) + .attr("x", marginLeft + cellgap / 2) + .attr("y", height - marginBottom - cellwidth + cellgap / 2) + .attr("patternUnits", "userSpaceOnUse") + .append("rect") + .attr("fill", (i) => F[i]) + .attr("width", cellwidth - cellgap) + .attr("height", cellwidth - cellgap) + ) + .call((g) => + g + .append("path") + .call(applyDirectStyles, this) + .attr("d", template`M${x0},${y0}H${x1}V${y2}H${x3}V${y4}H${x5}V${y6}H${x7}Z`) + .call(applyChannelStyles, this, channels) // TODO remove fill + .attr("fill", (i) => `url(#${patternId}-${i})`) + ) + ) + .node(); + } + _transform(selection, mark, {y}) { + selection.call(applyTransform, mark, {y}, 0, 0); + } + _width({x}, {x: X}, {marginRight, marginLeft, width}) { + const bandwidth = X && x ? x.bandwidth() : width - marginRight - marginLeft; + return Math.max(0, bandwidth); + } +} + +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/test/plots/index.ts b/test/plots/index.ts index 0a3ee3eb0d..303a809cea 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -327,6 +327,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..99e66f089c --- /dev/null +++ b/test/plots/waffle.ts @@ -0,0 +1,22 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export function waffleY() { + const data = 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 + ); + return Plot.plot({ + width: 500, + marginLeft: 20, + marks: [ + Plot.waffleY(data, {fill: "group", y: (d) => Math.round(d.freq / 1000)}), + Plot.ruleY([0]) + ] + }); +} From eb307870f70da551892864815d3bdda141f1e548 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 26 Mar 2024 20:27:53 -0700 Subject: [PATCH 02/22] tweaks --- src/marks/waffle.js | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/marks/waffle.js b/src/marks/waffle.js index eac55cd7df..2fefb08df8 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -32,17 +32,16 @@ export class WaffleY extends Mark { // TODO recycle patterns that share the same fill color const patternId = `plot-waffle-${++nextWaffleId}`; - // The “density” of the y-scale, in pixels per unit; the number of waffle - // cells must correspond to the same number of units along the y-scale. + // The length of a unit along y in pixels. // TODO multiples (e.g., each cell in the waffle represents one thousand) - const density = Math.abs(scales.y(0) - scales.y(1)); + const scale = Math.abs(scales.y(0) - scales.y(1)); // The width of the waffle, in cells. This must be an integer. TODO We need // to compute this automatically, but how? const columns = 10; - // The outer width of each waffle cell, in pixels, including the gap. - const cellwidth = density * columns; + // The outer size of each square waffle cell, in pixels, including the gap. + const cellsize = scale * columns; // The gap between adjacent cells, in pixels. const cellgap = 1; @@ -95,15 +94,15 @@ export class WaffleY extends Mark { const Y2 = channels.channels.y2.value; const {y} = scales; - const x0 = (i) => marginLeft + (columns - (Y2[i] % columns)) * cellwidth; + const x0 = (i) => marginLeft + (columns - (Y2[i] % columns)) * cellsize; const y0 = (i) => y(Math.ceil(Y2[i] / columns) * columns); - const x1 = () => marginLeft + columns * cellwidth; + const x1 = () => marginLeft + columns * cellsize; const y2 = (i) => y(Math.ceil(Y1[i] / columns) * columns); - const x3 = (i) => marginLeft + (columns - (Y1[i] % columns)) * cellwidth; + const x3 = (i) => marginLeft + (columns - (Y1[i] % columns)) * cellsize; const y4 = (i) => y(Math.floor(Y1[i] / columns) * columns); const x5 = () => marginLeft + 0; const y6 = (i) => y(Math.floor(Y2[i] / columns) * columns); - const x7 = (i) => marginLeft + (columns - (Y2[i] % columns)) * cellwidth; + const x7 = (i) => marginLeft + (columns - (Y2[i] % columns)) * cellsize; return create("svg:g", context) .call(applyIndirectStyles, this, dimensions, context) @@ -117,15 +116,17 @@ export class WaffleY extends Mark { g .append("pattern") .attr("id", (i) => `${patternId}-${i}`) - .attr("width", cellwidth) - .attr("height", cellwidth) - .attr("x", marginLeft + cellgap / 2) - .attr("y", height - marginBottom - cellwidth + cellgap / 2) + .attr("width", cellsize) + .attr("height", cellsize) + .attr("x", marginLeft) + .attr("y", height - marginBottom - cellsize) .attr("patternUnits", "userSpaceOnUse") .append("rect") .attr("fill", (i) => F[i]) - .attr("width", cellwidth - cellgap) - .attr("height", cellwidth - cellgap) + .attr("x", cellgap / 2) + .attr("y", cellgap / 2) + .attr("width", cellsize - cellgap) + .attr("height", cellsize - cellgap) ) .call((g) => g From 5820c4c0512bc494cf0b17f6cb244a0bcb39ada6 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 11:42:30 -0400 Subject: [PATCH 03/22] extend BarY --- src/marks/waffle.js | 135 ++++++++++++++----------------------------- test/plots/waffle.ts | 4 +- 2 files changed, 44 insertions(+), 95 deletions(-) diff --git a/src/marks/waffle.js b/src/marks/waffle.js index 2fefb08df8..a5a1bc4132 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -1,36 +1,22 @@ -import {template} from "../template.js"; -import {create} from "../context.js"; -import {Mark} from "../mark.js"; +import {namespaces} from "d3"; import {hasXY, identity, indexOf} from "../options.js"; -import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, getClipId} from "../style.js"; import {maybeIdentityY} from "../transforms/identity.js"; import {maybeIntervalY} from "../transforms/interval.js"; import {maybeStackY} from "../transforms/stack.js"; - -const defaults = { - ariaLabel: "waffle" -}; +import {BarY} from "./bar.js"; let nextWaffleId = 0; -export class WaffleY extends Mark { - constructor(data, options = {}) { - const {x, y1, y2} = options; - super( - data, - { - y1: {value: y1, scale: "y", type: "linear"}, - y2: {value: y2, scale: "y", type: "linear"}, - x: {value: x, scale: "x", type: "band", optional: true} - }, - options, - defaults - ); +export class WaffleY extends BarY { + constructor(data, options) { + super(data, options); } render(index, scales, channels, dimensions, context) { - // TODO rx, ry affects each waffle cell - // TODO recycle patterns that share the same fill color - const patternId = `plot-waffle-${++nextWaffleId}`; + const g = super.render(index, scales, channels, dimensions, context); + + // The available bandwidth; we might not use all the available space if the + // waffle cells don’t fit evenly. + const bandwidth = this._width(scales, channels, dimensions); // The length of a unit along y in pixels. // TODO multiples (e.g., each cell in the waffle represents one thousand) @@ -38,7 +24,7 @@ export class WaffleY extends Mark { // The width of the waffle, in cells. This must be an integer. TODO We need // to compute this automatically, but how? - const columns = 10; + const columns = Math.floor(Math.sqrt(bandwidth / scale)); // The outer size of each square waffle cell, in pixels, including the gap. const cellsize = scale * columns; @@ -46,10 +32,6 @@ export class WaffleY extends Mark { // The gap between adjacent cells, in pixels. const cellgap = 1; - // The available bandwidth; we might not use all the available space if the - // waffle cells don’t fit evenly. - const bandwidth = this._width(scales, channels, dimensions); - // A waffle is generally a rectangular shape, but may have one or two corner // cuts if the number of units in the starting or ending value of the waffle // is not an even multiple of the number of columns (the width of the waffle @@ -57,9 +39,9 @@ export class WaffleY extends Mark { // of five columns representing the interval 2–11: // // 0-1 - // 6-------7█| - // |█ █ █ █ █| - // |█ █ █3---2 + // 6-------7•| + // |• • • • •| + // |• • •3---2 // 5-----4 // // Note that points 0 and 1 always have the same y-value, points 1 and 2 @@ -73,78 +55,47 @@ export class WaffleY extends Mark { // then points 6, 7, and 0 are the same. // // 6/7/0-----1 - // |█ █ █ █ █| - // |█ █ █3---2 + // |• • • • •| + // |• • •3---2 // 5-----4 // // Likewise if the starting value is an even multiple, say representing the // interval 0–10, points 2–4 are coincident. // // 6/7/0-----1 - // |█ █ █ █ █| - // |█ █ █ █ █| + // |• • • • •| + // |• • • • •| // 5-----2/3/4 // // Waffles can also represent fractional intervals (e.g., 2.4–10.1), but we // haven’t implemented that yet. - const {marginLeft, marginBottom, height} = dimensions; - const F = channels.fill; - const Y1 = channels.channels.y1.value; - const Y2 = channels.channels.y2.value; - const {y} = scales; + // TODO rx, ry + for (const rect of g.querySelectorAll("rect")) { + const x = +rect.getAttribute("x"); + const fill = rect.getAttribute("fill"); + const patternId = `plot-waffle-${++nextWaffleId}`; + const pattern = context.document.createElementNS(namespaces.svg, "pattern"); + pattern.setAttribute("id", patternId) + pattern.setAttribute("width", cellsize); + pattern.setAttribute("height", cellsize); + pattern.setAttribute("patternUnits", "userSpaceOnUse"); + pattern.setAttribute("x", x); + pattern.setAttribute("y", scales.y(0) - cellgap); + const patternRect = context.document.createElementNS(namespaces.svg, "rect"); + patternRect.setAttribute("fill", fill); + patternRect.setAttribute("x", cellgap / 2); + patternRect.setAttribute("y", cellgap / 2); + patternRect.setAttribute("width", cellsize - cellgap); + patternRect.setAttribute("height", cellsize - cellgap); + pattern.appendChild(patternRect); + rect.setAttribute("transform", `translate(${(bandwidth - columns * cellsize) / 2},0)`); + rect.setAttribute("width", columns * cellsize); + rect.setAttribute("fill", `url(#${patternId})`); + g.insertBefore(pattern, rect); + } - const x0 = (i) => marginLeft + (columns - (Y2[i] % columns)) * cellsize; - const y0 = (i) => y(Math.ceil(Y2[i] / columns) * columns); - const x1 = () => marginLeft + columns * cellsize; - const y2 = (i) => y(Math.ceil(Y1[i] / columns) * columns); - const x3 = (i) => marginLeft + (columns - (Y1[i] % columns)) * cellsize; - const y4 = (i) => y(Math.floor(Y1[i] / columns) * columns); - const x5 = () => marginLeft + 0; - const y6 = (i) => y(Math.floor(Y2[i] / columns) * columns); - const x7 = (i) => marginLeft + (columns - (Y2[i] % columns)) * cellsize; - - return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) - .call(this._transform, this, scales) - .call((g) => - g - .selectAll() - .data(index) - .enter() - .call((g) => - g - .append("pattern") - .attr("id", (i) => `${patternId}-${i}`) - .attr("width", cellsize) - .attr("height", cellsize) - .attr("x", marginLeft) - .attr("y", height - marginBottom - cellsize) - .attr("patternUnits", "userSpaceOnUse") - .append("rect") - .attr("fill", (i) => F[i]) - .attr("x", cellgap / 2) - .attr("y", cellgap / 2) - .attr("width", cellsize - cellgap) - .attr("height", cellsize - cellgap) - ) - .call((g) => - g - .append("path") - .call(applyDirectStyles, this) - .attr("d", template`M${x0},${y0}H${x1}V${y2}H${x3}V${y4}H${x5}V${y6}H${x7}Z`) - .call(applyChannelStyles, this, channels) // TODO remove fill - .attr("fill", (i) => `url(#${patternId}-${i})`) - ) - ) - .node(); - } - _transform(selection, mark, {y}) { - selection.call(applyTransform, mark, {y}, 0, 0); - } - _width({x}, {x: X}, {marginRight, marginLeft, width}) { - const bandwidth = X && x ? x.bandwidth() : width - marginRight - marginLeft; - return Math.max(0, bandwidth); + return g; } } diff --git a/test/plots/waffle.ts b/test/plots/waffle.ts index 99e66f089c..28bc6f7fdb 100644 --- a/test/plots/waffle.ts +++ b/test/plots/waffle.ts @@ -12,10 +12,8 @@ Elderly 65+,65+,12456`, d3.autoType ); return Plot.plot({ - width: 500, - marginLeft: 20, marks: [ - Plot.waffleY(data, {fill: "group", y: (d) => Math.round(d.freq / 1000)}), + Plot.waffleY(data, {x: "group", fill: "group", y: (d) => d.freq / 100}), Plot.ruleY([0]) ] }); From 07af96bf106c353e97433facff6a1dd01cde988c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 12:17:58 -0400 Subject: [PATCH 04/22] waffles! --- src/marks/waffle.js | 138 +++++++++++++++++++++++++++----------------- 1 file changed, 86 insertions(+), 52 deletions(-) diff --git a/src/marks/waffle.js b/src/marks/waffle.js index a5a1bc4132..e7e81284da 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -12,6 +12,7 @@ export class WaffleY extends BarY { super(data, options); } render(index, scales, channels, dimensions, context) { + const {document} = context; const g = super.render(index, scales, channels, dimensions, context); // The available bandwidth; we might not use all the available space if the @@ -22,8 +23,7 @@ export class WaffleY extends BarY { // TODO multiples (e.g., each cell in the waffle represents one thousand) const scale = Math.abs(scales.y(0) - scales.y(1)); - // The width of the waffle, in cells. This must be an integer. TODO We need - // to compute this automatically, but how? + // The width of the waffle, in cells. This must be an integer. const columns = Math.floor(Math.sqrt(bandwidth / scale)); // The outer size of each square waffle cell, in pixels, including the gap. @@ -32,73 +32,107 @@ export class WaffleY extends BarY { // The gap between adjacent cells, in pixels. const cellgap = 1; - // A waffle is generally a rectangular shape, but may have one or two corner - // cuts if the number of units in the starting or ending value of the waffle - // is not an even multiple of the number of columns (the width of the waffle - // in cells). We can represent any waffle by seven points. Below is a waffle - // of five columns representing the interval 2–11: - // - // 0-1 - // 6-------7•| - // |• • • • •| - // |• • •3---2 - // 5-----4 - // - // 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. - // - // 6/7/0-----1 - // |• • • • •| - // |• • •3---2 - // 5-----4 - // - // Likewise if the starting value is an even multiple, say representing the - // interval 0–10, points 2–4 are coincident. - // - // 6/7/0-----1 - // |• • • • •| - // |• • • • •| - // 5-----2/3/4 - // - // Waffles can also represent fractional intervals (e.g., 2.4–10.1), but we - // haven’t implemented that yet. - // TODO rx, ry - for (const rect of g.querySelectorAll("rect")) { - const x = +rect.getAttribute("x"); + const Y1 = channels.channels.y1.value; + const Y2 = channels.channels.y2.value; + const ww = columns * cellsize; + const wx = (bandwidth - ww) / 2; + let rect = g.firstElementChild; + const y0 = scales.y(0) - cellgap; + for (const i of index) { + const x0 = +rect.getAttribute("x") + wx; const fill = rect.getAttribute("fill"); const patternId = `plot-waffle-${++nextWaffleId}`; - const pattern = context.document.createElementNS(namespaces.svg, "pattern"); - pattern.setAttribute("id", patternId) + const pattern = g.insertBefore(document.createElementNS(namespaces.svg, "pattern"), rect); + pattern.setAttribute("id", patternId); pattern.setAttribute("width", cellsize); pattern.setAttribute("height", cellsize); pattern.setAttribute("patternUnits", "userSpaceOnUse"); - pattern.setAttribute("x", x); - pattern.setAttribute("y", scales.y(0) - cellgap); - const patternRect = context.document.createElementNS(namespaces.svg, "rect"); + pattern.setAttribute("x", x0); + pattern.setAttribute("y", y0); + const patternRect = pattern.appendChild(document.createElementNS(namespaces.svg, "rect")); patternRect.setAttribute("fill", fill); patternRect.setAttribute("x", cellgap / 2); patternRect.setAttribute("y", cellgap / 2); patternRect.setAttribute("width", cellsize - cellgap); patternRect.setAttribute("height", cellsize - cellgap); - pattern.appendChild(patternRect); - rect.setAttribute("transform", `translate(${(bandwidth - columns * cellsize) / 2},0)`); - rect.setAttribute("width", columns * cellsize); - rect.setAttribute("fill", `url(#${patternId})`); - g.insertBefore(pattern, rect); + const path = document.createElementNS(namespaces.svg, "path"); + for (const a of rect.attributes) { + switch (a.name) { + case "x": + case "y": + case "width": + case "height": + case "fill": + continue; + } + path.setAttribute(a.name, a.value); + } + path.setAttribute( + "d", + `M${wafflePoints(Y1[i], Y2[i], columns) + .map(([x, y]) => [x * cellsize + x0, y0 - y * cellsize]) + .join("L")}Z` + ); + path.setAttribute("fill", `url(#${patternId})`); + const nextRect = rect.nextElementSibling; + rect.replaceWith(path); + rect = nextRect; } return g; } } +// A waffle is generally a rectangular shape, but may have one or two corner +// cuts if the number of units in the starting or ending value of the waffle +// is not an even multiple of the number of columns (the width of the waffle +// in cells). We can represent any waffle by seven points. Below is a waffle +// of five columns representing the interval 2–11: +// +// 0-1 +// 6-------7•| +// |• • • • •| +// |• • •3---2 +// 5-----4 +// +// 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. +// +// 6/7/0-----1 +// |• • • • •| +// |• • •3---2 +// 5-----4 +// +// Likewise if the starting value is an even multiple, say representing the +// interval 0–10, points 2–4 are coincident. +// +// 6/7/0-----1 +// |• • • • •| +// |• • • • •| +// 5-----2/3/4 +// +// Waffles can also represent fractional intervals (e.g., 2.4–10.1). +function wafflePoints(i1, i2, columns) { + return [ + [columns - (i2 % columns), Math.ceil(i2 / columns)], + [columns, Math.ceil(i2 / columns)], + [columns, Math.ceil(i1 / columns)], + [columns - (i1 % columns), Math.ceil(i1 / columns)], + [columns - (i1 % columns), Math.floor(i1 / columns)], + [0, Math.floor(i1 / columns)], + [0, Math.floor(i2 / columns)], + [columns - (i2 % columns), Math.floor(i2 / columns)] + ]; +} + export function waffleY(data, options = {}) { if (!hasXY(options)) options = {...options, x: indexOf, y2: identity}; return new WaffleY(data, maybeStackY(maybeIntervalY(maybeIdentityY(options)))); From 8ae54157e9f388e1edb3d27625fa0a6d56efcc17 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 12:33:06 -0400 Subject: [PATCH 05/22] change fractional orientation --- src/marks/waffle.js | 14 +++++++++----- test/plots/waffle.ts | 4 +++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/marks/waffle.js b/src/marks/waffle.js index e7e81284da..ce265980fa 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -32,7 +32,7 @@ export class WaffleY extends BarY { // The gap between adjacent cells, in pixels. const cellgap = 1; - // TODO rx, ry + // TODO rx, ry, insets const Y1 = channels.channels.y1.value; const Y2 = channels.channels.y2.value; const ww = columns * cellsize; @@ -122,14 +122,18 @@ export class WaffleY extends BarY { // Waffles can also represent fractional intervals (e.g., 2.4–10.1). function wafflePoints(i1, i2, columns) { return [ - [columns - (i2 % columns), Math.ceil(i2 / columns)], + [Math.ceil(columns - (i2 % columns)), Math.ceil(i2 / columns)], [columns, Math.ceil(i2 / columns)], [columns, Math.ceil(i1 / columns)], - [columns - (i1 % columns), Math.ceil(i1 / columns)], - [columns - (i1 % columns), Math.floor(i1 / columns)], + [Math.ceil(columns - (i1 % columns)), Math.ceil(i1 / columns)], + [Math.ceil(columns - (i1 % columns)), Math.floor(i1 / columns) + (i1 % 1)], + [Math.floor(columns - (i1 % columns)), Math.floor(i1 / columns) + (i1 % 1)], + [Math.floor(columns - (i1 % columns)), Math.floor(i1 / columns)], [0, Math.floor(i1 / columns)], [0, Math.floor(i2 / columns)], - [columns - (i2 % columns), Math.floor(i2 / columns)] + [Math.floor(columns - (i2 % columns)), Math.floor(i2 / columns)], + [Math.floor(columns - (i2 % columns)), Math.floor(i2 / columns) + (i2 % 1)], + [Math.ceil(columns - (i2 % columns)), Math.floor(i2 / columns) + (i2 % 1)] ]; } diff --git a/test/plots/waffle.ts b/test/plots/waffle.ts index 28bc6f7fdb..b7674fafb7 100644 --- a/test/plots/waffle.ts +++ b/test/plots/waffle.ts @@ -12,8 +12,10 @@ Elderly 65+,65+,12456`, d3.autoType ); return Plot.plot({ + x: {label: null}, + color: {scheme: "cool"}, marks: [ - Plot.waffleY(data, {x: "group", fill: "group", y: (d) => d.freq / 100}), + Plot.waffleY(data, {x: "group", fill: "group", y: (d) => d.freq / 100, sort: {x: null, color: null}}), Plot.ruleY([0]) ] }); From 216c121d0a003cd89f4731efdf5fe3ade877fd3c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 12:58:09 -0400 Subject: [PATCH 06/22] waffle test --- src/marks/waffle.js | 27 ++++++------ test/output/waffleY.svg | 91 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 test/output/waffleY.svg diff --git a/src/marks/waffle.js b/src/marks/waffle.js index ce265980fa..bf572e9b9c 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -9,7 +9,7 @@ let nextWaffleId = 0; export class WaffleY extends BarY { constructor(data, options) { - super(data, options); + super(data, {...options, stroke: "none"}); } render(index, scales, channels, dimensions, context) { const {document} = context; @@ -32,30 +32,33 @@ export class WaffleY extends BarY { // The gap between adjacent cells, in pixels. const cellgap = 1; - // TODO rx, ry, insets + // TODO rx, ry + // TODO insets? const Y1 = channels.channels.y1.value; const Y2 = channels.channels.y2.value; const ww = columns * cellsize; const wx = (bandwidth - ww) / 2; let rect = g.firstElementChild; const y0 = scales.y(0) - cellgap; + const basePattern = document.createElementNS(namespaces.svg, "pattern"); + basePattern.setAttribute("width", cellsize); + basePattern.setAttribute("height", cellsize); + basePattern.setAttribute("patternUnits", "userSpaceOnUse"); + basePattern.setAttribute("y", y0); + const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect")); + basePatternRect.setAttribute("x", cellgap / 2); + basePatternRect.setAttribute("y", cellgap / 2); + basePatternRect.setAttribute("width", cellsize - cellgap); + basePatternRect.setAttribute("height", cellsize - cellgap); for (const i of index) { const x0 = +rect.getAttribute("x") + wx; const fill = rect.getAttribute("fill"); const patternId = `plot-waffle-${++nextWaffleId}`; - const pattern = g.insertBefore(document.createElementNS(namespaces.svg, "pattern"), rect); + const pattern = g.insertBefore(basePattern.cloneNode(true), rect); + const patternRect = pattern.firstChild; pattern.setAttribute("id", patternId); - pattern.setAttribute("width", cellsize); - pattern.setAttribute("height", cellsize); - pattern.setAttribute("patternUnits", "userSpaceOnUse"); pattern.setAttribute("x", x0); - pattern.setAttribute("y", y0); - const patternRect = pattern.appendChild(document.createElementNS(namespaces.svg, "rect")); patternRect.setAttribute("fill", fill); - patternRect.setAttribute("x", cellgap / 2); - patternRect.setAttribute("y", cellgap / 2); - patternRect.setAttribute("width", cellsize - cellgap); - patternRect.setAttribute("height", cellsize - cellgap); const path = document.createElementNS(namespaces.svg, "path"); for (const a of rect.attributes) { switch (a.name) { diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg new file mode 100644 index 0000000000..8ce688cbeb --- /dev/null +++ b/test/output/waffleY.svg @@ -0,0 +1,91 @@ + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + 200 + 220 + 240 + 260 + 280 + 300 + + + + Infants <1 + Children <11 + Teens 12-17 + Adults 18+ + Elderly 65+ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 50c8f6df9f4d5d718e0f74cc7a2312b5e57f6668 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 14:13:15 -0400 Subject: [PATCH 07/22] more waffle --- docs/.vitepress/config.ts | 3 +- docs/marks/waffle.md | 26 +++ src/index.d.ts | 1 + src/marks/bar.d.ts | 1 + src/marks/waffle.d.ts | 45 +++++ src/marks/waffle.js | 69 ++++---- test/output/waffleShorthand.svg | 90 ++++++++++ test/output/waffleShorthandNegative.svg | 90 ++++++++++ test/output/waffleY.svg | 20 +-- test/output/waffleYGrouped.svg | 224 ++++++++++++++++++++++++ test/output/waffleYStacked.html | 103 +++++++++++ test/plots/waffle.ts | 38 +++- 12 files changed, 663 insertions(+), 47 deletions(-) create mode 100644 docs/marks/waffle.md create mode 100644 src/marks/waffle.d.ts create mode 100644 test/output/waffleShorthand.svg create mode 100644 test/output/waffleShorthandNegative.svg create mode 100644 test/output/waffleYGrouped.svg create mode 100644 test/output/waffleYStacked.html 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..f7b53b32d9 --- /dev/null +++ b/docs/marks/waffle.md @@ -0,0 +1,26 @@ +# Waffle mark + +TODO + +## Waffle options + +For required channels, see the [bar mark](./bar.md). The waffle mark supports the [standard mark options](../features/marks.md). It does not support [insets](../features/marks.md#insets), [rounded corners](../features/marks.md#rounded-corners), or **stroke**. The **fill** defaults to *currentColor*. + +## 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/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/waffle.d.ts b/src/marks/waffle.d.ts new file mode 100644 index 0000000000..e8696574e3 --- /dev/null +++ b/src/marks/waffle.d.ts @@ -0,0 +1,45 @@ +import type {Data, RenderableMark} from "../mark.js"; +import type {BarYOptions} from "./bar.js"; + +/** Options for the waffleY mark. */ +export interface WaffleYOptions extends BarYOptions { + /** The quantity each cell represents; defaults to 1. */ + unit?: number; + /** The gap in pixels between cells; defaults to 1. */ + gap?: number; +} + +/** + * Returns a new vertical waffle mark for the given *data* and *options*; the + * required *y* values should be quantitative or temporal, 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. + * + * 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; + +/** The waffleY mark. */ +export class WaffleY extends RenderableMark {} diff --git a/src/marks/waffle.js b/src/marks/waffle.js index bf572e9b9c..e6b71f2833 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -8,48 +8,45 @@ import {BarY} from "./bar.js"; let nextWaffleId = 0; export class WaffleY extends BarY { - constructor(data, options) { + constructor(data, {unit = 1, gap = 1, ...options} = {}) { super(data, {...options, stroke: "none"}); + this.unit = Math.max(0, unit); + this.gap = +gap; } render(index, scales, channels, dimensions, context) { + const {unit, gap} = this; const {document} = context; const g = super.render(index, scales, channels, dimensions, context); - // The available bandwidth; we might not use all the available space if the - // waffle cells don’t fit evenly. + // We might not use all the available bandwidth if the cells don’t fit evenly. const bandwidth = this._width(scales, channels, dimensions); // The length of a unit along y in pixels. - // TODO multiples (e.g., each cell in the waffle represents one thousand) - const scale = Math.abs(scales.y(0) - scales.y(1)); + const scale = Math.abs(scales.y(0) - scales.y(unit)); - // The width of the waffle, in cells. This must be an integer. + // The number of cells on each row of the waffle. const columns = Math.floor(Math.sqrt(bandwidth / scale)); - // The outer size of each square waffle cell, in pixels, including the gap. + // The outer size of each square cell, in pixels, including the gap. const cellsize = scale * columns; - // The gap between adjacent cells, in pixels. - const cellgap = 1; - - // TODO rx, ry - // TODO insets? + // TODO rx, ry, insets? const Y1 = channels.channels.y1.value; const Y2 = channels.channels.y2.value; const ww = columns * cellsize; const wx = (bandwidth - ww) / 2; let rect = g.firstElementChild; - const y0 = scales.y(0) - cellgap; + const y0 = scales.y(0) - gap; const basePattern = document.createElementNS(namespaces.svg, "pattern"); basePattern.setAttribute("width", cellsize); basePattern.setAttribute("height", cellsize); basePattern.setAttribute("patternUnits", "userSpaceOnUse"); basePattern.setAttribute("y", y0); const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect")); - basePatternRect.setAttribute("x", cellgap / 2); - basePatternRect.setAttribute("y", cellgap / 2); - basePatternRect.setAttribute("width", cellsize - cellgap); - basePatternRect.setAttribute("height", cellsize - cellgap); + basePatternRect.setAttribute("x", gap / 2); + basePatternRect.setAttribute("y", gap / 2); + basePatternRect.setAttribute("width", cellsize - gap); + basePatternRect.setAttribute("height", cellsize - gap); for (const i of index) { const x0 = +rect.getAttribute("x") + wx; const fill = rect.getAttribute("fill"); @@ -73,7 +70,7 @@ export class WaffleY extends BarY { } path.setAttribute( "d", - `M${wafflePoints(Y1[i], Y2[i], columns) + `M${wafflePoints(Y1[i] / unit, Y2[i] / unit, columns) .map(([x, y]) => [x * cellsize + x0, y0 - y * cellsize]) .join("L")}Z` ); @@ -125,21 +122,33 @@ export class WaffleY extends BarY { // Waffles can also represent fractional intervals (e.g., 2.4–10.1). function wafflePoints(i1, i2, columns) { return [ - [Math.ceil(columns - (i2 % columns)), Math.ceil(i2 / columns)], - [columns, Math.ceil(i2 / columns)], - [columns, Math.ceil(i1 / columns)], - [Math.ceil(columns - (i1 % columns)), Math.ceil(i1 / columns)], - [Math.ceil(columns - (i1 % columns)), Math.floor(i1 / columns) + (i1 % 1)], - [Math.floor(columns - (i1 % columns)), Math.floor(i1 / columns) + (i1 % 1)], - [Math.floor(columns - (i1 % columns)), Math.floor(i1 / columns)], - [0, Math.floor(i1 / columns)], - [0, Math.floor(i2 / columns)], - [Math.floor(columns - (i2 % columns)), Math.floor(i2 / columns)], - [Math.floor(columns - (i2 % columns)), Math.floor(i2 / columns) + (i2 % 1)], - [Math.ceil(columns - (i2 % columns)), Math.floor(i2 / columns) + (i2 % 1)] + [ceil(columns - (abs(i2) % columns)), ceil(i2 / columns)], + [columns, ceil(i2 / columns)], + [columns, ceil(i1 / columns)], + [ceil(columns - (abs(i1) % columns)), ceil(i1 / columns)], + [ceil(columns - (abs(i1) % columns)), floor(i1 / columns) + (i1 % 1)], + [floor(columns - (abs(i1) % columns)), floor(i1 / columns) + (i1 % 1)], + [floor(columns - (abs(i1) % columns)), floor(i1 / columns)], + [0, floor(i1 / columns)], + [0, floor(i2 / columns)], + [floor(columns - (abs(i2) % columns)), floor(i2 / columns)], + [floor(columns - (abs(i2) % columns)), floor(i2 / columns) + (i2 % 1)], + [ceil(columns - (abs(i2) % columns)), floor(i2 / columns) + (i2 % 1)] ]; } +function abs(x) { + return Math.abs(x); +} + +function ceil(x) { + return (x < 0 ? Math.floor : Math.ceil)(x); +} + +function floor(x) { + return (x < 0 ? Math.ceil : Math.floor)(x); +} + 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/test/output/waffleShorthand.svg b/test/output/waffleShorthand.svg new file mode 100644 index 0000000000..556f6116be --- /dev/null +++ b/test/output/waffleShorthand.svg @@ -0,0 +1,90 @@ + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + 45 + 50 + 55 + 60 + 65 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleShorthandNegative.svg b/test/output/waffleShorthandNegative.svg new file mode 100644 index 0000000000..f7a12fafbb --- /dev/null +++ b/test/output/waffleShorthandNegative.svg @@ -0,0 +1,90 @@ + + + + + −65 + −60 + −55 + −50 + −45 + −40 + −35 + −30 + −25 + −20 + −15 + −10 + −5 + 0 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg index 8ce688cbeb..97172b8ec0 100644 --- a/test/output/waffleY.svg +++ b/test/output/waffleY.svg @@ -64,26 +64,26 @@ Elderly 65+ - + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleYGrouped.svg b/test/output/waffleYGrouped.svg new file mode 100644 index 0000000000..cf07be9e0f --- /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..96ec1e2f15 --- /dev/null +++ b/test/output/waffleYStacked.html @@ -0,0 +1,103 @@ +
+
+ + + Infants <1 + + Children <11 + + Teens 12-17 + + Adults 18+ + + Elderly 65+ +
+ + + + 0 + 100 + 200 + 300 + 400 + 500 + 600 + 700 + 800 + 900 + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/waffle.ts b/test/plots/waffle.ts index b7674fafb7..9e3c158ecb 100644 --- a/test/plots/waffle.ts +++ b/test/plots/waffle.ts @@ -1,22 +1,48 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; -export function waffleY() { - const data = d3.csvParse( - `group,label,freq +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 - ); + d3.autoType +); + +export function waffleShorthand() { + return Plot.waffleY([4, 9, 24, 46, 66, 7]).plot(); +} + +export function waffleShorthandNegative() { + return Plot.waffleY([-4, -9, -24, -46, -66, -7]).plot({x: {axis: "top"}}); +} + +export function waffleY() { return Plot.plot({ x: {label: null}, color: {scheme: "cool"}, marks: [ - Plot.waffleY(data, {x: "group", fill: "group", y: (d) => d.freq / 100, sort: {x: null, color: null}}), + Plot.waffleY(demographics, {x: "group", fill: "group", y: (d) => d.freq / 100, sort: {x: null, color: null}}), Plot.ruleY([0]) ] }); } + +export function waffleYStacked() { + return Plot.plot({ + x: {label: null}, + color: {scheme: "cool", legend: true}, + marks: [Plot.waffleY(demographics, {fill: "group", y: (d) => d.freq / 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])] + }); +} From 750a3a3ed1bf0fb1cc245f7d1c1c1de34b8bb1ba Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 15:08:35 -0400 Subject: [PATCH 08/22] lazy clone waffleX --- src/index.js | 2 +- src/marks/waffle.d.ts | 55 ++++++++++-- src/marks/waffle.js | 93 ++++++++++++++++++-- test/output/waffleShorthand.svg | 24 ++--- test/output/waffleShorthandNegative.svg | 24 ++--- test/output/waffleX.svg | 73 +++++++++++++++ test/output/waffleXStacked.svg | 65 ++++++++++++++ test/output/waffleY.svg | 20 ++--- test/output/waffleYGrouped.svg | 112 ++++++++++++------------ test/output/waffleYStacked.html | 20 ++--- test/plots/waffle.ts | 21 +++++ 11 files changed, 396 insertions(+), 113 deletions(-) create mode 100644 test/output/waffleX.svg create mode 100644 test/output/waffleXStacked.svg diff --git a/src/index.js b/src/index.js index 47f610834b..a95fdbc035 100644 --- a/src/index.js +++ b/src/index.js @@ -38,7 +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 {waffleY} from "./marks/waffle.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/waffle.d.ts b/src/marks/waffle.d.ts index e8696574e3..e6fa8d1718 100644 --- a/src/marks/waffle.d.ts +++ b/src/marks/waffle.d.ts @@ -1,18 +1,24 @@ import type {Data, RenderableMark} from "../mark.js"; -import type {BarYOptions} from "./bar.js"; +import type {BarXOptions, BarYOptions} from "./bar.js"; -/** Options for the waffleY mark. */ -export interface WaffleYOptions extends BarYOptions { +/** 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; } +/** 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 or temporal, and the optional *x* - * values should be ordinal. For example, for a vertical waffle chart of Olympic + * 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 @@ -30,7 +36,8 @@ export interface WaffleYOptions extends BarYOptions { * 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. + * 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: @@ -41,5 +48,41 @@ export interface WaffleYOptions extends BarYOptions { */ 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 index e6b71f2833..405b456060 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -1,11 +1,87 @@ import {namespaces} from "d3"; import {hasXY, identity, indexOf} from "../options.js"; -import {maybeIdentityY} from "../transforms/identity.js"; -import {maybeIntervalY} from "../transforms/interval.js"; -import {maybeStackY} from "../transforms/stack.js"; -import {BarY} from "./bar.js"; +import {getClipId} from "../style.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"; -let nextWaffleId = 0; +export class WaffleX extends BarX { + constructor(data, {unit = 1, gap = 1, ...options} = {}) { + super(data, {...options, stroke: "none"}); + this.unit = Math.max(0, unit); + this.gap = +gap; + } + render(index, scales, channels, dimensions, context) { + const {unit, gap} = this; + const {document} = context; + const g = super.render(index, scales, channels, dimensions, context); + + // We might not use all the available bandwidth if the cells don’t fit evenly. + const bandwidth = this._height(scales, channels, dimensions); + + // The length of a unit along x in pixels. + const scale = Math.abs(scales.x(0) - scales.x(unit)); + + // The number of cells on each row of the waffle. + const columns = Math.floor(Math.sqrt(bandwidth / scale)); + + // The outer size of each square cell, in pixels, including the gap. + const cellsize = scale * columns; + + // TODO rx, ry, insets? + const X1 = channels.channels.x1.value; + const X2 = channels.channels.x2.value; + const ww = columns * cellsize; + const wy = (bandwidth - ww) / 2; + let rect = g.firstElementChild; + const x0 = scales.x(0) - gap; + const basePattern = document.createElementNS(namespaces.svg, "pattern"); + basePattern.setAttribute("width", cellsize); + basePattern.setAttribute("height", cellsize); + basePattern.setAttribute("patternUnits", "userSpaceOnUse"); + basePattern.setAttribute("x", x0); + const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect")); + basePatternRect.setAttribute("x", gap / 2); + basePatternRect.setAttribute("y", gap / 2); + basePatternRect.setAttribute("width", cellsize - gap); + basePatternRect.setAttribute("height", cellsize - gap); + for (const i of index) { + const y0 = +rect.getAttribute("y") + wy; + const fill = rect.getAttribute("fill"); + const patternId = getClipId(); // TODO lazy + const pattern = g.insertBefore(basePattern.cloneNode(true), rect); + const patternRect = pattern.firstChild; + pattern.setAttribute("id", patternId); + pattern.setAttribute("y", y0); + patternRect.setAttribute("fill", fill); + const path = document.createElementNS(namespaces.svg, "path"); + for (const a of rect.attributes) { + switch (a.name) { + case "x": + case "y": + case "width": + case "height": + case "fill": + continue; + } + path.setAttribute(a.name, a.value); + } + path.setAttribute( + "d", + `M${wafflePoints(X1[i] / unit, X2[i] / unit, columns) + .map(([y, x]) => [x * cellsize + x0, y0 + y * cellsize]) + .join("L")}Z` + ); + path.setAttribute("fill", `url(#${patternId})`); + const nextRect = rect.nextElementSibling; + rect.replaceWith(path); + rect = nextRect; + } + + return g; + } +} export class WaffleY extends BarY { constructor(data, {unit = 1, gap = 1, ...options} = {}) { @@ -50,7 +126,7 @@ export class WaffleY extends BarY { for (const i of index) { const x0 = +rect.getAttribute("x") + wx; const fill = rect.getAttribute("fill"); - const patternId = `plot-waffle-${++nextWaffleId}`; + const patternId = getClipId(); // TODO lazy const pattern = g.insertBefore(basePattern.cloneNode(true), rect); const patternRect = pattern.firstChild; pattern.setAttribute("id", patternId); @@ -149,6 +225,11 @@ function floor(x) { return (x < 0 ? Math.ceil : Math.floor)(x); } +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/test/output/waffleShorthand.svg b/test/output/waffleShorthand.svg index 556f6116be..70c894cb24 100644 --- a/test/output/waffleShorthand.svg +++ b/test/output/waffleShorthand.svg @@ -62,29 +62,29 @@ 5
- + - - + + - - + + - - + + - - + + - - + + - + \ No newline at end of file diff --git a/test/output/waffleShorthandNegative.svg b/test/output/waffleShorthandNegative.svg index f7a12fafbb..322f537255 100644 --- a/test/output/waffleShorthandNegative.svg +++ b/test/output/waffleShorthandNegative.svg @@ -62,29 +62,29 @@ 5 - + - - + + - - + + - - + + - - + + - - + + - + \ No newline at end of file diff --git a/test/output/waffleX.svg b/test/output/waffleX.svg new file mode 100644 index 0000000000..7b73e70c9a --- /dev/null +++ b/test/output/waffleX.svg @@ -0,0 +1,73 @@ + + + + + Infants <1 + Children <11 + Teens 12-17 + Adults 18+ + Elderly 65+ + + + + 0 + 50 + 100 + 150 + 200 + 250 + 300 + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleXStacked.svg b/test/output/waffleXStacked.svg new file mode 100644 index 0000000000..6f890ee180 --- /dev/null +++ b/test/output/waffleXStacked.svg @@ -0,0 +1,65 @@ + + + + + 0 + 100 + 200 + 300 + 400 + 500 + 600 + 700 + 800 + 900 + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg index 97172b8ec0..059936d282 100644 --- a/test/output/waffleY.svg +++ b/test/output/waffleY.svg @@ -64,26 +64,26 @@ Elderly 65+ - + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleYGrouped.svg b/test/output/waffleYGrouped.svg index cf07be9e0f..7cf9bd690d 100644 --- a/test/output/waffleYGrouped.svg +++ b/test/output/waffleYGrouped.svg @@ -105,118 +105,118 @@ wrestling - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleYStacked.html b/test/output/waffleYStacked.html index 96ec1e2f15..0a5caeacc2 100644 --- a/test/output/waffleYStacked.html +++ b/test/output/waffleYStacked.html @@ -75,26 +75,26 @@ 900 - + - - + + - - + + - - + + - - + + - + diff --git a/test/plots/waffle.ts b/test/plots/waffle.ts index 9e3c158ecb..12ce547efc 100644 --- a/test/plots/waffle.ts +++ b/test/plots/waffle.ts @@ -19,6 +19,27 @@ export function waffleShorthandNegative() { return Plot.waffleY([-4, -9, -24, -46, -66, -7]).plot({x: {axis: "top"}}); } +export function waffleX() { + return Plot.plot({ + marginLeft: 80, + x: {axis: "top"}, + y: {label: null}, + color: {scheme: "cool"}, + marks: [ + Plot.waffleX(demographics, {y: "group", fill: "group", x: (d) => d.freq / 100, sort: {y: null, color: null}}), + Plot.ruleX([0]) + ] + }); +} + +export function waffleXStacked() { + return Plot.plot({ + height: 240, + color: {scheme: "cool"}, + marks: [Plot.waffleX(demographics, {fill: "group", x: (d) => d.freq / 100, sort: {color: null}}), Plot.ruleX([0])] + }); +} + export function waffleY() { return Plot.plot({ x: {label: null}, From bbac238ff636649c6f2e330f465a598765297ad6 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 15:22:46 -0400 Subject: [PATCH 09/22] waffle docs --- docs/marks/waffle.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md index f7b53b32d9..91214332b9 100644 --- a/docs/marks/waffle.md +++ b/docs/marks/waffle.md @@ -6,6 +6,25 @@ TODO For required channels, see the [bar mark](./bar.md). The waffle mark supports the [standard mark options](../features/marks.md). It does not support [insets](../features/marks.md#insets), [rounded corners](../features/marks.md#rounded-corners), or **stroke**. The **fill** defaults to *currentColor*. +## 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 From f8d4e04b8aaba36736e22994dbdaeba5379c0927 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 15:58:58 -0400 Subject: [PATCH 10/22] fix zero columns --- docs/marks/waffle.md | 18 +++++++++++++++++- src/marks/waffle.js | 4 ++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md index 91214332b9..781448be9b 100644 --- a/docs/marks/waffle.md +++ b/docs/marks/waffle.md @@ -1,4 +1,20 @@ -# Waffle mark + + +# Waffle mark + +The **waffle mark** is similar to the [bar mark](./bar.md), but subdivides each bar into discrete square cells that are more easily counted. It comes in two orientations: waffleY extends vertically↑, while waffleX extends horizontally→. For example, the waffle chart below shows TKTK. + +:::plot +```js +Plot.waffleY([1, 2, 32, 400, 5]).plot() +``` +::: TODO diff --git a/src/marks/waffle.js b/src/marks/waffle.js index 405b456060..29a18cc25f 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -24,7 +24,7 @@ export class WaffleX extends BarX { const scale = Math.abs(scales.x(0) - scales.x(unit)); // The number of cells on each row of the waffle. - const columns = Math.floor(Math.sqrt(bandwidth / scale)); + const columns = Math.max(1, Math.floor(Math.sqrt(bandwidth / scale))); // The outer size of each square cell, in pixels, including the gap. const cellsize = scale * columns; @@ -101,7 +101,7 @@ export class WaffleY extends BarY { const scale = Math.abs(scales.y(0) - scales.y(unit)); // The number of cells on each row of the waffle. - const columns = Math.floor(Math.sqrt(bandwidth / scale)); + const columns = Math.max(1, Math.floor(Math.sqrt(bandwidth / scale))); // The outer size of each square cell, in pixels, including the gap. const cellsize = scale * columns; From 4b8145d6d8539d49721405aaa1d43456a3b2e2de Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 22:25:18 -0400 Subject: [PATCH 11/22] fix rounding errors --- docs/marks/waffle.md | 32 ++++- src/marks/waffle.js | 15 ++- test/output/waffleShorthand.svg | 36 ++--- test/output/waffleShorthandNegative.svg | 36 ++--- test/output/waffleX.svg | 30 ++--- test/output/waffleY.svg | 30 ++--- test/output/waffleYGrouped.svg | 168 ++++++++++++------------ test/output/waffleYStacked.html | 30 ++--- 8 files changed, 207 insertions(+), 170 deletions(-) diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md index 781448be9b..00b9fe5739 100644 --- a/docs/marks/waffle.md +++ b/docs/marks/waffle.md @@ -2,13 +2,23 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; +import {shallowRef, onMounted} from "vue"; import alphabet from "../data/alphabet.ts"; +const olympians = shallowRef([ + {weight: 31, height: 1.21, sex: "female"}, + {weight: 170, height: 2.21, sex: "male"} +]); + +onMounted(() => { + d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); +}); + # Waffle mark -The **waffle mark** is similar to the [bar mark](./bar.md), but subdivides each bar into discrete square cells that are more easily counted. It comes in two orientations: waffleY extends vertically↑, while waffleX extends horizontally→. For example, the waffle chart below shows TKTK. +The **waffle mark** is similar to the [bar mark](./bar.md), but subdivides values into discrete square cells that are more easily counted. :::plot ```js @@ -16,7 +26,25 @@ Plot.waffleY([1, 2, 32, 400, 5]).plot() ``` ::: -TODO +The waffle mark is often used in conjunction with the group transform. + +:::plot +```js +Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: (d) => Math.floor(d.date_of_birth?.getUTCFullYear() / 10) * 10, unit: 10})).plot({round: true, x: {tickFormat: ""}}) +``` +::: + +The waffle mark comes in two orientations: waffleY extends vertically↑, while waffleX extends horizontally→. + +Waffles typically used to represent countable integer values, such as people or days, though they can also encode fractional values with a partial first or last cell. + +:::plot +```js +Plot.waffleY([1.5, 2, 32, 400, 5]).plot() +``` +::: + +Waffles can be stacked. ## Waffle options diff --git a/src/marks/waffle.js b/src/marks/waffle.js index 29a18cc25f..3bc4abe10f 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -1,4 +1,4 @@ -import {namespaces} from "d3"; +import {extent, namespaces} from "d3"; import {hasXY, identity, indexOf} from "../options.js"; import {getClipId} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; @@ -21,7 +21,7 @@ export class WaffleX extends BarX { const bandwidth = this._height(scales, channels, dimensions); // The length of a unit along x in pixels. - const scale = Math.abs(scales.x(0) - scales.x(unit)); + const scale = unit * scaleof(scales.scales.x); // The number of cells on each row of the waffle. const columns = Math.max(1, Math.floor(Math.sqrt(bandwidth / scale))); @@ -98,7 +98,7 @@ export class WaffleY extends BarY { const bandwidth = this._width(scales, channels, dimensions); // The length of a unit along y in pixels. - const scale = Math.abs(scales.y(0) - scales.y(unit)); + 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(bandwidth / scale))); @@ -213,6 +213,15 @@ function wafflePoints(i1, i2, columns) { ]; } +function scaleof({domain, range}) { + return spread(range) / spread(domain); +} + +function spread(domain) { + const [min, max] = extent(domain); + return max - min; +} + function abs(x) { return Math.abs(x); } diff --git a/test/output/waffleShorthand.svg b/test/output/waffleShorthand.svg index 70c894cb24..290a22ecba 100644 --- a/test/output/waffleShorthand.svg +++ b/test/output/waffleShorthand.svg @@ -62,29 +62,29 @@ 5 - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + \ No newline at end of file diff --git a/test/output/waffleShorthandNegative.svg b/test/output/waffleShorthandNegative.svg index 322f537255..76a3a4714e 100644 --- a/test/output/waffleShorthandNegative.svg +++ b/test/output/waffleShorthandNegative.svg @@ -62,29 +62,29 @@ 5 - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + \ No newline at end of file diff --git a/test/output/waffleX.svg b/test/output/waffleX.svg index 7b73e70c9a..c55d9ebcd7 100644 --- a/test/output/waffleX.svg +++ b/test/output/waffleX.svg @@ -46,26 +46,26 @@ 300 - - + + - - - + + + - - - + + + - - - + + + - - - + + + - + diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg index 059936d282..de5ed60083 100644 --- a/test/output/waffleY.svg +++ b/test/output/waffleY.svg @@ -64,26 +64,26 @@ Elderly 65+ - - + + - - - + + + - - - + + + - - - + + + - - - + + + - + diff --git a/test/output/waffleYGrouped.svg b/test/output/waffleYGrouped.svg index 7cf9bd690d..af519cd96e 100644 --- a/test/output/waffleYGrouped.svg +++ b/test/output/waffleYGrouped.svg @@ -105,118 +105,118 @@ wrestling - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + diff --git a/test/output/waffleYStacked.html b/test/output/waffleYStacked.html index 0a5caeacc2..6e9bdfd447 100644 --- a/test/output/waffleYStacked.html +++ b/test/output/waffleYStacked.html @@ -75,26 +75,26 @@ 900 - - + + - - - + + + - - - + + + - - - + + + - - - + + + - + From d7b7a33905b8075b3c4baf1e5ca4a201bed1eb18 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 1 Aug 2024 22:36:53 -0400 Subject: [PATCH 12/22] rx, ry --- docs/marks/waffle.md | 2 +- src/marks/waffle.js | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md index 00b9fe5739..7cf08e258e 100644 --- a/docs/marks/waffle.md +++ b/docs/marks/waffle.md @@ -30,7 +30,7 @@ The waffle mark is often used in conjunction with the group transform. :::plot ```js -Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: (d) => Math.floor(d.date_of_birth?.getUTCFullYear() / 10) * 10, unit: 10})).plot({round: true, x: {tickFormat: ""}}) +Plot.waffleY(olympians, Plot.groupX({y: "count"}, {fill: "sex", x: (d) => Math.floor(d.date_of_birth?.getUTCFullYear() / 10) * 10, unit: 10, rx: 10})).plot({round: true, x: {tickFormat: ""}}) ``` ::: diff --git a/src/marks/waffle.js b/src/marks/waffle.js index 3bc4abe10f..bd3670af05 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -13,7 +13,7 @@ export class WaffleX extends BarX { this.gap = +gap; } render(index, scales, channels, dimensions, context) { - const {unit, gap} = this; + const {unit, gap, rx, ry} = this; const {document} = context; const g = super.render(index, scales, channels, dimensions, context); @@ -29,7 +29,7 @@ export class WaffleX extends BarX { // The outer size of each square cell, in pixels, including the gap. const cellsize = scale * columns; - // TODO rx, ry, insets? + // TODO insets? const X1 = channels.channels.x1.value; const X2 = channels.channels.x2.value; const ww = columns * cellsize; @@ -46,6 +46,8 @@ export class WaffleX extends BarX { basePatternRect.setAttribute("y", gap / 2); basePatternRect.setAttribute("width", cellsize - gap); basePatternRect.setAttribute("height", cellsize - gap); + if (rx != null) basePatternRect.setAttribute("rx", rx); + if (ry != null) basePatternRect.setAttribute("ry", ry); for (const i of index) { const y0 = +rect.getAttribute("y") + wy; const fill = rect.getAttribute("fill"); @@ -90,7 +92,7 @@ export class WaffleY extends BarY { this.gap = +gap; } render(index, scales, channels, dimensions, context) { - const {unit, gap} = this; + const {unit, gap, rx, ry} = this; const {document} = context; const g = super.render(index, scales, channels, dimensions, context); @@ -106,7 +108,7 @@ export class WaffleY extends BarY { // The outer size of each square cell, in pixels, including the gap. const cellsize = scale * columns; - // TODO rx, ry, insets? + // TODO insets? const Y1 = channels.channels.y1.value; const Y2 = channels.channels.y2.value; const ww = columns * cellsize; @@ -123,6 +125,8 @@ export class WaffleY extends BarY { basePatternRect.setAttribute("y", gap / 2); basePatternRect.setAttribute("width", cellsize - gap); basePatternRect.setAttribute("height", cellsize - gap); + if (rx != null) basePatternRect.setAttribute("rx", rx); + if (ry != null) basePatternRect.setAttribute("ry", ry); for (const i of index) { const x0 = +rect.getAttribute("x") + wx; const fill = rect.getAttribute("fill"); From d636c9b2fdcb80f7453dd8355d8e172366f0168c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Aug 2024 10:00:50 -0400 Subject: [PATCH 13/22] flip waffle orientation --- docs/marks/waffle.md | 43 +++++++++++++-- src/marks/waffle.js | 71 +++++++++++++------------ test/output/waffleShorthand.svg | 12 ++--- test/output/waffleShorthandNegative.svg | 12 ++--- test/output/waffleX.svg | 10 ++-- test/output/waffleXStacked.svg | 10 ++-- test/output/waffleY.svg | 10 ++-- test/output/waffleYGrouped.svg | 56 +++++++++---------- test/output/waffleYStacked.html | 10 ++-- 9 files changed, 136 insertions(+), 98 deletions(-) diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md index 7cf08e258e..06a23583a9 100644 --- a/docs/marks/waffle.md +++ b/docs/marks/waffle.md @@ -3,13 +3,19 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import {shallowRef, onMounted} from "vue"; -import alphabet from "../data/alphabet.ts"; const olympians = shallowRef([ {weight: 31, height: 1.21, sex: "female"}, {weight: 170, height: 2.21, sex: "male"} ]); +const survey = [ + {question: "don’t go out after dark", yes: 96}, + {question: "do no activities other than school", yes: 89}, + {question: "engage in political discussion and social movements, including online", yes: 10}, + {question: "would like to do activities but are prevented by safety concerns", yes: 73} +]; + onMounted(() => { d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data)); }); @@ -18,7 +24,7 @@ onMounted(() => { # Waffle mark -The **waffle mark** is similar to the [bar mark](./bar.md), but subdivides values into discrete square cells that are more easily counted. +The **waffle mark** is similar to the [bar mark](./bar.md) in that it displays a quantity (or two) for a given category; but unlike a bar, a waffle is subdivided into many square cells that allow discrete values to be more easily counted. :::plot ```js @@ -30,12 +36,20 @@ The waffle mark is often used in conjunction with the group transform. :::plot ```js -Plot.waffleY(olympians, Plot.groupX({y: "count"}, {fill: "sex", x: (d) => Math.floor(d.date_of_birth?.getUTCFullYear() / 10) * 10, unit: 10, rx: 10})).plot({round: true, x: {tickFormat: ""}}) +Plot.waffleY(olympians, Plot.groupX({y: "count"}, {fill: "sex", x: (d) => Math.floor(d.date_of_birth?.getUTCFullYear() / 10) * 10, unit: 10})).plot({round: true, x: {tickFormat: ""}}) ``` ::: The waffle mark comes in two orientations: waffleY extends vertically↑, while waffleX extends horizontally→. +Waffles use patterns and thus are dramatically more performant when rendering many points. + +:::plot +```js +Plot.waffleY(olympians, Plot.groupX({y: "count"}, {fill: "sex", x: "sex"})).plot() +``` +::: + Waffles typically used to represent countable integer values, such as people or days, though they can also encode fractional values with a partial first or last cell. :::plot @@ -44,6 +58,29 @@ Plot.waffleY([1.5, 2, 32, 400, 5]).plot() ``` ::: +Waffles representing proportion. Waffles with rounded corners. + +:::plot +```js +Plot.plot({ + axis: null, + label: null, + height: 260, + marginTop: 20, + marginBottom: 80, + title: "Subdued", + subtitle: "Of 120 surveyed Syrian teenagers:", + caption: "A recreation of a chart in “Teens in Syria” published by The Economist on August 19, 2015.", + 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"}), + ] +}) +``` +::: + Waffles can be stacked. ## Waffle options diff --git a/src/marks/waffle.js b/src/marks/waffle.js index bd3670af05..dc97aa2ee7 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -164,56 +164,57 @@ export class WaffleY extends BarY { } } -// A waffle is generally a rectangular shape, but may have one or two corner -// cuts if the number of units in the starting or ending value of the waffle -// is not an even multiple of the number of columns (the width of the waffle -// in cells). We can represent any waffle by seven points. Below is a waffle -// of five columns representing the interval 2–11: +// 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: // -// 0-1 -// 6-------7•| +// 1-0 +// |•7-------6 // |• • • • •| -// |• • •3---2 -// 5-----4 +// 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. +// 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. +// 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. // -// 6/7/0-----1 +// 1-----0/7/6 // |• • • • •| -// |• • •3---2 -// 5-----4 +// 2---3• • •| +// 4-----5 // // Likewise if the starting value is an even multiple, say representing the // interval 0–10, points 2–4 are coincident. // -// 6/7/0-----1 +// 1-----0/7/6 // |• • • • •| // |• • • • •| -// 5-----2/3/4 +// 4/3/2-----5 // -// Waffles can also represent fractional intervals (e.g., 2.4–10.1). +// 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) { return [ - [ceil(columns - (abs(i2) % columns)), ceil(i2 / columns)], - [columns, ceil(i2 / columns)], - [columns, ceil(i1 / columns)], - [ceil(columns - (abs(i1) % columns)), ceil(i1 / columns)], - [ceil(columns - (abs(i1) % columns)), floor(i1 / columns) + (i1 % 1)], - [floor(columns - (abs(i1) % columns)), floor(i1 / columns) + (i1 % 1)], - [floor(columns - (abs(i1) % columns)), floor(i1 / columns)], - [0, floor(i1 / columns)], - [0, floor(i2 / columns)], - [floor(columns - (abs(i2) % columns)), floor(i2 / columns)], - [floor(columns - (abs(i2) % columns)), floor(i2 / columns) + (i2 % 1)], - [ceil(columns - (abs(i2) % columns)), floor(i2 / columns) + (i2 % 1)] + [floor(abs(i2) % columns), ceil(i2 / columns)], + [0, ceil(i2 / columns)], + [0, ceil(i1 / columns)], + [floor(abs(i1) % columns), ceil(i1 / columns)], + [floor(abs(i1) % columns), floor(i1 / columns) + (i1 % 1)], + [ceil(abs(i1) % columns), floor(i1 / columns) + (i1 % 1)], + [ceil(abs(i1) % columns), floor(i1 / columns)], + [columns, floor(i1 / columns)], + [columns, floor(i2 / columns)], + [ceil(abs(i2) % columns), floor(i2 / columns)], + [ceil(abs(i2) % columns), floor(i2 / columns) + (i2 % 1)], + [floor(abs(i2) % columns), floor(i2 / columns) + (i2 % 1)] ]; } diff --git a/test/output/waffleShorthand.svg b/test/output/waffleShorthand.svg index 290a22ecba..28a850c868 100644 --- a/test/output/waffleShorthand.svg +++ b/test/output/waffleShorthand.svg @@ -65,26 +65,26 @@ - + - + - + - + - + - + \ No newline at end of file diff --git a/test/output/waffleShorthandNegative.svg b/test/output/waffleShorthandNegative.svg index 76a3a4714e..e0ddb0b166 100644 --- a/test/output/waffleShorthandNegative.svg +++ b/test/output/waffleShorthandNegative.svg @@ -65,26 +65,26 @@ - + - + - + - + - + - + \ No newline at end of file diff --git a/test/output/waffleX.svg b/test/output/waffleX.svg index c55d9ebcd7..8aa120d2c3 100644 --- a/test/output/waffleX.svg +++ b/test/output/waffleX.svg @@ -49,23 +49,23 @@ - + - + - + - + - + diff --git a/test/output/waffleXStacked.svg b/test/output/waffleXStacked.svg index 6f890ee180..4e235081b7 100644 --- a/test/output/waffleXStacked.svg +++ b/test/output/waffleXStacked.svg @@ -41,23 +41,23 @@ - + - + - + - + - + diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg index de5ed60083..b0739feb18 100644 --- a/test/output/waffleY.svg +++ b/test/output/waffleY.svg @@ -67,23 +67,23 @@ - + - + - + - + - + diff --git a/test/output/waffleYGrouped.svg b/test/output/waffleYGrouped.svg index af519cd96e..bf15d70cda 100644 --- a/test/output/waffleYGrouped.svg +++ b/test/output/waffleYGrouped.svg @@ -108,115 +108,115 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/test/output/waffleYStacked.html b/test/output/waffleYStacked.html index 6e9bdfd447..c2ca6c5f30 100644 --- a/test/output/waffleYStacked.html +++ b/test/output/waffleYStacked.html @@ -78,23 +78,23 @@ - + - + - + - + - + From f2a4ce579c1c036d095f2d622fd5d939942ece58 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Aug 2024 12:10:49 -0400 Subject: [PATCH 14/22] more docs; round option --- docs/marks/waffle.md | 77 +++++++++++++++++++++++++++++++++++-------- src/marks/waffle.d.ts | 2 ++ src/marks/waffle.js | 21 ++++++++---- 3 files changed, 80 insertions(+), 20 deletions(-) diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md index 06a23583a9..f64e14dae5 100644 --- a/docs/marks/waffle.md +++ b/docs/marks/waffle.md @@ -2,7 +2,10 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; -import {shallowRef, onMounted} from "vue"; +import {ref, shallowRef, onMounted} from "vue"; + +const apples = ref(512); +const unit = ref(10); const olympians = shallowRef([ {weight: 31, height: 1.21, sex: "female"}, @@ -24,41 +27,60 @@ onMounted(() => { # Waffle mark -The **waffle mark** is similar to the [bar mark](./bar.md) in that it displays a quantity (or two) for a given category; but unlike a bar, a waffle is subdivided into many square cells that allow discrete values to be more easily counted. +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 and comparing exact quantities. How quickly can you count the pears 🍐 below? How many more apples 🍎 are there than bananas 🍌? :::plot ```js -Plot.waffleY([1, 2, 32, 400, 5]).plot() +Plot.waffleY([212, 207, 315, 11], {x: ["apples", "bananas", "oranges", "pears"]}).plot({height: 420}) ``` ::: -The waffle mark is often used in conjunction with the group transform. +The waffle mark is often used in conjunction with the group transform to count things. The chart below compares the number of female and male athletes in the 2012 Olympics. :::plot ```js -Plot.waffleY(olympians, Plot.groupX({y: "count"}, {fill: "sex", x: (d) => Math.floor(d.date_of_birth?.getUTCFullYear() / 10) * 10, unit: 10})).plot({round: true, x: {tickFormat: ""}}) +Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sex"})).plot({x: {label: null}}) ``` ::: -The waffle mark comes in two orientations: waffleY extends vertically↑, while waffleX extends horizontally→. +:::info +Waffles are rendered using SVG patterns, making them more performant than alternatives such as the [dot mark](./dot.md) for rendering many points. +::: + +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. -Waffles use patterns and thus are dramatically more performant when rendering many points. +The **unit** option determines the quantity each cell represents; it defaults to one. The unit may be set to a value greater than one for large quantities, or to a value 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.groupX({y: "count"}, {fill: "sex", x: "sex"})).plot() +Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fx: "date_of_birth", unit})).plot({fx: {interval: "5 years", label: null}}) ``` ::: -Waffles typically used to represent countable integer values, such as people or days, though they can also encode fractional values with a partial first or last cell. +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([1.5, 2, 32, 400, 5]).plot() +Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fill: "sex", sort: "sex", fx: "weight", unit: 10})).plot({fx: {interval: 10}, color: {legend: true}}) ``` ::: -Waffles representing proportion. Waffles with rounded corners. +TODO Waffles representing proportion. Waffles with rounded corners using **rx** or **ry**. 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). :::plot ```js @@ -67,10 +89,9 @@ Plot.plot({ label: null, height: 260, marginTop: 20, - marginBottom: 80, + marginBottom: 70, title: "Subdued", subtitle: "Of 120 surveyed Syrian teenagers:", - caption: "A recreation of a chart in “Teens in Syria” published by The Economist on August 19, 2015.", marks: [ Plot.axisFx({lineWidth: 10, anchor: "bottom", dy: 20}), Plot.waffleY({length: 1}, {y: 120, fillOpacity: 0.4, rx: "100%"}), @@ -81,7 +102,35 @@ Plot.plot({ ``` ::: -Waffles can be stacked. +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. +::: + +TODO The **gap** option. + +TODO Waffle shorthand. + +:::plot +```js +Plot.waffleY("AAAAGAGTGAAGATGCTGGAGACGAGTGAAGCATTCACTTTAGGGAAAGCGAGGCAAGAGCGTTTCAGAAGACGAAACCTGGTAGGTGCACTCACCACAG", Plot.groupX()).plot() +``` +::: ## Waffle options diff --git a/src/marks/waffle.d.ts b/src/marks/waffle.d.ts index e6fa8d1718..2d32f04712 100644 --- a/src/marks/waffle.d.ts +++ b/src/marks/waffle.d.ts @@ -7,6 +7,8 @@ interface WaffleOptions { 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. */ diff --git a/src/marks/waffle.js b/src/marks/waffle.js index dc97aa2ee7..29958728de 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -7,13 +7,14 @@ import {maybeStackX, maybeStackY} from "../transforms/stack.js"; import {BarX, BarY} from "./bar.js"; export class WaffleX extends BarX { - constructor(data, {unit = 1, gap = 1, ...options} = {}) { + constructor(data, {unit = 1, gap = 1, round, ...options} = {}) { super(data, {...options, stroke: "none"}); this.unit = Math.max(0, unit); this.gap = +gap; + this.round = maybeRound(round); } render(index, scales, channels, dimensions, context) { - const {unit, gap, rx, ry} = this; + const {unit, gap, rx, ry, round} = this; const {document} = context; const g = super.render(index, scales, channels, dimensions, context); @@ -71,7 +72,7 @@ export class WaffleX extends BarX { } path.setAttribute( "d", - `M${wafflePoints(X1[i] / unit, X2[i] / unit, columns) + `M${wafflePoints(round(X1[i] / unit), round(X2[i] / unit), columns) .map(([y, x]) => [x * cellsize + x0, y0 + y * cellsize]) .join("L")}Z` ); @@ -86,13 +87,14 @@ export class WaffleX extends BarX { } export class WaffleY extends BarY { - constructor(data, {unit = 1, gap = 1, ...options} = {}) { + constructor(data, {unit = 1, gap = 1, round, ...options} = {}) { super(data, {...options, stroke: "none"}); this.unit = Math.max(0, unit); this.gap = +gap; + this.round = maybeRound(round); } render(index, scales, channels, dimensions, context) { - const {unit, gap, rx, ry} = this; + const {unit, gap, rx, ry, round} = this; const {document} = context; const g = super.render(index, scales, channels, dimensions, context); @@ -150,7 +152,7 @@ export class WaffleY extends BarY { } path.setAttribute( "d", - `M${wafflePoints(Y1[i] / unit, Y2[i] / unit, columns) + `M${wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), columns) .map(([x, y]) => [x * cellsize + x0, y0 - y * cellsize]) .join("L")}Z` ); @@ -218,6 +220,13 @@ function wafflePoints(i1, 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); } From be09c924d44b7d44166f13ab0224e3dc6a65421b Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Aug 2024 13:20:41 -0400 Subject: [PATCH 15/22] tweak position; support stroke --- docs/marks/waffle.md | 28 ++-- src/marks/waffle.js | 24 ++-- test/output/waffleShorthand.svg | 36 ++--- test/output/waffleShorthandNegative.svg | 36 ++--- test/output/waffleStroke.svg | 90 +++++++++++++ test/output/waffleStrokeNegative.svg | 90 +++++++++++++ test/output/waffleX.svg | 20 +-- test/output/waffleXStacked.svg | 20 +-- test/output/waffleY.svg | 20 +-- test/output/waffleYGrouped.svg | 168 ++++++++++++------------ test/output/waffleYStacked.html | 20 +-- test/plots/waffle.ts | 8 ++ 12 files changed, 374 insertions(+), 186 deletions(-) create mode 100644 test/output/waffleStroke.svg create mode 100644 test/output/waffleStrokeNegative.svg diff --git a/docs/marks/waffle.md b/docs/marks/waffle.md index f64e14dae5..e1d12d56cb 100644 --- a/docs/marks/waffle.md +++ b/docs/marks/waffle.md @@ -27,7 +27,7 @@ onMounted(() => { # 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 and comparing exact quantities. How quickly can you count the pears 🍐 below? How many more apples 🍎 are there than bananas 🍌? +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 @@ -35,7 +35,7 @@ Plot.waffleY([212, 207, 315, 11], {x: ["apples", "bananas", "oranges", "pears"]} ``` ::: -The waffle mark is often used in conjunction with the group transform to count things. The chart below compares the number of female and male athletes in the 2012 Olympics. +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 @@ -47,9 +47,7 @@ Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sex"})).plot({x: {label: Waffles are rendered using SVG patterns, making them more performant than alternatives such as the [dot mark](./dot.md) for rendering many points. ::: -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. - -The **unit** option determines the quantity each cell represents; it defaults to one. The unit may be set to a value greater than one for large quantities, or to a value less than one (but greater than zero) for small fractional quantities. Try changing the unit below to see its effect. +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.

@@ -70,6 +68,10 @@ Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fx: "date_of_birth", unit})). ``` ::: +:::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. @@ -80,7 +82,7 @@ Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fill: "sex", sort: "sex", fx: ``` ::: -TODO Waffles representing proportion. Waffles with rounded corners using **rx** or **ry**. 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). +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 @@ -96,7 +98,7 @@ Plot.plot({ 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"}), + 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"}) ] }) ``` @@ -122,19 +124,9 @@ Plot.waffleX([apples], {y: ["apples"]}).plot({height: 240}) 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. ::: -TODO The **gap** option. - -TODO Waffle shorthand. - -:::plot -```js -Plot.waffleY("AAAAGAGTGAAGATGCTGGAGACGAGTGAAGCATTCACTTTAGGGAAAGCGAGGCAAGAGCGTTTCAGAAGACGAAACCTGGTAGGTGCACTCACCACAG", Plot.groupX()).plot() -``` -::: - ## Waffle options -For required channels, see the [bar mark](./bar.md). The waffle mark supports the [standard mark options](../features/marks.md). It does not support [insets](../features/marks.md#insets), [rounded corners](../features/marks.md#rounded-corners), or **stroke**. The **fill** defaults to *currentColor*. +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} diff --git a/src/marks/waffle.js b/src/marks/waffle.js index 29958728de..88f4019df6 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -8,7 +8,7 @@ import {BarX, BarY} from "./bar.js"; export class WaffleX extends BarX { constructor(data, {unit = 1, gap = 1, round, ...options} = {}) { - super(data, {...options, stroke: "none"}); + super(data, options); this.unit = Math.max(0, unit); this.gap = +gap; this.round = maybeRound(round); @@ -36,7 +36,7 @@ export class WaffleX extends BarX { const ww = columns * cellsize; const wy = (bandwidth - ww) / 2; let rect = g.firstElementChild; - const x0 = scales.x(0) - gap; + const x0 = scales.x(0); const basePattern = document.createElementNS(namespaces.svg, "pattern"); basePattern.setAttribute("width", cellsize); basePattern.setAttribute("height", cellsize); @@ -51,13 +51,15 @@ export class WaffleX extends BarX { if (ry != null) basePatternRect.setAttribute("ry", ry); for (const i of index) { const y0 = +rect.getAttribute("y") + wy; - const fill = rect.getAttribute("fill"); + const fill = rect.getAttribute("fill"); // TODO handle constant fill + const stroke = rect.getAttribute("stroke"); // TODO handle constant fill const patternId = getClipId(); // TODO lazy const pattern = g.insertBefore(basePattern.cloneNode(true), rect); const patternRect = pattern.firstChild; pattern.setAttribute("id", patternId); pattern.setAttribute("y", y0); - patternRect.setAttribute("fill", fill); + if (fill != null) patternRect.setAttribute("fill", fill); + if (stroke != null) patternRect.setAttribute("stroke", stroke); const path = document.createElementNS(namespaces.svg, "path"); for (const a of rect.attributes) { switch (a.name) { @@ -66,6 +68,7 @@ export class WaffleX extends BarX { case "width": case "height": case "fill": + case "stroke": continue; } path.setAttribute(a.name, a.value); @@ -76,6 +79,7 @@ export class WaffleX extends BarX { .map(([y, x]) => [x * cellsize + x0, y0 + y * cellsize]) .join("L")}Z` ); + if (stroke != null) path.setAttribute("stroke", `url(#${patternId})`); // TODO if necessary path.setAttribute("fill", `url(#${patternId})`); const nextRect = rect.nextElementSibling; rect.replaceWith(path); @@ -88,7 +92,7 @@ export class WaffleX extends BarX { export class WaffleY extends BarY { constructor(data, {unit = 1, gap = 1, round, ...options} = {}) { - super(data, {...options, stroke: "none"}); + super(data, options); this.unit = Math.max(0, unit); this.gap = +gap; this.round = maybeRound(round); @@ -116,7 +120,7 @@ export class WaffleY extends BarY { const ww = columns * cellsize; const wx = (bandwidth - ww) / 2; let rect = g.firstElementChild; - const y0 = scales.y(0) - gap; + const y0 = scales.y(0); const basePattern = document.createElementNS(namespaces.svg, "pattern"); basePattern.setAttribute("width", cellsize); basePattern.setAttribute("height", cellsize); @@ -131,13 +135,15 @@ export class WaffleY extends BarY { if (ry != null) basePatternRect.setAttribute("ry", ry); for (const i of index) { const x0 = +rect.getAttribute("x") + wx; - const fill = rect.getAttribute("fill"); + const fill = rect.getAttribute("fill"); // TODO handle constant fill + const stroke = rect.getAttribute("stroke"); // TODO handle constant fill const patternId = getClipId(); // TODO lazy const pattern = g.insertBefore(basePattern.cloneNode(true), rect); const patternRect = pattern.firstChild; pattern.setAttribute("id", patternId); pattern.setAttribute("x", x0); - patternRect.setAttribute("fill", fill); + if (fill != null) patternRect.setAttribute("fill", fill); + if (stroke != null) patternRect.setAttribute("stroke", stroke); const path = document.createElementNS(namespaces.svg, "path"); for (const a of rect.attributes) { switch (a.name) { @@ -146,6 +152,7 @@ export class WaffleY extends BarY { case "width": case "height": case "fill": + case "stroke": continue; } path.setAttribute(a.name, a.value); @@ -156,6 +163,7 @@ export class WaffleY extends BarY { .map(([x, y]) => [x * cellsize + x0, y0 - y * cellsize]) .join("L")}Z` ); + if (stroke != null) path.setAttribute("stroke", `url(#${patternId})`); // TODO if necessary path.setAttribute("fill", `url(#${patternId})`); const nextRect = rect.nextElementSibling; rect.replaceWith(path); diff --git a/test/output/waffleShorthand.svg b/test/output/waffleShorthand.svg index 28a850c868..a9769ec74c 100644 --- a/test/output/waffleShorthand.svg +++ b/test/output/waffleShorthand.svg @@ -62,29 +62,29 @@ 5 - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + \ No newline at end of file diff --git a/test/output/waffleShorthandNegative.svg b/test/output/waffleShorthandNegative.svg index e0ddb0b166..b3d73e9cf3 100644 --- a/test/output/waffleShorthandNegative.svg +++ b/test/output/waffleShorthandNegative.svg @@ -62,29 +62,29 @@ 5 - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + \ No newline at end of file diff --git a/test/output/waffleStroke.svg b/test/output/waffleStroke.svg new file mode 100644 index 0000000000..b7d0bdc960 --- /dev/null +++ b/test/output/waffleStroke.svg @@ -0,0 +1,90 @@ + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + 45 + 50 + 55 + 60 + 65 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleStrokeNegative.svg b/test/output/waffleStrokeNegative.svg new file mode 100644 index 0000000000..d74dd9d5b2 --- /dev/null +++ b/test/output/waffleStrokeNegative.svg @@ -0,0 +1,90 @@ + + + + + −65 + −60 + −55 + −50 + −45 + −40 + −35 + −30 + −25 + −20 + −15 + −10 + −5 + 0 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleX.svg b/test/output/waffleX.svg index 8aa120d2c3..f82eb56525 100644 --- a/test/output/waffleX.svg +++ b/test/output/waffleX.svg @@ -46,26 +46,26 @@ 300 - + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleXStacked.svg b/test/output/waffleXStacked.svg index 4e235081b7..96c49f4028 100644 --- a/test/output/waffleXStacked.svg +++ b/test/output/waffleXStacked.svg @@ -38,26 +38,26 @@ 900 - + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg index b0739feb18..28e22d7e72 100644 --- a/test/output/waffleY.svg +++ b/test/output/waffleY.svg @@ -64,26 +64,26 @@ Elderly 65+ - + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleYGrouped.svg b/test/output/waffleYGrouped.svg index bf15d70cda..9a2bbea96e 100644 --- a/test/output/waffleYGrouped.svg +++ b/test/output/waffleYGrouped.svg @@ -105,118 +105,118 @@ wrestling - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + diff --git a/test/output/waffleYStacked.html b/test/output/waffleYStacked.html index c2ca6c5f30..4a6e5512a2 100644 --- a/test/output/waffleYStacked.html +++ b/test/output/waffleYStacked.html @@ -75,26 +75,26 @@ 900 - + - - + + - - + + - - + + - - + + - + diff --git a/test/plots/waffle.ts b/test/plots/waffle.ts index 12ce547efc..104881293e 100644 --- a/test/plots/waffle.ts +++ b/test/plots/waffle.ts @@ -19,6 +19,14 @@ export function waffleShorthandNegative() { return Plot.waffleY([-4, -9, -24, -46, -66, -7]).plot({x: {axis: "top"}}); } +export function waffleStroke() { + return Plot.waffleY([4.5, 9, 24, 46, 66, 7], {fill: "#eee", stroke: "red", gap: 0}).plot(); +} + +export function waffleStrokeNegative() { + return Plot.waffleY([-4.5, -9, -24, -46, -66, -7], {fill: "#eee", stroke: "red", gap: 0}).plot({x: {axis: "top"}}); +} + export function waffleX() { return Plot.plot({ marginLeft: 80, From efe5913f53437c369064282341efadb2e2347749 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Aug 2024 13:27:53 -0400 Subject: [PATCH 16/22] fix test snapshots --- src/marks/waffle.js | 6 +- src/style.js | 5 ++ test/output/waffleShorthand.svg | 24 ++--- test/output/waffleShorthandNegative.svg | 24 ++--- test/output/waffleStroke.svg | 24 ++--- test/output/waffleStrokeNegative.svg | 24 ++--- test/output/waffleX.svg | 20 ++--- test/output/waffleXStacked.svg | 20 ++--- test/output/waffleY.svg | 20 ++--- test/output/waffleYGrouped.svg | 112 ++++++++++++------------ test/output/waffleYStacked.html | 20 ++--- test/plot.js | 18 ++++ 12 files changed, 170 insertions(+), 147 deletions(-) diff --git a/src/marks/waffle.js b/src/marks/waffle.js index 88f4019df6..c1691ab6d6 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -1,6 +1,6 @@ import {extent, namespaces} from "d3"; import {hasXY, identity, indexOf} from "../options.js"; -import {getClipId} from "../style.js"; +import {getPatternId} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; @@ -53,7 +53,7 @@ export class WaffleX extends BarX { const y0 = +rect.getAttribute("y") + wy; const fill = rect.getAttribute("fill"); // TODO handle constant fill const stroke = rect.getAttribute("stroke"); // TODO handle constant fill - const patternId = getClipId(); // TODO lazy + const patternId = getPatternId(); // TODO reused shared patterns const pattern = g.insertBefore(basePattern.cloneNode(true), rect); const patternRect = pattern.firstChild; pattern.setAttribute("id", patternId); @@ -137,7 +137,7 @@ export class WaffleY extends BarY { const x0 = +rect.getAttribute("x") + wx; const fill = rect.getAttribute("fill"); // TODO handle constant fill const stroke = rect.getAttribute("stroke"); // TODO handle constant fill - const patternId = getClipId(); // TODO lazy + const patternId = getPatternId(); // TODO reused shared patterns const pattern = g.insertBefore(basePattern.cloneNode(true), rect); const patternRect = pattern.firstChild; pattern.setAttribute("id", patternId); 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/waffleShorthand.svg b/test/output/waffleShorthand.svg index a9769ec74c..55afd6b3af 100644 --- a/test/output/waffleShorthand.svg +++ b/test/output/waffleShorthand.svg @@ -62,29 +62,29 @@ 5 - + - - + + - - + + - - + + - - + + - - + + - + \ No newline at end of file diff --git a/test/output/waffleShorthandNegative.svg b/test/output/waffleShorthandNegative.svg index b3d73e9cf3..9c9c10677e 100644 --- a/test/output/waffleShorthandNegative.svg +++ b/test/output/waffleShorthandNegative.svg @@ -62,29 +62,29 @@ 5 - + - - + + - - + + - - + + - - + + - - + + - + \ No newline at end of file diff --git a/test/output/waffleStroke.svg b/test/output/waffleStroke.svg index b7d0bdc960..b078c85c80 100644 --- a/test/output/waffleStroke.svg +++ b/test/output/waffleStroke.svg @@ -62,29 +62,29 @@ 5 - + - - + + - - + + - - + + - - + + - - + + - + \ No newline at end of file diff --git a/test/output/waffleStrokeNegative.svg b/test/output/waffleStrokeNegative.svg index d74dd9d5b2..bc29837dd9 100644 --- a/test/output/waffleStrokeNegative.svg +++ b/test/output/waffleStrokeNegative.svg @@ -62,29 +62,29 @@ 5 - + - - + + - - + + - - + + - - + + - - + + - + \ No newline at end of file diff --git a/test/output/waffleX.svg b/test/output/waffleX.svg index f82eb56525..6328c62225 100644 --- a/test/output/waffleX.svg +++ b/test/output/waffleX.svg @@ -46,26 +46,26 @@ 300 - + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleXStacked.svg b/test/output/waffleXStacked.svg index 96c49f4028..0acb27e81c 100644 --- a/test/output/waffleXStacked.svg +++ b/test/output/waffleXStacked.svg @@ -38,26 +38,26 @@ 900 - + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg index 28e22d7e72..ad4e74b86e 100644 --- a/test/output/waffleY.svg +++ b/test/output/waffleY.svg @@ -64,26 +64,26 @@ Elderly 65+ - + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleYGrouped.svg b/test/output/waffleYGrouped.svg index 9a2bbea96e..c07212356d 100644 --- a/test/output/waffleYGrouped.svg +++ b/test/output/waffleYGrouped.svg @@ -105,118 +105,118 @@ wrestling - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + diff --git a/test/output/waffleYStacked.html b/test/output/waffleYStacked.html index 4a6e5512a2..373b21c96d 100644 --- a/test/output/waffleYStacked.html +++ b/test/output/waffleYStacked.html @@ -75,26 +75,26 @@ 900 - + - - + + - - + + - - + + - - + + - + 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) { From 1274ff43ff01b7c7173d423d36b790d2c4e62c29 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Aug 2024 16:27:46 -0400 Subject: [PATCH 17/22] DRY waffle --- src/marks/waffle.js | 116 ++++++++++---------------------------------- 1 file changed, 25 insertions(+), 91 deletions(-) diff --git a/src/marks/waffle.js b/src/marks/waffle.js index c1691ab6d6..abb1699b74 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -1,4 +1,5 @@ import {extent, namespaces} from "d3"; +import {composeRender} from "../mark.js"; import {hasXY, identity, indexOf} from "../options.js"; import {getPatternId} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; @@ -7,106 +8,35 @@ import {maybeStackX, maybeStackY} from "../transforms/stack.js"; import {BarX, BarY} from "./bar.js"; export class WaffleX extends BarX { - constructor(data, {unit = 1, gap = 1, round, ...options} = {}) { - super(data, options); + constructor(data, {unit = 1, gap = 1, round, render, ...options} = {}) { + super(data, {...options, render: composeRender(render, waffleRender("x"))}); this.unit = Math.max(0, unit); this.gap = +gap; this.round = maybeRound(round); } - render(index, scales, channels, dimensions, context) { - const {unit, gap, rx, ry, round} = this; - const {document} = context; - const g = super.render(index, scales, channels, dimensions, context); - - // We might not use all the available bandwidth if the cells don’t fit evenly. - const bandwidth = this._height(scales, channels, dimensions); - - // The length of a unit along x in pixels. - const scale = unit * scaleof(scales.scales.x); - - // The number of cells on each row of the waffle. - const columns = Math.max(1, Math.floor(Math.sqrt(bandwidth / scale))); - - // The outer size of each square cell, in pixels, including the gap. - const cellsize = scale * columns; - - // TODO insets? - const X1 = channels.channels.x1.value; - const X2 = channels.channels.x2.value; - const ww = columns * cellsize; - const wy = (bandwidth - ww) / 2; - let rect = g.firstElementChild; - const x0 = scales.x(0); - const basePattern = document.createElementNS(namespaces.svg, "pattern"); - basePattern.setAttribute("width", cellsize); - basePattern.setAttribute("height", cellsize); - basePattern.setAttribute("patternUnits", "userSpaceOnUse"); - basePattern.setAttribute("x", x0); - const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect")); - basePatternRect.setAttribute("x", gap / 2); - basePatternRect.setAttribute("y", gap / 2); - basePatternRect.setAttribute("width", cellsize - gap); - basePatternRect.setAttribute("height", cellsize - gap); - if (rx != null) basePatternRect.setAttribute("rx", rx); - if (ry != null) basePatternRect.setAttribute("ry", ry); - for (const i of index) { - const y0 = +rect.getAttribute("y") + wy; - const fill = rect.getAttribute("fill"); // TODO handle constant fill - const stroke = rect.getAttribute("stroke"); // TODO handle constant fill - const patternId = getPatternId(); // TODO reused shared patterns - const pattern = g.insertBefore(basePattern.cloneNode(true), rect); - const patternRect = pattern.firstChild; - pattern.setAttribute("id", patternId); - pattern.setAttribute("y", y0); - if (fill != null) patternRect.setAttribute("fill", fill); - if (stroke != null) patternRect.setAttribute("stroke", stroke); - const path = document.createElementNS(namespaces.svg, "path"); - for (const a of rect.attributes) { - switch (a.name) { - case "x": - case "y": - case "width": - case "height": - case "fill": - case "stroke": - continue; - } - path.setAttribute(a.name, a.value); - } - path.setAttribute( - "d", - `M${wafflePoints(round(X1[i] / unit), round(X2[i] / unit), columns) - .map(([y, x]) => [x * cellsize + x0, y0 + y * cellsize]) - .join("L")}Z` - ); - if (stroke != null) path.setAttribute("stroke", `url(#${patternId})`); // TODO if necessary - path.setAttribute("fill", `url(#${patternId})`); - const nextRect = rect.nextElementSibling; - rect.replaceWith(path); - rect = nextRect; - } - - return g; - } } export class WaffleY extends BarY { - constructor(data, {unit = 1, gap = 1, round, ...options} = {}) { - super(data, options); + constructor(data, {unit = 1, gap = 1, round, render, ...options} = {}) { + super(data, {...options, render: composeRender(render, waffleRender("y"))}); this.unit = Math.max(0, unit); this.gap = +gap; this.round = maybeRound(round); } - render(index, scales, channels, dimensions, context) { +} + +function waffleRender(y) { + const x = y === "y" ? "x" : "y"; + return function (index, scales, values, dimensions, context, next) { const {unit, gap, rx, ry, round} = this; const {document} = context; - const g = super.render(index, scales, channels, dimensions, context); + const g = next(index, scales, values, dimensions, context); // We might not use all the available bandwidth if the cells don’t fit evenly. - const bandwidth = this._width(scales, channels, dimensions); + const bandwidth = this[y === "y" ? "_width" : "_height"](scales, values, dimensions); // The length of a unit along y in pixels. - const scale = unit * scaleof(scales.scales.y); + 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(bandwidth / scale))); @@ -115,17 +45,17 @@ export class WaffleY extends BarY { const cellsize = scale * columns; // TODO insets? - const Y1 = channels.channels.y1.value; - const Y2 = channels.channels.y2.value; + const Y1 = values.channels[`${y}1`].value; + const Y2 = values.channels[`${y}2`].value; const ww = columns * cellsize; const wx = (bandwidth - ww) / 2; let rect = g.firstElementChild; - const y0 = scales.y(0); + const y0 = scales[y](0); const basePattern = document.createElementNS(namespaces.svg, "pattern"); basePattern.setAttribute("width", cellsize); basePattern.setAttribute("height", cellsize); basePattern.setAttribute("patternUnits", "userSpaceOnUse"); - basePattern.setAttribute("y", y0); + basePattern.setAttribute(y, y0); const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect")); basePatternRect.setAttribute("x", gap / 2); basePatternRect.setAttribute("y", gap / 2); @@ -134,14 +64,14 @@ export class WaffleY extends BarY { if (rx != null) basePatternRect.setAttribute("rx", rx); if (ry != null) basePatternRect.setAttribute("ry", ry); for (const i of index) { - const x0 = +rect.getAttribute("x") + wx; + const x0 = +rect.getAttribute(x) + wx; const fill = rect.getAttribute("fill"); // TODO handle constant fill const stroke = rect.getAttribute("stroke"); // TODO handle constant fill const patternId = getPatternId(); // TODO reused shared patterns const pattern = g.insertBefore(basePattern.cloneNode(true), rect); const patternRect = pattern.firstChild; pattern.setAttribute("id", patternId); - pattern.setAttribute("x", x0); + pattern.setAttribute(x, x0); if (fill != null) patternRect.setAttribute("fill", fill); if (stroke != null) patternRect.setAttribute("stroke", stroke); const path = document.createElementNS(namespaces.svg, "path"); @@ -160,7 +90,11 @@ export class WaffleY extends BarY { path.setAttribute( "d", `M${wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), columns) - .map(([x, y]) => [x * cellsize + x0, y0 - y * cellsize]) + .map( + y === "y" + ? ([x, y]) => [x * cellsize + x0, y0 - y * cellsize] + : ([x, y]) => [y * cellsize + y0, x0 + x * cellsize] + ) .join("L")}Z` ); if (stroke != null) path.setAttribute("stroke", `url(#${patternId})`); // TODO if necessary @@ -171,7 +105,7 @@ export class WaffleY extends BarY { } return g; - } + }; } // A waffle is a approximately rectangular shape, but may have one or two corner From 3462679a0dc43b96f2c06d9539407cd533a79d8a Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Aug 2024 17:17:10 -0400 Subject: [PATCH 18/22] optimize waffle rendering --- src/marks/bar.js | 14 +-- src/marks/waffle.js | 107 +++++++++++----------- test/output/waffleShorthand.svg | 26 +++--- test/output/waffleShorthandNegative.svg | 26 +++--- test/output/waffleStroke.svg | 26 +++--- test/output/waffleStrokeNegative.svg | 26 +++--- test/output/waffleX.svg | 22 ++--- test/output/waffleXStacked.svg | 22 ++--- test/output/waffleY.svg | 22 ++--- test/output/waffleYGrouped.svg | 114 ++++++++++++------------ test/output/waffleYStacked.html | 22 ++--- 11 files changed, 212 insertions(+), 215 deletions(-) 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.js b/src/marks/waffle.js index abb1699b74..9dcaee29b9 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -1,15 +1,21 @@ import {extent, namespaces} from "d3"; +import {create} from "../context.js"; import {composeRender} from "../mark.js"; import {hasXY, identity, indexOf} from "../options.js"; -import {getPatternId} from "../style.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"))}); + super(data, {...options, render: composeRender(render, waffleRender("x"))}, waffleDefaults); this.unit = Math.max(0, unit); this.gap = +gap; this.round = maybeRound(round); @@ -18,7 +24,7 @@ export class WaffleX extends BarX { export class WaffleY extends BarY { constructor(data, {unit = 1, gap = 1, round, render, ...options} = {}) { - super(data, {...options, render: composeRender(render, waffleRender("y"))}); + super(data, {...options, render: composeRender(render, waffleRender("y"))}, waffleDefaults); this.unit = Math.max(0, unit); this.gap = +gap; this.round = maybeRound(round); @@ -26,36 +32,37 @@ export class WaffleY extends BarY { } function waffleRender(y) { - const x = y === "y" ? "x" : "y"; - return function (index, scales, values, dimensions, context, next) { + return function (index, scales, values, dimensions, context) { const {unit, gap, rx, ry, round} = this; const {document} = context; - const g = next(index, scales, values, dimensions, 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 bandwidth = this[y === "y" ? "_width" : "_height"](scales, values, dimensions); + 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(bandwidth / scale))); + const columns = Math.max(1, Math.floor(Math.sqrt(barwidth / scale))); // The outer size of each square cell, in pixels, including the gap. const cellsize = scale * columns; // TODO insets? - const Y1 = values.channels[`${y}1`].value; - const Y2 = values.channels[`${y}2`].value; - const ww = columns * cellsize; - const wx = (bandwidth - ww) / 2; - let rect = g.firstElementChild; + const transform = y === "y" ? ([x, y]) => [x * cellsize, -y * cellsize] : ([x, y]) => [y * cellsize, x * cellsize]; + const tx = (barwidth - columns * cellsize) / 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", cellsize); basePattern.setAttribute("height", cellsize); basePattern.setAttribute("patternUnits", "userSpaceOnUse"); - basePattern.setAttribute(y, y0); const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect")); basePatternRect.setAttribute("x", gap / 2); basePatternRect.setAttribute("y", gap / 2); @@ -63,48 +70,38 @@ function waffleRender(y) { basePatternRect.setAttribute("height", cellsize - gap); if (rx != null) basePatternRect.setAttribute("rx", rx); if (ry != null) basePatternRect.setAttribute("ry", ry); - for (const i of index) { - const x0 = +rect.getAttribute(x) + wx; - const fill = rect.getAttribute("fill"); // TODO handle constant fill - const stroke = rect.getAttribute("stroke"); // TODO handle constant fill - const patternId = getPatternId(); // TODO reused shared patterns - const pattern = g.insertBefore(basePattern.cloneNode(true), rect); - const patternRect = pattern.firstChild; - pattern.setAttribute("id", patternId); - pattern.setAttribute(x, x0); - if (fill != null) patternRect.setAttribute("fill", fill); - if (stroke != null) patternRect.setAttribute("stroke", stroke); - const path = document.createElementNS(namespaces.svg, "path"); - for (const a of rect.attributes) { - switch (a.name) { - case "x": - case "y": - case "width": - case "height": - case "fill": - case "stroke": - continue; - } - path.setAttribute(a.name, a.value); - } - path.setAttribute( - "d", - `M${wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), columns) - .map( - y === "y" - ? ([x, y]) => [x * cellsize + x0, y0 - y * cellsize] - : ([x, y]) => [y * cellsize + y0, x0 + x * cellsize] + + 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` ) - .join("L")}Z` - ); - if (stroke != null) path.setAttribute("stroke", `url(#${patternId})`); // TODO if necessary - path.setAttribute("fill", `url(#${patternId})`); - const nextRect = rect.nextElementSibling; - rect.replaceWith(path); - rect = nextRect; - } - - return g; + .attr("fill", (i) => `url(#${patternId}-${i})`) + ) + .node(); }; } diff --git a/test/output/waffleShorthand.svg b/test/output/waffleShorthand.svg index 55afd6b3af..99c7672178 100644 --- a/test/output/waffleShorthand.svg +++ b/test/output/waffleShorthand.svg @@ -61,30 +61,30 @@ 4 5 - - + + - - + - - + - - + - - + - - + - + + + + + + \ No newline at end of file diff --git a/test/output/waffleShorthandNegative.svg b/test/output/waffleShorthandNegative.svg index 9c9c10677e..31ffd81ec5 100644 --- a/test/output/waffleShorthandNegative.svg +++ b/test/output/waffleShorthandNegative.svg @@ -61,30 +61,30 @@ 4 5 - - + + - - + - - + - - + - - + - - + - + + + + + + \ No newline at end of file diff --git a/test/output/waffleStroke.svg b/test/output/waffleStroke.svg index b078c85c80..a8a20006d6 100644 --- a/test/output/waffleStroke.svg +++ b/test/output/waffleStroke.svg @@ -61,30 +61,30 @@ 4 5 - - + + - - + - - + - - + - - + - - + - + + + + + + \ No newline at end of file diff --git a/test/output/waffleStrokeNegative.svg b/test/output/waffleStrokeNegative.svg index bc29837dd9..872fab54f9 100644 --- a/test/output/waffleStrokeNegative.svg +++ b/test/output/waffleStrokeNegative.svg @@ -61,30 +61,30 @@ 4 5 - - + + - - + - - + - - + - - + - - + - + + + + + + \ No newline at end of file diff --git a/test/output/waffleX.svg b/test/output/waffleX.svg index 6328c62225..a3a4da862a 100644 --- a/test/output/waffleX.svg +++ b/test/output/waffleX.svg @@ -45,27 +45,27 @@ 250 300 - - + + - - + - - + - - + - - + - + + + + + diff --git a/test/output/waffleXStacked.svg b/test/output/waffleXStacked.svg index 0acb27e81c..fc6c00f13a 100644 --- a/test/output/waffleXStacked.svg +++ b/test/output/waffleXStacked.svg @@ -37,27 +37,27 @@ 800 900 - - + + - - + - - + - - + - - + - + + + + + diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg index ad4e74b86e..3210850112 100644 --- a/test/output/waffleY.svg +++ b/test/output/waffleY.svg @@ -63,27 +63,27 @@ Adults 18+ Elderly 65+ - - + + - - + - - + - - + - - + - + + + + + diff --git a/test/output/waffleYGrouped.svg b/test/output/waffleYGrouped.svg index c07212356d..284be9edcf 100644 --- a/test/output/waffleYGrouped.svg +++ b/test/output/waffleYGrouped.svg @@ -104,119 +104,119 @@ weightlifting wrestling - - + + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/waffleYStacked.html b/test/output/waffleYStacked.html index 373b21c96d..45fe91b958 100644 --- a/test/output/waffleYStacked.html +++ b/test/output/waffleYStacked.html @@ -74,27 +74,27 @@ 800 900 - - + + - - + - - + - - + - - + - + + + + + From 96ae5686ac1c95760ffb071a8bedff4f8e86f38a Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Aug 2024 20:25:11 -0400 Subject: [PATCH 19/22] more robust waffle --- src/marks/waffle.js | 52 +++---- test/output/waffleRound.svg | 102 ++++++++++++++ test/output/waffleShorthand.svg | 104 ++++++++------ test/output/waffleShorthandNegative.svg | 90 ------------ test/output/waffleStroke.svg | 106 +++++++------- test/output/waffleStrokeMixed.svg | 109 +++++++++++++++ test/output/waffleStrokeNegative.svg | 177 +++++++++++++++++------- test/output/waffleStrokePositive.svg | 165 ++++++++++++++++++++++ test/output/waffleX.svg | 10 +- test/output/waffleXStacked.svg | 10 +- test/output/waffleY.svg | 10 +- test/output/waffleYGrouped.svg | 56 ++++---- test/output/waffleYStacked.html | 10 +- test/plots/waffle.ts | 160 ++++++++++++++++++++- 14 files changed, 849 insertions(+), 312 deletions(-) create mode 100644 test/output/waffleRound.svg delete mode 100644 test/output/waffleShorthandNegative.svg create mode 100644 test/output/waffleStrokeMixed.svg create mode 100644 test/output/waffleStrokePositive.svg diff --git a/src/marks/waffle.js b/src/marks/waffle.js index 9dcaee29b9..ab2b4957a3 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -100,6 +100,7 @@ function waffleRender(y) { .join("L")}Z` ) .attr("fill", (i) => `url(#${patternId}-${i})`) + .attr("stroke", this.stroke == null ? null : (i) => `url(#${patternId}-${i})`) ) .node(); }; @@ -143,19 +144,34 @@ function waffleRender(y) { // 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 [ - [floor(abs(i2) % columns), ceil(i2 / columns)], - [0, ceil(i2 / columns)], - [0, ceil(i1 / columns)], - [floor(abs(i1) % columns), ceil(i1 / columns)], - [floor(abs(i1) % columns), floor(i1 / columns) + (i1 % 1)], - [ceil(abs(i1) % columns), floor(i1 / columns) + (i1 % 1)], - [ceil(abs(i1) % columns), floor(i1 / columns)], - [columns, floor(i1 / columns)], - [columns, floor(i2 / columns)], - [ceil(abs(i2) % columns), floor(i2 / columns)], - [ceil(abs(i2) % columns), floor(i2 / columns) + (i2 % 1)], - [floor(abs(i2) % columns), floor(i2 / columns) + (i2 % 1)] + [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)] + ]) ]; } @@ -175,18 +191,6 @@ function spread(domain) { return max - min; } -function abs(x) { - return Math.abs(x); -} - -function ceil(x) { - return (x < 0 ? Math.floor : Math.ceil)(x); -} - -function floor(x) { - return (x < 0 ? Math.ceil : Math.floor)(x); -} - export function waffleX(data, options = {}) { if (!hasXY(options)) options = {...options, y: indexOf, x2: identity}; return new WaffleX(data, maybeStackX(maybeIntervalX(maybeIdentityX(options)))); 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 index 99c7672178..2c942bbba9 100644 --- a/test/output/waffleShorthand.svg +++ b/test/output/waffleShorthand.svg @@ -14,36 +14,22 @@ } - 0 - 5 - 10 - 15 - 20 - 25 - 30 - 35 - 40 - 45 - 50 - 55 - 60 - 65 + −60 + −40 + −20 + 0 + 20 + 40 + 60 - - + + - - + + - - + + - - + + - - + + - - + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/waffleShorthandNegative.svg b/test/output/waffleShorthandNegative.svg deleted file mode 100644 index 31ffd81ec5..0000000000 --- a/test/output/waffleShorthandNegative.svg +++ /dev/null @@ -1,90 +0,0 @@ - - - - - −65 - −60 - −55 - −50 - −45 - −40 - −35 - −30 - −25 - −20 - −15 - −10 - −5 - 0 - - - - 0 - 1 - 2 - 3 - 4 - 5 - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/waffleStroke.svg b/test/output/waffleStroke.svg index a8a20006d6..ff8bc9a54a 100644 --- a/test/output/waffleStroke.svg +++ b/test/output/waffleStroke.svg @@ -14,36 +14,22 @@ } - 0 - 5 - 10 - 15 - 20 - 25 - 30 - 35 - 40 - 45 - 50 - 55 - 60 - 65 + −60 + −40 + −20 + 0 + 20 + 40 + 60 - - - + + + - - + + - - + + - - + + - - + + - - + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 index 872fab54f9..8e03565a10 100644 --- a/test/output/waffleStrokeNegative.svg +++ b/test/output/waffleStrokeNegative.svg @@ -14,35 +14,29 @@ } - −65 - −60 - −55 - −50 - −45 - −40 - −35 - −30 - −25 - −20 - −15 - −10 - −5 + −10 + −9 + −8 + −7 + −6 + −5 + −4 + −3 + −2 + −1 0 - 0 - 1 - 2 - 3 - 4 - 5 + 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 index a3a4da862a..a605eb0ac4 100644 --- a/test/output/waffleX.svg +++ b/test/output/waffleX.svg @@ -61,11 +61,11 @@ - - - - - + + + + + diff --git a/test/output/waffleXStacked.svg b/test/output/waffleXStacked.svg index fc6c00f13a..cd308262ba 100644 --- a/test/output/waffleXStacked.svg +++ b/test/output/waffleXStacked.svg @@ -53,11 +53,11 @@ - - - - - + + + + + diff --git a/test/output/waffleY.svg b/test/output/waffleY.svg index 3210850112..2a971f484a 100644 --- a/test/output/waffleY.svg +++ b/test/output/waffleY.svg @@ -79,11 +79,11 @@ - - - - - + + + + + diff --git a/test/output/waffleYGrouped.svg b/test/output/waffleYGrouped.svg index 284be9edcf..0e9b4ec8f7 100644 --- a/test/output/waffleYGrouped.svg +++ b/test/output/waffleYGrouped.svg @@ -189,34 +189,34 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/waffleYStacked.html b/test/output/waffleYStacked.html index 45fe91b958..71656356ca 100644 --- a/test/output/waffleYStacked.html +++ b/test/output/waffleYStacked.html @@ -90,11 +90,11 @@ - - - - - + + + + + diff --git a/test/plots/waffle.ts b/test/plots/waffle.ts index 104881293e..eba4f4b20c 100644 --- a/test/plots/waffle.ts +++ b/test/plots/waffle.ts @@ -12,19 +12,167 @@ Elderly 65+,65+,12456`, ); export function waffleShorthand() { - return Plot.waffleY([4, 9, 24, 46, 66, 7]).plot(); + 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 waffleShorthandNegative() { - return Plot.waffleY([-4, -9, -24, -46, -66, -7]).plot({x: {axis: "top"}}); +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 waffleStroke() { - return Plot.waffleY([4.5, 9, 24, 46, 66, 7], {fill: "#eee", stroke: "red", gap: 0}).plot(); +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.waffleY([-4.5, -9, -24, -46, -66, -7], {fill: "#eee", stroke: "red", gap: 0}).plot({x: {axis: "top"}}); + 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() { From d128cff48a3488fc7dacf607c4a95c917333df19 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 2 Aug 2024 20:48:55 -0400 Subject: [PATCH 20/22] test polish --- test/output/waffleX.svg | 67 +++++++++++++++++--------------- test/output/waffleXStacked.svg | 27 +++++++------ test/output/waffleY.svg | 59 +++++++++++++++------------- test/output/waffleYStacked.html | 69 +++++++++++++++++---------------- test/plots/waffle.ts | 21 +++++++--- 5 files changed, 132 insertions(+), 111 deletions(-) diff --git a/test/output/waffleX.svg b/test/output/waffleX.svg index a605eb0ac4..7ab8f53e3b 100644 --- a/test/output/waffleX.svg +++ b/test/output/waffleX.svg @@ -14,36 +14,39 @@ } - Infants <1 - Children <11 - Teens 12-17 - Adults 18+ - Elderly 65+ + Infants <1 + Children <11 + Teens 12-17 + Adults 18+ + Elderly 65+ -