Skip to content

Commit 9eeafaa

Browse files
mbostockFil
andauthored
Add map & reduce transforms, nested marks (observablehq#207)
* aggregate * reduce transform * remove comment * reduce data * swap reduce[XY] * rename to value * marks.flat(Infinity) * Update test/plots/morley-boxplot.js * aggregate all candidate group channels * normalize as map * movingAverage as map * shorten * normalize basis: extent * prevent null coercion to zero * normalize parcoords * comment * consolidate code * name-year * more explicit separation * mapper and reducer interfaces * fix shadowing * simplify outliers * shorter * group by z * comment * rename index to facets * add ticks to parcoords Co-authored-by: Philippe Rivière <[email protected]>
1 parent e530041 commit 9eeafaa

19 files changed

+1406
-3398
lines changed

src/facet.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class Facet extends Mark {
1818
],
1919
options
2020
);
21-
this.marks = marks;
21+
this.marks = marks.flat(Infinity);
2222
// The following fields are set by initialize:
2323
this.marksChannels = undefined; // array of mark channels
2424
this.marksIndex = undefined; // array of mark indexes (for non-faceted marks)

src/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import "./style.css";
22

33
export {plot} from "./plot.js";
4-
export {Mark} from "./mark.js";
4+
export {Mark, valueof} from "./mark.js";
55
export {Area, area, areaX, areaY} from "./marks/area.js";
66
export {BarX, BarY, barX, barY} from "./marks/bar.js";
77
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
@@ -17,5 +17,7 @@ export {bin, binX, binY, binR, binFill} from "./transforms/bin.js";
1717
export {group, groupX, groupY, groupR, groupFill} from "./transforms/group.js";
1818
export {normalizeX, normalizeY} from "./transforms/normalize.js";
1919
export {movingAverageX, movingAverageY} from "./transforms/movingAverage.js";
20+
export {map} from "./transforms/map.js";
21+
export {reduceX, reduceY, reduce} from "./transforms/reduce.js";
2022
export {selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
2123
export {stackX, stackX1, stackX2, stackXMid, stackY, stackY1, stackY2, stackYMid} from "./transforms/stack.js";

src/mark.js

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class Mark {
3333
let index = facets === undefined && data != null ? range(data) : facets;
3434
if (data !== undefined && this.transform !== undefined) {
3535
if (facets === undefined) index = index.length ? [index] : [];
36-
({index, data} = this.transform(data, index));
36+
({facets: index, data} = this.transform(data, index));
3737
data = arrayify(data);
3838
if (facets === undefined && index.length) ([index] = index);
3939
}
@@ -170,6 +170,15 @@ export function take(values, index) {
170170
return Array.from(index, i => values[i]);
171171
}
172172

173+
export function maybeInput(key, options) {
174+
if (options[key] !== undefined) return options[key];
175+
switch (key) {
176+
case "x1": case "x2": key = "x"; break;
177+
case "y1": case "y2": key = "y"; break;
178+
}
179+
return options[key];
180+
}
181+
173182
// Defines a channel whose values are lazily populated by calling the returned
174183
// setter. If the given source is labeled, the label is propagated to the
175184
// returned channel definition.
@@ -223,9 +232,9 @@ export function maybeValue(value) {
223232
function compose(t1, t2) {
224233
if (t1 == null) return t2 === null ? undefined : t2;
225234
if (t2 == null) return t1 === null ? undefined : t1;
226-
return (data, index) => {
227-
({data, index} = t1(data, index));
228-
return t2(arrayify(data), index);
235+
return (data, facets) => {
236+
({data, facets} = t1(data, facets));
237+
return t2(arrayify(data), facets);
229238
};
230239
}
231240

@@ -234,23 +243,23 @@ function sort(value) {
234243
}
235244

236245
function sortCompare(compare) {
237-
return (data, index) => {
246+
return (data, facets) => {
238247
const compareData = (i, j) => compare(data[i], data[j]);
239-
return {data, index: index.map(I => I.slice().sort(compareData))};
248+
return {data, facets: facets.map(I => I.slice().sort(compareData))};
240249
};
241250
}
242251

243252
function sortValue(value) {
244-
return (data, index) => {
253+
return (data, facets) => {
245254
const V = valueof(data, value);
246255
const compareValue = (i, j) => ascendingDefined(V[i], V[j]);
247-
return {data, index: index.map(I => I.slice().sort(compareValue))};
256+
return {data, facets: facets.map(I => I.slice().sort(compareValue))};
248257
};
249258
}
250259

251260
function filter(value) {
252-
return (data, index) => {
261+
return (data, facets) => {
253262
const V = valueof(data, value);
254-
return {data, index: index.map(I => I.filter(i => V[i]))};
263+
return {data, facets: facets.map(I => I.filter(i => V[i]))};
255264
};
256265
}

src/plot.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export function plot(options = {}) {
1313
options = {...options, marks: facets(data, facet, marks)};
1414
}
1515

16-
const {marks = []} = options;
16+
// Flatten any nested marks.
17+
const marks = options.marks === undefined ? [] : options.marks.flat(Infinity);
1718

1819
// A Map from Mark instance to an object of named channel values.
1920
const markChannels = new Map();

src/transforms/bin.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ function bin1(x, {domain, thresholds, normalize, cumulative, ...options} = {}) {
4040
return [
4141
{
4242
...options,
43-
transform: maybeTransform(options, (data, index) => {
43+
transform: maybeTransform(options, (data, facets) => {
4444
const B = bin(data);
4545
const Z = valueof(data, z);
4646
const F = valueof(data, vfill);
4747
const S = valueof(data, vstroke);
48-
const binIndex = [];
48+
const binFacets = [];
4949
const binData = [];
5050
const X1 = setX1([]);
5151
const X2 = setX2([]);
@@ -57,7 +57,7 @@ function bin1(x, {domain, thresholds, normalize, cumulative, ...options} = {}) {
5757
const n = data.length;
5858
let i = 0;
5959
if (cumulative < 0) B.reverse();
60-
for (const facet of index) {
60+
for (const facet of facets) {
6161
const binFacet = [];
6262
for (const I of G ? group(facet, i => G[i]).values() : [facet]) {
6363
const set = new Set(I);
@@ -78,9 +78,9 @@ function bin1(x, {domain, thresholds, normalize, cumulative, ...options} = {}) {
7878
}
7979
}
8080
}
81-
binIndex.push(binFacet);
81+
binFacets.push(binFacet);
8282
}
83-
return {data: binData, index: binIndex};
83+
return {data: binData, facets: binFacets};
8484
})
8585
},
8686
X1,
@@ -115,12 +115,12 @@ function bin2(x, y, {domain, thresholds, normalize, ...options} = {}) {
115115
return [
116116
{
117117
...options,
118-
transform: maybeTransform(options, (data, index) => {
118+
transform: maybeTransform(options, (data, facets) => {
119119
const B = bin(data);
120120
const Z = valueof(data, z);
121121
const F = valueof(data, vfill);
122122
const S = valueof(data, vstroke);
123-
const binIndex = [];
123+
const binFacets = [];
124124
const binData = [];
125125
const X1 = setX1([]);
126126
const X2 = setX2([]);
@@ -133,7 +133,7 @@ function bin2(x, y, {domain, thresholds, normalize, ...options} = {}) {
133133
const BS = S && setS([]);
134134
const n = data.length;
135135
let i = 0;
136-
for (const facet of index) {
136+
for (const facet of facets) {
137137
const binFacet = [];
138138
for (const I of G ? group(facet, i => G[i]).values() : [facet]) {
139139
const set = new Set(I);
@@ -154,9 +154,9 @@ function bin2(x, y, {domain, thresholds, normalize, ...options} = {}) {
154154
}
155155
}
156156
}
157-
binIndex.push(binFacet);
157+
binFacets.push(binFacet);
158158
}
159-
return {data: binData, index: binIndex};
159+
return {data: binData, facets: binFacets};
160160
})
161161
},
162162
X1,

src/transforms/group.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ function group1(x = identity, {domain, normalize, ...options} = {}) {
3939
return [
4040
{
4141
...options,
42-
transform: maybeTransform(options, (data, index) => {
42+
transform: maybeTransform(options, (data, facets) => {
4343
const X = valueof(data, x);
4444
const Z = valueof(data, z);
4545
const F = valueof(data, vfill);
4646
const S = valueof(data, vstroke);
47-
const groupIndex = [];
47+
const groupFacets = [];
4848
const groupData = [];
4949
const G = Z || F || S;
5050
const BX = setX([]);
@@ -54,7 +54,7 @@ function group1(x = identity, {domain, normalize, ...options} = {}) {
5454
const BS = S && setS([]);
5555
const n = data.length;
5656
let i = 0;
57-
for (const facet of index) {
57+
for (const facet of facets) {
5858
const groupFacet = [];
5959
for (const I of G ? grouper(facet, i => G[i]).values() : [facet]) {
6060
for (const [x, f] of sort(grouper(I, i => X[i]), first)) {
@@ -69,9 +69,9 @@ function group1(x = identity, {domain, normalize, ...options} = {}) {
6969
if (S) BS.push(S[f[0]]);
7070
}
7171
}
72-
groupIndex.push(groupFacet);
72+
groupFacets.push(groupFacet);
7373
}
74-
return {data: groupData, index: groupIndex};
74+
return {data: groupData, facets: groupFacets};
7575
})
7676
},
7777
X,
@@ -101,13 +101,13 @@ function group2(xv, yv, {domain, normalize, ...options} = {}) {
101101
return [
102102
{
103103
...options,
104-
transform: maybeTransform(options, (data, index) => {
104+
transform: maybeTransform(options, (data, facets) => {
105105
const X = valueof(data, x);
106106
const Y = valueof(data, y);
107107
const Z = valueof(data, z);
108108
const F = valueof(data, vfill);
109109
const S = valueof(data, vstroke);
110-
const groupIndex = [];
110+
const groupFacets = [];
111111
const groupData = [];
112112
const G = Z || F || S;
113113
const BX = setX([]);
@@ -118,7 +118,7 @@ function group2(xv, yv, {domain, normalize, ...options} = {}) {
118118
const BS = S && setS([]);
119119
const n = data.length;
120120
let i = 0;
121-
for (const facet of index) {
121+
for (const facet of facets) {
122122
const groupFacet = [];
123123
for (const I of G ? grouper(facet, i => G[i]).values() : [facet]) {
124124
for (const [y, fy] of sort(grouper(I, i => Y[i]), first)) {
@@ -137,9 +137,9 @@ function group2(xv, yv, {domain, normalize, ...options} = {}) {
137137
}
138138
}
139139
}
140-
groupIndex.push(groupFacet);
140+
groupFacets.push(groupFacet);
141141
}
142-
return {data: groupData, index: groupIndex};
142+
return {data: groupData, facets: groupFacets};
143143
})
144144
},
145145
X,

src/transforms/map.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {group} from "d3";
2+
import {maybeTransform, maybeZ, take, valueof, maybeInput, lazyChannel} from "../mark.js";
3+
4+
export function map(outputs = {}, options = {}) {
5+
const z = maybeZ(options);
6+
const channels = Object.entries(outputs).map(([key, map]) => {
7+
const input = maybeInput(key, options);
8+
if (input == null) throw new Error(`missing channel: ${key}`);
9+
const [output, setOutput] = lazyChannel(input);
10+
return {key, input, output, setOutput, map: maybeMap(map)};
11+
});
12+
return {
13+
...options,
14+
...Object.fromEntries(channels.map(({key, output}) => [key, output])),
15+
transform: maybeTransform(options, (data, facets) => {
16+
const Z = valueof(data, z);
17+
const X = channels.map(({input}) => valueof(data, input));
18+
const MX = channels.map(({setOutput}) => setOutput(new Array(data.length)));
19+
for (const facet of facets) {
20+
for (const I of Z ? group(facet, i => Z[i]).values() : [facet]) {
21+
channels.forEach(({map}, i) => map.map(I, X[i], MX[i]));
22+
}
23+
}
24+
return {data, facets};
25+
})
26+
};
27+
}
28+
29+
function maybeMap(map) {
30+
if (map && typeof map.map === "function") return map;
31+
if (typeof map === "function") return mapFunction(map);
32+
throw new Error("invalid map");
33+
}
34+
35+
function mapFunction(f) {
36+
return {
37+
map(I, S, T) {
38+
const M = f(take(S, I));
39+
if (M.length !== I.length) throw new Error("mismatched length");
40+
for (let i = 0, n = I.length; i < n; ++i) T[I[i]] = M[i];
41+
}
42+
};
43+
}

src/transforms/movingAverage.js

Lines changed: 23 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,33 @@
1-
import {group} from "d3";
2-
import {maybeLazyChannel, valueof, maybeZ, maybeTransform} from "../mark.js";
1+
import {map} from "./map.js";
32

4-
export function movingAverageX({x, x1, x2, ...options} = {}) {
5-
const [transform, X, X1, X2] = movingAverage(options, x, x1, x2);
6-
return {...transform, x: X, x1: X1, x2: X2};
3+
export function movingAverageX({k, ...options} = {}) {
4+
const m = rollmean(k);
5+
return map({x: m, x1: m, x2: m}, options);
76
}
87

9-
export function movingAverageY({y, y1, y2, ...options} = {}) {
10-
const [transform, Y, Y1, Y2] = movingAverage(options, y, y1, y2);
11-
return {...transform, y: Y, y1: Y1, y2: Y2};
8+
export function movingAverageY({k, ...options} = {}) {
9+
const m = rollmean(k);
10+
return map({y: m, y1: m, y2: m}, options);
1211
}
1312

1413
// TODO allow partially-defined data
1514
// TODO expose shift option (leading, trailing, centered)
16-
function movingAverage({k, ...options}, ...inputs) {
15+
// TODO rolling minimum and maximum
16+
function rollmean(k) {
1717
if (!((k = Math.floor(k)) > 0)) throw new Error(`invalid k: ${k}`);
18-
const channels = inputs.map(i => [i, ...maybeLazyChannel(i)]);
19-
const z = maybeZ(options);
2018
const m = k >> 1;
21-
return [
22-
{
23-
...options,
24-
transform: maybeTransform(options, (data, index) => {
25-
const n = data.length;
26-
const Z = valueof(data, z);
27-
for (const [s,, setT] of channels) {
28-
if (s == null) continue;
29-
const S = valueof(data, s, Float64Array);
30-
const T = setT(new Float64Array(n).fill());
31-
for (const facet of index) {
32-
for (const I of Z ? group(facet, i => Z[i]).values() : [facet]) {
33-
let i = 0;
34-
let sum = 0;
35-
for (const n = Math.min(k - 1, I.length); i < n; ++i) {
36-
sum += S[I[i]];
37-
}
38-
for (const n = I.length; i < n; ++i) {
39-
sum += S[I[i]];
40-
T[I[i - m]] = sum / k;
41-
sum -= S[I[i - k + 1]];
42-
}
43-
}
44-
}
45-
}
46-
return {data, index};
47-
})
48-
},
49-
...channels.map(([, T]) => T)
50-
];
19+
return {
20+
map(I, S, T) {
21+
let i = 0;
22+
let sum = 0;
23+
for (const n = Math.min(k - 1, I.length); i < n; ++i) {
24+
sum += S[I[i]];
25+
}
26+
for (const n = I.length; i < n; ++i) {
27+
sum += S[I[i]];
28+
T[I[i - m]] = sum / k;
29+
sum -= S[I[i - k + 1]];
30+
}
31+
}
32+
};
5133
}

0 commit comments

Comments
 (0)