Skip to content

Commit 5d399c6

Browse files
Filmbostock
andauthored
expose scales (#538)
* expose scales * fix tests * Update src/plot.js Co-authored-by: Mike Bostock <[email protected]> * Update src/scales.js Co-authored-by: Mike Bostock <[email protected]> * Update src/scales.js Co-authored-by: Mike Bostock <[email protected]> * Update src/scales.js Co-authored-by: Mike Bostock <[email protected]> * follow 3dbd159 * replace scale.family by tests tiny difference with the parent commit: a non-position scale (such as opacity) can have an automatic label (including "%") * only expose explicit scale options * diverging-* * reverse: true on diverging scales * default type: - "point" for positional scales - "categorical" for color scales - "ordinal" for other scales * bugfix: when we create a finite range from a cyclical color scheme, the last color should not be the same as the first * transform quantile scales into threshold scales ("internally", ie not only when exporting) * scale type: "identity", and scale.transform * fix reverse * readme * a bunch of unit tests * prettier * check that scales are reusable * polylinear (aka piecewise linear) scale should work without specifying the domain * utc * braces * fix test * timezones… is this going to work? * more utc * polylinear color schemes * polylinear scale with a color interpolate function (color scheme) (seems like the most straightforward approach) * fix polylinear scheme * honor reverse: true, and accept a reversed (monotone-decreasing) domain. * `zero: true` was not compatible with a polylinear domain. (In practice, one probably wouldn’t try to use both options together. Still, looks safer.) * simplify * more tests for label (null, undefined) * pass the original information about _reverse_ * expose round if not subsumed in interpolate (e.g., in point scales) * use scale.range rather than scale.scale.range() subsumes the range in *interpolate* for continuous color scales. * prettier * test diverging-* scales * test diverging-log with negative values * prefer left-hand-side label with ← for a reversed domain such as [100, 0] this also fixes the issue of labelAnchor being wrong when exposing the scale, since reverse is sometimes subsumed in a reversal of the domain * line * Update src/scales.js Co-authored-by: Mike Bostock <[email protected]> * clarify interpolate; expose clamp only if true * force tests to pass * first pass at cleaning tests * run mocha once * invert error logic * jsdom test helper * cleaner interpolate * materialize clamp, interpolate; pow * more test cleanup * fix tests: * sqrt is now pow with exponent .5 * clamp is now exposed when false * fix polylinear * fix interpolator test * another test (piecewise + scheme + reverse) * materialize round for band and point scales * stricter validation of options * more tests * cyclical and sequential are linear * more tests * interpolate option for threshold and quantile scales * map categorical to ordinal * stricter validation for point and band scale options * more tidying, tests * test nonsense options * piecewise scale tests * categorical is promoted to ordinal * consolidate ordinality tests * fixes for diverging scales * test for positive, not exactly one * add mutation warning * don’t mutate domain input * don’t pass unnecessary options * Update README * tidy auto scale labels * mutate warnings * comment * remove obsolete comment * test ordinal transform * transform and percent are not strictly quantitative * handle percent, transform options generically * don’t duplicate default interpolate * identity accessor with interval transform * fix default tick count for poly ranges * robust scale order * Update README * fix scaleOrder for identity scales Co-authored-by: Mike Bostock <[email protected]>
1 parent 07765a0 commit 5d399c6

24 files changed

+3490
-179
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,23 @@ Plot.plot({
198198
})
199199
```
200200

201+
Scale definitions are exposed through the *plot*.**scale**(*name*) function of the returned plot.
202+
203+
```js
204+
const plot = Plot.plot(…); // render a plot
205+
const color = plot.scale("color"); // retrieve the color scale object
206+
console.log(color.range); // inspect the color scale’s range, ["red", "blue"]
207+
```
208+
209+
To reuse a scale across plots, pass the scale object into another plot specification:
210+
211+
```js
212+
const plot1 = Plot.plot(…);
213+
const plot2 = Plot.plot({…, color: plot1.scale("color")});
214+
```
215+
216+
The returned scale object represents the actual (or “materialized”) values encountered in the plot, including the domain, range, interpolate function, *etc.* The scale’s label, if any, is also returned; however, note that other axis properties are not currently exposed. The scale object is undefined if there is the associated plot has scale with the given *name*, and throws an error if the *name* is invalid (*i.e.*, not one of the known scale names: *x*, *y*, *fx*, *fy*, *r*, *color*, or *opacity*).
217+
201218
### Position options
202219

203220
The position scales (*x*, *y*, *fx*, and *fy*) support additional options:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"src/**/*.css"
2727
],
2828
"scripts": {
29-
"test": "mkdir -p test/output && mocha -r module-alias/register 'test/**/*-test.js' && mocha -r module-alias/register test/plot.js && eslint src test",
29+
"test": "mkdir -p test/output && mocha -r module-alias/register 'test/**/*-test.js' test/plot.js && eslint src test",
3030
"prepublishOnly": "rm -rf dist && rollup -c",
3131
"postpublish": "git push && git push --tags",
3232
"dev": "snowpack dev"

src/axes.js

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import {extent} from "d3";
12
import {AxisX, AxisY} from "./axis.js";
3+
import {isOrdinalScale, isTemporalScale, scaleOrder} from "./scales.js";
4+
import {position, registry} from "./scales/index.js";
25

36
export function Axes(
47
{x: xScale, y: yScale, fx: fxScale, fy: fyScale},
@@ -31,13 +34,13 @@ export function autoAxisTicks({x, y, fx, fy}, {x: xAxis, y: yAxis, fx: fxAxis, f
3134

3235
function autoAxisTicksK(scale, axis, k) {
3336
if (axis.ticks === undefined) {
34-
const [min, max] = scale.scale.range();
35-
axis.ticks = Math.abs(max - min) / k;
37+
const [min, max] = extent(scale.scale.range());
38+
axis.ticks = (max - min) / k;
3639
}
3740
}
3841

39-
// Mutates axis.{label,labelAnchor,labelOffset}!
40-
export function autoAxisLabels(channels, scales, {x, y, fx, fy}, dimensions) {
42+
// Mutates axis.{label,labelAnchor,labelOffset} and scale.label!
43+
export function autoScaleLabels(channels, scales, {x, y, fx, fy}, dimensions, options) {
4144
if (fx) {
4245
autoAxisLabelsX(fx, scales.fx, channels.get("fx"));
4346
if (fx.labelOffset === undefined) {
@@ -66,28 +69,47 @@ export function autoAxisLabels(channels, scales, {x, y, fx, fy}, dimensions) {
6669
y.labelOffset = y.axis === "left" ? marginLeft - facetMarginLeft : marginRight - facetMarginRight;
6770
}
6871
}
72+
for (const [key, type] of registry) {
73+
if (type !== position && scales[key]) { // not already handled above
74+
autoScaleLabel(key, scales[key], channels.get(key), options[key]);
75+
}
76+
}
6977
}
7078

79+
// Mutates axis.labelAnchor, axis.label, scale.label!
7180
function autoAxisLabelsX(axis, scale, channels) {
7281
if (axis.labelAnchor === undefined) {
73-
axis.labelAnchor = scale.type === "ordinal" ? "center"
74-
: scale.reverse ? "left"
82+
axis.labelAnchor = isOrdinalScale(scale) ? "center"
83+
: scaleOrder(scale) < 0 ? "left"
7584
: "right";
7685
}
7786
if (axis.label === undefined) {
7887
axis.label = inferLabel(channels, scale, axis, "x");
7988
}
89+
scale.label = axis.label;
8090
}
8191

92+
// Mutates axis.labelAnchor, axis.label, scale.label!
8293
function autoAxisLabelsY(axis, opposite, scale, channels) {
8394
if (axis.labelAnchor === undefined) {
84-
axis.labelAnchor = scale.type === "ordinal" ? "center"
85-
: opposite && opposite.axis === "top" ? "bottom" // TODO scale.reverse?
95+
axis.labelAnchor = isOrdinalScale(scale) ? "center"
96+
: opposite && opposite.axis === "top" ? "bottom" // TODO scaleOrder?
8697
: "top";
8798
}
8899
if (axis.label === undefined) {
89100
axis.label = inferLabel(channels, scale, axis, "y");
90101
}
102+
scale.label = axis.label;
103+
}
104+
105+
// Mutates scale.label!
106+
function autoScaleLabel(key, scale, channels, options) {
107+
if (options) {
108+
scale.label = options.label;
109+
}
110+
if (scale.label === undefined) {
111+
scale.label = inferLabel(channels, scale, null, key);
112+
}
91113
}
92114

93115
// Channels can have labels; if all the channels for a given scale are
@@ -102,17 +124,19 @@ function inferLabel(channels = [], scale, axis, key) {
102124
else if (candidate !== label) return;
103125
}
104126
if (candidate !== undefined) {
105-
const {percent, reverse} = scale;
106127
// Ignore the implicit label for temporal scales if it’s simply “date”.
107-
if (scale.type === "temporal" && /^(date|time|year)$/i.test(candidate)) return;
108-
if (scale.type !== "ordinal" && (key === "x" || key === "y")) {
109-
if (percent) candidate = `${candidate} (%)`;
110-
if (axis.labelAnchor === "center") {
111-
candidate = `${candidate} →`;
112-
} else if (key === "x") {
113-
candidate = reverse ? `← ${candidate}` : `${candidate} →`;
114-
} else {
115-
candidate = `${reverse ? "↓ " : "↑ "}${candidate}`;
128+
if (isTemporalScale(scale) && /^(date|time|year)$/i.test(candidate)) return;
129+
if (!isOrdinalScale(scale)) {
130+
if (scale.percent) candidate = `${candidate} (%)`;
131+
if (key === "x" || key === "y") {
132+
const order = scaleOrder(scale);
133+
if (order) {
134+
if (key === "x" || (axis && axis.labelAnchor === "center")) {
135+
candidate = key === "x" === order < 0 ? `← ${candidate}` : `${candidate} →`;
136+
} else {
137+
candidate = `${order < 0 ? "↑ " : "↓ "}${candidate}`;
138+
}
139+
}
116140
}
117141
}
118142
}

src/marks/area.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {Curve} from "../curve.js";
33
import {defined} from "../defined.js";
44
import {Mark, indexOf, maybeZ} from "../mark.js";
55
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js";
6+
import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js";
67
import {maybeStackX, maybeStackY} from "../transforms/stack.js";
78

89
const defaults = {
@@ -54,9 +55,9 @@ export function area(data, options) {
5455
}
5556

5657
export function areaX(data, {y = indexOf, ...options} = {}) {
57-
return new Area(data, maybeStackX({...options, y1: y, y2: undefined}));
58+
return new Area(data, maybeStackX(maybeIdentityX({...options, y1: y, y2: undefined})));
5859
}
5960

6061
export function areaY(data, {x = indexOf, ...options} = {}) {
61-
return new Area(data, maybeStackY({...options, x1: x, x2: undefined}));
62+
return new Area(data, maybeStackY(maybeIdentityY({...options, x1: x, x2: undefined})));
6263
}

src/marks/bar.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {filter} from "../defined.js";
33
import {Mark, number} from "../mark.js";
44
import {isCollapsed} from "../scales.js";
55
import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js";
6+
import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js";
67
import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js";
78
import {maybeStackX, maybeStackY} from "../transforms/stack.js";
89

@@ -117,9 +118,9 @@ export class BarY extends AbstractBar {
117118
}
118119

119120
export function barX(data, options) {
120-
return new BarX(data, maybeStackX(maybeIntervalX(options)));
121+
return new BarX(data, maybeStackX(maybeIntervalX(maybeIdentityX(options))));
121122
}
122123

123124
export function barY(data, options) {
124-
return new BarY(data, maybeStackY(maybeIntervalY(options)));
125+
return new BarY(data, maybeStackY(maybeIntervalY(maybeIdentityY(options))));
125126
}

src/marks/rect.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {filter} from "../defined.js";
33
import {Mark, number} from "../mark.js";
44
import {isCollapsed} from "../scales.js";
55
import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js";
6+
import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js";
67
import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js";
78
import {maybeStackX, maybeStackY} from "../transforms/stack.js";
89

@@ -69,9 +70,9 @@ export function rect(data, options) {
6970
}
7071

7172
export function rectX(data, options) {
72-
return new Rect(data, maybeStackX(maybeIntervalY(options)));
73+
return new Rect(data, maybeStackX(maybeIntervalY(maybeIdentityX(options))));
7374
}
7475

7576
export function rectY(data, options) {
76-
return new Rect(data, maybeStackY(maybeIntervalX(options)));
77+
return new Rect(data, maybeStackY(maybeIntervalX(maybeIdentityY(options))));
7778
}

src/plot.js

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {create} from "d3";
2-
import {Axes, autoAxisTicks, autoAxisLabels} from "./axes.js";
2+
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
33
import {facets} from "./facet.js";
44
import {markify} from "./mark.js";
5-
import {Scales, autoScaleRange, applyScales} from "./scales.js";
5+
import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js";
66
import {filterStyles, offset} from "./style.js";
77

88
export function plot(options = {}) {
@@ -50,8 +50,8 @@ export function plot(options = {}) {
5050
const dimensions = Dimensions(scaleDescriptors, axes, options);
5151

5252
autoScaleRange(scaleDescriptors, dimensions);
53+
autoScaleLabels(scaleChannels, scaleDescriptors, axes, dimensions, options);
5354
autoAxisTicks(scaleDescriptors, axes);
54-
autoAxisLabels(scaleChannels, scaleDescriptors, axes, dimensions);
5555

5656
// Normalize the options.
5757
options = {...scaleDescriptors, ...dimensions};
@@ -90,11 +90,15 @@ export function plot(options = {}) {
9090
}
9191

9292
// Wrap the plot in a figure with a caption, if desired.
93-
if (caption == null) return svg;
94-
const figure = document.createElement("figure");
95-
figure.appendChild(svg);
96-
const figcaption = figure.appendChild(document.createElement("figcaption"));
97-
figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption));
93+
let figure = svg;
94+
if (caption != null) {
95+
figure = document.createElement("figure");
96+
figure.appendChild(svg);
97+
const figcaption = figure.appendChild(document.createElement("figcaption"));
98+
figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption));
99+
}
100+
101+
figure.scale = exposeScales(scaleDescriptors);
98102
return figure;
99103
}
100104

@@ -141,6 +145,6 @@ function ScaleFunctions(scales) {
141145

142146
function autoHeight({y, fy, fx}) {
143147
const nfy = fy ? fy.scale.domain().length : 1;
144-
const ny = y ? (y.type === "ordinal" ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1;
148+
const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1;
145149
return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60;
146150
}

0 commit comments

Comments
 (0)