Skip to content

Commit

Permalink
expose {number,time,utc}Interval (#2075)
Browse files Browse the repository at this point in the history
* expose {number,time,utc}Interval

* more tests

* fix api reference

* remove unused import
  • Loading branch information
mbostock committed Jun 11, 2024
1 parent 17ed038 commit 18e61e5
Show file tree
Hide file tree
Showing 10 changed files with 297 additions and 152 deletions.
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default defineConfig({
{text: "Legends", link: "/features/legends"},
{text: "Curves", link: "/features/curves"},
{text: "Formats", link: "/features/formats"},
{text: "Intervals", link: "/features/intervals"},
{text: "Markers", link: "/features/markers"},
{text: "Shorthand", link: "/features/shorthand"},
{text: "Accessibility", link: "/features/accessibility"}
Expand Down
1 change: 1 addition & 0 deletions docs/data/api.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function getHref(name: string, path: string): string {
switch (path) {
case "features/curve":
case "features/format":
case "features/interval":
case "features/mark":
case "features/marker":
case "features/plot":
Expand Down
61 changes: 61 additions & 0 deletions docs/features/intervals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup>

import * as Plot from "@observablehq/plot";
import * as d3 from "d3";

</script>

# Intervals <VersionBadge pr="2075" />

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.

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.

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:

```js
Plot.utcInterval("day").floor(new Date("2013-04-12T12:34:56Z")) // 2013-04-12
```

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:

```js
Plot.utcInterval("day").offset(new Date("2013-04-12T12:34:56Z"), 1) // 2013-04-13T12:34:56Z
Plot.utcInterval("day").offset(new Date("2013-04-12T12:34:56Z"), -2) // 2013-03-22T12:34:56Z
```

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.

```js
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]
```

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:

```js
Plot.utcInterval("day").ceil(new Date("2013-04-12T12:34:56Z")) // 2013-04-13
```

## numberInterval(*period*) {#numberInterval}

```js
Plot.numberInterval(2)
```

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.

## timeInterval(*period*) {#timeInterval}

```js
Plot.timeInterval("2 days")
```

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.

## utcInterval(*period*) {#utcInterval}

```js
Plot.utcInterval("2 days")
```

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.
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ export {pointer, pointerX, pointerY} from "./interactions/pointer.js";
export {formatIsoDate, formatNumber, formatWeekday, formatMonth} from "./format.js";
export {scale} from "./scales.js";
export {legend} from "./legends.js";
export {numberInterval} from "./options.js";
export {timeInterval, utcInterval} from "./time.js";
15 changes: 14 additions & 1 deletion src/interval.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// For internal use.
/** A named interval. */
export type LiteralTimeInterval =
| "3 months"
| "10 years"
Expand Down Expand Up @@ -124,3 +124,16 @@ export type RangeInterval<T = any> = LiteralInterval<T> | RangeIntervalImplement
* - a number (for number intervals), defining intervals at integer multiples of *n*
*/
export type NiceInterval<T = any> = LiteralInterval<T> | NiceIntervalImplementation<T>;

/**
* Given a number *period*, returns a corresponding numeric range interval. If
* *period* is a negative number, the returned interval uses 1 / -*period*,
* allowing greater precision when *period* is a negative integer.
*/
export function numberInterval(period: number): RangeIntervalImplementation<number>;

/** Given a string *period*, returns a corresponding local time nice interval. */
export function timeInterval(period: LiteralTimeInterval): NiceIntervalImplementation<Date>;

/** Given a string *period*, returns a corresponding UTC nice interval. */
export function utcInterval(period: LiteralTimeInterval): NiceIntervalImplementation<Date>;
37 changes: 20 additions & 17 deletions src/options.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {quantile, range as rangei} from "d3";
import {parse as isoParse} from "isoformat";
import {defined} from "./defined.js";
import {maybeTimeInterval, maybeUtcInterval} from "./time.js";
import {timeInterval, utcInterval} from "./time.js";

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
export const TypedArray = Object.getPrototypeOf(Uint8Array);
Expand Down Expand Up @@ -322,27 +322,30 @@ export function maybeIntervalTransform(interval, type) {
// range} object similar to a D3 time interval.
export function maybeInterval(interval, type) {
if (interval == null) return;
if (typeof interval === "number") {
if (0 < interval && interval < 1 && Number.isInteger(1 / interval)) interval = -1 / interval;
const n = Math.abs(interval);
return interval < 0
? {
floor: (d) => Math.floor(d * n) / n,
offset: (d) => (d * n + 1) / n, // note: no optional step for simplicity
range: (lo, hi) => rangei(Math.ceil(lo * n), hi * n).map((x) => x / n)
}
: {
floor: (d) => Math.floor(d / n) * n,
offset: (d) => d + n, // note: no optional step for simplicity
range: (lo, hi) => rangei(Math.ceil(lo / n), hi / n).map((x) => x * n)
};
}
if (typeof interval === "string") return (type === "time" ? maybeTimeInterval : maybeUtcInterval)(interval);
if (typeof interval === "number") return numberInterval(interval);
if (typeof interval === "string") return (type === "time" ? timeInterval : utcInterval)(interval);
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;
}

export function numberInterval(interval) {
interval = +interval;
if (0 < interval && interval < 1 && Number.isInteger(1 / interval)) interval = -1 / interval;
const n = Math.abs(interval);
return interval < 0
? {
floor: (d) => Math.floor(d * n) / n,
offset: (d, s = 1) => (d * n + Math.floor(s)) / n,
range: (lo, hi) => rangei(Math.ceil(lo * n), hi * n).map((x) => x / n)
}
: {
floor: (d) => Math.floor(d / n) * n,
offset: (d, s = 1) => d + n * Math.floor(s),
range: (lo, hi) => rangei(Math.ceil(lo / n), hi / n).map((x) => x * n)
};
}

// Like maybeInterval, but requires a range method too.
export function maybeRangeInterval(interval, type) {
interval = maybeInterval(interval, type);
Expand Down
6 changes: 3 additions & 3 deletions src/time.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,11 @@ export function parseTimeInterval(input) {
return [name, period];
}

export function maybeTimeInterval(input) {
export function timeInterval(input) {
return asInterval(parseTimeInterval(input), "time");
}

export function maybeUtcInterval(input) {
export function utcInterval(input) {
return asInterval(parseTimeInterval(input), "utc");
}

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

function formatTimeInterval(name, type, anchor) {
Expand Down
4 changes: 2 additions & 2 deletions src/transforms/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
mid,
valueof
} from "../options.js";
import {maybeUtcInterval} from "../time.js";
import {utcInterval} from "../time.js";
import {basic} from "./basic.js";
import {
hasOutput,
Expand Down Expand Up @@ -322,7 +322,7 @@ export function maybeThresholds(thresholds, interval, defaultThresholds = thresh
case "auto":
return thresholdAuto;
}
return maybeUtcInterval(thresholds);
return utcInterval(thresholds);
}
return thresholds; // pass array, count, or function to bin.thresholds
}
Expand Down
64 changes: 64 additions & 0 deletions test/interval-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "assert";
import {numberInterval} from "../src/options.js";

describe("numberInterval(interval)", () => {
it("coerces the given interval to a number", () => {
assert.deepStrictEqual(numberInterval("1").range(0, 10), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it("implements range", () => {
assert.deepStrictEqual(numberInterval(1).range(0, 10), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
assert.deepStrictEqual(numberInterval(1).range(1, 9), [1, 2, 3, 4, 5, 6, 7, 8]);
assert.deepStrictEqual(numberInterval(2).range(1, 9), [2, 4, 6, 8]);
assert.deepStrictEqual(numberInterval(-1).range(2, 5), [2, 3, 4]);
assert.deepStrictEqual(numberInterval(-2).range(2, 5), [2, 2.5, 3, 3.5, 4, 4.5]);
assert.deepStrictEqual(numberInterval(2).range(0, 10), [0, 2, 4, 6, 8]);
assert.deepStrictEqual(numberInterval(-2).range(0, 5), [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5]);
});
it("considers descending ranges to be empty", () => {
assert.deepStrictEqual(numberInterval(1).range(10, 0), []);
assert.deepStrictEqual(numberInterval(1).range(-1, -9), []);
});
it("considers invalid ranges to be empty", () => {
assert.deepStrictEqual(numberInterval(1).range(0, Infinity), []);
assert.deepStrictEqual(numberInterval(1).range(NaN, 0), []);
});
it("considers invalid intervals to be empty", () => {
assert.deepStrictEqual(numberInterval(NaN).range(0, 10), []);
assert.deepStrictEqual(numberInterval(-Infinity).range(0, 10), []);
assert.deepStrictEqual(numberInterval(0).range(0, 10), []);
});
it("implements floor", () => {
assert.strictEqual(numberInterval(1).floor(9.9), 9);
assert.strictEqual(numberInterval(2).floor(9), 8);
assert.strictEqual(numberInterval(-2).floor(8.6), 8.5);
});
it("implements offset", () => {
assert.strictEqual(numberInterval(1).offset(8), 9);
assert.strictEqual(numberInterval(2).offset(8), 10);
assert.strictEqual(numberInterval(-2).offset(8), 8.5);
});
it("implements offset with step", () => {
assert.strictEqual(numberInterval(1).offset(8, 2), 10);
assert.strictEqual(numberInterval(2).offset(8, 2), 12);
assert.strictEqual(numberInterval(-2).offset(8, 2), 9);
});
it("does not require an aligned offset", () => {
assert.strictEqual(numberInterval(2).offset(7), 9);
assert.strictEqual(numberInterval(-2).offset(7.1), 7.6);
});
it("floors the offset step", () => {
assert.strictEqual(numberInterval(1).offset(8, 2.5), 10);
assert.strictEqual(numberInterval(2).offset(8, 2.5), 12);
assert.strictEqual(numberInterval(-2).offset(8, 2.5), 9);
});
it("coerces the offset step", () => {
assert.strictEqual(numberInterval(1).offset(8, "2.5"), 10);
assert.strictEqual(numberInterval(2).offset(8, "2.5"), 12);
assert.strictEqual(numberInterval(-2).offset(8, "2.5"), 9);
});
it("allows a negative offset step", () => {
assert.strictEqual(numberInterval(1).offset(8, -2), 6);
assert.strictEqual(numberInterval(2).offset(8, -2), 4);
assert.strictEqual(numberInterval(-2).offset(8, -2), 7);
});
});
Loading

0 comments on commit 18e61e5

Please sign in to comment.