Skip to content

Commit badc7ec

Browse files
mbostockRLesser
andauthored
Image mark (#599)
* Create Image mark * Added snapshot test result * Adding US President data example * Removed NBA data example, regenerated snapshots * Removed imageX and imageY * Changed size to r * Updated image and image-test to 0.2.0 * Added preserveAspectRatio and crossorigin to image * Fix for new attrs * fix test snapshot * fold transform into xy; crossOrigin; style * width, height, src * implied xMidYMid * default width to height * ignore fill and stroke on images * opacity * allow constant src * Update README * Update README * Update README Co-authored-by: Robert Lesser <[email protected]>
1 parent 7289b58 commit badc7ec

File tree

11 files changed

+534
-13
lines changed

11 files changed

+534
-13
lines changed

README.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ Given a scale definition, Plot can generate a legend.
231231

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

234-
Returns a suitable legend for the chart’s scale with the given *name*. For now, only *color* legends are supported.
234+
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.
235235

236236
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:
237237

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

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

262-
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.
262+
Returns a legend for the given *scale* definition, passing the options described in the previous section.
263263

264264
### Position options
265265

@@ -583,6 +583,7 @@ All marks support the following style options:
583583
* **strokeLinecap** - how to cap lines (*butt*, *round*, or *square*)
584584
* **strokeMiterlimit** - to limit the length of *miter* joins
585585
* **strokeDasharray** - a comma-separated list of dash lengths (in pixels)
586+
* **opacity** - object opacity (a number between 0 and 1)
586587
* **mixBlendMode** - the [blend mode](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode) (*e.g.*, *multiply*)
587588
* **shapeRendering** - the [shape-rendering mode](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/shape-rendering) (*e.g.*, *crispEdges*)
588589
* **dx** - horizontal offset (in pixels; defaults to 0)
@@ -597,9 +598,10 @@ All marks support the following optional channels:
597598
* **stroke** - a stroke color; bound to the *color* scale
598599
* **strokeOpacity** - a stroke opacity; bound to the *opacity* scale
599600
* **strokeWidth** - a stroke width (in pixels)
601+
* **opacity** - an object opacity; bound to the *opacity* scale
600602
* **title** - a tooltip (a string of text, possibly with newlines)
601603

602-
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.
604+
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.
603605

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

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

795797
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₂*, …].
796798

