diff --git a/src/facet.js b/src/facet.js
new file mode 100644
index 0000000000..cb2ff6cb71
--- /dev/null
+++ b/src/facet.js
@@ -0,0 +1,55 @@
+import {slice} from "./options.js";
+
+export function facetReindex(facets, n) {
+ // Count the number of overlapping indexes across facets.
+ const overlap = new Uint8Array(n);
+ let count = 0;
+ for (const facet of facets) {
+ for (const i of facet) {
+ if (overlap[i]) ++count;
+ overlap[i] = 1;
+ }
+ }
+
+ // For each overlapping index (duplicate number), assign a new unique index at
+ // the end of the existing array. For example, [[0, 1, 2], [2, 1, 3]] would
+ // become [[0, 1, 2], [4, 5, 3]]. Attach a plan to the facets array, to be
+ // able to read the values associated with the old index in unaffected
+ // channels.
+ if (count > 0) {
+ facets = facets.map((facet) => slice(facet, Uint32Array));
+ const plan = (facets.plan = new Uint32Array(n + count));
+ let j = 0;
+ for (; j < n; ++j) plan[j] = j;
+ overlap.fill(0);
+ for (const facet of facets) {
+ for (let k = 0; k < facet.length; ++k) {
+ const i = facet[k];
+ if (overlap[i]) {
+ plan[j] = i;
+ facet[k] = j;
+ j++;
+ }
+ overlap[i] = 1;
+ }
+ }
+ }
+ return facets;
+}
+
+// returns a function that reads X with the facets’ reindexing plan
+export function getter({plan}, X) {
+ return !X ? X : !plan || X.length === plan.length ? (i) => X[i] : (i) => X[plan[i]];
+}
+
+// returns an array of X expanded along the facets’ reindexing plan
+export function expander({plan}, X) {
+ if (!plan || !X || X.length === plan.length) return X;
+ const V = new X.constructor(plan.length);
+ for (let i = 0; i < plan.length; ++i) V[i] = X[plan[i]];
+ return V;
+}
+
+export function originals({plan}, I) {
+ return plan ? I.map((i) => plan[i]) : I;
+}
diff --git a/src/plot.js b/src/plot.js
index 0e53efdce0..0f3c0f88fb 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -609,8 +609,24 @@ export function plot(options = {}) {
.attr("transform", facetTranslate(fx, fy))
.each(function (key) {
const j = indexByFacet.get(key);
- for (const [mark, {channels, values, facets}] of stateByMark) {
- const facet = facets ? mark.filter(facets[j] ?? facets[0], channels, values) : null;
+ for (const [mark, {channels, values: original, facets}] of stateByMark) {
+ // this facet is possibly reindexed
+ let values = original;
+ let F = facets?.[j];
+ if (F && j > 0 && facets.plan) {
+ const {plan} = facets;
+ const long = Object.keys(values).filter((key) => values[key].length === plan.length);
+ const V = Object.fromEntries(long.map((key) => [key, []]));
+ F = [];
+ for (const i of facets[j]) {
+ const k = plan[i];
+ F.push(k);
+ for (const key of long) V[key][k] = original[key][i];
+ values = {...original, ...V};
+ }
+ }
+
+ const facet = facets ? mark.filter(F ?? facets[0], channels, values) : null;
const node = mark.render(facet, scales, values, subdimensions, context);
if (node != null) this.appendChild(node);
}
diff --git a/src/transforms/bin.js b/src/transforms/bin.js
index 91ab1666d5..71a926e001 100644
--- a/src/transforms/bin.js
+++ b/src/transforms/bin.js
@@ -1,7 +1,14 @@
-import {bin as binner, extent, thresholdFreedmanDiaconis, thresholdScott, thresholdSturges, utcTickInterval} from "d3";
+import {
+ bin as binner,
+ extent,
+ sum,
+ thresholdFreedmanDiaconis,
+ thresholdScott,
+ thresholdSturges,
+ utcTickInterval
+} from "d3";
import {
valueof,
- range,
identity,
maybeColumn,
maybeTuple,
@@ -29,6 +36,7 @@ import {
} from "./group.js";
import {maybeInsetX, maybeInsetY} from "./inset.js";
import {maybeInterval} from "./interval.js";
+import {expander, getter, originals} from "../facet.js";
/**
* ```js
@@ -172,12 +180,19 @@ function binn(
const GZ = Z && setGZ([]);
const GF = F && setGF([]);
const GS = S && setGS([]);
- const BX = bx ? bx(data) : [[, , (I) => I]];
- const BY = by ? by(data) : [[, , (I) => I]];
+ const BX = bx ? bx(data, facets) : [[, , (I) => I]];
+ const BY = by ? by(data, facets) : [[, , (I) => I]];
const BX1 = bx && setBX1([]);
const BX2 = bx && setBX2([]);
const BY1 = by && setBY1([]);
const BY2 = by && setBY2([]);
+
+ const eG = getter(facets, G);
+ const eK = getter(facets, K);
+ const gZ = getter(facets, Z);
+ const gF = getter(facets, F);
+ const gS = getter(facets, S);
+
let i = 0;
for (const o of outputs) o.initialize(data);
if (sort) sort.initialize(data);
@@ -187,8 +202,8 @@ function binn(
for (const o of outputs) o.scope("facet", facet);
if (sort) sort.scope("facet", facet);
if (filter) filter.scope("facet", facet);
- for (const [f, I] of maybeGroup(facet, G)) {
- for (const [k, g] of maybeGroup(I, K)) {
+ for (const [f, I] of maybeGroup(facet, eG)) {
+ for (const [k, g] of maybeGroup(I, eK)) {
for (const [x1, x2, fx] of BX) {
const bb = fx(g);
for (const [y1, y2, fy] of BY) {
@@ -196,11 +211,11 @@ function binn(
const b = fy(bb);
if (filter && !filter.reduce(b, extent)) continue;
groupFacet.push(i++);
- groupData.push(reduceData.reduce(b, data, extent));
+ groupData.push(reduceData.reduce(originals(facets, b), data, extent));
if (K) GK.push(k);
- if (Z) GZ.push(G === Z ? f : Z[b[0]]);
- if (F) GF.push(G === F ? f : F[b[0]]);
- if (S) GS.push(G === S ? f : S[b[0]]);
+ if (Z) GZ.push(G === Z ? f : gZ(b[0]));
+ if (F) GF.push(G === F ? f : gF(b[0]));
+ if (S) GS.push(G === S ? f : gS(b[0]));
if (BX1) BX1.push(x1), BX2.push(x2);
if (BY1) BY1.push(y1), BY2.push(y2);
for (const o of outputs) o.reduce(b, extent);
@@ -248,8 +263,8 @@ function maybeBinValueTuple(options) {
function maybeBin(options) {
if (options == null) return;
const {value, cumulative, domain = extent, thresholds} = options;
- const bin = (data) => {
- let V = valueof(data, value, Array); // d3.bin prefers Array input
+ const bin = (data, facets) => {
+ let V = expander(facets, valueof(data, value, Array)); // d3.bin prefers Array input
const bin = binner().value((i) => V[i]);
if (isTemporal(V) || isTimeThresholds(thresholds)) {
V = V.map(coerceDate);
@@ -279,7 +294,7 @@ function maybeBin(options) {
}
bin.thresholds(t).domain(d);
}
- let bins = bin(range(data)).map(binset);
+ let bins = bin(union(facets)).map(binset);
if (cumulative) bins = (cumulative < 0 ? bins.reverse() : bins).map(bincumset);
return bins.map(binfilter);
};
@@ -365,3 +380,10 @@ function binfilter([{x0, x1}, set]) {
function binempty() {
return new Uint32Array(0);
}
+
+function union(facets) {
+ const U = new Uint32Array(sum(facets, (d) => d.length));
+ let c = 0;
+ for (const facet of facets) for (const i of facet) U[c++] = i;
+ return U;
+}
diff --git a/src/transforms/dodge.js b/src/transforms/dodge.js
index 513a7b1efa..a74fbd755e 100644
--- a/src/transforms/dodge.js
+++ b/src/transforms/dodge.js
@@ -3,6 +3,7 @@ import {finite, positive} from "../defined.js";
import {identity, maybeNamed, number, valueof} from "../options.js";
import {coerceNumbers} from "../scales.js";
import {initializer} from "./basic.js";
+import {facetReindex, getter} from "../facet.js";
const anchorXLeft = ({marginLeft}) => [1, marginLeft];
const anchorXRight = ({width, marginRight}) => [-1, width - marginRight];
@@ -90,21 +91,26 @@ function dodge(y, x, anchor, padding, options) {
return initializer(options, function (data, facets, {[x]: X, r: R}, scales, dimensions) {
if (!X) throw new Error(`missing channel: ${x}`);
X = coerceNumbers(valueof(X.value, scales[X.scale] || identity));
+
+ // make facets exclusive
+ facets = facetReindex(facets, data.length);
+
const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? number(options.r) : 3;
if (R) R = coerceNumbers(valueof(R.value, scales[R.scale] || identity));
let [ky, ty] = anchor(dimensions);
const compare = ky ? compareAscending : compareSymmetric;
- const Y = new Float64Array(X.length);
- const radius = R ? (i) => R[i] : () => r;
+ const Y = new Float64Array((facets.plan || X).length);
+ const radius = R ? getter(facets, R) : () => r;
+ const getX = getter(facets, X);
for (let I of facets) {
const tree = IntervalTree();
- I = I.filter(R ? (i) => finite(X[i]) && positive(R[i]) : (i) => finite(X[i]));
+ I = I.filter(R ? (i) => finite(getX(i)) && positive(radius(i)) : (i) => finite(getX(i)));
const intervals = new Float64Array(2 * I.length + 2);
for (const i of I) {
const ri = radius(i);
const y0 = ky ? ri + padding : 0; // offset baseline for varying radius
- const l = X[i] - ri;
- const h = X[i] + ri;
+ const l = getX(i) - ri;
+ const h = getX(i) + ri;
// The first two positions are 0 to test placing the dot on the baseline.
let k = 2;
@@ -114,8 +120,8 @@ function dodge(y, x, anchor, padding, options) {
// https://observablehq.com/@mbostock/circle-offset-along-line
tree.queryInterval(l - padding, h + padding, ([, , j]) => {
const yj = Y[j] - y0;
- const dx = X[i] - X[j];
- const dr = padding + (R ? R[i] + R[j] : 2 * r);
+ const dx = getX(i) - getX(j);
+ const dr = padding + (R ? radius(i) + radius(j) : 2 * r);
const dy = Math.sqrt(dr * dr - dx * dx);
intervals[k++] = yj - dy;
intervals[k++] = yj + dy;
diff --git a/src/transforms/group.js b/src/transforms/group.js
index 5aa82be38a..cd3b320455 100644
--- a/src/transforms/group.js
+++ b/src/transforms/group.js
@@ -31,6 +31,7 @@ import {
percentile
} from "../options.js";
import {basic} from "./basic.js";
+import {getter, originals} from "../facet.js";
/**
* ```js
@@ -158,6 +159,14 @@ function groupn(
const GZ = Z && setGZ([]);
const GF = F && setGF([]);
const GS = S && setGS([]);
+
+ const eG = getter(facets, G);
+ const eX = getter(facets, X);
+ const eY = getter(facets, Y);
+ const gZ = getter(facets, Z);
+ const gF = getter(facets, F);
+ const gS = getter(facets, S);
+
let i = 0;
for (const o of outputs) o.initialize(data);
if (sort) sort.initialize(data);
@@ -167,17 +176,17 @@ function groupn(
for (const o of outputs) o.scope("facet", facet);
if (sort) sort.scope("facet", facet);
if (filter) filter.scope("facet", facet);
- for (const [f, I] of maybeGroup(facet, G)) {
- for (const [y, gg] of maybeGroup(I, Y)) {
- for (const [x, g] of maybeGroup(gg, X)) {
+ for (const [f, I] of maybeGroup(facet, eG)) {
+ for (const [y, gg] of maybeGroup(I, eY)) {
+ for (const [x, g] of maybeGroup(gg, eX)) {
if (filter && !filter.reduce(g)) continue;
groupFacet.push(i++);
- groupData.push(reduceData.reduce(g, data));
+ groupData.push(reduceData.reduce(originals(facets, g), data));
if (X) GX.push(x);
if (Y) GY.push(y);
- if (Z) GZ.push(G === Z ? f : Z[g[0]]);
- if (F) GF.push(G === F ? f : F[g[0]]);
- if (S) GS.push(G === S ? f : S[g[0]]);
+ if (Z) GZ.push(G === Z ? f : gZ(g[0]));
+ if (F) GF.push(G === F ? f : gF(g[0]));
+ if (S) GS.push(G === S ? f : gS(g[0]));
for (const o of outputs) o.reduce(g);
if (sort) sort.reduce(g);
}
@@ -256,10 +265,10 @@ export function maybeEvaluator(name, reduce, inputs) {
};
}
-export function maybeGroup(I, X) {
- return X
+export function maybeGroup(I, x) {
+ return x
? sort(
- grouper(I, (i) => X[i]),
+ grouper(I, (i) => x(i)),
first
)
: [[, I]];
diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js
index ebd0e50dc8..14fd0b5cc0 100644
--- a/src/transforms/hexbin.js
+++ b/src/transforms/hexbin.js
@@ -3,6 +3,7 @@ import {sqrt3} from "../symbols.js";
import {identity, isNoneish, number, valueof} from "../options.js";
import {initializer} from "./basic.js";
import {hasOutput, maybeGroup, maybeOutputs, maybeSubgroup} from "./group.js";
+import {getter} from "../facet.js";
// We don’t want the hexagons to align with the edges of the plot frame, as that
// would cause extreme x-values (the upper bound of the default x-scale domain)
@@ -100,19 +101,28 @@ export function hexbin(outputs = {fill: "count"}, options = {}) {
const BX = [];
const BY = [];
let i = -1;
+
+ // Mind reindexed facets
+ const eG = getter(facets, G);
+ const gX = getter(facets, X);
+ const gY = getter(facets, Y);
+ const gZ = getter(facets, Z);
+ const gF = getter(facets, F);
+ const gS = getter(facets, S);
+ const gQ = getter(facets, Q);
for (const o of outputs) o.initialize(data);
for (const facet of facets) {
const binFacet = [];
for (const o of outputs) o.scope("facet", facet);
- for (const [f, I] of maybeGroup(facet, G)) {
- for (const bin of hbin(I, X, Y, binWidth)) {
+ for (const [f, I] of maybeGroup(facet, eG)) {
+ for (const bin of hbin(I, gX, gY, binWidth)) {
binFacet.push(++i);
BX.push(bin.x);
BY.push(bin.y);
- if (Z) GZ.push(G === Z ? f : Z[bin[0]]);
- if (F) GF.push(G === F ? f : F[bin[0]]);
- if (S) GS.push(G === S ? f : S[bin[0]]);
- if (Q) GQ.push(G === Q ? f : Q[bin[0]]);
+ if (Z) GZ.push(G === Z ? f : gZ(bin[0]));
+ if (F) GF.push(G === F ? f : gF(bin[0]));
+ if (S) GS.push(G === S ? f : gS(bin[0]));
+ if (Q) GQ.push(G === Q ? f : gQ(bin[0]));
for (const o of outputs) o.reduce(bin);
}
}
@@ -139,12 +149,12 @@ export function hexbin(outputs = {fill: "count"}, options = {}) {
});
}
-function hbin(I, X, Y, dx) {
+function hbin(I, x, y, dx) {
const dy = dx * (1.5 / sqrt3);
const bins = new Map();
for (const i of I) {
- let px = X[i],
- py = Y[i];
+ let px = x(i),
+ py = y(i);
if (isNaN(px) || isNaN(py)) continue;
let pj = Math.round((py = (py - oy) / dy)),
pi = Math.round((px = (px - ox) / dx - (pj & 1) / 2)),
diff --git a/src/transforms/map.js b/src/transforms/map.js
index b62ea564c0..6016cebc68 100644
--- a/src/transforms/map.js
+++ b/src/transforms/map.js
@@ -1,6 +1,7 @@
import {count, group, rank} from "d3";
import {maybeZ, take, valueof, maybeInput, column} from "../options.js";
import {basic} from "./basic.js";
+import {facetReindex, getter, expander} from "../facet.js";
/**
* ```js
@@ -58,11 +59,13 @@ export function map(outputs = {}, options = {}) {
});
return {
...basic(options, (data, facets) => {
+ // make facets exclusive
+ facets = facetReindex(facets, data.length);
const Z = valueof(data, z);
- const X = channels.map(({input}) => valueof(data, input));
- const MX = channels.map(({setOutput}) => setOutput(new Array(data.length)));
+ const X = channels.map(({input}) => expander(facets, valueof(data, input)));
+ const MX = channels.map(({setOutput}) => setOutput(new Array((facets.plan || data).length)));
for (const facet of facets) {
- for (const I of Z ? group(facet, (i) => Z[i]).values() : [facet]) {
+ for (const I of Z ? group(facet, getter(facets, Z)).values() : [facet]) {
channels.forEach(({map}, i) => map.map(I, X[i], MX[i]));
}
}
diff --git a/src/transforms/select.js b/src/transforms/select.js
index 5f972851d3..7e6f5e6c6b 100644
--- a/src/transforms/select.js
+++ b/src/transforms/select.js
@@ -1,6 +1,7 @@
import {greatest, group, least} from "d3";
import {maybeZ, valueof} from "../options.js";
import {basic} from "./basic.js";
+import {expander, getter} from "../facet.js";
/**
* Selects the points of each series selected by the *selector*, which can be
@@ -143,12 +144,13 @@ function selectChannel(v, selector, options) {
}
const z = maybeZ(options);
return basic(options, (data, facets) => {
- const Z = valueof(data, z);
- const V = valueof(data, v);
+ const gz = getter(facets, valueof(data, z));
+ const V = expander(facets, valueof(data, v));
const selectFacets = [];
+ if ("plan" in facets) selectFacets.plan = facets.plan;
for (const facet of facets) {
const selectFacet = [];
- for (const I of Z ? group(facet, (i) => Z[i]).values() : [facet]) {
+ for (const I of gz ? group(facet, gz).values() : [facet]) {
for (const i of selector(I, V)) {
selectFacet.push(i);
}
diff --git a/src/transforms/stack.js b/src/transforms/stack.js
index 7018ff5ce9..b9f158a4e1 100644
--- a/src/transforms/stack.js
+++ b/src/transforms/stack.js
@@ -1,5 +1,6 @@
import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} from "d3";
import {ascendingDefined} from "../defined.js";
+import {facetReindex, getter, expander} from "../facet.js";
import {field, column, maybeColumn, maybeZ, mid, range, valueof, maybeZero, one} from "../options.js";
import {basic} from "./basic.js";
@@ -146,24 +147,28 @@ function stack(x, y = one, ky, {offset, order, reverse}, options) {
offset = maybeOffset(offset);
order = maybeOrder(order, offset, ky);
return [
- basic(options, (data, facets) => {
+ basic(options, function (data, facets) {
const X = x == null ? undefined : setX(valueof(data, x));
const Y = valueof(data, y, Float64Array);
const Z = valueof(data, z);
const O = order && order(data, X, Y, Z);
- const n = data.length;
- const Y1 = setY1(new Float64Array(n));
- const Y2 = setY2(new Float64Array(n));
+
+ // make facets exclusive
+ facets = facetReindex(facets, data.length);
+ const Y1 = setY1(new Float64Array((facets.plan || data).length));
+ const Y2 = setY2(new Float64Array((facets.plan || data).length));
+
const facetstacks = [];
for (const facet of facets) {
- const stacks = X ? Array.from(group(facet, (i) => X[i]).values()) : [facet];
- if (O) applyOrder(stacks, O);
+ const stacks = X ? Array.from(group(facet, getter(facets, X)).values()) : [facet];
+ const getY = getter(facets, Y);
+ if (O) applyOrder(stacks, getter(facets, O));
for (const stack of stacks) {
let yn = 0,
yp = 0;
if (reverse) stack.reverse();
for (const i of stack) {
- const y = Y[i];
+ const y = getY(i);
if (y < 0) yn = Y2[i] = (Y1[i] = yn) + y;
else if (y > 0) yp = Y2[i] = (Y1[i] = yp) + y;
else Y2[i] = Y1[i] = yp; // NaN or zero
@@ -171,7 +176,7 @@ function stack(x, y = one, ky, {offset, order, reverse}, options) {
}
facetstacks.push(stacks);
}
- if (offset) offset(facetstacks, Y1, Y2, Z);
+ if (offset) offset(facetstacks, Y1, Y2, expander(facets, Z));
return {data, facets};
}),
X,
@@ -396,8 +401,8 @@ function orderZDomain(Z, domain) {
return Z.map((z) => domain.get(z));
}
-function applyOrder(stacks, O) {
+function applyOrder(stacks, o) {
for (const stack of stacks) {
- stack.sort((i, j) => ascendingDefined(O[i], O[j]));
+ stack.sort((i, j) => ascendingDefined(o(i), o(j)));
}
}
diff --git a/src/transforms/tree.js b/src/transforms/tree.js
index 830d6a5c6c..da2e99ac51 100644
--- a/src/transforms/tree.js
+++ b/src/transforms/tree.js
@@ -2,6 +2,7 @@ import {stratify, tree} from "d3";
import {ascendingDefined} from "../defined.js";
import {column, identity, isObject, one, valueof} from "../options.js";
import {basic} from "./basic.js";
+import {getter} from "../facet.js";
/**
* Based on the tree options described above, populates the **x** and **y**
@@ -50,20 +51,20 @@ export function treeNode(options = {}) {
y: Y,
frameAnchor,
...basic(remainingOptions, (data, facets) => {
- const P = normalize(valueof(data, path));
+ const gP = getter(facets, normalize(valueof(data, path)));
const X = setX([]);
const Y = setY([]);
let treeIndex = -1;
const treeData = [];
const treeFacets = [];
- const rootof = stratify().path((i) => P[i]);
+ const rootof = stratify().path(gP);
const layout = treeLayout();
if (layout.nodeSize) layout.nodeSize([1, 1]);
if (layout.separation && treeSeparation !== undefined) layout.separation(treeSeparation ?? one);
for (const o of outputs) o[output_values] = o[output_setValues]([]);
for (const facet of facets) {
const treeFacet = [];
- const root = rootof(facet.filter((i) => P[i] != null)).each((node) => (node.data = data[node.data]));
+ const root = rootof(facet.filter((i) => gP(i) != null)).each((node) => (node.data = data[node.data]));
if (treeSort != null) root.sort(treeSort);
layout(root);
for (const node of root.descendants()) {
diff --git a/test/output/generativeRoses.svg b/test/output/generativeRoses.svg
new file mode 100644
index 0000000000..e4a7205c79
--- /dev/null
+++ b/test/output/generativeRoses.svg
@@ -0,0 +1,296 @@
+
\ No newline at end of file
diff --git a/test/output/hexbinExclude.svg b/test/output/hexbinExclude.svg
new file mode 100644
index 0000000000..ca4edb5e0f
--- /dev/null
+++ b/test/output/hexbinExclude.svg
@@ -0,0 +1,401 @@
+
\ No newline at end of file
diff --git a/test/output/musicRevenueBars.svg b/test/output/musicRevenueBars.svg
new file mode 100644
index 0000000000..67d0421b08
--- /dev/null
+++ b/test/output/musicRevenueBars.svg
@@ -0,0 +1,9986 @@
+
\ No newline at end of file
diff --git a/test/output/musicRevenueBin.svg b/test/output/musicRevenueBin.svg
new file mode 100644
index 0000000000..a55a967481
--- /dev/null
+++ b/test/output/musicRevenueBin.svg
@@ -0,0 +1,2318 @@
+
\ No newline at end of file
diff --git a/test/output/musicRevenueGroup.svg b/test/output/musicRevenueGroup.svg
new file mode 100644
index 0000000000..6f2f3eadba
--- /dev/null
+++ b/test/output/musicRevenueGroup.svg
@@ -0,0 +1,484 @@
+
\ No newline at end of file
diff --git a/test/output/musicRevenueWiggle.svg b/test/output/musicRevenueWiggle.svg
new file mode 100644
index 0000000000..f446964356
--- /dev/null
+++ b/test/output/musicRevenueWiggle.svg
@@ -0,0 +1,412 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCumsumExclude.svg b/test/output/penguinCumsumExclude.svg
new file mode 100644
index 0000000000..3e0fb57005
--- /dev/null
+++ b/test/output/penguinCumsumExclude.svg
@@ -0,0 +1,806 @@
+
\ No newline at end of file
diff --git a/test/output/penguinDodgeReindexed.svg b/test/output/penguinDodgeReindexed.svg
new file mode 100644
index 0000000000..c6a90c6451
--- /dev/null
+++ b/test/output/penguinDodgeReindexed.svg
@@ -0,0 +1,746 @@
+
\ No newline at end of file
diff --git a/test/output/stackExclude.svg b/test/output/stackExclude.svg
new file mode 100644
index 0000000000..1631981e99
--- /dev/null
+++ b/test/output/stackExclude.svg
@@ -0,0 +1,68 @@
+
\ No newline at end of file
diff --git a/test/plots/generative-roses.js b/test/plots/generative-roses.js
new file mode 100644
index 0000000000..cb9d2df848
--- /dev/null
+++ b/test/plots/generative-roses.js
@@ -0,0 +1,40 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+// Generate roses from a cumulative sum of vectors with various angles. The
+// twist is that each facet selects a subset of these angles to ignore (with
+// facet: "exclude").
+export default async function () {
+ const data = d3.range(0, 48, 0.7);
+ const mapped = Plot.mapY(
+ "cumsum",
+ Plot.mapX("cumsum", {
+ facet: "exclude",
+ x: Math.sin,
+ y: Math.cos
+ })
+ );
+ return Plot.plot({
+ facet: {
+ data,
+ x: (d, i) => (i % 8) % 3,
+ y: (d, i) => Math.floor((i % 8) / 3),
+ marginRight: 80
+ },
+ axis: null,
+ marks: [
+ Plot.line(data, {...mapped, curve: "natural"}),
+ ["First", "Last", "MaxX", "MinX", "MaxY", "MinY"].map((p) =>
+ Plot.dot(
+ data,
+ Plot[`select${p}`]({
+ ...mapped,
+ fill: () => p,
+ title: () => p,
+ r: 6
+ })
+ )
+ )
+ ]
+ });
+}
diff --git a/test/plots/hexbin-exclude.js b/test/plots/hexbin-exclude.js
new file mode 100644
index 0000000000..c862a43cf0
--- /dev/null
+++ b/test/plots/hexbin-exclude.js
@@ -0,0 +1,45 @@
+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);
+ const noise = d3.randomNormal.source(d3.randomLcg(42))(0, 0.1);
+ return Plot.plot({
+ width: 960,
+ height: 320,
+ inset: 14,
+ facet: {
+ data: penguins,
+ x: "species",
+ marginRight: 80
+ },
+ marks: [
+ Plot.frame(),
+ Plot.dot(
+ penguins,
+ Plot.hexbin(
+ {fillOpacity: "count"},
+ Plot.map(
+ {
+ x: (X) => X.map((d) => d + noise()),
+ y: (Y) => Y.map((d) => d + noise())
+ },
+ {
+ x: "culmen_depth_mm",
+ y: "culmen_length_mm",
+ fill: "species",
+ facet: "exclude"
+ }
+ )
+ )
+ ),
+ Plot.dot(
+ penguins,
+ Plot.hexbin(
+ {fillOpacity: "count"},
+ {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "species", stroke: "species"}
+ )
+ )
+ ]
+ });
+}
diff --git a/test/plots/index.js b/test/plots/index.js
index 9a94cecfee..dbd041ec4c 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -75,6 +75,7 @@ export {default as footballCoverage} from "./football-coverage.js";
export {default as frameCorners} from "./frame-corners.js";
export {default as fruitSales} from "./fruit-sales.js";
export {default as fruitSalesDate} from "./fruit-sales-date.js";
+export {default as generativeRoses} from "./generative-roses.js";
export {default as gistempAnomaly} from "./gistemp-anomaly.js";
export {default as gistempAnomalyMoving} from "./gistemp-anomaly-moving.js";
export {default as gistempAnomalyTransform} from "./gistemp-anomaly-transform.js";
@@ -85,6 +86,7 @@ export {default as gridChoroplethDx} from "./grid-choropleth-dx.js";
export {default as groupedRects} from "./grouped-rects.js";
export {default as hadcrutWarmingStripes} from "./hadcrut-warming-stripes.js";
export {default as hexbin} from "./hexbin.js";
+export {default as hexbinExclude} from "./hexbin-exclude.js";
export {default as hexbinOranges} from "./hexbin-oranges.js";
export {default as hexbinR} from "./hexbin-r.js";
export {default as hexbinSymbol} from "./hexbin-symbol.js";
@@ -134,6 +136,10 @@ export {default as mobyDickLetterRelativeFrequency} from "./moby-dick-letter-rel
export {default as morleyBoxplot} from "./morley-boxplot.js";
export {default as moviesProfitByGenre} from "./movies-profit-by-genre.js";
export {default as musicRevenue} from "./music-revenue.js";
+export {default as musicRevenueBars} from "./music-revenue-bars.js";
+export {default as musicRevenueBin} from "./music-revenue-bin.js";
+export {default as musicRevenueGroup} from "./music-revenue-group.js";
+export {default as musicRevenueWiggle} from "./music-revenue-wiggle.js";
export {default as ordinalBar} from "./ordinal-bar.js";
export {default as penguinAnnotated} from "./penguin-annotated.js";
export {default as penguinCulmen} from "./penguin-culmen.js";
@@ -142,12 +148,13 @@ export {default as penguinCulmenDelaunay} from "./penguin-culmen-delaunay.js";
export {default as penguinCulmenDelaunayMesh} from "./penguin-culmen-delaunay-mesh.js";
export {default as penguinCulmenDelaunaySpecies} from "./penguin-culmen-delaunay-species.js";
export {default as penguinCulmenVoronoi} from "./penguin-culmen-voronoi.js";
-export {default as penguinVoronoi1D} from "./penguin-voronoi-1d.js";
+export {default as penguinCumsumExclude} from "./penguin-cumsum-exclude.js";
export {default as penguinDensity} from "./penguin-density.js";
export {default as penguinDensityFill} from "./penguin-density-fill.js";
export {default as penguinDensityZ} from "./penguin-density-z.js";
export {default as penguinDodge} from "./penguin-dodge.js";
export {default as penguinDodgeHexbin} from "./penguin-dodge-hexbin.js";
+export {default as penguinDodgeReindexed} from "./penguin-dodge-reindexed.js";
export {default as penguinDodgeVoronoi} from "./penguin-dodge-voronoi.js";
export {default as penguinFacetDodge} from "./penguin-facet-dodge.js";
export {default as penguinFacetDodgeIdentity} from "./penguin-facet-dodge-identity.js";
@@ -167,6 +174,7 @@ export {default as penguinSpeciesGroup} from "./penguin-species-group.js";
export {default as penguinSpeciesIsland} from "./penguin-species-island.js";
export {default as penguinSpeciesIslandRelative} from "./penguin-species-island-relative.js";
export {default as penguinSpeciesIslandSex} from "./penguin-species-island-sex.js";
+export {default as penguinVoronoi1D} from "./penguin-voronoi-1d.js";
export {default as polylinear} from "./polylinear.js";
export {default as randomBins} from "./random-bins.js";
export {default as randomBinsXY} from "./random-bins-xy.js";
@@ -207,6 +215,7 @@ export {default as singleValueBar} from "./single-value-bar.js";
export {default as singleValueBin} from "./single-value-bin.js";
export {default as softwareVersions} from "./software-versions.js";
export {default as sparseCell} from "./sparse-cell.js";
+export {default as stackExclude} from "./stack-exclude.js";
export {default as stackedBar} from "./stacked-bar.js";
export {default as stackedRect} from "./stacked-rect.js";
export {default as stargazers} from "./stargazers.js";
diff --git a/test/plots/music-revenue-bars.js b/test/plots/music-revenue-bars.js
new file mode 100644
index 0000000000..e29eda1572
--- /dev/null
+++ b/test/plots/music-revenue-bars.js
@@ -0,0 +1,49 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const data = await d3.csv("data/riaa-us-revenue.csv", d3.autoType);
+ const stack = {x: (d) => d["year"].getFullYear(), y: "revenue", z: "format", order: "value", reverse: true};
+ return Plot.plot({
+ marginRight: 90,
+ marginBottom: 35,
+ facet: {data, y: "group", marginRight: 90},
+ x: {ticks: d3.range(1975, 2020, 5), tickFormat: ""},
+ y: {
+ grid: true,
+ label: "↑ Annual revenue (billions, adj.)",
+ transform: (d) => d / 1000,
+ nice: true
+ },
+ marks: [
+ Plot.frame(),
+ Plot.barY(
+ data,
+ Plot.groupX(
+ {y: "sum"},
+ Plot.windowY({
+ ...stack,
+ k: 3,
+ y: (d) => -d.revenue,
+ fill: "group",
+ facet: "exclude",
+ order: "sum"
+ })
+ )
+ ),
+ Plot.barY(
+ data,
+ Plot.groupX(
+ {y: "sum"},
+ Plot.windowY({
+ ...stack,
+ k: 3,
+ fill: "group",
+ title: (d) => `${d.format}\n${d.group}`
+ })
+ )
+ ),
+ Plot.ruleY([0])
+ ]
+ });
+}
diff --git a/test/plots/music-revenue-bin.js b/test/plots/music-revenue-bin.js
new file mode 100644
index 0000000000..adfa085c44
--- /dev/null
+++ b/test/plots/music-revenue-bin.js
@@ -0,0 +1,48 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const data = await d3.csv("data/riaa-us-revenue.csv", d3.autoType);
+ const stack = {x: "year", y: "revenue", z: "format", order: "value", reverse: true};
+ return Plot.plot({
+ marginRight: 90,
+ facet: {data, y: "group", marginRight: 90},
+ y: {
+ grid: true,
+ label: "↑ Annual revenue (billions, adj.)",
+ transform: (d) => d / 1000,
+ nice: true
+ },
+ marks: [
+ Plot.frame(),
+ Plot.rectY(
+ data,
+ Plot.binX(
+ {y: "sum"},
+ Plot.windowY({
+ ...stack,
+ k: 7,
+ interval: d3.utcYear.every(5),
+ y: (d) => -d.revenue,
+ fill: "#eee",
+ facet: "exclude"
+ })
+ )
+ ),
+ Plot.rectY(
+ data,
+ Plot.binX(
+ {y: "sum"},
+ Plot.windowY({
+ ...stack,
+ k: 7,
+ interval: d3.utcYear.every(5),
+ fill: "group",
+ title: (d) => `${d.format}\n${d.group}`
+ })
+ )
+ ),
+ Plot.ruleY([0])
+ ]
+ });
+}
diff --git a/test/plots/music-revenue-group.js b/test/plots/music-revenue-group.js
new file mode 100644
index 0000000000..e5a3662eda
--- /dev/null
+++ b/test/plots/music-revenue-group.js
@@ -0,0 +1,32 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const data = await d3.csv("data/riaa-us-revenue.csv", d3.autoType);
+ const stack = {x: "year", y: "revenue", z: "format", order: "appearance", reverse: true};
+ return Plot.plot({
+ marginRight: 90,
+ facet: {data, y: "group", marginRight: 90},
+ y: {
+ grid: true,
+ label: "↑ Annual revenue (billions, adj.)",
+ transform: (d) => d / 1000
+ },
+ marks: [
+ Plot.areaY(data, Plot.stackY({...stack, fill: "group", title: (d) => `${d.format}\n${d.group}`})),
+ Plot.areaY(
+ data,
+ Plot.stackY({
+ ...stack,
+ y: (d) => -1 - d.revenue,
+ fill: "#eee",
+ stroke: "#fff",
+ facet: "exclude"
+ })
+ ),
+ Plot.lineY(data, Plot.stackY2({...stack, stroke: "white", strokeWidth: 1})),
+ Plot.ruleY([0]),
+ Plot.frame()
+ ]
+ });
+}
diff --git a/test/plots/music-revenue-wiggle.js b/test/plots/music-revenue-wiggle.js
new file mode 100644
index 0000000000..0bee1b535c
--- /dev/null
+++ b/test/plots/music-revenue-wiggle.js
@@ -0,0 +1,40 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const data = await d3.csv("data/riaa-us-revenue.csv", d3.autoType);
+ const stack = {
+ x: "year",
+ y: "revenue",
+ z: "format",
+ // order and offset are used to test that these options follow facet reindexation
+ order: ["Cassette", "Paid Subscription"],
+ offset: "wiggle",
+ reverse: true
+ };
+ return Plot.plot({
+ marginRight: 90,
+ facet: {data, y: "group", marginRight: 90},
+ y: {
+ grid: true,
+ label: "↑ Annual revenue (billions, adj.)",
+ transform: (d) => d / 1000
+ },
+ marks: [
+ Plot.areaY(
+ data,
+ Plot.stackY({
+ ...stack,
+ y: (d) => -1 - d.revenue,
+ fill: "#eee",
+ stroke: "#fff",
+ facet: "exclude"
+ })
+ ),
+ Plot.areaY(data, Plot.stackY({...stack, fill: "group", title: (d) => `${d.format}\n${d.group}`})),
+ Plot.lineY(data, Plot.stackY2({...stack, stroke: "white", strokeWidth: 1})),
+ Plot.ruleY([0]),
+ Plot.frame()
+ ]
+ });
+}
diff --git a/test/plots/penguin-cumsum-exclude.js b/test/plots/penguin-cumsum-exclude.js
new file mode 100644
index 0000000000..d3f665a6be
--- /dev/null
+++ b/test/plots/penguin-cumsum-exclude.js
@@ -0,0 +1,37 @@
+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({
+ facet: {data: penguins, x: "island"},
+ width: 860,
+ height: 300,
+ y: {nice: true},
+ marks: [
+ Plot.frame(),
+ Plot.ruleY([0]),
+ Plot.dot(
+ penguins,
+ Plot.mapY(
+ "cumsum",
+ Plot.sort("body_mass_g", {x: "body_mass_g", y: -1, fill: "island", facet: "exclude", z: null, r: 1})
+ )
+ ),
+ Plot.lineY(
+ penguins,
+ Plot.mapY(
+ "cumsum",
+ Plot.sort("body_mass_g", {
+ x: "body_mass_g",
+ y: 1,
+ strokeWidth: 2,
+ stroke: "island",
+ facet: "include",
+ z: null
+ })
+ )
+ )
+ ]
+ });
+}
diff --git a/test/plots/penguin-dodge-reindexed.js b/test/plots/penguin-dodge-reindexed.js
new file mode 100644
index 0000000000..e4d7b76480
--- /dev/null
+++ b/test/plots/penguin-dodge-reindexed.js
@@ -0,0 +1,11 @@
+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({
+ facet: {data: penguins, y: "island"},
+ height: 400,
+ marks: [Plot.dot(penguins, Plot.dodgeY({x: "body_mass_g", facet: "exclude", fill: "island"}))]
+ });
+}
diff --git a/test/plots/stack-exclude.js b/test/plots/stack-exclude.js
new file mode 100644
index 0000000000..e2f2afdc81
--- /dev/null
+++ b/test/plots/stack-exclude.js
@@ -0,0 +1,26 @@
+import * as Plot from "@observablehq/plot";
+
+export default async function () {
+ const data = Float64Array.of(1, 2, 3);
+ const facets = ["a", "b", "c"];
+ return Plot.plot({
+ height: 180,
+ facet: {data, x: facets},
+ marks: [
+ Plot.barY(data, {
+ stroke: (d) => d, // channel as accessor
+ fill: data, // channel as array
+ fillOpacity: 0.5,
+ facet: "exclude"
+ }),
+ Plot.textY(
+ data,
+ Plot.stackY({
+ y: data,
+ text: (d, i) => i, // the original index
+ facet: "exclude"
+ })
+ )
+ ]
+ });
+}