-
Notifications
You must be signed in to change notification settings - Fork 185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
waffle tips #2132
waffle tips #2132
Changes from 15 commits
7dcccea
da7d094
1f0d634
caa5009
83d0a4d
f27e321
c7ef726
0233683
8ef4c82
57f8e3c
0c7224f
e927e69
4ef5034
1f4518c
28bdcaf
bed5d25
a67bd8d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"; | ||
|
@@ -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,20 +26,20 @@ 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); | ||
this.multiple = maybeMultiple(multiple); | ||
} | ||
} | ||
|
||
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; | ||
function wafflePolygon(y, options) { | ||
return initializer(waffleRender(options), function (data, facets, channels, scales, dimensions) { | ||
const {round, unit} = this; | ||
|
||
const values = valueObject(channels, scales); | ||
mbostock marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const Y1 = values.channels[`${y}1`].value; | ||
const Y2 = values.channels[`${y}2`].value; | ||
|
||
|
@@ -55,63 +57,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}, | ||
[y === "y" ? "cx" : "cy"]: {value: [cx, x0], source: null, filter: null}, | ||
[y === "y" ? "cy" : "cx"]: {value: [cy, y0], source: null, filter: 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({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 +185,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 +211,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 +274,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)))}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is this change doing? It’s not covered by tests, and I don’t notice any difference in manual testing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, so it’s removing the default behavior of the stack transform when the tip option is true, which is to use I wonder if this means we should set the maxRadius to Infinity for waffle marks (just so it’s clear that something is happening), or if it means the overall approach of choosing a single representative point for a mark is insufficient, and we need to supply the pointer transform with a full geometry so it can do e.g. a signed distance check. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps another possibility is to change the pointer behavior when x (or y) is a band scale. But that’s a bit weird because the provided x here is already in screen space… (and the x scale may not even exist). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think setting maxRadius to infinity is the correct interim choice. But it would be neat to have a full geometry (both for geo and waffles) in the future. Note that, for the geo mark, one thing that works well currently with a centroid-based pointer — and will be harder to solve with full geometry —, is to make "small countries" discoverable even when their shape is very small (islands are easy, the hard part is small countries surrounded by larger countries). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I’m not sure how we’d set maxRadius to Infinity for waffle marks. We can do it on the tip option for the derived tip, but that wouldn’t affect how the pointer transform behaves when applied to the waffle mark. 🤔 It kind of makes me think the waffle mark should supply a geometry channel, and then the pointer transform should compute the signed distance to the geometries… (That would also let us fix pointing at bars wider than 40px, too.) |
||
} | ||
|
||
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)))}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This allows e.g.
tip: {maxRadius: Infinity}
.