diff --git a/README.md b/README.md
index 6fb96f2eb3..59dd5f54b3 100644
--- a/README.md
+++ b/README.md
@@ -1543,6 +1543,42 @@ Plot.groupZ({x: "proportion"}, {fill: "species"})
Groups on the first channel of *z*, *fill*, or *stroke*, if any. If none of *z*, *fill*, or *stroke* are channels, then all data (within each facet) is placed into a single group.
+### Hexbin
+
+[Source](./src/transforms/hexbin.js) · [Examples](https://observablehq.com/@observablehq/plot-hexbin) · Groups the scaled values into hexagonal bins, and returns the ⟨x,y⟩ positions of these hexagons together with their aggregated value. Binning happens in pixel space, ensuring symmetrical hexagons.
+
+This transform is available in several flavors:
+
+#### Plot.hexbin(data, *options*)
+
+This shortcut extends the [dot](#dot) mark by applying a hexbinFill transform and requesting the hexagon symbol.
+
+#### Plot.hexbinFill(*hexbinOptions*, **options*)
+
+This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and fill depends on the number of data points they contain.
+
+#### Plot.hexbinOpacity(*hexbinOptions*, **options*)
+
+This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and opacity is proportional to the number of data points they contain, scaled so that the largest bin as an opacity of 1.
+
+#### Plot.hexbinR(*hexbinOptions*, **options*)
+
+This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and r specifies a symbol with a size proportional to the bin’s value, scaled so that the largest bin has the full radius.
+
+#### Plot.hexbinText(*hexbinOptions*, **options*)
+
+This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and text depends on the number of data points they contain.
+
+The following *hexbinOptions* are supported:
+* *radius* - the radius of the hexagons tesselation, in pixels, which defaults to 10
+* *value* - the value of a bin: a function that receives as input the binned data, and default to the bin’s length
+* *r* - the radius of a bin, as a function of the bin’s value (uses the scale r which defaults to a type sqrt)
+* *opacity* - the opacity of a bin, as a function of the bin’s value (on the opacity scale)
+* *fill* - the fill color of a bin, as a function of the bin’s value (on the color scale)
+* *text* - a text that represents the contents of the bin, to use with Plot.text; defaults to its value
+* *title* - a text that represents the contents of the bin, to use as a title; defaults to its value
+
+The other *options* are passed to the mark.
### Map
[
](https://observablehq.com/@observablehq/plot-map)
diff --git a/src/index.js b/src/index.js
index 2eadbeeab7..bc39443542 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,6 +4,7 @@ export {Arrow, arrow} from "./marks/arrow.js";
export {BarX, BarY, barX, barY} from "./marks/bar.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
+export {Hexgrid, hexgrid} from "./marks/hexgrid.js";
export {Frame, frame} from "./marks/frame.js";
export {Image, image} from "./marks/image.js";
export {Line, line, lineX, lineY} from "./marks/line.js";
@@ -15,6 +16,7 @@ export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
export {Vector, vector} from "./marks/vector.js";
export {valueof} from "./options.js";
export {dodgeX, dodgeY} from "./layouts/dodge.js";
+export {hexbin} from "./layouts/hexbin.js";
export {filter, reverse, sort, shuffle} from "./transforms/basic.js";
export {bin, binX, binY} from "./transforms/bin.js";
export {group, groupX, groupY, groupZ} from "./transforms/group.js";
diff --git a/src/layouts/dodge.js b/src/layouts/dodge.js
index 5e7371519b..d34408b79c 100644
--- a/src/layouts/dodge.js
+++ b/src/layouts/dodge.js
@@ -40,13 +40,14 @@ export function dodgeY(dodgeOptions = {}, options = {}) {
function dodge(y, x, anchor, padding, options) {
const [, r] = maybeNumberChannel(options.r, 3);
- return layout(options, (I, scales, {[x]: X, r: R}, dimensions) => {
+ return layout(options, (I, scales, channels, dimensions) => {
+ let {[x]: X, [y]: Y, r: R} = channels;
if (X == null) throw new Error(`missing channel: ${x}`);
let [ky, ty] = anchor(dimensions);
const compare = ky ? compareAscending : compareSymmetric;
if (ky) ty += ky * ((R ? max(I, i => R[i]) : r) + padding); else ky = 1;
- if (!R) R = new Float64Array(X.length).fill(r);
- const Y = new Float64Array(X.length);
+ if (!R) R = channels.r = new Float64Array(X.length).fill(r);
+ if (!Y) Y = channels.y = new Float64Array(X.length).fill(0);
const tree = IntervalTree();
for (const i of I) {
const intervals = [];
@@ -75,7 +76,8 @@ function dodge(y, x, anchor, padding, options) {
// Insert the placed circle into the interval tree.
tree.insert([l, r, i]);
}
- return {[y]: Y.map(y => y * ky + ty)};
+ for (const i of I) Y[i] = Y[i] * ky + ty;
+ return {[y]: Y};
});
}
diff --git a/src/layouts/hexbin.js b/src/layouts/hexbin.js
new file mode 100644
index 0000000000..b0cc3f07f5
--- /dev/null
+++ b/src/layouts/hexbin.js
@@ -0,0 +1,92 @@
+import {groups} from "d3";
+import {layout} from "./index.js";
+import {basic} from "../transforms/basic.js";
+import {maybeOutputs, hasOutput} from "../transforms/group.js";
+import {valueof} from "../options.js";
+
+const defaults = {
+ ariaLabel: "hex",
+ symbol: "hexagon"
+};
+
+// width factor (allows the hexbin transform to work with circular dots!)
+const w0 = Math.sin(Math.PI / 3);
+
+function hbin(I, X, Y, r) {
+ const dx = r * 2 * w0;
+ const dy = r * 1.5;
+ const keys = new Map();
+ return groups(I, i => {
+ let px = X[i] / dx;
+ let py = Y[i] / dy;
+ if (isNaN(px) || isNaN(py)) return;
+ let pj = Math.round(py),
+ pi = Math.round(px = px - (pj & 1) / 2),
+ py1 = py - pj;
+ if (Math.abs(py1) * 3 > 1) {
+ let px1 = px - pi,
+ pi2 = pi + (px < pi ? -1 : 1) / 2,
+ pj2 = pj + (py < pj ? -1 : 1),
+ px2 = px - pi2,
+ py2 = py - pj2;
+ if (px1 * px1 + py1 * py1 > px2 * px2 + py2 * py2) pi = pi2 + (pj & 1 ? 1 : -1) / 2, pj = pj2;
+ }
+ const key = `${pi}|${pj}`;
+ keys.set(key, [pi, pj]);
+ return key;
+ })
+ .filter(([p]) => p)
+ .map(([p, bin]) => {
+ const [pi, pj] = keys.get(p);
+ bin.x = (pi + (pj & 1) / 2) * dx;
+ bin.y = pj * dy;
+ return bin;
+ });
+}
+
+// Allow hexbin options to be specified as part of outputs; merge them into options.
+function mergeOptions({radius = 10, ...outputs}, options) {
+ return [outputs, {radius, ...options}];
+}
+
+function hexbinLayout(radius, outputs, options) {
+ // we defer to Plot.bin’s reducers, but some of them are not supported
+ for (const reduce of Object.values(outputs)) {
+ if (typeof reduce === "string"
+ && !reduce.match(/^(first|last|count|distinct|sum|deviation|min|min-index|max|max-index|mean|median|variance|mode|proportion|proportion-facet)$/i))
+ throw new Error(`invalid reduce ${reduce}`);
+ }
+ outputs = maybeOutputs(outputs, options);
+ const rescales = {
+ r: {scale: "r", options: {range: [0, radius * w0]}},
+ fill: {scale: "color"},
+ stroke: {scale: "color"},
+ fillOpacity: {scale: "opacity"}
+ };
+ const {x, y} = options;
+ if (x == null) throw new Error("missing channel: x");
+ if (y == null) throw new Error("missing channel: y");
+ return layout({...defaults, ...options}, function(index, scales, {x: X, y: Y}) {
+ const bins = hbin(index, X, Y, radius);
+ for (const o of outputs) {
+ o.initialize(this.data);
+ o.scope("facet", index);
+ }
+ for (const bin of bins) {
+ for (const o of outputs) o.reduce(bin);
+ }
+ return {
+ reindex: true, // we're sending transformed data!
+ x: valueof(bins, "x"),
+ y: valueof(bins, "y"),
+ ...!hasOutput(outputs, "r") && {r: new Float64Array(bins.length).fill(radius)}, // TODO: constant??
+ ...Object.fromEntries(outputs.map(({name, output}) => [name, name in rescales ? {values: output.transform(), ...rescales[name]} : output.transform()]))
+ };
+ });
+}
+
+export function hexbin(outputs, options) {
+ ([outputs, options] = mergeOptions(outputs, options));
+ const {radius, ...inputs} = options;
+ return basic(hexbinLayout(radius, outputs, inputs), (data, facets) => ({data, facets}));
+}
diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js
new file mode 100644
index 0000000000..bf0c6e5ebe
--- /dev/null
+++ b/src/marks/hexgrid.js
@@ -0,0 +1,52 @@
+import {create} from "d3";
+import {Mark} from "../plot.js";
+import {number} from "../options.js";
+import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";
+
+// width factor (allows the hexbin transform to work with circular dots!)
+const w0 = Math.sin(Math.PI / 3);
+
+const defaultsMesh = {
+ ariaLabel: "hexagonal mesh",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 0.25
+};
+
+export function hexgrid(options) {
+ return new Hexgrid(options);
+}
+
+export class Hexgrid extends Mark {
+ constructor({radius = 10, clip = true, ...options} = {}) {
+ super(undefined, undefined, {clip, ...options}, defaultsMesh);
+ this.radius = number(radius);
+ }
+ render(I, scales, channels, dimensions) {
+ const {dx, dy, radius} = this;
+ const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions;
+ return create("svg:g")
+ .call(applyIndirectStyles, this, dimensions)
+ .call(g => g.append("path")
+ .call(applyDirectStyles, this)
+ .call(applyTransform, null, null, offset + dx, offset + dy)
+ .attr("d", mesh(radius, marginLeft, width - marginRight, marginTop, height - marginBottom)))
+ .node();
+ }
+}
+
+function mesh(r, x0, x1, y0, y1) {
+ const dx = r * 2 * w0;
+ const dy = r * 1.5;
+ x1 += dx / 2;
+ y1 += r;
+ const fragment = Array.from({length: 4}, (_, i) => [r * Math.sin((i + 1) * Math.PI / 3), r * Math.cos((i + 1) * Math.PI / 3)]).join("l");
+ const m = [];
+ let j = Math.round(y0 / dy);
+ for (let y = dy * j; y < y1; y += dy, ++j) {
+ for (let x = (Math.round(x0 / dx) + (j & 1) / 2) * dx; x < x1; x += dx) {
+ m.push(`M${x - dx / 2},${y + dy / 3}m${fragment}`);
+ }
+ }
+ return m.join("");
+}
\ No newline at end of file
diff --git a/src/options.js b/src/options.js
index 7effd60512..0d4916bafd 100644
--- a/src/options.js
+++ b/src/options.js
@@ -250,12 +250,22 @@ export function isColor(value) {
|| color(value) !== null;
}
+const hex = Array.from({length: 6}, (_, i) => [Math.sin(i * Math.PI / 3), Math.cos(i * Math.PI / 3)]);
+const symbolHexagon = {
+ draw(context, size) {
+ const s = Math.sqrt(size / Math.PI);
+ for (let i = 0; i < 6; i++) context[i ? "lineTo" : "moveTo"](hex[i][0] * s, hex[i][1] * s);
+ context.closePath();
+ }
+};
+
const symbols = new Map([
["asterisk", symbolAsterisk],
["circle", symbolCircle],
["cross", symbolCross],
["diamond", symbolDiamond],
["diamond2", symbolDiamond2],
+ ["hexagon", symbolHexagon],
["plus", symbolPlus],
["square", symbolSquare],
["square2", symbolSquare2],
diff --git a/src/plot.js b/src/plot.js
index 857d6fff41..3c6d83a04c 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -52,7 +52,6 @@ export function plot(options = {}) {
}
const scaleDescriptors = Scales(scaleChannels, options);
- const scales = ScaleFunctions(scaleDescriptors);
const axes = Axes(scaleDescriptors, options);
const dimensions = Dimensions(scaleDescriptors, axes, options);
@@ -60,9 +59,52 @@ export function plot(options = {}) {
autoScaleLabels(scaleChannels, scaleDescriptors, axes, dimensions, options);
autoAxisTicks(scaleDescriptors, axes);
+ // layouts might return new data to scale with existing or new scales
+ const scales = ScaleFunctions(scaleDescriptors);
+ const markValues = new Map();
+ const newChannels = new Map();
+ const newOptions = {};
+ for (const mark of marks) {
+ const channels = markChannels.get(mark) ?? [];
+ const values = applyScales(channels, scales);
+ let index = filter(markIndex.get(mark), channels, values);
+ const rescale = [];
+ if (mark.layout != null) {
+ let {reindex, ...newValues} = mark.layout(index, scales, values, dimensions) || {};
+ for (let key in newValues) {
+ let c = newValues[key];
+ const {scale} = c;
+ if (scale) {
+ if (!newChannels.has(scale)) newChannels.set(scale, []);
+ newChannels.get(scale).push({scale, value: c.values});
+ newOptions[scale] = {...c.options, ...options[scale]};
+ values[key] = c.values;
+ rescale.push([scale, values[key]]);
+ } else {
+ values[key] = c;
+ }
+ if (reindex) {
+ index = range(values[key]);
+ reindex = false;
+ }
+ }
+ }
+ markValues.set(mark, {index, values, rescale});
+ }
+ const newScaleDescriptors = Scales(newChannels, newOptions);
+ Object.assign(scaleDescriptors, newScaleDescriptors);
+ Object.assign(scales, ScaleFunctions(newScaleDescriptors));
+ for (const [, {rescale}] of markValues) {
+ for (const [scale, values] of rescale) {
+ for (let i = 0; i < values.length; i++) {
+ values[i] = scales[scale](values[i]);
+ }
+ }
+ }
+
// When faceting, render axes for fx and fy instead of x and y.
- const x = facet !== undefined && scales.fx ? "fx" : "x";
- const y = facet !== undefined && scales.fy ? "fy" : "y";
+ const x = facet !== undefined && scaleDescriptors.fx ? "fx" : "x";
+ const y = facet !== undefined && scaleDescriptors.fy ? "fy" : "y";
if (axes[x]) marks.unshift(axes[x]);
if (axes[y]) marks.unshift(axes[y]);
@@ -94,10 +136,7 @@ export function plot(options = {}) {
.node();
for (const mark of marks) {
- const channels = markChannels.get(mark) ?? [];
- let values = applyScales(channels, scales);
- const index = filter(markIndex.get(mark), channels, values);
- if (mark.layout != null) values = mark.layout(index, scales, values, dimensions);
+ const {index, values} = markValues.get(mark) || {};
const node = mark.render(index, scales, values, dimensions, axes);
if (node != null) svg.appendChild(node);
}
@@ -220,6 +259,7 @@ class Facet extends Mark {
// The following fields are set by initialize:
this.marksChannels = undefined; // array of mark channels
this.marksIndexByFacet = undefined; // map from facet key to array of mark indexes
+ this.marksLayouts = undefined;
}
initialize() {
const {index, channels} = super.initialize();
@@ -229,6 +269,7 @@ class Facet extends Mark {
const subchannels = [];
const marksChannels = this.marksChannels = [];
const marksIndexByFacet = this.marksIndexByFacet = facetMap(channels);
+ const marksLayouts = this.marksLayouts = [];
for (const facetKey of facetsKeys) {
marksIndexByFacet.set(facetKey, new Array(this.marks.length));
}
@@ -260,18 +301,73 @@ class Facet extends Mark {
subchannels.push([, channel]);
}
marksChannels.push(markChannels);
+ if (mark.layout) marksLayouts.push(mark);
}
+ this.layout = function(index, scales, channels, dimensions) {
+ const {fx, fy} = scales;
+ const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
+ const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
+ const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
+
+ this.marksValues = marksChannels.map(channels => applyScales(channels, scales));
+ const rescaleChannels = [];
+ for (let i = 0; i < this.marks.length; ++i) {
+ const mark = this.marks[i];
+ if (!mark.layout) continue;
+ let values = facetsKeys.map(facet => [
+ facet,
+ mark.layout(
+ filter(marksIndexByFacet.get(facet)[i], marksChannels[i], this.marksValues[i]),
+ scales,
+ this.marksValues[i],
+ subdimensions
+ )
+ ]);
+ if (values.some(([, d]) => d.reindex)) {
+ const index = [];
+ const newValues = new Map();
+ for (const [facet, value] of values) {
+ const j = index.length;
+ const newIndex = new Set();
+ for (let key in value) {
+ if (key === "reindex") continue; // TODO: better internal API
+ if (!newValues.has(key)) newValues.set(key, []);
+ const V = newValues.get(key);
+ const {scale, options} = value[key];
+ if (scale) Object.assign(V, {scale, options});
+ const U = scale !== undefined ? value[key].values : value[key];
+ for (let i = 0; i < U.length; i++) {
+ const k = i + j;
+ newIndex.add(k);
+ V[k] = U[i];
+ }
+ }
+ for (const i of newIndex) index.push(i);
+ marksIndexByFacet.get(facet)[i] = [...newIndex];
+ }
+ values = Object.fromEntries(newValues);
+ } else {
+ values = values[0][1];
+ }
+ this.marksValues[i] = values;
+ for (let k in values) {
+ if (values[k].scale !== undefined) {
+ rescaleChannels.push([rescaleChannels.length, {values: values[k], scale: values[k].scale, options: values[k].options}]);
+ }
+ }
+ }
+ return Object.fromEntries(rescaleChannels);
+ };
return {index, channels: [...channels, ...subchannels]};
}
render(I, scales, channels, dimensions, axes) {
- const {marks, marksChannels, marksIndexByFacet} = this;
+ const {marks, marksChannels, marksValues, marksIndexByFacet} = this;
const {fx, fy} = scales;
const fyDomain = fy && fy.domain();
const fxDomain = fx && fx.domain();
const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
- const marksValues = marksChannels.map(channels => applyScales(channels, scales));
return create("svg:g")
.call(g => {
if (fy && axes.y) {
@@ -316,7 +412,6 @@ class Facet extends Mark {
const mark = marks[i];
let values = marksValues[i];
const index = filter(marksFacetIndex[i], marksChannels[i], values);
- if (mark.layout != null) values = mark.layout(index, scales, values, subdimensions);
const node = mark.render(index, scales, values, subdimensions);
if (node != null) this.appendChild(node);
}
diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg
new file mode 100644
index 0000000000..03ffdfba66
--- /dev/null
+++ b/test/output/hexbin.svg
@@ -0,0 +1,568 @@
+
\ No newline at end of file
diff --git a/test/output/hexbinDot.html b/test/output/hexbinDot.html
new file mode 100644
index 0000000000..569746072c
--- /dev/null
+++ b/test/output/hexbinDot.html
@@ -0,0 +1,223 @@
+
\ No newline at end of file
diff --git a/test/output/hexbinR.html b/test/output/hexbinR.html
new file mode 100644
index 0000000000..67275000fe
--- /dev/null
+++ b/test/output/hexbinR.html
@@ -0,0 +1,574 @@
+
\ No newline at end of file
diff --git a/test/output/hexbinTextOpacity.svg b/test/output/hexbinTextOpacity.svg
new file mode 100644
index 0000000000..34e5b24b53
--- /dev/null
+++ b/test/output/hexbinTextOpacity.svg
@@ -0,0 +1,222 @@
+
\ No newline at end of file
diff --git a/test/output/penguinFacetDodgeIsland.html b/test/output/penguinFacetDodgeIsland.html
new file mode 100644
index 0000000000..350776e4dc
--- /dev/null
+++ b/test/output/penguinFacetDodgeIsland.html
@@ -0,0 +1,448 @@
+
+
+ BiscoeDreamTorgersen
+
+
\ No newline at end of file
diff --git a/test/plots/hexbin-dot.js b/test/plots/hexbin-dot.js
new file mode 100644
index 0000000000..3205b389cd
--- /dev/null
+++ b/test/plots/hexbin-dot.js
@@ -0,0 +1,12 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ marks: [
+ Plot.dot(penguins, Plot.hexbin({r: "count", fill: "median"}, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "body_mass_g", radius: 20, symbol: "circle"}))
+ ],
+ color: {scheme: "viridis", legend: true, label: "body mass (g)"}
+ });
+}
diff --git a/test/plots/hexbin-r.js b/test/plots/hexbin-r.js
new file mode 100644
index 0000000000..e63eb72ae5
--- /dev/null
+++ b/test/plots/hexbin-r.js
@@ -0,0 +1,20 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ width: 820,
+ height: 320,
+ color: {scheme: "reds", nice: true, tickFormat: d => 100 * d, label: "Proportion of each facet (%)", legend: true},
+ facet: {
+ data: penguins,
+ x: "sex",
+ marginRight: 80
+ },
+ marks: [
+ Plot.frame(),
+ Plot.dot(penguins, Plot.hexbin({title: "proportion-facet", r: "count", fill: "proportion-facet"}, {x: "culmen_depth_mm", y: "culmen_length_mm", strokeWidth: 1}))
+ ]
+ });
+}
diff --git a/test/plots/hexbin-text-opacity.js b/test/plots/hexbin-text-opacity.js
new file mode 100644
index 0000000000..8ab4086588
--- /dev/null
+++ b/test/plots/hexbin-text-opacity.js
@@ -0,0 +1,21 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ width: 820,
+ height: 320,
+ facet: {
+ data: penguins,
+ x: "sex",
+ marginRight: 80
+ },
+ inset: 14,
+ marks: [
+ Plot.frame(),
+ Plot.dot(penguins, Plot.hexbin({fillOpacity: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "brown", stroke: "black", strokeWidth: 0.5})),
+ Plot.text(penguins, Plot.hexbin({text: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm"}))
+ ]
+ });
+}
diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js
new file mode 100644
index 0000000000..c5795f1e85
--- /dev/null
+++ b/test/plots/hexbin.js
@@ -0,0 +1,36 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ color: {scheme: "cividis", type: "log", reverse: true},
+ width: 820,
+ height: 320,
+ x: {inset: 20, ticks: 5},
+ y: {inset: 10},
+ facet: {
+ data: penguins,
+ x: "sex",
+ marginRight: 80
+ },
+ marks: [
+ Plot.frame(),
+ Plot.hexgrid({radius: 12}),
+ Plot.dot(penguins, Plot.hexbin({fill: "count"}, {
+ x: "culmen_depth_mm",
+ y: "culmen_length_mm",
+ radius: 12,
+ strokeWidth: 0.5
+ })),
+ Plot.dot(penguins, {
+ x: "culmen_depth_mm",
+ y: "culmen_length_mm",
+ fill: "white",
+ stroke: "black",
+ strokeWidth: 0.5,
+ r: 1.5
+ })
+ ]
+ });
+}
diff --git a/test/plots/index.js b/test/plots/index.js
index df7cc1044e..a439b10cdc 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -51,6 +51,10 @@ export {default as gistempAnomalyTransform} from "./gistemp-anomaly-transform.js
export {default as googleTrendsRidgeline} from "./google-trends-ridgeline.js";
export {default as gridChoropleth} from "./grid-choropleth.js";
export {default as hadcrutWarmingStripes} from "./hadcrut-warming-stripes.js";
+export {default as hexbin} from "./hexbin.js";
+export {default as hexbinDot} from "./hexbin-dot.js";
+export {default as hexbinR} from "./hexbin-r.js";
+export {default as hexbinTextOpacity} from "./hexbin-text-opacity.js";
export {default as highCardinalityOrdinal} from "./high-cardinality-ordinal.js";
export {default as identityScale} from "./identity-scale.js";
export {default as industryUnemployment} from "./industry-unemployment.js";
@@ -89,6 +93,7 @@ export {default as penguinCulmen} from "./penguin-culmen.js";
export {default as penguinCulmenArray} from "./penguin-culmen-array.js";
export {default as penguinDodge} from "./penguin-dodge.js";
export {default as penguinFacetDodge} from "./penguin-facet-dodge.js";
+export {default as penguinFacetDodgeIsland} from "./penguin-facet-dodge-island.js";
export {default as penguinIslandUnknown} from "./penguin-island-unknown.js";
export {default as penguinMass} from "./penguin-mass.js";
export {default as penguinMassSex} from "./penguin-mass-sex.js";
diff --git a/test/plots/penguin-facet-dodge-island.js b/test/plots/penguin-facet-dodge-island.js
new file mode 100644
index 0000000000..4c40151192
--- /dev/null
+++ b/test/plots/penguin-facet-dodge-island.js
@@ -0,0 +1,22 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ height: 300,
+ x: {
+ grid: true
+ },
+ facet: {
+ data: penguins,
+ y: "species",
+ label: null,
+ marginLeft: 60
+ },
+ marks: [
+ Plot.dot(penguins, Plot.dodgeY("middle", {x: "body_mass_g", fill: "island"}))
+ ],
+ color: {legend: true}
+ });
+}