Skip to content

Commit 48166af

Browse files
Filmbostock
andauthored
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]>
1 parent 2738cc2 commit 48166af

20 files changed

+1031
-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 to create an array of ordinal values
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
@@ -2,6 +2,7 @@ import {extent} from "d3";
22
import {AxisX, AxisY} from "./axis.js";
33
import {isOrdinalScale, isTemporalScale, scaleOrder} from "./scales.js";
44
import {position, registry} from "./scales/index.js";
5+
import {formatDefault} from "./format.js";
56

67
export function Axes(
78
{x: xScale, y: yScale, fx: fxScale, fy: fyScale},
@@ -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/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 === undefined
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: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ export const ordinalImplicit = Symbol("ordinal");
1616
export function ScaleO(scale, channels, {
1717
type,
1818
interval,
19-
domain = inferDomain(channels, interval),
19+
domain,
2020
range,
2121
reverse,
2222
hint
2323
}) {
24+
interval = maybeInterval(interval);
25+
if (domain === undefined) domain = inferDomain(channels, interval);
2426
if (type === "categorical" || type === ordinalImplicit) type = "ordinal"; // shorthand for color schemes
2527
if (reverse) domain = reverseof(domain);
2628
scale.domain(domain);
@@ -29,18 +31,20 @@ export function ScaleO(scale, channels, {
2931
if (typeof range === "function") range = range(domain);
3032
scale.range(range);
3133
}
32-
return {type, domain, range, scale, hint};
34+
return {type, domain, range, scale, hint, interval};
3335
}
3436

3537
export function ScaleOrdinal(key, channels, {
3638
type,
3739
interval,
38-
domain = inferDomain(channels, interval),
40+
domain,
3941
range,
4042
scheme,
4143
unknown,
4244
...options
4345
}) {
46+
interval = maybeInterval(interval);
47+
if (domain === undefined) domain = inferDomain(channels, interval);
4448
let hint;
4549
if (registry.get(key) === symbol) {
4650
hint = inferSymbolHint(channels);
@@ -113,7 +117,7 @@ function inferDomain(channels, interval) {
113117
if (value === undefined) continue;
114118
for (const v of value) values.add(v);
115119
}
116-
if ((interval = maybeInterval(interval)) != null) {
120+
if (interval !== undefined) {
117121
const [min, max] = extent(values).map(interval.floor, interval);
118122
return interval.range(min, interval.offset(max));
119123
}

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)