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

legends and scales #484

Closed
wants to merge 44 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
a86edf8
checkpoint
Fil May 29, 2021
d3dc27d
expose scales as a reusable Plot scale options object
Fil May 31, 2021
85ae2c2
fix 'r' label
Fil May 31, 2021
295942e
scale:legend option
Fil Jun 1, 2021
4169fcf
simplify: always compute scales
Fil Jun 1, 2021
228565b
The plot's scales object contains the minimum set of options that can…
Fil Jun 7, 2021
0c1905c
fix tests
Fil Jun 7, 2021
ac21376
Update README.md
Fil Jun 7, 2021
7600f65
Update src/scales.js
Fil Jun 7, 2021
c15934e
fix tests (clamp option)
Fil Jun 7, 2021
0c0d701
Update README.md
mbostock Jun 9, 2021
8ddd39a
remove scale.legend for now
Fil Jun 9, 2021
5a04ff1
use a lazy getter on each scale
Fil Jun 10, 2021
3cdb631
remove comment
Fil Jun 10, 2021
291f6c7
disambiguate the scale's family ("temporal", "quantitative", "ordinal…
Fil Jun 11, 2021
d3c252c
expose all scale options
Fil Jun 11, 2021
8b686ff
max width, label, swatches
Fil Jun 12, 2021
cbe49eb
separation
Fil Jun 13, 2021
77bd54b
expose Plot.legendColor
Fil Jun 13, 2021
b7146f0
if the domain of a diverging scale already has 3 values, ignore the p…
Fil Jun 13, 2021
3d4908c
Merge branch 'fil/expose-scales' into fil/color-legends
Fil Jun 13, 2021
f2a877e
accept a plot or a scale descriptor
Fil Jun 13, 2021
258375e
accept scaleLog
Fil Jun 13, 2021
f5614d3
swatches styles work outside of the figure
Fil Jun 13, 2021
c0c3a08
cleanup
Fil Jun 13, 2021
6d9692b
don't expose the range if it's been subsumed in the interpolator
Fil Jun 13, 2021
35f0b53
color legend tests
Fil Jun 13, 2021
0cc54ce
legendOpacity
Fil Jun 13, 2021
3947b93
legendRadius, built with Plot
Fil Jun 13, 2021
1f52572
document legendRadius
Fil Jun 13, 2021
959b83b
set the figure's max-width to the plot's width
Fil Jun 16, 2021
6ba8a9a
cleaner; pass color.width to the swatches (only works with the color.…
Fil Jun 17, 2021
d9cbc2e
fix tests
Fil Jun 17, 2021
d275e69
rebase legends
Fil Aug 5, 2021
33d7906
Merge branch 'main' into fil/legends-again
Fil Aug 19, 2021
7432c83
Merge branch 'main' into fil/legends-again
Fil Aug 30, 2021
a48a545
merge
Fil Aug 30, 2021
0bbf70e
coerce to the scale’s type (#532)
mbostock Sep 7, 2021
151d4aa
Keep style.css in ES module (#524)
juba Sep 7, 2021
07cf90b
support dx, dy on all marks (#488)
Fil Sep 7, 2021
2f351a7
Document dx, dy in CHANGELOG; fix README: the 0.5 offset is used for …
Fil Sep 7, 2021
45137f6
checkpoint
Fil May 29, 2021
f18a9a5
conflict
Fil Sep 10, 2021
7733925
rebase
Fil Sep 10, 2021
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Observable Plot - Changelog

## 0.3.0

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


### Marks

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.

### 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
78 changes: 73 additions & 5 deletions 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 @@ -196,6 +198,70 @@ Plot.plot({
})
```

All the scale definitions are exposed as the *scales* property of the plot.

```js
color = Plot.plot({…}).scales.color;
color.range // ["red", "blue"]
```

And, to reuse the scale in another plot:

```js
const plot1 = Plot.plot(…);

Plot.plot({
color: plot1.scales.color
})
```

#### Plot.scale(*scaleOptions*)

```js
Plot.scale(plot1.scales.color)
```

Returns a [D3 scale](https://github.com/d3/d3-scale) that matches the given Plot scale *options* object.

### Legends

Plot will add a color legend to the figure if the *color*.*legend* option is given.

* *color*.**legend** - a function that is passed the color options, and returns a DOM element to inserted at the top of the figure. If *color.legend* is true, defaults to Plot.legendColor.

#### Plot.legendColor(*scaleOptions*)

Generates a color legend, with swatches for categorical and ordinal scales, and a ramp for continuous scales.

The color swatches can be configured with the following options:
* *color*.**columns** - the number of swatches per row
* *color*.**format** - a format function for the labels
* *color*.**swatchSize** - the size of the swatch (if square)
* *color*.**swatchWidth** - the swatches’ width
* *color*.**swatchHeight** - the swatches’ height
* *color*.**marginLeft** - the legend’s left margin

The continuous color legends can be configured with the following options:
* *color*.**label** - the scale’s label
* *color*.**tickSize** - the tick size
* *color*.**width** - the legend’s width
* *color*.**height** - the legend’s height
* *color*.**marginTop** - the legend’s top margin
* *color*.**marginRight** - the legend’s right margin
* *color*.**marginBottom** - the legend’s bottom margin
* *color*.**marginLeft** - the legend’s left margin
* *color*.**ticks** - number of ticks
* *color*.**tickFormat** - a format function for the legend’s ticks
* *color*.**tickValues** - the legend’s tick values

#### Plot.legendOpacity(*scaleOptions*)

The default opacity legend—rendered as a grayscale color legend. Plot will add an opacity legend to the figure if the *opacity*.*legend* option is given. If *opacity*.*legend* is true, uses the default opacity legend; if *opacity*.*legend* is a function, it is called with the scale’s options and should return a DOM element.

#### Plot.legendRadius(*scaleOptions*)

The default radius legend—rendered as a circles on a common base. Plot will add a radius legend to the figure if the *r*.*legend* option is given. If *r*.*legend* is true, uses the default radius legend; if *r*.*legend* is a function, it is called with the scale’s options and should return a DOM element.

### Position options

The position scales (*x*, *y*, *fx*, and *fy*) support additional options:
Expand Down Expand Up @@ -235,7 +301,7 @@ Plot automatically generates axes for position scales. You can configure these a
* *scale*.**labelAnchor** - the label anchor: *top*, *right*, *bottom*, *left*, or *center*
* *scale*.**labelOffset** - the label position offset (in pixels; default 0, typically for facet axes)

Plot does not currently generate a legend for the *color*, *radius*, or *opacity* scales, but when it does, we expect that some of the above options will also be used to configure legends. Top-level options are also supported as shorthand: **grid** and **line** (for *x* and *y* only; see also [facet.grid](#facet-options)), **label**, **axis**, **inset**, **round**, **align**, and **padding**.
Top-level options are also supported as shorthand: **grid** (for *x* and *y* only; see [facet.grid](#facet-options)), **label**, **axis**, **inset**, **round**, **align**, and **padding**.

### Color options

Expand Down Expand Up @@ -509,6 +575,10 @@ All marks support the following style options:
* **strokeDasharray** - a comma-separated list of dash lengths (in pixels)
* **mixBlendMode** - the [blend mode](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode) (*e.g.*, *multiply*)
* **shapeRendering** - the [shape-rendering mode](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/shape-rendering) (*e.g.*, *crispEdges*)
* **dx** - horizontal offset (in pixels; defaults to 0)
* **dy** - vertical offset (in pixels; defaults to 0)

For all marks except [text](#plottextdata-options), the **dx** and **dy** options are rendered as a transform property, possibly including a 0.5px offset on low-density screens.

All marks support the following optional channels:

Expand Down Expand Up @@ -889,11 +959,9 @@ The following text-specific constant options are also supported:
* **fontStyle** - the [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style); defaults to normal
* **fontVariant** - the [font variant](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant); defaults to normal
* **fontWeight** - the [font weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight); defaults to normal
* **dx** - the horizontal offset; defaults to 0
* **dy** - the vertical offset; defaults to 0
* **rotate** - the rotation in degrees clockwise; defaults to 0

The **dx** and **dy** options can be specified either as numbers representing pixels or as a string including units. For example, `"1em"` shifts the text by one [em](https://en.wikipedia.org/wiki/Em_(typography)), which is proportional to the **fontSize**. The **fontSize** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel.
For text marks, the **dx** and **dy** options can be specified either as numbers representing pixels or as a string including units. For example, `"1em"` shifts the text by one [em](https://en.wikipedia.org/wiki/Em_(typography)), which is proportional to the **fontSize**. The **fontSize** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel.

#### Plot.text(*data*, *options*)

Expand Down Expand Up @@ -1532,7 +1600,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
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
},
"files": [
"dist/**/*.js",
"src/**/*.js"
"src/**/*.js",
"src/**/*.css"
],
"scripts": {
"test": "mkdir -p test/output && mocha -r module-alias/register 'test/**/*-test.js' && mocha -r module-alias/register test/plot.js && eslint src test",
Expand All @@ -37,6 +38,7 @@
"devDependencies": {
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.0.4",
"canvas": "^2.8.0",
"clean-css": "^5.1.1",
"eslint": "^7.12.1",
"htl": "^0.3.0",
Expand All @@ -51,7 +53,7 @@
},
"dependencies": {
"d3": "^7.0.0",
"isoformat": "^0.1.0"
"isoformat": "^0.2.0"
},
"engines": {
"node": ">=12"
Expand Down
19 changes: 15 additions & 4 deletions src/axes.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function autoAxisTicksK(scale, axis, k) {
}

// Mutates axis.{label,labelAnchor,labelOffset}!
// Mutates scale.label!
export function autoAxisLabels(channels, scales, {x, y, fx, fy}, dimensions) {
if (fx) {
autoAxisLabelsX(fx, scales.fx, channels.get("fx"));
Expand Down Expand Up @@ -70,24 +71,34 @@ export function autoAxisLabels(channels, scales, {x, y, fx, fy}, dimensions) {

function autoAxisLabelsX(axis, scale, channels) {
if (axis.labelAnchor === undefined) {
axis.labelAnchor = scale.type === "ordinal" ? "center"
axis.labelAnchor = scale.family === "ordinal" ? "center"
: scale.reverse ? "left"
: "right";
}
if (axis.label === undefined) {
axis.label = inferLabel(channels, scale, axis, "x");
}
scale.label = axis.label;
}

function autoAxisLabelsY(axis, opposite, scale, channels) {
if (axis.labelAnchor === undefined) {
axis.labelAnchor = scale.type === "ordinal" ? "center"
axis.labelAnchor = scale.family === "ordinal" ? "center"
: opposite && opposite.axis === "top" ? "bottom" // TODO scale.reverse?
: "top";
}
if (axis.label === undefined) {
axis.label = inferLabel(channels, scale, axis, "y");
}
scale.label = axis.label;
}

export function autoScaleLabel(scale, channels, options) {
if (scale === undefined) return;
if (options !== undefined) scale.label = options.label;
if (scale.label === undefined) {
scale.label = inferLabel(channels, scale, {});
}
}

// Channels can have labels; if all the channels for a given scale are
Expand All @@ -104,8 +115,8 @@ function inferLabel(channels = [], scale, axis, key) {
if (candidate !== undefined) {
const {percent, reverse} = scale;
// Ignore the implicit label for temporal scales if it’s simply “date”.
if (scale.type === "temporal" && /^(date|time|year)$/i.test(candidate)) return;
if (scale.type !== "ordinal" && (key === "x" || key === "y")) {
if (scale.family === "temporal" && /^(date|time|year)$/i.test(candidate)) return;
if (scale.family !== "ordinal" && (key === "x" || key === "y")) {
if (percent) candidate = `${candidate} (%)`;
if (axis.labelAnchor === "center") {
candidate = `${candidate} →`;
Expand Down
24 changes: 24 additions & 0 deletions src/figure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

// Wrap the plot in a figure with a caption, if desired.
export function figureWrap(svg, {width}, caption, legends) {
if (caption == null && legends.length === 0) return svg;
const figure = document.createElement("figure");
figure.style = `max-width: ${width}px`;
if (legends.length > 0) {
const figlegends = document.createElement("div");
figlegends.className = "legends";
figure.appendChild(figlegends);
for (const l of legends) {
if (l instanceof Node) {
figlegends.appendChild(l);
}
}
}
figure.appendChild(svg);
if (caption != null) {
const figcaption = document.createElement("figcaption");
figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption));
figure.appendChild(figcaption);
}
return figure;
}
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");
}
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {plot} from "./plot.js";
export {scale} from "./scales.js";
export {Mark, marks, valueof} from "./mark.js";
export {Area, area, areaX, areaY} from "./marks/area.js";
export {BarX, BarY, barX, barY} from "./marks/bar.js";
Expand All @@ -22,3 +23,5 @@ export {windowX, windowY} from "./transforms/window.js";
export {selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";
export {legendColor} from "./legends/color.js";
export {legendOpacity} from "./legends/opacity.js";
18 changes: 18 additions & 0 deletions src/legends.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {legendColor} from "./legends/color.js";
import {legendOpacity} from "./legends/opacity.js";
import {legendRadius} from "./legends/radius.js";

export function createLegends(descriptors, dimensions) {
const legends = [];
for (let key in descriptors) {
let {legend, ...options} = descriptors[key];
if (key === "color" && legend === true) legend = legendColor;
if (key === "opacity" && legend === true) legend = legendOpacity;
if (key === "r" && legend === true) legend = legendRadius;
if (typeof legend === "function") {
const l = legend(options, dimensions);
if (l instanceof Node) legends.push(l);
}
}
return legends;
}
19 changes: 19 additions & 0 deletions src/legends/color.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {scale} from "../scales.js";
import {legendRamp} from "./ramp.js";
import {legendSwatches} from "./swatches.js";

export function legendColor(color, {width: maxWidth = 640} = {}) {
if (typeof color === "object" && "scales" in color) {
color = color.scales.color;
}
if (!color) return;
const {...options} = color;
switch (options.type) {
case "ordinal": case "categorical":
return legendSwatches(scale(options), options);
default:
options.key = "color"; // for diverging
if (options.width === undefined) options.width = Math.min(240, maxWidth);
return legendRamp(scale(options), options);
}
}
13 changes: 13 additions & 0 deletions src/legends/opacity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {legendColor} from "./color.js";

export function legendOpacity(plotOrScale, dimensions) {
if (!plotOrScale) return;
const opacity = "scales" in plotOrScale ? plotOrScale.scales.opacity : plotOrScale;
if (!opacity) return;
return legendColor({
...opacity,
range: undefined,
interpolate: undefined,
scheme: "greys"
}, dimensions);
}
64 changes: 64 additions & 0 deletions src/legends/radius.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {plot} from "../plot.js";
import {link} from "../marks/link.js";
import {text} from "../marks/text.js";
import {dot} from "../marks/dot.js";
import {scale} from "../scales.js";

export function legendRadius({
label,
ticks = 5,
tickFormat = (d) => d,
strokeWidth = 0.5,
strokeDasharray = [5, 4],
minStep = 8,
gap = 20,
...r
}) {
const s = scale(r);
const r0 = s.range()[1];

const shiftY = label ? 10 : 0;

let h = Infinity;
const values = s
.ticks(ticks)
.reverse()
.filter((t) => h - s(t) > minStep / 2 && (h = s(t)));

return plot({
x: { type: "identity", axis: null },
r: { type: "identity" },
y: { type: "identity", axis: null },
marks: [
link(values, {
x1: r0 + 2,
y1: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY,
x2: 2 * r0 + 2 + gap,
y2: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY,
strokeWidth: strokeWidth / 2,
strokeDasharray
}),
dot(values, {
r: s,
x: r0 + 2,
y: (d) => 8 + 2 * r0 - s(d) + shiftY,
strokeWidth
}),
text(values, {
x: 2 * r0 + 2 + gap,
y: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY,
textAnchor: "start",
dx: 4,
text: tickFormat
}),
text(label ? [label] : [], {
x: 0,
y: 6,
textAnchor: "start",
fontWeight: "bold",
text: tickFormat
})
],
height: 2 * r0 + 10 + shiftY
});
}
Loading