Skip to content

Commit 0bbf70e

Browse files
mbostockFil
andauthored
coerce to the scale’s type (#532)
* coerce to the scale’s type * upgrade isoformat * document type coercion * language * changelog * update CHANGELOG * coerce invalid dates to undefined * update README * update README Co-authored-by: Philippe Rivière <[email protected]>
1 parent 0c291ed commit 0bbf70e

File tree

9 files changed

+170
-8
lines changed

9 files changed

+170
-8
lines changed

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Observable Plot - Changelog
22

3+
## 0.3.0
4+
5+
*Not yet released.* These notes are a work in progress.
6+
7+
### Scales
8+
9+
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).
10+
311
## 0.2.0
412

513
Released August 20, 2021.

README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ For ordinal data (*e.g.*, strings), use the *ordinal* scale type or the *point*
165165

166166
You can opt-out of a scale using the *identity* scale type. This is useful if you wish to specify literal colors or pixel positions within a mark channel rather than relying on the scale to convert abstract values into visual values. For position scales (*x* and *y*), an *identity* scale is still quantitative and may produce an axis, yet unlike a *linear* scale the domain and range are fixed based on the plot layout.
167167

168+
Quantitative scales, as well as identity position scales, 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).
169+
168170
A scale’s domain (the extent of its inputs, abstract values) and range (the extent of its outputs, visual values) are typically inferred automatically. You can set them explicitly using these options:
169171

