Skip to content

cumulative facet filters #1089

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

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,15 @@ Faceting can be explicitly enabled or disabled on a mark with the *facet* option
* *include* (or true) - draw the subset of the mark’s data in the current facet
* *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)
* an object with a xFilter or yFilter option

The facet filter 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({
Expand All @@ -716,6 +725,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 for each individual mark by specifying the channel options **fx** or **fy**. In that case, the **facet** option only considers the mark data, and the default *auto* setting is equivalent to *include*. Other values of the *facet* option are unchanged: null or false disable faceting, and *exclude* draws the subset of the mark’s data *not* in the current facet.

```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
88 changes: 88 additions & 0 deletions src/facet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {group, intersection, sort} from "d3";
import {arrayify, keyword, range} from "./options.js";

export function maybeFacet({fx, fy, xFilter, yFilter, facet = "auto"} = {}) {
if (facet === null || facet === false) return null;
if (facet === true) facet = "include";
return {
x: fx,
y: fy,
xFilter: maybeFacetFilter(xFilter),
yFilter: maybeFacetFilter(yFilter),
method: keyword(facet, "facet", ["auto", "include", "exclude"])
};
}

// Facet filter, by mark; if a filter is provided, sort the keys and apply.
export function filterFacets(facets, {fx, fy}, {xFilter, yFilter}) {
const X = fx != null && fx.value;
const Y = fy != null && fy.value;
const index = range(X || Y);
const gx = filteredFacet(X && group(index, (i) => X[i]), xFilter);
const gy = filteredFacet(Y && group(index, (i) => Y[i]), yFilter);

return X && Y
? facets.map(({x, y}) => arrayify(intersection(gx(x), gy(y))))
: X
? facets.map(({x}) => gx(x) ?? [])
: facets.map(({y}) => gy(y) ?? []);
}

// Returns keys in order of the associated scale’s domains.
export function facetKeys(facets, {fx, fy}) {
const fxI = fx && new Map(fx.domain().map((x, i) => [x, i]));
const fyI = fy && new Map(fy.domain().map((y, i) => [y, i]));
return sort(
facets,
({x: xa, y: ya}, {x: xb, y: yb}) => (fxI && fxI.get(xa) - fxI.get(xb)) || (fyI && fyI.get(ya) - fyI.get(yb))
);
}

// Returns a (possibly nested) Map of [[key1, index1], [key2, index2], …]
// representing the data indexes associated with each facet.
export function facetGroups(index, {fx, fy}) {
return fx && fy ? facetGroup2(index, fx, fy) : fx ? facetGroup1(index, fx) : facetGroup1(index, fy);
}

function facetGroup1(index, {value: F}) {
return group(index, (i) => F[i]);
}

function facetGroup2(index, {value: FX}, {value: FY}) {
return group(
index,
(i) => FX[i],
(i) => FY[i]
);
}

export function facetTranslate(fx, fy) {
return fx && fy
? ({x, y}) => `translate(${fx(x)},${fy(y)})`
: fx
? ({x}) => `translate(${fx(x)},0)`
: ({y}) => `translate(0,${fy(y)})`;
}

function filteredFacet(g, test) {
if (!g) return;
if (test === undefined) return (x) => g.get(x) ?? [];
return (x) => Array.from(g, ([key, index]) => (test(key, x) ? index : [])).flat();
}

function maybeFacetFilter(filter = "eq") {
if (typeof filter === "function" && filter.length === 2) return filter;
switch (filter) {
case "eq":
return undefined;
case "lte":
return (a, b) => a <= b;
case "lt":
return (a, b) => a < b;
case "gte":
return (a, b) => a >= b;
case "gt":
return (a, b) => a > b;
}
throw new Error(`unsupported facet filter: ${filter}`);
}
Loading