Skip to content

Commit c9c2812

Browse files
mbostockFil
andauthored
named time intervals (#1205)
* named time intervals * named time ticks * document * accept named intervals for the thresholds option * appropriate TODO comment * minimize diff Co-authored-by: Philippe Rivière <[email protected]>
1 parent b773d87 commit c9c2812

26 files changed

+151
-104
lines changed

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ The default range depends on the scale: for [position scales](#position-options)
198198

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

201-
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.
201+
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. The option can alternatively be specified as a string (second, minute, hour, day, week, month, year, monday, tuesday, wednesday, thursday, friday, saturday, sunday) naming the corresponding UTC interval. 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.
202202

203203
Quantitative scales can be further customized with additional options:
204204

@@ -893,10 +893,10 @@ Returns a new area with the given *data* and *options*. This constructor is used
893893
If the **interval** option is specified, the [binY transform](#bin) is implicitly applied to the specified *options*. The reducer of the output *x* channel may be specified via the **reduce** option, which defaults to *first*. To default to zero instead of showing gaps in data, as when the observed value represents a quantity, use the *sum* reducer.
894894

895895
```js
896-
Plot.areaX(observations, {y: "date", x: "temperature", interval: d3.utcDay})
896+
Plot.areaX(observations, {y: "date", x: "temperature", interval: "day"})
897897
```
898898

899-
The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use d3.utcDay as the interval.
899+
The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval.
900900

901901
<!-- jsdocEnd areaX -->
902902

@@ -913,10 +913,10 @@ Returns a new area with the given *data* and *options*. This constructor is used
913913
If the **interval** option is specified, the [binX transform](#bin) is implicitly applied to the specified *options*. The reducer of the output *y* channel may be specified via the **reduce** option, which defaults to *first*. To default to zero instead of showing gaps in data, as when the observed value represents a quantity, use the *sum* reducer.
914914

915915
```js
916-
Plot.areaY(observations, {x: "date", y: "temperature", interval: d3.utcDay)
916+
Plot.areaY(observations, {x: "date", y: "temperature", interval: "day")
917917
```
918918
919-
The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use d3.utcDay as the interval.
919+
The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval.
920920
921921
<!-- jsdocEnd areaY -->
922922
@@ -1501,10 +1501,10 @@ Similar to [Plot.line](#plotlinedata-options) except that if the **x** option is
15011501
If the **interval** option is specified, the [binY transform](#bin) is implicitly applied to the specified *options*. The reducer of the output *x* channel may be specified via the **reduce** option, which defaults to *first*. To default to zero instead of showing gaps in data, as when the observed value represents a quantity, use the *sum* reducer.
15021502
15031503
```js
1504-
Plot.lineX(observations, {y: "date", x: "temperature", interval: d3.utcDay})
1504+
Plot.lineX(observations, {y: "date", x: "temperature", interval: "day"})
15051505
```
15061506
1507-
The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use d3.utcDay as the interval.
1507+
The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval.
15081508
15091509
<!-- jsdocEnd lineX -->
15101510
@@ -1521,10 +1521,10 @@ Similar to [Plot.line](#plotlinedata-options) except that if the **y** option is
15211521
If the **interval** option is specified, the [binX transform](#bin) is implicitly applied to the specified *options*. The reducer of the output *y* channel may be specified via the **reduce** option, which defaults to *first*. To default to zero instead of showing gaps in data, as when the observed value represents a quantity, use the *sum* reducer.
15221522
15231523
```js
1524-
Plot.lineY(observations, {x: "date", y: "temperature", interval: d3.utcDay})
1524+
Plot.lineY(observations, {x: "date", y: "temperature", interval: "day"})
15251525
```
15261526
1527-
The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use d3.utcDay as the interval.
1527+
The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval.
15281528
15291529
<!-- jsdocEnd lineY -->
15301530
@@ -2136,7 +2136,7 @@ The **thresholds** option may be specified as a named method or a variety of oth
21362136
* an interval or time interval (for temporal binning; see below)
21372137
* a function that returns an array, count, or time interval
21382138
2139-
If the **thresholds** option is specified as a function, it is passed three arguments: the array of input values, the domain minimum, and the domain maximum. If a number, [d3.ticks](https://github.com/d3/d3-array/blob/main/README.md#ticks) or [d3.utcTicks](https://github.com/d3/d3-time/blob/main/README.md#ticks) is used to choose suitable nice thresholds. If an interval, it must expose an *interval*.floor(*value*), *interval*.ceil(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) methods. If the interval is a time interval such as d3.utcDay, or if the thresholds are specified as an array of dates, then the binned values are implicitly coerced to dates. Time intervals are intervals that are also functions that return a Date instance when called with no arguments.
2139+
If the **thresholds** option is specified as a function, it is passed three arguments: the array of input values, the domain minimum, and the domain maximum. If a number, [d3.ticks](https://github.com/d3/d3-array/blob/main/README.md#ticks) or [d3.utcTicks](https://github.com/d3/d3-time/blob/main/README.md#ticks) is used to choose suitable nice thresholds. If an interval, it must expose an *interval*.floor(*value*), *interval*.ceil(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) methods. If the interval is a time interval such as "day" (equivalently, d3.utcDay), or if the thresholds are specified as an array of dates, then the binned values are implicitly coerced to dates. Time intervals are intervals that are also functions that return a Date instance when called with no arguments.
21402140
21412141
If the **interval** option is used instead of **thresholds**, it may be either an interval, a time interval, or a number. If a number *n*, threshold values are consecutive multiples of *n* that span the domain; otherwise, the **interval** option is equivalent to the **thresholds** option. When the thresholds are specified as an interval, and the default **domain** is used, the domain will automatically be extended to start and end to align with the interval.
21422142

src/axes.js

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,28 +31,10 @@ export function Axes(
3131
if (!fyScale) fyAxis = null;
3232
else if (fyAxis === true) fyAxis = yAxis === "left" ? "right" : "left";
3333
return {
34-
...(xAxis && {x: new AxisX({grid, line, label, fontVariant: inferFontVariant(xScale), ...x, axis: xAxis})}),
35-
...(yAxis && {y: new AxisY({grid, line, label, fontVariant: inferFontVariant(yScale), ...y, axis: yAxis})}),
36-
...(fxAxis && {
37-
fx: new AxisX({
38-
name: "fx",
39-
grid: facetGrid,
40-
label: facetLabel,
41-
fontVariant: inferFontVariant(fxScale),
42-
...fx,
43-
axis: fxAxis
44-
})
45-
}),
46-
...(fyAxis && {
47-
fy: new AxisY({
48-
name: "fy",
49-
grid: facetGrid,
50-
label: facetLabel,
51-
fontVariant: inferFontVariant(fyScale),
52-
...fy,
53-
axis: fyAxis
54-
})
55-
})
34+
...(xAxis && {x: new AxisX(xScale, {grid, line, label, ...x, axis: xAxis})}),
35+
...(yAxis && {y: new AxisY(yScale, {grid, line, label, ...y, axis: yAxis})}),
36+
...(fxAxis && {fx: new AxisX(fxScale, {name: "fx", grid: facetGrid, label: facetLabel, ...fx, axis: fxAxis})}),
37+
...(fyAxis && {fy: new AxisY(fyScale, {name: "fy", grid: facetGrid, label: facetLabel, ...fy, axis: fyAxis})})
5638
};
5739
}
5840

@@ -188,7 +170,3 @@ function inferLabel(channels = [], scale, axis, key) {
188170
}
189171
return candidate;
190172
}
191-
192-
export function inferFontVariant(scale) {
193-
return isOrdinalScale(scale) && scale.interval === undefined ? undefined : "tabular-nums";
194-
}

src/axis.js

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,34 @@ import {create} from "./context.js";
33
import {formatIsoDate} from "./format.js";
44
import {radians} from "./math.js";
55
import {boolean, take, number, string, keyword, maybeKeyword, constant, isTemporal} from "./options.js";
6+
import {isOrdinalScale, isTemporalScale} from "./scales.js";
67
import {applyAttr, impliedString} from "./style.js";
8+
import {maybeTimeInterval, maybeUtcInterval} from "./time.js";
79

810
export class AxisX {
9-
constructor({
10-
name = "x",
11-
axis,
12-
ticks,
13-
tickSize = name === "fx" ? 0 : 6,
14-
tickPadding = tickSize === 0 ? 9 : 3,
15-
tickFormat,
16-
fontVariant,
17-
grid,
18-
label,
19-
labelAnchor,
20-
labelOffset,
21-
line,
22-
tickRotate,
23-
ariaLabel,
24-
ariaDescription
25-
} = {}) {
11+
constructor(
12+
scale,
13+
{
14+
name = "x",
15+
axis,
16+
ticks,
17+
tickSize = name === "fx" ? 0 : 6,
18+
tickPadding = tickSize === 0 ? 9 : 3,
19+
tickFormat,
20+
fontVariant = inferFontVariant(scale),
21+
grid,
22+
label,
23+
labelAnchor,
24+
labelOffset,
25+
line,
26+
tickRotate,
27+
ariaLabel,
28+
ariaDescription
29+
} = {}
30+
) {
2631
this.name = name;
2732
this.axis = keyword(axis, "axis", ["top", "bottom"]);
28-
this.ticks = maybeTicks(ticks);
33+
this.ticks = maybeTicks(ticks, scale);
2934
this.tickSize = number(tickSize);
3035
this.tickPadding = number(tickPadding);
3136
this.tickFormat = maybeTickFormat(tickFormat);
@@ -99,26 +104,29 @@ export class AxisX {
99104
}
100105

101106
export class AxisY {
102-
constructor({
103-
name = "y",
104-
axis,
105-
ticks,
106-
tickSize = name === "fy" ? 0 : 6,
107-
tickPadding = tickSize === 0 ? 9 : 3,
108-
tickFormat,
109-
fontVariant,
110-
grid,
111-
label,
112-
labelAnchor,
113-
labelOffset,
114-
line,
115-
tickRotate,
116-
ariaLabel,
117-
ariaDescription
118-
} = {}) {
107+
constructor(
108+
scale,
109+
{
110+
name = "y",
111+
axis,
112+
ticks,
113+
tickSize = name === "fy" ? 0 : 6,
114+
tickPadding = tickSize === 0 ? 9 : 3,
115+
tickFormat,
116+
fontVariant = inferFontVariant(scale),
117+
grid,
118+
label,
119+
labelAnchor,
120+
labelOffset,
121+
line,
122+
tickRotate,
123+
ariaLabel,
124+
ariaDescription
125+
} = {}
126+
) {
119127
this.name = name;
120128
this.axis = keyword(axis, "axis", ["left", "right"]);
121-
this.ticks = maybeTicks(ticks);
129+
this.ticks = maybeTicks(ticks, scale);
122130
this.tickSize = number(tickSize);
123131
this.tickPadding = number(tickPadding);
124132
this.tickFormat = maybeTickFormat(tickFormat);
@@ -224,8 +232,12 @@ function gridFacetY(index, fx, tx) {
224232
.attr("d", (index ? take(domain, index) : domain).map((v) => `M${fx(v) + tx},0h${dx}`).join(""));
225233
}
226234

227-
function maybeTicks(ticks) {
228-
return ticks === null ? [] : ticks;
235+
function maybeTicks(ticks, scale) {
236+
return ticks === null
237+
? []
238+
: isTemporalScale(scale) && typeof ticks === "string"
239+
? (scale.type === "time" ? maybeTimeInterval : maybeUtcInterval)(ticks)
240+
: ticks;
229241
}
230242

231243
function maybeTickFormat(tickFormat) {
@@ -280,3 +292,7 @@ function maybeTickRotate(g, rotate) {
280292
text.setAttribute("dy", "0.32em");
281293
}
282294
}
295+
296+
export function inferFontVariant(scale) {
297+
return isOrdinalScale(scale) && scale.interval === undefined ? undefined : "tabular-nums";
298+
}

src/legends/ramp.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {quantize, interpolateNumber, piecewise, format, scaleBand, scaleLinear, axisBottom} from "d3";
2-
import {inferFontVariant} from "../axes.js";
2+
import {inferFontVariant} from "../axis.js";
33
import {Context, create} from "../context.js";
44
import {map} from "../options.js";
55
import {interpolatePiecewise} from "../scales/quantitative.js";

src/legends/swatches.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {pathRound as path} from "d3";
2-
import {inferFontVariant} from "../axes.js";
2+
import {inferFontVariant} from "../axis.js";
33
import {maybeAutoTickFormat} from "../axis.js";
44
import {Context, create} from "../context.js";
55
import {isNoneish, maybeColorChannel, maybeNumberChannel} from "../options.js";

src/options.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {parse as isoParse} from "isoformat";
22
import {color, descending, range as rangei, quantile} from "d3";
3+
import {maybeUtcInterval} from "./time.js";
34

45
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
56
const TypedArray = Object.getPrototypeOf(Uint8Array);
@@ -248,6 +249,7 @@ export function maybeInterval(interval) {
248249
range: (lo, hi) => rangei(Math.ceil(lo / n), hi / n).map((x) => n * x)
249250
};
250251
}
252+
if (typeof interval === "string") return maybeUtcInterval(interval); // TODO local time, or timeZone option
251253
if (typeof interval.floor !== "function") throw new Error("invalid interval; missing floor method");
252254
if (typeof interval.offset !== "function") throw new Error("invalid interval; missing offset method");
253255
return interval;

src/time.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {utcSecond, utcMinute, utcHour, utcDay, utcWeek, utcMonth, utcYear} from "d3";
2+
import {utcMonday, utcTuesday, utcWednesday, utcThursday, utcFriday, utcSaturday, utcSunday} from "d3";
3+
import {timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear} from "d3";
4+
import {timeMonday, timeTuesday, timeWednesday, timeThursday, timeFriday, timeSaturday, timeSunday} from "d3";
5+
6+
const timeIntervals = new Map([
7+
["second", timeSecond],
8+
["minute", timeMinute],
9+
["hour", timeHour],
10+
["day", timeDay],
11+
["week", timeWeek],
12+
["month", timeMonth],
13+
["year", timeYear],
14+
["monday", timeMonday],
15+
["tuesday", timeTuesday],
16+
["wednesday", timeWednesday],
17+
["thursday", timeThursday],
18+
["friday", timeFriday],
19+
["saturday", timeSaturday],
20+
["sunday", timeSunday]
21+
]);
22+
23+
const utcIntervals = new Map([
24+
["second", utcSecond],
25+
["minute", utcMinute],
26+
["hour", utcHour],
27+
["day", utcDay],
28+
["week", utcWeek],
29+
["month", utcMonth],
30+
["year", utcYear],
31+
["monday", utcMonday],
32+
["tuesday", utcTuesday],
33+
["wednesday", utcWednesday],
34+
["thursday", utcThursday],
35+
["friday", utcFriday],
36+
["saturday", utcSaturday],
37+
["sunday", utcSunday]
38+
]);
39+
40+
export function maybeTimeInterval(interval) {
41+
const i = timeIntervals.get(`${interval}`.toLowerCase());
42+
if (!i) throw new Error(`unknown interval: ${interval}`);
43+
return i;
44+
}
45+
46+
export function maybeUtcInterval(interval) {
47+
const i = utcIntervals.get(`${interval}`.toLowerCase());
48+
if (!i) throw new Error(`unknown interval: ${interval}`);
49+
return i;
50+
}

src/transforms/bin.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,8 @@ export function maybeThresholds(thresholds, interval, defaultThresholds = thresh
313313
case "auto":
314314
return thresholdAuto;
315315
}
316+
const interval = maybeInterval(thresholds);
317+
if (interval !== undefined) return interval;
316318
throw new Error(`invalid thresholds: ${thresholds}`);
317319
}
318320
return thresholds; // pass array, count, or function to bin.thresholds

src/transforms/interval.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import {isTemporal, labelof, map, maybeInterval, maybeValue, valueof} from "../o
22
import {maybeInsetX, maybeInsetY} from "./inset.js";
33

44
// The interval may be specified either as x: {value, interval} or as {x,
5-
// interval}. The former is used, for example, for Plot.rect.
5+
// interval}. The former can be used to specify separate intervals for x and y,
6+
// for example with Plot.rect.
67
function maybeIntervalValue(value, {interval}) {
78
value = {...maybeValue(value)};
89
value.interval = maybeInterval(value.interval === undefined ? interval : value.interval);

test/plots/aapl-volume-rect.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ export default async function () {
1010
label: "↑ Daily trade volume (millions)"
1111
},
1212
marks: [
13-
Plot.rectY(AAPL, {x: "Date", interval: d3.utcDay, y: "Volume", fill: "#ccc"}),
14-
Plot.ruleY(AAPL, {x: "Date", interval: d3.utcDay, y: "Volume"}),
13+
Plot.rectY(AAPL, {x: "Date", interval: "day", y: "Volume", fill: "#ccc"}),
14+
Plot.ruleY(AAPL, {x: "Date", interval: "day", y: "Volume"}),
1515
Plot.ruleY([0])
1616
]
1717
});

0 commit comments

Comments
 (0)