Skip to content

Commit

Permalink
interval for rect (#550)
Browse files Browse the repository at this point in the history
* interval for rect

* default insets for intervals

* Update src/transforms/interval.js

* numeric interval & documentation (#552)

* interval for rule and bar

* Update CHANGELOG

Co-authored-by: Philippe Rivière <[email protected]>
  • Loading branch information
mbostock and Fil authored Sep 24, 2021
1 parent ce19abb commit 33ab79e
Show file tree
Hide file tree
Showing 11 changed files with 301 additions and 42 deletions.
30 changes: 19 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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()
```
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*)

Expand Down
5 changes: 3 additions & 2 deletions src/marks/bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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)));
}
7 changes: 4 additions & 3 deletions src/marks/rect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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)));
}
11 changes: 7 additions & 4 deletions src/marks/rule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 9 additions & 21 deletions src/transforms/bin.js
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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];
}
17 changes: 17 additions & 0 deletions src/transforms/inset.js
Original file line number Diff line number Diff line change
@@ -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];
}
47 changes: 47 additions & 0 deletions src/transforms/interval.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit 33ab79e

Please sign in to comment.