Skip to content

Commit 077fd0e

Browse files
Filmbostock
andauthored
circular images (#1425)
* Plot.image's **r** option sets the default height, width and clips to a circle() * kitten tests * edits * fix dodge r propagation * fix dodge r default * documentation edits --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent 678df1d commit 077fd0e

15 files changed

+449
-150
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1529,10 +1529,13 @@ In addition to the [standard mark options](#marks), the following optional chann
15291529
* **y** - the vertical position; bound to the *y* scale
15301530
* **width** - the image width (in pixels)
15311531
* **height** - the image height (in pixels)
1532+
* **r** - the image radius; bound to the *r* scale
15321533
15331534
If either of the **x** or **y** channels are not specified, the corresponding position is controlled by the **frameAnchor** option.
15341535
1535-
The **width** and **height** options default to 16 pixels and can be specified as either a channel or constant. When the width or height is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. Images with a nonpositive width or height are not drawn. If a **width** is specified but not a **height**, or *vice versa*, the one defaults to the other. Images do not support either a fill or a stroke.
1536+
The **width** and **height** options default to 16 pixels (unless **r** is specified) and can be specified as either a channel or constant. When the width or height is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. Images with a nonpositive width or height are not drawn. If a **width** is specified but not a **height**, or *vice versa*, the one defaults to the other. Images do not support either a fill or a stroke.
1537+
1538+
The **r** option, if not null (the default), enables circular clipping; it may be specified as a constant in pixels or a channel. Use the **preserveAspectRatio** option to control which part of the image is clipped. Also defaults the **width** and **height** to twice the effective radius.
15361539
15371540
The following image-specific constant options are also supported:
15381541

src/marks/image.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export interface ImageOptions extends MarkOptions {
3131
*/
3232
height?: ChannelValue;
3333

34+
/**
35+
* The image clip radius, for circular images. If null (default), images are
36+
* not clipped; when a number, it is interpreted as a constant in pixels;
37+
* otherwise it is interpreted as a channel, typically bound to the *r* scale.
38+
* Also defaults **height** and **width** to twice its value.
39+
*/
40+
r?: ChannelValue;
41+
3442
/**
3543
* The required image URL (or relative path). If a string that starts with a
3644
* dot, slash, or URL protocol (*e.g.*, “https:”) it is assumed to be a

src/marks/image.js

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import {positive} from "../defined.js";
33
import {Mark} from "../mark.js";
44
import {maybeFrameAnchor, maybeNumberChannel, maybeTuple, string} from "../options.js";
55
import {
6+
applyAttr,
67
applyChannelStyles,
78
applyDirectStyles,
8-
applyIndirectStyles,
9-
applyAttr,
10-
impliedString,
119
applyFrameAnchor,
12-
applyTransform
10+
applyIndirectStyles,
11+
applyTransform,
12+
impliedString
1313
} from "../style.js";
14+
import {withDefaultSort} from "./dot.js";
1415

1516
const defaults = {
1617
ariaLabel: "image",
@@ -40,35 +41,41 @@ function maybePathChannel(value) {
4041

4142
export class Image extends Mark {
4243
constructor(data, options = {}) {
43-
let {x, y, width, height, src, preserveAspectRatio, crossOrigin, frameAnchor, imageRendering} = options;
44-
if (width === undefined && height !== undefined) width = height;
44+
let {x, y, r, width, height, src, preserveAspectRatio, crossOrigin, frameAnchor, imageRendering} = options;
45+
if (r == null) r = undefined;
46+
if (r === undefined && width === undefined && height === undefined) width = height = 16;
47+
else if (width === undefined && height !== undefined) width = height;
4548
else if (height === undefined && width !== undefined) height = width;
4649
const [vs, cs] = maybePathChannel(src);
47-
const [vw, cw] = maybeNumberChannel(width, 16);
48-
const [vh, ch] = maybeNumberChannel(height, 16);
50+
const [vr, cr] = maybeNumberChannel(r);
51+
const [vw, cw] = maybeNumberChannel(width, cr !== undefined ? cr * 2 : undefined);
52+
const [vh, ch] = maybeNumberChannel(height, cr !== undefined ? cr * 2 : undefined);
4953
super(
5054
data,
5155
{
5256
x: {value: x, scale: "x", optional: true},
5357
y: {value: y, scale: "y", optional: true},
58+
r: {value: vr, scale: "r", filter: positive, optional: true},
5459
width: {value: vw, filter: positive, optional: true},
5560
height: {value: vh, filter: positive, optional: true},
5661
src: {value: vs, optional: true}
5762
},
58-
options,
63+
withDefaultSort(options),
5964
defaults
6065
);
6166
this.src = cs;
6267
this.width = cw;
6368
this.height = ch;
69+
this.r = cr;
6470
this.preserveAspectRatio = impliedString(preserveAspectRatio, "xMidYMid");
6571
this.crossOrigin = string(crossOrigin);
6672
this.frameAnchor = maybeFrameAnchor(frameAnchor);
6773
this.imageRendering = impliedString(imageRendering, "auto");
6874
}
6975
render(index, scales, channels, dimensions, context) {
7076
const {x, y} = scales;
71-
const {x: X, y: Y, width: W, height: H, src: S} = channels;
77+
const {x: X, y: Y, width: W, height: H, r: R, src: S} = channels;
78+
const {r, width, height} = this;
7279
const [cx, cy] = applyFrameAnchor(this, dimensions);
7380
return create("svg:g", context)
7481
.call(applyIndirectStyles, this, dimensions, context)
@@ -80,38 +87,39 @@ export class Image extends Mark {
8087
.enter()
8188
.append("image")
8289
.call(applyDirectStyles, this)
83-
.attr(
84-
"x",
85-
W && X
86-
? (i) => X[i] - W[i] / 2
87-
: W
88-
? (i) => cx - W[i] / 2
89-
: X
90-
? (i) => X[i] - this.width / 2
91-
: cx - this.width / 2
92-
)
93-
.attr(
94-
"y",
95-
H && Y
96-
? (i) => Y[i] - H[i] / 2
97-
: H
98-
? (i) => cy - H[i] / 2
99-
: Y
100-
? (i) => Y[i] - this.height / 2
101-
: cy - this.height / 2
102-
)
103-
.attr("width", W ? (i) => W[i] : this.width)
104-
.attr("height", H ? (i) => H[i] : this.height)
90+
.attr("x", position(X, W, R, cx, width, r))
91+
.attr("y", position(Y, H, R, cy, height, r))
92+
.attr("width", W ? (i) => W[i] : width !== undefined ? width : R ? (i) => R[i] * 2 : r * 2)
93+
.attr("height", H ? (i) => H[i] : height !== undefined ? height : R ? (i) => R[i] * 2 : r * 2)
10594
.call(applyAttr, "href", S ? (i) => S[i] : this.src)
10695
.call(applyAttr, "preserveAspectRatio", this.preserveAspectRatio)
10796
.call(applyAttr, "crossorigin", this.crossOrigin)
10897
.call(applyAttr, "image-rendering", this.imageRendering)
98+
.call(applyAttr, "clip-path", R ? (i) => `circle(${R[i]}px)` : r !== undefined ? `circle(${r}px)` : null)
10999
.call(applyChannelStyles, this, channels)
110100
)
111101
.node();
112102
}
113103
}
114104

105+
function position(X, W, R, x, w, r) {
106+
return W && X
107+
? (i) => X[i] - W[i] / 2
108+
: W
109+
? (i) => x - W[i] / 2
110+
: X && w !== undefined
111+
? (i) => X[i] - w / 2
112+
: w !== undefined
113+
? x - w / 2
114+
: R && X
115+
? (i) => X[i] - R[i]
116+
: R
117+
? (i) => x - R[i]
118+
: X
119+
? (i) => X[i] - r
120+
: x - r;
121+
}
122+
115123
export function image(data, options = {}) {
116124
let {x, y, ...remainingOptions} = options;
117125
if (options.frameAnchor === undefined) [x, y] = maybeTuple(x, y);

src/transforms/dodge.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function maybeAnchor(anchor) {
1717

1818
export function dodgeX(dodgeOptions = {}, options = {}) {
1919
if (arguments.length === 1) [dodgeOptions, options] = mergeOptions(dodgeOptions);
20-
let {anchor = "left", padding = 1} = maybeAnchor(dodgeOptions);
20+
let {anchor = "left", padding = 1, r = options.r} = maybeAnchor(dodgeOptions);
2121
switch (`${anchor}`.toLowerCase()) {
2222
case "left":
2323
anchor = anchorXLeft;
@@ -31,12 +31,12 @@ export function dodgeX(dodgeOptions = {}, options = {}) {
3131
default:
3232
throw new Error(`unknown dodge anchor: ${anchor}`);
3333
}
34-
return dodge("x", "y", anchor, number(padding), options);
34+
return dodge("x", "y", anchor, number(padding), r, options);
3535
}
3636

3737
export function dodgeY(dodgeOptions = {}, options = {}) {
3838
if (arguments.length === 1) [dodgeOptions, options] = mergeOptions(dodgeOptions);
39-
let {anchor = "bottom", padding = 1} = maybeAnchor(dodgeOptions);
39+
let {anchor = "bottom", padding = 1, r = options.r} = maybeAnchor(dodgeOptions);
4040
switch (`${anchor}`.toLowerCase()) {
4141
case "top":
4242
anchor = anchorYTop;
@@ -50,16 +50,16 @@ export function dodgeY(dodgeOptions = {}, options = {}) {
5050
default:
5151
throw new Error(`unknown dodge anchor: ${anchor}`);
5252
}
53-
return dodge("y", "x", anchor, number(padding), options);
53+
return dodge("y", "x", anchor, number(padding), r, options);
5454
}
5555

5656
function mergeOptions(options) {
5757
const {anchor, padding, ...rest} = options;
58-
return [{anchor, padding}, rest];
58+
const {r} = rest; // don’t consume r; allow it to propagate
59+
return [{anchor, padding, r}, rest];
5960
}
6061

61-
function dodge(y, x, anchor, padding, options) {
62-
const {r} = options;
62+
function dodge(y, x, anchor, padding, r, options) {
6363
if (r != null && typeof r !== "number") {
6464
const {channels, sort, reverse} = options;
6565
options = {...options, channels: {r: {value: r, scale: "r"}, ...maybeNamed(channels)}};
@@ -69,12 +69,12 @@ function dodge(y, x, anchor, padding, options) {
6969
let {[x]: X, r: R} = channels;
7070
if (!channels[x]) throw new Error(`missing channel: ${x}`);
7171
({[x]: X} = applyPosition(channels, scales, context));
72-
const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? number(options.r) : 3;
72+
const cr = R ? undefined : r !== undefined ? number(r) : this.r !== undefined ? this.r : 3;
7373
if (R) R = valueof(R.value, scales[R.scale] || identity, Float64Array);
7474
let [ky, ty] = anchor(dimensions);
7575
const compare = ky ? compareAscending : compareSymmetric;
7676
const Y = new Float64Array(X.length);
77-
const radius = R ? (i) => R[i] : () => r;
77+
const radius = R ? (i) => R[i] : () => cr;
7878
for (let I of facets) {
7979
const tree = IntervalTree();
8080
I = I.filter(R ? (i) => finite(X[i]) && positive(R[i]) : (i) => finite(X[i]));
@@ -94,7 +94,7 @@ function dodge(y, x, anchor, padding, options) {
9494
tree.queryInterval(l - padding, h + padding, ([, , j]) => {
9595
const yj = Y[j] - y0;
9696
const dx = X[i] - X[j];
97-
const dr = padding + (R ? R[i] + R[j] : 2 * r);
97+
const dr = padding + (R ? R[i] + R[j] : 2 * cr);
9898
const dy = Math.sqrt(dr * dr - dx * dx);
9999
intervals[k++] = yj - dy;
100100
intervals[k++] = yj + dy;

test/data/us-president-favorability.csv

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Name,Very Favorable %,Somewhat Favorable %,Somewhat Unfavorable %,Very Unfavorable %,Don’t know %,Have not heard of them %,First Inauguration Date,Portrait URL
22
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
3-
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
3+
John Adams,16,30,7,4,37,5,1797-03-04,https://upload.wikimedia.org/wikipedia/commons/thumb/0/07/John_Adams_A18236.jpg/320px-John_Adams_A18236.jpg
44
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
55
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
66
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
@@ -43,4 +43,4 @@ Bill Clinton,15,30,20,22,10,2,1993-01-20,https://upload.wikimedia.org/wikipedia/
4343
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
4444
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
4545
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
46-
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
46+
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

test/output/kittenClipNull.svg

Lines changed: 43 additions & 0 deletions
Loading

test/output/kittenConstant.svg

Lines changed: 43 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)