Skip to content

Commit d3dc27d

Browse files
committed
expose scales as a reusable Plot scale options object
1 parent a86edf8 commit d3dc27d

File tree

4 files changed

+211
-17
lines changed

4 files changed

+211
-17
lines changed

src/axes.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ function autoAxisTicksK(scale, axis, k) {
3737
}
3838

3939
// Mutates axis.{label,labelAnchor,labelOffset}!
40+
// Mutates scale.label!
4041
export function autoAxisLabels(channels, scales, {x, y, fx, fy}, dimensions) {
4142
if (fx) {
4243
autoAxisLabelsX(fx, scales.fx, channels.get("fx"));
@@ -77,6 +78,7 @@ function autoAxisLabelsX(axis, scale, channels) {
7778
if (axis.label === undefined) {
7879
axis.label = inferLabel(channels, scale, axis, "x");
7980
}
81+
scale.label = axis.label;
8082
}
8183

8284
function autoAxisLabelsY(axis, opposite, scale, channels) {
@@ -88,6 +90,15 @@ function autoAxisLabelsY(axis, opposite, scale, channels) {
8890
if (axis.label === undefined) {
8991
axis.label = inferLabel(channels, scale, axis, "y");
9092
}
93+
scale.label = axis.label;
94+
}
95+
96+
export function autoScaleLabel(scale, channels, {color} = {}) {
97+
if (scale === undefined) return;
98+
if (color !== undefined) scale.label = color.label;
99+
if (scale.label === undefined) {
100+
scale.label = inferLabel(channels, scale, {});
101+
}
91102
}
92103

93104
// Channels can have labels; if all the channels for a given scale are

src/plot.js

Lines changed: 6 additions & 3 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, autoAxisLabels, autoScaleLabel} from "./axes.js";
33
import {facets} from "./facet.js";
44
import {values} from "./mark.js";
5-
import {Scales, autoScaleRange} from "./scales.js";
5+
import {Scales, autoScaleRange, exposeScales} from "./scales.js";
66
import {offset} from "./style.js";
77

88
export function plot(options = {}) {
@@ -52,6 +52,9 @@ export function plot(options = {}) {
5252
autoScaleRange(scaleDescriptors, dimensions);
5353
autoAxisTicks(scaleDescriptors, axes);
5454
autoAxisLabels(scaleChannels, scaleDescriptors, axes, dimensions);
55+
for (const key of ["color", "r", "opacity"]) {
56+
autoScaleLabel(scaleDescriptors[key], scaleChannels.get(key), options);
57+
}
5558

5659
// Normalize the options.
5760
options = {...scaleDescriptors, ...dimensions};
@@ -89,7 +92,7 @@ export function plot(options = {}) {
8992
}
9093

9194
const figure = wrap(svg, {caption});
92-
figure.scales = scales;
95+
figure.scales = (key) => exposeScales(scaleDescriptors, key);
9396
return figure;
9497
}
9598

src/scales.js

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,22 +57,26 @@ function autoScaleRound(scale) {
5757
}
5858

5959
function Scale(key, channels = [], options = {}) {
60-
switch (inferScaleType(key, channels, options)) {
61-
case "diverging": return ScaleDiverging(key, channels, options);
62-
case "categorical": case "ordinal": return ScaleOrdinal(key, channels, options);
63-
case "cyclical": case "sequential": case "linear": return ScaleLinear(key, channels, options);
64-
case "sqrt": return ScalePow(key, channels, {...options, exponent: 0.5});
65-
case "pow": return ScalePow(key, channels, options);
66-
case "log": return ScaleLog(key, channels, options);
67-
case "symlog": return ScaleSymlog(key, channels, options);
68-
case "utc": return ScaleUtc(key, channels, options);
69-
case "time": return ScaleTime(key, channels, options);
70-
case "point": return ScalePoint(key, channels, options);
71-
case "band": return ScaleBand(key, channels, options);
72-
case "identity": return registry.get(key) === position ? ScaleIdentity(key, channels, options) : undefined;
73-
case undefined: return;
60+
const type = inferScaleType(key, channels, options);
61+
let scale;
62+
switch (type) {
63+
case "diverging": scale = ScaleDiverging(key, channels, options); break;
64+
case "categorical": case "ordinal": scale = ScaleOrdinal(key, channels, options); break;
65+
case "cyclical": case "sequential": case "linear": scale = ScaleLinear(key, channels, options); break;
66+
case "sqrt": scale = ScalePow(key, channels, {...options, exponent: 0.5}); break;
67+
case "pow": scale = ScalePow(key, channels, options); break;
68+
case "log": scale = ScaleLog(key, channels, options); break;
69+
case "symlog": scale = ScaleSymlog(key, channels, options); break;
70+
case "utc": scale = ScaleUtc(key, channels, options); break;
71+
case "time": scale = ScaleTime(key, channels, options); break;
72+
case "point": scale = ScalePoint(key, channels, options); break;
73+
case "band": scale = ScaleBand(key, channels, options); break;
74+
case "identity": scale = registry.get(key) === position ? ScaleIdentity(key, channels, options) : undefined; break;
75+
case undefined: break;
7476
default: throw new Error(`unknown scale type: ${options.type}`);
7577
}
78+
if (scale) scale.scale.type = type;
79+
return scale;
7680
}
7781

7882
function inferScaleType(key, channels, {type, domain, range}) {
@@ -105,3 +109,28 @@ function inferScaleType(key, channels, {type, domain, range}) {
105109
function asOrdinalType(key) {
106110
return registry.get(key) === position ? "point" : "ordinal";
107111
}
112+
113+
// prepare scales for exposure through the plot's scales() function
114+
export function exposeScales(scaleDescriptors, key) {
115+
if (key === undefined) {
116+
return Object.fromEntries(
117+
Object.entries(scaleDescriptors)
118+
.map(([key, descriptor]) => [key, exposeScale(descriptor)])
119+
);
120+
}
121+
if (key in scaleDescriptors) {
122+
return exposeScale(scaleDescriptors[key]);
123+
}
124+
}
125+
126+
function exposeScale({scale, label}) {
127+
return {
128+
domain: scale.domain(),
129+
range: scale.range(),
130+
...scale.interpolate && {interpolate: scale.interpolate()},
131+
...label !== undefined && {label},
132+
...scale.type && {type: scale.type},
133+
...scale.clamp && scale.clamp() && {clamp: true},
134+
scale
135+
};
136+
}

test/scales/scales-test.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import * as Plot from "@observablehq/plot";
2+
import tape from "tape-await";
3+
import {JSDOM} from "jsdom";
4+
const {window} = new JSDOM("");
5+
global.document = window.document;
6+
7+
tape("plot(…).scales() exposes the plot’s scales", test => {
8+
const plot = Plot.dot([1, 2], {x: d => d, y: d => d}).plot();
9+
test.equal(typeof plot.scales, "function");
10+
const scales = plot.scales();
11+
test.equal(Object.entries(scales).length, 2);
12+
test.assert("x" in scales);
13+
test.assert("y" in scales);
14+
});
15+
16+
tape("plot(…).scales('x') exposes the plot’s x scale", test => {
17+
const x = Plot.dot([1, 2], {x: d => d}).plot().scales('x');
18+
test.deepEqual(x.domain, [1, 2]);
19+
test.deepEqual(x.range, [20, 620]);
20+
test.equal(typeof x.interpolate, "function");
21+
test.equal(x.type, "linear");
22+
test.equal(x.clamp, undefined);
23+
test.equal(typeof x.scale, "function");
24+
});
25+
26+
tape("plot(…).scales('y') exposes the plot’s y scale", test => {
27+
const y0 = Plot.dot([1, 2], {x: d => d}).plot().scales('y');
28+
test.equal(y0, undefined);
29+
const y = Plot.dot([1, 2], {y: d => d}).plot().scales('y');
30+
test.deepEqual(y.domain, [1, 2]);
31+
test.deepEqual(y.range, [380, 20]);
32+
test.equal(typeof y.interpolate, "function");
33+
test.equal(y.type, "linear");
34+
test.equal(y.clamp, undefined);
35+
test.equal(typeof y.scale, "function");
36+
});
37+
38+
tape("plot(…).scales('fx') exposes the plot’s fx scale", test => {
39+
const fx0 = Plot.dot([1, 2], {x: d => d}).plot().scales('fx');
40+
test.equal(fx0, undefined);
41+
const data = [1, 2];
42+
const fx = Plot.dot(data, {y: d => d}).plot({facet: {data, x: data}}).scales('fx');
43+
test.deepEqual(fx.domain, [1, 2]);
44+
test.deepEqual(fx.range, [40, 620]);
45+
test.equal(typeof fx.interpolate, "undefined");
46+
test.equal(fx.type, "band");
47+
test.equal(fx.clamp, undefined);
48+
test.equal(typeof fx.scale, "function");
49+
});
50+
51+
tape("plot(…).scales('fy') exposes the plot’s fy scale", test => {
52+
const fy0 = Plot.dot([1, 2], {x: d => d}).plot().scales('fy');
53+
test.equal(fy0, undefined);
54+
const data = [1, 2];
55+
const fy = Plot.dot(data, {y: d => d}).plot({facet: {data, y: data}}).scales('fy');
56+
test.deepEqual(fy.domain, [1, 2]);
57+
test.deepEqual(fy.range, [20, 380]);
58+
test.equal(typeof fy.interpolate, "undefined");
59+
test.equal(fy.type, "band");
60+
test.equal(fy.clamp, undefined);
61+
test.equal(typeof fy.scale, "function");
62+
});
63+
64+
tape("plot(…).scales('color') exposes a continuous color scale", test => {
65+
const color0 = Plot.dot([1, 2], {x: d => d}).plot().scales('color');
66+
test.equal(color0, undefined);
67+
const data = [1, 2, 3, 4, 5];
68+
const color = Plot.dot(data, {y: d => d, fill: d => d}).plot().scales('color');
69+
test.deepEqual(color.domain, [1, 5]);
70+
test.deepEqual(color.range, [0, 1]);
71+
test.equal(typeof color.interpolate, "function");
72+
test.equal(color.type, "linear");
73+
test.equal(color.clamp, undefined);
74+
test.equal(typeof color.scale, "function");
75+
});
76+
77+
tape("plot(…).scales('color') exposes an ordinal color scale", test => {
78+
const data = ["a", "b", "c", "d"];
79+
const color = Plot.dot(data, {y: d => d, fill: d => d}).plot().scales('color');
80+
test.deepEqual(color.domain, data);
81+
test.deepEqual(color.range, ['#4e79a7', '#f28e2c', '#e15759', '#76b7b2', '#59a14f', '#edc949', '#af7aa1', '#ff9da7', '#9c755f', '#bab0ab']);
82+
test.equal(typeof color.interpolate, "undefined");
83+
test.equal(color.type, "ordinal");
84+
test.equal(color.clamp, undefined);
85+
test.equal(typeof color.scale, "function");
86+
});
87+
88+
tape("plot(…).scales('r') exposes a radius scale", test => {
89+
const r0 = Plot.dot([1, 2], {x: d => d}).plot().scales('r');
90+
test.equal(r0, undefined);
91+
const data = [1, 2, 3, 4, 9];
92+
const r = Plot.dot(data, {r: d => d}).plot().scales('r');
93+
test.deepEqual(r.domain, [0, 9]);
94+
test.deepEqual(r.range, [0, Math.sqrt(40.5)]);
95+
test.equal(typeof r.interpolate, "function");
96+
test.equal(r.type, "sqrt");
97+
test.equal(r.clamp, undefined);
98+
test.equal(typeof r.scale, "function");
99+
});
100+
101+
tape("plot(…).scales('opacity') exposes a linear scale", test => {
102+
const opacity0 = Plot.dot([1, 2], {x: d => d}).plot().scales('opacity');
103+
test.equal(opacity0, undefined);
104+
const data = [1, 2, 3, 4, 9];
105+
const opacity = Plot.dot(data, {fillOpacity: d => d}).plot().scales('opacity');
106+
test.deepEqual(opacity.domain, [0, 9]);
107+
test.deepEqual(opacity.range, [0, 1]);
108+
test.equal(typeof opacity.interpolate, "function");
109+
test.equal(opacity.type, "linear");
110+
test.equal(opacity.clamp, undefined);
111+
test.equal(typeof opacity.scale, "function");
112+
});
113+
114+
tape("plot(…).scales expose inset domain", test => {
115+
test.deepEqual(scaleOpt({inset: null}).range, [20, 620]);
116+
test.deepEqual(scaleOpt({inset: 7}).range, [27, 613]);
117+
});
118+
119+
tape("plot(…).scales expose clamp", test => {
120+
test.equal(scaleOpt({clamp: false}).clamp, undefined);
121+
test.equal(scaleOpt({clamp: true}).clamp, true);
122+
});
123+
124+
tape("plot(…).scales expose rounded scales", test => {
125+
test.equal(scaleOpt({round: false}).scale(Math.SQRT2), 144.26406871192853);
126+
test.equal(scaleOpt({round: true}).scale(Math.SQRT2), 144);
127+
test.equal(scaleOpt({round: true}).interpolate(0, 100)(Math.SQRT1_2), 71);
128+
});
129+
130+
tape("plot(…).scales expose label", test => {
131+
test.equal(scaleOpt({}).label, "x →");
132+
test.equal(scaleOpt({label: "value"}).label, "value");
133+
});
134+
135+
tape("plot(…).scales expose color label", test => {
136+
const x = Plot.dot([{x: 1}, {x: 2}, {x: 3}], {fill: "x"}).plot().scales("color");
137+
test.equal(x.label, "x");
138+
const y = Plot.dot([{x: 1}, {x: 2}, {x: 3}], {fill: "x"}).plot({color: {label: "y"}}).scales("color");
139+
test.equal(y.label, "y");
140+
});
141+
142+
tape("plot(…).scales expose radius label", test => {
143+
const x = Plot.dot([{x: 1}, {x: 2}, {x: 3}], {r: "x"}).plot().scales("r");
144+
test.equal(x.label, "x");
145+
const r = Plot.dot([{x: 1}, {x: 2}, {x: 3}], {r: "x"}).plot({color: {label: "radius"}}).scales("color");
146+
test.equal(r.label, "radius");
147+
});
148+
149+
function scaleOpt(x) {
150+
return Plot.dot([{x: 1}, {x: 2}, {x: 3}], {x: "x"}).plot({x}).scales("x");
151+
}

0 commit comments

Comments
 (0)