diff --git a/README.md b/README.md index 1419c9c8ae..9b7cac8bf9 100644 --- a/README.md +++ b/README.md @@ -778,7 +778,7 @@ All marks support the following style options: * **pointerEvents** - the [pointer events](https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events) (*e.g.*, *none*) * **clip** - whether and how to clip the mark -If the **clip** option is *frame* (or equivalently true), the mark is clipped to the frame’s dimensions; if the **clip** option is null (or equivalently false), the mark is not clipped. If the **clip** option is *sphere*, then a [geographic projection](#projection-options) is required and the mark will be clipped to the projected sphere (_e.g._, the front hemisphere when using the orthographic projection). +If the **clip** option is *frame* (or equivalently true), the mark is clipped to the frame’s dimensions; if the **clip** option is null (or equivalently false), the mark is not clipped. If the **clip** option is *sphere*, then a [geographic projection](#projection-options) is required and the mark will be clipped to the projected sphere (_e.g._, the front hemisphere when using the orthographic projection). The **clip** option can also be specified as a CSS [basic-shape](https://developer.mozilla.org/en-US/docs/Web/CSS/basic-shape). For all marks except [text](#plottextdata-options), the **dx** and **dy** options are rendered as a transform property, possibly including a 0.5px offset on low-density screens. @@ -1529,10 +1529,13 @@ In addition to the [standard mark options](#marks), the following optional chann * **y** - the vertical position; bound to the *y* scale * **width** - the image width (in pixels) * **height** - the image height (in pixels) +* **r** - the image radius, for rounded images; bound to the *r* scale If either of the **x** or **y** channels are not specified, the corresponding position is controlled by the **frameAnchor** option. -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. +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. + +The **r** option, if specified as a constant or a channel, defaults **clip** to *circle()*, and defaults **width** and **height** to twice the effective radius, thus allowing to draw the image as a disc of the given radius. The following image-specific constant options are also supported: diff --git a/src/mark.d.ts b/src/mark.d.ts index 2989400944..cab7597d71 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -273,11 +273,14 @@ export interface MarkOptions { * * - *frame* or true - clip to the plot’s frame (inner area) * - *sphere* - clip to the projected sphere (*e.g.*, front hemisphere) + * - a CSS [basic shape][1], such as *circle()* * - null or false - do not clip * * The *sphere* clip option requires a geographic projection. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/CSS/basic-shape */ - clip?: "frame" | "sphere" | boolean | null; + clip?: "frame" | "sphere" | "circle()" | (string & Record) | boolean | null; /** * The horizontal offset in pixels; a constant option. On low-density screens, diff --git a/src/mark.js b/src/mark.js index a29ea05a22..f7f5c3233f 100644 --- a/src/mark.js +++ b/src/mark.js @@ -62,7 +62,7 @@ export class Mark { this.marginRight = +marginRight; this.marginBottom = +marginBottom; this.marginLeft = +marginLeft; - this.clip = maybeClip(clip); + [this.clip, this.clipShape] = maybeClip(clip); // Super-faceting currently disallow position channels; in the future, we // could allow position to be specified in fx and fy in addition to (or // instead of) x and y. diff --git a/src/marks/image.d.ts b/src/marks/image.d.ts index b8117ffdc4..9742207585 100644 --- a/src/marks/image.d.ts +++ b/src/marks/image.d.ts @@ -31,6 +31,15 @@ export interface ImageOptions extends MarkOptions { */ height?: ChannelValue; + /** + * The image radius, for rounded images. When a number, it is interpreted as a + * constant radius in pixels; otherwise it is interpreted as a channel, + * typically bound to the *r* scale. Also sets the default **clip** option to + * _circle()_, and the default **height** and **width** to twice its effective + * value. + */ + r?: ChannelValue; + /** * The required image URL (or relative path). If a string that starts with a * dot, slash, or URL protocol (*e.g.*, “https:”) it is assumed to be a diff --git a/src/marks/image.js b/src/marks/image.js index d4f183d81e..5da58cfd56 100644 --- a/src/marks/image.js +++ b/src/marks/image.js @@ -40,27 +40,34 @@ function maybePathChannel(value) { export class Image extends Mark { constructor(data, options = {}) { - let {x, y, width, height, src, preserveAspectRatio, crossOrigin, frameAnchor, imageRendering} = options; + let {x, y, r, width, height, src, preserveAspectRatio, crossOrigin, frameAnchor, imageRendering} = options; if (width === undefined && height !== undefined) width = height; else if (height === undefined && width !== undefined) height = width; const [vs, cs] = maybePathChannel(src); - const [vw, cw] = maybeNumberChannel(width, 16); - const [vh, ch] = maybeNumberChannel(height, 16); + const [vr, cr] = maybeNumberChannel(r); + const [vw, cw] = maybeNumberChannel(width, r != null ? cr : 16); + const [vh, ch] = maybeNumberChannel(height, r != null ? cr : 16); super( data, { x: {value: x, scale: "x", optional: true}, y: {value: y, scale: "y", optional: true}, + r: {value: vr, scale: "r", filter: positive, optional: true}, width: {value: vw, filter: positive, optional: true}, height: {value: vh, filter: positive, optional: true}, src: {value: vs, optional: true} }, - options, + { + ...(r != null && {clip: "circle()"}), + ...options + }, defaults ); this.src = cs; this.width = cw; this.height = ch; + this.sw = width == null && r != null ? 1 : 1 / 2; + this.sh = height == null && r != null ? 1 : 1 / 2; this.preserveAspectRatio = impliedString(preserveAspectRatio, "xMidYMid"); this.crossOrigin = string(crossOrigin); this.frameAnchor = maybeFrameAnchor(frameAnchor); @@ -68,7 +75,8 @@ export class Image extends Mark { } render(index, scales, channels, dimensions, context) { const {x, y} = scales; - const {x: X, y: Y, width: W, height: H, src: S} = channels; + const {width, height, sw, sh} = this; + const {x: X, y: Y, r: R, width: W = width == null && R, height: H = height == null && R, src: S} = channels; const [cx, cy] = applyFrameAnchor(this, dimensions); return create("svg:g", context) .call(applyIndirectStyles, this, dimensions, context) @@ -83,25 +91,25 @@ export class Image extends Mark { .attr( "x", W && X - ? (i) => X[i] - W[i] / 2 + ? (i) => X[i] - W[i] * sw : W - ? (i) => cx - W[i] / 2 + ? (i) => cx - W[i] * sw : X - ? (i) => X[i] - this.width / 2 - : cx - this.width / 2 + ? (i) => X[i] - width * sw + : cx - width * sw ) .attr( "y", H && Y - ? (i) => Y[i] - H[i] / 2 + ? (i) => Y[i] - H[i] * sh : H - ? (i) => cy - H[i] / 2 + ? (i) => cy - H[i] * sh : Y - ? (i) => Y[i] - this.height / 2 - : cy - this.height / 2 + ? (i) => Y[i] - height * sh + : cy - height * sh ) - .attr("width", W ? (i) => W[i] : this.width) - .attr("height", H ? (i) => H[i] : this.height) + .attr("width", W ? (i) => W[i] * 2 * sw : width * 2 * sw) + .attr("height", H ? (i) => H[i] * 2 * sh : height * 2 * sh) .call(applyAttr, "href", S ? (i) => S[i] : this.src) .call(applyAttr, "preserveAspectRatio", this.preserveAspectRatio) .call(applyAttr, "crossorigin", this.crossOrigin) diff --git a/src/style.js b/src/style.js index e247afece4..719e97a3a4 100644 --- a/src/style.js +++ b/src/style.js @@ -304,7 +304,9 @@ export function* groupIndex(I, position, {z}, channels) { export function maybeClip(clip) { if (clip === true) clip = "frame"; else if (clip === false) clip = null; - return maybeKeyword(clip, "clip", ["frame", "sphere"]); + return typeof clip === "string" && /^(circle|ellipse|inset|polygon|rectangle)\([^)]*\)$/.test(clip) + ? [, clip] + : [maybeKeyword(clip, "clip", ["frame", "sphere"])]; } // Note: may mutate selection.node! @@ -374,6 +376,7 @@ export function applyIndirectStyles(selection, mark, dimensions, context) { export function applyDirectStyles(selection, mark) { applyStyle(selection, "mix-blend-mode", mark.mixBlendMode); + applyStyle(selection, "clip-path", mark.clipShape); applyAttr(selection, "opacity", mark.opacity); } diff --git a/test/output/usPresidentGalleryEllipse.svg b/test/output/usPresidentGalleryEllipse.svg new file mode 100644 index 0000000000..b48ca6eeb3 --- /dev/null +++ b/test/output/usPresidentGalleryEllipse.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + 1800 + 1850 + 1900 + 1950 + 2000 + + + 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/output/usPresidentGalleryRadius.svg b/test/output/usPresidentGalleryRadius.svg new file mode 100644 index 0000000000..fed7b6af5c --- /dev/null +++ b/test/output/usPresidentGalleryRadius.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + 1800 + 1850 + 1900 + 1950 + 2000 + + + Date of first inauguration + + + Abraham Lincoln + George Washington + Barack Obama + John F. Kennedy + Ronald Reagan + Thomas Jefferson + Franklin D. Roosevelt + Joe Biden + Theodore Roosevelt + Jimmy Carter + Donald Trump + Dwight D. Eisenhower + Harry S. Truman + John Adams + Bill Clinton + Ulysses S. Grant + John Quincy Adams + James Madison + Andrew Jackson + George H. W. Bush + George W. Bush + Lyndon B. Johnson + James Monroe + Richard Nixon + Woodrow Wilson + Gerald Ford + Andrew Johnson + Calvin Coolidge + Herbert Hoover + James K. Polk + Rutherford B. Hayes + James A. Garfield + Grover Cleveland + Martin Van Buren + William Henry Harrison + John Tyler + Franklin Pierce + James Buchanan + Chester A. Arthur + Benjamin Harrison + William Howard Taft + Warren G. Harding + Zachary Taylor + William McKinley + Millard Fillmore + + \ No newline at end of file diff --git a/test/plots/us-president-gallery.ts b/test/plots/us-president-gallery.ts index 05ccba79bb..48dc48bf09 100644 --- a/test/plots/us-president-gallery.ts +++ b/test/plots/us-president-gallery.ts @@ -17,8 +17,59 @@ export async function usPresidentGallery() { width: 960, height: 300, marks: [ - Plot.image(data, {...dodge, width: 60, src: "Portrait URL"}), + Plot.image(data, {...dodge, width: 60, src: "Portrait URL", clip: null}), Plot.dot(data, {...dodge, stroke: "white", dy: -5}) ] }); } + +export async function usPresidentGalleryEllipse() { + const data = await d3.csv("data/us-president-favorability.csv", d3.autoType); + return Plot.plot({ + height: 540, + x: { + label: "Date of first inauguration", + inset: 30, + grid: true + }, + marks: [ + Plot.image( + data, + Plot.dodgeY({ + x: "First Inauguration Date", + src: "Portrait URL", + title: "Name", + r: 30, + padding: -2, + anchor: "middle", + clip: "ellipse(30% 50%)" + }) + ) + ] + }); +} + +export async function usPresidentGalleryRadius() { + const data = await d3.csv("data/us-president-favorability.csv", d3.autoType); + return Plot.plot({ + height: 240, + x: { + label: "Date of first inauguration", + inset: 30, + grid: true + }, + r: {range: [0, 40]}, + marks: [ + Plot.image( + data, + Plot.dodgeY({ + x: "First Inauguration Date", + src: "Portrait URL", + title: "Name", + r: "Very Favorable %", + anchor: "middle" + }) + ) + ] + }); +}