Skip to content

Commit a915648

Browse files
committed
per-channel scale override; "auto" scale
1 parent 28b1a4c commit a915648

19 files changed

Lines changed: 984 additions & 984 deletions

src/channel.js

Lines changed: 42 additions & 8 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+
export function Channel(data, {scale, type, value, filter, hint}, name) {
9+
let V = valueof(data, value);
10+
11+
// If the channel uses the "auto" scale, infer the scale from the channel name
12+
// and the provided values. For color and symbol channels, no scale is applied
13+
// if the values are literal; however for symbols, we must promote symbol
14+
// names (e.g., "plus") to symbol implementations (symbolPlus).
15+
if (scale === "auto") {
16+
scale = inferChannelScale(name);
17+
if (V) {
18+
if (scale === "color") {
19+
if (isEvery(V, isColor)) {
20+
scale = undefined;
21+
}
22+
} else if (scale === "symbol") {
23+
if (isEvery(V, isSymbol)) {
24+
V = map(V, maybeSymbol);
25+
scale = undefined;
26+
}
27+
}
28+
}
29+
}
30+
831
return {
932
scale,
1033
type,
11-
value: valueof(data, value),
34+
value: V,
1235
label: labelof(value),
1336
filter,
1437
hint
1538
};
1639
}
1740

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

2245
// TODO Use Float64Array for scales with numeric ranges, e.g. position?
2346
export function valueObject(channels, scales) {
2447
return Object.fromEntries(
2548
Object.entries(channels).map(([name, {scale: scaleName, value}]) => {
2649
let scale;
27-
if (scaleName !== undefined) {
28-
scale = scales[scaleName];
29-
}
50+
if (scaleName != null) scale = scales[scaleName];
3051
return [name, scale === undefined ? value : map(value, scale)];
3152
})
3253
);
3354
}
3455

56+
// Returns the default scale for the channel with the given name.
57+
export function inferChannelScale(name) {
58+
switch (name) {
59+
case "fill":
60+
case "stroke":
61+
return "color";
62+
case "fillOpacity":
63+
case "strokeOpacity":
64+
return "opacity";
65+
}
66+
return registry.has(name) ? name : null;
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 & 21 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,10 @@ 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) {
375375
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-
}
376+
if (channel.scale === true) channel.scale = inferChannelScale(name);
394377
}
395378
}
396379

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", () => {

test/marks/bar-test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ it("barX(data, {fill}) allows fill to be a variable color", () => {
6262
assert.strictEqual(bar.fill, undefined);
6363
const {fill} = bar.channels;
6464
assert.strictEqual(fill.value, "x");
65-
assert.strictEqual(fill.scale, "color");
65+
assert.strictEqual(fill.scale, "auto");
6666
});
6767

6868
it("barX(data, {stroke}) allows stroke to be a constant color", () => {
@@ -80,7 +80,7 @@ it("barX(data, {stroke}) allows stroke to be a variable color", () => {
8080
assert.strictEqual(bar.stroke, undefined);
8181
const {stroke} = bar.channels;
8282
assert.strictEqual(stroke.value, "x");
83-
assert.strictEqual(stroke.scale, "color");
83+
assert.strictEqual(stroke.scale, "auto");
8484
});
8585

8686
it("barX(data, {x, y}) defaults x1 to zero and x2 to x", () => {
@@ -162,7 +162,7 @@ it("barY(data, {fill}) allows fill to be a variable color", () => {
162162
assert.strictEqual(bar.fill, undefined);
163163
const {fill} = bar.channels;
164164
assert.strictEqual(fill.value, "x");
165-
assert.strictEqual(fill.scale, "color");
165+
assert.strictEqual(fill.scale, "auto");
166166
});
167167

168168
it("barY(data, {stroke}) allows stroke to be a constant color", () => {
@@ -180,7 +180,7 @@ it("barY(data, {stroke}) allows stroke to be a variable color", () => {
180180
assert.strictEqual(bar.stroke, undefined);
181181
const {stroke} = bar.channels;
182182
assert.strictEqual(stroke.value, "x");
183-
assert.strictEqual(stroke.scale, "color");
183+
assert.strictEqual(stroke.scale, "auto");
184184
});
185185

186186
it("barY(data, {x, y}) defaults y1 to zero and y2 to y", () => {

test/marks/cell-test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ it("cell(data, {fill}) allows fill to be a variable color", () => {
6767
assert.strictEqual(cell.fill, undefined);
6868
const {fill} = cell.channels;
6969
assert.strictEqual(fill.value, "x");
70-
assert.strictEqual(fill.scale, "color");
70+
assert.strictEqual(fill.scale, "auto");
7171
});
7272

7373
it("cell(data, {stroke}) allows stroke to be a constant color", () => {
@@ -85,7 +85,7 @@ it("cell(data, {stroke}) allows stroke to be a variable color", () => {
8585
assert.strictEqual(cell.stroke, undefined);
8686
const {stroke} = cell.channels;
8787
assert.strictEqual(stroke.value, "x");
88-
assert.strictEqual(stroke.scale, "color");
88+
assert.strictEqual(stroke.scale, "auto");
8989
});
9090

9191
it("cellX() defaults x to identity and y to null", () => {
@@ -102,7 +102,7 @@ it("cellX() defaults x to identity and y to null", () => {
102102
);
103103
assert.deepStrictEqual(
104104
Object.values(cell.channels).map((c) => c.scale),
105-
["color", "x"]
105+
["auto", "x"]
106106
);
107107
assert.strictEqual(cell.channels.x.type, "band");
108108
});
@@ -121,7 +121,7 @@ it("cellY() defaults y to identity and x to null", () => {
121121
);
122122
assert.deepStrictEqual(
123123
Object.values(cell.channels).map((c) => c.scale),
124-
["color", "y"]
124+
["auto", "y"]
125125
);
126126
assert.strictEqual(cell.channels.y.type, "band");
127127
});

0 commit comments

Comments
 (0)