799+
### Image
800+
801+
[<img src="./img/image.png" width="320" height="198" alt="a scatterplot of Presidential portraits">](https://observablehq.com/@observablehq/plot-image)
802+
803+
[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.
804+
805+
In addition to the [standard mark options](#marks), the following optional channels are supported:
806+
807+
* **x** - the horizontal position; bound to the *x* scale
808+
* **y** - the vertical position; bound to the *y* scale
809+
* **width** - the image width (in pixels)
810+
* **height** - the image height (in pixels)
811+
812+
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.
813+
814+
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.
815+
816+
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.
817+
818+
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).
819+
820+
#### Plot.image(*data*, *options*)
821+
822+
```js
823+
Plot.image(presidents, {x: "inauguration", y: "favorability", src: "portrait"})
824+
```
825+
826+
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₂*, …].
827+
797828
### Line
798829

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

img/image.png

263 KB
Loading

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {BarX, BarY, barX, barY} from "./marks/bar.js";
55
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
66
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
77
export {Frame, frame} from "./marks/frame.js";
8+
export {Image, image} from "./marks/image.js";
89
export {Line, line, lineX, lineY} from "./marks/line.js";
910
export {Link, link} from "./marks/link.js";
1011
export {Rect, rect, rectX, rectY} from "./marks/rect.js";

src/marks/image.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {create} from "d3";
2+
import {filter, positive} from "../defined.js";
3+
import {Mark, maybeNumber, maybeTuple, string} from "../mark.js";
4+
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr, offset, impliedString} from "../style.js";
5+
6+
const defaults = {
7+
fill: null,
8+
stroke: null
9+
};
10+
11+
// Tests if the given string is a path: does it start with a dot-slash
12+
// (./foo.png), dot-dot-slash (../foo.png), or slash (/foo.png)?
13+
function isPath(string) {
14+
return /^\.*\//.test(string);
15+
}
16+
17+
// Tests if the given string is a URL (e.g., https://placekitten.com/200/300).
18+
// The allowed protocols is overly restrictive, but we don’t want to allow any
19+
// scheme here because it would increase the likelihood of a false positive with
20+
// a field name that happens to contain a colon.
21+
function isUrl(string) {
22+
return /^(blob|data|file|http|https):/i.test(string);
23+
}
24+
25+
// Disambiguates a constant src definition from a channel. A path or URL string
26+
// is assumed to be a constant; any other string is assumed to be a field name.
27+
function maybePath(value) {
28+
return typeof value === "string" && (isPath(value) || isUrl(value))
29+
? [undefined, value]
30+
: [value, undefined];
31+
}
32+
33+
export class Image extends Mark {
34+
constructor(data, options = {}) {
35+
let {x, y, width, height, src, preserveAspectRatio, crossOrigin} = options;
36+
if (width === undefined && height !== undefined) width = height;
37+
else if (height === undefined && width !== undefined) height = width;
38+
const [vs, cs] = maybePath(src);
39+
const [vw, cw] = maybeNumber(width, 16);
40+
const [vh, ch] = maybeNumber(height, 16);
41+
super(
42+
data,
43+
[
44+
{name: "x", value: x, scale: "x", optional: true},
45+
{name: "y", value: y, scale: "y", optional: true},
46+
{name: "width", value: vw, optional: true},
47+
{name: "height", value: vh, optional: true},
48+
{name: "src", value: vs, optional: true}
49+
],
50+
options,
51+
defaults
52+
);
53+
this.src = cs;
54+
this.width = cw;
55+
this.height = ch;
56+
this.preserveAspectRatio = impliedString(preserveAspectRatio, "xMidYMid");
57+
this.crossOrigin = string(crossOrigin);
58+
}
59+
render(
60+
I,
61+
{x, y},
62+
channels,
63+
{width, height, marginTop, marginRight, marginBottom, marginLeft}
64+
) {
65+
const {x: X, y: Y, width: W, height: H, src: S} = channels;
66+
let index = filter(I, X, Y, S);
67+
if (W) index = index.filter(i => positive(W[i]));
68+
if (H) index = index.filter(i => positive(H[i]));
69+
const cx = (marginLeft + width - marginRight) / 2;
70+
const cy = (marginTop + height - marginBottom) / 2;
71+
return create("svg:g")
72+
.call(applyIndirectStyles, this)
73+
.call(applyTransform, x, y, offset, offset)
74+
.call(g => g.selectAll()
75+
.data(index)
76+
.join("image")
77+
.call(applyDirectStyles, this)
78+
.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)
79+
.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)
80+
.attr("width", W ? i => W[i] : this.width)
81+
.attr("height", H ? i => H[i] : this.height)
82+
.call(applyAttr, "href", S ? i => S[i] : this.src)
83+
.call(applyAttr, "preserveAspectRatio", this.preserveAspectRatio)
84+
.call(applyAttr, "crossorigin", this.crossOrigin)
85+
.call(applyChannelStyles, channels))
86+
.node();
87+
}
88+
}
89+
90+
export function image(data, {x, y, ...options} = {}) {
91+
([x, y] = maybeTuple(x, y));
92+
return new Image(data, {...options, x, y});
93+
}

src/style.js

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function styles(
1616
strokeLinecap,
1717
strokeMiterlimit,
1818
strokeDasharray,
19+
opacity,
1920
mixBlendMode,
2021
shapeRendering
2122
},
@@ -35,6 +36,12 @@ export function styles(
3536
fillOpacity = null;
3637
}
3738

39+
// Some marks don’t support stroke (e.g., image).
40+
if (defaultStroke === null) {
41+
stroke = null;
42+
strokeOpacity = null;
43+
}
44+
3845
// Some marks default to fill with no stroke, while others default to stroke
3946
// with no fill. For example, bar and area default to fill, while dot and line
4047
// default to stroke. For marks that fill by default, the default fill only
@@ -51,6 +58,7 @@ export function styles(
5158
const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity);
5259
const [vstroke, cstroke] = maybeColor(stroke, defaultStroke);
5360
const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity);
61+
const [vopacity, copacity] = maybeNumber(opacity);
5462

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

