Skip to content

Revisit facets #1041

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,16 @@ Faceting can be explicitly enabled or disabled on a mark with the *facet* option
* *exclude* - draw the subset of the mark’s data *not* in the current facet
* null (or false) - repeat this mark’s data across all facets (i.e., no faceting)

By default, the data points shown in each facet are those that exactly match the facet value. Use the **xFilter** and **yFilter** options to change that behavior for, respectively, the *x* and *y* facets:

The xFilter or yFilter option can be one of:
* *eq* (default) - the data points shown in each facet are those that exactly match the facet value
* *lte* - the data points shown in each facet are those that are lower than or equal to the facet value
* *gte* - the data points shown in each facet are those that are greater than or equal to the facet value
* *lt* - the data points shown in each facet are those that are lower than the facet value
* *gt* - the data points shown in each facet are those that are greater than the facet value
* a function which takes as input the value of the data point and the facet value, and returns whether the data point is present in the facet

```js
Plot.plot({
facet: {
Expand All @@ -716,6 +726,21 @@ Plot.plot({

When the *include* or *exclude* facet mode is chosen, the mark data must be parallel to the facet data: the mark data must have the same length and order as the facet data. If the data are not parallel, then the wrong data may be shown in each facet. The default *auto* therefore requires strict equality (`===`) for safety, and using the facet data as mark data is recommended when using the *exclude* facet mode. (To construct parallel data safely, consider using [*array*.map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) on the facet data.)

Alternatively, facets can be defined from each mark by specifying the channel options **fx** or **fy**.

```js
Plot.plot({
marks: [
Plot.dot(penguins, {
x: "culmen_length_mm",
y: "culmen_depth_mm",
fx: "sex",
fy: "island"
})
]
})
```

## Legends

Plot can generate legends for *color*, *opacity*, and *symbol* [scales](#scale-options). (An opacity scale is treated as a color scale with varying transparency.) For an inline legend, use the *scale*.**legend** option:
Expand Down
141 changes: 141 additions & 0 deletions src/facet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {keyword, isTypedArray, range, slice} from "./options.js";
import {warn} from "./warnings.js";

// facet filter, by mark
export function filterFacets(facetCells, {xFilter, yFilter}, {fx, fy}, facetChannels) {
const vx = fx != null ? fx.value : facetChannels?.fx?.value;
const vy = fy != null ? fy.value : facetChannels?.fy?.value;
if (!vx && !vy) return; // ignore facet filter without facets
const I = range(vx || vy);
return facetCells.map(([x, y]) => {
let index = I;
if (vx) index = facetFilter(xFilter, "x")(index, vx, x);
if (vy) index = facetFilter(yFilter, "y")(index, vy, y);
return index;
});
}

export function maybeFacet(options) {
const {fx, xFilter, fy, yFilter, facet = "auto"} = options;
if (fx !== undefined || fy !== undefined || xFilter !== undefined || yFilter !== undefined)
return {x: fx, xFilter, y: fy, yFilter};
if (facet === null || facet === false) return null;
if (facet === true) return "include";
if (typeof facet === "string") return keyword(facet, "facet", ["auto", "include", "exclude"]);
if (facet) throw new Error(`Unsupported facet ${facet}`);
}

function facetFilter(filter = "eq", x) {
if (typeof filter === "function") return facetFunction(filter);
switch (`${filter}`.toLowerCase()) {
case "lt":
return facetLt;
case "lte":
return facetLte;
case "gt":
return facetGt;
case "gte":
return facetGte;
case "eq":
return facetEq;
}
throw new Error(`invalid ${x} filter: ${filter}`);
}

function facetFunction(f) {
return (I, T, facet) => {
return I.filter((i) => f(T[i], facet));
};
}

function facetLt(I, T, facet) {
return I.filter((i) => T[i] < facet);
}

function facetLte(I, T, facet) {
return I.filter((i) => T[i] <= facet);
}

function facetGt(I, T, facet) {
return I.filter((i) => T[i] > facet);
}

function facetGte(I, T, facet) {
return I.filter((i) => T[i] >= facet);
}

function facetEq(I, T, facet) {
return I.filter((i) => facetKeyEquals(T[i], facet));
}

// This must match the key structure of facetCells
export function facetTranslate(fx, fy) {
return fx && fy
? ([kx, ky]) => `translate(${fx(kx)},${fy(ky)})`
: fx
? ([kx]) => `translate(${fx(kx)},0)`
: ([, ky]) => `translate(0,${fy(ky)})`;
}

export function facetReindex(facets, data, channels) {
const n = data.length;

// Survey all indices which belong to multiple 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;
}
}

// Create a new index for each of them, and update the facets accordingly.
// Expand the data array to match. If any channel is specified as an array,
// expand it as well, taking care not to mutate the original channels
if (n + count > 2 ** 30) {
warn("This transform implies a combinatorial extension that exceeds capacity. Please change the facet filter.");
} else if (count > 0) {
facets = facets.map((facet) => slice(facet, Uint32Array));
const reindex = new Uint32Array(count);
let c = 0;
overlap.fill(0);
for (const facet of facets) {
for (let k = 0; k < facet.length; ++k) {
const i = facet[k];
if (overlap[i]) {
reindex[c] = i;
facet[k] = n + c;
++c;
}
overlap[i] = 1;
}
}
data = expandArray(data, count);
for (let i = 0; i < count; ++i) data[n + i] = data[reindex[i]];
for (const key in channels) {
const A = channels[key].value;
if (Array.isArray(A) || isTypedArray(A)) {
channels = {...channels}; // avoid mutation
channels[key].value = (_, i) => A[i < n ? i : reindex[i - n]];
}
}
}

return {facets, data, channels};
}

// expands an array or typed array to make room for n more values
function expandArray(values, n) {
if (isTypedArray(values)) {
const d = new values.constructor(values.length + n);
d.set(values);
return d;
}
return slice(values);
}

// test is a value equals a facet key
export function facetKeyEquals(a, b) {
return a instanceof Date && b instanceof Date ? +a === +b : a === b;
}
Loading