diff --git a/src/mark.d.ts b/src/mark.d.ts
index 4e5a60cbed..c40b1a1a56 100644
--- a/src/mark.d.ts
+++ b/src/mark.d.ts
@@ -1,6 +1,7 @@
import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
import type {Context} from "./context.js";
import type {Dimensions} from "./dimensions.js";
+import type {PointerOptions} from "./interactions/pointer.js";
import type {TipOptions} from "./marks/tip.js";
import type {plot} from "./plot.js";
import type {ScaleFunctions} from "./scales.js";
@@ -288,7 +289,7 @@ export interface MarkOptions {
title?: ChannelValue;
/** Whether to generate a tooltip for this mark, and any tip options. */
- tip?: boolean | TipPointer | (TipOptions & {pointer?: TipPointer});
+ tip?: boolean | TipPointer | (TipOptions & PointerOptions & {pointer?: TipPointer});
/**
* How to clip the mark; one of:
diff --git a/src/marks/waffle.js b/src/marks/waffle.js
index 4a95d38ec6..3b2b6d5774 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 {hasXY, identity, indexOf, isObject} 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";
@@ -14,8 +16,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);
+ constructor(data, {unit = 1, gap = 1, round, multiple, ...options} = {}) {
+ super(data, wafflePolygon("x", options), waffleDefaults);
this.unit = Math.max(0, unit);
this.gap = +gap;
this.round = maybeRound(round);
@@ -24,8 +26,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);
+ constructor(data, {unit = 1, gap = 1, round, multiple, ...options} = {}) {
+ super(data, wafflePolygon("y", options), waffleDefaults);
this.unit = Math.max(0, unit);
this.gap = +gap;
this.round = maybeRound(round);
@@ -33,17 +35,19 @@ export class WaffleY extends BarY {
}
}
-function waffleRender(y) {
- return function (index, scales, values, dimensions, context) {
- const {ariaLabel, href, title, ...visualValues} = values;
- const {unit, gap, rx, ry, round} = this;
- const {document} = context;
- const Y1 = values.channels[`${y}1`].value;
- const Y2 = values.channels[`${y}2`].value;
+function wafflePolygon(y, options) {
+ const x = y === "y" ? "x" : "y";
+ const y1 = `${y}1`;
+ const y2 = `${y}2`;
+ return initializer(waffleRender(options), function (data, facets, channels, scales, dimensions) {
+ const {round, unit} = this;
+ const Y1 = channels[y1].value;
+ const Y2 = channels[y2].value;
// We might not use all the available bandwidth if the cells don’t fit evenly.
- const barwidth = this[y === "y" ? "_width" : "_height"](scales, values, dimensions);
- const barx = this[y === "y" ? "_x" : "_y"](scales, values, dimensions);
+ const xy = valueObject({...(x in channels && {[x]: channels[x]}), [y1]: channels[y1], [y2]: channels[y2]}, scales);
+ const barwidth = this[y === "y" ? "_width" : "_height"](scales, xy, dimensions);
+ const barx = this[y === "y" ? "_x" : "_y"](scales, xy, dimensions);
// The length of a unit along y in pixels.
const scale = unit * scaleof(scales.scales[y]);
@@ -55,63 +59,98 @@ 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);
- // Create a base pattern with shared attributes for cloning.
- const patternId = getPatternId();
- const basePattern = document.createElementNS(namespaces.svg, "pattern");
- basePattern.setAttribute("width", y === "y" ? cx : cy);
- basePattern.setAttribute("height", y === "y" ? cy : cx);
- basePattern.setAttribute("patternUnits", "userSpaceOnUse");
- const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect"));
- basePatternRect.setAttribute("x", gap / 2);
- basePatternRect.setAttribute("y", gap / 2);
- basePatternRect.setAttribute("width", (y === "y" ? cx : cy) - gap);
- basePatternRect.setAttribute("height", (y === "y" ? cy : cx) - gap);
- if (rx != null) basePatternRect.setAttribute("rx", rx);
- if (ry != null) basePatternRect.setAttribute("ry", ry);
-
- return create("svg:g", context)
- .call(applyIndirectStyles, this, dimensions, context)
- .call(this._transform, this, scales)
- .call((g) =>
- g
- .selectAll()
- .data(index)
- .enter()
- .append(() => basePattern.cloneNode(true))
- .attr("id", (i) => `${patternId}-${i}`)
- .select("rect")
- .call(applyDirectStyles, this)
- .call(applyChannelStyles, this, visualValues)
- )
- .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), multiple)
- .map(transform)
- .join("L")}Z`
- )
- .attr("fill", (i) => `url(#${patternId}-${i})`)
- .attr("stroke", this.stroke == null ? null : (i) => `url(#${patternId}-${i})`)
- .call(applyChannelStyles, this, {ariaLabel, href, title})
- )
- .node();
+ // 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(); // extract the transformed centroid
+ X[i] = c[ix] + mx(i);
+ Y[i] = c[iy] + y0;
+ }
+
+ return {
+ channels: {
+ polygon: {value: P, source: null, filter: null},
+ [`c${x}`]: {value: [cx, x0], source: null, filter: null},
+ [`c${y}`]: {value: [cy, y0], source: null, filter: null},
+ [x]: {value: X, scale: null, source: null},
+ [y1]: {value: Y, scale: null, source: channels[y1]},
+ [y2]: {value: Y, scale: null, source: channels[y2]}
+ }
+ };
+ });
+}
+
+function waffleRender({render, ...options}) {
+ return {
+ ...options,
+ render: composeRender(render, function (index, scales, values, dimensions, context) {
+ const {gap, rx, ry} = this;
+ const {channels, ariaLabel, href, title, ...visualValues} = values;
+ const {document} = context;
+ const polygon = channels.polygon.value;
+ const [cx, x0] = channels.cx.value;
+ const [cy, y0] = channels.cy.value;
+
+ // Create a base pattern with shared attributes for cloning.
+ const patternId = getPatternId();
+ const basePattern = document.createElementNS(namespaces.svg, "pattern");
+ basePattern.setAttribute("width", cx);
+ basePattern.setAttribute("height", cy);
+ basePattern.setAttribute("patternUnits", "userSpaceOnUse");
+ const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect"));
+ basePatternRect.setAttribute("x", gap / 2);
+ basePatternRect.setAttribute("y", gap / 2);
+ basePatternRect.setAttribute("width", cx - gap);
+ basePatternRect.setAttribute("height", cy - gap);
+ if (rx != null) basePatternRect.setAttribute("rx", rx);
+ if (ry != null) basePatternRect.setAttribute("ry", ry);
+
+ return create("svg:g", context)
+ .call(applyIndirectStyles, this, dimensions, context)
+ .call(this._transform, this, scales)
+ .call((g) =>
+ g
+ .selectAll()
+ .data(index)
+ .enter()
+ .append(() => basePattern.cloneNode(true))
+ .attr("id", (i) => `${patternId}-${i}`)
+ .select("rect")
+ .call(applyDirectStyles, this)
+ .call(applyChannelStyles, this, visualValues)
+ )
+ .call((g) =>
+ g
+ .selectAll()
+ .data(index)
+ .enter()
+ .append("path")
+ .attr("transform", template`translate(${x0},${y0})`)
+ .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})`)
+ .call(applyChannelStyles, this, {ariaLabel, href, title})
+ )
+ .node();
+ })
};
}
-// A waffle is a approximately rectangular shape, but may have one or two corner
+// A waffle is approximately a 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:
@@ -148,14 +187,11 @@ 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
- return wafflePoints(i1 + k * columns, i2 + k * columns, columns).map(([x, y]) => [x, y - k]);
- }
- if (i2 < i1) {
- return wafflePoints(i2, i1, columns);
- }
+ if (i2 < i1) return wafflePoints(i2, i1, columns); // ensure i1 <= i2
+ if (i1 < 0) return wafflePointsOffset(i1, i2, columns, Math.ceil(-Math.min(i1, i2) / columns)); // ensure i1 >= 0
const x1f = Math.floor(i1 % columns);
const x1c = Math.ceil(i1 % columns);
const x2f = Math.floor(i2 % columns);
@@ -177,9 +213,49 @@ function wafflePoints(i1, i2, columns) {
points.push([x2f, y2c]);
if (y2c > y1c) points.push([0, y2c]);
}
+ points.push(waffleCentroid(i1, i2, columns));
return points;
}
+function wafflePointsOffset(i1, i2, columns, k) {
+ return wafflePoints(i1 + k * columns, i2 + k * columns, columns).map(([x, y]) => [x, y - k]);
+}
+
+function waffleCentroid(i1, i2, columns) {
+ const r = Math.floor(i2 / columns) - Math.floor(i1 / columns);
+ return r === 0
+ ? // Single row
+ waffleRowCentroid(i1, i2, columns)
+ : r === 1
+ ? // Two incomplete rows; use the midpoint of their overlap if any, otherwise the larger row
+ 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)
+ ? waffleRowCentroid(i2 - (i2 % columns), i2, columns)
+ : waffleRowCentroid(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 waffleRowCentroid(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 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 the 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;
@@ -200,12 +276,28 @@ 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: waffleTip(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: waffleTip(tip), ...maybeStackY(maybeIntervalY(maybeIdentityY(options)))});
+}
+
+/**
+ * Waffle tips behave a bit unpredictably because we they are driven by the
+ * waffle centroid; you could be hovering over a waffle segment, but more than
+ * 40px away from its centroid, or closer to the centroid of another segment.
+ * We’d rather show a tip, even if it’s the “wrong” one, so we increase the
+ * default maxRadius to Infinity. The “right” way to fix this would be to use
+ * signed distance to the waffle geometry rather than the centroid.
+ */
+function waffleTip(tip) {
+ return tip === true
+ ? {maxRadius: Infinity}
+ : isObject(tip) && tip.maxRadius === undefined
+ ? {...tip, maxRadius: Infinity}
+ : undefined;
}
diff --git a/test/output/wafflePointer.svg b/test/output/wafflePointer.svg
new file mode 100644
index 0000000000..d20a88f20c
--- /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..7c587be531
--- /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..2d5277a659
--- /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..4c35675277
--- /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..7b1ec373e7
--- /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..7923ce8524
--- /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..41a47d10d2
--- /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..8fb0bb719b
--- /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..72ca0297f9
--- /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 d5fe683bdf..8212911b19 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
@@ -247,6 +248,101 @@ export async function waffleYGrouped() {
});
}
+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 waffleHref() {
return Plot.plot({
inset: 10,
@@ -265,6 +361,24 @@ export function waffleHref() {
});
}
+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})
+ ]
+ });
+}
+
export function waffleShapes() {
const k = 10;
let offset = 0;