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 + +[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 [a line chart](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 @@ + + + + + + −30 + + + + −20 + + + + −10 + + + + +0 + + + + +10 + + + + +20 + + + + +30 + + + + +40 + + + + +50 + + + + +60 + + + + +70 + Net favorability (%) + + + + 1800 + + + 1820 + + + 1840 + + + 1860 + + + 1880 + + + 1900 + + + 1920 + + + 1940 + + + 1960 + + + 1980 + + + 2000 + + + 2020 + Date of first inauguration + + + + + + + George Washington + + + John Adams + + + Thomas Jefferson + + + James Madison + + + James Monroe + + + John Quincy Adams + + + Andrew Jackson + + + Martin Van Buren + + + William Henry Harrison + + + John Tyler + + + James K. Polk + + + Zachary Taylor + + + Millard Fillmore + + + Franklin Pierce + + + James Buchanan + + + Abraham Lincoln + + + Andrew Johnson + + + Ulysses S. Grant + + + Rutherford B. Hayes + + + James A. Garfield + + + Chester A. Arthur + + + Grover Cleveland + + + Benjamin Harrison + + + William McKinley + + + Theodore Roosevelt + + + William Howard Taft + + + Woodrow Wilson + + + Warren G. Harding + + + Calvin Coolidge + + + Herbert Hoover + + + Franklin D. Roosevelt + + + Harry S. Truman + + + Dwight D. Eisenhower + + + John F. Kennedy + + + Lyndon B. Johnson + + + Richard Nixon + + + Gerald Ford + + + Jimmy Carter + + + Ronald Reagan + + + George H. W. Bush + + + Bill Clinton + + + George W. Bush + + + Barack Obama + + + Donald Trump + + + Joe Biden + + + \ 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" + } + ) + ] + }); +}