diff --git a/src/options.js b/src/options.js
index f70a118ef8..3fdcbd8bff 100644
--- a/src/options.js
+++ b/src/options.js
@@ -7,6 +7,10 @@ import {maybeTimeInterval, maybeUtcInterval} from "./time.js";
export const TypedArray = Object.getPrototypeOf(Uint8Array);
const objectToString = Object.prototype.toString;
+// If a reindex is attached to the data, channel values expressed as arrays will
+// be reindexed when the channels are instantiated. See exclusiveFacets.
+export const reindex = Symbol("reindex");
+
export function valueof(data, value, type) {
const valueType = typeof value;
return valueType === "string"
@@ -17,7 +21,11 @@ export function valueof(data, value, type) {
? map(data, constant(value), type)
: typeof value?.transform === "function"
? maybeTypedArrayify(value.transform(data), type)
- : maybeTypedArrayify(value, type);
+ : maybeTake(maybeTypedArrayify(value, type), data?.[reindex]);
+}
+
+function maybeTake(values, index) {
+ return index ? take(values, index) : values;
}
function maybeTypedMap(data, f, type) {
@@ -170,6 +178,7 @@ export function isScaleOptions(option) {
// Disambiguates an options object (e.g., {y: "x2"}) from a channel value
// definition expressed as a channel transform (e.g., {transform: …}).
+// TODO Check typeof option[Symbol.iterator] !== "function"?
export function isOptions(option) {
return isObject(option) && typeof option.transform !== "function";
}
@@ -223,7 +232,7 @@ export function where(data, test) {
// Returns an array [values[index[0]], values[index[1]], …].
export function take(values, index) {
- return map(index, (i) => values[i]);
+ return map(index, (i) => values[i], values.constructor);
}
// If f does not take exactly one argument, wraps it in a function that uses take.
diff --git a/src/transforms/exclusiveFacets.js b/src/transforms/exclusiveFacets.js
new file mode 100644
index 0000000000..facf94bfb8
--- /dev/null
+++ b/src/transforms/exclusiveFacets.js
@@ -0,0 +1,40 @@
+import {reindex, slice} from "../options.js";
+
+export function exclusiveFacets(data, facets) {
+ if (facets.length === 1) return {data, facets}; // only one facet; trivially exclusive
+
+ const n = data.length;
+ const O = new Uint8Array(n);
+ let overlaps = 0;
+
+ // Count the number of overlapping indexes across facets.
+ for (const facet of facets) {
+ for (const i of facet) {
+ if (O[i]) ++overlaps;
+ O[i] = 1;
+ }
+ }
+
+ // Do nothing if the facets are already exclusive.
+ if (overlaps === 0) return {data, facets}; // facets are exclusive
+
+ // For each overlapping index (duplicate), assign a new unique index at the
+ // end of the existing array, duplicating the datum. For example, [[0, 1, 2],
+ // [2, 1, 3]] would become [[0, 1, 2], [4, 5, 3]]. Also attach a reindex to
+ // the data to preserve the association of channel values specified as arrays.
+ data = slice(data);
+ const R = (data[reindex] = new Uint32Array(n + overlaps));
+ facets = facets.map((facet) => slice(facet, Uint32Array));
+ let j = n;
+ O.fill(0);
+ for (const facet of facets) {
+ for (let k = 0, m = facet.length; k < m; ++k) {
+ const i = facet[k];
+ if (O[i]) (facet[k] = j), (data[j] = data[i]), (R[j] = i), ++j;
+ else R[i] = i;
+ O[i] = 1;
+ }
+ }
+
+ return {data, facets};
+}
diff --git a/src/transforms/stack.js b/src/transforms/stack.js
index 25d676fe2f..9c96997af0 100644
--- a/src/transforms/stack.js
+++ b/src/transforms/stack.js
@@ -4,6 +4,7 @@ import {withTip} from "../mark.js";
import {maybeApplyInterval, maybeColumn, maybeZ, maybeZero} from "../options.js";
import {column, field, mid, one, range, valueof} from "../options.js";
import {basic} from "./basic.js";
+import {exclusiveFacets} from "./exclusiveFacets.js";
export function stackX(stackOptions = {}, options = {}) {
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
@@ -85,6 +86,7 @@ function stack(x, y = one, kx, ky, {offset, order, reverse}, options) {
order = maybeOrder(order, offset, ky);
return [
basic(options, (data, facets, plotOptions) => {
+ ({data, facets} = exclusiveFacets(data, facets));
const X = x == null ? undefined : setX(maybeApplyInterval(valueof(data, x), plotOptions?.[kx]));
const Y = valueof(data, y, Float64Array);
const Z = valueof(data, z);
diff --git a/test/output/facetReindex.svg b/test/output/facetReindex.svg
new file mode 100644
index 0000000000..db90254da3
--- /dev/null
+++ b/test/output/facetReindex.svg
@@ -0,0 +1,1136 @@
+
\ No newline at end of file
diff --git a/test/plots/facet-reindex.ts b/test/plots/facet-reindex.ts
new file mode 100644
index 0000000000..1d77563861
--- /dev/null
+++ b/test/plots/facet-reindex.ts
@@ -0,0 +1,34 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export async function facetReindex() {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ const island = Plot.valueof(penguins, "island");
+ return Plot.plot({
+ width: 830,
+ marginLeft: 74,
+ marginRight: 68,
+ height: 130,
+ x: {domain: [0, penguins.length], round: true},
+ y: {label: "facet option", axis: "right"},
+ facet: {data: penguins, y: island},
+ fy: {label: "facet value"},
+ marks: [
+ Plot.barX(penguins, {
+ facet: "exclude",
+ fill: island, // array channel to be reindexed
+ x: 1,
+ y: () => "exclude",
+ fillOpacity: 0.5,
+ insetRight: 0.5
+ }),
+ Plot.barX(penguins, {
+ facet: "include",
+ fill: island,
+ x: 1,
+ y: () => "include"
+ }),
+ Plot.frame()
+ ]
+ });
+}
diff --git a/test/plots/index.ts b/test/plots/index.ts
index 65c6cfb5c6..82b3c2e88a 100644
--- a/test/plots/index.ts
+++ b/test/plots/index.ts
@@ -80,6 +80,7 @@ export * from "./empty-legend.js";
export * from "./empty-x.js";
export * from "./empty.js";
export * from "./energy-production.js";
+export * from "./facet-reindex.js";
export * from "./faithful-density-1d.js";
export * from "./faithful-density.js";
export * from "./federal-funds.js";