Skip to content

Commit

Permalink
named time intervals (#1205)
Browse files Browse the repository at this point in the history
* 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 <fil@rezo.net>
  • Loading branch information
mbostock and Fil authored Jan 17, 2023
1 parent b773d87 commit c9c2812
Show file tree
Hide file tree
Showing 26 changed files with 151 additions and 104 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ The default range depends on the scale: for [position scales](#position-options)

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

Quantitative scales can be further customized with additional options:

Expand Down Expand Up @@ -893,10 +893,10 @@ Returns a new area with the given *data* and *options*. This constructor is used
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.

```js
Plot.areaX(observations, {y: "date", x: "temperature", interval: d3.utcDay})
Plot.areaX(observations, {y: "date", x: "temperature", interval: "day"})
```

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

<!-- jsdocEnd areaX -->

Expand All @@ -913,10 +913,10 @@ Returns a new area with the given *data* and *options*. This constructor is used
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.

```js
Plot.areaY(observations, {x: "date", y: "temperature", interval: d3.utcDay)
Plot.areaY(observations, {x: "date", y: "temperature", interval: "day")
```
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.
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.
<!-- jsdocEnd areaY -->
Expand Down Expand Up @@ -1501,10 +1501,10 @@ Similar to [Plot.line](#plotlinedata-options) except that if the **x** option is
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.
```js
Plot.lineX(observations, {y: "date", x: "temperature", interval: d3.utcDay})
Plot.lineX(observations, {y: "date", x: "temperature", interval: "day"})
```
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.
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.
<!-- jsdocEnd lineX -->
Expand All @@ -1521,10 +1521,10 @@ Similar to [Plot.line](#plotlinedata-options) except that if the **y** option is
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.
```js
Plot.lineY(observations, {x: "date", y: "temperature", interval: d3.utcDay})
Plot.lineY(observations, {x: "date", y: "temperature", interval: "day"})
```
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.
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.
<!-- jsdocEnd lineY -->
Expand Down Expand Up @@ -2136,7 +2136,7 @@ The **thresholds** option may be specified as a named method or a variety of oth
* an interval or time interval (for temporal binning; see below)
* a function that returns an array, count, or time interval
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.
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.
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.
Expand Down
30 changes: 4 additions & 26 deletions src/axes.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,28 +31,10 @@ export function Axes(
if (!fyScale) fyAxis = null;
else if (fyAxis === true) fyAxis = yAxis === "left" ? "right" : "left";
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,
fontVariant: inferFontVariant(fxScale),
...fx,
axis: fxAxis
})
}),
...(fyAxis && {
fy: new AxisY({
name: "fy",
grid: facetGrid,
label: facetLabel,
fontVariant: inferFontVariant(fyScale),
...fy,
axis: fyAxis
})
})
...(xAxis && {x: new AxisX(xScale, {grid, line, label, ...x, axis: xAxis})}),
...(yAxis && {y: new AxisY(yScale, {grid, line, label, ...y, axis: yAxis})}),
...(fxAxis && {fx: new AxisX(fxScale, {name: "fx", grid: facetGrid, label: facetLabel, ...fx, axis: fxAxis})}),
...(fyAxis && {fy: new AxisY(fyScale, {name: "fy", grid: facetGrid, label: facetLabel, ...fy, axis: fyAxis})})
};
}

Expand Down Expand Up @@ -188,7 +170,3 @@ function inferLabel(channels = [], scale, axis, key) {
}
return candidate;
}

export function inferFontVariant(scale) {
return isOrdinalScale(scale) && scale.interval === undefined ? undefined : "tabular-nums";
}
92 changes: 54 additions & 38 deletions src/axis.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,34 @@ import {create} from "./context.js";
import {formatIsoDate} from "./format.js";
import {radians} from "./math.js";
import {boolean, take, number, string, keyword, maybeKeyword, constant, isTemporal} from "./options.js";
import {isOrdinalScale, isTemporalScale} from "./scales.js";
import {applyAttr, impliedString} from "./style.js";
import {maybeTimeInterval, maybeUtcInterval} from "./time.js";

