Skip to content

Commit cf338e4

Browse files
authored
Merge branch 'main' into fil/dx-dy
2 parents 457f7fa + 0c291ed commit cf338e4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+5474
-705
lines changed

CHANGELOG.md

Lines changed: 163 additions & 32 deletions
Large diffs are not rendered by default.

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ To run the tests:
5151
yarn test
5252
```
5353

54-
This will automatically generate any missing snapshots in `test/output`, which you should remember to `git add` before committing your changes. (If you forget, your PR will fail in CI, and you’ll get a reminder.)
54+
This will automatically generate any missing snapshots in `test/output`, which you should remember to `git add` before committing your changes. (If you forget, your PR will fail in CI, and you’ll get a reminder.) Changed snapshots are saved alongside the originals with a -changed suffix, for visual inspection. These should never be commited.
5555

5656
If your code intentionally changes some of the existing snapshots, simply blow away the existing snapshots and run the tests again. You can then review what’s changed using `git diff`.
5757

README.md

Lines changed: 91 additions & 43 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@observablehq/plot",
33
"description": "A JavaScript library for exploratory data visualization.",
4-
"version": "0.1.0",
4+
"version": "0.2.0",
55
"author": {
66
"name": "Observable, Inc.",
77
"url": "https://observablehq.com"
@@ -43,7 +43,7 @@
4343
"js-beautify": "^1.13.0",
4444
"jsdom": "^17.0.0",
4545
"jsesc": "^3.0.2",
46-
"mocha": "^9.0.3",
46+
"mocha": "^9.1.0",
4747
"module-alias": "^2.2.2",
4848
"rollup": "^2.32.1",
4949
"rollup-plugin-terser": "^7.0.2",
@@ -57,6 +57,6 @@
5757
"node": ">=12"
5858
},
5959
"publishConfig": {
60-
"registry": "https://npm.pkg.github.com"
60+
"access": "public"
6161
}
6262
}

src/axes.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,21 @@ import {AxisX, AxisY} from "./axis.js";
22

33
export function Axes(
44
{x: xScale, y: yScale, fx: fxScale, fy: fyScale},
5-
{x = {}, y = {}, fx = {}, fy = {}, grid, facet: {grid: facetGrid} = {}} = {}
5+
{x = {}, y = {}, fx = {}, fy = {}, axis = true, grid, line, label, facet: {axis: facetAxis = axis, grid: facetGrid, label: facetLabel = label} = {}} = {}
66
) {
7-
let {axis: xAxis = true} = x;
8-
let {axis: yAxis = true} = y;
9-
let {axis: fxAxis = true} = fx;
10-
let {axis: fyAxis = true} = fy;
7+
let {axis: xAxis = axis} = x;
8+
let {axis: yAxis = axis} = y;
9+
let {axis: fxAxis = facetAxis} = fx;
10+
let {axis: fyAxis = facetAxis} = fy;
1111
if (!xScale) xAxis = null; else if (xAxis === true) xAxis = "bottom";
1212
if (!yScale) yAxis = null; else if (yAxis === true) yAxis = "left";
1313
if (!fxScale) fxAxis = null; else if (fxAxis === true) fxAxis = xAxis === "bottom" ? "top" : "bottom";
1414
if (!fyScale) fyAxis = null; else if (fyAxis === true) fyAxis = yAxis === "left" ? "right" : "left";
1515
return {
16-
...xAxis && {x: new AxisX({grid, ...x, axis: xAxis})},
17-
...yAxis && {y: new AxisY({grid, ...y, axis: yAxis})},
18-
...fxAxis && {fx: new AxisX({name: "fx", grid: facetGrid, ...fx, axis: fxAxis})},
19-
...fyAxis && {fy: new AxisY({name: "fy", grid: facetGrid, ...fy, axis: fyAxis})}
16+
...xAxis && {x: new AxisX({grid, line, label, ...x, axis: xAxis})},
17+
...yAxis && {y: new AxisY({grid, line, label, ...y, axis: yAxis})},
18+
...fxAxis && {fx: new AxisX({name: "fx", grid: facetGrid, label: facetLabel, ...fx, axis: fxAxis})},
19+
...fyAxis && {fy: new AxisY({name: "fy", grid: facetGrid, label: facetLabel, ...fy, axis: fyAxis})}
2020
};
2121
}
2222

src/axis.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {axisTop, axisBottom, axisRight, axisLeft, create, format, utcFormat} from "d3";
22
import {formatIsoDate} from "./format.js";
3-
import {boolean, number, string, keyword, maybeKeyword, constant, isTemporal} from "./mark.js";
3+
import {boolean, take, number, string, keyword, maybeKeyword, constant, isTemporal} from "./mark.js";
44

55
export class AxisX {
66
constructor({
@@ -67,7 +67,7 @@ export class AxisX {
6767
.attr("font-family", null)
6868
.call(!line ? g => g.select(".domain").remove() : () => {})
6969
.call(!grid ? () => {}
70-
: fy ? gridFacetX(fy, -ty)
70+
: fy ? gridFacetX(index, fy, -ty)
7171
: gridX(offsetSign * (marginBottom + marginTop - height)))
7272
.call(!label ? () => {} : g => g.append("text")
7373
.attr("fill", "currentColor")
@@ -148,7 +148,7 @@ export class AxisY {
148148
.attr("font-family", null)
149149
.call(!line ? g => g.select(".domain").remove() : () => {})
150150
.call(!grid ? () => {}
151-
: fx ? gridFacetY(fx, -tx)
151+
: fx ? gridFacetY(index, fx, -tx)
152152
: gridY(offsetSign * (marginLeft + marginRight - width)))
153153
.call(!label ? () => {} : g => g.append("text")
154154
.attr("fill", "currentColor")
@@ -182,22 +182,24 @@ function gridY(x2) {
182182
.attr("x2", x2);
183183
}
184184

185-
function gridFacetX(fy, ty) {
185+
function gridFacetX(index, fy, ty) {
186186
const dy = fy.bandwidth();
187+
const domain = fy.domain();
187188
return g => g.selectAll(".tick")
188189
.append("path")
189190
.attr("stroke", "currentColor")
190191
.attr("stroke-opacity", 0.1)
191-
.attr("d", fy.domain().map(v => `M0,${fy(v) + ty}v${dy}`).join(""));
192+
.attr("d", (index ? take(domain, index) : domain).map(v => `M0,${fy(v) + ty}v${dy}`).join(""));
192193
}
193194

194-
function gridFacetY(fx, tx) {
195+
function gridFacetY(index, fx, tx) {
195196
const dx = fx.bandwidth();
197+
const domain = fx.domain();
196198
return g => g.selectAll(".tick")
197199
.append("path")
198200
.attr("stroke", "currentColor")
199201
.attr("stroke-opacity", 0.1)
200-
.attr("d", fx.domain().map(v => `M${fx(v) + tx},0h${dx}`).join(""));
202+
.attr("d", (index ? take(domain, index) : domain).map(v => `M${fx(v) + tx},0h${dx}`).join(""));
201203
}
202204

203205
function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) {

src/facet.js

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {cross, difference, groups, InternMap} from "d3";
22
import {create} from "d3";
3-
import {Mark, first, second, markify} from "./mark.js";
3+
import {Mark, first, second, markify, where} from "./mark.js";
44
import {applyScales} from "./scales.js";
55
import {filterStyles} from "./style.js";
66

@@ -24,7 +24,6 @@ class Facet extends Mark {
2424
this.marks = marks.flat(Infinity).map(markify);
2525
// The following fields are set by initialize:
2626
this.marksChannels = undefined; // array of mark channels
27-
this.marksIndex = undefined; // array of mark indexes (for non-faceted marks)
2827
this.marksIndexByFacet = undefined; // map from facet key to array of mark indexes
2928
}
3029
initialize() {
@@ -34,7 +33,6 @@ class Facet extends Mark {
3433
const facetsIndex = Array.from(facets, second);
3534
const subchannels = [];
3635
const marksChannels = this.marksChannels = [];
37-
const marksIndex = this.marksIndex = new Array(this.marks.length);
3836
const marksIndexByFacet = this.marksIndexByFacet = facetMap(channels);
3937
for (const facetKey of facetsKeys) {
4038
marksIndexByFacet.set(facetKey, new Array(this.marks.length));
@@ -47,7 +45,7 @@ class Facet extends Mark {
4745
: facet === "include" ? facetsIndex
4846
: facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(index, f))))
4947
: undefined;
50-
const {index: I, channels} = mark.initialize(markFacets);
48+
const {index: I, channels: markChannels} = mark.initialize(markFacets, channels);
5149
// If an index is returned by mark.initialize, its structure depends on
5250
// whether or not faceting has been applied: it is a flat index ([0, 1, 2,
5351
// …]) when not faceted, and a nested index ([[0, 1, …], [2, 3, …], …])
@@ -57,60 +55,68 @@ class Facet extends Mark {
5755
for (let j = 0; j < facetsKeys.length; ++j) {
5856
marksIndexByFacet.get(facetsKeys[j])[i] = I[j];
5957
}
60-
marksIndex[i] = []; // implicit empty index for sparse facets
6158
} else {
6259
for (let j = 0; j < facetsKeys.length; ++j) {
6360
marksIndexByFacet.get(facetsKeys[j])[i] = I;
6461
}
65-
marksIndex[i] = I;
6662
}
6763
}
68-
for (const [, channel] of channels) {
64+
for (const [, channel] of markChannels) {
6965
subchannels.push([, channel]);
7066
}
71-
marksChannels.push(channels);
67+
marksChannels.push(markChannels);
7268
}
7369
return {index, channels: [...channels, ...subchannels]};
7470
}
7571
render(I, scales, channels, dimensions, axes) {
76-
const {marks, marksChannels, marksIndex, marksIndexByFacet} = this;
72+
const {marks, marksChannels, marksIndexByFacet} = this;
7773
const {fx, fy} = scales;
74+
const fyDomain = fy && fy.domain();
75+
const fxDomain = fx && fx.domain();
7876
const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
7977
const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
8078
const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
8179
const marksValues = marksChannels.map(channels => applyScales(channels, scales));
8280
return create("svg:g")
8381
.call(g => {
8482
if (fy && axes.y) {
85-
const domain = fy.domain();
8683
const axis1 = axes.y, axis2 = nolabel(axis1);
87-
const j = axis1.labelAnchor === "bottom" ? domain.length - 1 : axis1.labelAnchor === "center" ? domain.length >> 1 : 0;
84+
const j = axis1.labelAnchor === "bottom" ? fyDomain.length - 1 : axis1.labelAnchor === "center" ? fyDomain.length >> 1 : 0;
8885
const fyDimensions = {...dimensions, ...fyMargins};
8986
g.selectAll()
90-
.data(domain)
87+
.data(fyDomain)
9188
.join("g")
9289
.attr("transform", ky => `translate(0,${fy(ky)})`)
93-
.append((_, i) => (i === j ? axis1 : axis2).render(null, scales, null, fyDimensions));
90+
.append((ky, i) => (i === j ? axis1 : axis2).render(
91+
fx && where(fxDomain, kx => marksIndexByFacet.has([kx, ky])),
92+
scales,
93+
null,
94+
fyDimensions
95+
));
9496
}
9597
if (fx && axes.x) {
96-
const domain = fx.domain();
9798
const axis1 = axes.x, axis2 = nolabel(axis1);
98-
const j = axis1.labelAnchor === "right" ? domain.length - 1 : axis1.labelAnchor === "center" ? domain.length >> 1 : 0;
99+
const j = axis1.labelAnchor === "right" ? fxDomain.length - 1 : axis1.labelAnchor === "center" ? fxDomain.length >> 1 : 0;
99100
const {marginLeft, marginRight} = dimensions;
100101
const fxDimensions = {...dimensions, ...fxMargins, labelMarginLeft: marginLeft, labelMarginRight: marginRight};
101102
g.selectAll()
102-
.data(domain)
103+
.data(fxDomain)
103104
.join("g")
104105
.attr("transform", kx => `translate(${fx(kx)},0)`)
105-
.append((_, i) => (i === j ? axis1 : axis2).render(null, scales, null, fxDimensions));
106+
.append((kx, i) => (i === j ? axis1 : axis2).render(
107+
fy && where(fyDomain, ky => marksIndexByFacet.has([kx, ky])),
108+
scales,
109+
null,
110+
fxDimensions
111+
));
106112
}
107113
})
108114
.call(g => g.selectAll()
109-
.data(facetKeys(scales))
115+
.data(facetKeys(scales).filter(marksIndexByFacet.has, marksIndexByFacet))
110116
.join("g")
111117
.attr("transform", facetTranslate(fx, fy))
112118
.each(function(key) {
113-
const marksFacetIndex = marksIndexByFacet.get(key) || marksIndex;
119+
const marksFacetIndex = marksIndexByFacet.get(key);
114120
for (let i = 0; i < marks.length; ++i) {
115121
const values = marksValues[i];
116122
const index = filterStyles(marksFacetIndex[i], values);

src/mark.js

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1+
import {ascending, descending, rollup, sort} from "d3";
12
import {color} from "d3";
23
import {nonempty} from "./defined.js";
34
import {plot} from "./plot.js";
5+
import {registry} from "./scales/index.js";
46
import {styles} from "./style.js";
57
import {basic} from "./transforms/basic.js";
8+
import {maybeReduce} from "./transforms/group.js";
69

710
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
811
const TypedArray = Object.getPrototypeOf(Uint8Array);
912
const objectToString = Object.prototype.toString;
1013

1114
export class Mark {
1215
constructor(data, channels = [], options = {}, defaults) {
13-
const {facet = "auto", dx, dy} = options;
16+
const {facet = "auto", sort, dx, dy} = options;
1417
const names = new Set();
1518
this.data = data;
16-
this.facet = facet ? keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]) : null;
19+
this.sort = isOptions(sort) ? sort : null;
20+
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
1721
const {transform} = basic(options);
1822
this.transform = transform;
1923
if (defaults !== undefined) channels = styles(this, options, channels, defaults);
@@ -34,7 +38,7 @@ export class Mark {
3438
this.dx = +dx || 0;
3539
this.dy = +dy || 0;
3640
}
37-
initialize(facets) {
41+
initialize(facets, facetChannels) {
3842
let data = arrayify(this.data);
3943
let index = facets === undefined && data != null ? range(data) : facets;
4044
if (data !== undefined && this.transform !== undefined) {
@@ -43,13 +47,12 @@ export class Mark {
4347
data = arrayify(data);
4448
if (facets === undefined && index.length) ([index] = index);
4549
}
46-
return {
47-
index,
48-
channels: this.channels.map(channel => {
49-
const {name} = channel;
50-
return [name == null ? undefined : name + "", Channel(data, channel)];
51-
})
52-
};
50+
const channels = this.channels.map(channel => {
51+
const {name} = channel;
52+
return [name == null ? undefined : name + "", Channel(data, channel)];
53+
});
54+
if (this.sort != null) channelSort(channels, facetChannels, data, this.sort);
55+
return {index, channels};
5356
}
5457
plot({marks = [], ...options} = {}) {
5558
return plot({...options, marks: [...marks, this]});
@@ -66,6 +69,43 @@ function Channel(data, {scale, type, value}) {
6669
};
6770
}
6871

72+
function channelSort(channels, facetChannels, data, options) {
73+
const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options;
74+
for (const x in options) {
75+
if (!registry.has(x)) continue; // ignore unknown scale keys
76+
const {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]);
77+
if (reduce == null || reduce === false) continue; // disabled reducer
78+
const X = channels.find(([, {scale}]) => scale === x) || facetChannels && facetChannels.find(([, {scale}]) => scale === x);
79+
if (!X) throw new Error(`missing channel for scale: ${x}`);
80+
const XV = X[1].value;
81+
const [lo = 0, hi = Infinity] = limit && typeof limit[Symbol.iterator] === "function" ? limit : limit < 0 ? [limit] : [0, limit];
82+
if (y == null) {
83+
X[1].domain = () => {
84+
let domain = XV;
85+
if (reverse) domain = domain.slice().reverse();
86+
if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi);
87+
return domain;
88+
};
89+
} else {
90+
let YV;
91+
if (y === "data") {
92+
YV = data;
93+
} else {
94+
const Y = channels.find(([name]) => name === y);
95+
if (!Y) throw new Error(`missing channel: ${y}`);
96+
YV = Y[1].value;
97+
}
98+
const reducer = maybeReduce(reduce === true ? "max" : reduce, YV);
99+
X[1].domain = () => {
100+
let domain = rollup(range(XV), I => reducer.reduce(I, YV), i => XV[i]);
101+
domain = sort(domain, reverse ? descendingGroup : ascendingGroup);
102+
if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi);
103+
return domain.map(first);
104+
};
105+
}
106+
}
107+
}
108+
69109
// This allows transforms to behave equivalently to channels.
70110
export function valueof(data, value, type) {
71111
const array = type === undefined ? Array : type;
@@ -143,6 +183,14 @@ export function arrayify(data, type) {
143183
: (data instanceof type ? data : type.from(data)));
144184
}
145185

186+
// Disambiguates an options object (e.g., {y: "x2"}) from a channel value
187+
// definition expressed as a channel transform (e.g., {transform: …}).
188+
export function isOptions(option) {
189+
return option
190+
&& option.toString === objectToString
191+
&& typeof option.transform !== "function";
192+
}
193+
146194
// For marks specified either as [0, x] or [x1, x2], such as areas and bars.
147195
export function maybeZero(x, x1, x2, x3 = identity) {
148196
if (x1 === undefined && x2 === undefined) { // {x} or {}
@@ -189,6 +237,11 @@ export function range(data) {
189237
return Uint32Array.from(data, indexOf);
190238
}
191239

240+
// Returns a filtered range of data given the test function.
241+
export function where(data, test) {
242+
return range(data).filter(i => test(data[i], i, data));
243+
}
244+
192245
// Returns an array [values[index[0]], values[index[1]], …].
193246
export function take(values, index) {
194247
return Array.from(index, i => values[i]);
@@ -247,9 +300,7 @@ export function mid(x1, x2) {
247300

248301
// This distinguishes between per-dimension options and a standalone value.
249302
export function maybeValue(value) {
250-
return value === undefined || (value &&
251-
value.toString === objectToString &&
252-
typeof value.transform !== "function") ? value : {value};
303+
return value === undefined || isOptions(value) ? value : {value};
253304
}
254305

255306
export function numberChannel(source) {
@@ -292,3 +343,11 @@ export function marks(...marks) {
292343
marks.plot = Mark.prototype.plot;
293344
return marks;
294345
}
346+
347+
function ascendingGroup([ak, av], [bk, bv]) {
348+
return ascending(av, bv) || ascending(ak, bk);
349+
}
350+
351+
function descendingGroup([ak, av], [bk, bv]) {
352+
return descending(av, bv) || ascending(ak, bk);
353+
}

src/scales.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {registry, position, radius, opacity} from "./scales/index.js";
22
import {ScaleLinear, ScaleSqrt, ScalePow, ScaleLog, ScaleSymlog, ScaleQuantile, ScaleThreshold, ScaleIdentity} from "./scales/quantitative.js";
3-
import {ScaleDiverging, ScaleDivergingSqrt, ScaleDivergingPow, ScaleDivergingLog, ScaleDivergingSymlog} from "./scales/quantitative.js";
3+
import {ScaleDiverging, ScaleDivergingSqrt, ScaleDivergingPow, ScaleDivergingLog, ScaleDivergingSymlog} from "./scales/diverging.js";
44
import {ScaleTime, ScaleUtc} from "./scales/temporal.js";
55
import {ScaleOrdinal, ScalePoint, ScaleBand} from "./scales/ordinal.js";
66
import {isOrdinal, isTemporal} from "./mark.js";

0 commit comments

Comments
 (0)