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
224 changes: 150 additions & 74 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 @@ -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,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)))});
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

@mbostock mbostock Nov 21, 2024

Choose a reason for hiding this comment

The 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 pointer: "y" on on waffleX, and pointer: "x" on waffleY. I get why you’re doing it, but I’m not sure it’s an improvement — in a way it makes things worse by having lots of dead space where the tip isn’t activated even though you’re hovering over a waffle; you need to be within 40px of the waffle centroid for it to activate, but it isn’t always obvious where that centroid is, and why the tip isn’t activating.

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.

Copy link
Member

@mbostock mbostock Nov 21, 2024

Choose a reason for hiding this comment

The 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).

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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).

Copy link
Member

Choose a reason for hiding this comment

The 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)))});
}
Loading