diff --git a/README.md b/README.md
index 4cc9bfd33c..e126c8346e 100644
--- a/README.md
+++ b/README.md
@@ -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:
@@ -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
@@ -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)
@@ -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:
@@ -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
+
+[](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
[](https://observablehq.com/@observablehq/plot-line)
diff --git a/img/image.png b/img/image.png
new file mode 100644
index 0000000000..829305ea23
Binary files /dev/null and b/img/image.png differ
diff --git a/src/index.js b/src/index.js
index 74bb524473..5044ad5d03 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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";
diff --git a/src/marks/image.js b/src/marks/image.js
new file mode 100644
index 0000000000..42170c1b49
--- /dev/null
+++ b/src/marks/image.js
@@ -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});
+}
diff --git a/src/style.js b/src/style.js
index 8ea03c920c..625bebd201 100644
--- a/src/style.js
+++ b/src/style.js
@@ -16,6 +16,7 @@ export function styles(
strokeLinecap,
strokeMiterlimit,
strokeDasharray,
+ opacity,
mixBlendMode,
shapeRendering
},
@@ -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
@@ -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.
@@ -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");
@@ -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);
}
@@ -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) {
diff --git a/test/data/README.md b/test/data/README.md
index a4fd243e53..5baf12df93 100644
--- a/test/data/README.md
+++ b/test/data/README.md
@@ -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
diff --git a/test/data/us-president-favorability.csv b/test/data/us-president-favorability.csv
new file mode 100644
index 0000000000..ca515281c5
--- /dev/null
+++ b/test/data/us-president-favorability.csv
@@ -0,0 +1,46 @@
+Name,Very Favorable %,Somewhat Favorable %,Somewhat Unfavorable %,Very Unfavorable %,Don’t know %,Have not heard of them %,First Inauguration Date,Portrait URL
+George Washington,44,26,6,4,18,3,1789-04-30,https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Gilbert_Stuart_Williamstown_Portrait_of_George_Washington.jpg/160px-Gilbert_Stuart_Williamstown_Portrait_of_George_Washington.jpg
+John Adams,16,30,7,4,37,5,1797-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/John_Adams%2C_Gilbert_Stuart%2C_c1800_1815.jpg/160px-John_Adams%2C_Gilbert_Stuart%2C_c1800_1815.jpg
+Thomas Jefferson,28,34,10,5,23,1,1801-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Thomas_Jefferson_by_Rembrandt_Peale%2C_1800.jpg/160px-Thomas_Jefferson_by_Rembrandt_Peale%2C_1800.jpg
+James Madison,12,27,5,4,43,9,1809-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/1/1d/James_Madison.jpg/160px-James_Madison.jpg
+James Monroe,8,21,8,4,49,10,1817-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/James_Monroe_White_House_portrait_1819.jpg/160px-James_Monroe_White_House_portrait_1819.jpg
+John Quincy Adams,13,31,5,4,41,6,1825-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/JQA_Photo.tif/lossy-page1-160px-JQA_Photo.tif.jpg
+Andrew Jackson,11,23,12,17,32,5,1829-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Andrew_jackson_head.jpg/165px-Andrew_jackson_head.jpg
+Martin Van Buren,3,12,10,4,54,18,1837-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Martin_Van_Buren_edit.jpg/160px-Martin_Van_Buren_edit.jpg
+William Henry Harrison,3,15,9,5,55,14,1841-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/William_Henry_Harrison_daguerreotype_edit.jpg/160px-William_Henry_Harrison_daguerreotype_edit.jpg
+John Tyler,3,11,10,4,51,21,1841-04-04,https://upload.wikimedia.org/wikipedia/commons/thumb/1/1d/John_Tyler%2C_Jr.jpg/160px-John_Tyler%2C_Jr.jpg
+James K. Polk,4,12,9,8,55,13,1845-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/JKP.jpg/160px-JKP.jpg
+Zachary Taylor,2,14,10,3,53,18,1849-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Zachary_Taylor_restored_and_cropped.jpg/160px-Zachary_Taylor_restored_and_cropped.jpg
+Millard Fillmore,1,10,9,5,53,21,1850-07-09,https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Fillmore.jpg/160px-Fillmore.jpg
+Franklin Pierce,3,12,10,5,53,18,1853-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Mathew_Brady_-_Franklin_Pierce_-_alternate_crop_%28cropped%29.jpg/160px-Mathew_Brady_-_Franklin_Pierce_-_alternate_crop_%28cropped%29.jpg
+James Buchanan,3,12,12,9,52,14,1857-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/f/fd/James_Buchanan.jpg/160px-James_Buchanan.jpg
+Abraham Lincoln,56,24,6,3,11,1,1861-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Abraham_Lincoln_O-77_matte_collodion_print.jpg/160px-Abraham_Lincoln_O-77_matte_collodion_print.jpg
+Andrew Johnson,6,17,13,12,43,8,1865-04-15,https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/Andrew_Johnson_photo_portrait_head_and_shoulders%2C_c1870-1880-Edit1.jpg/160px-Andrew_Johnson_photo_portrait_head_and_shoulders%2C_c1870-1880-Edit1.jpg
+Ulysses S. Grant,14,30,9,6,34,7,1869-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Ulysses_S_Grant_by_Brady_c1870-restored.jpg/160px-Ulysses_S_Grant_by_Brady_c1870-restored.jpg
+Rutherford B. Hayes,4,14,9,6,54,13,1877-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/President_Rutherford_Hayes_1870_-_1880_Restored.jpg/160px-President_Rutherford_Hayes_1870_-_1880_Restored.jpg
+James A. Garfield,4,16,9,5,54,12,1881-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/1/1f/James_Abram_Garfield%2C_photo_portrait_seated.jpg/160px-James_Abram_Garfield%2C_photo_portrait_seated.jpg
+Chester A. Arthur,3,10,8,5,48,27,1881-09-19,https://upload.wikimedia.org/wikipedia/commons/thumb/7/79/Chester_Alan_Arthur.jpg/160px-Chester_Alan_Arthur.jpg
+Grover Cleveland,4,17,12,6,47,14,1885-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/Grover_Cleveland_-_NARA_-_518139_%28cropped%29.jpg/160px-Grover_Cleveland_-_NARA_-_518139_%28cropped%29.jpg
+Benjamin Harrison,3,13,10,4,54,15,1889-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Benjamin_Harrison%2C_head_and_shoulders_bw_photo%2C_1896.jpg/160px-Benjamin_Harrison%2C_head_and_shoulders_bw_photo%2C_1896.jpg
+William McKinley,2,16,11,4,53,14,1897-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Mckinley.jpg/160px-Mckinley.jpg
+Theodore Roosevelt,25,37,8,4,23,3,1901-09-14,https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/President_Roosevelt_-_Pach_Bros.jpg/160px-President_Roosevelt_-_Pach_Bros.jpg
+William Howard Taft,3,18,13,6,52,8,1909-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/William_Howard_Taft_-_Harris_and_Ewing.jpg/160px-William_Howard_Taft_-_Harris_and_Ewing.jpg
+Woodrow Wilson,7,25,14,11,39,4,1913-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Thomas_Woodrow_Wilson%2C_Harris_%26_Ewing_bw_photo_portrait%2C_1919.jpg/160px-Thomas_Woodrow_Wilson%2C_Harris_%26_Ewing_bw_photo_portrait%2C_1919.jpg
+Warren G. Harding,3,12,12,11,46,16,1921-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/c/c4/Warren_G_Harding-Harris_%26_Ewing.jpg/160px-Warren_G_Harding-Harris_%26_Ewing.jpg
+Calvin Coolidge,6,18,11,8,47,10,1923-08-02,https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Calvin_Coolidge_cph.3g10777_%28cropped%29.jpg/160px-Calvin_Coolidge_cph.3g10777_%28cropped%29.jpg
+Herbert Hoover,5,20,16,14,37,8,1929-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/President_Hoover_portrait.jpg/165px-President_Hoover_portrait.jpg
+Franklin D. Roosevelt,26,32,9,8,22,3,1933-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/FDR_1944_Color_Portrait.jpg/160px-FDR_1944_Color_Portrait.jpg
+Harry S. Truman,17,34,10,4,28,5,1945-04-12,https://upload.wikimedia.org/wikipedia/commons/thumb/3/39/TRUMAN_58-766-06_CROPPED.jpg/160px-TRUMAN_58-766-06_CROPPED.jpg
+Dwight D. Eisenhower,22,36,7,4,25,7,1953-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/6/63/Dwight_D._Eisenhower%2C_official_photo_portrait%2C_May_29%2C_1959.jpg/160px-Dwight_D._Eisenhower%2C_official_photo_portrait%2C_May_29%2C_1959.jpg
+John F. Kennedy,35,38,10,4,12,2,1961-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/John_F._Kennedy%2C_White_House_color_photo_portrait.jpg/160px-John_F._Kennedy%2C_White_House_color_photo_portrait.jpg
+Lyndon B. Johnson,9,28,18,17,21,7,1963-11-22,https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/37_Lyndon_Johnson_3x4.jpg/160px-37_Lyndon_Johnson_3x4.jpg
+Richard Nixon,8,19,23,34,14,3,1969-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/2/2c/Richard_Nixon_presidential_portrait_%281%29.jpg/160px-Richard_Nixon_presidential_portrait_%281%29.jpg
+Gerald Ford,7,34,22,8,25,4,1974-08-09,https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Gerald_Ford_presidential_portrait.jpg/160px-Gerald_Ford_presidential_portrait.jpg
+Jimmy Carter,23,22,12,20,18,5,1977-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/5/5a/JimmyCarterPortrait2.jpg/160px-JimmyCarterPortrait2.jpg
+Ronald Reagan,31,23,14,17,12,3,1981-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/1/16/Official_Portrait_of_President_Reagan_1981.jpg/165px-Official_Portrait_of_President_Reagan_1981.jpg
+George H. W. Bush,11,33,27,17,11,1,1989-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/e/ee/George_H._W._Bush_presidential_portrait_%28cropped%29.jpg/160px-George_H._W._Bush_presidential_portrait_%28cropped%29.jpg
+Bill Clinton,15,30,20,22,10,2,1993-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Bill_Clinton.jpg/160px-Bill_Clinton.jpg
+George W. Bush,10,32,24,19,11,4,2001-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/George-W-Bush.jpeg/160px-George-W-Bush.jpeg
+Barack Obama,36,18,10,31,4,1,2009-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Official_portrait_of_Barack_Obama.jpg/160px-Official_portrait_of_Barack_Obama.jpg
+Donald Trump,23,16,7,47,5,1,2017-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Donald_Trump_official_portrait.jpg/160px-Donald_Trump_official_portrait.jpg
+Joe Biden,26,21,9,35,6,2,2021-01-20,https://upload.wikimedia.org/wikipedia/commons/thumb/6/68/Joe_Biden_presidential_portrait.jpg/160px-Joe_Biden_presidential_portrait.jpg
\ No newline at end of file
diff --git a/test/marks/image-test.js b/test/marks/image-test.js
new file mode 100644
index 0000000000..2a9f51dfd2
--- /dev/null
+++ b/test/marks/image-test.js
@@ -0,0 +1,55 @@
+import * as Plot from "@observablehq/plot";
+import assert from "assert";
+
+it("image(undefined, {src}) has the expected defaults", () => {
+ const image = Plot.image(undefined, {src: "foo"});
+ assert.strictEqual(image.data, undefined);
+ assert.strictEqual(image.transform, undefined);
+ assert.deepStrictEqual(image.channels.map(c => c.name), ["x", "y", "src"]);
+ assert.deepStrictEqual(image.channels.map(c => Plot.valueof([[1, 2], [3, 4]], c.value)), [[1, 3], [2, 4], [undefined, undefined]]);
+ assert.deepStrictEqual(image.channels.map(c => c.scale), ["x", "y", undefined]);
+ assert.strictEqual(image.width, 16);
+ assert.strictEqual(image.height, 16);
+ assert.strictEqual(image.preserveAspectRatio, undefined);
+ assert.strictEqual(image.crossOrigin, undefined);
+});
+
+it("image(data, {width, height, src}) allows width and height to be a constant amount", () => {
+ const image = Plot.image(undefined, {width: 42, height: 43, src: "foo"});
+ assert.strictEqual(image.width, 42);
+ assert.strictEqual(image.height, 43);
+});
+
+it("image(data, {width, height, src}) allows width and height to be a variable amount", () => {
+ const image = Plot.image(undefined, {width: "x", height: "y", src: "foo"});
+ assert.strictEqual(image.width, undefined);
+ assert.strictEqual(image.height, undefined);
+ const width = image.channels.find(c => c.name === "width");
+ const height = image.channels.find(c => c.name === "height");
+ assert.strictEqual(width.value, "x");
+ assert.strictEqual(height.value, "y");
+});
+
+it("image(data, {title, src}) specifies an optional title channel", () => {
+ const image = Plot.image(undefined, {title: "x", src: "foo"});
+ const title = image.channels.find(c => c.name === "title");
+ assert.strictEqual(title.value, "x");
+ assert.strictEqual(title.scale, undefined);
+});
+
+it("image(data, {src}) allows src to be a constant", () => {
+ assert.strictEqual(Plot.image(undefined, {src: "./foo.png"}).src, "./foo.png");
+ assert.strictEqual(Plot.image(undefined, {src: "../foo.png"}).src, "../foo.png");
+ assert.strictEqual(Plot.image(undefined, {src: "/foo.png"}).src, "/foo.png");
+ assert.strictEqual(Plot.image(undefined, {src: "https://example.com/foo.png"}).src, "https://example.com/foo.png");
+ assert.strictEqual(Plot.image(undefined, {src: "http://example.com/foo.png"}).src, "http://example.com/foo.png");
+ assert.strictEqual(Plot.image(undefined, {src: "blob:https://login.worker.test:5000/67f16cef-373a-4019-aefe-d4d68937e5fa"}).src, "blob:https://login.worker.test:5000/67f16cef-373a-4019-aefe-d4d68937e5fa");
+ assert.strictEqual(Plot.image(undefined, {src: "data:image/png;base64,=="}).src, "data:image/png;base64,==");
+});
+
+it("image(data, {src}) allows src to be a channel", () => {
+ const image = Plot.image(undefined, {src: "foo"});
+ const src = image.channels.find(c => c.name === "src");
+ assert.strictEqual(src.value, "foo");
+ assert.strictEqual(src.scale, undefined);
+});
diff --git a/test/output/usPresidentFavorabilityDots.svg b/test/output/usPresidentFavorabilityDots.svg
new file mode 100644
index 0000000000..766ea3b811
--- /dev/null
+++ b/test/output/usPresidentFavorabilityDots.svg
@@ -0,0 +1,239 @@
+
\ No newline at end of file
diff --git a/test/plots/index.js b/test/plots/index.js
index 584a3ee17f..40847d691b 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -120,6 +120,7 @@ export {default as usCongressAge} from "./us-congress-age.js";
export {default as usCongressAgeGender} from "./us-congress-age-gender.js";
export {default as usPopulationStateAge} from "./us-population-state-age.js";
export {default as usPopulationStateAgeDots} from "./us-population-state-age-dots.js";
+export {default as usPresidentFavorabilityDots} from "./us-president-favorability-dots.js";
export {default as usPresidentialElection2020} from "./us-presidential-election-2020.js";
export {default as usPresidentialForecast2016} from "./us-presidential-forecast-2016.js";
export {default as usRetailSales} from "./us-retail-sales.js";
diff --git a/test/plots/us-president-favorability-dots.js b/test/plots/us-president-favorability-dots.js
new file mode 100644
index 0000000000..b59cbe8ec7
--- /dev/null
+++ b/test/plots/us-president-favorability-dots.js
@@ -0,0 +1,33 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function() {
+ const data = await d3.csv("data/us-president-favorability.csv", d3.autoType);
+ return Plot.plot({
+ inset: 30,
+ width: 960,
+ height: 600,
+ x: {
+ label: "Date of first inauguration"
+ },
+ y: {
+ grid: true,
+ label: "Net favorability (%)",
+ percent: true,
+ tickFormat: "+f"
+ },
+ marks: [
+ Plot.ruleY([0]),
+ Plot.image(
+ data,
+ {
+ x: "First Inauguration Date",
+ y: d => (d["Very Favorable %"] + d["Somewhat Favorable %"] - d["Very Unfavorable %"] - d["Somewhat Unfavorable %"]) / 100,
+ width: 60,
+ src: "Portrait URL",
+ title: "Name"
+ }
+ )
+ ]
+ });
+}