Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

coerce to the scale’s type #532

Merged
merged 9 commits into from
Sep 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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.
Comment on lines +198 to +201
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe these comments could be reflected in the README?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will take a pass at the README before I merge. Thank you! 👍

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