Skip to content

scale unknown #559

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

Merged
merged 6 commits into from
Sep 26, 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,15 @@ A scale’s domain (the extent of its inputs, abstract values) and range (the ex

* *scale*.**domain** - typically [*min*, *max*], or an array of ordinal or categorical values
* *scale*.**range** - typically [*min*, *max*], or an array of ordinal or categorical values
* *scale*.**reverse** - reverses the domain, say to flip the chart along *x* or *y*
* *scale*.**unknown** - the desired output value (defaults to undefined) for invalid input values
* *scale*.**reverse** - reverses the domain (or in somes cases, the range), say to flip the chart along *x* or *y*

For most quantitative scales, the default domain is the [*min*, *max*] of all values associated with the scale. For the *radius* and *opacity* scales, the default domain is [0, *max*] to ensure a meaningful value encoding. For ordinal scales, the default domain is the set of all distinct values associated with the scale in natural ascending order; for a different order, set the domain explicitly or add a [sort option](#sort-options) to an associated mark. For threshold scales, the default domain is [0] to separate negative and non-negative values. For quantile scales, the default domain is the set of all defined values associated with the scale. If a scale is reversed, it is equivalent to setting the domain as [*max*, *min*] instead of [*min*, *max*].

The default range depends on the scale: for [position scales](#position-options) (*x*, *y*, *fx*, and *fy*), the default range depends on the plot’s [size and margins](#layout-options). For [color scales](#color-options), there are default color schemes for quantitative, ordinal, and categorical data. For opacity, the default range is [0, 1]. And for radius, the default range is designed to produce dots of “reasonable” size assuming a *sqrt* scale type for accurate area representation: zero maps to zero, the first quartile maps to a radius of three pixels, and other values are extrapolated. This convention for radius ensures that if the scale’s data values are all equal, dots have the default constant radius of three pixels, while if the data varies, dots will tend to be larger.

The behavior of the *scale*.unknown option depends on the scale type. For quantitative and temporal scales, the unknown value is used whenever the input value is undefined, null, or NaN. For ordinal or categorical scales, the unknown value is returned for any input value outside the domain. For band or point scales, the unknown option has no effect; it is effectively always equal to undefined. If the unknown option is set to undefined (the default), or null or NaN, then the affected input values will be considered undefined and filtered from the output.

Quantitative scales can be further customized with additional options:

* *scale*.**clamp** - if true, clamp input values to the scale’s domain
Expand Down
2 changes: 2 additions & 0 deletions src/scales.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,13 +293,15 @@ function exposeScale({
}) {
if (type === "identity") return {type: "identity"};
const domain = scale.domain();
const unknown = scale.unknown ? scale.unknown() : undefined;
return {
type,
domain,
...range !== undefined && {range: Array.from(range)}, // defensive copy
...transform !== undefined && {transform},
...percent && {percent}, // only exposed if truthy
...label !== undefined && {label},
...unknown !== undefined && {unknown},

// quantitative
...interpolate !== undefined && {interpolate},
Expand Down
3 changes: 2 additions & 1 deletion src/scales/diverging.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function ScaleD(key, scale, transform, channels, {
nice,
clamp,
domain = inferDomain(channels),
unknown,
pivot = 0,
range,
scheme = "rdbu",
Expand Down Expand Up @@ -46,7 +47,7 @@ function ScaleD(key, scale, transform, channels, {
}

if (reverse) interpolate = flip(interpolate);
scale.domain([min, pivot, max]).interpolator(interpolate);
scale.domain([min, pivot, max]).unknown(unknown).interpolator(interpolate);
if (clamp) scale.clamp(clamp);
if (nice) scale.nice(nice);
return {type, interpolate, scale};
Expand Down
6 changes: 4 additions & 2 deletions src/scales/ordinal.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {InternSet, reverse as reverseof, sort} from "d3";
import {scaleBand, scaleOrdinal, scalePoint} from "d3";
import {scaleBand, scaleOrdinal, scalePoint, scaleImplicit} from "d3";
import {ordinalScheme} from "./schemes.js";
import {ascendingDefined} from "../defined.js";
import {registry, color} from "./index.js";
Expand Down Expand Up @@ -27,9 +27,11 @@ export function ScaleOrdinal(key, channels, {
type,
scheme = type === "ordinal" ? "turbo" : "tableau10", // ignored if not color
range = registry.get(key) === color ? ordinalScheme(scheme) : undefined,
unknown,
...options
}) {
return ScaleO(scaleOrdinal().unknown(undefined), channels, {type, range, ...options});
if (unknown === scaleImplicit) throw new Error("implicit unknown is not supported");
return ScaleO(scaleOrdinal().unknown(unknown), channels, {type, range, ...options});
}

export function ScalePoint(key, channels, {
Expand Down
6 changes: 4 additions & 2 deletions src/scales/quantitative.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function ScaleQ(key, scale, channels, {
clamp,
zero,
domain = (registry.get(key) === radius || registry.get(key) === opacity ? inferZeroDomain : inferDomain)(channels),
unknown,
round,
range = registry.get(key) === radius ? inferRadialRange(channels, domain) : registry.get(key) === opacity ? unit : undefined,
type,
Expand Down Expand Up @@ -105,7 +106,7 @@ export function ScaleQ(key, scale, channels, {
}

if (reverse) domain = reverseof(domain);
scale.domain(domain);
scale.domain(domain).unknown(unknown);
if (nice) scale.nice(nice === true ? undefined : nice);
if (range !== undefined) scale.range(range);
if (clamp) scale.clamp(clamp);
Expand Down Expand Up @@ -149,14 +150,15 @@ export function ScaleSymlog(key, channels, {constant = 1, ...options}) {

export function ScaleThreshold(key, channels, {
domain = [0], // explicit thresholds in ascending order
unknown,
scheme = "rdylbu",
interpolate,
range = interpolate !== undefined ? quantize(interpolate, domain.length + 1) : registry.get(key) === color ? ordinalRange(scheme, domain.length + 1) : undefined,
reverse
}) {
if (!pairs(domain).every(([a, b]) => ascending(a, b) <= 0)) throw new Error("non-ascending domain");
if (reverse) range = reverseof(range); // domain ascending, so reverse range
return {type: "threshold", scale: scaleThreshold(domain, range), domain, range};
return {type: "threshold", scale: scaleThreshold(domain, range).unknown(unknown), domain, range};
}

export function ScaleIdentity() {
Expand Down
56 changes: 56 additions & 0 deletions test/output/penguinIslandUnknown.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion test/output/simpsonsRatings.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions test/plots/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export {default as musicRevenue} from "./music-revenue.js";
export {default as ordinalBar} from "./ordinal-bar.js";
export {default as penguinCulmen} from "./penguin-culmen.js";
export {default as penguinCulmenArray} from "./penguin-culmen-array.js";
export {default as penguinIslandUnknown} from "./penguin-island-unknown.js";
export {default as penguinMass} from "./penguin-mass.js";
export {default as penguinMassSex} from "./penguin-mass-sex.js";
export {default as penguinMassSexSpecies} from "./penguin-mass-sex-species.js";
Expand Down
16 changes: 16 additions & 0 deletions test/plots/penguin-island-unknown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";

export default async function() {
const penguins = await d3.csv("data/penguins.csv", d3.autoType);
return Plot.plot({
color: {
domain: ["Dream"],
unknown: "#ccc"
},
marks: [
Plot.barY(penguins, Plot.groupX({y: "count"}, {x: "sex", fill: "island"})),
Plot.ruleY([0])
]
});
}
5 changes: 3 additions & 2 deletions test/plots/simpsons-ratings.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export default async function() {
label: "Season"
},
color: {
scheme: "PiYG"
scheme: "PiYG",
unknown: "#ddd"
},
height: 640,
marks: [
Expand All @@ -26,7 +27,7 @@ export default async function() {
Plot.text(data, {
x: "number_in_season",
y: "season",
text: d => d.imdb_rating == null ? null : d.imdb_rating.toFixed(1),
text: d => d.imdb_rating == null ? "-" : d.imdb_rating.toFixed(1),
title: "title"
})
]
Expand Down
52 changes: 52 additions & 0 deletions test/scales/scales-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,58 @@ it("plot(…).scale('fy') can return a band scale", () => {
});
});

it("plot(…).scale(name).unknown reflects the given unknown option for an ordinal scale", async () => {
const penguins = await d3.csv("data/penguins.csv", d3.autoType);
const plot = Plot.dotX(penguins, {x: "body_mass_g", fill: "island"}).plot({color: {domain: ["Dream"], unknown: "#ccc"}});
assert.deepStrictEqual(plot.scale("color"), {
type: "ordinal",
domain: ["Dream"],
unknown: "#ccc",
range: d3.schemeTableau10,
label: "island"
});
});

it("plot(…).scale(name).unknown reflects the given unknown option for a continuous scale", async () => {
const penguins = await d3.csv("data/penguins.csv", d3.autoType);
const plot = Plot.dotX(penguins, {x: "body_mass_g", fill: "body_mass_g"}).plot({color: {unknown: "black"}});
assert.deepStrictEqual(plot.scale("color"), {
type: "linear",
domain: [2700, 6300],
range: [0, 1],
clamp: false,
unknown: "black",
interpolate: d3.interpolateTurbo,
label: "body_mass_g"
});
});

it("plot(…).scale(name).unknown reflects the given unknown option for a threshold scale", async () => {
const penguins = await d3.csv("data/penguins.csv", d3.autoType);
const plot = Plot.dotX(penguins, {x: "body_mass_g", fill: "body_mass_g"}).plot({color: {type: "threshold", domain: [3000], unknown: "black"}});
assert.deepStrictEqual(plot.scale("color"), {
type: "threshold",
domain: [3000],
unknown: "black",
range: [d3.schemeRdYlBu[3][0], d3.schemeRdYlBu[3][2]],
label: "body_mass_g"
});
});

it("plot(…).scale(name).unknown reflects the given unknown option for a diverging scale", async () => {
const gistemp = await d3.csv("data/gistemp.csv", d3.autoType);
const plot = Plot.dotX(gistemp, {x: "Date", fill: "Anomaly"}).plot({color: {type: "diverging", symmetric: false, unknown: "black"}});
assert.deepStrictEqual(plot.scale("color"), {
type: "diverging",
domain: [-0.78, 1.35],
pivot: 0,
clamp: false,
unknown: "black",
interpolate: d3.interpolateRdBu,
label: "Anomaly"
});
});

it("plot(…).scale(name) promotes the given zero option to the domain", async () => {
const penguins = await d3.csv("data/penguins.csv", d3.autoType);
const plot = Plot.dotX(penguins, {x: "body_mass_g"}).plot({x: {zero: true}});
Expand Down