Skip to content

Commit

Permalink
coerce to the scale’s type (observablehq#532)
Browse files Browse the repository at this point in the history
* 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 <fil@rezo.net>
  • Loading branch information
mbostock and Fil authored Sep 7, 2021
1 parent 0c291ed commit 0bbf70e
Show file tree
Hide file tree
Showing 9 changed files with 170 additions and 8 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Observable Plot - Changelog

## 0.3.0

*Not yet released.* These notes are a work in progress.

### Scales

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).

## 0.2.0

Released August 20, 2021.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ For ordinal data (*e.g.*, strings), use the *ordinal* scale type or the *point*

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.

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).

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:

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

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

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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
},
"dependencies": {
"d3": "^7.0.0",
"isoformat": "^0.1.0"
"isoformat": "^0.2.0"
},
"engines": {
"node": ">=12"
Expand Down
6 changes: 5 additions & 1 deletion src/format.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export {default as formatIsoDate} from "isoformat";
import {format as isoFormat} from "isoformat";

export function formatMonth(locale = "en-US", month = "short") {
const format = new Intl.DateTimeFormat(locale, {timeZone: "UTC", month});
Expand All @@ -17,3 +17,7 @@ export function formatWeekday(locale = "en-US", weekday = "short") {
}
};
}

export function formatIsoDate(date) {
return isoFormat(date, "Invalid Date");
}
63 changes: 62 additions & 1 deletion src/scales.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {ScaleDiverging, ScaleDivergingSqrt, ScaleDivergingPow, ScaleDivergingLog
import {ScaleTime, ScaleUtc} from "./scales/temporal.js";
import {ScaleOrdinal, ScalePoint, ScaleBand} from "./scales/ordinal.js";
import {isOrdinal, isTemporal} from "./mark.js";
import {parse as isoParse} from "isoformat";

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

function Scale(key, channels = [], options = {}) {
switch (inferScaleType(key, channels, options)) {
const type = inferScaleType(key, channels, options);

// Once the scale type is known, coerce the associated channel values and any
// explicitly-specified domain to the expected type.
switch (type) {
case "diverging":
case "diverging-sqrt":
case "diverging-pow":
case "diverging-log":
case "diverging-symlog":
case "cyclical":
case "sequential":
case "linear":
case "sqrt":
case "threshold":
case "quantile":
case "pow":
case "log":
case "symlog":
options = coerceType(channels, options, coerceNumber, Float64Array);
break;
case "identity":
if (registry.get(key) === position) options = coerceType(channels, options, coerceNumber, Float64Array);
break;
case "utc":
case "time":
options = coerceType(channels, options, coerceDate);
break;
}

switch (type) {
case "diverging": return ScaleDiverging(key, channels, options);
case "diverging-sqrt": return ScaleDivergingSqrt(key, channels, options);
case "diverging-pow": return ScaleDivergingPow(key, channels, options);
Expand Down Expand Up @@ -144,3 +175,33 @@ export function isCollapsed(scale) {
}
return true;
}

// Mutates channel.value!
function coerceType(channels, options, coerce, type) {
for (const c of channels) c.value = coerceArray(c.value, coerce, type);
return {...options, domain: coerceArray(options.domain, coerce, type)};
}

function coerceArray(array, coerce, type = Array) {
if (array !== undefined) return type.from(array, coerce);
}

// Unlike Mark’s number, here we want to convert null and undefined to NaN,
// since the result will be stored in a Float64Array and we don’t want null to
// be coerced to zero.
function coerceNumber(x) {
return x == null ? NaN : +x;
}

// When coercing strings to dates, we only want to allow the ISO 8601 format
// since the built-in string parsing of the Date constructor varies across
// browsers. (In the future, this could be made more liberal if desired, though
// it is still generally preferable to do date parsing yourself explicitly,
// rather than rely on Plot.) Any non-string values are coerced to number first
// and treated as milliseconds since UNIX epoch.
function coerceDate(x) {
return x instanceof Date && !isNaN(x) ? x
: typeof x === "string" ? isoParse(x)
: x == null || isNaN(x = +x) ? undefined
: new Date(x);
}
67 changes: 67 additions & 0 deletions test/output/aaplCloseUntyped.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions test/plots/aapl-close-untyped.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";

export default async function() {
const AAPL = await d3.csv("data/aapl.csv");
return Plot.plot({
x: {
type: "utc"
},
y: {
type: "linear",
grid: true
},
marks: [
Plot.line(AAPL, {x: "Date", y: "Close"}),
Plot.ruleY([0])
]
});
}
1 change: 1 addition & 0 deletions test/plots/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {default as aaplCandlestick} from "./aapl-candlestick.js";
export {default as aaplChangeVolume} from "./aapl-change-volume.js";
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 anscombeQuartet} from "./anscombe-quartet.js";
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2243,10 +2243,10 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=

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

isstream@~0.1.2:
version "0.1.2"
Expand Down

0 comments on commit 0bbf70e

Please sign in to comment.