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

Merged
merged 17 commits into from
Nov 21, 2024
3 changes: 2 additions & 1 deletion src/mark.d.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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});
Copy link
Member

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}.


/**
* How to clip the mark; one of:
Expand Down
242 changes: 167 additions & 75 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 {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";
Expand All @@ -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);
Expand All @@ -24,26 +26,28 @@ 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;
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]);
Expand All @@ -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:
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
}
Loading