Skip to content

Commit 3579c92

Browse files
mbostockFil
andauthored
per-channel scale override, and “auto” scale (#1247)
* per-channel scale override; "auto" scale * document the {value, scale} pattern for color and symbol channels * Update README.md Co-authored-by: Mike Bostock <[email protected]> * const style * validate scale name * fix inferred scale for initializers * inferChannelScale * less painful colors * Update README.md * Update README.md * Update README.md * Update README --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent b817f58 commit 3579c92

23 files changed

+1317
-990
lines changed

README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Plot.plot({
161161

162162
Plot supports many scale types. Some scale types are for quantitative data: values that can be added or subtracted, such as temperature or time. Other scale types are for ordinal or categorical data: unquantifiable values that can only be ordered, such as t-shirt sizes, or values with no inherent order that can only be tested for equality, such as types of fruit. Some scale types are further intended for specific visual encodings: for example, as [position](#position-options) or [color](#color-options).
163163

164-
You can set the scale type explicitly via the *scale*.**type** option, though typically the scale type is inferred automatically. Some marks mandate a particular scale type: for example, [Plot.barY](#plotbarydata-options) requires that the *x* scale is a *band* scale. Some scales have a default type: for example, the *radius* scale defaults to *sqrt* and the *opacity* scale defaults to *linear*. Most often, the scale type is inferred from associated data, pulled either from the domain (if specified) or from associated channels. A *color* scale defaults to *identity* if no range or scheme is specified and all associated defined values are valid CSS color strings. Otherwise, strings and booleans imply an ordinal scale; dates imply a UTC scale; and anything else is linear. Unless they represent text, we recommend explicitly converting strings to more specific types when loading data (*e.g.*, with d3.autoType or Observable’s FileAttachment). For simplicity’s sake, Plot assumes that data is consistently typed; type inference is based solely on the first non-null, non-undefined value.
164+
You can set the scale type explicitly via the *scale*.**type** option, though typically the scale type is inferred automatically. Some marks mandate a particular scale type: for example, [Plot.barY](#plotbarydata-options) requires that the *x* scale is a *band* scale. Some scales have a default type: for example, the *radius* scale defaults to *sqrt* and the *opacity* scale defaults to *linear*. Most often, the scale type is inferred from associated data, pulled either from the domain (if specified) or from associated channels. Strings and booleans imply an ordinal scale; dates imply a UTC scale; and anything else is linear. Unless they represent text, we recommend explicitly converting strings to more specific types when loading data (*e.g.*, with d3.autoType or Observable’s FileAttachment). For simplicity’s sake, Plot assumes that data is consistently typed; type inference is based solely on the first non-null, non-undefined value.
165165

166166
For quantitative data (*i.e.* numbers), a mathematical transform may be applied to the data by changing the scale type:
167167

@@ -800,6 +800,26 @@ All marks support the following optional channels:
800800

801801
The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, **strokeOpacity**, and **opacity** options can be specified as either channels or constants. When the fill or stroke is specified as a function or array, it is interpreted as a channel; when the fill or stroke is specified as a string, it is interpreted as a constant if a valid CSS color and otherwise it is interpreted as a column name for a channel. Similarly when the fill opacity, stroke opacity, object opacity, stroke width, or radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel.
802802

803+
The scale associated with any channel can be overridden by specifying the channel as an object with a *value* property specifying the channel values and a *scale* property specifying the desired scale name or null for an unscaled channel. For example, to force the **stroke** channel to be unscaled, interpreting the associated values as literal color strings:
804+
805+
```js
806+
Plot.dot(data, {stroke: {value: "fieldName", scale: null}})
807+
```
808+
809+
To instead force the **stroke** channel to be bound to the *color* scale regardless of the provided values, say:
810+
811+
```js
812+
Plot.dot(data, {stroke: {value: "fieldName", scale: "color"}})
813+
```
814+
815+
The color channels (**fill** and **stroke**) are bound to the *color* scale by default, unless the provided values are all valid CSS color strings or nullish, in which case the values are interpreted literally and unscaled.
816+
817+
In addition to functions of data, arrays, and column names, channel values can be specified as an object with a *transform* method; this transform method is passed the mark’s array of data and must return the corresponding array of channel values. (Whereas a channel value specified as a function is invoked repeatedly for each element in the mark’s data, similar to *array*.map, the transform method is invoked only once being passed the entire array of data.) For example, to pass the mark’s data directly to the **x** channel, equivalent to [Plot.identity](#plotidentity):
818+
819+
```js
820+
Plot.dot(numbers, {x: {transform: data => data}})
821+
```
822+
803823
The **title**, **href**, and **ariaLabel** options can *only* be specified as channels. When these options are specified as a string, the string refers to the name of a column in the mark’s associated data. If you’d like every instance of a particular mark to have the same value, specify the option as a function that returns the desired value, *e.g.* `() => "Hello, world!"`.
804824

805825
The rectangular marks ([bar](#bar), [cell](#cell), [frame](#frame), and [rect](#rect)) support insets and rounded corner constant options:
@@ -1461,7 +1481,7 @@ The following dot-specific constant options are also supported:
14611481
14621482
The **r** option can be specified as either a channel or constant. When the radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. The radius defaults to 4.5 pixels when using the **symbol** channel, and otherwise 3 pixels. Dots with a nonpositive radius are not drawn.
14631483
1464-
The **stroke** defaults to none. The **fill** defaults to currentColor if the stroke is none, and to none otherwise. The **strokeWidth** defaults to 1.5. The **rotate** and **symbol** options can be specified as either channels or constants. When rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When symbol is a valid symbol name or symbol object (implementing the draw method), it is interpreted as a constant; otherwise it is interpreted as a channel.
1484+
The **stroke** defaults to none. The **fill** defaults to currentColor if the stroke is none, and to none otherwise. The **strokeWidth** defaults to 1.5. The **rotate** and **symbol** options can be specified as either channels or constants. When rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When symbol is a valid symbol name or symbol object (implementing the draw method), it is interpreted as a constant; otherwise it is interpreted as a channel. If the **symbol** channel’s values are all symbols, symbol names, or nullish, the channel is unscaled (values are interpreted literally); otherwise, the channel is bound to the *symbol* scale.
14651485
14661486
The built-in **symbol** types are: *circle*, *cross*, *diamond*, *square*, *star*, *triangle*, and *wye* (for fill) and *circle*, *plus*, *times*, *triangle2*, *asterisk*, *square2*, and *diamond2* (for stroke, based on [Heman Robinson’s research](https://www.tandfonline.com/doi/abs/10.1080/10618600.2019.1637746)). The *hexagon* symbol is also supported. You can also specify a D3 or custom symbol type as an object that implements the [*symbol*.draw(*context*, *size*)](https://github.com/d3/d3-shape/blob/main/README.md#custom-symbol-types) method.
14671487

src/channel.js

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,71 @@
11
import {ascending, descending, rollup, sort} from "d3";
2-
import {first, isIterable, labelof, map, maybeValue, range, valueof} from "./options.js";
2+
import {first, isColor, isEvery, isIterable, labelof, map, maybeValue, range, valueof} from "./options.js";
33
import {registry} from "./scales/index.js";
4+
import {isSymbol, maybeSymbol} from "./symbols.js";
45
import {maybeReduce} from "./transforms/group.js";
56

67
// TODO Type coercion?
7-
export function Channel(data, {scale, type, value, filter, hint}) {
8-
return {
8+
export function Channel(data, {scale, type, value, filter, hint}, name) {
9+
return inferChannelScale(name, {
910
scale,
1011
type,
1112
value: valueof(data, value),
1213
label: labelof(value),
1314
filter,
1415
hint
15-
};
16+
});
1617
}
1718

18-
export function Channels(descriptors, data) {
19-
return Object.fromEntries(Object.entries(descriptors).map(([name, channel]) => [name, Channel(data, channel)]));
19+
export function Channels(channels, data) {
20+
return Object.fromEntries(Object.entries(channels).map(([name, channel]) => [name, Channel(data, channel, name)]));
2021
}
2122

2223
// TODO Use Float64Array for scales with numeric ranges, e.g. position?
2324
export function valueObject(channels, scales) {
2425
return Object.fromEntries(
2526
Object.entries(channels).map(([name, {scale: scaleName, value}]) => {
26-
let scale;
27-
if (scaleName !== undefined) {
28-
scale = scales[scaleName];
29-
}
30-
return [name, scale === undefined ? value : map(value, scale)];
27+
const scale = scaleName == null ? null : scales[scaleName];
28+
return [name, scale == null ? value : map(value, scale)];
3129
})
3230
);
3331
}
3432

33+
// If the channel uses the "auto" scale (or equivalently true), infer the scale
34+
// from the channel name and the provided values. For color and symbol channels,
35+
// no scale is applied if the values are literal; however for symbols, we must
36+
// promote symbol names (e.g., "plus") to symbol implementations (symbolPlus).
37+
// Note: mutates channel!
38+
export function inferChannelScale(name, channel) {
39+
const {scale, value} = channel;
40+
if (scale === true || scale === "auto") {
41+
switch (name) {
42+
case "fill":
43+
case "stroke":
44+
case "color":
45+
channel.scale = isEvery(value, isColor) ? null : "color";
46+
break;
47+
case "fillOpacity":
48+
case "strokeOpacity":
49+
channel.scale = "opacity";
50+
break;
51+
case "symbol":
52+
if (isEvery(value, isSymbol)) {
53+
channel.scale = null;
54+
channel.value = map(value, maybeSymbol);
55+
} else {
56+
channel.scale = "symbol";
57+
}
58+
break;
59+
default:
60+
channel.scale = registry.has(name) ? name : null;
61+
break;
62+
}
63+
} else if (scale != null && !registry.has(scale)) {
64+
throw new Error(`unknown scale: ${scale}`);
65+
}
66+
return channel;
67+
}
68+
3569
// Note: mutates channel.domain! This is set to a function so that it is lazily
3670
// computed; i.e., if the scale’s domain is set explicitly, that takes priority
3771
// over the sort option, and we don’t need to do additional work.

src/mark.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {Channels, channelDomain, valueObject} from "./channel.js";
22
import {defined} from "./defined.js";
33
import {maybeFacetAnchor} from "./facet.js";
4-
import {arrayify, isDomainSort, range} from "./options.js";
4+
import {arrayify, isDomainSort, isOptions, range} from "./options.js";
55
import {keyword, maybeNamed} from "./options.js";
66
import {maybeProject} from "./projection.js";
77
import {maybeClip, styles} from "./style.js";
@@ -41,11 +41,20 @@ export class Mark {
4141
if (extraChannels !== undefined) channels = {...maybeNamed(extraChannels), ...channels};
4242
if (defaults !== undefined) channels = {...styles(this, options, defaults), ...channels};
4343
this.channels = Object.fromEntries(
44-
Object.entries(channels).filter(([name, {value, optional}]) => {
45-
if (value != null) return true;
46-
if (optional) return false;
47-
throw new Error(`missing channel value: ${name}`);
48-
})
44+
Object.entries(channels)
45+
.map(([name, channel]) => {
46+
const {value} = channel;
47+
if (isOptions(value)) {
48+
channel = {...channel, value: value.value};
49+
if (value.scale !== undefined) channel.scale = value.scale;
50+
}
51+
return [name, channel];
52+
})
53+
.filter(([name, {value, optional}]) => {
54+
if (value != null) return true;
55+
if (optional) return false;
56+
throw new Error(`missing channel value: ${name}`);
57+
})
4958
);
5059
this.dx = +dx;
5160
this.dy = +dy;

src/marks/dot.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class Dot extends Mark {
4141
y: {value: y, scale: "y", optional: true},
4242
r: {value: vr, scale: "r", filter: positive, optional: true},
4343
rotate: {value: vrotate, optional: true},
44-
symbol: {value: vsymbol, scale: "symbol", optional: true}
44+
symbol: {value: vsymbol, scale: "auto", optional: true}
4545
},
4646
withDefaultSort(options),
4747
defaults

src/options.js

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -336,23 +336,18 @@ export function isNumeric(values) {
336336
}
337337
}
338338

339-
export function isFirst(values, is) {
340-
for (const value of values) {
341-
if (value == null) continue;
342-
return is(value);
343-
}
344-
}
345-
346-
// Whereas isFirst only tests the first defined value and returns undefined for
347-
// an empty array, this tests all defined values and only returns true if all of
348-
// them are valid colors. It also returns true for an empty array, and thus
349-
// should generally be used in conjunction with isFirst.
339+
// Returns true if every non-null value in the specified iterable of values
340+
// passes the specified predicate, and there is at least one non-null value;
341+
// returns false if at least one non-null value does not pass the specified
342+
// predicate; otherwise returns undefined (as if all values are null).
350343
export function isEvery(values, is) {
344+
let every;
351345
for (const value of values) {
352346
if (value == null) continue;
353347
if (!is(value)) return false;
348+
every = true;
354349
}
355-
return true;
350+
return every;
356351
}
357352

358353
// Mostly relies on d3-color, with a few extra color keywords. Currently this

src/plot.js

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {select} from "d3";
2-
import {Channel} from "./channel.js";
2+
import {Channel, inferChannelScale} from "./channel.js";
33
import {Context, create} from "./context.js";
44
import {Dimensions} from "./dimensions.js";
55
import {Facets, facetExclude, facetGroups, facetOrder, facetTranslate, facetFilter} from "./facet.js";
@@ -156,7 +156,7 @@ export function plot(options = {}) {
156156
state.facets = update.facets;
157157
}
158158
if (update.channels !== undefined) {
159-
inferChannelScale(update.channels, mark);
159+
inferChannelScales(update.channels);
160160
Object.assign(state.channels, update.channels);
161161
for (const channel of Object.values(update.channels)) {
162162
const {scale} = channel;
@@ -370,27 +370,9 @@ function applyScaleTransform(channel, options) {
370370
// An initializer may generate channels without knowing how the downstream mark
371371
// will use them. Marks are typically responsible associated scales with
372372
// channels, but here we assume common behavior across marks.
373-
function inferChannelScale(channels) {
373+
function inferChannelScales(channels) {
374374
for (const name in channels) {
375-
const channel = channels[name];
376-
let {scale} = channel;
377-
if (scale === true) {
378-
switch (name) {
379-
case "fill":
380-
case "stroke":
381-
scale = "color";
382-
break;
383-
case "fillOpacity":
384-
case "strokeOpacity":
385-
case "opacity":
386-
scale = "opacity";
387-
break;
388-
default:
389-
scale = scaleRegistry.has(name) ? name : null;
390-
break;
391-
}
392-
channel.scale = scale;
393-
}
375+
inferChannelScale(name, channels[name]);
394376
}
395377
}
396378

src/scales.js

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import {parse as isoParse} from "isoformat";
22
import {
3-
isColor,
4-
isEvery,
53
isOrdinal,
6-
isFirst,
74
isTemporal,
85
isTemporalString,
96
isNumericString,
@@ -34,7 +31,7 @@ import {
3431
import {isDivergingScheme} from "./scales/schemes.js";
3532
import {ScaleTime, ScaleUtc} from "./scales/temporal.js";
3633
import {ScaleOrdinal, ScalePoint, ScaleBand, ordinalImplicit} from "./scales/ordinal.js";
37-
import {isSymbol, maybeSymbol} from "./symbols.js";
34+
import {maybeSymbol} from "./symbols.js";
3835
import {warn} from "./warnings.js";
3936

4037
export function Scales(
@@ -406,20 +403,8 @@ function inferScaleType(key, channels, {type, domain, range, scheme, pivot, proj
406403
// If there’s no data (and no type) associated with this scale, don’t create a scale.
407404
if (domain === undefined && !channels.some(({value}) => value !== undefined)) return;
408405

409-
const kind = registry.get(key);
410-
411-
// For color scales, if no range or scheme is specified and all associated
412-
// defined values (from the domain if present, and otherwise from channels)
413-
// are valid colors, then default to the identity scale. This allows, for
414-
// example, a fill channel to return literal colors; without this, the colors
415-
// would be remapped to a categorical scheme!
416-
if (kind === color && range === undefined && scheme === undefined && isAll(domain, channels, isColor))
417-
return "identity";
418-
419-
// Similarly for symbols…
420-
if (kind === symbol && range === undefined && isAll(domain, channels, isSymbol)) return "identity";
421-
422406
// Some scales have default types.
407+
const kind = registry.get(key);
423408
if (kind === radius) return "sqrt";
424409
if (kind === opacity || kind === length) return "linear";
425410
if (kind === symbol) return "ordinal";
@@ -461,13 +446,6 @@ function asOrdinalType(kind) {
461446
}
462447
}
463448

464-
function isAll(domain, channels, is) {
465-
return domain !== undefined
466-
? isFirst(domain, is) && isEvery(domain, is)
467-
: channels.some(({value}) => value !== undefined && isFirst(value, is)) &&
468-
channels.every(({value}) => value === undefined || isEvery(value, is));
469-
}
470-
471449
export function isTemporalScale({type}) {
472450
return type === "time" || type === "utc";
473451
}

src/style.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,9 @@ export function styles(
143143
title: {value: title, optional: true},
144144
href: {value: href, optional: true},
145145
ariaLabel: {value: variaLabel, optional: true},
146-
fill: {value: vfill, scale: "color", optional: true},
146+
fill: {value: vfill, scale: "auto", optional: true},
147147
fillOpacity: {value: vfillOpacity, scale: "opacity", optional: true},
148-
stroke: {value: vstroke, scale: "color", optional: true},
148+
stroke: {value: vstroke, scale: "auto", optional: true},
149149
strokeOpacity: {value: vstrokeOpacity, scale: "opacity", optional: true},
150150
strokeWidth: {value: vstrokeWidth, optional: true},
151151
opacity: {value: vopacity, scale: "opacity", optional: true}

test/marks/area-test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ it("area(data, {fill}) allows fill to be a variable color", () => {
7373
assert.strictEqual(area.fill, undefined);
7474
const {fill} = area.channels;
7575
assert.strictEqual(fill.value, "x");
76-
assert.strictEqual(fill.scale, "color");
76+
assert.strictEqual(fill.scale, "auto");
7777
});
7878

7979
it("area(data, {fill}) implies a default z channel if fill is variable", () => {
@@ -98,7 +98,7 @@ it("area(data, {stroke}) allows stroke to be a variable color", () => {
9898
assert.strictEqual(area.stroke, undefined);
9999
const {stroke} = area.channels;
100100
assert.strictEqual(stroke.value, "x");
101-
assert.strictEqual(stroke.scale, "color");
101+
assert.strictEqual(stroke.scale, "auto");
102102
});
103103

104104
it("area(data, {stroke}) implies a default z channel if stroke is variable", () => {

0 commit comments

Comments
 (0)