Skip to content

Commit ee95211

Browse files
Filmbostock
andauthored
sort, reverse and shuffle can be applied as initializers (#908)
* sort, reverse and shuffle can be applied as initialzers if sort is applied on a string such as "y" and a (possibly derived) y channel exists, it is preferred to sorting on data[i]["y"]; which allows to sort("y", dodgeY()). closes #907 * apply helper; filter initializer * basic transforms for initializers * avoid circular import * prefer basic initializer * generalize channel sorting Co-authored-by: Mike Bostock <[email protected]>
1 parent de66b18 commit ee95211

16 files changed

+752
-383
lines changed

README.md

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -424,8 +424,6 @@ Plot.plot({
424424

425425
### Sort options
426426

427-
#### Ordinal domain sorting
428-
429427
If an ordinal scale’s domain is not set, it defaults to natural ascending order; to order the domain by associated values in another dimension, either compute the domain manually (consider [d3.groupSort](https://github.com/d3/d3-array/blob/main/README.md#groupSort)) or use an associated mark’s **sort** option. For example, to sort bars by ascending frequency rather than alphabetically by letter:
430428

431429
```js
@@ -464,20 +462,6 @@ If the input channel is *data*, then the reducer is passed groups of the mark’
464462

465463
Note: when the value of the sort option is a string or a function, it is interpreted as a [basic sort transform](#transforms). To use both sort options and a sort transform, use [Plot.sort](#plotsortorder-options).
466464

467-
#### Index sorting
468-
469-
In addition to the [sort transform](#transforms) which allow sorting by data, you can use the **sort** option to sort marks by some other channel value. For example, to sort dots by descending radius:
470-
471-
```js
472-
Plot.dot(earthquakes, {x: "longitude", y: "latitude", r: "intensity", sort: {channel: "r", reverse: true}})
473-
```
474-
475-
In fact, sorting by descending radius is the default behavior of the dot mark when an *r* channel is specified. You can disable this by setting the sort explicitly to null:
476-
477-
```js
478-
Plot.dot(earthquakes, {x: "longitude", y: "latitude", r: "intensity", sort: null})
479-
```
480-
481465
### Facet options
482466

483467
The *facet* option enables [faceting](https://observablehq.com/@observablehq/plot-facets). When faceting, two additional band scales may be configured:
@@ -1475,6 +1459,18 @@ Plot.barY(alphabet.filter(d => /[aeiou]/i.test(d.letter)), {x: "letter", y: "fre
14751459
14761460
Together the **sort** and **reverse** transforms allow control over *z* order, which can be important when addressing overplotting. If the sort option is a function but does not take exactly one argument, it is assumed to be a [comparator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#description); otherwise, the sort option is interpreted as a channel value definition and thus may be either as a column name, accessor function, or array of values.
14771461
1462+
The sort transform can also be used to sort on channel values, including those derived by [initializers](#initializers). For example, to sort dots by descending radius:
1463+
1464+
```js
1465+
Plot.dot(earthquakes, {x: "longitude", y: "latitude", r: "intensity", sort: {channel: "r", order: "descending}})
1466+
```
1467+
1468+
In fact, sorting by descending radius is the default behavior of the dot mark when an *r* channel is specified. You can disable this by setting the sort explicitly to null:
1469+
1470+
```js
1471+
Plot.dot(earthquakes, {x: "longitude", y: "latitude", r: "intensity", sort: null})
1472+
```
1473+
14781474
For greater control, you can also implement a [custom transform function](#custom-transforms):
14791475
14801476
* **transform** - a function that returns transformed *data* and *index*

src/channel.js

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import {ascending, descending, rollup, sort} from "d3";
2-
import {ascendingDefined, descendingDefined} from "./defined.js";
32
import {first, isIterable, labelof, map, maybeValue, range, valueof} from "./options.js";
43
import {registry} from "./scales/index.js";
54
import {maybeReduce} from "./transforms/group.js";
6-
import {composeInitializer} from "./transforms/initializer.js";
75

86
// TODO Type coercion?
97
export function Channel(data, {scale, type, value, filter, hint}) {
@@ -73,22 +71,6 @@ export function channelDomain(channels, facetChannels, data, options) {
7371
}
7472
}
7573

76-
function sortInitializer(name, optional, compare = ascendingDefined) {
77-
return (data, facets, {[name]: V}) => {
78-
if (!V) {
79-
if (optional) return {}; // do nothing if given channel does not exist
80-
throw new Error(`missing channel: ${name}`);
81-
}
82-
V = V.value;
83-
const compareValue = (i, j) => compare(V[i], V[j]);
84-
return {facets: facets.map(I => I.slice().sort(compareValue))};
85-
};
86-
}
87-
88-
export function channelSort(initializer, {channel, optional, reverse}) {
89-
return composeInitializer(initializer, sortInitializer(channel, optional, reverse ? descendingDefined : ascendingDefined));
90-
}
91-
9274
function findScaleChannel(channels, scale) {
9375
for (const name in channels) {
9476
const channel = channels[name];

src/index.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,11 @@ export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
1717
export {tree, cluster} from "./marks/tree.js";
1818
export {Vector, vector, vectorX, vectorY} from "./marks/vector.js";
1919
export {valueof, column} from "./options.js";
20-
export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js";
20+
export {filter, reverse, sort, shuffle, basic as transform, initializer} from "./transforms/basic.js";
2121
export {bin, binX, binY} from "./transforms/bin.js";
2222
export {dodgeX, dodgeY} from "./transforms/dodge.js";
2323
export {group, groupX, groupY, groupZ} from "./transforms/group.js";
2424
export {hexbin} from "./transforms/hexbin.js";
25-
export {initializer} from "./transforms/initializer.js";
2625
export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js";
2726
export {map, mapX, mapY} from "./transforms/map.js";
2827
export {window, windowX, windowY} from "./transforms/window.js";

src/marks/dot.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {identity, maybeFrameAnchor, maybeNumberChannel, maybeTuple} from "../opt
44
import {Mark} from "../plot.js";
55
import {applyChannelStyles, applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, offset} from "../style.js";
66
import {maybeSymbolChannel} from "../symbols.js";
7+
import {sort} from "../transforms/basic.js";
78
import {maybeIntervalMidX, maybeIntervalMidY} from "../transforms/interval.js";
89

910
const defaults = {
@@ -28,7 +29,7 @@ export class Dot extends Mark {
2829
{name: "rotate", value: vrotate, optional: true},
2930
{name: "symbol", value: vsymbol, scale: "symbol", optional: true}
3031
],
31-
options.sort === undefined ? {...options, sort: {channel: "r", optional: true, reverse: true}} : options,
32+
options.sort === undefined && options.reverse === undefined ? sort({channel: "r", order: "descending"}, options) : options,
3233
defaults
3334
);
3435
this.r = cr;

src/options.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ export function isOptions(option) {
114114
return isObject(option) && typeof option.transform !== "function";
115115
}
116116

117+
// Disambiguates a sort transform (e.g., {sort: "date"}) from a channel domain
118+
// sort definition (e.g., {sort: {y: "x"}}).
119+
export function isDomainSort(sort) {
120+
return isOptions(sort) && sort.value === undefined && sort.channel === undefined;
121+
}
122+
117123
// For marks specified either as [0, x] or [x1, x2], such as areas and bars.
118124
export function maybeZero(x, x1, x2, x3 = identity) {
119125
if (x1 === undefined && x2 === undefined) { // {x} or {}

src/plot.js

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import {create, cross, difference, groups, InternMap, select} from "d3";
22
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
3-
import {Channel, channelObject, channelDomain, channelSort, valueObject} from "./channel.js";
3+
import {Channel, channelObject, channelDomain, valueObject} from "./channel.js";
44
import {defined} from "./defined.js";
55
import {Dimensions} from "./dimensions.js";
66
import {Legends, exposeLegends} from "./legends.js";
7-
import {arrayify, isOptions, isScaleOptions, keyword, map, range, second, where, yes} from "./options.js";
7+
import {arrayify, isDomainSort, isScaleOptions, keyword, map, range, second, where, yes} from "./options.js";
88
import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
99
import {registry as scaleRegistry} from "./scales/index.js";
1010
import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js";
11-
import {basic} from "./transforms/basic.js";
11+
import {basic, initializer} from "./transforms/basic.js";
1212
import {consumeWarnings} from "./warnings.js";
1313

1414
export function plot(options = {}) {
@@ -248,14 +248,13 @@ export function plot(options = {}) {
248248

249249
export class Mark {
250250
constructor(data, channels = [], options = {}, defaults) {
251-
const {facet = "auto", sort, dx, dy, clip, initializer, channels: extraChannels} = options;
251+
const {facet = "auto", sort, dx, dy, clip, channels: extraChannels} = options;
252252
const names = new Set();
253253
this.data = data;
254-
this.sort = isOptions(sort) ? sort : null;
255-
this.initializer = this.sort?.channel == null ? initializer : channelSort(initializer, this.sort);
254+
this.sort = isDomainSort(sort) ? sort : null;
255+
this.initializer = initializer(options).initializer;
256+
this.transform = this.initializer ? options.transform : basic(options).transform;
256257
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
257-
const {transform} = basic(options);
258-
this.transform = transform;
259258
if (extraChannels !== undefined) channels = [...channels, ...extraChannels.filter(e => !channels.some(c => c.name === e.name))];
260259
if (defaults !== undefined) channels = [...channels, ...styles(this, options, defaults)];
261260
this.channels = channels.filter(channel => {

src/scales/ordinal.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ function maybeRound(scale, channels, options) {
106106
function inferDomain(channels) {
107107
const values = new InternSet();
108108
for (const {value, domain} of channels) {
109-
if (domain !== undefined) return domain(); // see channelSort
109+
if (domain !== undefined) return domain(); // see channelDomain
110110
if (value === undefined) continue;
111111
for (const v of value) values.add(v);
112112
}

src/transforms/basic.js

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {randomLcg} from "d3";
2-
import {ascendingDefined} from "../defined.js";
3-
import {arrayify, isOptions, valueof} from "../options.js";
2+
import {ascendingDefined, descendingDefined} from "../defined.js";
3+
import {arrayify, isDomainSort, isOptions, maybeValue, valueof} from "../options.js";
44

55
// If both t1 and t2 are defined, returns a composite transform that first
66
// applies t1 and then applies t2.
@@ -14,17 +14,37 @@ export function basic({
1414
} = {}, t2) {
1515
if (t1 === undefined) { // explicit transform overrides filter, sort, and reverse
1616
if (f1 != null) t1 = filterTransform(f1);
17-
if (s1 != null && !isOptions(s1)) t1 = composeTransform(t1, sortTransform(s1));
17+
if (s1 != null && !isDomainSort(s1)) t1 = composeTransform(t1, sortTransform(s1));
1818
if (r1) t1 = composeTransform(t1, reverseTransform);
1919
}
2020
if (t2 != null && i1 != null) throw new Error("transforms cannot be applied after initializers");
2121
return {
2222
...options,
23-
...(s1 === null || isOptions(s1)) && {sort: s1},
23+
...(s1 === null || isDomainSort(s1)) && {sort: s1},
2424
transform: composeTransform(t1, t2)
2525
};
2626
}
2727

28+
// If both i1 and i2 are defined, returns a composite initializer that first
29+
// applies i1 and then applies i2.
30+
export function initializer({
31+
filter: f1,
32+
sort: s1,
33+
reverse: r1,
34+
initializer: i1,
35+
...options
36+
} = {}, i2) {
37+
if (i1 === undefined) { // explicit initializer overrides filter, sort, and reverse
38+
if (f1 != null) i1 = filterTransform(f1);
39+
if (s1 != null && !isDomainSort(s1)) i1 = composeInitializer(i1, sortTransform(s1));
40+
if (r1) i1 = composeInitializer(i1, reverseTransform);
41+
}
42+
return {
43+
...options,
44+
initializer: composeInitializer(i1, i2)
45+
};
46+
}
47+
2848
function composeTransform(t1, t2) {
2949
if (t1 == null) return t2 === null ? undefined : t2;
3050
if (t2 == null) return t1 === null ? undefined : t1;
@@ -34,8 +54,23 @@ function composeTransform(t1, t2) {
3454
};
3555
}
3656

57+
function composeInitializer(i1, i2) {
58+
if (i1 == null) return i2 === null ? undefined : i2;
59+
if (i2 == null) return i1 === null ? undefined : i1;
60+
return function(data, facets, channels, scales, dimensions) {
61+
let c1, d1, f1, c2, d2, f2;
62+
({data: d1 = data, facets: f1 = facets, channels: c1} = i1.call(this, data, facets, channels, scales, dimensions));
63+
({data: d2 = d1, facets: f2 = f1, channels: c2} = i2.call(this, d1, f1, {...channels, ...c1}, scales, dimensions));
64+
return {data: d2, facets: f2, channels: {...c1, ...c2}};
65+
};
66+
}
67+
68+
function apply(options, t) {
69+
return (options.initializer != null ? initializer : basic)(options, t);
70+
}
71+
3772
export function filter(value, options) {
38-
return basic(options, filterTransform(value));
73+
return apply(options, filterTransform(value));
3974
}
4075

4176
function filterTransform(value) {
@@ -46,36 +81,53 @@ function filterTransform(value) {
4681
}
4782

4883
export function reverse(options) {
49-
return {...basic(options, reverseTransform), sort: null};
84+
return {...apply(options, reverseTransform), sort: null};
5085
}
5186

5287
function reverseTransform(data, facets) {
5388
return {data, facets: facets.map(I => I.slice().reverse())};
5489
}
5590

5691
export function shuffle({seed, ...options} = {}) {
57-
return {...basic(options, sortValue(seed == null ? Math.random : randomLcg(seed))), sort: null};
92+
return {...apply(options, sortValue(seed == null ? Math.random : randomLcg(seed))), sort: null};
5893
}
5994

6095
export function sort(value, options) {
61-
return {...basic(options, sortTransform(value)), sort: null};
96+
return {...(isOptions(value) && value.channel !== undefined ? initializer : apply)(options, sortTransform(value)), sort: null};
6297
}
6398

6499
function sortTransform(value) {
65-
return (typeof value === "function" && value.length !== 1 ? sortCompare : sortValue)(value);
100+
return (typeof value === "function" && value.length !== 1 ? sortData : sortValue)(value);
66101
}
67102

68-
function sortCompare(compare) {
103+
function sortData(compare) {
69104
return (data, facets) => {
70105
const compareData = (i, j) => compare(data[i], data[j]);
71106
return {data, facets: facets.map(I => I.slice().sort(compareData))};
72107
};
73108
}
74109

75110
function sortValue(value) {
76-
return (data, facets) => {
77-
const V = valueof(data, value);
78-
const compareValue = (i, j) => ascendingDefined(V[i], V[j]);
111+
let channel, order;
112+
({channel, value, order = ascendingDefined} = {...maybeValue(value)});
113+
if (typeof order !== "function") {
114+
switch (`${order}`.toLowerCase()) {
115+
case "ascending": order = ascendingDefined; break;
116+
case "descending": order = descendingDefined; break;
117+
default: throw new Error(`invalid order: ${order}`);
118+
}
119+
}
120+
return (data, facets, channels) => {
121+
let V;
122+
if (channel === undefined) {
123+
V = valueof(data, value);
124+
} else {
125+
if (channels === undefined) throw new Error("channel sort requires an initializer");
126+
V = channels[channel];
127+
if (!V) return {}; // ignore missing channel
128+
V = V.value;
129+
}
130+
const compareValue = (i, j) => order(V[i], V[j]);
79131
return {data, facets: facets.map(I => I.slice().sort(compareValue))};
80132
};
81133
}

src/transforms/dodge.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import IntervalTree from "interval-tree-1d";
33
import {finite, positive} from "../defined.js";
44
import {identity, number, valueof} from "../options.js";
55
import {coerceNumbers} from "../scales.js";
6-
import {initializer} from "./initializer.js";
6+
import {initializer} from "./basic.js";
77

88
const anchorXLeft = ({marginLeft}) => [1, marginLeft];
99
const anchorXRight = ({width, marginRight}) => [-1, width - marginRight];
@@ -50,7 +50,7 @@ function dodge(y, x, anchor, padding, options) {
5050
if (r != null && typeof r !== "number") {
5151
const {channels, sort, reverse} = options;
5252
options = {...options, channels: [...channels ?? [], {name: "r", value: r, scale: "r"}]};
53-
if (sort === undefined && reverse === undefined) options.sort = r, options.reverse = true;
53+
if (sort === undefined && reverse === undefined) options.sort = {channel: "r", order: "descending"};
5454
}
5555
return initializer(options, function(data, facets, {[x]: X, r: R}, scales, dimensions) {
5656
if (!X) throw new Error(`missing channel: ${x}`);

src/transforms/hexbin.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {coerceNumbers} from "../scales.js";
22
import {sqrt3} from "../symbols.js";
33
import {identity, isNoneish, number, valueof} from "../options.js";
4+
import {initializer} from "./basic.js";
45
import {hasOutput, maybeGroup, maybeOutputs, maybeSubgroup} from "./group.js";
5-
import {initializer} from "./initializer.js";
66

77
// We don’t want the hexagons to align with the edges of the plot frame, as that
88
// would cause extreme x-values (the upper bound of the default x-scale domain)

0 commit comments

Comments
 (0)