Skip to content

Commit

Permalink
vector + symbol (#649)
Browse files Browse the repository at this point in the history
* vector

* default anchor to middle

* remove blank lines

* further code golf

* symbol (#652)

* symbol

* optimize rendering

* maybeSymbolChannel

* move default symbol to Plot.Dot

* symbol legend

* symbol legend paint options

* color+symbol legend

* automatic symbol hints

* fix for orthogonal color and symbol

* preattentive symbols

* update README

* remove vectorX, vectorY

* update README

* length scale

* Update README

* limit max default vector length to 60px

* fix y-only symbols

* Update README.md

Co-authored-by: Philippe Rivière <fil@rezo.net>

* Update README

* update README

Co-authored-by: Philippe Rivière <fil@rezo.net>
  • Loading branch information
mbostock and Fil authored Jan 7, 2022
1 parent 1760910 commit 7f39a77
Show file tree
Hide file tree
Showing 32 changed files with 1,944 additions and 277 deletions.
70 changes: 61 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ Each scale’s options are specified as a nested options object with the corresp
* **r** - radius (size)
* **color** - fill or stroke
* **opacity** - fill or stroke opacity
* **length** - linear length (for [vectors](#vector))
* **symbol** - categorical symbol (for [dots](#dot))

For example, to set the domain for the *x* and *y* scales:

Expand Down Expand Up @@ -496,11 +498,11 @@ When the *include* or *exclude* facet mode is chosen, the mark data must be para

## Legends

Plot can generate legends for *color* and *opacity* [scales](#scale-options). (An opacity scale is treated as a color scale with varying transparency.) For an inline legend, use the *scale*.**legend** option:
Plot can generate legends for *color*, *opacity*, and *symbol* [scales](#scale-options). (An opacity scale is treated as a color scale with varying transparency.) For an inline legend, use the *scale*.**legend** option:

* *scale*.**legend** - if truthy, generate a legend for the given scale

If the *scale*.**legend** option is true, the default legend will be produced for the scale; otherwise, the meaning of the *legend* option depends on the scale. For quantitative color scales, it defaults to *ramp* but may be set to *swatches* for a discrete scale (most commonly for *threshold* color scales); for ordinal color scales, only the *swatches* value is supported.
If the *scale*.**legend** option is true, the default legend will be produced for the scale; otherwise, the meaning of the *legend* option depends on the scale. For quantitative color scales, it defaults to *ramp* but may be set to *swatches* for a discrete scale (most commonly for *threshold* color scales); for ordinal color scales and symbol scales, only the *swatches* value is supported.

### *plot*.legend(*name*, *options*)

Expand All @@ -524,6 +526,17 @@ Categorical and ordinal color legends are rendered as swatches, unless *options*
* *options*.**className** - a class name, that defaults to a randomly generated string scoping the styles
* *options*.**width** - the legend’s width (in pixels)

Symbol legends are rendered as swatches and share the same options as above. In addition, symbol legends support the following additional options:

* *options*.**fill** - the symbol fill color
* *options*.**fillOpacity** - the symbol fill opacity; defaults to 1
* *options*.**stroke** - the symbol stroke color
* *options*.**strokeOpacity** - the symbol stroke opacity; defaults to 1
* *options*.**strokeWidth** - the symbol stroke width; defaults to 1.5
* *options*.**r** - the symbol radius; defaults to 4.5 pixels

The **fill** and **stroke** options can be specified as “color” to use the corresponding color encoding, for when the symbol encoding is redundant. The **fill** defaults to none. The **stroke** defaults to currentColor if the fill is none, and to none otherwise. The **fill** and **stroke** options may also be inherited from the corresponding options on an associated dot mark.

Continuous color legends are rendered as a ramp, and can be configured with the following options:

* *options*.**label** - the scale’s label
Expand Down Expand Up @@ -593,7 +606,7 @@ Channel values can also be specified as numbers for constant values, say for a f
Plot.area(aapl, {x1: "Date", y1: 0, y2: "Close"}).plot()
```

Missing and invalid data are handled specifically for each mark type and channel. Plot.dot will not generate circles with null, undefined or negative radius, or null or undefined coordinates. Similarly, Plot.line and Plot.area will stop the path before any invalid point and start again at the next valid point, thus creating interruptions rather than interpolating between valid points; Plot.link, Plot.rect will only create shapes where x1, x2, y1 and y2 are not null or undefined. Marks will not generate elements for null or undefined fill or stroke, stroke width, fill or stroke opacity. Titles will only be added if they are non-empty.
Missing and invalid data are handled specifically for each mark type and channel. In most cases, if the provided channel value for a given datum is null, undefined, or (strictly) NaN, the mark will implicitly filter the datum and not generate a corresponding output. In some cases, such as the radius (*r*) of a dot, the channel value must additionally be positive. Plot.line and Plot.area will stop the path before any invalid point and start again at the next valid point, thus creating interruptions rather than interpolating between valid points. Titles will only be added if they are non-empty.

All marks support the following style options:

Expand Down Expand Up @@ -783,17 +796,21 @@ Equivalent to [Plot.cell](#plotcelldata-options), except that if the **y** optio

[<img src="./img/dot.png" width="320" height="198" alt="a scatterplot">](https://observablehq.com/@observablehq/plot-dot)

[Source](./src/marks/dot.js) · [Examples](https://observablehq.com/@observablehq/plot-dot) · Draws circles (and in the future, possibly other symbols) as in a scatterplot.
[Source](./src/marks/dot.js) · [Examples](https://observablehq.com/@observablehq/plot-dot) · Draws circles, or other symbols, as in a scatterplot.

In addition to the [standard mark options](#marks), the following optional channels are supported:

* **x** - the horizontal position; bound to the *x* scale
* **y** - the vertical position; bound to the *y* scale
* **r** - the radius (area); bound to the *radius* scale, which defaults to *sqrt*
* **rotate** - the rotation angle in degrees clockwise; defaults to 0
* **symbol** - the categorical symbol; bound to the *symbol* scale; defaults to circle

If the **x** channel is not specified, dots will be horizontally centered in the plot (or facet). Likewise if the **y** channel is not specified, dots will vertically centered in the plot (or facet). Typically either *x*, *y*, or both are specified.
If the **x** channel is not specified, dots will be horizontally centered in the plot (or facet). Likewise if the **y** channel is not specified, dots will be vertically centered in the plot (or facet). Typically either *x*, *y*, or both are specified.

The **r** option defaults to three pixels and can be specified as either a channel or constant. When the radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. Dots with a nonpositive radius are not drawn. The **stroke** defaults to none. The **fill** defaults to currentColor if the stroke is none, and to none otherwise. The **strokeWidth** defaults to 1.5.
The **r** option can be specified as either a channel or constant. When the radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. The radius defaults to 4.5 pixels when using the **symbol** channel, and otherwise 3 pixels. Dots with a nonpositive radius are not drawn.

The **stroke** defaults to none. The **fill** defaults to currentColor if the stroke is none, and to none otherwise. The **strokeWidth** defaults to 1.5. The **rotate** and **symbol** options can be specified as either channels or constants. When rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When symbol is a valid symbol name or symbol object (implementing the draw method), it is interpreted as a constant; otherwise it is interpreted as a channel.

Dots are drawn in input order, with the last data drawn on top. If sorting is needed, say to mitigate overplotting by drawing the smallest dots on top, consider a [sort and reverse transform](#transforms).

Expand Down Expand Up @@ -834,7 +851,7 @@ In addition to the [standard mark options](#marks), the following optional chann
* **width** - the image width (in pixels)
* **height** - the image height (in pixels)

If the **x** channel is not specified, images will be horizontally centered in the plot (or facet). Likewise if the **y** channel is not specified, images will vertically centered in the plot (or facet). Typically either *x*, *y*, or both are specified.
If the **x** channel is not specified, images will be horizontally centered in the plot (or facet). Likewise if the **y** channel is not specified, images will be vertically centered in the plot (or facet). Typically either *x*, *y*, or both are specified.

The **width** and **height** options default to 16 pixels and can be specified as either a channel or constant. When the width or height is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. Images with a nonpositive width or height are not drawn. If a **width** is specified but not a **height**, or *vice versa*, the one defaults to the other. Images do not support either a fill or a stroke.

Expand Down Expand Up @@ -1028,7 +1045,7 @@ In addition to the [standard mark options](#marks), the following optional chann
* **x** - the horizontal position; bound to the *x* scale
* **y** - the vertical position; bound to the *y* scale
* **fontSize** - the font size in pixels
* **rotate** - the rotation in degrees clockwise
* **rotate** - the rotation angle in degrees clockwise

The following text-specific constant options are also supported:

Expand All @@ -1037,7 +1054,7 @@ 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
* **rotate** - the rotation in degrees clockwise; defaults to 0
* **rotate** - the rotation angle in degrees clockwise; defaults to 0

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.

Expand Down Expand Up @@ -1093,6 +1110,41 @@ The following optional channels are supported:

If the **x** channel is not specified, the tick will span the full vertical extent of the plot (or facet).

### Vector

[<img src="./img/vector.png" width="320" height="200" alt="a vector field">](https://observablehq.com/@observablehq/plot-vector)

[Source](./src/marks/vector.js) · [Examples](https://observablehq.com/@observablehq/plot-vector) · Draws little arrows as in a vector field.

In addition to the [standard mark options](#marks), the following optional channels are supported:

* **x** - the horizontal position; bound to the *x* scale
* **y** - the vertical position; bound to the *y* scale
* **length** - the length in pixels; bound to the *length* scale; defaults to 12
* **rotate** - the rotation angle in degrees clockwise; defaults to 0

The following options are also supported:

* **anchor** - one of *start*, *middle*, or *end*; defaults to *middle*

If the **anchor** is *start*, the arrow will start at the given *xy* position and point in the direction given by the rotation angle. If the **anchor** is *end*, the arrow will maintain the same orientation, but be positioned such that it ends in the given *xy* position. If the **anchor** is *middle*, the arrow will be likewise be positioned such that its midpoint intersects the given *xy* position.

If the **x** channel is not specified, vectors will be horizontally centered in the plot (or facet). Likewise if the **y** channel is not specified, vectors will be vertically centered in the plot (or facet). Typically either *x*, *y*, or both are specified.

The **rotate** and **length** options can be specified as either channels or constants. When specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. The length defaults to 12 pixels, and the rotate defaults to 0 degrees (pointing up↑). Vectors with a negative length will be drawn inverted. Positive angles proceed clockwise from noon.

The **stroke** defaults to currentColor. The **strokeWidth** defaults to 1.5, and the **strokeLinecap** defaults to *round*.

Vectors are drawn in input order, with the last data drawn on top. If sorting is needed, say to mitigate overplotting by drawing the smallest vectors on top, consider a [sort and reverse transform](#transforms).

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

```js
Plot.vector(wind, {x: "longitude", y: "latitude", length: "speed", rotate: "direction"})
```

Returns a new vector with the given *data* and *options*. If neither the **x** nor **y** options are specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] such that **x** = [*x₀*, *x₁*, *x₂*, …] and **y** = [*y₀*, *y₁*, *y₂*, …].

### Plot.marks(...*marks*)

A convenience method for composing a mark from a series of other marks. Returns an array of marks that implements the *mark*.plot function.
Expand Down
Binary file added img/vector.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"snowpack": "3"
},
"dependencies": {
"d3": "7",
"d3": "^7.3.0",
"isoformat": "0.2"
},
"engines": {
Expand Down
3 changes: 1 addition & 2 deletions src/axis.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {axisTop, axisBottom, axisRight, axisLeft, create, format, utcFormat} from "d3";
import {formatIsoDate} from "./format.js";
import {boolean, take, number, string, keyword, maybeKeyword, constant, isTemporal} from "./mark.js";
import {radians} from "./math.js";
import {impliedString} from "./style.js";

export class AxisX {
Expand Down Expand Up @@ -234,8 +235,6 @@ function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) {
.tickValues(Array.isArray(ticks) ? ticks : null);
}

const radians = Math.PI / 180;

function maybeTickRotate(g, rotate) {
if (!(rotate = +rotate)) return;
for (const text of g.selectAll("text")) {
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {Rect, rect, rectX, rectY} from "./marks/rect.js";
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
export {Text, text, textX, textY} from "./marks/text.js";
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
export {Vector, vector} from "./marks/vector.js";
export {filter} from "./transforms/filter.js";
export {reverse} from "./transforms/reverse.js";
export {sort, shuffle} from "./transforms/sort.js";
Expand Down
40 changes: 35 additions & 5 deletions src/legends.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import {rgb} from "d3";
import {normalizeScale} from "./scales.js";
import {legendColor} from "./legends/color.js";
import {legendOpacity} from "./legends/opacity.js";
import {legendRamp} from "./legends/ramp.js";
import {legendSwatches, legendSymbols} from "./legends/swatches.js";
import {isObject} from "./mark.js";

const legendRegistry = new Map([
["color", legendColor],
["symbol", legendSymbols],
["opacity", legendOpacity]
]);

export function legend(options = {}) {
for (const [key, value] of legendRegistry) {
const scale = options[key];
if (isObject(scale)) { // e.g., ignore {color: "red"}
return value(normalizeScale(key, scale), legendOptions(scale, options));
return value(normalizeScale(key, scale), legendOptions(scale, options), key => isObject(options[key]) ? normalizeScale(key, options[key]) : null);
}
}
throw new Error("unknown legend type");
Expand All @@ -22,20 +24,48 @@ export function exposeLegends(scales, defaults = {}) {
return (key, options) => {
if (!legendRegistry.has(key)) throw new Error(`unknown legend type: ${key}`);
if (!(key in scales)) return;
return legendRegistry.get(key)(scales[key], legendOptions(defaults[key], options));
return legendRegistry.get(key)(scales[key], legendOptions(defaults[key], options), key => scales[key]);
};
}

function legendOptions({label, ticks, tickFormat} = {}, options = {}) {
return {label, ticks, tickFormat, ...options};
}

function legendColor(color, {
legend = true,
...options
}) {
if (legend === true) legend = color.type === "ordinal" ? "swatches" : "ramp";
switch (`${legend}`.toLowerCase()) {
case "swatches": return legendSwatches(color, options);
case "ramp": return legendRamp(color, options);
default: throw new Error(`unknown legend type: ${legend}`);
}
}

function legendOpacity({type, interpolate, ...scale}, {
legend = true,
color = rgb(0, 0, 0),
...options
}) {
if (!interpolate) throw new Error(`${type} opacity scales are not supported`);
if (legend === true) legend = "ramp";
if (`${legend}`.toLowerCase() !== "ramp") throw new Error(`${legend} opacity legends are not supported`);
return legendColor({type, ...scale, interpolate: interpolateOpacity(color)}, {legend, ...options});
}

function interpolateOpacity(color) {
const {r, g, b} = rgb(color) || rgb(0, 0, 0); // treat invalid color as black
return t => `rgba(${r},${g},${b},${t})`;
}

export function Legends(scales, options) {
const legends = [];
for (const [key, value] of legendRegistry) {
const o = options[key];
if (o && o.legend) {
legends.push(value(scales[key], legendOptions(scales[key], o)));
legends.push(value(scales[key], legendOptions(scales[key], o), key => scales[key]));
}
}
return legends;
Expand Down
14 changes: 0 additions & 14 deletions src/legends/color.js

This file was deleted.

20 changes: 0 additions & 20 deletions src/legends/opacity.js

This file was deleted.

Loading

0 comments on commit 7f39a77

Please sign in to comment.