Skip to content
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

interval-aware transforms #1511

Merged
merged 6 commits into from
May 1, 2023
Merged
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
2 changes: 2 additions & 0 deletions docs/features/scales.md
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,8 @@ Plot.plot({
As an added bonus, the **fontVariant** and **type** options are no longer needed because Plot now understands that the *x* scale, despite being *ordinal*, represents daily observations.
:::

While the example above relies on the **interval** being promoted to the scale’s **transform**, the [stack](../transforms/stack.md), [bin](../transforms/bin.md), and [group](../transforms/group.md) transforms are also interval-aware: they apply the scale’s **interval**, if any, *before* grouping values. (This results in the interval being applied twice, both before and after the mark transform, but the second application has no effect since interval application is idempotent.)

The **interval** option can also be used for quantitative and temporal scales. This enforces uniformity, say rounding timed observations down to the nearest hour, which may be helpful for the [stack transform](../transforms/stack.md) among other uses.

## Scale options
Expand Down
2 changes: 1 addition & 1 deletion docs/features/transforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Plot.plot({
```
:::

The **transform** function is passed two arguments, *data* and *facets*, representing the mark’s data and facet indexes; it must then return a {*data*, *facets*} object with the transformed data and facet indexes. The *facets* are represented as a nested array of arrays such as [[0, 1, 3, …], [2, 5, 10, …], …]; each element in *facets* specifies the zero-based indexes of elements in *data* that are in a given facet (*i.e.*, have a distinct value in the associated *fx* or *fy* dimension).
The **transform** function is passed three arguments, *data*, *facets*, and *options* representing the mark’s data and facet indexes, and the plot’s options; it must then return a {*data*, *facets*} object with the transformed data and facet indexes. The *facets* are represented as a nested array of arrays such as [[0, 1, 3, …], [2, 5, 10, …], …]; each element in *facets* specifies the zero-based indexes of elements in *data* that are in a given facet (*i.e.*, have a distinct value in the associated *fx* or *fy* dimension).

If the **transform** option is specified, it supersedes any basic transforms (*i.e.*, the **filter**, **sort** and **reverse** options are ignored). However, the **transform** option is rarely used directly; instead one of Plot’s built-in transforms are used, and these transforms automatically compose with the basic **filter**, **sort** and **reverse** transforms.

Expand Down
4 changes: 2 additions & 2 deletions src/mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ export class Mark {
}
}
}
initialize(facets, facetChannels) {
initialize(facets, facetChannels, plotOptions) {
let data = arrayify(this.data);
if (facets === undefined && data != null) facets = [range(data)];
const originalFacets = facets;
if (this.transform != null) ({facets, data} = this.transform(data, facets)), (data = arrayify(data));
if (this.transform != null) ({facets, data} = this.transform(data, facets, plotOptions)), (data = arrayify(data));
if (facets !== undefined) facets.original = originalFacets; // needed up read facetChannels
const channels = createChannels(this.channels, data);
if (this.sort != null) channelDomain(data, facets, channels, facetChannels, this.sort); // mutates facetChannels!
Expand Down
15 changes: 14 additions & 1 deletion src/options.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {color, descending, quantile, range as rangei} from "d3";
import {parse as isoParse} from "isoformat";
import {color, descending, range as rangei, quantile} from "d3";
import {defined} from "./defined.js";
import {maybeTimeInterval, maybeUtcInterval} from "./time.js";

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
Expand Down Expand Up @@ -269,6 +270,18 @@ export function mid(x1, x2) {
};
}

// If the scale options declare an interval, applies it to the values V.
export function maybeApplyInterval(V, scale) {
const t = maybeIntervalTransform(scale?.interval, scale?.type);
return t ? map(V, t) : V;
}

// Returns the equivalent scale transform for the specified interval option.
export function maybeIntervalTransform(interval, type) {
const i = maybeInterval(interval, type);
return i && ((v) => (defined(v) ? i.floor(v) : v));
}

// If interval is not nullish, converts interval shorthand such as a number (for
// multiples) or a time interval name (such as “day”) to a {floor, offset,
// range} object similar to a D3 time interval.
Expand Down
6 changes: 3 additions & 3 deletions src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {createLegends, exposeLegends} from "./legends.js";
import {Mark} from "./mark.js";
import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./marks/axis.js";
import {frame} from "./marks/frame.js";
import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeInterval} from "./options.js";
import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeIntervalTransform} from "./options.js";
import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
import {innerDimensions, outerDimensions} from "./scales.js";
import {position, registry as scaleRegistry} from "./scales/index.js";
Expand Down Expand Up @@ -126,7 +126,7 @@ export function plot(options = {}) {
for (const mark of marks) {
if (stateByMark.has(mark)) throw new Error("duplicate mark; each mark must be unique");
const {facetsIndex, channels: facetChannels} = facetStateByMark.get(mark) ?? {};
const {data, facets, channels} = mark.initialize(facetsIndex, facetChannels);
const {data, facets, channels} = mark.initialize(facetsIndex, facetChannels, options);
applyScaleTransforms(channels, options);
stateByMark.set(mark, {data, facets, channels});
}
Expand Down Expand Up @@ -363,7 +363,7 @@ function applyScaleTransform(channel, options) {
type,
percent,
interval,
transform = percent ? (x) => x * 100 : maybeInterval(interval, type)?.floor
transform = percent ? (x) => x * 100 : maybeIntervalTransform(interval, type)
} = options[scale] ?? {};
if (transform != null) channel.value = map(channel.value, transform);
}
Expand Down
19 changes: 12 additions & 7 deletions src/transforms/basic.d.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import type {PlotOptions} from "../plot.js";
import type {ChannelName, Channels, ChannelValue} from "../channel.js";
import type {Context} from "../context.js";
import type {Dimensions} from "../dimensions.js";
import type {ScaleFunctions} from "../scales.js";

/**
* A mark transform function is passed the mark’s *data* and a nested index into
* the data, *facets*. The transform function returns new mark data and facets;
* the returned **data** defaults to the passed *data*, and the returned
* **facets** defaults to the passed *facets*. The mark is the *this* context.
* Transform functions can also trigger side-effects, say to populate
* lazily-derived columns; see also Plot.column.
* A mark transform function is passed the mark’s *data*, a nested index into
* the data, *facets*, and the plot’s *options*. The transform function returns
* new mark data and facets; the returned **data** defaults to the passed
* *data*, and the returned **facets** defaults to the passed *facets*. The mark
* is the *this* context. Transform functions can also trigger side-effects, say
* to populate lazily-derived columns; see also Plot.column.
*/
export type TransformFunction = (data: any[], facets: number[][]) => {data?: any[]; facets?: number[][]};
export type TransformFunction = (
data: any[],
facets: number[][],
options?: PlotOptions
) => {data?: any[]; facets?: number[][]};

/**
* A mark initializer function is passed the mark’s (possibly transformed)
Expand Down
6 changes: 3 additions & 3 deletions src/transforms/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ export function initializer({filter: f1, sort: s1, reverse: r1, initializer: i1,
function composeTransform(t1, t2) {
if (t1 == null) return t2 === null ? undefined : t2;
if (t2 == null) return t1 === null ? undefined : t1;
return function (data, facets) {
({data, facets} = t1.call(this, data, facets));
return t2.call(this, arrayify(data), facets);
return function (data, facets, plotOptions) {
({data, facets} = t1.call(this, data, facets, plotOptions));
return t2.call(this, arrayify(data), facets, plotOptions);
};
}

Expand Down
5 changes: 3 additions & 2 deletions src/transforms/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
reduceIdentity
} from "./group.js";
import {maybeInsetX, maybeInsetY} from "./inset.js";
import {maybeApplyInterval} from "../options.js";

export function binX(outputs = {y: "count"}, options = {}) {
// Group on {z, fill, stroke}, then optionally on y, then bin x.
Expand Down Expand Up @@ -143,8 +144,8 @@ function binn(
...("z" in inputs && {z: GZ || z}),
...("fill" in inputs && {fill: GF || fill}),
...("stroke" in inputs && {stroke: GS || stroke}),
...basic(options, (data, facets) => {
const K = valueof(data, k);
...basic(options, (data, facets, plotOptions) => {
const K = maybeApplyInterval(valueof(data, k), plotOptions?.[gk]);
const Z = valueof(data, z);
const F = valueof(data, vfill);
const S = valueof(data, vstroke);
Expand Down
43 changes: 22 additions & 21 deletions src/transforms/group.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,37 @@
import {
group as grouper,
sort,
sum,
InternSet,
deviation,
min,
group as grouper,
max,
maxIndex,
mean,
median,
mode,
variance,
InternSet,
min,
minIndex,
maxIndex,
rollup
mode,
rollup,
sort,
sum,
variance
} from "d3";
import {ascendingDefined} from "../defined.js";
import {
valueof,
maybeColorChannel,
maybeInput,
maybeTuple,
maybeColumn,
column,
first,
identity,
take,
isObject,
isTemporal,
labelof,
maybeApplyInterval,
maybeColorChannel,
maybeColumn,
maybeInput,
maybeTuple,
percentile,
range,
second,
percentile,
isTemporal,
isObject
take,
valueof
} from "../options.js";
import {basic} from "./basic.js";

Expand Down Expand Up @@ -107,9 +108,9 @@ function groupn(
...("z" in inputs && {z: GZ || z}),
...("fill" in inputs && {fill: GF || fill}),
...("stroke" in inputs && {stroke: GS || stroke}),
...basic(options, (data, facets) => {
const X = valueof(data, x);
const Y = valueof(data, y);
...basic(options, (data, facets, plotOptions) => {
const X = maybeApplyInterval(valueof(data, x), plotOptions?.x);
const Y = maybeApplyInterval(valueof(data, y), plotOptions?.y);
const Z = valueof(data, z);
const F = valueof(data, vfill);
const S = valueof(data, vstroke);
Expand Down
46 changes: 22 additions & 24 deletions src/transforms/stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,47 @@ import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} fr
import {ascendingDefined} from "../defined.js";
import {field, column, maybeColumn, maybeZ, mid, range, valueof, maybeZero, one} from "../options.js";
import {basic} from "./basic.js";
import {maybeApplyInterval} from "../options.js";

export function stackX(stack = {}, options = {}) {
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
export function stackX(stackOptions = {}, options = {}) {
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
const {y1, y = y1, x, ...rest} = options; // note: consumes x!
const [transform, Y, x1, x2] = stackAlias(y, x, "x", stack, rest);
const [transform, Y, x1, x2] = stack(y, x, "y", "x", stackOptions, rest);
return {...transform, y1, y: Y, x1, x2, x: mid(x1, x2)};
}

export function stackX1(stack = {}, options = {}) {
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
export function stackX1(stackOptions = {}, options = {}) {
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
const {y1, y = y1, x} = options;
const [transform, Y, X] = stackAlias(y, x, "x", stack, options);
const [transform, Y, X] = stack(y, x, "y", "x", stackOptions, options);
return {...transform, y1, y: Y, x: X};
}

export function stackX2(stack = {}, options = {}) {
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
export function stackX2(stackOptions = {}, options = {}) {
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
const {y1, y = y1, x} = options;
const [transform, Y, , X] = stackAlias(y, x, "x", stack, options);
const [transform, Y, , X] = stack(y, x, "y", "x", stackOptions, options);
return {...transform, y1, y: Y, x: X};
}

export function stackY(stack = {}, options = {}) {
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
export function stackY(stackOptions = {}, options = {}) {
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
const {x1, x = x1, y, ...rest} = options; // note: consumes y!
const [transform, X, y1, y2] = stackAlias(x, y, "y", stack, rest);
const [transform, X, y1, y2] = stack(x, y, "x", "y", stackOptions, rest);
return {...transform, x1, x: X, y1, y2, y: mid(y1, y2)};
}

export function stackY1(stack = {}, options = {}) {
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
export function stackY1(stackOptions = {}, options = {}) {
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
const {x1, x = x1, y} = options;
const [transform, X, Y] = stackAlias(x, y, "y", stack, options);
const [transform, X, Y] = stack(x, y, "x", "y", stackOptions, options);
return {...transform, x1, x: X, y: Y};
}

export function stackY2(stack = {}, options = {}) {
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
export function stackY2(stackOptions = {}, options = {}) {
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
const {x1, x = x1, y} = options;
const [transform, X, , Y] = stackAlias(x, y, "y", stack, options);
const [transform, X, , Y] = stack(x, y, "x", "y", stackOptions, options);
return {...transform, x1, x: X, y: Y};
}

Expand All @@ -65,16 +66,16 @@ function mergeOptions(options) {
return [{offset, order, reverse}, rest];
}

function stack(x, y = one, ky, {offset, order, reverse}, options) {
function stack(x, y = one, kx, ky, {offset, order, reverse}, options) {
const z = maybeZ(options);
const [X, setX] = maybeColumn(x);
const [Y1, setY1] = column(y);
const [Y2, setY2] = column(y);
offset = maybeOffset(offset);
order = maybeOrder(order, offset, ky);
return [
basic(options, (data, facets) => {
const X = x == null ? undefined : setX(valueof(data, x));
basic(options, (data, facets, plotOptions) => {
const X = x == null ? undefined : setX(maybeApplyInterval(valueof(data, x), plotOptions?.[kx]));
const Y = valueof(data, y, Float64Array);
const Z = valueof(data, z);
const O = order && order(data, X, Y, Z);
Expand Down Expand Up @@ -107,9 +108,6 @@ function stack(x, y = one, ky, {offset, order, reverse}, options) {
];
}

// This is used internally so we can use `stack` as an argument name.
const stackAlias = stack;

function maybeOffset(offset) {
if (offset == null) return;
if (typeof offset === "function") return offset;
Expand Down
Loading