Skip to content

Commit 18e61e5

Browse files
authored
expose {number,time,utc}Interval (#2075)
* expose {number,time,utc}Interval * more tests * fix api reference * remove unused import
1 parent 17ed038 commit 18e61e5

File tree

10 files changed

+297
-152
lines changed

10 files changed

+297
-152
lines changed

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export default defineConfig({
6868
{text: "Legends", link: "/features/legends"},
6969
{text: "Curves", link: "/features/curves"},
7070
{text: "Formats", link: "/features/formats"},
71+
{text: "Intervals", link: "/features/intervals"},
7172
{text: "Markers", link: "/features/markers"},
7273
{text: "Shorthand", link: "/features/shorthand"},
7374
{text: "Accessibility", link: "/features/accessibility"}

docs/data/api.data.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function getHref(name: string, path: string): string {
4343
switch (path) {
4444
case "features/curve":
4545
case "features/format":
46+
case "features/interval":
4647
case "features/mark":
4748
case "features/marker":
4849
case "features/plot":

docs/features/intervals.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<script setup>
2+
3+
import * as Plot from "@observablehq/plot";
4+
import * as d3 from "d3";
5+
6+
</script>
7+
8+
# Intervals <VersionBadge pr="2075" />
9+
10+
Plot provides several built-in interval implementations for use with the **tick** option for [scales](./scales.md), as the **thresholds** option for a [bin transform](../transforms/bin.md), or other use. See also [d3-time](https://d3js.org/d3-time). You can also implement custom intervals.
11+
12+
At a minimum, intervals implement *interval*.**floor** and *interval*.**offset**. Range intervals additionally implement *interval*.**range**, and nice intervals additionally implement *interval*.**ceil**. These latter implementations are required in some contexts; see Plot’s TypeScript definitions for details.
13+
14+
The *interval*.**floor** method takes a *value* and returns the corresponding value representing the greatest interval boundary less than or equal to the specified *value*. For example, for the “day” time interval, it returns the preceding midnight:
15+
16+
```js
17+
Plot.utcInterval("day").floor(new Date("2013-04-12T12:34:56Z")) // 2013-04-12
18+
```
19+
20+
The *interval*.**offset** method takes a *value* and returns the corresponding value equal to *value* plus *step* intervals. If *step* is not specified it defaults to 1. If *step* is negative, then the returned value will be less than the specified *value*. For example:
21+
22+
```js
23+
Plot.utcInterval("day").offset(new Date("2013-04-12T12:34:56Z"), 1) // 2013-04-13T12:34:56Z
24+
Plot.utcInterval("day").offset(new Date("2013-04-12T12:34:56Z"), -2) // 2013-03-22T12:34:56Z
25+
```
26+
27+
The *interval*.**range** method returns an array of values representing every interval boundary greater than or equal to *start* (inclusive) and less than *stop* (exclusive). The first value in the returned array is the least boundary greater than or equal to *start*; subsequent values are offset by intervals and floored.
28+
29+
```js
30+
Plot.utcInterval("week").range(new Date("2013-04-12T12:34:56Z"), new Date("2013-05-12T12:34:56Z")) // [2013-04-14, 2013-04-21, 2013-04-28, 2013-05-05, 2013-05-12]
31+
```
32+
33+
The *interval*.**ceil** method returns the value representing the least interval boundary value greater than or equal to the specified *value*. For example, for the “day” time interval, it returns the preceding midnight:
34+
35+
```js
36+
Plot.utcInterval("day").ceil(new Date("2013-04-12T12:34:56Z")) // 2013-04-13
37+
```
38+
39+
## numberInterval(*period*) {#numberInterval}
40+
41+
```js
42+
Plot.numberInterval(2)
43+
```
44+
45+
Given a number *period*, returns a corresponding range interval implementation. If *period* is a negative number, the resulting interval uses 1 / -*period*; this allows more precise results when *period* is a negative integer. The returned interval implements the *interval*.range, *interval*.floor, and *interval*.offset methods.
46+
47+
## timeInterval(*period*) {#timeInterval}
48+
49+
```js
50+
Plot.timeInterval("2 days")
51+
```
52+
53+
Given a string *period* describing a local time interval, returns a corresponding nice interval implementation. The period can be *second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, or *sunday*, or a skip interval consisting of a number followed by the interval name (possibly pluralized), such as *3 months* or *10 years*. The returned interval implements the *interval*.range, *interval*.floor, *interval*.ceil, and *interval*.offset methods.
54+
55+
## utcInterval(*period*) {#utcInterval}
56+
57+
```js
58+
Plot.utcInterval("2 days")
59+
```
60+
61+
Given a string *period* describing a UTC time interval, returns a corresponding nice interval implementation. The period can be *second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, or *sunday*, or a skip interval consisting of a number followed by the interval name (possibly pluralized), such as *3 months* or *10 years*. The returned interval implements the *interval*.range, *interval*.floor, *interval*.ceil, and *interval*.offset methods.

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,5 @@ export {pointer, pointerX, pointerY} from "./interactions/pointer.js";
5656
export {formatIsoDate, formatNumber, formatWeekday, formatMonth} from "./format.js";
5757
export {scale} from "./scales.js";
5858
export {legend} from "./legends.js";
59+
export {numberInterval} from "./options.js";
60+
export {timeInterval, utcInterval} from "./time.js";

src/interval.d.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// For internal use.
1+
/** A named interval. */
22
export type LiteralTimeInterval =
33
| "3 months"
44
| "10 years"
@@ -124,3 +124,16 @@ export type RangeInterval<T = any> = LiteralInterval<T> | RangeIntervalImplement
124124
* - a number (for number intervals), defining intervals at integer multiples of *n*
125125
*/
126126
export type NiceInterval<T = any> = LiteralInterval<T> | NiceIntervalImplementation<T>;
127+
128+
/**
129+
* Given a number *period*, returns a corresponding numeric range interval. If
130+
* *period* is a negative number, the returned interval uses 1 / -*period*,
131+
* allowing greater precision when *period* is a negative integer.
132+
*/
133+
export function numberInterval(period: number): RangeIntervalImplementation<number>;
134+
135+
/** Given a string *period*, returns a corresponding local time nice interval. */
136+
export function timeInterval(period: LiteralTimeInterval): NiceIntervalImplementation<Date>;
137+
138+
/** Given a string *period*, returns a corresponding UTC nice interval. */
139+
export function utcInterval(period: LiteralTimeInterval): NiceIntervalImplementation<Date>;

src/options.js

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {quantile, range as rangei} from "d3";
22
import {parse as isoParse} from "isoformat";
33
import {defined} from "./defined.js";
4-
import {maybeTimeInterval, maybeUtcInterval} from "./time.js";
4+
import {timeInterval, utcInterval} from "./time.js";
55

66
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
77
export const TypedArray = Object.getPrototypeOf(Uint8Array);
@@ -322,27 +322,30 @@ export function maybeIntervalTransform(interval, type) {
322322
// range} object similar to a D3 time interval.
323323
export function maybeInterval(interval, type) {
324324
if (interval == null) return;
325-
if (typeof interval === "number") {
326-
if (0 < interval && interval < 1 && Number.isInteger(1 / interval)) interval = -1 / interval;
327-
const n = Math.abs(interval);
328-
return interval < 0
329-
? {
330-
floor: (d) => Math.floor(d * n) / n,
331-
offset: (d) => (d * n + 1) / n, // note: no optional step for simplicity
332-
range: (lo, hi) => rangei(Math.ceil(lo * n), hi * n).map((x) => x / n)
333-
}
334-
: {
335-
floor: (d) => Math.floor(d / n) * n,
336-
offset: (d) => d + n, // note: no optional step for simplicity
337-
range: (lo, hi) => rangei(Math.ceil(lo / n), hi / n).map((x) => x * n)
338-
};
339-
}
340-
if (typeof interval === "string") return (type === "time" ? maybeTimeInterval : maybeUtcInterval)(interval);
325+
if (typeof interval === "number") return numberInterval(interval);
326+
if (typeof interval === "string") return (type === "time" ? timeInterval : utcInterval)(interval);
341327
if (typeof interval.floor !== "function") throw new Error("invalid interval; missing floor method");
342328
if (typeof interval.offset !== "function") throw new Error("invalid interval; missing offset method");
343329
return interval;
344330
}
345331

332+
export function numberInterval(interval) {
333+
interval = +interval;
334+
if (0 < interval && interval < 1 && Number.isInteger(1 / interval)) interval = -1 / interval;
335+
const n = Math.abs(interval);
336+
return interval < 0
337+
? {
338+
floor: (d) => Math.floor(d * n) / n,
339+
offset: (d, s = 1) => (d * n + Math.floor(s)) / n,
340+
range: (lo, hi) => rangei(Math.ceil(lo * n), hi * n).map((x) => x / n)
341+
}
342+
: {
343+
floor: (d) => Math.floor(d / n) * n,
344+
offset: (d, s = 1) => d + n * Math.floor(s),
345+
range: (lo, hi) => rangei(Math.ceil(lo / n), hi / n).map((x) => x * n)
346+
};
347+
}
348+
346349
// Like maybeInterval, but requires a range method too.
347350
export function maybeRangeInterval(interval, type) {
348351
interval = maybeInterval(interval, type);

src/time.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,11 @@ export function parseTimeInterval(input) {
183183
return [name, period];
184184
}
185185

186-
export function maybeTimeInterval(input) {
186+
export function timeInterval(input) {
187187
return asInterval(parseTimeInterval(input), "time");
188188
}
189189

190-
export function maybeUtcInterval(input) {
190+
export function utcInterval(input) {
191191
return asInterval(parseTimeInterval(input), "utc");
192192
}
193193

@@ -209,7 +209,7 @@ export function generalizeTimeInterval(interval, n) {
209209
if (!tickIntervals.some(([, d]) => d === duration)) return; // nonstandard or unknown interval
210210
if (duration % durationDay === 0 && durationDay < duration && duration < durationMonth) return; // not generalizable
211211
const [i] = tickIntervals[bisector(([, step]) => Math.log(step)).center(tickIntervals, Math.log(duration * n))];
212-
return (interval[intervalType] === "time" ? maybeTimeInterval : maybeUtcInterval)(i);
212+
return (interval[intervalType] === "time" ? timeInterval : utcInterval)(i);
213213
}
214214

215215
function formatTimeInterval(name, type, anchor) {

src/transforms/bin.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
mid,
2929
valueof
3030
} from "../options.js";
31-
import {maybeUtcInterval} from "../time.js";
31+
import {utcInterval} from "../time.js";
3232
import {basic} from "./basic.js";
3333
import {
3434
hasOutput,
@@ -322,7 +322,7 @@ export function maybeThresholds(thresholds, interval, defaultThresholds = thresh
322322
case "auto":
323323
return thresholdAuto;
324324
}
325-
return maybeUtcInterval(thresholds);
325+
return utcInterval(thresholds);
326326
}
327327
return thresholds; // pass array, count, or function to bin.thresholds
328328
}

test/interval-test.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import assert from "assert";
2+
import {numberInterval} from "../src/options.js";
3+
4+
describe("numberInterval(interval)", () => {
5+
it("coerces the given interval to a number", () => {
6+
assert.deepStrictEqual(numberInterval("1").range(0, 10), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
7+
});
8+
it("implements range", () => {
9+
assert.deepStrictEqual(numberInterval(1).range(0, 10), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
10+
assert.deepStrictEqual(numberInterval(1).range(1, 9), [1, 2, 3, 4, 5, 6, 7, 8]);
11+
assert.deepStrictEqual(numberInterval(2).range(1, 9), [2, 4, 6, 8]);
12+
assert.deepStrictEqual(numberInterval(-1).range(2, 5), [2, 3, 4]);
13+
assert.deepStrictEqual(numberInterval(-2).range(2, 5), [2, 2.5, 3, 3.5, 4, 4.5]);
14+
assert.deepStrictEqual(numberInterval(2).range(0, 10), [0, 2, 4, 6, 8]);
15+
assert.deepStrictEqual(numberInterval(-2).range(0, 5), [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5]);
16+
});
17+
it("considers descending ranges to be empty", () => {
18+
assert.deepStrictEqual(numberInterval(1).range(10, 0), []);
19+
assert.deepStrictEqual(numberInterval(1).range(-1, -9), []);
20+
});
21+
it("considers invalid ranges to be empty", () => {
22+
assert.deepStrictEqual(numberInterval(1).range(0, Infinity), []);
23+
assert.deepStrictEqual(numberInterval(1).range(NaN, 0), []);
24+
});
25+
it("considers invalid intervals to be empty", () => {
26+
assert.deepStrictEqual(numberInterval(NaN).range(0, 10), []);
27+
assert.deepStrictEqual(numberInterval(-Infinity).range(0, 10), []);
28+
assert.deepStrictEqual(numberInterval(0).range(0, 10), []);
29+
});
30+
it("implements floor", () => {
31+
assert.strictEqual(numberInterval(1).floor(9.9), 9);
32+
assert.strictEqual(numberInterval(2).floor(9), 8);
33+
assert.strictEqual(numberInterval(-2).floor(8.6), 8.5);
34+
});
35+
it("implements offset", () => {
36+
assert.strictEqual(numberInterval(1).offset(8), 9);
37+
assert.strictEqual(numberInterval(2).offset(8), 10);
38+
assert.strictEqual(numberInterval(-2).offset(8), 8.5);
39+
});
40+
it("implements offset with step", () => {
41+
assert.strictEqual(numberInterval(1).offset(8, 2), 10);
42+
assert.strictEqual(numberInterval(2).offset(8, 2), 12);
43+
assert.strictEqual(numberInterval(-2).offset(8, 2), 9);
44+
});
45+
it("does not require an aligned offset", () => {
46+
assert.strictEqual(numberInterval(2).offset(7), 9);
47+
assert.strictEqual(numberInterval(-2).offset(7.1), 7.6);
48+
});
49+
it("floors the offset step", () => {
50+
assert.strictEqual(numberInterval(1).offset(8, 2.5), 10);
51+
assert.strictEqual(numberInterval(2).offset(8, 2.5), 12);
52+
assert.strictEqual(numberInterval(-2).offset(8, 2.5), 9);
53+
});
54+
it("coerces the offset step", () => {
55+
assert.strictEqual(numberInterval(1).offset(8, "2.5"), 10);
56+
assert.strictEqual(numberInterval(2).offset(8, "2.5"), 12);
57+
assert.strictEqual(numberInterval(-2).offset(8, "2.5"), 9);
58+
});
59+
it("allows a negative offset step", () => {
60+
assert.strictEqual(numberInterval(1).offset(8, -2), 6);
61+
assert.strictEqual(numberInterval(2).offset(8, -2), 4);
62+
assert.strictEqual(numberInterval(-2).offset(8, -2), 7);
63+
});
64+
});

0 commit comments

Comments
 (0)