From 189b42a413b4b5bf12b6154ca851b4c8f181fd82 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 27 Apr 2023 11:14:27 -0700 Subject: [PATCH 1/5] interval transform --- docs/transforms/group.md | 31 ++++----- src/index.d.ts | 1 + src/index.js | 1 + src/scales.js | 10 ++- src/transforms/group.js | 2 + src/transforms/interval.d.ts | 46 +++++++++++++ src/transforms/interval.js | 23 +++++++ test/output/groupIntervalYear.svg | 85 +++++++++++++++++++++++++ test/output/groupIntervalYearSparse.svg | 83 ++++++++++++++++++++++++ test/plots/group-interval.ts | 22 +++++++ test/plots/index.ts | 1 + 11 files changed, 287 insertions(+), 18 deletions(-) create mode 100644 src/transforms/interval.d.ts create mode 100644 test/output/groupIntervalYear.svg create mode 100644 test/output/groupIntervalYearSparse.svg create mode 100644 test/plots/group-interval.ts diff --git a/docs/transforms/group.md b/docs/transforms/group.md index abe1611caa..d7fdc2636e 100644 --- a/docs/transforms/group.md +++ b/docs/transforms/group.md @@ -16,7 +16,7 @@ onMounted(() => { # Group transform :::tip -The group transform is for aggregating ordinal or nominal data. For quantitative or temporal data, use the [bin transform](./bin.md). +The group transform is for aggregating ordinal or nominal data. For quantitative or temporal data, use the [bin transform](./bin.md), or use the [interval transform](./interval.md) to make continuous data ordinal. ::: The **group transform** groups ordinal or nominal data—discrete values such as name, type, or category. You can then compute summary statistics for each group, such as a count, sum, or proportion. The group transform is most often used to make bar charts with the [bar mark](../marks/bar.md). @@ -43,22 +43,6 @@ Ordinal domains are sorted naturally (alphabetically) by default. Either set the The groupX transform groups on **x**. The *outputs* argument (here `{y: "count"}`) declares desired output channels (**y**) and the associated reducer (*count*). Hence the height of each bar above represents the number of penguins of each species. - - - - While the groupX transform is often used to generate **y**, it can output to any channel. For example, by declaring **r** in *outputs*, we can generate dots of size proportional to the number of athletes in each sport. :::plot https://observablehq.com/@observablehq/plot-groups-as-dots @@ -326,6 +310,19 @@ Plot.plot({ Although barX applies an implicit stackX transform, [textX](../marks/text.md) does not; this example uses an explicit stackX transform in both cases for clarity, and to pass the additional **order** and **reverse** options to place the largest sport on the left. The [filter transform](./filter.md) is applied after the stack transform to hide the labels on the smallest sports where the bars are too thin. ::: +While you should generally use the [bin transform](./bin.md) for quantitative or temporal data, you can use the [group transform](./group.md) if you also use the [interval transform](./interval.md) to make the data ordinal. + +:::plot defer +```js +Plot.plot({ + x: {tickFormat: "%Y"}, + marks: [ + Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.intervalX(d3.utcYear.every(5), {x: "date_of_birth"}))) + ] +}) +``` +::: + ## Group options Given input *data* = [*d₀*, *d₁*, *d₂*, …], by default the resulting grouped data is an array of arrays where each inner array is a subset of the input data such as [[*d₁*, *d₂*, …], [*d₀*, …], …]. Each inner array is in input order. The outer array is in natural ascending order according to the associated dimension (*x* then *y*). diff --git a/src/index.d.ts b/src/index.d.ts index 87cb46de1b..14f0def17f 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -45,6 +45,7 @@ export * from "./transforms/centroid.js"; export * from "./transforms/dodge.js"; export * from "./transforms/group.js"; export * from "./transforms/hexbin.js"; +export * from "./transforms/interval.js"; export * from "./transforms/map.js"; export * from "./transforms/normalize.js"; export * from "./transforms/select.js"; diff --git a/src/index.js b/src/index.js index d0fbf51546..7dff1ab6e9 100644 --- a/src/index.js +++ b/src/index.js @@ -33,6 +33,7 @@ export {centroid, geoCentroid} from "./transforms/centroid.js"; export {dodgeX, dodgeY} from "./transforms/dodge.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; export {hexbin} from "./transforms/hexbin.js"; +export {intervalX, intervalY, intervalMap} from "./transforms/interval.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; export {map, mapX, mapY} from "./transforms/map.js"; export {window, windowX, windowY} from "./transforms/window.js"; diff --git a/src/scales.js b/src/scales.js index 1e0138380c..30dbc4d3dd 100644 --- a/src/scales.js +++ b/src/scales.js @@ -142,6 +142,14 @@ function inferScaleLabel(channels = [], scale) { return {inferred: true, toString: () => label}; } +export function inferScaleInterval(channels = []) { + for (const {value} of channels) { + if (value == null) continue; + const {interval} = value; + if (interval !== undefined) return interval; + } +} + // Returns the dimensions of the outer frame; this is subdivided into facets // with the margins of each facet collapsing into the outer margins. export function outerDimensions(dimensions) { @@ -252,7 +260,7 @@ function createScale(key, channels = [], options = {}) { options.type === undefined && options.domain === undefined && options.range === undefined && - options.interval == null && + (options.interval === undefined ? (options.interval = inferScaleInterval(channels)) : options.interval) == null && // Mutates input! key !== "fx" && key !== "fy" && isOrdinalScale({type}) diff --git a/src/transforms/group.js b/src/transforms/group.js index c7fdac605f..2ab3b99331 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -121,6 +121,8 @@ function groupn( const GZ = Z && setGZ([]); const GF = F && setGF([]); const GS = S && setGS([]); + if (X && X.interval) GX.interval = X.interval; // propagate interval hint + if (Y && Y.interval) GY.interval = Y.interval; // propagate interval hint let i = 0; for (const o of outputs) o.initialize(data); if (sort) sort.initialize(data); diff --git a/src/transforms/interval.d.ts b/src/transforms/interval.d.ts new file mode 100644 index 0000000000..55aa370408 --- /dev/null +++ b/src/transforms/interval.d.ts @@ -0,0 +1,46 @@ +import type {Interval} from "../interval.js"; +import type {Transformed} from "./basic.js"; +import type {Map} from "./map.js"; + +/** Options for the interval transform. */ +export interface IntervalOptions { + /** + * How to quantize the continuous data; one of: + * + * - an object that implements *floor* and *offset* methods + * - a named time interval such as *day* (for date intervals) + * - a number (for number intervals), defining intervals at integer multiples of *n* + * + * For example, for integer bins: + * + * ```js + * Plot.barY(numbers, Plot.groupX({y: "count"}, Plot.intervalX(1))) + * ``` + */ + interval?: Interval; +} + +/** + * Derives new **x**, **x1**, and **x2** channels for each corresponding input + * channel by quantizing to the given *interval*. + */ +export function intervalX(interval?: Interval, options?: T): Transformed; +export function intervalX(options?: T & IntervalOptions): Transformed; + +/** + * Derives new **y**, **y1**, and **y2** channels for each corresponding input + * channel by quantizing to the given *interval*. + */ +export function intervalY(interval?: Interval, options?: T): Transformed; +export function intervalY(options?: T & IntervalOptions): Transformed; + +/** + * Given an *interval*, returns a corresponding map implementation for use with + * the map transform, allowing the normalization of arbitrary channels instead + * of only **x** and **y**. For example, to interval the **stroke** channel: + * + * ```js + * Plot.map({stroke: Plot.intervalMap(10)}, {x: "Date", stroke: "Close", stroke: "Symbol"}) + * ``` + */ +export function intervalMap(interval: Interval): Map; diff --git a/src/transforms/interval.js b/src/transforms/interval.js index a417e83618..94889f018b 100644 --- a/src/transforms/interval.js +++ b/src/transforms/interval.js @@ -1,5 +1,28 @@ import {isTemporal, labelof, map, maybeInterval, maybeValue, valueof} from "../options.js"; import {maybeInsetX, maybeInsetY} from "./inset.js"; +import {mapX, mapY} from "./map.js"; + +export function intervalX(interval, options) { + if (arguments.length === 1) ({interval, ...options} = interval); + return mapX(intervalMap(interval), options); +} + +export function intervalY(interval, options) { + if (arguments.length === 1) ({interval, ...options} = interval); + return mapY(intervalMap(interval), options); +} + +export function intervalMap(interval, type) { + interval = maybeInterval(interval, type); + return { + mapIndex(I, S, T) { + T.interval = interval; // hint for scales + for (let i = 0, n = I.length; i < n; ++i) { + T[I[i]] = interval.floor(S[I[i]]); + } + } + }; +} // The interval may be specified either as x: {value, interval} or as {x, // interval}. The former can be used to specify separate intervals for x and y, diff --git a/test/output/groupIntervalYear.svg b/test/output/groupIntervalYear.svg new file mode 100644 index 0000000000..60b422dd8c --- /dev/null +++ b/test/output/groupIntervalYear.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + 0 + 500 + 1,000 + 1,500 + 2,000 + 2,500 + 3,000 + 3,500 + 4,000 + 4,500 + + + ↑ Frequency + + + + + + + + + + + + + + + + 1950 + 1955 + 1960 + 1965 + 1970 + 1975 + 1980 + 1985 + 1990 + 1995 + 2000 + + + date_of_birth + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/groupIntervalYearSparse.svg b/test/output/groupIntervalYearSparse.svg new file mode 100644 index 0000000000..b482493f45 --- /dev/null +++ b/test/output/groupIntervalYearSparse.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + 0 + 500 + 1,000 + 1,500 + 2,000 + 2,500 + 3,000 + 3,500 + 4,000 + 4,500 + + + ↑ Frequency + + + + + + + + + + + + + + + + 1950 + 1955 + 1960 + 1965 + 1970 + 1975 + 1980 + 1985 + 1990 + 1995 + 2000 + + + date_of_birth + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/group-interval.ts b/test/plots/group-interval.ts new file mode 100644 index 0000000000..32dda9e0ba --- /dev/null +++ b/test/plots/group-interval.ts @@ -0,0 +1,22 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function groupIntervalYear() { + const olympians = await d3.csv("data/athletes.csv", d3.autoType); + return Plot.plot({ + x: {tickFormat: "%Y"}, + marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.intervalX(d3.utcYear.every(5), {x: "date_of_birth"})))] + }); +} + +// Simulates missing data; the gap for 1960 and 1965 should be visible. +export async function groupIntervalYearSparse() { + const olympians = (await d3.csv("data/athletes.csv", d3.autoType)).filter((d) => { + const year = d.date_of_birth.getUTCFullYear(); + return year < 1960 || year >= 1970; + }); + return Plot.plot({ + x: {tickFormat: "%Y"}, + marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.intervalX(d3.utcYear.every(5), {x: "date_of_birth"})))] + }); +} diff --git a/test/plots/index.ts b/test/plots/index.ts index 2d4d1bcd61..2baf8ea947 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -98,6 +98,7 @@ export * from "./google-trends-ridgeline.js"; export * from "./graticule.js"; export * from "./greek-gods.js"; export * from "./grid-choropleth.js"; +export * from "./group-interval.js"; export * from "./grouped-rects.js"; export * from "./hadcrut-warming-stripes.js"; export * from "./heatmap.js"; From b1f1f7a3368cb2c9ba6e7f6fb6b19b6854571b96 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 28 Apr 2023 12:49:50 -0700 Subject: [PATCH 2/5] rename to quantize --- docs/.vitepress/config.ts | 1 + docs/transforms/group.md | 6 ++--- docs/transforms/quantize.md | 7 ++++++ src/index.d.ts | 2 +- src/index.js | 2 +- src/transforms/interval.js | 23 ------------------ .../{interval.d.ts => quantize.d.ts} | 22 ++++++++--------- src/transforms/quantize.js | 24 +++++++++++++++++++ test/plots/group-interval.ts | 4 ++-- 9 files changed, 50 insertions(+), 41 deletions(-) create mode 100644 docs/transforms/quantize.md rename src/transforms/{interval.d.ts => quantize.d.ts} (59%) create mode 100644 src/transforms/quantize.js diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 43f67ce887..8d57362678 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -111,6 +111,7 @@ export default defineConfig({ {text: "Interval", link: "/transforms/interval"}, {text: "Map", link: "/transforms/map"}, {text: "Normalize", link: "/transforms/normalize"}, + {text: "Quantize", link: "/transforms/quantize"}, {text: "Select", link: "/transforms/select"}, {text: "Sort", link: "/transforms/sort"}, {text: "Stack", link: "/transforms/stack"}, diff --git a/docs/transforms/group.md b/docs/transforms/group.md index d7fdc2636e..e8b8e1efe0 100644 --- a/docs/transforms/group.md +++ b/docs/transforms/group.md @@ -16,7 +16,7 @@ onMounted(() => { # Group transform :::tip -The group transform is for aggregating ordinal or nominal data. For quantitative or temporal data, use the [bin transform](./bin.md), or use the [interval transform](./interval.md) to make continuous data ordinal. +The group transform is for aggregating ordinal or nominal data. For quantitative or temporal data, use the [bin transform](./bin.md), or use the [quantize transform](./quantize.md) to make continuous data ordinal. ::: The **group transform** groups ordinal or nominal data—discrete values such as name, type, or category. You can then compute summary statistics for each group, such as a count, sum, or proportion. The group transform is most often used to make bar charts with the [bar mark](../marks/bar.md). @@ -310,14 +310,14 @@ Plot.plot({ Although barX applies an implicit stackX transform, [textX](../marks/text.md) does not; this example uses an explicit stackX transform in both cases for clarity, and to pass the additional **order** and **reverse** options to place the largest sport on the left. The [filter transform](./filter.md) is applied after the stack transform to hide the labels on the smallest sports where the bars are too thin. ::: -While you should generally use the [bin transform](./bin.md) for quantitative or temporal data, you can use the [group transform](./group.md) if you also use the [interval transform](./interval.md) to make the data ordinal. +While you should generally use the [bin transform](./bin.md) for quantitative or temporal data, you can use the [group transform](./group.md) if you also use the [quantize transform](./quantize.md) to make the data ordinal. :::plot defer ```js Plot.plot({ x: {tickFormat: "%Y"}, marks: [ - Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.intervalX(d3.utcYear.every(5), {x: "date_of_birth"}))) + Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.quantizeX(d3.utcYear.every(5), {x: "date_of_birth"}))) ] }) ``` diff --git a/docs/transforms/quantize.md b/docs/transforms/quantize.md new file mode 100644 index 0000000000..721a6f5fb9 --- /dev/null +++ b/docs/transforms/quantize.md @@ -0,0 +1,7 @@ +# Quantize transform + +:::tip +There’s also an [**interval** scale option](../features/scales.md#scale-transforms) for quantizing continuous data. +::: + +TODO Describe the **quantize transform**. diff --git a/src/index.d.ts b/src/index.d.ts index 14f0def17f..583c6bc344 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -45,9 +45,9 @@ export * from "./transforms/centroid.js"; export * from "./transforms/dodge.js"; export * from "./transforms/group.js"; export * from "./transforms/hexbin.js"; -export * from "./transforms/interval.js"; export * from "./transforms/map.js"; export * from "./transforms/normalize.js"; +export * from "./transforms/quantize.js"; export * from "./transforms/select.js"; export * from "./transforms/stack.js"; export * from "./transforms/tree.js"; diff --git a/src/index.js b/src/index.js index 7dff1ab6e9..4cfede765e 100644 --- a/src/index.js +++ b/src/index.js @@ -33,10 +33,10 @@ export {centroid, geoCentroid} from "./transforms/centroid.js"; export {dodgeX, dodgeY} from "./transforms/dodge.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; export {hexbin} from "./transforms/hexbin.js"; -export {intervalX, intervalY, intervalMap} from "./transforms/interval.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; export {map, mapX, mapY} from "./transforms/map.js"; export {window, windowX, windowY} from "./transforms/window.js"; +export {quantizeX, quantizeY, quantizeMap} from "./transforms/quantize.js"; export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js"; export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js"; export {treeNode, treeLink} from "./transforms/tree.js"; diff --git a/src/transforms/interval.js b/src/transforms/interval.js index 94889f018b..a417e83618 100644 --- a/src/transforms/interval.js +++ b/src/transforms/interval.js @@ -1,28 +1,5 @@ import {isTemporal, labelof, map, maybeInterval, maybeValue, valueof} from "../options.js"; import {maybeInsetX, maybeInsetY} from "./inset.js"; -import {mapX, mapY} from "./map.js"; - -export function intervalX(interval, options) { - if (arguments.length === 1) ({interval, ...options} = interval); - return mapX(intervalMap(interval), options); -} - -export function intervalY(interval, options) { - if (arguments.length === 1) ({interval, ...options} = interval); - return mapY(intervalMap(interval), options); -} - -export function intervalMap(interval, type) { - interval = maybeInterval(interval, type); - return { - mapIndex(I, S, T) { - T.interval = interval; // hint for scales - for (let i = 0, n = I.length; i < n; ++i) { - T[I[i]] = interval.floor(S[I[i]]); - } - } - }; -} // The interval may be specified either as x: {value, interval} or as {x, // interval}. The former can be used to specify separate intervals for x and y, diff --git a/src/transforms/interval.d.ts b/src/transforms/quantize.d.ts similarity index 59% rename from src/transforms/interval.d.ts rename to src/transforms/quantize.d.ts index 55aa370408..f445dda382 100644 --- a/src/transforms/interval.d.ts +++ b/src/transforms/quantize.d.ts @@ -2,8 +2,8 @@ import type {Interval} from "../interval.js"; import type {Transformed} from "./basic.js"; import type {Map} from "./map.js"; -/** Options for the interval transform. */ -export interface IntervalOptions { +/** Options for the quantize transform. */ +export interface QuantizeOptions { /** * How to quantize the continuous data; one of: * @@ -14,7 +14,7 @@ export interface IntervalOptions { * For example, for integer bins: * * ```js - * Plot.barY(numbers, Plot.groupX({y: "count"}, Plot.intervalX(1))) + * Plot.barY(numbers, Plot.groupX({y: "count"}, Plot.quantizeX(1))) * ``` */ interval?: Interval; @@ -24,23 +24,23 @@ export interface IntervalOptions { * Derives new **x**, **x1**, and **x2** channels for each corresponding input * channel by quantizing to the given *interval*. */ -export function intervalX(interval?: Interval, options?: T): Transformed; -export function intervalX(options?: T & IntervalOptions): Transformed; +export function quantizeX(interval?: Interval, options?: T): Transformed; +export function quantizeX(options?: T & QuantizeOptions): Transformed; /** * Derives new **y**, **y1**, and **y2** channels for each corresponding input * channel by quantizing to the given *interval*. */ -export function intervalY(interval?: Interval, options?: T): Transformed; -export function intervalY(options?: T & IntervalOptions): Transformed; +export function quantizeY(interval?: Interval, options?: T): Transformed; +export function quantizeY(options?: T & QuantizeOptions): Transformed; /** * Given an *interval*, returns a corresponding map implementation for use with - * the map transform, allowing the normalization of arbitrary channels instead - * of only **x** and **y**. For example, to interval the **stroke** channel: + * the map transform, allowing the quantization of arbitrary channels instead of + * only **x** and **y**. For example, to quantize the **stroke** channel: * * ```js - * Plot.map({stroke: Plot.intervalMap(10)}, {x: "Date", stroke: "Close", stroke: "Symbol"}) + * Plot.map({stroke: Plot.quantizeMap(10)}, {x: "Date", stroke: "Close", stroke: "Symbol"}) * ``` */ -export function intervalMap(interval: Interval): Map; +export function quantizeMap(interval: Interval): Map; diff --git a/src/transforms/quantize.js b/src/transforms/quantize.js new file mode 100644 index 0000000000..3364e2660f --- /dev/null +++ b/src/transforms/quantize.js @@ -0,0 +1,24 @@ +import {maybeInterval} from "../options.js"; +import {mapX, mapY} from "./map.js"; + +export function quantizeX(interval, options) { + if (arguments.length === 1) ({interval, ...options} = interval); + return mapX(quantizeMap(interval), options); +} + +export function quantizeY(interval, options) { + if (arguments.length === 1) ({interval, ...options} = interval); + return mapY(quantizeMap(interval), options); +} + +export function quantizeMap(interval, type) { + interval = maybeInterval(interval, type); + return { + mapIndex(I, S, T) { + T.interval = interval; // hint for scales + for (let i = 0, n = I.length; i < n; ++i) { + T[I[i]] = interval.floor(S[I[i]]); + } + } + }; +} diff --git a/test/plots/group-interval.ts b/test/plots/group-interval.ts index 32dda9e0ba..edb8229004 100644 --- a/test/plots/group-interval.ts +++ b/test/plots/group-interval.ts @@ -5,7 +5,7 @@ export async function groupIntervalYear() { const olympians = await d3.csv("data/athletes.csv", d3.autoType); return Plot.plot({ x: {tickFormat: "%Y"}, - marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.intervalX(d3.utcYear.every(5), {x: "date_of_birth"})))] + marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.quantizeX(d3.utcYear.every(5), {x: "date_of_birth"})))] }); } @@ -17,6 +17,6 @@ export async function groupIntervalYearSparse() { }); return Plot.plot({ x: {tickFormat: "%Y"}, - marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.intervalX(d3.utcYear.every(5), {x: "date_of_birth"})))] + marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.quantizeX(d3.utcYear.every(5), {x: "date_of_birth"})))] }); } From e4b36f38ab92bdd53c16a34f754463ef97456559 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 28 Apr 2023 13:46:00 -0700 Subject: [PATCH 3/5] generic quantize transform --- src/transforms/quantize.d.ts | 18 ++++++++++++++---- src/transforms/quantize.js | 10 +++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/transforms/quantize.d.ts b/src/transforms/quantize.d.ts index f445dda382..d0baeb21b7 100644 --- a/src/transforms/quantize.d.ts +++ b/src/transforms/quantize.d.ts @@ -1,3 +1,4 @@ +import {ChannelName} from "channel.js"; import type {Interval} from "../interval.js"; import type {Transformed} from "./basic.js"; import type {Map} from "./map.js"; @@ -34,13 +35,22 @@ export function quantizeX(options?: T & QuantizeOptions): Transformed; export function quantizeY(interval?: Interval, options?: T): Transformed; export function quantizeY(options?: T & QuantizeOptions): Transformed; +/** Outputs for the quantize transform, and a corresponding *interval*. */ +export type QuantizeOutputs = {[key in ChannelName]?: Interval}; + /** - * Given an *interval*, returns a corresponding map implementation for use with - * the map transform, allowing the quantization of arbitrary channels instead of - * only **x** and **y**. For example, to quantize the **stroke** channel: + * For each channel in the specified *outputs*, derives a new channel by + * quantizing to the corresponding *interval*. For example, to quantize the + * **stroke** channel: * * ```js - * Plot.map({stroke: Plot.quantizeMap(10)}, {x: "Date", stroke: "Close", stroke: "Symbol"}) + * Plot.quantize({stroke: 10}, {x: "Date", stroke: "Close", stroke: "Volume"}) * ``` */ +export function quantize(outputs?: QuantizeOutputs, options?: T): Transformed; + +/** + * Given an *interval*, returns a corresponding map implementation for use with + * the map transform. See quantize. + */ export function quantizeMap(interval: Interval): Map; diff --git a/src/transforms/quantize.js b/src/transforms/quantize.js index 3364e2660f..f5f5b29697 100644 --- a/src/transforms/quantize.js +++ b/src/transforms/quantize.js @@ -1,5 +1,5 @@ import {maybeInterval} from "../options.js"; -import {mapX, mapY} from "./map.js"; +import {map, mapX, mapY} from "./map.js"; export function quantizeX(interval, options) { if (arguments.length === 1) ({interval, ...options} = interval); @@ -11,6 +11,14 @@ export function quantizeY(interval, options) { return mapY(quantizeMap(interval), options); } +export function quantize(outputs = {}, options = {}) { + return map(Object.fromEntries(Object.entries(outputs).map(quantizeMapEntry)), options); +} + +function quantizeMapEntry([key, interval]) { + return [key, quantizeMap(interval)]; +} + export function quantizeMap(interval, type) { interval = maybeInterval(interval, type); return { From 0924d7c5716885a0fe27dfef2565740c70810547 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 28 Apr 2023 15:15:56 -0700 Subject: [PATCH 4/5] adopt named intervals --- docs/features/scales.md | 2 +- docs/marks/axis.md | 2 +- docs/marks/bar.md | 4 ++-- docs/marks/dot.md | 4 ++-- docs/marks/rect.md | 2 +- docs/marks/rule.md | 4 ++-- docs/marks/text.md | 4 ++-- docs/transforms/bin.md | 2 +- src/scales.js | 2 +- test/plots/band-clip.ts | 2 +- test/plots/group-interval.ts | 4 ++-- test/plots/hadcrut-warming-stripes.ts | 17 +++-------------- 12 files changed, 19 insertions(+), 30 deletions(-) diff --git a/docs/features/scales.md b/docs/features/scales.md index 18aae3bbc0..19ddd16851 100644 --- a/docs/features/scales.md +++ b/docs/features/scales.md @@ -705,7 +705,7 @@ The default range depends on the scale: for position scales (*x*, *y*, *fx*, and The behavior of the **unknown** scale 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 [**interval** option](#scale-transforms) 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*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, *sunday*) naming the corresponding time interval, or a skip interval consisting of a number followed by the interval name (possibly pluralized), such as *3 months* or *10 years*. 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 [**interval** option](#scale-transforms) can be used to enforce uniformity. The specified *interval* object—or a named interval such as *month*—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*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, *sunday*) naming the corresponding time interval, or a skip interval consisting of a number followed by the interval name (possibly pluralized), such as *3 months* or *10 years*. 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: diff --git a/docs/marks/axis.md b/docs/marks/axis.md index 295478fb6c..32f1fd6b06 100644 --- a/docs/marks/axis.md +++ b/docs/marks/axis.md @@ -175,7 +175,7 @@ Alternatively, you can add multiple axes with options for hierarchical time inte :::plot https://observablehq.com/@observablehq/plot-multiscale-date-axis ```js Plot.plot({ - x: {round: true, nice: d3.utcWeek}, + x: {round: true, nice: "week"}, y: {inset: 6}, marks: [ Plot.frame({fill: "currentColor", fillOpacity: 0.1}), diff --git a/docs/marks/bar.md b/docs/marks/bar.md index e258ba9027..19643ef2a3 100644 --- a/docs/marks/bar.md +++ b/docs/marks/bar.md @@ -232,7 +232,7 @@ The following optional channels are supported: If neither the **x1** nor **x2** option is specified, the **x** option may be specified as shorthand to apply an implicit [stackX transform](../transforms/stack.md); this is the typical configuration for a horizontal bar chart with bars aligned at *x* = 0. If the **x** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **x2** as identity and **y** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to barX to make a quick sequential bar chart. If the **y** channel is not specified, the bar will span the full vertical extent of the plot (or facet). -If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each *x* to produce *x1*, and *interval*.offset(*x1*) is invoked for each *x1* to produce *x2*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). +If an **interval** is specified, such as *hour* (or d3.utcHour), **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each *x* to produce *x1*, and *interval*.offset(*x1*) is invoked for each *x1* to produce *x2*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). ## barY(*data*, *options*) @@ -251,4 +251,4 @@ The following optional channels are supported: If neither the **y1** nor **y2** option is specified, the **y** option may be specified as shorthand to apply an implicit [stackY transform](../transforms/stack.md); this is the typical configuration for a vertical bar chart with bars aligned at *y* = 0. If the **y** option is not specified, it defaults to [identity](../features/transforms.md#identity). If *options* is undefined, then it defaults to **y2** as identity and **x** as the zero-based index [0, 1, 2, …]; this allows an array of numbers to be passed to barY to make a quick sequential bar chart. If the **x** channel is not specified, the bar will span the full horizontal extent of the plot (or facet). -If an **interval** is specified, such as d3.utcDay, **y1** and **y2** can be derived from **y**: *interval*.floor(*y*) is invoked for each *y* to produce *y1*, and *interval*.offset(*y1*) is invoked for each *y1* to produce *y2*. If the interval is specified as a number *n*, *y1* and *y2* are taken as the two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). +If an **interval** is specified, such as *hour* (or d3.utcHour), **y1** and **y2** can be derived from **y**: *interval*.floor(*y*) is invoked for each *y* to produce *y1*, and *interval*.offset(*y1*) is invoked for each *y1* to produce *y2*. If the interval is specified as a number *n*, *y1* and *y2* are taken as the two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). diff --git a/docs/marks/dot.md b/docs/marks/dot.md index ca607f384a..f370088828 100644 --- a/docs/marks/dot.md +++ b/docs/marks/dot.md @@ -349,7 +349,7 @@ Plot.dotX(cars.map((d) => d["economy (mpg)"])) Equivalent to [dot](#dot-data-options) except that if the **x** option is not specified, it defaults to the identity function and assumes that *data* = [*x₀*, *x₁*, *x₂*, …]. -If an **interval** is specified, such as d3.utcDay, **y** is transformed to (*interval*.floor(*y*) + *interval*.offset(*interval*.floor(*y*))) / 2. If the interval is specified as a number *n*, *y* will be the midpoint of two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). +If an **interval** is specified, such as *hour* (or d3.utcHour), **y** is transformed to (*interval*.floor(*y*) + *interval*.offset(*interval*.floor(*y*))) / 2. If the interval is specified as a number *n*, *y* will be the midpoint of two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). ## dotY(*data*, *options*) @@ -359,7 +359,7 @@ Plot.dotY(cars.map((d) => d["economy (mpg)"])) Equivalent to [dot](#dot-data-options) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …]. -If an **interval** is specified, such as d3.utcDay, **x** is transformed to (*interval*.floor(*x*) + *interval*.offset(*interval*.floor(*x*))) / 2. If the interval is specified as a number *n*, *x* will be the midpoint of two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). +If an **interval** is specified, such as *hour* (or d3.utcHour), **x** is transformed to (*interval*.floor(*x*) + *interval*.offset(*interval*.floor(*x*))) / 2. If the interval is specified as a number *n*, *x* will be the midpoint of two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). ## circle(*data*, *options*) diff --git a/docs/marks/rect.md b/docs/marks/rect.md index 00c64ffab6..5430692194 100644 --- a/docs/marks/rect.md +++ b/docs/marks/rect.md @@ -201,7 +201,7 @@ The following channels are optional: Typically either **x1** and **x2** are specified, or **y1** and **y2**, or both. -If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each **x** to produce **x1**, and *interval*.offset(*x1*) is invoked for each **x1** to produce **x2**. The same is true for **y**, **y1**, and **y2**, respectively. If the interval is specified as a number *n*, **x1** and **x2** are taken as the two consecutive multiples of *n* that bracket **x**. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). +If an **interval** is specified, such as *hour* (or d3.utcHour), **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each **x** to produce **x1**, and *interval*.offset(*x1*) is invoked for each **x1** to produce **x2**. The same is true for **y**, **y1**, and **y2**, respectively. If the interval is specified as a number *n*, **x1** and **x2** are taken as the two consecutive multiples of *n* that bracket **x**. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). The rect mark supports the [standard mark options](../features/marks.md#mark-options), including insets and rounded corners. The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise. diff --git a/docs/marks/rule.md b/docs/marks/rule.md index 75d53ce539..e54b8c86bf 100644 --- a/docs/marks/rule.md +++ b/docs/marks/rule.md @@ -160,7 +160,7 @@ If **x** is not specified, it defaults to [identity](../features/transforms.md#i If **y** is specified, it is shorthand for **y2** with **y1** equal to zero; this is the typical configuration for a vertical lollipop chart with rules aligned at *y* = 0. If **y1** is not specified, the rule will start at the top of the plot (or facet). If **y2** is not specified, the rule will end at the bottom of the plot (or facet). -If an **interval** is specified, such as d3.utcDay, **y1** and **y2** can be derived from **y**: *interval*.floor(*y*) is invoked for each *y* to produce *y1*, and *interval*.offset(*y1*) is invoked for each *y1* to produce *y2*. If the interval is specified as a number *n*, *y1* and *y2* are taken as the two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). +If an **interval** is specified, such as *hour* (or d3.utcHour), **y1** and **y2** can be derived from **y**: *interval*.floor(*y*) is invoked for each *y* to produce *y1*, and *interval*.offset(*y1*) is invoked for each *y1* to produce *y2*. If the interval is specified as a number *n*, *y1* and *y2* are taken as the two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). ## ruleY(*data*, *options*) @@ -181,4 +181,4 @@ If **y** is not specified, it defaults to [identity](../features/transforms.md#i If **x** is specified, it is shorthand for **x2** with **x1** equal to zero; this is the typical configuration for a horizontal lollipop chart with rules aligned at *x* = 0. If **x1** is not specified, the rule will start at the left edge of the plot (or facet). If **x2** is not specified, the rule will end at the right edge of the plot (or facet). -If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each *x* to produce *x1*, and *interval*.offset(*x1*) is invoked for each *x1* to produce *x2*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). +If an **interval** is specified, such as *hour* (or d3.utcHour), **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each *x* to produce *x1*, and *interval*.offset(*x1*) is invoked for each *x1* to produce *x2*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). diff --git a/docs/marks/text.md b/docs/marks/text.md index 448122fda8..a4bda56ff6 100644 --- a/docs/marks/text.md +++ b/docs/marks/text.md @@ -256,7 +256,7 @@ Plot.textX(alphabet.map((d) => d.frequency)) Equivalent to [text](#text-data-options), except **x** defaults to [identity](../features/transforms.md#identity) and assumes that *data* = [*x₀*, *x₁*, *x₂*, …]. -If an **interval** is specified, such as d3.utcDay, **y** is transformed to (*interval*.floor(*y*) + *interval*.offset(*interval*.floor(*y*))) / 2. If the interval is specified as a number *n*, *y* will be the midpoint of two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). +If an **interval** is specified, such as *hour* (or d3.utcHour), **y** is transformed to (*interval*.floor(*y*) + *interval*.offset(*interval*.floor(*y*))) / 2. If the interval is specified as a number *n*, *y* will be the midpoint of two consecutive multiples of *n* that bracket *y*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). ## textY(*data*, *options*) @@ -266,4 +266,4 @@ Plot.textY(alphabet.map((d) => d.frequency)) Equivalent to [text](#text-data-options), except **y** defaults to [identity](../features/transforms.md#identity) and assumes that *data* = [*y₀*, *y₁*, *y₂*, …]. -If an **interval** is specified, such as d3.utcDay, **x** is transformed to (*interval*.floor(*x*) + *interval*.offset(*interval*.floor(*x*))) / 2. If the interval is specified as a number *n*, *x* will be the midpoint of two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). +If an **interval** is specified, such as *hour* (or d3.utcHour), **x** is transformed to (*interval*.floor(*x*) + *interval*.offset(*interval*.floor(*x*))) / 2. If the interval is specified as a number *n*, *x* will be the midpoint of two consecutive multiples of *n* that bracket *x*. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales#scale-options). diff --git a/docs/transforms/bin.md b/docs/transforms/bin.md index d348bc73dc..6e233da835 100644 --- a/docs/transforms/bin.md +++ b/docs/transforms/bin.md @@ -334,7 +334,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 "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 **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 *hour* (or d3.utcHour), 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. diff --git a/src/scales.js b/src/scales.js index 30dbc4d3dd..15cdae5c22 100644 --- a/src/scales.js +++ b/src/scales.js @@ -270,7 +270,7 @@ function createScale(key, channels = [], options = {}) { warn( `Warning: some data associated with the ${key} scale are dates. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType( type - )}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can specify the interval of the ${key} scale (e.g., d3.utcDay), or you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType( + )}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can specify the interval of the ${key} scale (e.g., "day"), or you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType( type )}".` ); diff --git a/test/plots/band-clip.ts b/test/plots/band-clip.ts index 34380664ce..3b7baba7d6 100644 --- a/test/plots/band-clip.ts +++ b/test/plots/band-clip.ts @@ -28,7 +28,7 @@ export async function bandClip2() { ]; return Plot.plot({ grid: true, - x: {interval: d3.utcDay}, + x: {interval: "day"}, marks: [ Plot.ruleY([0]), Plot.barY(data, Plot.groupX({y: "sum"}, {x: "Date", y: "Count", rx: 6, insetBottom: -6, clip: "frame"})) diff --git a/test/plots/group-interval.ts b/test/plots/group-interval.ts index edb8229004..9b098a1454 100644 --- a/test/plots/group-interval.ts +++ b/test/plots/group-interval.ts @@ -5,7 +5,7 @@ export async function groupIntervalYear() { const olympians = await d3.csv("data/athletes.csv", d3.autoType); return Plot.plot({ x: {tickFormat: "%Y"}, - marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.quantizeX(d3.utcYear.every(5), {x: "date_of_birth"})))] + marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.quantizeX("5 years", {x: "date_of_birth"})))] }); } @@ -17,6 +17,6 @@ export async function groupIntervalYearSparse() { }); return Plot.plot({ x: {tickFormat: "%Y"}, - marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.quantizeX(d3.utcYear.every(5), {x: "date_of_birth"})))] + marks: [Plot.barY(olympians, Plot.groupX({y: "count"}, Plot.quantizeX("5 years", {x: "date_of_birth"})))] }); } diff --git a/test/plots/hadcrut-warming-stripes.ts b/test/plots/hadcrut-warming-stripes.ts index d12fd455e3..2c62140fbd 100644 --- a/test/plots/hadcrut-warming-stripes.ts +++ b/test/plots/hadcrut-warming-stripes.ts @@ -12,19 +12,8 @@ export async function hadcrutWarmingStripes() { anomaly: +anomaly })); return Plot.plot({ - x: { - round: true - }, - color: { - scheme: "BuRd", - symmetric: false - }, - marks: [ - Plot.barX(hadcrut, { - x1: "year", // start of current year - x2: (d) => d3.utcYear.offset(d.year), // start of next year - fill: "anomaly" - }) - ] + x: {round: true}, + color: {scheme: "BuRd", symmetric: false}, + marks: [Plot.barX(hadcrut, {x: "year", interval: "year", inset: 0, fill: "anomaly"})] }); } From f3309add84298c194eaa195e9d110024340780f7 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 28 Apr 2023 15:18:14 -0700 Subject: [PATCH 5/5] remove unused import --- test/plots/band-clip.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/plots/band-clip.ts b/test/plots/band-clip.ts index 3b7baba7d6..812a76890f 100644 --- a/test/plots/band-clip.ts +++ b/test/plots/band-clip.ts @@ -1,5 +1,4 @@ import * as Plot from "@observablehq/plot"; -import * as d3 from "d3"; export async function bandClip() { return Plot.plot({