Skip to content

Commit 4735253

Browse files
mbostockFil
andauthored
ordinal interval (#849)
* ordinal interval * fix test (913629f) * date scale interval & warning (#852) * Specifying a scale interval shows the intent of having ordinal numerical or ordinal dates: suppress warning. Side note: if a numeric interval was specified, string numerics have already been coerced to numbers by the scale transform; so this in fact only has consequences for ordinal dates, such as in the downloads-ordinal test plot. * document scale intervals * test plot with year intervals * 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]> * d3.utcDay-like intervals do not parse string dates * reusable interval option * When the interval option is applied on a quantitative scale, generate the ticks with the interval; also set the tickFormat so that we don't show 1.0, 2.0, 3.0 if the interval is an integer. * tests * normalize intervals lists a few TODOs re: the default tick format: - we don't want decimal notation if the interval is specified as an integer - we don't want months to appear if the interval is specified as d3.utcYear - we don't want years to appear with commas (#768) * formatDefault for ordinal scales * Update README * call maybeInterval sooner * tabular-nums for interval’d ordinal axes Co-authored-by: Mike Bostock <[email protected]> * Update README * options.interval is not normalized here Co-authored-by: Philippe Rivière <[email protected]>
1 parent c6ad4bb commit 4735253

22 files changed

+1342
-19
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,15 @@ A scale’s domain (the extent of its inputs, abstract values) and range (the ex
185185
* *scale*.**range** - typically [*min*, *max*], or an array of ordinal or categorical values
186186
* *scale*.**unknown** - the desired output value (defaults to undefined) for invalid input values
187187
* *scale*.**reverse** - reverses the domain (or in somes cases, the range), say to flip the chart along *x* or *y*
188+
* *scale*.**interval** - an interval or time interval (for interval data; see below)
188189

189190
For most quantitative scales, the default domain is the [*min*, *max*] of all values associated with the scale. For the *radius* and *opacity* scales, the default domain is [0, *max*] to ensure a meaningful value encoding. For ordinal scales, the default domain is the set of all distinct values associated with the scale in natural ascending order; for a different order, set the domain explicitly or add a [sort option](#sort-options) to an associated mark. For threshold scales, the default domain is [0] to separate negative and non-negative values. For quantile scales, the default domain is the set of all defined values associated with the scale. If a scale is reversed, it is equivalent to setting the domain as [*max*, *min*] instead of [*min*, *max*].
190191

191192
The default range depends on the scale: for [position scales](#position-options) (*x*, *y*, *fx*, and *fy*), the default range depends on the plot’s [size and margins](#layout-options). For [color scales](#color-options), there are default color schemes for quantitative, ordinal, and categorical data. For opacity, the default range is [0, 1]. And for radius, the default range is designed to produce dots of “reasonable” size assuming a *sqrt* scale type for accurate area representation: zero maps to zero, the first quartile maps to a radius of three pixels, and other values are extrapolated. This convention for radius ensures that if the scale’s data values are all equal, dots have the default constant radius of three pixels, while if the data varies, dots will tend to be larger.
192193

193-
The behavior of the *scale*.unknown option depends on the scale type. For quantitative and temporal scales, the unknown value is used whenever the input value is undefined, null, or NaN. For ordinal or categorical scales, the unknown value is returned for any input value outside the domain. For band or point scales, the unknown option has no effect; it is effectively always equal to undefined. If the unknown option is set to undefined (the default), or null or NaN, then the affected input values will be considered undefined and filtered from the output.
194+
The behavior of the *scale*.**unknown** option depends on the scale type. For quantitative and temporal scales, the unknown value is used whenever the input value is undefined, null, or NaN. For ordinal or categorical scales, the unknown value is returned for any input value outside the domain. For band or point scales, the unknown option has no effect; it is effectively always equal to undefined. If the unknown option is set to undefined (the default), or null or NaN, then the affected input values will be considered undefined and filtered from the output.
195+
196+
For data at regular intervals, such as integer values or daily samples, the *scale*.**interval** option can be used to enforce uniformity. The specified *interval*—such as d3.utcMonth—must expose an *interval*.floor(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) functions. The option can also be specified as a number, in which case it will be promoted to a numeric interval with the given step. This option sets the default *scale*.transform to the given interval’s *interval*.floor function. In addition, the default *scale*.domain is an array of uniformly-spaced values spanning the extent of the values associated with the scale.
194197

195198
Quantitative scales can be further customized with additional options:
196199

src/axes.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {extent} from "d3";
22
import {AxisX, AxisY} from "./axis.js";
3+
import {formatDefault} from "./format.js";
34
import {isOrdinalScale, isTemporalScale, scaleOrder} from "./scales.js";
45
import {position, registry} from "./scales/index.js";
56

@@ -18,8 +19,8 @@ export function Axes(
1819
return {
1920
...xAxis && {x: new AxisX({grid, line, label, fontVariant: inferFontVariant(xScale), ...x, axis: xAxis})},
2021
...yAxis && {y: new AxisY({grid, line, label, fontVariant: inferFontVariant(yScale), ...y, axis: yAxis})},
21-
...fxAxis && {fx: new AxisX({name: "fx", grid: facetGrid, label: facetLabel, ...fx, axis: fxAxis})},
22-
...fyAxis && {fy: new AxisY({name: "fy", grid: facetGrid, label: facetLabel, ...fy, axis: fyAxis})}
22+
...fxAxis && {fx: new AxisX({name: "fx", grid: facetGrid, label: facetLabel, fontVariant: inferFontVariant(fxScale), ...fx, axis: fxAxis})},
23+
...fyAxis && {fy: new AxisY({name: "fy", grid: facetGrid, label: facetLabel, fontVariant: inferFontVariant(fyScale), ...fy, axis: fyAxis})}
2324
};
2425
}
2526

@@ -34,8 +35,20 @@ export function autoAxisTicks({x, y, fx, fy}, {x: xAxis, y: yAxis, fx: fxAxis, f
3435

3536
function autoAxisTicksK(scale, axis, k) {
3637
if (axis.ticks === undefined) {
37-
const [min, max] = extent(scale.scale.range());
38-
axis.ticks = (max - min) / k;
38+
const interval = scale.interval;
39+
if (interval !== undefined) {
40+
const [min, max] = extent(scale.scale.domain());
41+
axis.ticks = interval.range(interval.floor(min), interval.offset(interval.floor(max)));
42+
} else {
43+
const [min, max] = extent(scale.scale.range());
44+
axis.ticks = (max - min) / k;
45+
}
46+
}
47+
// D3’s ordinal scales simply use toString by default, but if the ordinal
48+
// scale domain (or ticks) are numbers or dates (say because we’re applying a
49+
// time interval to the ordinal scale), we want Plot’s default formatter.
50+
if (axis.tickFormat === undefined && isOrdinalScale(scale)) {
51+
axis.tickFormat = formatDefault;
3952
}
4053
}
4154

@@ -144,5 +157,5 @@ function inferLabel(channels = [], scale, axis, key) {
144157
}
145158

146159
export function inferFontVariant(scale) {
147-
return isOrdinalScale(scale) ? undefined : "tabular-nums";
160+
return isOrdinalScale(scale) && scale.interval === undefined ? undefined : "tabular-nums";
148161
}

src/plot.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"
99
import {registry as scaleRegistry} from "./scales/index.js";
1010
import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js";
1111
import {basic, initializer} from "./transforms/basic.js";
12+
import {maybeInterval} from "./transforms/interval.js";
1213
import {consumeWarnings} from "./warnings.js";
1314

1415
export function plot(options = {}) {
@@ -322,7 +323,11 @@ function applyScaleTransforms(channels, options) {
322323
const channel = channels[name];
323324
const {scale} = channel;
324325
if (scale != null) {
325-
const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {};
326+
const {
327+
percent,
328+
interval,
329+
transform = percent ? x => x * 100 : maybeInterval(interval)?.floor
330+
} = options[scale] || {};
326331
if (transform != null) channel.value = map(channel.value, transform);
327332
}
328333
}

src/scales.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,20 +137,21 @@ function Scale(key, channels = [], options = {}) {
137137
const type = inferScaleType(key, channels, options);
138138

139139
// Warn for common misuses of implicit ordinal scales. We disable this test if
140-
// you set the domain or range explicitly, since setting the domain or range
141-
// (typically with a cardinality of more than two) is another indication that
142-
// you intended for the scale to be ordinal; we also disable it for facet
143-
// scales since these are always band scales.
140+
// you specify a scale interval or if you set the domain or range explicitly,
141+
// since setting the domain or range (typically with a cardinality of more than
142+
// two) is another indication that you intended for the scale to be ordinal; we
143+
// also disable it for facet scales since these are always band scales.
144144
if (options.type === undefined
145145
&& options.domain === undefined
146146
&& options.range === undefined
147+
&& options.interval == null
147148
&& key !== "fx"
148149
&& key !== "fy"
149150
&& isOrdinalScale({type})) {
150151
const values = channels.map(({value}) => value).filter(value => value !== undefined);
151-
if (values.some(isTemporal)) warn(`Warning: some data associated with the ${key} scale are dates. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`);
152+
if (values.some(isTemporal)) warn(`Warning: some data associated with the ${key} scale are dates. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can specify the interval of the ${key} scale (e.g., d3.utcDay), or you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`);
152153
else if (values.some(isTemporalString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be dates (e.g., YYYY-MM-DD). If these strings represent dates, you should parse them to Date objects. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`);
153-
else if (values.some(isNumericString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be numbers. If these strings represent numbers, you should parse or coerce them to numbers. Numbers are typically associated with a "linear" scale rather than a "${formatScaleType(type)}" scale. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`);
154+
else if (values.some(isNumericString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be numbers. If these strings represent numbers, you should parse or coerce them to numbers. Numbers are typically associated with a "linear" scale rather than a "${formatScaleType(type)}" scale. If you want to treat this data as ordinal, you can specify the interval of the ${key} scale (e.g., 1 for integers), or you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`);
154155
}
155156

156157
options.type = type; // Mutates input!
@@ -409,6 +410,7 @@ function exposeScale({
409410
range,
410411
label,
411412
interpolate,
413+
interval,
412414
transform,
413415
percent,
414416
pivot
@@ -423,6 +425,7 @@ function exposeScale({
423425
...percent && {percent}, // only exposed if truthy
424426
...label !== undefined && {label},
425427
...unknown !== undefined && {unknown},
428+
...interval !== undefined && {interval},
426429

427430
// quantitative
428431
...interpolate !== undefined && {interpolate},

src/scales/ordinal.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import {InternSet, quantize, reverse as reverseof, sort, symbolsFill, symbolsStroke} from "d3";
1+
import {InternSet, extent, quantize, reverse as reverseof, sort, symbolsFill, symbolsStroke} from "d3";
22
import {scaleBand, scaleOrdinal, scalePoint, scaleImplicit} from "d3";
33
import {ascendingDefined} from "../defined.js";
44
import {isNoneish, map} from "../options.js";
5+
import {maybeInterval} from "../transforms/interval.js";
56
import {maybeSymbol} from "../symbols.js";
67
import {registry, color, symbol} from "./index.js";
78
import {maybeBooleanRange, ordinalScheme, quantitativeScheme} from "./schemes.js";
@@ -14,11 +15,14 @@ export const ordinalImplicit = Symbol("ordinal");
1415

1516
export function ScaleO(scale, channels, {
1617
type,
17-
domain = inferDomain(channels),
18+
interval,
19+
domain,
1820
range,
1921
reverse,
2022
hint
2123
}) {
24+
interval = maybeInterval(interval);
25+
if (domain === undefined) domain = inferDomain(channels, interval);
2226
if (type === "categorical" || type === ordinalImplicit) type = "ordinal"; // shorthand for color schemes
2327
if (reverse) domain = reverseof(domain);
2428
scale.domain(domain);
@@ -27,17 +31,20 @@ export function ScaleO(scale, channels, {
2731
if (typeof range === "function") range = range(domain);
2832
scale.range(range);
2933
}
30-
return {type, domain, range, scale, hint};
34+
return {type, domain, range, scale, hint, interval};
3135
}
3236

3337
export function ScaleOrdinal(key, channels, {
3438
type,
35-
domain = inferDomain(channels),
39+
interval,
40+
domain,
3641
range,
3742
scheme,
3843
unknown,
3944
...options
4045
}) {
46+
interval = maybeInterval(interval);
47+
if (domain === undefined) domain = inferDomain(channels, interval);
4148
let hint;
4249
if (registry.get(key) === symbol) {
4350
hint = inferSymbolHint(channels);
@@ -103,13 +110,17 @@ function maybeRound(scale, channels, options) {
103110
return scale;
104111
}
105112

106-
function inferDomain(channels) {
113+
function inferDomain(channels, interval) {
107114
const values = new InternSet();
108115
for (const {value, domain} of channels) {
109116
if (domain !== undefined) return domain(); // see channelDomain
110117
if (value === undefined) continue;
111118
for (const v of value) values.add(v);
112119
}
120+
if (interval !== undefined) {
121+
const [min, max] = extent(values).map(interval.floor, interval);
122+
return interval.range(min, interval.offset(max));
123+
}
113124
return sort(values, ascendingDefined);
114125
}
115126

src/scales/quantitative.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import {positive, negative, finite} from "../defined.js";
2727
import {arrayify, constant, order, slice} from "../options.js";
2828
import {ordinalRange, quantitativeScheme} from "./schemes.js";
29+
import {maybeInterval} from "../transforms/interval.js";
2930
import {registry, radius, opacity, color, length} from "./index.js";
3031

3132
export const flip = i => t => i(1 - t);
@@ -57,10 +58,12 @@ export function ScaleQ(key, scale, channels, {
5758
unknown,
5859
round,
5960
scheme,
61+
interval,
6062
range = registry.get(key) === radius ? inferRadialRange(channels, domain) : registry.get(key) === length ? inferLengthRange(channels, domain) : registry.get(key) === opacity ? unit : undefined,
6163
interpolate = registry.get(key) === color ? (scheme == null && range !== undefined ? interpolateRgb : quantitativeScheme(scheme !== undefined ? scheme : type === "cyclical" ? "rainbow" : "turbo")) : round ? interpolateRound : interpolateNumber,
6264
reverse
6365
}) {
66+
interval = maybeInterval(interval);
6467
if (type === "cyclical" || type === "sequential") type = "linear"; // shorthand for color schemes
6568
reverse = !!reverse;
6669

@@ -105,7 +108,7 @@ export function ScaleQ(key, scale, channels, {
105108
if (nice) scale.nice(nice === true ? undefined : nice), domain = scale.domain();
106109
if (range !== undefined) scale.range(range);
107110
if (clamp) scale.clamp(clamp);
108-
return {type, domain, range, scale, interpolate};
111+
return {type, domain, range, scale, interpolate, interval};
109112
}
110113

111114
export function ScaleLinear(key, channels, options) {

0 commit comments

Comments
 (0)