Skip to content
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

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 92 additions & 20 deletions src/marks/waffle.js
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";
Expand All @@ -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);
Expand All @@ -25,19 +28,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);
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);
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 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;

Expand All @@ -55,12 +59,51 @@ 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 {ariaLabel, href, title, ...visualValues} = values;
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");
Expand Down Expand Up @@ -96,13 +139,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})`)
.call(applyChannelStyles, this, {ariaLabel, href, title})
Expand Down Expand Up @@ -148,6 +185,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
Expand Down Expand Up @@ -177,9 +216,42 @@ function wafflePoints(i1, i2, columns) {
points.push([x2f, y2c]);
if (y2c > y1c) points.push([0, y2c]);
}
points.push(centroid(i1, i2, columns));
return points;
}

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;
Expand All @@ -200,12 +272,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)))});
}
Loading