Skip to content

Commit

Permalink
ordinal interval (#849)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
mbostock and Fil authored Jun 10, 2022
1 parent c6ad4bb commit 4735253
Show file tree
Hide file tree
Showing 22 changed files with 1,342 additions and 19 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,15 @@ A scale’s domain (the extent of its inputs, abstract values) and range (the ex
* *scale*.**range** - typically [*min*, *max*], or an array of ordinal or categorical values
* *scale*.**unknown** - the desired output value (defaults to undefined) for invalid input values
* *scale*.**reverse** - reverses the domain (or in somes cases, the range), say to flip the chart along *x* or *y*
* *scale*.**interval** - an interval or time interval (for interval data; see below)

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*].

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.

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.
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.

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.

Quantitative scales can be further customized with additional options:

Expand Down
23 changes: 18 additions & 5 deletions src/axes.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {extent} from "d3";
import {AxisX, AxisY} from "./axis.js";
import {formatDefault} from "./format.js";
import {isOrdinalScale, isTemporalScale, scaleOrder} from "./scales.js";
import {position, registry} from "./scales/index.js";

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

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

function autoAxisTicksK(scale, axis, k) {
if (axis.ticks === undefined) {
const [min, max] = extent(scale.scale.range());
axis.ticks = (max - min) / k;
const interval = scale.interval;
if (interval !== undefined) {
const [min, max] = extent(scale.scale.domain());
axis.ticks = interval.range(interval.floor(min), interval.offset(interval.floor(max)));
} else {
const [min, max] = extent(scale.scale.range());
axis.ticks = (max - min) / k;
}
}
// D3’s ordinal scales simply use toString by default, but if the ordinal
// scale domain (or ticks) are numbers or dates (say because we’re applying a
// time interval to the ordinal scale), we want Plot’s default formatter.
if (axis.tickFormat === undefined && isOrdinalScale(scale)) {
axis.tickFormat = formatDefault;
}
}

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

export function inferFontVariant(scale) {
return isOrdinalScale(scale) ? undefined : "tabular-nums";
return isOrdinalScale(scale) && scale.interval === undefined ? undefined : "tabular-nums";
}
7 changes: 6 additions & 1 deletion src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"
import {registry as scaleRegistry} from "./scales/index.js";
import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js";
import {basic, initializer} from "./transforms/basic.js";
import {maybeInterval} from "./transforms/interval.js";
import {consumeWarnings} from "./warnings.js";

export function plot(options = {}) {
Expand Down Expand Up @@ -322,7 +323,11 @@ function applyScaleTransforms(channels, options) {
const channel = channels[name];
const {scale} = channel;
if (scale != null) {
const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {};
const {
percent,
interval,
transform = percent ? x => x * 100 : maybeInterval(interval)?.floor
} = options[scale] || {};
if (transform != null) channel.value = map(channel.value, transform);
}
}
Expand Down
15 changes: 9 additions & 6 deletions src/scales.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,21 @@ function Scale(key, channels = [], options = {}) {
const type = inferScaleType(key, channels, options);

// Warn for common misuses of implicit ordinal scales. We disable this test if
// you set the domain or range explicitly, since setting the domain or range
// (typically with a cardinality of more than two) is another indication that
// you intended for the scale to be ordinal; we also disable it for facet
// scales since these are always band scales.
// you specify a scale interval or if you set the domain or range explicitly,
// since setting the domain or range (typically with a cardinality of more than
// two) is another indication that you intended for the scale to be ordinal; we
// also disable it for facet scales since these are always band scales.
if (options.type === undefined
&& options.domain === undefined
&& options.range === undefined
&& options.interval == null
&& key !== "fx"
&& key !== "fy"
&& isOrdinalScale({type})) {
const values = channels.map(({value}) => value).filter(value => value !== undefined);
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)}".`);
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)}".`);
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)}".`);
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)}".`);
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)}".`);
}

options.type = type; // Mutates input!
Expand Down Expand Up @@ -409,6 +410,7 @@ function exposeScale({
range,
label,
interpolate,
interval,
transform,
percent,
pivot
Expand All @@ -423,6 +425,7 @@ function exposeScale({
...percent && {percent}, // only exposed if truthy
...label !== undefined && {label},
...unknown !== undefined && {unknown},
...interval !== undefined && {interval},

// quantitative
...interpolate !== undefined && {interpolate},
Expand Down
21 changes: 16 additions & 5 deletions src/scales/ordinal.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {InternSet, quantize, reverse as reverseof, sort, symbolsFill, symbolsStroke} from "d3";
import {InternSet, extent, quantize, reverse as reverseof, sort, symbolsFill, symbolsStroke} from "d3";
import {scaleBand, scaleOrdinal, scalePoint, scaleImplicit} from "d3";
import {ascendingDefined} from "../defined.js";
import {isNoneish, map} from "../options.js";
import {maybeInterval} from "../transforms/interval.js";
import {maybeSymbol} from "../symbols.js";
import {registry, color, symbol} from "./index.js";
import {maybeBooleanRange, ordinalScheme, quantitativeScheme} from "./schemes.js";
Expand All @@ -14,11 +15,14 @@ export const ordinalImplicit = Symbol("ordinal");

export function ScaleO(scale, channels, {
type,
domain = inferDomain(channels),
interval,
domain,
range,
reverse,
hint
}) {
interval = maybeInterval(interval);
if (domain === undefined) domain = inferDomain(channels, interval);
if (type === "categorical" || type === ordinalImplicit) type = "ordinal"; // shorthand for color schemes
if (reverse) domain = reverseof(domain);
scale.domain(domain);
Expand All @@ -27,17 +31,20 @@ export function ScaleO(scale, channels, {
if (typeof range === "function") range = range(domain);
scale.range(range);
}
return {type, domain, range, scale, hint};
return {type, domain, range, scale, hint, interval};
}

export function ScaleOrdinal(key, channels, {
type,
domain = inferDomain(channels),
interval,
domain,
range,
scheme,
unknown,
...options
}) {
interval = maybeInterval(interval);
if (domain === undefined) domain = inferDomain(channels, interval);
let hint;
if (registry.get(key) === symbol) {
hint = inferSymbolHint(channels);
Expand Down Expand Up @@ -103,13 +110,17 @@ function maybeRound(scale, channels, options) {
return scale;
}

function inferDomain(channels) {
function inferDomain(channels, interval) {
const values = new InternSet();
for (const {value, domain} of channels) {
if (domain !== undefined) return domain(); // see channelDomain
if (value === undefined) continue;
for (const v of value) values.add(v);
}
if (interval !== undefined) {
const [min, max] = extent(values).map(interval.floor, interval);
return interval.range(min, interval.offset(max));
}
return sort(values, ascendingDefined);
}

Expand Down
5 changes: 4 additions & 1 deletion src/scales/quantitative.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import {positive, negative, finite} from "../defined.js";
import {arrayify, constant, order, slice} from "../options.js";
import {ordinalRange, quantitativeScheme} from "./schemes.js";
import {maybeInterval} from "../transforms/interval.js";
import {registry, radius, opacity, color, length} from "./index.js";

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

Expand Down Expand Up @@ -105,7 +108,7 @@ export function ScaleQ(key, scale, channels, {
if (nice) scale.nice(nice === true ? undefined : nice), domain = scale.domain();
if (range !== undefined) scale.range(range);
if (clamp) scale.clamp(clamp);
return {type, domain, range, scale, interpolate};
return {type, domain, range, scale, interpolate, interval};
}

export function ScaleLinear(key, channels, options) {
Expand Down
Loading

0 comments on commit 4735253

Please sign in to comment.