Skip to content

Image mark #599

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 20 commits into from
Dec 1, 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
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ Given a scale definition, Plot can generate a legend.

#### *chart*.legend(*name*[, *options*])

Returns a suitable legend for the chart’s scale with the given *name*. For now, only *color* legends are supported.
Returns a suitable legend for the chart’s scale with the given *name*. Currently supports only *color* and *opacity* scales. An opacity scale is treated as a color scale with varying transparency.

Categorical and ordinal color legends are rendered as swatches, unless *options*.**legend** is set to *ramp*. The swatches can be configured with the following options:

Expand Down Expand Up @@ -259,7 +259,7 @@ Continuous color legends are rendered as a ramp, and can be configured with the

#### Plot.legend({[*name*]: *scale*, ...*options*})

Returns a legend for the given *scale* definition, passing the options described in the previous section. Currently supports only *color* and *opacity* scales. An opacity scale is treated as a color scale with varying transparency.
Returns a legend for the given *scale* definition, passing the options described in the previous section.

### Position options

Expand Down Expand Up @@ -583,6 +583,7 @@ All marks support the following style options:
* **strokeLinecap** - how to cap lines (*butt*, *round*, or *square*)
* **strokeMiterlimit** - to limit the length of *miter* joins
* **strokeDasharray** - a comma-separated list of dash lengths (in pixels)
* **opacity** - object opacity (a number between 0 and 1)
* **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)
Expand All @@ -597,9 +598,10 @@ All marks support the following optional channels:
* **stroke** - a stroke color; bound to the *color* scale
* **strokeOpacity** - a stroke opacity; bound to the *opacity* scale
* **strokeWidth** - a stroke width (in pixels)
* **opacity** - an object opacity; bound to the *opacity* scale
* **title** - a tooltip (a string of text, possibly with newlines)

The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, and **strokeOpacity** options can be specified as either channels or constants. When the fill or stroke is specified as a function or array, it is interpreted as a channel; when the fill or stroke is specified as a string, it is interpreted as a constant if a valid CSS color and otherwise it is interpreted as a column name for a channel. Similarly when the fill or stroke opacity or the stroke width is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When the radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel.
The **fill**, **fillOpacity**, **stroke**, **strokeWidth**, **strokeOpacity**, and **opacity** options can be specified as either channels or constants. When the fill or stroke is specified as a function or array, it is interpreted as a channel; when the fill or stroke is specified as a string, it is interpreted as a constant if a valid CSS color and otherwise it is interpreted as a column name for a channel. Similarly when the fill opacity, stroke opacity, object opacity, stroke width, or radius is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel.

