Skip to content

Commit 477cfd1

Browse files
mbostockFil
andauthored
interval-aware transforms (#1511)
* interval-aware group transform * interval-aware bin, stack * rename to interval-aware * fix key reversal * remove redundant tickFormat * document interval-aware group transform (#1518) * document interval aware transforms * API doc * Update scales.md * restore caution * describe interval-aware transforms * types for custom transform functions --------- Co-authored-by: Mike Bostock <[email protected]> --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent 07f2b4d commit 477cfd1

File tree

16 files changed

+12172
-70
lines changed

16 files changed

+12172
-70
lines changed

docs/features/scales.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,8 @@ Plot.plot({
638638
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.
639639
:::
640640

641+
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.)
642+
641643
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.
642644

643645
## Scale options

docs/features/transforms.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ Plot.plot({
175175
```
176176
:::
177177

178-
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).
178+
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).
179179

180180
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.
181181

src/mark.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,11 @@ export class Mark {
7575
}
7676
}
7777
}
78-
initialize(facets, facetChannels) {
78+
initialize(facets, facetChannels, plotOptions) {
7979
let data = arrayify(this.data);
8080
if (facets === undefined && data != null) facets = [range(data)];
8181
const originalFacets = facets;
82-
if (this.transform != null) ({facets, data} = this.transform(data, facets)), (data = arrayify(data));
82+
if (this.transform != null) ({facets, data} = this.transform(data, facets, plotOptions)), (data = arrayify(data));
8383
if (facets !== undefined) facets.original = originalFacets; // needed up read facetChannels
8484
const channels = createChannels(this.channels, data);
8585
if (this.sort != null) channelDomain(data, facets, channels, facetChannels, this.sort); // mutates facetChannels!

src/options.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import {color, descending, quantile, range as rangei} from "d3";
12
import {parse as isoParse} from "isoformat";
2-
import {color, descending, range as rangei, quantile} from "d3";
3+
import {defined} from "./defined.js";
34
import {maybeTimeInterval, maybeUtcInterval} from "./time.js";
45

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

273+
// If the scale options declare an interval, applies it to the values V.
274+
export function maybeApplyInterval(V, scale) {
275+
const t = maybeIntervalTransform(scale?.interval, scale?.type);
276+
return t ? map(V, t) : V;
277+
}
278+
279+
// Returns the equivalent scale transform for the specified interval option.
280+
export function maybeIntervalTransform(interval, type) {
281+
const i = maybeInterval(interval, type);
282+
return i && ((v) => (defined(v) ? i.floor(v) : v));
283+
}
284+
272285
// If interval is not nullish, converts interval shorthand such as a number (for
273286
// multiples) or a time interval name (such as “day”) to a {floor, offset,
274287
// range} object similar to a D3 time interval.

src/plot.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {createLegends, exposeLegends} from "./legends.js";
77
import {Mark} from "./mark.js";
88
import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./marks/axis.js";
99
import {frame} from "./marks/frame.js";
10-
import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeInterval} from "./options.js";
10+
import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeIntervalTransform} from "./options.js";
1111
import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
1212
import {innerDimensions, outerDimensions} from "./scales.js";
1313
import {position, registry as scaleRegistry} from "./scales/index.js";
@@ -126,7 +126,7 @@ export function plot(options = {}) {
126126
for (const mark of marks) {
127127
if (stateByMark.has(mark)) throw new Error("duplicate mark; each mark must be unique");
128128
const {facetsIndex, channels: facetChannels} = facetStateByMark.get(mark) ?? {};
129-
const {data, facets, channels} = mark.initialize(facetsIndex, facetChannels);
129+
const {data, facets, channels} = mark.initialize(facetsIndex, facetChannels, options);
130130
applyScaleTransforms(channels, options);
131131
stateByMark.set(mark, {data, facets, channels});
132132
}
@@ -363,7 +363,7 @@ function applyScaleTransform(channel, options) {
363363
type,
364364
percent,
365365
interval,
366-
transform = percent ? (x) => x * 100 : maybeInterval(interval, type)?.floor
366+
transform = percent ? (x) => x * 100 : maybeIntervalTransform(interval, type)
367367
} = options[scale] ?? {};
368368
if (transform != null) channel.value = map(channel.value, transform);
369369
}

src/transforms/basic.d.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1+
import type {PlotOptions} from "../plot.js";
12
import type {ChannelName, Channels, ChannelValue} from "../channel.js";
23
import type {Context} from "../context.js";
34
import type {Dimensions} from "../dimensions.js";
45
import type {ScaleFunctions} from "../scales.js";
56

67
/**
7-
* A mark transform function is passed the mark’s *data* and a nested index into
8-
* the data, *facets*. The transform function returns new mark data and facets;
9-
* the returned **data** defaults to the passed *data*, and the returned
10-
* **facets** defaults to the passed *facets*. The mark is the *this* context.
11-
* Transform functions can also trigger side-effects, say to populate
12-
* lazily-derived columns; see also Plot.column.
8+
* A mark transform function is passed the mark’s *data*, a nested index into
9+
* the data, *facets*, and the plot’s *options*. The transform function returns
10+
* new mark data and facets; the returned **data** defaults to the passed
11+
* *data*, and the returned **facets** defaults to the passed *facets*. The mark
12+
* is the *this* context. Transform functions can also trigger side-effects, say
13+
* to populate lazily-derived columns; see also Plot.column.
1314
*/
14-
export type TransformFunction = (data: any[], facets: number[][]) => {data?: any[]; facets?: number[][]};
15+
export type TransformFunction = (
16+
data: any[],
17+
facets: number[][],
18+
options?: PlotOptions
19+
) => {data?: any[]; facets?: number[][]};
1520

1621
/**
1722
* A mark initializer function is passed the mark’s (possibly transformed)

src/transforms/basic.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ export function initializer({filter: f1, sort: s1, reverse: r1, initializer: i1,
3838
function composeTransform(t1, t2) {
3939
if (t1 == null) return t2 === null ? undefined : t2;
4040
if (t2 == null) return t1 === null ? undefined : t1;
41-
return function (data, facets) {
42-
({data, facets} = t1.call(this, data, facets));
43-
return t2.call(this, arrayify(data), facets);
41+
return function (data, facets, plotOptions) {
42+
({data, facets} = t1.call(this, data, facets, plotOptions));
43+
return t2.call(this, arrayify(data), facets, plotOptions);
4444
};
4545
}
4646

src/transforms/bin.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
reduceIdentity
4141
} from "./group.js";
4242
import {maybeInsetX, maybeInsetY} from "./inset.js";
43+
import {maybeApplyInterval} from "../options.js";
4344

4445
export function binX(outputs = {y: "count"}, options = {}) {
4546
// Group on {z, fill, stroke}, then optionally on y, then bin x.
@@ -143,8 +144,8 @@ function binn(
143144
...("z" in inputs && {z: GZ || z}),
144145
...("fill" in inputs && {fill: GF || fill}),
145146
...("stroke" in inputs && {stroke: GS || stroke}),
146-
...basic(options, (data, facets) => {
147-
const K = valueof(data, k);
147+
...basic(options, (data, facets, plotOptions) => {
148+
const K = maybeApplyInterval(valueof(data, k), plotOptions?.[gk]);
148149
const Z = valueof(data, z);
149150
const F = valueof(data, vfill);
150151
const S = valueof(data, vstroke);

src/transforms/group.js

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,37 @@
11
import {
2-
group as grouper,
3-
sort,
4-
sum,
2+
InternSet,
53
deviation,
6-
min,
4+
group as grouper,
75
max,
6+
maxIndex,
87
mean,
98
median,
10-
mode,
11-
variance,
12-
InternSet,
9+
min,
1310
minIndex,
14-
maxIndex,
15-
rollup
11+
mode,
12+
rollup,
13+
sort,
14+
sum,
15+
variance
1616
} from "d3";
1717
import {ascendingDefined} from "../defined.js";
1818
import {
19-
valueof,
20-
maybeColorChannel,
21-
maybeInput,
22-
maybeTuple,
23-
maybeColumn,
2419
column,
2520
first,
2621
identity,
27-
take,
22+
isObject,
23+
isTemporal,
2824
labelof,
25+
maybeApplyInterval,
26+
maybeColorChannel,
27+
maybeColumn,
28+
maybeInput,
29+
maybeTuple,
30+
percentile,
2931
range,
3032
second,
31-
percentile,
32-
isTemporal,
33-
isObject
33+
take,
34+
valueof
3435
} from "../options.js";
3536
import {basic} from "./basic.js";
3637

@@ -107,9 +108,9 @@ function groupn(
107108
...("z" in inputs && {z: GZ || z}),
108109
...("fill" in inputs && {fill: GF || fill}),
109110
...("stroke" in inputs && {stroke: GS || stroke}),
110-
...basic(options, (data, facets) => {
111-
const X = valueof(data, x);
112-
const Y = valueof(data, y);
111+
...basic(options, (data, facets, plotOptions) => {
112+
const X = maybeApplyInterval(valueof(data, x), plotOptions?.x);
113+
const Y = maybeApplyInterval(valueof(data, y), plotOptions?.y);
113114
const Z = valueof(data, z);
114115
const F = valueof(data, vfill);
115116
const S = valueof(data, vstroke);

src/transforms/stack.js

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,47 @@ import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} fr
22
import {ascendingDefined} from "../defined.js";
33
import {field, column, maybeColumn, maybeZ, mid, range, valueof, maybeZero, one} from "../options.js";
44
import {basic} from "./basic.js";
5+
import {maybeApplyInterval} from "../options.js";
56

6-
export function stackX(stack = {}, options = {}) {
7-
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
7+
export function stackX(stackOptions = {}, options = {}) {
8+
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
89
const {y1, y = y1, x, ...rest} = options; // note: consumes x!
9-
const [transform, Y, x1, x2] = stackAlias(y, x, "x", stack, rest);
10+
const [transform, Y, x1, x2] = stack(y, x, "y", "x", stackOptions, rest);
1011
return {...transform, y1, y: Y, x1, x2, x: mid(x1, x2)};
1112
}
1213

13-
export function stackX1(stack = {}, options = {}) {
14-
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
14+
export function stackX1(stackOptions = {}, options = {}) {
15+
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
1516
const {y1, y = y1, x} = options;
16-
const [transform, Y, X] = stackAlias(y, x, "x", stack, options);
17+
const [transform, Y, X] = stack(y, x, "y", "x", stackOptions, options);
1718
return {...transform, y1, y: Y, x: X};
1819
}
1920

20-
export function stackX2(stack = {}, options = {}) {
21-
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
21+
export function stackX2(stackOptions = {}, options = {}) {
22+
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
2223
const {y1, y = y1, x} = options;
23-
const [transform, Y, , X] = stackAlias(y, x, "x", stack, options);
24+
const [transform, Y, , X] = stack(y, x, "y", "x", stackOptions, options);
2425
return {...transform, y1, y: Y, x: X};
2526
}
2627

27-
export function stackY(stack = {}, options = {}) {
28-
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
28+
export function stackY(stackOptions = {}, options = {}) {
29+
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
2930
const {x1, x = x1, y, ...rest} = options; // note: consumes y!
30-
const [transform, X, y1, y2] = stackAlias(x, y, "y", stack, rest);
31+
const [transform, X, y1, y2] = stack(x, y, "x", "y", stackOptions, rest);
3132
return {...transform, x1, x: X, y1, y2, y: mid(y1, y2)};
3233
}
3334

34-
export function stackY1(stack = {}, options = {}) {
35-
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
35+
export function stackY1(stackOptions = {}, options = {}) {
36+
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
3637
const {x1, x = x1, y} = options;
37-
const [transform, X, Y] = stackAlias(x, y, "y", stack, options);
38+
const [transform, X, Y] = stack(x, y, "x", "y", stackOptions, options);
3839
return {...transform, x1, x: X, y: Y};
3940
}
4041

41-
export function stackY2(stack = {}, options = {}) {
42-
if (arguments.length === 1) [stack, options] = mergeOptions(stack);
42+
export function stackY2(stackOptions = {}, options = {}) {
43+
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
4344
const {x1, x = x1, y} = options;
44-
const [transform, X, , Y] = stackAlias(x, y, "y", stack, options);
45+
const [transform, X, , Y] = stack(x, y, "x", "y", stackOptions, options);
4546
return {...transform, x1, x: X, y: Y};
4647
}
4748

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

68-
function stack(x, y = one, ky, {offset, order, reverse}, options) {
69+
function stack(x, y = one, kx, ky, {offset, order, reverse}, options) {
6970
const z = maybeZ(options);
7071
const [X, setX] = maybeColumn(x);
7172
const [Y1, setY1] = column(y);
7273
const [Y2, setY2] = column(y);
7374
offset = maybeOffset(offset);
7475
order = maybeOrder(order, offset, ky);
7576
return [
76-
basic(options, (data, facets) => {
77-
const X = x == null ? undefined : setX(valueof(data, x));
77+
basic(options, (data, facets, plotOptions) => {
78+
const X = x == null ? undefined : setX(maybeApplyInterval(valueof(data, x), plotOptions?.[kx]));
7879
const Y = valueof(data, y, Float64Array);
7980
const Z = valueof(data, z);
8081
const O = order && order(data, X, Y, Z);
@@ -107,9 +108,6 @@ function stack(x, y = one, ky, {offset, order, reverse}, options) {
107108
];
108109
}
109110

110-
// This is used internally so we can use `stack` as an argument name.
111-
const stackAlias = stack;
112-
113111
function maybeOffset(offset) {
114112
if (offset == null) return;
115113
if (typeof offset === "function") return offset;

0 commit comments

Comments
 (0)