diff --git a/src/marks/waffle.js b/src/marks/waffle.js
index c9d8771d21..c6eb4acc30 100644
--- a/src/marks/waffle.js
+++ b/src/marks/waffle.js
@@ -1,9 +1,11 @@
import {extent, namespaces} from "d3";
+import {valueObject} from "../channel.js";
import {create} from "../context.js";
import {composeRender} from "../mark.js";
import {hasXY, identity, indexOf} from "../options.js";
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, getPatternId} from "../style.js";
import {template} from "../template.js";
+import {initializer} from "../transforms/basic.js";
import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js";
import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js";
import {maybeStackX, maybeStackY} from "../transforms/stack.js";
@@ -15,7 +17,8 @@ const waffleDefaults = {
export class WaffleX extends BarX {
constructor(data, {unit = 1, gap = 1, round, render, multiple, ...options} = {}) {
- super(data, {...options, render: composeRender(render, waffleRender("x"))}, waffleDefaults);
+ options = initializer({...options, render: composeRender(render, waffleRender("x"))}, waffleInitializer("x"));
+ super(data, options, waffleDefaults);
this.unit = Math.max(0, unit);
this.gap = +gap;
this.round = maybeRound(round);
@@ -25,7 +28,8 @@ export class WaffleX extends BarX {
export class WaffleY extends BarY {
constructor(data, {unit = 1, gap = 1, round, render, multiple, ...options} = {}) {
- super(data, {...options, render: composeRender(render, waffleRender("y"))}, waffleDefaults);
+ options = initializer({...options, render: composeRender(render, waffleRender("y"))}, waffleInitializer("y"));
+ super(data, options, waffleDefaults);
this.unit = Math.max(0, unit);
this.gap = +gap;
this.round = maybeRound(round);
@@ -33,10 +37,11 @@ export class WaffleY extends BarY {
}
}
-function waffleRender(y) {
- return function (index, scales, values, dimensions, context) {
- const {unit, gap, rx, ry, round} = this;
- const {document} = context;
+function waffleInitializer(y) {
+ return function (data, facets, channels, scales, dimensions) {
+ const {round, unit} = this;
+
+ const values = valueObject(channels, scales);
const Y1 = values.channels[`${y}1`].value;
const Y2 = values.channels[`${y}2`].value;
@@ -54,12 +59,50 @@ function waffleRender(y) {
const cx = Math.min(barwidth / multiple, scale * multiple);
const cy = scale * multiple;
- // TODO insets?
- const transform = y === "y" ? ([x, y]) => [x * cx, -y * cy] : ([x, y]) => [y * cy, x * cx];
+ // The reference position.
const tx = (barwidth - multiple * cx) / 2;
const x0 = typeof barx === "function" ? (i) => barx(i) + tx : barx + tx;
const y0 = scales[y](0);
+ // TODO insets?
+ const transform = y === "y" ? ([x, y]) => [x * cx, -y * cy] : ([x, y]) => [y * cy, x * cx];
+ const mx = typeof x0 === "function" ? (i) => x0(i) - barwidth / 2 : () => x0;
+ const [ix, iy] = y === "y" ? [0, 1] : [1, 0];
+
+ const n = Y2.length;
+ const P = new Array(n);
+ const X = new Float64Array(n);
+ const Y = new Float64Array(n);
+
+ for (let i = 0; i < n; ++i) {
+ P[i] = wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), multiple).map(transform);
+ const c = P[i].pop();
+ X[i] = c[ix] + mx(i);
+ Y[i] = c[iy] + y0;
+ }
+
+ this.cx = cx;
+ this.cy = cy;
+ this.x0 = x0;
+ this.y0 = y0;
+
+ return {
+ channels: {
+ polygon: {value: P, source: null},
+ [y === "y" ? "x" : "y"]: {value: X, scale: null, source: null},
+ [`${y}1`]: {value: Y, scale: null, source: channels[`${y}1`]},
+ [`${y}2`]: {value: Y, scale: null, source: channels[`${y}2`]}
+ }
+ };
+ };
+}
+
+function waffleRender(y) {
+ return function (index, scales, values, dimensions, context) {
+ const {gap, cx, cy, rx, ry, x0, y0} = this;
+ const {document} = context;
+ const polygon = values.channels.polygon.value;
+
// Create a base pattern with shared attributes for cloning.
const patternId = getPatternId();
const basePattern = document.createElementNS(namespaces.svg, "pattern");
@@ -95,13 +138,7 @@ function waffleRender(y) {
.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), multiple)
- .map(transform)
- .join("L")}Z`
- )
+ .attr("d", (i) => `M${polygon[i].join("L")}Z`)
.attr("fill", (i) => `url(#${patternId}-${i})`)
.attr("stroke", this.stroke == null ? null : (i) => `url(#${patternId}-${i})`)
)
@@ -146,6 +183,8 @@ function waffleRender(y) {
// 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.
+//
+// The last point describes the centroid (used for pointing)
function wafflePoints(i1, i2, columns) {
if (i1 < 0 || i2 < 0) {
const k = Math.ceil(-Math.min(i1, i2) / columns); // shift negative to positive
@@ -174,10 +213,43 @@ function wafflePoints(i1, i2, columns) {
: [
[Math.floor(i2 % columns), Math.ceil(i2 / columns)],
[0, Math.ceil(i2 / columns)]
- ])
+ ]),
+ centroid(i1, i2, columns)
];
}
+function centroid(i1, i2, columns) {
+ const r = Math.floor(i2 / columns) - Math.floor(i1 / columns);
+ return r === 0 // Single row
+ ? singleRowCentroid(i1, i2, columns)
+ : // Two incomplete rows, use the midpoint of their overlap if they do, otherwise use the largest
+ r === 1
+ ? Math.floor(i2 % columns) > Math.ceil(i1 % columns)
+ ? [(Math.floor(i2 % columns) + Math.ceil(i1 % columns)) / 2, Math.floor(i2 / columns)]
+ : i2 % columns > columns - (i1 % columns)
+ ? singleRowCentroid(i2 - (i2 % columns), i2, columns)
+ : singleRowCentroid(i1, columns * Math.ceil(i1 / columns), columns)
+ : // At least one full row, take the midpoint of all the rows that include the middle
+ [columns / 2, (Math.round(i1 / columns) + Math.round(i2 / columns)) / 2];
+}
+
+function singleRowCentroid(i1, i2, columns) {
+ const c = Math.floor(i2) - Math.floor(i1);
+ return c === 0 // Single cell
+ ? [Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (((i1 + i2) / 2) % 1)]
+ : c === 1 // Two incomplete cells, use the overlap if it is large enough, otherwise use the largest
+ ? (i2 % 1) - (i1 % 1) > 0.5
+ ? [Math.ceil(i1 % columns), Math.floor(i2 / columns) + ((i1 % 1) + (i2 % 1)) / 2]
+ : i2 % 1 > 1 - (i1 % 1)
+ ? [Math.floor(i2 % columns) + 0.5, Math.floor(i2 / columns) + (i2 % 1) / 2]
+ : [Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (1 + (i1 % 1)) / 2]
+ : // At least one full cell, take their midpoint
+ [
+ Math.ceil(i1 % columns) + Math.ceil(Math.floor(i2) - Math.ceil(i1)) / 2,
+ Math.floor(i1 / columns) + (i2 >= 1 + i1 ? 0.5 : ((i1 + i2) / 2) % 1)
+ ];
+}
+
function maybeRound(round) {
if (round === undefined || round === false) return Number;
if (round === true) return Math.round;
@@ -198,12 +270,12 @@ function spread(domain) {
return max - min;
}
-export function waffleX(data, options = {}) {
+export function waffleX(data, {tip, ...options} = {}) {
if (!hasXY(options)) options = {...options, y: indexOf, x2: identity};
- return new WaffleX(data, maybeStackX(maybeIntervalX(maybeIdentityX(options))));
+ return new WaffleX(data, {tip, ...maybeStackX(maybeIntervalX(maybeIdentityX(options)))});
}
-export function waffleY(data, options = {}) {
+export function waffleY(data, {tip, ...options} = {}) {
if (!hasXY(options)) options = {...options, x: indexOf, y2: identity};
- return new WaffleY(data, maybeStackY(maybeIntervalY(maybeIdentityY(options))));
+ return new WaffleY(data, {tip, ...maybeStackY(maybeIntervalY(maybeIdentityY(options)))});
}
diff --git a/test/output/wafflePointer.svg b/test/output/wafflePointer.svg
new file mode 100644
index 0000000000..52bc316d01
--- /dev/null
+++ b/test/output/wafflePointer.svg
@@ -0,0 +1,450 @@
+
\ No newline at end of file
diff --git a/test/output/wafflePointerFractional.svg b/test/output/wafflePointerFractional.svg
new file mode 100644
index 0000000000..fd12792d0c
--- /dev/null
+++ b/test/output/wafflePointerFractional.svg
@@ -0,0 +1,136 @@
+
\ No newline at end of file
diff --git a/test/output/waffleTip.svg b/test/output/waffleTip.svg
new file mode 100644
index 0000000000..c9925a71f1
--- /dev/null
+++ b/test/output/waffleTip.svg
@@ -0,0 +1,67 @@
+
\ No newline at end of file
diff --git a/test/output/waffleTipFacet.svg b/test/output/waffleTipFacet.svg
new file mode 100644
index 0000000000..83e2abf8c8
--- /dev/null
+++ b/test/output/waffleTipFacet.svg
@@ -0,0 +1,2084 @@
+
\ No newline at end of file
diff --git a/test/output/waffleTipFacetX.svg b/test/output/waffleTipFacetX.svg
new file mode 100644
index 0000000000..2fb0a28a3d
--- /dev/null
+++ b/test/output/waffleTipFacetX.svg
@@ -0,0 +1,2080 @@
+
\ No newline at end of file
diff --git a/test/output/waffleTipFacetXY.svg b/test/output/waffleTipFacetXY.svg
new file mode 100644
index 0000000000..a7dda41ded
--- /dev/null
+++ b/test/output/waffleTipFacetXY.svg
@@ -0,0 +1,2085 @@
+
\ No newline at end of file
diff --git a/test/output/waffleTipUnit.svg b/test/output/waffleTipUnit.svg
new file mode 100644
index 0000000000..2b8dfcc906
--- /dev/null
+++ b/test/output/waffleTipUnit.svg
@@ -0,0 +1,447 @@
+
\ No newline at end of file
diff --git a/test/output/waffleTipUnitX.svg b/test/output/waffleTipUnitX.svg
new file mode 100644
index 0000000000..e5e4a33dcc
--- /dev/null
+++ b/test/output/waffleTipUnitX.svg
@@ -0,0 +1,447 @@
+
\ No newline at end of file
diff --git a/test/output/waffleTipX.svg b/test/output/waffleTipX.svg
new file mode 100644
index 0000000000..4940dfa48b
--- /dev/null
+++ b/test/output/waffleTipX.svg
@@ -0,0 +1,70 @@
+
\ No newline at end of file
diff --git a/test/plots/waffle.ts b/test/plots/waffle.ts
index 455efd3a4a..c30f69cc27 100644
--- a/test/plots/waffle.ts
+++ b/test/plots/waffle.ts
@@ -1,5 +1,6 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
+import {svg} from "htl";
const demographics = d3.csvParse(
`group,label,freq
@@ -246,3 +247,115 @@ export async function waffleYGrouped() {
marks: [Plot.waffleY(athletes, Plot.groupX({y: "count"}, {x: "sport", unit: 10})), Plot.ruleY([0])]
});
}
+
+export function wafflePointer() {
+ const random = d3.randomLcg(42);
+ const data = Array.from({length: 100}, (_, i) => ({x: i % 3, fill: random()}));
+ return Plot.plot({
+ y: {inset: 12},
+ marks: [
+ Plot.waffleY(data, {x: "x", y: 1, fill: "#888"}),
+ Plot.waffleY(data, Plot.pointer({x: "x", y: 1, fill: "fill"}))
+ ]
+ });
+}
+
+export function wafflePointerFractional() {
+ const values = [0.51, 0.99, 0.5, 6, 0.3, 1.6, 9.1, 2, 18, 6, 0.5, 2.5, 46, 34, 20, 7, 0.5, 0.1, 0, 2.5, 1, 0.1, 0.8];
+ const multiple = 16;
+ return Plot.plot({
+ axis: null,
+ y: {insetTop: 12},
+ color: {scheme: "Dark2"},
+ marks: [
+ Plot.waffleY(values, {
+ x: null,
+ multiple,
+ fill: (d, i) => i % 7,
+ tip: true
+ }),
+ Plot.waffleY(values, {
+ x: null,
+ multiple,
+ // eslint-disable-next-line
+ render: (index, scales, values, dimensions, context, next) => {
+ const format = (d: number) => +d.toFixed(2);
+ const y1 = (values.channels.y1 as any).source.value;
+ const y2 = (values.channels.y2 as any).source.value;
+ return svg`${Array.from(
+ index,
+ (i) =>
+ svg`${format(y2[i] - y1[i])}`
+ )}`;
+ }
+ })
+ ]
+ });
+}
+export function waffleTip() {
+ return Plot.plot({
+ color: {type: "sqrt", scheme: "spectral"},
+ y: {inset: 12},
+ marks: [Plot.waffleY([1, 4, 9, 24, 46, 66, 7], {x: null, fill: Plot.identity, tip: true})]
+ });
+}
+
+export function waffleTipUnit() {
+ return Plot.plot({
+ y: {inset: 12},
+ marks: [Plot.waffleY({length: 100}, {x: (d, i) => i % 3, y: 1, fill: d3.randomLcg(42), tip: true})]
+ });
+}
+
+export function waffleTipFacet() {
+ return Plot.plot({
+ marks: [
+ Plot.waffleY({length: 500}, {x: (d, i) => i % 3, fx: (d, i) => i % 2, y: 1, fill: d3.randomLcg(42), tip: true})
+ ]
+ });
+}
+
+export function waffleTipX() {
+ return Plot.plot({
+ style: {overflow: "visible"},
+ color: {type: "sqrt", scheme: "spectral"},
+ x: {label: "quantity"},
+ y: {inset: 12},
+ marks: [Plot.waffleX([1, 4, 9, 24, 46, 66, 7], {y: null, fill: Plot.identity, tip: true})]
+ });
+}
+
+export function waffleTipUnitX() {
+ return Plot.plot({
+ height: 300,
+ y: {inset: 12},
+ marks: [
+ Plot.waffleX(
+ {length: 100},
+ {multiple: 5, y: (d, i) => i % 3, x: 1, fill: d3.randomLcg(42), tip: {format: {x: false}}}
+ )
+ ]
+ });
+}
+
+export function waffleTipFacetX() {
+ return Plot.plot({
+ height: 500,
+ marks: [
+ Plot.waffleX({length: 500}, {y: (d, i) => i % 3, fx: (d, i) => i % 2, x: 1, fill: d3.randomLcg(42), tip: true})
+ ]
+ });
+}
+
+export function waffleTipFacetXY() {
+ return Plot.plot({
+ height: 600,
+ marks: [
+ Plot.waffleX({length: 500}, {fx: (d, i) => i % 3, fy: (d, i) => i % 2, x: 1, fill: d3.randomLcg(42), tip: true})
+ ]
+ });
+}