The rectangular marks ([bar](#bar), [cell](#cell), and [rect](#rect)) support insets and rounded corner constant options:

Expand Down Expand Up @@ -794,6 +796,35 @@ Plot.dotY(cars.map(d => d["economy (mpg)"]))

Equivalent to [Plot.dot](#plotdotdata-options) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …].

### Image

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

[Source](./src/marks/image.js) · [Examples](https://observablehq.com/@observablehq/plot-image) · Draws images as in a scatterplot. The required **src** option specifies the URL (or relative path) of each image. If **src** is specified as a string that starts with a dot, slash, or URL protocol (*e.g.*, “https:”) it is assumed to be a constant; otherwise it is interpreted as a channel.

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

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

The **preserveAspectRatio** and **crossOrigin** options, both constant, allow control over the [aspect ratio](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio) and [cross-origin](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/crossorigin) behavior, respectively. The default aspect ratio behavior is “xMidYMid meet”; consider “xMidYMid slice” to crop the image instead of scaling it to fit.

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

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

```js
Plot.image(presidents, {x: "inauguration", y: "favorability", src: "portrait"})
```

Returns a new image 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₂*, …].

### Line

[<img src="./img/line.png" width="320" height="198" alt="a line chart">](https://observablehq.com/@observablehq/plot-line)
Expand Down
Binary file added img/image.png
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 src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {BarX, BarY, barX, barY} from "./marks/bar.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
export {Frame, frame} from "./marks/frame.js";
export {Image, image} from "./marks/image.js";
export {Line, line, lineX, lineY} from "./marks/line.js";
export {Link, link} from "./marks/link.js";
export {Rect, rect, rectX, rectY} from "./marks/rect.js";
Expand Down
93 changes: 93 additions & 0 deletions src/marks/image.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {create} from "d3";
import {filter, positive} from "../defined.js";
import {Mark, maybeNumber, maybeTuple, string} from "../mark.js";
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr, offset, impliedString} from "../style.js";

const defaults = {
fill: null,
stroke: null
};

// Tests if the given string is a path: does it start with a dot-slash
// (./foo.png), dot-dot-slash (../foo.png), or slash (/foo.png)?
function isPath(string) {
return /^\.*\//.test(string);
}

// Tests if the given string is a URL (e.g., https://placekitten.com/200/300).
// The allowed protocols is overly restrictive, but we don’t want to allow any
// scheme here because it would increase the likelihood of a false positive with
// a field name that happens to contain a colon.
function isUrl(string) {
return /^(blob|data|file|http|https):/i.test(string);
}

// Disambiguates a constant src definition from a channel. A path or URL string
// is assumed to be a constant; any other string is assumed to be a field name.
function maybePath(value) {
return typeof value === "string" && (isPath(value) || isUrl(value))
? [undefined, value]
: [value, undefined];
}

export class Image extends Mark {
constructor(data, options = {}) {
let {x, y, width, height, src, preserveAspectRatio, crossOrigin} = options;
if (width === undefined && height !== undefined) width = height;
else if (height === undefined && width !== undefined) height = width;
const [vs, cs] = maybePath(src);
const [vw, cw] = maybeNumber(width, 16);
const [vh, ch] = maybeNumber(height, 16);
super(
data,
[
{name: "x", value: x, scale: "x", optional: true},
{name: "y", value: y, scale: "y", optional: true},
{name: "width", value: vw, optional: true},
{name: "height", value: vh, optional: true},
{name: "src", value: vs, optional: true}
],
options,
defaults
);
this.src = cs;
this.width = cw;
this.height = ch;
this.preserveAspectRatio = impliedString(preserveAspectRatio, "xMidYMid");
this.crossOrigin = string(crossOrigin);
}
render(
I,
{x, y},
channels,
{width, height, marginTop, marginRight, marginBottom, marginLeft}
) {
const {x: X, y: Y, width: W, height: H, src: S} = channels;
let index = filter(I, X, Y, S);
if (W) index = index.filter(i => positive(W[i]));
if (H) index = index.filter(i => positive(H[i]));
const cx = (marginLeft + width - marginRight) / 2;
const cy = (marginTop + height - marginBottom) / 2;
return create("svg:g")
.call(applyIndirectStyles, this)
.call(applyTransform, x, y, offset, offset)
.call(g => g.selectAll()
.data(index)
.join("image")
.call(applyDirectStyles, this)
.attr("x", W && X ? i => X[i] - W[i] / 2 : W ? i => cx - W[i] / 2 : X ? i => X[i] - this.width / 2 : cx - this.width / 2)
.attr("y", H && Y ? i => Y[i] - H[i] / 2 : H ? i => cy - H[i] / 2 : Y ? i => Y[i] - this.height / 2 : cy - this.height / 2)
.attr("width", W ? i => W[i] : this.width)
.attr("height", H ? i => H[i] : this.height)
.call(applyAttr, "href", S ? i => S[i] : this.src)
.call(applyAttr, "preserveAspectRatio", this.preserveAspectRatio)
.call(applyAttr, "crossorigin", this.crossOrigin)
.call(applyChannelStyles, channels))
.node();
}
}

export function image(data, {x, y, ...options} = {}) {
([x, y] = maybeTuple(x, y));
return new Image(data, {...options, x, y});
}
37 changes: 27 additions & 10 deletions src/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function styles(
strokeLinecap,
strokeMiterlimit,
strokeDasharray,
opacity,
mixBlendMode,
shapeRendering
},
Expand All @@ -35,6 +36,12 @@ export function styles(
fillOpacity = null;
}

// Some marks don’t support stroke (e.g., image).
if (defaultStroke === null) {
stroke = null;
strokeOpacity = null;
}

// Some marks default to fill with no stroke, while others default to stroke
// with no fill. For example, bar and area default to fill, while dot and line
// default to stroke. For marks that fill by default, the default fill only
Expand All @@ -51,6 +58,7 @@ export function styles(
const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity);
const [vstroke, cstroke] = maybeColor(stroke, defaultStroke);
const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity);
const [vopacity, copacity] = maybeNumber(opacity);

// For styles that have no effect if there is no stroke, only apply the
// defaults if the stroke is not (constant) none.
Expand All @@ -68,13 +76,18 @@ export function styles(
mark.fillOpacity = impliedNumber(cfillOpacity, 1);
}

mark.stroke = impliedString(cstroke, "none");
mark.strokeWidth = impliedNumber(cstrokeWidth, 1);
mark.strokeOpacity = impliedNumber(cstrokeOpacity, 1);
mark.strokeLinejoin = impliedString(strokeLinejoin, "miter");
mark.strokeLinecap = impliedString(strokeLinecap, "butt");
mark.strokeMiterlimit = impliedNumber(strokeMiterlimit, 4);
mark.strokeDasharray = string(strokeDasharray);
// Some marks don’t support stroke (e.g., image).
if (defaultStroke !== null) {
mark.stroke = impliedString(cstroke, "none");
mark.strokeWidth = impliedNumber(cstrokeWidth, 1);
mark.strokeOpacity = impliedNumber(cstrokeOpacity, 1);
mark.strokeLinejoin = impliedString(strokeLinejoin, "miter");
mark.strokeLinecap = impliedString(strokeLinecap, "butt");
mark.strokeMiterlimit = impliedNumber(strokeMiterlimit, 4);
mark.strokeDasharray = string(strokeDasharray);
}

mark.opacity = impliedNumber(copacity, 1);
mark.mixBlendMode = impliedString(mixBlendMode, "normal");
mark.shapeRendering = impliedString(shapeRendering, "auto");

Expand All @@ -85,25 +98,28 @@ export function styles(
{name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true},
{name: "stroke", value: vstroke, scale: "color", optional: true},
{name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true},
{name: "strokeWidth", value: vstrokeWidth, optional: true}
{name: "strokeWidth", value: vstrokeWidth, optional: true},
{name: "opacity", value: vopacity, scale: "opacity", optional: true}
];
}

export function applyChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW}) {
export function applyChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O}) {
applyAttr(selection, "fill", F && (i => F[i]));
applyAttr(selection, "fill-opacity", FO && (i => FO[i]));
applyAttr(selection, "stroke", S && (i => S[i]));
applyAttr(selection, "stroke-opacity", SO && (i => SO[i]));
applyAttr(selection, "stroke-width", SW && (i => SW[i]));
applyAttr(selection, "opacity", O && (i => O[i]));
title(L)(selection);
}

