Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

expose {number,time,utc}Interval #2075

Merged
merged 6 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
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. The returned interval implements the *interval*.range, *interval*.floor, and *interval*.offset methods.

remove repeat — or did you mean "when period is a fractional value"?

Copy link
Member Author

@mbostock mbostock Jun 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that’s what I meant, and no, I’m not repeating myself. There are two statements here:

  1. When period is a negative number, the resulting interval uses 1 / -period, and
  2. When period is a negative integer, the results are more precise as a result of (1).

If period is a negative number that is not a negative integer, it still works, but there’s not really any reason to do it. So these are saying two separate things.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I’m not documenting that a period of 0.5 is automatically promoted to -2, but I feel like that’s an implementation detail that doesn’t need to be documented.)


## 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, 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* allowing greater precision when *period* is a negative integer.
* allowing greater precision.

same suggestion/question

*/
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