71-
mark.stroke = impliedString(cstroke, "none");
72-
mark.strokeWidth = impliedNumber(cstrokeWidth, 1);
73-
mark.strokeOpacity = impliedNumber(cstrokeOpacity, 1);
74-
mark.strokeLinejoin = impliedString(strokeLinejoin, "miter");
75-
mark.strokeLinecap = impliedString(strokeLinecap, "butt");
76-
mark.strokeMiterlimit = impliedNumber(strokeMiterlimit, 4);
77-
mark.strokeDasharray = string(strokeDasharray);
79+
// Some marks don’t support stroke (e.g., image).
80+
if (defaultStroke !== null) {
81+
mark.stroke = impliedString(cstroke, "none");
82+
mark.strokeWidth = impliedNumber(cstrokeWidth, 1);
83+
mark.strokeOpacity = impliedNumber(cstrokeOpacity, 1);
84+
mark.strokeLinejoin = impliedString(strokeLinejoin, "miter");
85+
mark.strokeLinecap = impliedString(strokeLinecap, "butt");
86+
mark.strokeMiterlimit = impliedNumber(strokeMiterlimit, 4);
87+
mark.strokeDasharray = string(strokeDasharray);
88+
}
89+
90+
mark.opacity = impliedNumber(copacity, 1);
7891
mark.mixBlendMode = impliedString(mixBlendMode, "normal");
7992
mark.shapeRendering = impliedString(shapeRendering, "auto");
8093

@@ -85,25 +98,28 @@ export function styles(
8598
{name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true},
8699
{name: "stroke", value: vstroke, scale: "color", optional: true},
87100
{name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true},
88-
{name: "strokeWidth", value: vstrokeWidth, optional: true}
101+
{name: "strokeWidth", value: vstrokeWidth, optional: true},
102+
{name: "opacity", value: vopacity, scale: "opacity", optional: true}
89103
];
90104
}
91105

92-
export function applyChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW}) {
106+
export function applyChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O}) {
93107
applyAttr(selection, "fill", F && (i => F[i]));
94108
applyAttr(selection, "fill-opacity", FO && (i => FO[i]));
95109
applyAttr(selection, "stroke", S && (i => S[i]));
96110
applyAttr(selection, "stroke-opacity", SO && (i => SO[i]));
97111
applyAttr(selection, "stroke-width", SW && (i => SW[i]));
112+
applyAttr(selection, "opacity", O && (i => O[i]));
98113
title(L)(selection);
99114
}
100115

101-
export function applyGroupedChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW}) {
116+
export function applyGroupedChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O}) {
102117
applyAttr(selection, "fill", F && (([i]) => F[i]));
103118
applyAttr(selection, "fill-opacity", FO && (([i]) => FO[i]));
104119
applyAttr(selection, "stroke", S && (([i]) => S[i]));
105120
applyAttr(selection, "stroke-opacity", SO && (([i]) => SO[i]));
106121
applyAttr(selection, "stroke-width", SW && (([i]) => SW[i]));
122+
applyAttr(selection, "opacity", O && (([i]) => O[i]));
107123
titleGroup(L)(selection);
108124
}
109125

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

123139
export function applyDirectStyles(selection, mark) {
124140
applyStyle(selection, "mix-blend-mode", mark.mixBlendMode);
141+
applyAttr(selection, "opacity", mark.opacity);
125142
}
126143

127144
export function applyAttr(selection, name, value) {

test/data/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ https://www.tsa.gov/coronavirus/passenger-throughput
9090
U.S. Census Bureau
9191
https://observablehq.com/@d3/barcode-plot
9292

93+
## us-president-favorability.csv
94+
YouGov (polling data) and Wikipedia (presidential portraits)
95+
https://today.yougov.com/topics/politics/articles-reports/2021/07/27/most-and-least-popular-us-presidents-according-ame
96+
https://en.wikipedia.org/wiki/List_of_presidents_of_the_United_States
97+
9398
## us-presidential-election-2020.csv
9499
Fabio Votta/Edison Research/NYT
95100
https://github.com/favstats/USElection2020-EdisonResearch-Results

0 commit comments

Comments
 (0)