export function applyGroupedChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW}) {
export function applyGroupedChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O}) {
applyAttr(selection, "fill", F && (([i]) => F[i]));
applyAttr(selection, "fill-opacity", FO && (([i]) => FO[i]));
applyAttr(selection, "stroke", S && (([i]) => S[i]));
applyAttr(selection, "stroke-opacity", SO && (([i]) => SO[i]));
applyAttr(selection, "stroke-width", SW && (([i]) => SW[i]));
applyAttr(selection, "opacity", O && (([i]) => O[i]));
titleGroup(L)(selection);
}

Expand All @@ -122,6 +138,7 @@ export function applyIndirectStyles(selection, mark) {

export function applyDirectStyles(selection, mark) {
applyStyle(selection, "mix-blend-mode", mark.mixBlendMode);
applyAttr(selection, "opacity", mark.opacity);
}

export function applyAttr(selection, name, value) {
Expand Down
5 changes: 5 additions & 0 deletions test/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ https://www.tsa.gov/coronavirus/passenger-throughput
U.S. Census Bureau
https://observablehq.com/@d3/barcode-plot

## us-president-favorability.csv
YouGov (polling data) and Wikipedia (presidential portraits)
https://today.yougov.com/topics/politics/articles-reports/2021/07/27/most-and-least-popular-us-presidents-according-ame
https://en.wikipedia.org/wiki/List_of_presidents_of_the_United_States

## us-presidential-election-2020.csv
Fabio Votta/Edison Research/NYT
https://github.com/favstats/USElection2020-EdisonResearch-Results
Expand Down
Loading