170172
* *scale*.**domain** - typically [*min*, *max*], or an array of ordinal or categorical values
@@ -1532,7 +1534,7 @@ These helper functions are provided for use as a *scale*.tickFormat [axis option
15321534
Plot.formatIsoDate(new Date("2020-01-01T00:00.000Z")) // "2020-01-01"
15331535
```
15341536

1535-
Given a *date*, returns the shortest equivalent ISO 8601 UTC string.
1537+
Given a *date*, returns the shortest equivalent ISO 8601 UTC string. If the given *date* is not valid, returns `"Invalid Date"`.
15361538

15371539
#### Plot.formatWeekday(*locale*, *format*)
15381540

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
},
5252
"dependencies": {
5353
"d3": "^7.0.0",
54-
"isoformat": "^0.1.0"
54+
"isoformat": "^0.2.0"
5555
},
5656
"engines": {
5757
"node": ">=12"

src/format.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export {default as formatIsoDate} from "isoformat";
1+
import {format as isoFormat} from "isoformat";
22

33
export function formatMonth(locale = "en-US", month = "short") {
44
const format = new Intl.DateTimeFormat(locale, {timeZone: "UTC", month});
@@ -17,3 +17,7 @@ export function formatWeekday(locale = "en-US", weekday = "short") {
1717
}
1818
};
1919
}
20+
21+
export function formatIsoDate(date) {
22+
return isoFormat(date, "Invalid Date");
23+
}

src/scales.js

+62-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {ScaleDiverging, ScaleDivergingSqrt, ScaleDivergingPow, ScaleDivergingLog
44
import {ScaleTime, ScaleUtc} from "./scales/temporal.js";
55
import {ScaleOrdinal, ScalePoint, ScaleBand} from "./scales/ordinal.js";
66
import {isOrdinal, isTemporal} from "./mark.js";
7+
import {parse as isoParse} from "isoformat";
78

89
export function Scales(channels, {inset, round, nice, align, padding, ...options} = {}) {
910
const scales = {};
@@ -58,7 +59,37 @@ function autoScaleRound(scale) {
5859
}
5960

6061
function Scale(key, channels = [], options = {}) {
61-
switch (inferScaleType(key, channels, options)) {
62+
const type = inferScaleType(key, channels, options);
63+
64+
// Once the scale type is known, coerce the associated channel values and any
65+
// explicitly-specified domain to the expected type.
66+
switch (type) {
67+
case "diverging":
68+
case "diverging-sqrt":
69+
case "diverging-pow":
70+
case "diverging-log":
71+
case "diverging-symlog":
72+
case "cyclical":
73+
case "sequential":
74+
case "linear":
75+
case "sqrt":
76+
case "threshold":
77+
case "quantile":
78+
case "pow":
79+
case "log":
80+
case "symlog":
81+
options = coerceType(channels, options, coerceNumber, Float64Array);
82+
break;
83+
case "identity":
84+
if (registry.get(key) === position) options = coerceType(channels, options, coerceNumber, Float64Array);
85+
break;
86+
case "utc":
87+
case "time":
88+
options = coerceType(channels, options, coerceDate);
89+
break;
90+
}
91+
92+
switch (type) {
6293
case "diverging": return ScaleDiverging(key, channels, options);
6394
case "diverging-sqrt": return ScaleDivergingSqrt(key, channels, options);
6495
case "diverging-pow": return ScaleDivergingPow(key, channels, options);
@@ -144,3 +175,33 @@ export function isCollapsed(scale) {
144175
}
145176
return true;
146177
}
178+
179+
// Mutates channel.value!
180+
function coerceType(channels, options, coerce, type) {
181+
for (const c of channels) c.value = coerceArray(c.value, coerce, type);
182+
return {...options, domain: coerceArray(options.domain, coerce, type)};
183+
}
184+
185+
function coerceArray(array, coerce, type = Array) {
186+
if (array !== undefined) return type.from(array, coerce);
187+
}
188+
189+
// Unlike Mark’s number, here we want to convert null and undefined to NaN,
190+
// since the result will be stored in a Float64Array and we don’t want null to
191+
// be coerced to zero.
192+
function coerceNumber(x) {
193+
return x == null ? NaN : +x;
194+
}
195+
196+
// When coercing strings to dates, we only want to allow the ISO 8601 format
197+
// since the built-in string parsing of the Date constructor varies across
198+
// browsers. (In the future, this could be made more liberal if desired, though
199+
// it is still generally preferable to do date parsing yourself explicitly,
200+
// rather than rely on Plot.) Any non-string values are coerced to number first
201+
// and treated as milliseconds since UNIX epoch.
202+
function coerceDate(x) {
203+
return x instanceof Date && !isNaN(x) ? x
204+
: typeof x === "string" ? isoParse(x)
205+
: x == null || isNaN(x = +x) ? undefined
206+
: new Date(x);
207+
}

test/output/aaplCloseUntyped.svg

+67
Loading

test/plots/aapl-close-untyped.js

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
4+
export default async function() {
5+
const AAPL = await d3.csv("data/aapl.csv");
6+
return Plot.plot({
7+
x: {
8+
type: "utc"
9+
},
10+
y: {
11+
type: "linear",
12+
grid: true
13+
},
14+
marks: [
15+
Plot.line(AAPL, {x: "Date", y: "Close"}),
16+
Plot.ruleY([0])
17+
]
18+
});
19+
}

test/plots/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export {default as aaplCandlestick} from "./aapl-candlestick.js";
22
export {default as aaplChangeVolume} from "./aapl-change-volume.js";
33
export {default as aaplClose} from "./aapl-close.js";
4+
export {default as aaplCloseUntyped} from "./aapl-close-untyped.js";
45
export {default as aaplMonthly} from "./aapl-monthly.js";
56
export {default as aaplVolume} from "./aapl-volume.js";
67
export {default as anscombeQuartet} from "./anscombe-quartet.js";

yarn.lock

+4-4
Original file line numberDiff line numberDiff line change
@@ -2243,10 +2243,10 @@ isexe@^2.0.0:
22432243
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
22442244
integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
22452245

2246-
isoformat@^0.1.0:
2247-
version "0.1.0"
2248-
resolved "https://registry.yarnpkg.com/isoformat/-/isoformat-0.1.0.tgz#b693c1c9ee9ab02f1af5af41ceeae52bf501b233"
2249-
integrity sha512-4wCSk50Ov1PKbZ2m+YN0rUgQfF4NRkIavbhpW1mANEqD9HxBZ+j/fWk8hERq1yxn+CfWqvOac4m9axLuF0NfEw==
2246+
isoformat@^0.2.0:
2247+
version "0.2.0"
2248+
resolved "https://registry.yarnpkg.com/isoformat/-/isoformat-0.2.0.tgz#52c3dce6c281adb6cb7f060895a731b7b2d52c1b"
2249+
integrity sha512-iyxQ94xMvUZryoHVaXg/TSLM318/aO7xS7Ute+t4MkvZ17IDfe9MkI/MQuu7XgxbmTiGkeggNj+1f6wmxF876Q==
22502250

22512251
isstream@~0.1.2:
22522252
version "0.1.2"

0 commit comments

Comments
 (0)