diff --git a/README.md b/README.md index 4dd68991aa..9512711e57 100644 --- a/README.md +++ b/README.md @@ -955,6 +955,32 @@ The basic transforms are composable: the *filter* transform is applied first, th Plot’s option transforms, listed below, do more than populate the **transform** function: they derive new mark options and channels. These transforms take a mark’s *options* object (and possibly transform-specific options as the first argument) and return a new, transformed, *options*. Option transforms are composable: you can pass an *options* objects through more than one transform before passing it to a mark. You can also reuse the same transformed *options* on multiple marks. +The *filter*, *sort* and *reverse* transforms are also available as functions, allowing the order of operations to be specified explicitly. For example, sorting before binning results in sorted data inside bins, whereas sorting after binning results affects the *z*-order of rendered marks. + +### Plot.sort(*order*, *options*) + +```js +Plot.sort(d => d.value, options) // show data in ascending value order +``` + +Sorts the data by the specified *order*, which can be an acessor function, a comparator function, or a channel value definition. + +### Plot.reverse(*options*) + +```js +Plot.reverse(options) // reverse the input order +``` + +Reverses the order of the data. + +### Plot.filter(*test*, *options*) + +```js +Plot.filter(d => d.value > 3, options) // show data whose value is greater than three +``` + +Filters the data given the specified *test*. The test can be given as an accessor function (which receives the datum and index), or as a channel value definition; truthy values are retained. + ### Bin [a histogram of athletes by weight](https://observablehq.com/@observablehq/plot-bin) diff --git a/src/index.js b/src/index.js index 1583d4a352..9a4360df2c 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,9 @@ export {Rect, rect, rectX, rectY} from "./marks/rect.js"; export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; export {Text, text, textX, textY} from "./marks/text.js"; export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; +export {filter} from "./transforms/filter.js"; +export {reverse} from "./transforms/reverse.js"; +export {sort} from "./transforms/sort.js"; export {bin, binX, binY} from "./transforms/bin.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; export {normalizeX, normalizeY} from "./transforms/normalize.js"; diff --git a/src/mark.js b/src/mark.js index 662749574a..45cd137353 100644 --- a/src/mark.js +++ b/src/mark.js @@ -1,7 +1,8 @@ import {color} from "d3"; -import {ascendingDefined, nonempty} from "./defined.js"; +import {nonempty} from "./defined.js"; import {plot} from "./plot.js"; import {styles} from "./style.js"; +import {basic} from "./transforms/basic.js"; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray const TypedArray = Object.getPrototypeOf(Uint8Array); @@ -13,7 +14,7 @@ export class Mark { const names = new Set(); this.data = data; this.facet = facet ? keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]) : null; - const {transform} = maybeTransform(options); + const {transform} = basic(options); this.transform = transform; if (defaults !== undefined) channels = styles(this, options, channels, defaults); this.channels = channels.filter(channel => { @@ -225,23 +226,6 @@ export function maybeLazyChannel(source) { return source == null ? [source] : lazyChannel(source); } -// If both t1 and t2 are defined, returns a composite transform that first -// applies t1 and then applies t2. -export function maybeTransform({ - filter: f1, - sort: s1, - reverse: r1, - transform: t1, - ...options -} = {}, t2) { - if (t1 === undefined) { - if (f1 != null) t1 = filter(f1); - if (s1 != null) t1 = compose(t1, sort(s1)); - if (r1) t1 = compose(t1, reverse); - } - return {...options, transform: compose(t1, t2)}; -} - // Assuming that both x1 and x2 and lazy channels (per above), this derives a // new a channel that’s the average of the two, and which inherits the channel // label (if any). Both input channels are assumed to be quantitative. If either @@ -266,45 +250,6 @@ export function maybeValue(value) { typeof value.transform !== "function") ? value : {value}; } -function compose(t1, t2) { - if (t1 == null) return t2 === null ? undefined : t2; - if (t2 == null) return t1 === null ? undefined : t1; - return (data, facets) => { - ({data, facets} = t1(data, facets)); - return t2(arrayify(data), facets); - }; -} - -function sort(value) { - return (typeof value === "function" && value.length !== 1 ? sortCompare : sortValue)(value); -} - -function sortCompare(compare) { - return (data, facets) => { - const compareData = (i, j) => compare(data[i], data[j]); - return {data, facets: facets.map(I => I.slice().sort(compareData))}; - }; -} - -function sortValue(value) { - return (data, facets) => { - const V = valueof(data, value); - const compareValue = (i, j) => ascendingDefined(V[i], V[j]); - return {data, facets: facets.map(I => I.slice().sort(compareValue))}; - }; -} - -function filter(value) { - return (data, facets) => { - const V = valueof(data, value); - return {data, facets: facets.map(I => I.filter(i => V[i]))}; - }; -} - -function reverse(data, facets) { - return {data, facets: facets.map(I => I.slice().reverse())}; -} - export function numberChannel(source) { return { transform: data => valueof(data, source, Float64Array), diff --git a/src/transforms/basic.js b/src/transforms/basic.js new file mode 100644 index 0000000000..213206422e --- /dev/null +++ b/src/transforms/basic.js @@ -0,0 +1,21 @@ +import {composeTransform} from "./compose.js"; +import {filterTransform} from "./filter.js"; +import {reverseTransform} from "./reverse.js"; +import {sortTransform} from "./sort.js"; + +// If both t1 and t2 are defined, returns a composite transform that first +// applies t1 and then applies t2. +export function basic({ + filter: f1, + sort: s1, + reverse: r1, + transform: t1, + ...options +} = {}, t2) { + if (t1 === undefined) { // explicit transform overrides filter, sort, and reverse + if (f1 != null) t1 = filterTransform(f1); + if (s1 != null) t1 = composeTransform(t1, sortTransform(s1)); + if (r1) t1 = composeTransform(t1, reverseTransform); + } + return {...options, transform: composeTransform(t1, t2)}; +} diff --git a/src/transforms/bin.js b/src/transforms/bin.js index b3e068cc3f..3b65b9d2f0 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -1,6 +1,7 @@ import {bin as binner, extent, thresholdFreedmanDiaconis, thresholdScott, thresholdSturges, utcTickInterval} from "d3"; -import {valueof, range, identity, maybeLazyChannel, maybeTransform, maybeTuple, maybeColor, maybeValue, mid, labelof, isTemporal} from "../mark.js"; +import {valueof, range, identity, maybeLazyChannel, maybeTuple, maybeColor, maybeValue, mid, labelof, isTemporal} from "../mark.js"; import {offset} from "../style.js"; +import {basic} from "./basic.js"; import {maybeGroup, maybeOutputs, maybeReduce, maybeSubgroup, reduceIdentity} from "./group.js"; // Group on {z, fill, stroke}, then optionally on y, then bin x. @@ -69,7 +70,7 @@ function binn( ..."z" in inputs && {z: GZ || z}, ..."fill" in inputs && {fill: GF || fill}, ..."stroke" in inputs && {stroke: GS || stroke}, - ...maybeTransform(options, (data, facets) => { + ...basic(options, (data, facets) => { const K = valueof(data, k); const Z = valueof(data, z); const F = valueof(data, vfill); diff --git a/src/transforms/compose.js b/src/transforms/compose.js new file mode 100644 index 0000000000..7691d5f4f0 --- /dev/null +++ b/src/transforms/compose.js @@ -0,0 +1,10 @@ +import {arrayify} from "../mark.js"; + +export function composeTransform(t1, t2) { + if (t1 == null) return t2 === null ? undefined : t2; + if (t2 == null) return t1 === null ? undefined : t1; + return (data, facets) => { + ({data, facets} = t1(data, facets)); + return t2(arrayify(data), facets); + }; +} diff --git a/src/transforms/filter.js b/src/transforms/filter.js new file mode 100644 index 0000000000..ec4f9cf5ee --- /dev/null +++ b/src/transforms/filter.js @@ -0,0 +1,13 @@ +import {valueof} from "../mark.js"; +import {basic} from "./basic.js"; + +export function filter(value, options) { + return basic(options, filterTransform(value)); +} + +export function filterTransform(value) { + return (data, facets) => { + const V = valueof(data, value); + return {data, facets: facets.map(I => I.filter(i => V[i]))}; + }; +} diff --git a/src/transforms/group.js b/src/transforms/group.js index b3d7bcb5ae..553012cd36 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -1,6 +1,7 @@ import {group as grouper, sort, sum, deviation, min, max, mean, median, mode, variance, InternSet} from "d3"; import {firstof} from "../defined.js"; -import {valueof, maybeColor, maybeInput, maybeTransform, maybeTuple, maybeLazyChannel, lazyChannel, first, identity, take, labelof, range} from "../mark.js"; +import {valueof, maybeColor, maybeInput, maybeTuple, maybeLazyChannel, lazyChannel, first, identity, take, labelof, range} from "../mark.js"; +import {basic} from "./basic.js"; // Group on {z, fill, stroke}. export function groupZ(outputs, options) { @@ -57,7 +58,7 @@ function groupn( ..."z" in inputs && {z: GZ || z}, ..."fill" in inputs && {fill: GF || fill}, ..."stroke" in inputs && {stroke: GS || stroke}, - ...maybeTransform(options, (data, facets) => { + ...basic(options, (data, facets) => { const X = valueof(data, x); const Y = valueof(data, y); const Z = valueof(data, z); diff --git a/src/transforms/map.js b/src/transforms/map.js index 416b51712a..5f3d6c431f 100644 --- a/src/transforms/map.js +++ b/src/transforms/map.js @@ -1,5 +1,6 @@ import {group} from "d3"; -import {maybeTransform, maybeZ, take, valueof, maybeInput, lazyChannel} from "../mark.js"; +import {maybeZ, take, valueof, maybeInput, lazyChannel} from "../mark.js"; +import {basic} from "./basic.js"; export function mapX(m, options = {}) { return map(Object.fromEntries(["x", "x1", "x2"] @@ -22,7 +23,7 @@ export function map(outputs = {}, options = {}) { return {key, input, output, setOutput, map: maybeMap(map)}; }); return { - ...maybeTransform(options, (data, facets) => { + ...basic(options, (data, facets) => { const Z = valueof(data, z); const X = channels.map(({input}) => valueof(data, input)); const MX = channels.map(({setOutput}) => setOutput(new Array(data.length))); diff --git a/src/transforms/reverse.js b/src/transforms/reverse.js new file mode 100644 index 0000000000..adbc9701e7 --- /dev/null +++ b/src/transforms/reverse.js @@ -0,0 +1,9 @@ +import {basic} from "./basic.js"; + +export function reverse(options) { + return basic(options, reverseTransform); +} + +export function reverseTransform(data, facets) { + return {data, facets: facets.map(I => I.slice().reverse())}; +} diff --git a/src/transforms/select.js b/src/transforms/select.js index c42dfc4e44..f70f3729ea 100644 --- a/src/transforms/select.js +++ b/src/transforms/select.js @@ -1,5 +1,6 @@ import {greatest, group, least} from "d3"; -import {maybeTransform, maybeZ, valueof} from "../mark.js"; +import {maybeZ, valueof} from "../mark.js"; +import {basic} from "./basic.js"; export function selectFirst(options) { return select(first, undefined, options); @@ -53,7 +54,7 @@ function* max(I, X) { function select(selectIndex, v, options) { const z = maybeZ(options); - return maybeTransform(options, (data, facets) => { + return basic(options, (data, facets) => { const Z = valueof(data, z); const V = valueof(data, v); const selectFacets = []; diff --git a/src/transforms/sort.js b/src/transforms/sort.js new file mode 100644 index 0000000000..b36dee21f9 --- /dev/null +++ b/src/transforms/sort.js @@ -0,0 +1,26 @@ +import {ascendingDefined} from "../defined.js"; +import {valueof} from "../mark.js"; +import {basic} from "./basic.js"; + +export function sort(value, options) { + return basic(options, sortTransform(value)); +} + +export function sortTransform(value) { + return (typeof value === "function" && value.length !== 1 ? sortCompare : sortValue)(value); +} + +function sortCompare(compare) { + return (data, facets) => { + const compareData = (i, j) => compare(data[i], data[j]); + return {data, facets: facets.map(I => I.slice().sort(compareData))}; + }; +} + +function sortValue(value) { + return (data, facets) => { + const V = valueof(data, value); + const compareValue = (i, j) => ascendingDefined(V[i], V[j]); + return {data, facets: facets.map(I => I.slice().sort(compareValue))}; + }; +} diff --git a/src/transforms/stack.js b/src/transforms/stack.js index ba6c213997..b7c1fce8a0 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -1,6 +1,7 @@ import {InternMap, cumsum, group, groupSort, greatest, rollup, sum, min} from "d3"; import {ascendingDefined} from "../defined.js"; -import {field, lazyChannel, maybeTransform, maybeLazyChannel, maybeZ, mid, range, valueof, identity, maybeZero} from "../mark.js"; +import {field, lazyChannel, maybeLazyChannel, maybeZ, mid, range, valueof, identity, maybeZero} from "../mark.js"; +import {basic} from "./basic.js"; export function stackX({y1, y = y1, x, ...options} = {}) { const [transform, Y, x1, x2] = stack(y, x, "x", options); @@ -58,7 +59,7 @@ function stack(x, y = () => 1, ky, {offset, order, reverse, ...options} = {}) { offset = maybeOffset(offset); order = maybeOrder(order, offset, ky); return [ - maybeTransform(options, (data, facets) => { + basic(options, (data, facets) => { const X = x == null ? undefined : setX(valueof(data, x)); const Y = valueof(data, y, Float64Array); const Z = valueof(data, z);