Skip to content

Commit 699e2d6

Browse files
authored
waffle mark 🧇 (#2040)
* checkpoint waffle * tweaks * extend BarY * waffles! * change fractional orientation * waffle test * more waffle * lazy clone waffleX * waffle docs * fix zero columns * fix rounding errors * rx, ry * flip waffle orientation * more docs; round option * tweak position; support stroke * fix test snapshots * DRY waffle * optimize waffle rendering * more robust waffle * test polish * padding tip * squish waffles if needed
1 parent 7fdbbab commit 699e2d6

24 files changed

+2104
-8
lines changed

docs/.vitepress/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ export default defineConfig({
107107
{text: "Tick", link: "/marks/tick"},
108108
{text: "Tip", link: "/marks/tip"},
109109
{text: "Tree", link: "/marks/tree"},
110-
{text: "Vector", link: "/marks/vector"}
110+
{text: "Vector", link: "/marks/vector"},
111+
{text: "Waffle", link: "/marks/waffle"}
111112
]
112113
},
113114
{

docs/marks/waffle.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<script setup>
2+
3+
import * as Plot from "@observablehq/plot";
4+
import * as d3 from "d3";
5+
import {ref, shallowRef, onMounted} from "vue";
6+
7+
const apples = ref(512);
8+
const unit = ref(10);
9+
10+
const olympians = shallowRef([
11+
{weight: 31, height: 1.21, sex: "female"},
12+
{weight: 170, height: 2.21, sex: "male"}
13+
]);
14+
15+
const survey = [
16+
{question: "don’t go out after dark", yes: 96},
17+
{question: "do no activities other than school", yes: 89},
18+
{question: "engage in political discussion and social movements, including online", yes: 10},
19+
{question: "would like to do activities but are prevented by safety concerns", yes: 73}
20+
];
21+
22+
onMounted(() => {
23+
d3.csv("../data/athletes.csv", d3.autoType).then((data) => (olympians.value = data));
24+
});
25+
26+
</script>
27+
28+
# Waffle mark <VersionBadge pr="2040" />
29+
30+
The **waffle mark** is similar to the [bar mark](./bar.md) in that it displays a quantity (or quantitative extent) for a given category; but unlike a bar, a waffle is subdivided into square cells that allow easier counting. Waffles are useful for reading exact quantities. How quickly can you count the pears 🍐 below? How many more apples 🍎 are there than bananas 🍌?
31+
32+
:::plot
33+
```js
34+
Plot.waffleY([212, 207, 315, 11], {x: ["apples", "bananas", "oranges", "pears"]}).plot({height: 420})
35+
```
36+
:::
37+
38+
The waffle mark is often used with the [group transform](../transforms/group.md) to compute counts. The chart below compares the number of female and male athletes in the 2012 Olympics.
39+
40+
:::plot
41+
```js
42+
Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sex"})).plot({x: {label: null}})
43+
```
44+
:::
45+
46+
:::info
47+
Waffles are rendered using SVG patterns, making them more performant than alternatives such as the [dot mark](./dot.md) for rendering many points.
48+
:::
49+
50+
The **unit** option determines the quantity each waffle cell represents; it defaults to one. The unit may be set to a value greater than one for large quantities, or less than one (but greater than zero) for small fractional quantities. Try changing the unit below to see its effect.
51+
52+
<p>
53+
<span class="label-input">
54+
Unit:
55+
<label style="margin-left: 0.5em;"><input type="radio" name="unit" value="1" v-model="unit" /> 1</label>
56+
<label style="margin-left: 0.5em;"><input type="radio" name="unit" value="2" v-model="unit" /> 2</label>
57+
<label style="margin-left: 0.5em;"><input type="radio" name="unit" value="5" v-model="unit" /> 5</label>
58+
<label style="margin-left: 0.5em;"><input type="radio" name="unit" value="10" v-model="unit" /> 10</label>
59+
<label style="margin-left: 0.5em;"><input type="radio" name="unit" value="25" v-model="unit" /> 25</label>
60+
<label style="margin-left: 0.5em;"><input type="radio" name="unit" value="50" v-model="unit" /> 50</label>
61+
<label style="margin-left: 0.5em;"><input type="radio" name="unit" value="100" v-model="unit" /> 100</label>
62+
</span>
63+
</p>
64+
65+
:::plot
66+
```js
67+
Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fx: "date_of_birth", unit})).plot({fx: {interval: "5 years", label: null}})
68+
```
69+
:::
70+
71+
:::tip
72+
Use [faceting](../features/facets.md) as an alternative to supplying an ordinal channel (_i.e._, *fx* instead of *x* for a vertical waffleY). The facet scale’s **interval** option then allows grouping by a quantitative or temporal variable, such as the athlete’s year of birth in the chart below.
73+
:::
74+
75+
While waffles typically represent integer quantities, say to count people or days, they can also encode fractional values with a partial first or last cell. Set the **round** option to true to disable partial cells, or to Math.ceil or Math.floor to round up or down.
76+
77+
Like bars, waffles can be [stacked](../transforms/stack.md), and implicitly apply the stack transform when only a single quantitative channel is supplied.
78+
79+
:::plot
80+
```js
81+
Plot.waffleY(olympians, Plot.groupZ({y: "count"}, {fill: "sex", sort: "sex", fx: "weight", unit: 10})).plot({fx: {interval: 10}, color: {legend: true}})
82+
```
83+
:::
84+
85+
Waffles can also be used to highlight a proportion of the whole. The chart below recreates a graphic of survey responses from [“Teens in Syria”](https://www.economist.com/graphic-detail/2015/08/19/teens-in-syria) by _The Economist_ (August 19, 2015); positive responses are in orange, while negative responses are in gray. The **rx** option is used to produce circles instead of squares.
86+
87+
:::plot
88+
```js
89+
Plot.plot({
90+
axis: null,
91+
label: null,
92+
height: 260,
93+
marginTop: 20,
94+
marginBottom: 70,
95+
title: "Subdued",
96+
subtitle: "Of 120 surveyed Syrian teenagers:",
97+
marks: [
98+
Plot.axisFx({lineWidth: 10, anchor: "bottom", dy: 20}),
99+
Plot.waffleY({length: 1}, {y: 120, fillOpacity: 0.4, rx: "100%"}),
100+
Plot.waffleY(survey, {fx: "question", y: "yes", rx: "100%", fill: "orange"}),
101+
Plot.text(survey, {fx: "question", text: (d) => (d.yes / 120).toLocaleString("en-US", {style: "percent"}), frameAnchor: "bottom", lineAnchor: "top", dy: 6, fill: "orange", fontSize: 24, fontWeight: "bold"})
102+
]
103+
})
104+
```
105+
:::
106+
107+
The waffle mark comes in two orientations: waffleY extends vertically↑, while waffleX extends horizontally→. The waffle mark automatically determines the appropriate number of cells per row or per column (depending on orientation) such that the cells are square, don’t overlap, and are consistent with position scales.
108+
109+
<p>
110+
<label class="label-input">
111+
<span>Apples:</span>
112+
<input type="range" v-model.number="apples" min="10" max="1028" step="1" />
113+
<span style="font-variant-numeric: tabular-nums;">{{apples}}</span>
114+
</label>
115+
</p>
116+
117+
:::plot
118+
```js
119+
Plot.waffleX([apples], {y: ["apples"]}).plot({height: 240})
120+
```
121+
:::
122+
123+
:::info
124+
The number of rows in the waffle above is guaranteed to be an integer, but it might not be a multiple or factor of the *x*-axis tick interval. For example, the waffle might have 15 rows while the *x*-axis shows ticks every 100 units.
125+
:::
126+
:::tip
127+
While you can’t control the number of rows (or columns) directly, you can affect it via the **padding** option on the corresponding band scale. Padding defaults to 0.1; a higher value may produce more rows, while a lower (or zero) value may produce fewer rows.
128+
:::
129+
130+
## Waffle options
131+
132+
For required channels, see the [bar mark](./bar.md). The waffle mark supports the [standard mark options](../features/marks.md), including [insets](../features/marks.md#insets) and [rounded corners](../features/marks.md#rounded-corners). The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise.
133+
134+
## waffleX(*data*, *options*) {#waffleX}
135+
136+
```js
137+
Plot.waffleX(olympians, Plot.groupY({x: "count"}, {y: "sport"}))
138+
```
139+
140+
Returns a new horizontal→ waffle with the given *data* and *options*. The following channels are required:
141+
142+
* **x1** - the starting horizontal position; bound to the *x* scale
143+
* **x2** - the ending horizontal position; bound to the *x* scale
144+
145+
The following optional channels are supported:
146+
147+
* **y** - the vertical position; bound to the *y* scale, which must be *band*
148+
149+
If neither the **x1** nor **x2** option is specified, the **x** option may be specified as shorthand to apply an implicit [stackX transform](../transforms/stack.md); this is the typical configuration for a horizontal waffle chart with columns aligned at *x* = 0. If the **x** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **x2** as identity and **y** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to waffleX to make a quick sequential waffle chart. If the **y** channel is not specified, the column will span the full vertical extent of the plot (or facet).
150+
151+
If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each *x* to produce *x1*, and *interval*.offset(*x1*) is invoked for each *x1* to produce *x2*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options).
152+
153+
## waffleY(*data*, *options*) {#waffleY}
154+
155+
```js
156+
Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sport"}))
157+
```
158+
159+
Returns a new vertical↑ waffle with the given *data* and *options*. The following channels are required:
160+
161+
* **y1** - the starting vertical position; bound to the *y* scale
162+
* **y2** - the ending vertical position; bound to the *y* scale
163+
164+
The following optional channels are supported:
165+
166+
* **x** - the horizontal position; bound to the *x* scale, which must be *band*
167+
168+
If neither the **y1** nor **y2** option is specified, the **y** option may be specified as shorthand to apply an implicit [stackY transform](../transforms/stack.md); this is the typical configuration for a vertical waffle chart with columns aligned at *y* = 0. If the **y** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **y2** as identity and **x** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to waffleY to make a quick sequential waffle chart. If the **x** channel is not specified, the column will span the full horizontal extent of the plot (or facet).
169+
170+
If an **interval** is specified, such as d3.utcDay, **y1** and **y2** can be derived from **y**: *interval*.floor(*y*) is invoked for each *y* to produce *y1*, and *interval*.offset(*y1*) is invoked for each *y1* to produce *y2*. If the interval is specified as a number *n*, *y1* and *y2* are taken as the two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options).

src/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export * from "./marks/tick.js";
3838
export * from "./marks/tip.js";
3939
export * from "./marks/tree.js";
4040
export * from "./marks/vector.js";
41+
export * from "./marks/waffle.js";
4142
export * from "./options.js";
4243
export * from "./plot.js";
4344
export * from "./projection.js";

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
3838
export {Tip, tip} from "./marks/tip.js";
3939
export {tree, cluster} from "./marks/tree.js";
4040
export {Vector, vector, vectorX, vectorY, spike} from "./marks/vector.js";
41+
export {WaffleX, WaffleY, waffleX, waffleY} from "./marks/waffle.js";
4142
export {valueof, column, identity, indexOf} from "./options.js";
4243
export {filter, reverse, sort, shuffle, basic as transform, initializer} from "./transforms/basic.js";
4344
export {bin, binX, binY} from "./transforms/bin.js";

src/marks/bar.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export function barX(data?: Data, options?: BarXOptions): BarX;
170170
* ```js
171171
* Plot.barY(alphabet, {y: "frequency", x: "letter"})
172172
* ```
173+
*
173174
* If neither **y1** nor **y2** nor **interval** is specified, an implicit
174175
* stackY transform is applied and **y** defaults to the identity function,
175176
* assuming that *data* = [*y₀*, *y₁*, *y₂*, …]. Otherwise if an **interval** is

src/marks/bar.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js";
88
import {maybeStackX, maybeStackY} from "../transforms/stack.js";
99
import {applyRoundedRect, rectInsets, rectRadii} from "./rect.js";
1010

11+
const barDefaults = {
12+
ariaLabel: "bar"
13+
};
14+
1115
export class AbstractBar extends Mark {
12-
constructor(data, channels, options = {}, defaults) {
16+
constructor(data, channels, options = {}, defaults = barDefaults) {
1317
super(data, channels, options, defaults);
1418
rectInsets(this, options);
1519
rectRadii(this, options);
@@ -81,12 +85,8 @@ function add(a, b) {
8185
: a + b;
8286
}
8387

84-
const defaults = {
85-
ariaLabel: "bar"
86-
};
87-
8888
export class BarX extends AbstractBar {
89-
constructor(data, options = {}) {
89+
constructor(data, options = {}, defaults) {
9090
const {x1, x2, y} = options;
9191
super(
9292
data,
@@ -115,7 +115,7 @@ export class BarX extends AbstractBar {
115115
}
116116

117117
export class BarY extends AbstractBar {
118-
constructor(data, options = {}) {
118+
constructor(data, options = {}, defaults) {
119119
const {x, y1, y2} = options;
120120
super(
121121
data,

src/marks/waffle.d.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type {Data, RenderableMark} from "../mark.js";
2+
import type {BarXOptions, BarYOptions} from "./bar.js";
3+
4+
/** Options for the waffleX and waffleY mark. */
5+
interface WaffleOptions {
6+
/** The quantity each cell represents; defaults to 1. */
7+
unit?: number;
8+
/** The gap in pixels between cells; defaults to 1. */
9+
gap?: number;
10+
/** If true, round to integers to avoid partial cells. */
11+
round?: boolean | ((value: number) => number);
12+
}
13+
14+
/** Options for the waffleX mark. */
15+
export interface WaffleXOptions extends BarXOptions, WaffleOptions {}
16+
17+
/** Options for the waffleY mark. */
18+
export interface WaffleYOptions extends BarYOptions, WaffleOptions {}
19+
20+
/**
21+
* Returns a new vertical waffle mark for the given *data* and *options*; the
22+
* required *y* values should be quantitative, and the optional *x* values
23+
* should be ordinal. For example, for a vertical waffle chart of Olympic
24+
* athletes by sport:
25+
*
26+
* ```js
27+
* Plot.waffleY(olympians, Plot.groupX({y: "count"}, {x: "sport"}))
28+
* ```
29+
*
30+
* If neither **y1** nor **y2** nor **interval** is specified, an implicit
31+
* stackY transform is applied and **y** defaults to the identity function,
32+
* assuming that *data* = [*y₀*, *y₁*, *y₂*, …]. Otherwise if an **interval** is
33+
* specified, then **y1** and **y2** are derived from **y**, representing the
34+
* lower and upper bound of the containing interval, respectively. Otherwise, if
35+
* only one of **y1** or **y2** is specified, the other defaults to **y**, which
36+
* defaults to zero.
37+
*
38+
* The optional **x** ordinal channel specifies the horizontal position; it is
39+
* typically bound to the *x* scale, which must be a *band* scale. If the **x**
40+
* channel is not specified, the waffle will span the horizontal extent of the
41+
* plot’s frame. Because a waffle represents a discrete number of square cells,
42+
* it may not use all of the available bandwidth.
43+
*
44+
* If *options* is undefined, then **x** defaults to the zero-based index of
45+
* *data* [0, 1, 2, …], allowing a quick waffle chart from an array of numbers:
46+
*
47+
* ```js
48+
* Plot.waffleY([4, 9, 24, 46, 66, 7])
49+
* ```
50+
*/
51+
export function waffleY(data?: Data, options?: WaffleYOptions): WaffleY;
52+
53+
/**
54+
* Returns a new horizonta waffle mark for the given *data* and *options*; the
55+
* required *x* values should be quantitative, and the optional *y* values
56+
* should be ordinal. For example, for a horizontal waffle chart of Olympic
57+
* athletes by sport:
58+
*
59+
* ```js
60+
* Plot.waffleX(olympians, Plot.groupY({x: "count"}, {y: "sport"}))
61+
* ```
62+
*
63+
* If neither **x1** nor **x2** nor **interval** is specified, an implicit
64+
* stackX transform is applied and **x** defaults to the identity function,
65+
* assuming that *data* = [*x₀*, *x₁*, *x₂*, …]. Otherwise if an **interval** is
66+
* specified, then **x1** and **x2** are derived from **x**, representing the
67+
* lower and upper bound of the containing interval, respectively. Otherwise, if
68+
* only one of **x1** or **x2** is specified, the other defaults to **x**, which
69+
* defaults to zero.
70+
*
71+
* The optional **y** ordinal channel specifies the vertical position; it is
72+
* typically bound to the *y* scale, which must be a *band* scale. If the **y**
73+
* channel is not specified, the waffle will span the vertical extent of the
74+
* plot’s frame. Because a waffle represents a discrete number of square cells,
75+
* it may not use all of the available bandwidth.
76+
*
77+
* If *options* is undefined, then **y** defaults to the zero-based index of
78+
* *data* [0, 1, 2, …], allowing a quick waffle chart from an array of numbers:
79+
*
80+
* ```js
81+
* Plot.waffleX([4, 9, 24, 46, 66, 7])
82+
* ```
83+
*/
84+
export function waffleX(data?: Data, options?: WaffleXOptions): WaffleX;
85+
86+
/** The waffleX mark. */
87+
export class WaffleX extends RenderableMark {}
88+
89+
/** The waffleY mark. */
90+
export class WaffleY extends RenderableMark {}

0 commit comments

Comments
 (0)