From 8b9016a5da5b3e26c5ab0ebf934553e8e20f0d03 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 21 Aug 2023 01:18:31 -0700 Subject: [PATCH] exclusiveFacets (#1649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * exclusiveFacets * pad the data with duplicates * reindex * test * done * reindex iterables * reindex symbol --------- Co-authored-by: Philippe Rivière --- src/options.js | 13 +- src/transforms/exclusiveFacets.js | 40 + src/transforms/stack.js | 2 + test/output/facetReindex.svg | 1136 +++++++++++++++++++++++++++++ test/plots/facet-reindex.ts | 34 + test/plots/index.ts | 1 + 6 files changed, 1224 insertions(+), 2 deletions(-) create mode 100644 src/transforms/exclusiveFacets.js create mode 100644 test/output/facetReindex.svg create mode 100644 test/plots/facet-reindex.ts 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 @@ + + + + + Biscoe + + + Dream + + + Torgersen + + + + facet value + + + + + + + + + + + + + + + + + + exclude + include + + + exclude + include + + + exclude + include + + + + facet option + + + + + + + + + + + + + + + 0 + 50 + 100 + 150 + 200 + 250 + 300 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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";