Skip to content

Commit eb85419

Browse files
Filmbostock
andauthored
waffle tips (#2132)
* waffle tips * test faceting * simpler x * support waffle pointer, and simplify * crazy logic to compute the waffles' centroids * remove single hint * prettier * tidy * pass cell dimensions as channels * prettier * no filter on polygon * prettier * fix tip option type * fewer channels * default maxRadius to infinity --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent 6bea18e commit eb85419

12 files changed

+8149
-76
lines changed

src/mark.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
22
import type {Context} from "./context.js";
33
import type {Dimensions} from "./dimensions.js";
4+
import type {PointerOptions} from "./interactions/pointer.js";
45
import type {TipOptions} from "./marks/tip.js";
56
import type {plot} from "./plot.js";
67
import type {ScaleFunctions} from "./scales.js";
@@ -288,7 +289,7 @@ export interface MarkOptions {
288289
title?: ChannelValue;
289290

290291
/** Whether to generate a tooltip for this mark, and any tip options. */
291-
tip?: boolean | TipPointer | (TipOptions & {pointer?: TipPointer});
292+
tip?: boolean | TipPointer | (TipOptions & PointerOptions & {pointer?: TipPointer});
292293

293294
/**
294295
* How to clip the mark; one of:

src/marks/waffle.js

Lines changed: 167 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {extent, namespaces} from "d3";
2+
import {valueObject} from "../channel.js";
23
import {create} from "../context.js";
34
import {composeRender} from "../mark.js";
4-
import {hasXY, identity, indexOf} from "../options.js";
5+
import {hasXY, identity, indexOf, isObject} from "../options.js";
56
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, getPatternId} from "../style.js";
67
import {template} from "../template.js";
8+
import {initializer} from "../transforms/basic.js";
79
import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js";
810
import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js";
911
import {maybeStackX, maybeStackY} from "../transforms/stack.js";
@@ -14,8 +16,8 @@ const waffleDefaults = {
1416
};
1517

1618
export class WaffleX extends BarX {
17-
constructor(data, {unit = 1, gap = 1, round, render, multiple, ...options} = {}) {
18-
super(data, {...options, render: composeRender(render, waffleRender("x"))}, waffleDefaults);
19+
constructor(data, {unit = 1, gap = 1, round, multiple, ...options} = {}) {
20+
super(data, wafflePolygon("x", options), waffleDefaults);
1921
this.unit = Math.max(0, unit);
2022
this.gap = +gap;
2123
this.round = maybeRound(round);
@@ -24,26 +26,28 @@ export class WaffleX extends BarX {
2426
}
2527

2628
export class WaffleY extends BarY {
27-
constructor(data, {unit = 1, gap = 1, round, render, multiple, ...options} = {}) {
28-
super(data, {...options, render: composeRender(render, waffleRender("y"))}, waffleDefaults);
29+
constructor(data, {unit = 1, gap = 1, round, multiple, ...options} = {}) {
30+
super(data, wafflePolygon("y", options), waffleDefaults);
2931
this.unit = Math.max(0, unit);
3032
this.gap = +gap;
3133
this.round = maybeRound(round);
3234
this.multiple = maybeMultiple(multiple);
3335
}
3436
}
3537

36-
function waffleRender(y) {
37-
return function (index, scales, values, dimensions, context) {
38-
const {ariaLabel, href, title, ...visualValues} = values;
39-
const {unit, gap, rx, ry, round} = this;
40-
const {document} = context;
41-
const Y1 = values.channels[`${y}1`].value;
42-
const Y2 = values.channels[`${y}2`].value;
38+
function wafflePolygon(y, options) {
39+
const x = y === "y" ? "x" : "y";
40+
const y1 = `${y}1`;
41+
const y2 = `${y}2`;
42+
return initializer(waffleRender(options), function (data, facets, channels, scales, dimensions) {
43+
const {round, unit} = this;
44+
const Y1 = channels[y1].value;
45+
const Y2 = channels[y2].value;
4346

4447
// We might not use all the available bandwidth if the cells don’t fit evenly.
45-
const barwidth = this[y === "y" ? "_width" : "_height"](scales, values, dimensions);
46-
const barx = this[y === "y" ? "_x" : "_y"](scales, values, dimensions);
48+
const xy = valueObject({...(x in channels && {[x]: channels[x]}), [y1]: channels[y1], [y2]: channels[y2]}, scales);
49+
const barwidth = this[y === "y" ? "_width" : "_height"](scales, xy, dimensions);
50+
const barx = this[y === "y" ? "_x" : "_y"](scales, xy, dimensions);
4751

4852
// The length of a unit along y in pixels.
4953
const scale = unit * scaleof(scales.scales[y]);
@@ -55,63 +59,98 @@ function waffleRender(y) {
5559
const cx = Math.min(barwidth / multiple, scale * multiple);
5660
const cy = scale * multiple;
5761

58-
// TODO insets?
59-
const transform = y === "y" ? ([x, y]) => [x * cx, -y * cy] : ([x, y]) => [y * cy, x * cx];
62+
// The reference position.
6063
const tx = (barwidth - multiple * cx) / 2;
6164
const x0 = typeof barx === "function" ? (i) => barx(i) + tx : barx + tx;
6265
const y0 = scales[y](0);
6366

64-
// Create a base pattern with shared attributes for cloning.
65-
const patternId = getPatternId();
66-
const basePattern = document.createElementNS(namespaces.svg, "pattern");
67-
basePattern.setAttribute("width", y === "y" ? cx : cy);
68-
basePattern.setAttribute("height", y === "y" ? cy : cx);
69-
basePattern.setAttribute("patternUnits", "userSpaceOnUse");
70-
const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect"));
71-
basePatternRect.setAttribute("x", gap / 2);
72-
basePatternRect.setAttribute("y", gap / 2);
73-
basePatternRect.setAttribute("width", (y === "y" ? cx : cy) - gap);
74-
basePatternRect.setAttribute("height", (y === "y" ? cy : cx) - gap);
75-
if (rx != null) basePatternRect.setAttribute("rx", rx);
76-
if (ry != null) basePatternRect.setAttribute("ry", ry);
77-
78-
return create("svg:g", context)
79-
.call(applyIndirectStyles, this, dimensions, context)
80-
.call(this._transform, this, scales)
81-
.call((g) =>
82-
g
83-
.selectAll()
84-
.data(index)
85-
.enter()
86-
.append(() => basePattern.cloneNode(true))
87-
.attr("id", (i) => `${patternId}-${i}`)
88-
.select("rect")
89-
.call(applyDirectStyles, this)
90-
.call(applyChannelStyles, this, visualValues)
91-
)
92-
.call((g) =>
93-
g
94-
.selectAll()
95-
.data(index)
96-
.enter()
97-
.append("path")
98-
.attr("transform", y === "y" ? template`translate(${x0},${y0})` : template`translate(${y0},${x0})`)
99-
.attr(
100-
"d",
101-
(i) =>
102-
`M${wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), multiple)
103-
.map(transform)
104-
.join("L")}Z`
105-
)
106-
.attr("fill", (i) => `url(#${patternId}-${i})`)
107-
.attr("stroke", this.stroke == null ? null : (i) => `url(#${patternId}-${i})`)
108-
.call(applyChannelStyles, this, {ariaLabel, href, title})
109-
)
110-
.node();
67+
// TODO insets?
68+
const transform = y === "y" ? ([x, y]) => [x * cx, -y * cy] : ([x, y]) => [y * cy, x * cx];
69+
const mx = typeof x0 === "function" ? (i) => x0(i) - barwidth / 2 : () => x0;
70+
const [ix, iy] = y === "y" ? [0, 1] : [1, 0];
71+
72+
const n = Y2.length;
73+
const P = new Array(n);
74+
const X = new Float64Array(n);
75+
const Y = new Float64Array(n);
76+
77+
for (let i = 0; i < n; ++i) {
78+
P[i] = wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), multiple).map(transform);
79+
const c = P[i].pop(); // extract the transformed centroid
80+
X[i] = c[ix] + mx(i);
81+
Y[i] = c[iy] + y0;
82+
}
83+
84+
return {
85+
channels: {
86+
polygon: {value: P, source: null, filter: null},
87+
[`c${x}`]: {value: [cx, x0], source: null, filter: null},
88+
[`c${y}`]: {value: [cy, y0], source: null, filter: null},
89+
[x]: {value: X, scale: null, source: null},
90+
[y1]: {value: Y, scale: null, source: channels[y1]},
91+
[y2]: {value: Y, scale: null, source: channels[y2]}
92+
}
93+
};
94+
});
95+
}
96+
97+
function waffleRender({render, ...options}) {
98+
return {
99+
...options,
100+
render: composeRender(render, function (index, scales, values, dimensions, context) {
101+
const {gap, rx, ry} = this;
102+
const {channels, ariaLabel, href, title, ...visualValues} = values;
103+
const {document} = context;
104+
const polygon = channels.polygon.value;
105+
const [cx, x0] = channels.cx.value;
106+
const [cy, y0] = channels.cy.value;
107+
108+
// Create a base pattern with shared attributes for cloning.
109+
const patternId = getPatternId();
110+
const basePattern = document.createElementNS(namespaces.svg, "pattern");
111+
basePattern.setAttribute("width", cx);
112+
basePattern.setAttribute("height", cy);
113+
basePattern.setAttribute("patternUnits", "userSpaceOnUse");
114+
const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect"));
115+
basePatternRect.setAttribute("x", gap / 2);
116+
basePatternRect.setAttribute("y", gap / 2);
117+
basePatternRect.setAttribute("width", cx - gap);
118+
basePatternRect.setAttribute("height", cy - gap);
119+
if (rx != null) basePatternRect.setAttribute("rx", rx);
120+
if (ry != null) basePatternRect.setAttribute("ry", ry);
121+
122+
return create("svg:g", context)
123+
.call(applyIndirectStyles, this, dimensions, context)
124+
.call(this._transform, this, scales)
125+
.call((g) =>
126+
g
127+
.selectAll()
128+
.data(index)
129+
.enter()
130+
.append(() => basePattern.cloneNode(true))
131+
.attr("id", (i) => `${patternId}-${i}`)
132+
.select("rect")
133+
.call(applyDirectStyles, this)
134+
.call(applyChannelStyles, this, visualValues)
135+
)
136+
.call((g) =>
137+
g
138+
.selectAll()
139+
.data(index)
140+
.enter()
141+
.append("path")
142+
.attr("transform", template`translate(${x0},${y0})`)
143+
.attr("d", (i) => `M${polygon[i].join("L")}Z`)
144+
.attr("fill", (i) => `url(#${patternId}-${i})`)
145+
.attr("stroke", this.stroke == null ? null : (i) => `url(#${patternId}-${i})`)
146+
.call(applyChannelStyles, this, {ariaLabel, href, title})
147+
)
148+
.node();
149+
})
111150
};
112151
}
113152

114-
// A waffle is a approximately rectangular shape, but may have one or two corner
153+
// A waffle is approximately a rectangular shape, but may have one or two corner
115154
// cuts if the starting or ending value is not an even multiple of the number of
116155
// columns (the width of the waffle in cells). We can represent any waffle by
117156
// 8 points; below is a waffle of five columns representing the interval 2–11:
@@ -148,14 +187,11 @@ function waffleRender(y) {
148187
// Waffles can also represent fractional intervals (e.g., 2.4–10.1). These
149188
// require additional corner cuts, so the implementation below generates a few
150189
// more points.
190+
//
191+
// The last point describes the centroid (used for pointing)
151192
function wafflePoints(i1, i2, columns) {
152-
if (i1 < 0 || i2 < 0) {
153-
const k = Math.ceil(-Math.min(i1, i2) / columns); // shift negative to positive
154-
return wafflePoints(i1 + k * columns, i2 + k * columns, columns).map(([x, y]) => [x, y - k]);
155-
}
156-
if (i2 < i1) {
157-
return wafflePoints(i2, i1, columns);
158-
}
193+
if (i2 < i1) return wafflePoints(i2, i1, columns); // ensure i1 <= i2
194+
if (i1 < 0) return wafflePointsOffset(i1, i2, columns, Math.ceil(-Math.min(i1, i2) / columns)); // ensure i1 >= 0
159195
const x1f = Math.floor(i1 % columns);
160196
const x1c = Math.ceil(i1 % columns);
161197
const x2f = Math.floor(i2 % columns);
@@ -177,9 +213,49 @@ function wafflePoints(i1, i2, columns) {
177213
points.push([x2f, y2c]);
178214
if (y2c > y1c) points.push([0, y2c]);
179215
}
216+
points.push(waffleCentroid(i1, i2, columns));
180217
return points;
181218
}
182219

220+
function wafflePointsOffset(i1, i2, columns, k) {
221+
return wafflePoints(i1 + k * columns, i2 + k * columns, columns).map(([x, y]) => [x, y - k]);
222+
}
223+
224+
function waffleCentroid(i1, i2, columns) {
225+
const r = Math.floor(i2 / columns) - Math.floor(i1 / columns);
226+
return r === 0
227+
? // Single row
228+
waffleRowCentroid(i1, i2, columns)
229+
: r === 1
230+
? // Two incomplete rows; use the midpoint of their overlap if any, otherwise the larger row
231+
Math.floor(i2 % columns) > Math.ceil(i1 % columns)
232+
? [(Math.floor(i2 % columns) + Math.ceil(i1 % columns)) / 2, Math.floor(i2 / columns)]
233+
: i2 % columns > columns - (i1 % columns)
234+
? waffleRowCentroid(i2 - (i2 % columns), i2, columns)
235+
: waffleRowCentroid(i1, columns * Math.ceil(i1 / columns), columns)
236+
: // At least one full row; take the midpoint of all the rows that include the middle
237+
[columns / 2, (Math.round(i1 / columns) + Math.round(i2 / columns)) / 2];
238+
}
239+
240+
function waffleRowCentroid(i1, i2, columns) {
241+
const c = Math.floor(i2) - Math.floor(i1);
242+
return c === 0
243+
? // Single cell
244+
[Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (((i1 + i2) / 2) % 1)]
245+
: c === 1
246+
? // Two incomplete cells; use the overlap if large enough, otherwise use the largest
247+
(i2 % 1) - (i1 % 1) > 0.5
248+
? [Math.ceil(i1 % columns), Math.floor(i2 / columns) + ((i1 % 1) + (i2 % 1)) / 2]
249+
: i2 % 1 > 1 - (i1 % 1)
250+
? [Math.floor(i2 % columns) + 0.5, Math.floor(i2 / columns) + (i2 % 1) / 2]
251+
: [Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (1 + (i1 % 1)) / 2]
252+
: // At least one full cell; take the midpoint
253+
[
254+
Math.ceil(i1 % columns) + Math.ceil(Math.floor(i2) - Math.ceil(i1)) / 2,
255+
Math.floor(i1 / columns) + (i2 >= 1 + i1 ? 0.5 : ((i1 + i2) / 2) % 1)
256+
];
257+
}
258+
183259
function maybeRound(round) {
184260
if (round === undefined || round === false) return Number;
185261
if (round === true) return Math.round;
@@ -200,12 +276,28 @@ function spread(domain) {
200276
return max - min;
201277
}
202278

203-
export function waffleX(data, options = {}) {
279+
export function waffleX(data, {tip, ...options} = {}) {
204280
if (!hasXY(options)) options = {...options, y: indexOf, x2: identity};
205-
return new WaffleX(data, maybeStackX(maybeIntervalX(maybeIdentityX(options))));
281+
return new WaffleX(data, {tip: waffleTip(tip), ...maybeStackX(maybeIntervalX(maybeIdentityX(options)))});
206282
}
207283

208-
export function waffleY(data, options = {}) {
284+
export function waffleY(data, {tip, ...options} = {}) {
209285
if (!hasXY(options)) options = {...options, x: indexOf, y2: identity};
210-
return new WaffleY(data, maybeStackY(maybeIntervalY(maybeIdentityY(options))));
286+
return new WaffleY(data, {tip: waffleTip(tip), ...maybeStackY(maybeIntervalY(maybeIdentityY(options)))});
287+
}
288+
289+
/**
290+
* Waffle tips behave a bit unpredictably because we they are driven by the
291+
* waffle centroid; you could be hovering over a waffle segment, but more than
292+
* 40px away from its centroid, or closer to the centroid of another segment.
293+
* We’d rather show a tip, even if it’s the “wrong” one, so we increase the
294+
* default maxRadius to Infinity. The “right” way to fix this would be to use
295+
* signed distance to the waffle geometry rather than the centroid.
296+
*/
297+
function waffleTip(tip) {
298+
return tip === true
299+
? {maxRadius: Infinity}
300+
: isObject(tip) && tip.maxRadius === undefined
301+
? {...tip, maxRadius: Infinity}
302+
: undefined;
211303
}

0 commit comments

Comments
 (0)