diff --git a/CHANGELOG.md b/CHANGELOG.md index 3000818eeb..1a35534a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,32 +1,40 @@ # Observable Plot - Changelog -## 0.2.1 +## 0.2.3 + +*Not yet released.* These notes are a work in progress. + +Rect, bar, and rule marks now accept an *interval* option that allows to derive *x1* and *x2* from *x*, or *y1* and *y2* from *y*, where appropriate. A typical use case is for data that represents a fixed time interval; for example, using d3.utcDay as the interval creates rects that span a whole day, from UTC midnight to UTC midnight, that contains the associated time instant. The interval must be specifed as an object with two methods: **floor**(*x*) returns the start of the interval *x1* for the given *x*, while **offset**(*x*) returns the end of the interval *x2* for the given interval start *x*. If the interval is specified as a number, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. + +## 0.2.2 Released September 19, 2021. -### Marks +Fix a crash with the axis.tickRotate option when there are no ticks to rotate. -The constant *dx* and *dy* options have been extended to all marks, allowing to shift the mark by *dx* pixels horizontally and *dy* pixels vertically. Since only text elements accept the dx and dy properties, in all the other marks these are rendered as a transform (2D transformation) property of the mark’s parent, possibly including a 0.5px offset on low-density screens. +## 0.2.1 -### Scales +Released September 19, 2021. -Quantitative scales, as well as identity position scales, now coerce channel values to numbers; both null and undefined are coerced to NaN. Similarly, time scales coerce channel values to dates; numbers are assumed to be milliseconds since UNIX epoch, while strings are assumed to be in [ISO 8601 format](https://github.com/mbostock/isoformat/blob/main/README.md#parsedate-fallback). +The constant *dx* and *dy* options have been extended to all marks, allowing to shift the mark by *dx* pixels horizontally and *dy* pixels vertically. Since only text elements accept the dx and dy properties, in all the other marks these are rendered as a transform (2D transformation) property of the mark’s parent, possibly including a 0.5px offset on low-density screens. -### Transforms +Quantitative scales, as well as identity position scales, now coerce channel values to numbers; both null and undefined are coerced to NaN. Similarly, time scales coerce channel values to dates; numbers are assumed to be milliseconds since UNIX epoch, while strings are assumed to be in [ISO 8601 format](https://github.com/mbostock/isoformat/blob/main/README.md#parsedate-fallback). -#### Plot.bin +Bin transform reducers now receive the extent of the current bin as an argument after the data. For example, it allows to create meaningful titles: -The reducers now receive the extent of the current bin as an argument after the data. For example, it allows to create meaningful titles: ```js Plot.rect( athletes, Plot.bin( { fill: "count", - title: (bin, { x1, x2, y1, y2 }) => - `${bin.length} athletes weighing between ${x1} and ${x2} and with a height between ${y1} and ${y2}` + title: (bin, {x1, x2, y1, y2}) => `${bin.length} athletes weighing between ${x1} and ${x2} and with a height between ${y1} and ${y2}` }, - { x: "weight", y: "height", inset: 0 } + { + x: "weight", + y: "height", + inset: 0 + } ) ).plot() ``` diff --git a/README.md b/README.md index 13dd900356..0a650d7d28 100644 --- a/README.md +++ b/README.md @@ -801,7 +801,9 @@ The following channels are optional: * **x2** - the ending horizontal position; bound to the *x* scale * **y2** - the ending vertical position; bound to the *y* scale -Typically either **x1** and **x2** are specified, or **y1** and **y2**, or both. The rect mark supports the [standard mark options](#marks), including insets and rounded corners. The **stroke** defaults to none. The **fill** defaults to currentColor if the stroke is none, and to none otherwise. +Typically either **x1** and **x2** are specified, or **y1** and **y2**, or both. **x1** and **x2** can be derived from **x** and an **interval** object (such as d3.utcDay) with a **floor** method that returns *x1* from *x* and an **offset** method that returns *x2* from *x1*. If the interval is specified as a number *n*, *x1* and *x2* are taken as the two consecutive multiples of *n* that bracket *x*. The interval may be specified either as as {x, interval} or x: {value, interval}—typically to apply different intervals to x and y. + +The rect mark supports the [standard mark options](#marks), including insets and rounded corners. The **stroke** defaults to none. The **fill** defaults to currentColor if the stroke is none, and to none otherwise. #### Plot.rect(*data*, *options*) diff --git a/src/marks/bar.js b/src/marks/bar.js index 9b223edadc..d814ceb4ea 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -3,6 +3,7 @@ import {filter} from "../defined.js"; import {Mark, number} from "../mark.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js"; +import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; const defaults = {}; @@ -116,9 +117,9 @@ export class BarY extends AbstractBar { } export function barX(data, options) { - return new BarX(data, maybeStackX(options)); + return new BarX(data, maybeStackX(maybeIntervalX(options))); } export function barY(data, options) { - return new BarY(data, maybeStackY(options)); + return new BarY(data, maybeStackY(maybeIntervalY(options))); } diff --git a/src/marks/rect.js b/src/marks/rect.js index f303751771..ba8b625e24 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -3,6 +3,7 @@ import {filter} from "../defined.js"; import {Mark, number} from "../mark.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js"; +import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; const defaults = {}; @@ -64,13 +65,13 @@ export class Rect extends Mark { } export function rect(data, options) { - return new Rect(data, options); + return new Rect(data, maybeIntervalX(maybeIntervalY(options))); } export function rectX(data, options) { - return new Rect(data, maybeStackX(options)); + return new Rect(data, maybeStackX(maybeIntervalY(options))); } export function rectY(data, options) { - return new Rect(data, maybeStackY(options)); + return new Rect(data, maybeStackY(maybeIntervalX(options))); } diff --git a/src/marks/rule.js b/src/marks/rule.js index bfad5a0067..cab950245d 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -3,6 +3,7 @@ import {filter} from "../defined.js"; import {Mark, identity, number} from "../mark.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; +import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; const defaults = { fill: null, @@ -97,14 +98,16 @@ export class RuleY extends Mark { } } -export function ruleX(data, {x = identity, y, y1, y2, ...options} = {}) { +export function ruleX(data, options) { + let {x = identity, y, y1, y2, ...rest} = maybeIntervalY(options); ([y1, y2] = maybeOptionalZero(y, y1, y2)); - return new RuleX(data, {...options, x, y1, y2}); + return new RuleX(data, {...rest, x, y1, y2}); } -export function ruleY(data, {y = identity, x, x1, x2, ...options} = {}) { +export function ruleY(data, options) { + let {y = identity, x, x1, x2, ...rest} = maybeIntervalX(options); ([x1, x2] = maybeOptionalZero(x, x1, x2)); - return new RuleY(data, {...options, y, x1, x2}); + return new RuleY(data, {...rest, y, x1, x2}); } // For marks specified either as [0, x] or [x1, x2], or nothing. diff --git a/src/transforms/bin.js b/src/transforms/bin.js index 1a9397bf1b..09d96b6469 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -1,31 +1,25 @@ import {bin as binner, extent, thresholdFreedmanDiaconis, thresholdScott, thresholdSturges, utcTickInterval} from "d3"; import {valueof, range, identity, maybeLazyChannel, maybeTuple, maybeColor, maybeValue, mid, labelof, isTemporal} from "../mark.js"; -import {offset} from "../style.js"; import {basic} from "./basic.js"; import {maybeEvaluator, maybeGroup, maybeOutput, maybeOutputs, maybeReduce, maybeSort, maybeSubgroup, reduceCount, reduceIdentity} from "./group.js"; +import {maybeInsetX, maybeInsetY} from "./inset.js"; // Group on {z, fill, stroke}, then optionally on y, then bin x. -export function binX(outputs = {y: "count"}, {inset, insetLeft, insetRight, ...options} = {}) { - let {x, y} = options; - x = maybeBinValue(x, options, identity); - ([insetLeft, insetRight] = maybeInset(inset, insetLeft, insetRight)); - return binn(x, null, null, y, outputs, {inset, insetLeft, insetRight, ...options}); +export function binX(outputs = {y: "count"}, options = {}) { + const {x, y} = options; + return binn(maybeBinValue(x, options, identity), null, null, y, outputs, maybeInsetX(options)); } // Group on {z, fill, stroke}, then optionally on x, then bin y. -export function binY(outputs = {x: "count"}, {inset, insetTop, insetBottom, ...options} = {}) { - let {x, y} = options; - y = maybeBinValue(y, options, identity); - ([insetTop, insetBottom] = maybeInset(inset, insetTop, insetBottom)); - return binn(null, y, x, null, outputs, {inset, insetTop, insetBottom, ...options}); +export function binY(outputs = {x: "count"}, options = {}) { + const {x, y} = options; + return binn(null, maybeBinValue(y, options, identity), x, null, outputs, maybeInsetY(options)); } // Group on {z, fill, stroke}, then bin on x and y. -export function bin(outputs = {fill: "count"}, {inset, insetTop, insetRight, insetBottom, insetLeft, ...options} = {}) { +export function bin(outputs = {fill: "count"}, options = {}) { const {x, y} = maybeBinValueTuple(options); - ([insetTop, insetBottom] = maybeInset(inset, insetTop, insetBottom)); - ([insetLeft, insetRight] = maybeInset(inset, insetLeft, insetRight)); - return binn(x, y, null, null, outputs, {inset, insetTop, insetRight, insetBottom, insetLeft, ...options}); + return binn(x, y, null, null, outputs, maybeInsetX(maybeInsetY(options))); } function binn( @@ -252,9 +246,3 @@ function binfilter([{x0, x1}, set]) { function binempty() { return new Uint32Array(0); } - -function maybeInset(inset, inset1, inset2) { - return inset === undefined && inset1 === undefined && inset2 === undefined - ? (offset ? [1, 0] : [0.5, 0.5]) - : [inset1, inset2]; -} diff --git a/src/transforms/inset.js b/src/transforms/inset.js new file mode 100644 index 0000000000..46cd750d44 --- /dev/null +++ b/src/transforms/inset.js @@ -0,0 +1,17 @@ +import {offset} from "../style.js"; + +export function maybeInsetX({inset, insetLeft, insetRight, ...options} = {}) { + ([insetLeft, insetRight] = maybeInset(inset, insetLeft, insetRight)); + return {inset, insetLeft, insetRight, ...options}; +} + +export function maybeInsetY({inset, insetTop, insetBottom, ...options} = {}) { + ([insetTop, insetBottom] = maybeInset(inset, insetTop, insetBottom)); + return {inset, insetTop, insetBottom, ...options}; +} + +function maybeInset(inset, inset1, inset2) { + return inset === undefined && inset1 === undefined && inset2 === undefined + ? (offset ? [1, 0] : [0.5, 0.5]) + : [inset1, inset2]; +} diff --git a/src/transforms/interval.js b/src/transforms/interval.js new file mode 100644 index 0000000000..1dac682957 --- /dev/null +++ b/src/transforms/interval.js @@ -0,0 +1,47 @@ +import {labelof, maybeValue, valueof} from "../mark.js"; +import {maybeInsetX, maybeInsetY} from "./inset.js"; + +// TODO Allow the interval to be specified as a string, e.g. “day” or “hour”? +// This will require the interval knowing the type of the associated scale to +// chose between UTC and local time (or better, an explicit timeZone option). +function maybeInterval(interval) { + if (interval == null) return; + if (typeof interval === "number") { + const n = interval; + // Note: this offset doesn’t support the optional step argument for simplicity. + interval = {floor: d => n * Math.floor(d / n), offset: d => d + n}; + } + if (typeof interval.floor !== "function" || typeof interval.offset !== "function") throw new Error("invalid interval"); + return interval; +} + +// The interval may be specified either as x: {value, interval} or as {x, +// interval}. The former is used, for example, for Plot.rect. +function maybeIntervalValue(value, {interval} = {}) { + value = {...maybeValue(value)}; + value.interval = maybeInterval(value.interval === undefined ? interval : value.interval); + return value; +} + +function maybeIntervalK(k, maybeInsetK, options = {}) { + const {[k]: v, [`${k}1`]: v1, [`${k}2`]: v2} = options; + const {value, interval} = maybeIntervalValue(v, options); + if (interval == null) return options; + let V1; + const tv1 = data => V1 || (V1 = valueof(data, value).map(v => interval.floor(v))); + const label = labelof(v); + return maybeInsetK({ + ...options, + [k]: undefined, + [`${k}1`]: v1 === undefined ? {transform: tv1, label} : v1, + [`${k}2`]: v2 === undefined ? {transform: () => tv1().map(v => interval.offset(v)), label} : v2 + }); +} + +export function maybeIntervalX(options) { + return maybeIntervalK("x", maybeInsetX, options); +} + +export function maybeIntervalY(options = {}) { + return maybeIntervalK("y", maybeInsetY, options); +} diff --git a/test/output/aaplVolumeRect.svg b/test/output/aaplVolumeRect.svg new file mode 100644 index 0000000000..fee5740556 --- /dev/null +++ b/test/output/aaplVolumeRect.svg @@ -0,0 +1,173 @@ + + + + + 0 + + + + 5 + + + + 10 + + + + 15 + + + + 20 + + + + 25 + + + + 30 + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + + + 60 + + + + 65 + ↑ Daily trade volume (millions) + + + + Mar 18 + + + Mar 25 + + + April + + + Apr 08 + + + Apr 15 + + + Apr 22 + + + Apr 29 + + + May 06 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/aapl-volume-rect.js b/test/plots/aapl-volume-rect.js new file mode 100644 index 0000000000..26296d5a5e --- /dev/null +++ b/test/plots/aapl-volume-rect.js @@ -0,0 +1,18 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const AAPL = (await d3.csv("data/aapl.csv", d3.autoType)).slice(-40); + return Plot.plot({ + y: { + grid: true, + transform: d => d / 1e6, + label: "↑ Daily trade volume (millions)" + }, + marks: [ + Plot.rectY(AAPL, {x: "Date", interval: d3.utcDay, y: "Volume", fill: "#ccc"}), + Plot.ruleY(AAPL, {x: "Date", interval: d3.utcDay, y: "Volume"}), + Plot.ruleY([0]) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 33b7797717..9d4da351a8 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -5,6 +5,7 @@ export {default as aaplClose} from "./aapl-close.js"; export {default as aaplCloseUntyped} from "./aapl-close-untyped.js"; export {default as aaplMonthly} from "./aapl-monthly.js"; export {default as aaplVolume} from "./aapl-volume.js"; +export {default as aaplVolumeRect} from "./aapl-volume-rect.js"; export {default as anscombeQuartet} from "./anscombe-quartet.js"; export {default as athletesHeightWeight} from "./athletes-height-weight.js"; export {default as athletesHeightWeightBin} from "./athletes-height-weight-bin.js";