Skip to content

expose {number,time,utc}Interval #2075

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

Merged
merged 6 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
34 changes: 34 additions & 0 deletions docs/features/intervals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup>

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

</script>

# Intervals <VersionBadge pr="2075" />

These helper functions are provided for convenience as a **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).

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

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

Given a number *period*, returns a corresponding range interval implementation. 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, 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";
11 changes: 10 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,12 @@ 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 range interval. */
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