export class AxisX {
constructor({
name = "x",
axis,
ticks,
tickSize = name === "fx" ? 0 : 6,
tickPadding = tickSize === 0 ? 9 : 3,
tickFormat,
fontVariant,
grid,
label,
labelAnchor,
labelOffset,
line,
tickRotate,
ariaLabel,
ariaDescription
} = {}) {
constructor(
scale,
{
name = "x",
axis,
ticks,
tickSize = name === "fx" ? 0 : 6,
tickPadding = tickSize === 0 ? 9 : 3,
tickFormat,
fontVariant = inferFontVariant(scale),
grid,
label,
labelAnchor,
labelOffset,
line,
tickRotate,
ariaLabel,
ariaDescription
} = {}
) {
this.name = name;
this.axis = keyword(axis, "axis", ["top", "bottom"]);
this.ticks = maybeTicks(ticks);
this.ticks = maybeTicks(ticks, scale);
this.tickSize = number(tickSize);
this.tickPadding = number(tickPadding);
this.tickFormat = maybeTickFormat(tickFormat);
Expand Down Expand Up @@ -99,26 +104,29 @@ export class AxisX {
}

export class AxisY {
constructor({
name = "y",
axis,
ticks,
tickSize = name === "fy" ? 0 : 6,
tickPadding = tickSize === 0 ? 9 : 3,
tickFormat,
fontVariant,
grid,
label,
labelAnchor,
labelOffset,
line,
tickRotate,
ariaLabel,
ariaDescription
} = {}) {
constructor(
scale,
{
name = "y",
axis,
ticks,
tickSize = name === "fy" ? 0 : 6,
tickPadding = tickSize === 0 ? 9 : 3,
tickFormat,
fontVariant = inferFontVariant(scale),
grid,
label,
labelAnchor,
labelOffset,
line,
tickRotate,
ariaLabel,
ariaDescription
} = {}
) {
this.name = name;
this.axis = keyword(axis, "axis", ["left", "right"]);
this.ticks = maybeTicks(ticks);
this.ticks = maybeTicks(ticks, scale);
this.tickSize = number(tickSize);
this.tickPadding = number(tickPadding);
this.tickFormat = maybeTickFormat(tickFormat);
Expand Down Expand Up @@ -224,8 +232,12 @@ function gridFacetY(index, fx, tx) {
.attr("d", (index ? take(domain, index) : domain).map((v) => `M${fx(v) + tx},0h${dx}`).join(""));
}

function maybeTicks(ticks) {
return ticks === null ? [] : ticks;
function maybeTicks(ticks, scale) {
return ticks === null
? []
: isTemporalScale(scale) && typeof ticks === "string"
? (scale.type === "time" ? maybeTimeInterval : maybeUtcInterval)(ticks)
: ticks;
}

function maybeTickFormat(tickFormat) {
Expand Down Expand Up @@ -280,3 +292,7 @@ function maybeTickRotate(g, rotate) {
text.setAttribute("dy", "0.32em");
}
}

export function inferFontVariant(scale) {
return isOrdinalScale(scale) && scale.interval === undefined ? undefined : "tabular-nums";
}
2 changes: 1 addition & 1 deletion src/legends/ramp.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {quantize, interpolateNumber, piecewise, format, scaleBand, scaleLinear, axisBottom} from "d3";
import {inferFontVariant} from "../axes.js";
import {inferFontVariant} from "../axis.js";
import {Context, create} from "../context.js";
import {map} from "../options.js";
import {interpolatePiecewise} from "../scales/quantitative.js";
Expand Down
2 changes: 1 addition & 1 deletion src/legends/swatches.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {pathRound as path} from "d3";
import {inferFontVariant} from "../axes.js";
import {inferFontVariant} from "../axis.js";
import {maybeAutoTickFormat} from "../axis.js";
import {Context, create} from "../context.js";
import {isNoneish, maybeColorChannel, maybeNumberChannel} from "../options.js";
Expand Down
2 changes: 2 additions & 0 deletions src/options.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {parse as isoParse} from "isoformat";
import {color, descending, range as rangei, quantile} from "d3";
import {maybeUtcInterval} from "./time.js";

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
const TypedArray = Object.getPrototypeOf(Uint8Array);
Expand Down Expand Up @@ -248,6 +249,7 @@ export function maybeInterval(interval) {
range: (lo, hi) => rangei(Math.ceil(lo / n), hi / n).map((x) => n * x)
};
}
if (typeof interval === "string") return maybeUtcInterval(interval); // TODO local time, or timeZone option
if (typeof interval.floor !== "function") throw new Error("invalid interval; missing floor method");
if (typeof interval.offset !== "function") throw new Error("invalid interval; missing offset method");
return interval;
Expand Down
50 changes: 50 additions & 0 deletions src/time.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {utcSecond, utcMinute, utcHour, utcDay, utcWeek, utcMonth, utcYear} from "d3";
import {utcMonday, utcTuesday, utcWednesday, utcThursday, utcFriday, utcSaturday, utcSunday} from "d3";
import {timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear} from "d3";
import {timeMonday, timeTuesday, timeWednesday, timeThursday, timeFriday, timeSaturday, timeSunday} from "d3";

const timeIntervals = new Map([
["second", timeSecond],
["minute", timeMinute],
["hour", timeHour],
["day", timeDay],
["week", timeWeek],
["month", timeMonth],
["year", timeYear],
["monday", timeMonday],
["tuesday", timeTuesday],
["wednesday", timeWednesday],
["thursday", timeThursday],
["friday", timeFriday],
["saturday", timeSaturday],
["sunday", timeSunday]
]);

const utcIntervals = new Map([
["second", utcSecond],
["minute", utcMinute],
["hour", utcHour],
["day", utcDay],
["week", utcWeek],
["month", utcMonth],
["year", utcYear],
["monday", utcMonday],
["tuesday", utcTuesday],
["wednesday", utcWednesday],
["thursday", utcThursday],
["friday", utcFriday],
["saturday", utcSaturday],
["sunday", utcSunday]
]);

export function maybeTimeInterval(interval) {
const i = timeIntervals.get(`${interval}`.toLowerCase());
if (!i) throw new Error(`unknown interval: ${interval}`);
return i;
}

export function maybeUtcInterval(interval) {
const i = utcIntervals.get(`${interval}`.toLowerCase());
if (!i) throw new Error(`unknown interval: ${interval}`);
return i;
}
2 changes: 2 additions & 0 deletions src/transforms/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ export function maybeThresholds(thresholds, interval, defaultThresholds = thresh
case "auto":
return thresholdAuto;
}
const interval = maybeInterval(thresholds);
if (interval !== undefined) return interval;
throw new Error(`invalid thresholds: ${thresholds}`);
}
return thresholds; // pass array, count, or function to bin.thresholds
Expand Down
3 changes: 2 additions & 1 deletion src/transforms/interval.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import {isTemporal, labelof, map, maybeInterval, maybeValue, valueof} from "../o
import {maybeInsetX, maybeInsetY} from "./inset.js";

// The interval may be specified either as x: {value, interval} or as {x,
// interval}. The former is used, for example, for Plot.rect.
// interval}. The former can be used to specify separate intervals for x and y,
// for example with Plot.rect.
function maybeIntervalValue(value, {interval}) {
value = {...maybeValue(value)};
value.interval = maybeInterval(value.interval === undefined ? interval : value.interval);
Expand Down
4 changes: 2 additions & 2 deletions test/plots/aapl-volume-rect.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export default async function () {
label: "↑ Daily trade volume (millions)"
},
marks: [
Plot.rectY(AAPL, {x: "Date", interval: d3.utcDay, y: "Volume", fill: "#ccc"}),
Plot.ruleY(AAPL, {x: "Date", interval: d3.utcDay, y: "Volume"}),
Plot.rectY(AAPL, {x: "Date", interval: "day", y: "Volume", fill: "#ccc"}),
Plot.ruleY(AAPL, {x: "Date", interval: "day", y: "Volume"}),
Plot.ruleY([0])
]
});
Expand Down
4 changes: 2 additions & 2 deletions test/plots/availability.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ export default async function () {
Plot.areaY(data, {
x: "date",
y: "value",
interval: d3.utcDay,
interval: "day",
reduce: sum,
curve: "step",
fill: "#f2f2fe"
}),
Plot.lineY(data, {
x: "date",
y: "value",
interval: d3.utcDay,
interval: "day",
reduce: sum,
curve: "step"
}),
Expand Down
3 changes: 1 addition & 2 deletions test/plots/bin-timestamps.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";

export default async function () {
const timestamps = Float64Array.of(
Expand All @@ -11,5 +10,5 @@ export default async function () {
1609891200000,
1609977600000
);
return Plot.rectY(timestamps, Plot.binX({y: "count"}, {interval: d3.utcDay})).plot();
return Plot.rectY(timestamps, Plot.binX({y: "count"}, {interval: "day"})).plot();
}
2 changes: 1 addition & 1 deletion test/plots/crimean-war-overlapped.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default async function () {
label: null
},
marks: [
Plot.rectY(data, {x: "date", interval: d3.utcMonth, y2: "deaths", fill: "cause", mixBlendMode: "multiply"}),
Plot.rectY(data, {x: "date", interval: "month", y2: "deaths", fill: "cause", mixBlendMode: "multiply"}),
Plot.ruleY([0])
]
});
Expand Down
2 changes: 1 addition & 1 deletion test/plots/crimean-war-stacked.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default async function () {
label: null
},
marks: [
Plot.rectY(data, {x: "date", interval: d3.utcMonth, y: "deaths", fill: "cause", reverse: true}),
Plot.rectY(data, {x: "date", interval: "month", y: "deaths", fill: "cause", reverse: true}),
Plot.ruleY([0])
]
});
Expand Down
Loading

0 comments on commit c9c2812

Please sign in to comment.