From 3f43c524c5f2287f1638af81f278296daff9412f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 10 Jul 2024 14:11:05 -0400 Subject: [PATCH 01/20] rounded rect --- src/marks/rect.d.ts | 44 +++++++- src/marks/rect.js | 123 +++++++++++++++------- test/output/roundedRect.svg | 173 +++++++++++++++++++++++++++++++ test/output/roundedRectSides.svg | 173 +++++++++++++++++++++++++++++++ test/plots/index.ts | 1 + test/plots/rounded-rect.ts | 53 ++++++++++ 6 files changed, 525 insertions(+), 42 deletions(-) create mode 100644 test/output/roundedRect.svg create mode 100644 test/output/roundedRectSides.svg create mode 100644 test/plots/rounded-rect.ts diff --git a/src/marks/rect.d.ts b/src/marks/rect.d.ts index 3133b87b2c..a788d8cd5e 100644 --- a/src/marks/rect.d.ts +++ b/src/marks/rect.d.ts @@ -9,7 +9,9 @@ export interface RectCornerOptions { /** * The rounded corner [*x*-radius][1], either in pixels or as a percentage of * the rect width. If **rx** is not specified, it defaults to **ry** if - * present, and otherwise draws square corners. + * present, and otherwise draws square corners. This option is ignored if a + * more specific corner radius (one of **rx1y1**, **rx2y1**, **rx1y2**, or + * **rx2y2**) is specified. * * [1]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx */ @@ -18,11 +20,49 @@ export interface RectCornerOptions { /** * The rounded corner [*y*-radius][1], either in pixels or as a percentage of * the rect height. If **ry** is not specified, it defaults to **rx** if - * present, and otherwise draws square corners. + * present, and otherwise draws square corners. This option is ignored if a + * more specific corner radius (one of **rx1y1**, **rx2y1**, **rx1y2**, or + * **rx2y2**) is specified. * * [1]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry */ ry?: number | string; + + /** + * The rounded radius for the **x1** corners, in pixels; shorthand for + * **rx1y1** and **rx1y2**. + */ + rx1?: number; + + /** + * The rounded radius for the **y1** corners, in pixels; shorthand for + * **rx1y1** and **rx2y1**. + */ + ry1?: number; + + /** + * The rounded radius for the **x2** corners, in pixels; shorthand for + * **rx2y1** and **rx2y2**. + */ + rx2?: number; + + /** + * The rounded radius for the **y2** corners, in pixels; shorthand for + * **rx1y2** and **rx2y2**. + */ + ry2?: number; + + /** The rounded radius for the **x1y1** corner, in pixels. */ + rx1y1?: number; + + /** The rounded radius for the **x1y2** corner, in pixels. */ + rx1y2?: number; + + /** The rounded radius for the **x2y1** corner, in pixels. */ + rx2y1?: number; + + /** The rounded radius for the **x2y2** corner, in pixels. */ + rx2y2?: number; } /** Options for the rect mark. */ diff --git a/src/marks/rect.js b/src/marks/rect.js index 7dee9fbcf1..1babcdbc85 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -25,7 +25,15 @@ export class Rect extends Mark { insetBottom = inset, insetLeft = inset, rx, - ry + ry, + rx1, + ry1, + rx2, + ry2, + rx1y1 = rx1 !== undefined ? +rx1 : ry1 !== undefined ? +ry1 : 0, + rx1y2 = rx1 !== undefined ? +rx1 : ry2 !== undefined ? +ry2 : 0, + rx2y1 = rx2 !== undefined ? +rx2 : ry1 !== undefined ? +ry1 : 0, + rx2y2 = rx2 !== undefined ? +rx2 : ry2 !== undefined ? +ry2 : 0 } = options; super( data, @@ -42,15 +50,22 @@ export class Rect extends Mark { this.insetRight = number(insetRight); this.insetBottom = number(insetBottom); this.insetLeft = number(insetLeft); - this.rx = impliedString(rx, "auto"); // number or percentage - this.ry = impliedString(ry, "auto"); + if (rx1y1 || rx1y2 || rx2y1 || rx2y2) { + this.rx1y1 = Math.max(0, rx1y1); + this.rx1y2 = Math.max(0, rx1y2); + this.rx2y1 = Math.max(0, rx2y1); + this.rx2y2 = Math.max(0, rx2y2); + } else { + this.rx = impliedString(rx, "auto"); // number or percentage + this.ry = impliedString(ry, "auto"); + } } render(index, scales, channels, dimensions, context) { const {x, y} = scales; const {x1: X1, y1: Y1, x2: X2, y2: Y2} = channels; const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; const {projection} = context; - const {insetTop, insetRight, insetBottom, insetLeft, rx, ry} = this; + const {insetTop, insetRight, insetBottom, insetLeft, rx, ry, rx1y1, rx1y2, rx2y1, rx2y2} = this; const bx = (x?.bandwidth ? x.bandwidth() : 0) - insetLeft - insetRight; const by = (y?.bandwidth ? y.bandwidth() : 0) - insetTop - insetBottom; return create("svg:g", context) @@ -61,43 +76,71 @@ export class Rect extends Mark { .selectAll() .data(index) .enter() - .append("rect") - .call(applyDirectStyles, this) - .attr( - "x", - X1 && (projection || !isCollapsed(x)) - ? X2 - ? (i) => Math.min(X1[i], X2[i]) + insetLeft - : (i) => X1[i] + insetLeft - : marginLeft + insetLeft + .call( + rx1y1 || rx1y2 || rx2y1 || rx2y2 + ? (g) => + g + .append("path") + .call(applyDirectStyles, this) + .attr("d", (i) => { + const ix = X1[i] > X2[i]; + const iy = Y1[i] > Y2[i]; + const x1 = (ix ? X2[i] : X1[i]) + insetLeft; + const x2 = (ix ? X1[i] : X2[i]) - insetRight; + const y1 = (iy ? Y2[i] : Y1[i]) + insetTop; + const y2 = (iy ? Y1[i] : Y2[i]) - insetBottom; + const r = Math.min(x2 - x1, y2 - y1) / 2; + const rx1y1i = Math.min(r, ix ? (iy ? rx2y2 : rx2y1) : iy ? rx1y2 : rx1y1); + const rx2y1i = Math.min(r, ix ? (iy ? rx1y2 : rx1y1) : iy ? rx2y2 : rx2y1); + const rx2y2i = Math.min(r, ix ? (iy ? rx1y1 : rx1y2) : iy ? rx2y1 : rx2y2); + const rx1y2i = Math.min(r, ix ? (iy ? rx2y1 : rx2y2) : iy ? rx1y1 : rx1y2); + return `M${x1},${y1 + rx1y1i}A${rx1y1i},${rx1y1i} 0 0 1 ${x1 + rx1y1i},${y1} + H${x2 - rx2y1i}A${rx2y1i},${rx2y1i} 0 0 1 ${x2},${y1 + rx2y1i} + V${y2 - rx2y2i}A${rx2y2i},${rx2y2i} 0 0 1 ${x2 - rx2y2i},${y2} + H${x1 + rx1y2i}A${rx1y2i},${rx1y2i} 0 0 1 ${x1},${y2 - rx1y2i} + Z`; + }) + .call(applyChannelStyles, this, channels) + : (g) => + g + .append("rect") + .call(applyDirectStyles, this) + .attr( + "x", + X1 && (projection || !isCollapsed(x)) + ? X2 + ? (i) => Math.min(X1[i], X2[i]) + insetLeft + : (i) => X1[i] + insetLeft + : marginLeft + insetLeft + ) + .attr( + "y", + Y1 && (projection || !isCollapsed(y)) + ? Y2 + ? (i) => Math.min(Y1[i], Y2[i]) + insetTop + : (i) => Y1[i] + insetTop + : marginTop + insetTop + ) + .attr( + "width", + X1 && (projection || !isCollapsed(x)) + ? X2 + ? (i) => Math.max(0, Math.abs(X2[i] - X1[i]) + bx) + : bx + : width - marginRight - marginLeft - insetRight - insetLeft + ) + .attr( + "height", + Y1 && (projection || !isCollapsed(y)) + ? Y2 + ? (i) => Math.max(0, Math.abs(Y1[i] - Y2[i]) + by) + : by + : height - marginTop - marginBottom - insetTop - insetBottom + ) + .call(applyAttr, "rx", rx) + .call(applyAttr, "ry", ry) + .call(applyChannelStyles, this, channels) ) - .attr( - "y", - Y1 && (projection || !isCollapsed(y)) - ? Y2 - ? (i) => Math.min(Y1[i], Y2[i]) + insetTop - : (i) => Y1[i] + insetTop - : marginTop + insetTop - ) - .attr( - "width", - X1 && (projection || !isCollapsed(x)) - ? X2 - ? (i) => Math.max(0, Math.abs(X2[i] - X1[i]) + bx) - : bx - : width - marginRight - marginLeft - insetRight - insetLeft - ) - .attr( - "height", - Y1 && (projection || !isCollapsed(y)) - ? Y2 - ? (i) => Math.max(0, Math.abs(Y1[i] - Y2[i]) + by) - : by - : height - marginTop - marginBottom - insetTop - insetBottom - ) - .call(applyAttr, "rx", rx) - .call(applyAttr, "ry", ry) - .call(applyChannelStyles, this, channels) ) .node(); } diff --git a/test/output/roundedRect.svg b/test/output/roundedRect.svg new file mode 100644 index 0000000000..381921d3b2 --- /dev/null +++ b/test/output/roundedRect.svg @@ -0,0 +1,173 @@ + + + + + 4.0 + 3.5 + 3.0 + 2.5 + 2.0 + 1.5 + 1.0 + 0.5 + 0.0 + + + + 0.0 + 0.5 + 1.0 + 1.5 + 2.0 + 2.5 + 3.0 + 3.5 + 4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/roundedRectSides.svg b/test/output/roundedRectSides.svg new file mode 100644 index 0000000000..5c8764dcfb --- /dev/null +++ b/test/output/roundedRectSides.svg @@ -0,0 +1,173 @@ + + + + + 4.0 + 3.5 + 3.0 + 2.5 + 2.0 + 1.5 + 1.0 + 0.5 + 0.0 + + + + 0.0 + 0.5 + 1.0 + 1.5 + 2.0 + 2.5 + 3.0 + 3.5 + 4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/index.ts b/test/plots/index.ts index 7b99e1ada1..b7af3ca8cd 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -253,6 +253,7 @@ export * from "./raster-vapor.js"; export * from "./raster-walmart.js"; export * from "./rect-band.js"; export * from "./reducer-scale-override.js"; +export * from "./rounded-rect.js"; export * from "./seattle-precipitation-density.js"; export * from "./seattle-precipitation-rule.js"; export * from "./seattle-precipitation-sum.js"; diff --git a/test/plots/rounded-rect.ts b/test/plots/rounded-rect.ts new file mode 100644 index 0000000000..5352ad6798 --- /dev/null +++ b/test/plots/rounded-rect.ts @@ -0,0 +1,53 @@ +import * as Plot from "@observablehq/plot"; + +export function roundedRect() { + return Plot.plot({ + y: {reverse: true}, + inset: 4, + marks: [ + Plot.frame(), + Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 0, x2: 1, y1: 0, y2: 1, inset: 4, rx1y1: 20}), + Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 1, x2: 2, y1: 0, y2: 1, inset: 4, rx2y1: 20}), + Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 2, x2: 3, y1: 0, y2: 1, inset: 4, rx2y2: 20}), + Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 3, x2: 4, y1: 0, y2: 1, inset: 4, rx1y2: 20}), + Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 1, x2: 0, y1: 1, y2: 2, inset: 4, rx1y1: 20}), + Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 2, x2: 1, y1: 1, y2: 2, inset: 4, rx2y1: 20}), + Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 3, x2: 2, y1: 1, y2: 2, inset: 4, rx2y2: 20}), + Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 4, x2: 3, y1: 1, y2: 2, inset: 4, rx1y2: 20}), + Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 0, x2: 1, y1: 3, y2: 2, inset: 4, rx1y1: 20}), + Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 1, x2: 2, y1: 3, y2: 2, inset: 4, rx2y1: 20}), + Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 2, x2: 3, y1: 3, y2: 2, inset: 4, rx2y2: 20}), + Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 3, x2: 4, y1: 3, y2: 2, inset: 4, rx1y2: 20}), + Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 1, x2: 0, y1: 4, y2: 3, inset: 4, rx1y1: 20}), + Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 2, x2: 1, y1: 4, y2: 3, inset: 4, rx2y1: 20}), + Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 3, x2: 2, y1: 4, y2: 3, inset: 4, rx2y2: 20}), + Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 4, x2: 3, y1: 4, y2: 3, inset: 4, rx1y2: 20}) + ] + }); +} + +export function roundedRectSides() { + return Plot.plot({ + y: {reverse: true}, + inset: 4, + marks: [ + Plot.frame(), + Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 0, x2: 1, y1: 0, y2: 1, inset: 4, rx1: 20}), + Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 1, x2: 2, y1: 0, y2: 1, inset: 4, ry1: 20}), + Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 2, x2: 3, y1: 0, y2: 1, inset: 4, rx2: 20}), + Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 3, x2: 4, y1: 0, y2: 1, inset: 4, ry2: 20}), + Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 1, x2: 0, y1: 1, y2: 2, inset: 4, rx1: 20}), + Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 2, x2: 1, y1: 1, y2: 2, inset: 4, ry1: 20}), + Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 3, x2: 2, y1: 1, y2: 2, inset: 4, rx2: 20}), + Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 4, x2: 3, y1: 1, y2: 2, inset: 4, ry2: 20}), + Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 0, x2: 1, y1: 3, y2: 2, inset: 4, rx1: 20}), + Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 1, x2: 2, y1: 3, y2: 2, inset: 4, ry1: 20}), + Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 2, x2: 3, y1: 3, y2: 2, inset: 4, rx2: 20}), + Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 3, x2: 4, y1: 3, y2: 2, inset: 4, ry2: 20}), + Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 1, x2: 0, y1: 4, y2: 3, inset: 4, rx1: 20}), + Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 2, x2: 1, y1: 4, y2: 3, inset: 4, ry1: 20}), + Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 3, x2: 2, y1: 4, y2: 3, inset: 4, rx2: 20}), + Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 4, x2: 3, y1: 4, y2: 3, inset: 4, ry2: 20}) + ] + }); +} From 4490e7da0e19d6232a6a19cbb38f7a7cb189daec Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 11 Jul 2024 08:56:26 -0400 Subject: [PATCH 02/20] handle collapsed & 1d rects --- src/marks/rect.js | 82 ++++++++++++++--------- test/output/roundedRect.svg | 96 +++++---------------------- test/output/roundedRectBand.svg | 62 +++++++++++++++++ test/output/roundedRectCollapsedX.svg | 52 +++++++++++++++ test/output/roundedRectCollapsedY.svg | 52 +++++++++++++++ test/output/roundedRectPartial.svg | 62 +++++++++++++++++ test/output/roundedRectSides.svg | 96 +++++---------------------- test/plots/rounded-rect.ts | 27 ++++++++ 8 files changed, 339 insertions(+), 190 deletions(-) create mode 100644 test/output/roundedRectBand.svg create mode 100644 test/output/roundedRectCollapsedX.svg create mode 100644 test/output/roundedRectCollapsedY.svg create mode 100644 test/output/roundedRectPartial.svg diff --git a/src/marks/rect.js b/src/marks/rect.js index 1babcdbc85..ec8e6dce4f 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -62,12 +62,15 @@ export class Rect extends Mark { } render(index, scales, channels, dimensions, context) { const {x, y} = scales; - const {x1: X1, y1: Y1, x2: X2, y2: Y2} = channels; + let {x1: X1, y1: Y1, x2: X2, y2: Y2} = channels; const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; const {projection} = context; - const {insetTop, insetRight, insetBottom, insetLeft, rx, ry, rx1y1, rx1y2, rx2y1, rx2y2} = this; - const bx = (x?.bandwidth ? x.bandwidth() : 0) - insetLeft - insetRight; - const by = (y?.bandwidth ? y.bandwidth() : 0) - insetTop - insetBottom; + const {insetTop, insetRight, insetBottom, insetLeft} = this; + const {rx, ry, rx1y1, rx1y2, rx2y1, rx2y2} = this; + if ((X1 || X2) && !projection && isCollapsed(x)) X1 = X2 = null; // ignore if collapsed + if ((Y1 || Y2) && !projection && isCollapsed(y)) Y1 = Y2 = null; // ignore if collapsed + const bx = x?.bandwidth ? x.bandwidth() : 0; + const by = y?.bandwidth ? y.bandwidth() : 0; return create("svg:g", context) .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {}, 0, 0) @@ -82,24 +85,14 @@ export class Rect extends Mark { g .append("path") .call(applyDirectStyles, this) - .attr("d", (i) => { - const ix = X1[i] > X2[i]; - const iy = Y1[i] > Y2[i]; - const x1 = (ix ? X2[i] : X1[i]) + insetLeft; - const x2 = (ix ? X1[i] : X2[i]) - insetRight; - const y1 = (iy ? Y2[i] : Y1[i]) + insetTop; - const y2 = (iy ? Y1[i] : Y2[i]) - insetBottom; - const r = Math.min(x2 - x1, y2 - y1) / 2; - const rx1y1i = Math.min(r, ix ? (iy ? rx2y2 : rx2y1) : iy ? rx1y2 : rx1y1); - const rx2y1i = Math.min(r, ix ? (iy ? rx1y2 : rx1y1) : iy ? rx2y2 : rx2y1); - const rx2y2i = Math.min(r, ix ? (iy ? rx1y1 : rx1y2) : iy ? rx2y1 : rx2y2); - const rx1y2i = Math.min(r, ix ? (iy ? rx2y1 : rx2y2) : iy ? rx1y1 : rx1y2); - return `M${x1},${y1 + rx1y1i}A${rx1y1i},${rx1y1i} 0 0 1 ${x1 + rx1y1i},${y1} - H${x2 - rx2y1i}A${rx2y1i},${rx2y1i} 0 0 1 ${x2},${y1 + rx2y1i} - V${y2 - rx2y2i}A${rx2y2i},${rx2y2i} 0 0 1 ${x2 - rx2y2i},${y2} - H${x1 + rx1y2i}A${rx1y2i},${rx1y2i} 0 0 1 ${x1},${y2 - rx1y2i} - Z`; - }) + .call( + applyRoundedRect, + this, + X1 ? (i) => X1[i] : () => marginLeft, + Y1 ? (i) => Y1[i] : () => marginTop, + X1 ? (X2 ? (i) => X2[i] : (i) => X1[i] + bx) : () => width - marginRight, + Y1 ? (Y2 ? (i) => Y2[i] : (i) => Y1[i] + by) : () => height - marginBottom + ) .call(applyChannelStyles, this, channels) : (g) => g @@ -107,7 +100,7 @@ export class Rect extends Mark { .call(applyDirectStyles, this) .attr( "x", - X1 && (projection || !isCollapsed(x)) + X1 ? X2 ? (i) => Math.min(X1[i], X2[i]) + insetLeft : (i) => X1[i] + insetLeft @@ -115,7 +108,7 @@ export class Rect extends Mark { ) .attr( "y", - Y1 && (projection || !isCollapsed(y)) + Y1 ? Y2 ? (i) => Math.min(Y1[i], Y2[i]) + insetTop : (i) => Y1[i] + insetTop @@ -123,18 +116,18 @@ export class Rect extends Mark { ) .attr( "width", - X1 && (projection || !isCollapsed(x)) + X1 ? X2 - ? (i) => Math.max(0, Math.abs(X2[i] - X1[i]) + bx) - : bx + ? (i) => Math.max(0, Math.abs(X2[i] - X1[i]) + bx - insetLeft - insetRight) + : bx - insetLeft - insetRight : width - marginRight - marginLeft - insetRight - insetLeft ) .attr( "height", - Y1 && (projection || !isCollapsed(y)) + Y1 ? Y2 - ? (i) => Math.max(0, Math.abs(Y1[i] - Y2[i]) + by) - : by + ? (i) => Math.max(0, Math.abs(Y1[i] - Y2[i]) + by - insetTop - insetBottom) + : by - insetTop - insetBottom : height - marginTop - marginBottom - insetTop - insetBottom ) .call(applyAttr, "rx", rx) @@ -146,6 +139,35 @@ export class Rect extends Mark { } } +export function applyRoundedRect(selection, mark, X1, Y1, X2, Y2) { + const {insetTop, insetRight, insetBottom, insetLeft} = mark; + const {rx1y1, rx1y2, rx2y1, rx2y2} = mark; + selection.attr("d", (i) => { + const x1i = X1(i); + const y1i = Y1(i); + const x2i = X2(i); + const y2i = Y2(i); + const ix = x1i > x2i; + const iy = y1i > y2i; + const x1 = (ix ? x2i : x1i) + insetLeft; + const x2 = (ix ? x1i : x2i) - insetRight; + const y1 = (iy ? y2i : y1i) + insetTop; + const y2 = (iy ? y1i : y2i) - insetBottom; + const r = Math.min(x2 - x1, y2 - y1) / 2; + const rx1y1i = Math.min(r, ix ? (iy ? rx2y2 : rx2y1) : iy ? rx1y2 : rx1y1); + const rx2y1i = Math.min(r, ix ? (iy ? rx1y2 : rx1y1) : iy ? rx2y2 : rx2y1); + const rx2y2i = Math.min(r, ix ? (iy ? rx1y1 : rx1y2) : iy ? rx2y1 : rx2y2); + const rx1y2i = Math.min(r, ix ? (iy ? rx2y1 : rx2y2) : iy ? rx1y1 : rx1y2); + return ( + `M${x1},${y1 + rx1y1i}A${rx1y1i},${rx1y1i} 0 0 1 ${x1 + rx1y1i},${y1}` + + `H${x2 - rx2y1i}A${rx2y1i},${rx2y1i} 0 0 1 ${x2},${y1 + rx2y1i}` + + `V${y2 - rx2y2i}A${rx2y2i},${rx2y2i} 0 0 1 ${x2 - rx2y2i},${y2}` + + `H${x1 + rx1y2i}A${rx1y2i},${rx1y2i} 0 0 1 ${x1},${y2 - rx1y2i}` + + `Z` + ); + }); +} + export function rect(data, options) { return new Rect(data, maybeTrivialIntervalX(maybeTrivialIntervalY(options))); } diff --git a/test/output/roundedRect.svg b/test/output/roundedRect.svg index 381921d3b2..14d37afb87 100644 --- a/test/output/roundedRect.svg +++ b/test/output/roundedRect.svg @@ -59,115 +59,51 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + \ No newline at end of file diff --git a/test/output/roundedRectBand.svg b/test/output/roundedRectBand.svg new file mode 100644 index 0000000000..aa076b27de --- /dev/null +++ b/test/output/roundedRectBand.svg @@ -0,0 +1,62 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 1 + 2 + 3 + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/roundedRectCollapsedX.svg b/test/output/roundedRectCollapsedX.svg new file mode 100644 index 0000000000..52a8315c5c --- /dev/null +++ b/test/output/roundedRectCollapsedX.svg @@ -0,0 +1,52 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 1 + + + + + + \ No newline at end of file diff --git a/test/output/roundedRectCollapsedY.svg b/test/output/roundedRectCollapsedY.svg new file mode 100644 index 0000000000..f275bc8415 --- /dev/null +++ b/test/output/roundedRectCollapsedY.svg @@ -0,0 +1,52 @@ + + + + + 1 + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + + + + \ No newline at end of file diff --git a/test/output/roundedRectPartial.svg b/test/output/roundedRectPartial.svg new file mode 100644 index 0000000000..fdf2bb40a4 --- /dev/null +++ b/test/output/roundedRectPartial.svg @@ -0,0 +1,62 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 1 + 2 + 3 + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/roundedRectSides.svg b/test/output/roundedRectSides.svg index 5c8764dcfb..cef52eb026 100644 --- a/test/output/roundedRectSides.svg +++ b/test/output/roundedRectSides.svg @@ -59,115 +59,51 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + \ No newline at end of file diff --git a/test/plots/rounded-rect.ts b/test/plots/rounded-rect.ts index 5352ad6798..01b2f54f5a 100644 --- a/test/plots/rounded-rect.ts +++ b/test/plots/rounded-rect.ts @@ -26,6 +26,33 @@ export function roundedRect() { }); } +export function roundedRectBand() { + return Plot.plot({ + x: {inset: 2}, + y: {reverse: true}, + padding: 0, + marks: [ + Plot.frame(), + Plot.rect({length: 1}, {x: 1, y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2, ry1: 20}), + Plot.rect({length: 1}, {x: 2, y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2, ry1: 20}), + Plot.rect({length: 1}, {x: 3, y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2, ry1: 20}) + ] + }); +} + +export function roundedRectCollapsedX() { + return Plot.plot({ + y: {reverse: true}, + marks: [Plot.frame(), Plot.rect({length: 1}, {x2: 1, y1: 0, y2: 1, inset: 4, ry1: 20})] + }); +} + +export function roundedRectCollapsedY() { + return Plot.plot({ + marks: [Plot.frame(), Plot.rect({length: 1}, {x1: 0, x2: 1, y2: 1, inset: 4, ry1: 20})] + }); +} + export function roundedRectSides() { return Plot.plot({ y: {reverse: true}, From 1b9c2fcb4b772b80732cb6dd0a97a9396a0f595f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 11 Jul 2024 09:01:29 -0400 Subject: [PATCH 03/20] remove unused test snapshot --- test/output/roundedRectPartial.svg | 62 ------------------------------ 1 file changed, 62 deletions(-) delete mode 100644 test/output/roundedRectPartial.svg diff --git a/test/output/roundedRectPartial.svg b/test/output/roundedRectPartial.svg deleted file mode 100644 index fdf2bb40a4..0000000000 --- a/test/output/roundedRectPartial.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - 1.0 - 0.9 - 0.8 - 0.7 - 0.6 - 0.5 - 0.4 - 0.3 - 0.2 - 0.1 - 0.0 - - - - 1 - 2 - 3 - - - - - - - - - - - - \ No newline at end of file From 94cab02b63bbae6e9f2737906c86e489e928ffed Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 11 Jul 2024 09:32:44 -0400 Subject: [PATCH 04/20] ratio-preserving radius reduction --- src/marks/rect.js | 14 ++- test/output/roundedRectAsymmetricX.svg | 67 +++++++++++++ test/output/roundedRectAsymmetricY.svg | 67 +++++++++++++ ...roundedRect.svg => roundedRectCorners.svg} | 32 +++--- test/output/roundedRectSides.svg | 32 +++--- test/plots/rounded-rect.ts | 98 ++++++++++++------- 6 files changed, 240 insertions(+), 70 deletions(-) create mode 100644 test/output/roundedRectAsymmetricX.svg create mode 100644 test/output/roundedRectAsymmetricY.svg rename test/output/{roundedRect.svg => roundedRectCorners.svg} (80%) diff --git a/src/marks/rect.js b/src/marks/rect.js index ec8e6dce4f..422a6b5705 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -153,11 +153,15 @@ export function applyRoundedRect(selection, mark, X1, Y1, X2, Y2) { const x2 = (ix ? x1i : x2i) - insetRight; const y1 = (iy ? y2i : y1i) + insetTop; const y2 = (iy ? y1i : y2i) - insetBottom; - const r = Math.min(x2 - x1, y2 - y1) / 2; - const rx1y1i = Math.min(r, ix ? (iy ? rx2y2 : rx2y1) : iy ? rx1y2 : rx1y1); - const rx2y1i = Math.min(r, ix ? (iy ? rx1y2 : rx1y1) : iy ? rx2y2 : rx2y1); - const rx2y2i = Math.min(r, ix ? (iy ? rx1y1 : rx1y2) : iy ? rx2y1 : rx2y2); - const rx1y2i = Math.min(r, ix ? (iy ? rx2y1 : rx2y2) : iy ? rx1y1 : rx1y2); + const k = Math.min( + 1, + (x2 - x1) / Math.max(rx1y1 + rx2y1, rx1y2 + rx2y2), + (y2 - y1) / Math.max(rx1y1 + rx1y2, rx2y1 + rx2y2) + ); + const rx1y1i = k * (ix ? (iy ? rx2y2 : rx2y1) : iy ? rx1y2 : rx1y1); + const rx2y1i = k * (ix ? (iy ? rx1y2 : rx1y1) : iy ? rx2y2 : rx2y1); + const rx2y2i = k * (ix ? (iy ? rx1y1 : rx1y2) : iy ? rx2y1 : rx2y2); + const rx1y2i = k * (ix ? (iy ? rx2y1 : rx2y2) : iy ? rx1y1 : rx1y2); return ( `M${x1},${y1 + rx1y1i}A${rx1y1i},${rx1y1i} 0 0 1 ${x1 + rx1y1i},${y1}` + `H${x2 - rx2y1i}A${rx2y1i},${rx2y1i} 0 0 1 ${x2},${y1 + rx2y1i}` + diff --git a/test/output/roundedRectAsymmetricX.svg b/test/output/roundedRectAsymmetricX.svg new file mode 100644 index 0000000000..a1d5c3aeb1 --- /dev/null +++ b/test/output/roundedRectAsymmetricX.svg @@ -0,0 +1,67 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/roundedRectAsymmetricY.svg b/test/output/roundedRectAsymmetricY.svg new file mode 100644 index 0000000000..2e13c85a60 --- /dev/null +++ b/test/output/roundedRectAsymmetricY.svg @@ -0,0 +1,67 @@ + + + + + 0 + 1 + 2 + 3 + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6 + 0.7 + 0.8 + 0.9 + 1.0 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/roundedRect.svg b/test/output/roundedRectCorners.svg similarity index 80% rename from test/output/roundedRect.svg rename to test/output/roundedRectCorners.svg index 14d37afb87..3ad86a954b 100644 --- a/test/output/roundedRect.svg +++ b/test/output/roundedRectCorners.svg @@ -59,51 +59,51 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + \ No newline at end of file diff --git a/test/output/roundedRectSides.svg b/test/output/roundedRectSides.svg index cef52eb026..eac354c73b 100644 --- a/test/output/roundedRectSides.svg +++ b/test/output/roundedRectSides.svg @@ -59,51 +59,51 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + \ No newline at end of file diff --git a/test/plots/rounded-rect.ts b/test/plots/rounded-rect.ts index 01b2f54f5a..7e8221b4dc 100644 --- a/test/plots/rounded-rect.ts +++ b/test/plots/rounded-rect.ts @@ -1,27 +1,59 @@ import * as Plot from "@observablehq/plot"; -export function roundedRect() { +export function roundedRectAsymmetricX() { + const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; + return Plot.plot({ + x: {inset: 2}, + y: {reverse: true}, + padding: 0, + marks: [ + Plot.frame(), + Plot.rect({length: 1}, {x: 0, ...xy, rx1y1: 500, rx2y1: 50}), + Plot.rect({length: 1}, {x: 1, ...xy, rx2y1: 500, rx1y1: 50}), + Plot.rect({length: 1}, {x: 2, ...xy, rx2y2: 500, rx1y2: 50}), + Plot.rect({length: 1}, {x: 3, ...xy, rx1y2: 500, rx2y2: 50}) + ] + }); +} + +export function roundedRectAsymmetricY() { + const xy = {x1: 0, x2: 1, inset: 4, insetTop: 2, insetBottom: 2}; + return Plot.plot({ + y: {inset: 2}, + height: 400, + padding: 0, + marks: [ + Plot.frame(), + Plot.rect({length: 1}, {y: 0, ...xy, rx1y1: 500, rx1y2: 50}), + Plot.rect({length: 1}, {y: 1, ...xy, rx2y1: 500, rx2y2: 50}), + Plot.rect({length: 1}, {y: 2, ...xy, rx2y2: 500, rx2y1: 50}), + Plot.rect({length: 1}, {y: 3, ...xy, rx1y2: 500, rx1y1: 50}) + ] + }); +} + +export function roundedRectCorners() { return Plot.plot({ y: {reverse: true}, inset: 4, marks: [ Plot.frame(), - Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 0, x2: 1, y1: 0, y2: 1, inset: 4, rx1y1: 20}), - Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 1, x2: 2, y1: 0, y2: 1, inset: 4, rx2y1: 20}), - Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 2, x2: 3, y1: 0, y2: 1, inset: 4, rx2y2: 20}), - Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 3, x2: 4, y1: 0, y2: 1, inset: 4, rx1y2: 20}), - Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 1, x2: 0, y1: 1, y2: 2, inset: 4, rx1y1: 20}), - Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 2, x2: 1, y1: 1, y2: 2, inset: 4, rx2y1: 20}), - Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 3, x2: 2, y1: 1, y2: 2, inset: 4, rx2y2: 20}), - Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 4, x2: 3, y1: 1, y2: 2, inset: 4, rx1y2: 20}), - Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 0, x2: 1, y1: 3, y2: 2, inset: 4, rx1y1: 20}), - Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 1, x2: 2, y1: 3, y2: 2, inset: 4, rx2y1: 20}), - Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 2, x2: 3, y1: 3, y2: 2, inset: 4, rx2y2: 20}), - Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 3, x2: 4, y1: 3, y2: 2, inset: 4, rx1y2: 20}), - Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 1, x2: 0, y1: 4, y2: 3, inset: 4, rx1y1: 20}), - Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 2, x2: 1, y1: 4, y2: 3, inset: 4, rx2y1: 20}), - Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 3, x2: 2, y1: 4, y2: 3, inset: 4, rx2y2: 20}), - Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 4, x2: 3, y1: 4, y2: 3, inset: 4, rx1y2: 20}) + Plot.rect({length: 1}, {x1: 0, x2: 1, y1: 0, y2: 1, inset: 4, rx1y1: 20}), + Plot.rect({length: 1}, {x1: 1, x2: 2, y1: 0, y2: 1, inset: 4, rx2y1: 20}), + Plot.rect({length: 1}, {x1: 2, x2: 3, y1: 0, y2: 1, inset: 4, rx2y2: 20}), + Plot.rect({length: 1}, {x1: 3, x2: 4, y1: 0, y2: 1, inset: 4, rx1y2: 20}), + Plot.rect({length: 1}, {x1: 1, x2: 0, y1: 1, y2: 2, inset: 4, rx1y1: 20}), + Plot.rect({length: 1}, {x1: 2, x2: 1, y1: 1, y2: 2, inset: 4, rx2y1: 20}), + Plot.rect({length: 1}, {x1: 3, x2: 2, y1: 1, y2: 2, inset: 4, rx2y2: 20}), + Plot.rect({length: 1}, {x1: 4, x2: 3, y1: 1, y2: 2, inset: 4, rx1y2: 20}), + Plot.rect({length: 1}, {x1: 0, x2: 1, y1: 3, y2: 2, inset: 4, rx1y1: 20}), + Plot.rect({length: 1}, {x1: 1, x2: 2, y1: 3, y2: 2, inset: 4, rx2y1: 20}), + Plot.rect({length: 1}, {x1: 2, x2: 3, y1: 3, y2: 2, inset: 4, rx2y2: 20}), + Plot.rect({length: 1}, {x1: 3, x2: 4, y1: 3, y2: 2, inset: 4, rx1y2: 20}), + Plot.rect({length: 1}, {x1: 1, x2: 0, y1: 4, y2: 3, inset: 4, rx1y1: 20}), + Plot.rect({length: 1}, {x1: 2, x2: 1, y1: 4, y2: 3, inset: 4, rx2y1: 20}), + Plot.rect({length: 1}, {x1: 3, x2: 2, y1: 4, y2: 3, inset: 4, rx2y2: 20}), + Plot.rect({length: 1}, {x1: 4, x2: 3, y1: 4, y2: 3, inset: 4, rx1y2: 20}) ] }); } @@ -59,22 +91,22 @@ export function roundedRectSides() { inset: 4, marks: [ Plot.frame(), - Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 0, x2: 1, y1: 0, y2: 1, inset: 4, rx1: 20}), - Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 1, x2: 2, y1: 0, y2: 1, inset: 4, ry1: 20}), - Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 2, x2: 3, y1: 0, y2: 1, inset: 4, rx2: 20}), - Plot.rect({length: 1}, {fill: ["x1y1x2y2"], x1: 3, x2: 4, y1: 0, y2: 1, inset: 4, ry2: 20}), - Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 1, x2: 0, y1: 1, y2: 2, inset: 4, rx1: 20}), - Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 2, x2: 1, y1: 1, y2: 2, inset: 4, ry1: 20}), - Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 3, x2: 2, y1: 1, y2: 2, inset: 4, rx2: 20}), - Plot.rect({length: 1}, {fill: ["x2y1x1y2"], x1: 4, x2: 3, y1: 1, y2: 2, inset: 4, ry2: 20}), - Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 0, x2: 1, y1: 3, y2: 2, inset: 4, rx1: 20}), - Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 1, x2: 2, y1: 3, y2: 2, inset: 4, ry1: 20}), - Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 2, x2: 3, y1: 3, y2: 2, inset: 4, rx2: 20}), - Plot.rect({length: 1}, {fill: ["x1y2x2y1"], x1: 3, x2: 4, y1: 3, y2: 2, inset: 4, ry2: 20}), - Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 1, x2: 0, y1: 4, y2: 3, inset: 4, rx1: 20}), - Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 2, x2: 1, y1: 4, y2: 3, inset: 4, ry1: 20}), - Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 3, x2: 2, y1: 4, y2: 3, inset: 4, rx2: 20}), - Plot.rect({length: 1}, {fill: ["x2y2x1y1"], x1: 4, x2: 3, y1: 4, y2: 3, inset: 4, ry2: 20}) + Plot.rect({length: 1}, {x1: 0, x2: 1, y1: 0, y2: 1, inset: 4, rx1: 20}), + Plot.rect({length: 1}, {x1: 1, x2: 2, y1: 0, y2: 1, inset: 4, ry1: 20}), + Plot.rect({length: 1}, {x1: 2, x2: 3, y1: 0, y2: 1, inset: 4, rx2: 20}), + Plot.rect({length: 1}, {x1: 3, x2: 4, y1: 0, y2: 1, inset: 4, ry2: 20}), + Plot.rect({length: 1}, {x1: 1, x2: 0, y1: 1, y2: 2, inset: 4, rx1: 20}), + Plot.rect({length: 1}, {x1: 2, x2: 1, y1: 1, y2: 2, inset: 4, ry1: 20}), + Plot.rect({length: 1}, {x1: 3, x2: 2, y1: 1, y2: 2, inset: 4, rx2: 20}), + Plot.rect({length: 1}, {x1: 4, x2: 3, y1: 1, y2: 2, inset: 4, ry2: 20}), + Plot.rect({length: 1}, {x1: 0, x2: 1, y1: 3, y2: 2, inset: 4, rx1: 20}), + Plot.rect({length: 1}, {x1: 1, x2: 2, y1: 3, y2: 2, inset: 4, ry1: 20}), + Plot.rect({length: 1}, {x1: 2, x2: 3, y1: 3, y2: 2, inset: 4, rx2: 20}), + Plot.rect({length: 1}, {x1: 3, x2: 4, y1: 3, y2: 2, inset: 4, ry2: 20}), + Plot.rect({length: 1}, {x1: 1, x2: 0, y1: 4, y2: 3, inset: 4, rx1: 20}), + Plot.rect({length: 1}, {x1: 2, x2: 1, y1: 4, y2: 3, inset: 4, ry1: 20}), + Plot.rect({length: 1}, {x1: 3, x2: 2, y1: 4, y2: 3, inset: 4, rx2: 20}), + Plot.rect({length: 1}, {x1: 4, x2: 3, y1: 4, y2: 3, inset: 4, ry2: 20}) ] }); } From d9df8faa82371528c8305bd2eff2ef3f4c6b93b7 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 14 Jul 2024 09:24:11 -0400 Subject: [PATCH 05/20] r shorthand; more docs --- docs/features/marks.md | 6 ++- src/marks/rect.d.ts | 96 ++++++++++++++++++++++++++------------ src/marks/rect.js | 13 +++--- test/plots/rounded-rect.ts | 48 +++++++++++++++++++ 4 files changed, 124 insertions(+), 39 deletions(-) diff --git a/docs/features/marks.md b/docs/features/marks.md index da319afdb0..5c9c2a3ff2 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -537,8 +537,10 @@ The rectangular marks ([bar](../marks/bar.md), [cell](../marks/cell.md), [frame] * **insetRight** - inset the right edge * **insetBottom** - inset the bottom edge * **insetLeft** - inset the left edge -* **rx** - the [*x* radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx) for rounded corners -* **ry** - the [*y* radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry) for rounded corners +* **rx** - the [*x*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx) for rounded corners +* **ry** - the [*y*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry) for rounded corners + +TODO The rounded corner options should move to the rect mark documentation, since they are ballooning into quite a few additional options. The inset options also apply to the rule and tick marks. Insets are specified in pixels. Corner radii are specified in either pixels or percentages (strings). Both default to zero. Insets are typically used to ensure a one-pixel gap between adjacent bars; note that the [bin transform](../transforms/bin.md) provides default insets, and that the [band scale padding](./scales.md#position-scale-options) defaults to 0.1, which also provides separation. diff --git a/src/marks/rect.d.ts b/src/marks/rect.d.ts index a788d8cd5e..52a5b1a9a1 100644 --- a/src/marks/rect.d.ts +++ b/src/marks/rect.d.ts @@ -7,62 +7,96 @@ import type {StackOptions} from "../transforms/stack.js"; /** Options for marks that render rectangles, including bar, cell, and rect. */ export interface RectCornerOptions { /** - * The rounded corner [*x*-radius][1], either in pixels or as a percentage of - * the rect width. If **rx** is not specified, it defaults to **ry** if - * present, and otherwise draws square corners. This option is ignored if a - * more specific corner radius (one of **rx1y1**, **rx2y1**, **rx1y2**, or - * **rx2y2**) is specified. - * - * [1]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx + * The rounded radius for all corners, in pixels; shorthand for **rx1y1**, + * **rx2y1**, **rx2y2**, and **rx1y2**. If the combined corner radii for each + * side is greater than the corresponding length (width or height) of the + * rect, the corner radii will be shrunk proportionally to maintain circular + * corners. For elliptic corners, or to specify the corner radius as a + * proportion of the width or height, use **rx** and **ry** instead. */ - rx?: number | string; + r?: number; /** - * The rounded corner [*y*-radius][1], either in pixels or as a percentage of - * the rect height. If **ry** is not specified, it defaults to **rx** if - * present, and otherwise draws square corners. This option is ignored if a - * more specific corner radius (one of **rx1y1**, **rx2y1**, **rx1y2**, or - * **rx2y2**) is specified. - * - * [1]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry - */ - ry?: number | string; - - /** - * The rounded radius for the **x1** corners, in pixels; shorthand for - * **rx1y1** and **rx1y2**. + * The rounded radius for the **x1** corners (typically left for positive + * *x*-values), in pixels; shorthand for **rx1y1** and **rx1y2**. */ rx1?: number; /** - * The rounded radius for the **y1** corners, in pixels; shorthand for - * **rx1y1** and **rx2y1**. + * The rounded radius for the **y1** corners (typically bottom for positive + * *y*-values), in pixels; shorthand for **rx1y1** and **rx2y1**. */ ry1?: number; /** - * The rounded radius for the **x2** corners, in pixels; shorthand for - * **rx2y1** and **rx2y2**. + * The rounded radius for the **x2** corners (typically right for positive + * *x*-values), in pixels; shorthand for **rx2y1** and **rx2y2**. */ rx2?: number; /** - * The rounded radius for the **y2** corners, in pixels; shorthand for - * **rx1y2** and **rx2y2**. + * The rounded radius for the **y2** corners (typically top for positive + * *y*-values), in pixels; shorthand for **rx1y2** and **rx2y2**. */ ry2?: number; - /** The rounded radius for the **x1y1** corner, in pixels. */ + /** + * The rounded radius for the **x1y1** corner (typically bottom-left for + * positive values), in pixels. If **rx1y1** + **rx2y1** is greater than the + * width of the rect, or if **rx1y1** + **rx1y2** is greater than the height + * of the rect, the corner radii will be shrunk proportionally to maintain + * circular corners. + */ rx1y1?: number; - /** The rounded radius for the **x1y2** corner, in pixels. */ + /** + * The rounded radius for the **x1y2** corner (typically top-left for positive + * values), in pixels. If **rx1y2** + **rx2y2** is greater than the + * width of the rect, or if **rx1y1** + **rx1y2** is greater than the height + * of the rect, the corner radii will be shrunk proportionally to maintain + * circular corners. + */ rx1y2?: number; - /** The rounded radius for the **x2y1** corner, in pixels. */ + /** + * The rounded radius for the **x2y1** corner (typically bottom-right for + * positive values), in pixels. If **rx1y1** + **rx2y1** is greater than the + * width of the rect, or if **rx2y1** + **rx2y2** is greater than the height + * of the rect, the corner radii will be shrunk proportionally to maintain + * circular corners. + */ rx2y1?: number; - /** The rounded radius for the **x2y2** corner, in pixels. */ + /** + * The rounded radius for the **x2y2** corner (typically top-right for + * positive values), in pixels. If **rx1y2** + **rx2y2** is greater than the + * width of the rect, or if **rx2y1** + **rx2y2** is greater than the height + * of the rect, the corner radii will be shrunk proportionally to maintain + * circular corners. + */ rx2y2?: number; + + /** + * The rounded corner [*x*-radius][1], either in pixels or as a percentage of + * the rect width. If **rx** is not specified, it defaults to **ry** if + * present, and otherwise draws square corners. This option is ignored if a + * more specific corner radius (one of **rx1y1**, **rx2y1**, **rx1y2**, or + * **rx2y2**) is specified. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx + */ + rx?: number | string; + + /** + * The rounded corner [*y*-radius][1], either in pixels or as a percentage of + * the rect height. If **ry** is not specified, it defaults to **rx** if + * present, and otherwise draws square corners. This option is ignored if a + * more specific corner radius (one of **rx1y1**, **rx2y1**, **rx1y2**, or + * **rx2y2**) is specified. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry + */ + ry?: number | string; } /** Options for the rect mark. */ diff --git a/src/marks/rect.js b/src/marks/rect.js index 422a6b5705..a7106e349a 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -24,12 +24,13 @@ export class Rect extends Mark { insetRight = inset, insetBottom = inset, insetLeft = inset, - rx, - ry, - rx1, - ry1, - rx2, - ry2, + r, + rx, // for elliptic corners + ry, // for elliptic corners + rx1 = r, + ry1 = r, + rx2 = r, + ry2 = r, rx1y1 = rx1 !== undefined ? +rx1 : ry1 !== undefined ? +ry1 : 0, rx1y2 = rx1 !== undefined ? +rx1 : ry2 !== undefined ? +ry2 : 0, rx2y1 = rx2 !== undefined ? +rx2 : ry1 !== undefined ? +ry1 : 0, diff --git a/test/plots/rounded-rect.ts b/test/plots/rounded-rect.ts index 7e8221b4dc..1ed0176be1 100644 --- a/test/plots/rounded-rect.ts +++ b/test/plots/rounded-rect.ts @@ -1,5 +1,53 @@ import * as Plot from "@observablehq/plot"; +export function roundedRectR() { + const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; + return Plot.plot({ + x: {inset: 2}, + y: {reverse: true}, + padding: 0, + marks: [ + Plot.frame(), + Plot.rect({length: 1}, {x: 0, ...xy, r: 25}), + Plot.rect({length: 1}, {x: 1, ...xy, r: 50}), + Plot.rect({length: 1}, {x: 2, ...xy, r: 75}), + Plot.rect({length: 1}, {x: 3, ...xy, r: 100}) + ] + }); +} + +export function roundedRectRx() { + const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; + return Plot.plot({ + x: {inset: 2}, + y: {reverse: true}, + padding: 0, + marks: [ + Plot.frame(), + Plot.rect({length: 1}, {x: 0, ...xy, rx: 25}), + Plot.rect({length: 1}, {x: 1, ...xy, rx: 50}), + Plot.rect({length: 1}, {x: 2, ...xy, rx: 75}), + Plot.rect({length: 1}, {x: 3, ...xy, rx: 100}) + ] + }); +} + +export function roundedRectRy() { + const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; + return Plot.plot({ + x: {inset: 2}, + y: {reverse: true}, + padding: 0, + marks: [ + Plot.frame(), + Plot.rect({length: 1}, {x: 0, ...xy, ry: 25}), + Plot.rect({length: 1}, {x: 1, ...xy, ry: 50}), + Plot.rect({length: 1}, {x: 2, ...xy, ry: 75}), + Plot.rect({length: 1}, {x: 3, ...xy, ry: 100}) + ] + }); +} + export function roundedRectAsymmetricX() { const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; return Plot.plot({ From c7cc4afe21ed96d6c66bcf651d73c924feadfbec Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 14 Jul 2024 09:46:14 -0400 Subject: [PATCH 06/20] rounded frame --- src/marks/frame.d.ts | 4 +- src/marks/frame.js | 15 +++-- src/marks/rect.js | 117 +++++++++++++++++----------------- test/output/roundedRectR.svg | 67 +++++++++++++++++++ test/output/roundedRectRx.svg | 67 +++++++++++++++++++ test/output/roundedRectRy.svg | 67 +++++++++++++++++++ 6 files changed, 271 insertions(+), 66 deletions(-) create mode 100644 test/output/roundedRectR.svg create mode 100644 test/output/roundedRectRx.svg create mode 100644 test/output/roundedRectRy.svg diff --git a/src/marks/frame.d.ts b/src/marks/frame.d.ts index 5d0e456823..da2cb6a147 100644 --- a/src/marks/frame.d.ts +++ b/src/marks/frame.d.ts @@ -6,8 +6,8 @@ import type {RectCornerOptions} from "./rect.js"; export interface FrameOptions extends MarkOptions, InsetOptions, RectCornerOptions { /** * If null (default), the rectangular outline of the frame is drawn; otherwise - * the frame is drawn as a line only on the given side, and the **rx**, - * **ry**, **fill**, and **fillOpacity** options are ignored. + * the frame is drawn as a line only on the given side, and the corner radii + * (**r** *etc.*) and fill (**fill** and **fillOpacity**) options are ignored. */ anchor?: "top" | "right" | "bottom" | "left" | null; } diff --git a/src/marks/frame.js b/src/marks/frame.js index 372678cf33..531a090e8f 100644 --- a/src/marks/frame.js +++ b/src/marks/frame.js @@ -2,6 +2,7 @@ import {create} from "../context.js"; import {Mark} from "../mark.js"; import {maybeKeyword, number, singleton} from "../options.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; +import {corners, pathRoundedRect} from "./rect.js"; const defaults = { ariaLabel: "frame", @@ -26,9 +27,7 @@ export class Frame extends Mark { insetTop = inset, insetRight = inset, insetBottom = inset, - insetLeft = inset, - rx, - ry + insetLeft = inset } = options; super(singleton, undefined, options, anchor == null ? defaults : lineDefaults); this.anchor = maybeKeyword(anchor, "anchor", ["top", "right", "bottom", "left"]); @@ -36,17 +35,17 @@ export class Frame extends Mark { this.insetRight = number(insetRight); this.insetBottom = number(insetBottom); this.insetLeft = number(insetLeft); - this.rx = number(rx); - this.ry = number(ry); + if (!anchor) corners(this, options); } render(index, scales, channels, dimensions, context) { const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; - const {anchor, insetTop, insetRight, insetBottom, insetLeft, rx, ry} = this; + const {anchor, insetTop, insetRight, insetBottom, insetLeft} = this; + const {rx, ry, rx1y1, rx1y2, rx2y1, rx2y2} = this; const x1 = marginLeft + insetLeft; const x2 = width - marginRight - insetRight; const y1 = marginTop + insetTop; const y2 = height - marginBottom - insetBottom; - return create(anchor ? "svg:line" : "svg:rect", context) + return create(anchor ? "svg:line" : rx1y1 || rx1y2 || rx2y1 || rx2y2 ? "svg:path" : "svg:rect", context) .datum(0) .call(applyIndirectStyles, this, dimensions, context) .call(applyDirectStyles, this) @@ -61,6 +60,8 @@ export class Frame extends Mark { ? (line) => line.attr("x1", x1).attr("x2", x2).attr("y1", y1).attr("y2", y1) : anchor === "bottom" ? (line) => line.attr("x1", x1).attr("x2", x2).attr("y1", y2).attr("y2", y2) + : rx1y1 || rx1y2 || rx2y1 || rx2y2 + ? (path) => path.attr("d", pathRoundedRect(x1, y1, x2, y2, this)) : (rect) => rect .attr("x", x1) diff --git a/src/marks/rect.js b/src/marks/rect.js index a7106e349a..c3a41e1e1e 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -23,18 +23,7 @@ export class Rect extends Mark { insetTop = inset, insetRight = inset, insetBottom = inset, - insetLeft = inset, - r, - rx, // for elliptic corners - ry, // for elliptic corners - rx1 = r, - ry1 = r, - rx2 = r, - ry2 = r, - rx1y1 = rx1 !== undefined ? +rx1 : ry1 !== undefined ? +ry1 : 0, - rx1y2 = rx1 !== undefined ? +rx1 : ry2 !== undefined ? +ry2 : 0, - rx2y1 = rx2 !== undefined ? +rx2 : ry1 !== undefined ? +ry1 : 0, - rx2y2 = rx2 !== undefined ? +rx2 : ry2 !== undefined ? +ry2 : 0 + insetLeft = inset } = options; super( data, @@ -51,15 +40,7 @@ export class Rect extends Mark { this.insetRight = number(insetRight); this.insetBottom = number(insetBottom); this.insetLeft = number(insetLeft); - if (rx1y1 || rx1y2 || rx2y1 || rx2y2) { - this.rx1y1 = Math.max(0, rx1y1); - this.rx1y2 = Math.max(0, rx1y2); - this.rx2y1 = Math.max(0, rx2y1); - this.rx2y2 = Math.max(0, rx2y2); - } else { - this.rx = impliedString(rx, "auto"); // number or percentage - this.ry = impliedString(ry, "auto"); - } + corners(this, options); } render(index, scales, channels, dimensions, context) { const {x, y} = scales; @@ -86,13 +67,14 @@ export class Rect extends Mark { g .append("path") .call(applyDirectStyles, this) - .call( - applyRoundedRect, - this, - X1 ? (i) => X1[i] : () => marginLeft, - Y1 ? (i) => Y1[i] : () => marginTop, - X1 ? (X2 ? (i) => X2[i] : (i) => X1[i] + bx) : () => width - marginRight, - Y1 ? (Y2 ? (i) => Y2[i] : (i) => Y1[i] + by) : () => height - marginBottom + .attr("d", (i) => + pathRoundedRect( + X1 ? X1[i] : marginLeft, + Y1 ? Y1[i] : marginTop, + X1 ? (X2 ? X2[i] : X1[i] + bx) : width - marginRight, + Y1 ? (Y2 ? Y2[i] : Y1[i] + by) : height - marginBottom, + this + ) ) .call(applyChannelStyles, this, channels) : (g) => @@ -140,37 +122,58 @@ export class Rect extends Mark { } } +export function corners( + mark, + { + r, + rx, // for elliptic corners + ry, // for elliptic corners + rx1 = r, + ry1 = r, + rx2 = r, + ry2 = r, + rx1y1 = rx1 !== undefined ? +rx1 : ry1 !== undefined ? +ry1 : 0, + rx1y2 = rx1 !== undefined ? +rx1 : ry2 !== undefined ? +ry2 : 0, + rx2y1 = rx2 !== undefined ? +rx2 : ry1 !== undefined ? +ry1 : 0, + rx2y2 = rx2 !== undefined ? +rx2 : ry2 !== undefined ? +ry2 : 0 + } = {} +) { + if (rx1y1 || rx1y2 || rx2y1 || rx2y2) { + mark.rx1y1 = Math.max(0, rx1y1); + mark.rx1y2 = Math.max(0, rx1y2); + mark.rx2y1 = Math.max(0, rx2y1); + mark.rx2y2 = Math.max(0, rx2y2); + } else { + mark.rx = impliedString(rx, "auto"); // number or percentage + mark.ry = impliedString(ry, "auto"); + } +} + export function applyRoundedRect(selection, mark, X1, Y1, X2, Y2) { + selection.attr("d", (i) => pathRoundedRect(X1(i), Y1(i), X2(i), Y2(i), mark)); +} + +export function pathRoundedRect(x1, y1, x2, y2, mark) { const {insetTop, insetRight, insetBottom, insetLeft} = mark; - const {rx1y1, rx1y2, rx2y1, rx2y2} = mark; - selection.attr("d", (i) => { - const x1i = X1(i); - const y1i = Y1(i); - const x2i = X2(i); - const y2i = Y2(i); - const ix = x1i > x2i; - const iy = y1i > y2i; - const x1 = (ix ? x2i : x1i) + insetLeft; - const x2 = (ix ? x1i : x2i) - insetRight; - const y1 = (iy ? y2i : y1i) + insetTop; - const y2 = (iy ? y1i : y2i) - insetBottom; - const k = Math.min( - 1, - (x2 - x1) / Math.max(rx1y1 + rx2y1, rx1y2 + rx2y2), - (y2 - y1) / Math.max(rx1y1 + rx1y2, rx2y1 + rx2y2) - ); - const rx1y1i = k * (ix ? (iy ? rx2y2 : rx2y1) : iy ? rx1y2 : rx1y1); - const rx2y1i = k * (ix ? (iy ? rx1y2 : rx1y1) : iy ? rx2y2 : rx2y1); - const rx2y2i = k * (ix ? (iy ? rx1y1 : rx1y2) : iy ? rx2y1 : rx2y2); - const rx1y2i = k * (ix ? (iy ? rx2y1 : rx2y2) : iy ? rx1y1 : rx1y2); - return ( - `M${x1},${y1 + rx1y1i}A${rx1y1i},${rx1y1i} 0 0 1 ${x1 + rx1y1i},${y1}` + - `H${x2 - rx2y1i}A${rx2y1i},${rx2y1i} 0 0 1 ${x2},${y1 + rx2y1i}` + - `V${y2 - rx2y2i}A${rx2y2i},${rx2y2i} 0 0 1 ${x2 - rx2y2i},${y2}` + - `H${x1 + rx1y2i}A${rx1y2i},${rx1y2i} 0 0 1 ${x1},${y2 - rx1y2i}` + - `Z` - ); - }); + const {rx1y1: r11, rx1y2: r12, rx2y1: r21, rx2y2: r22} = mark; + const ix = x1 > x2; + const iy = y1 > y2; + const l = (ix ? x2 : x1) + insetLeft; + const r = (ix ? x1 : x2) - insetRight; + const t = (iy ? y2 : y1) + insetTop; + const b = (iy ? y1 : y2) - insetBottom; + const k = Math.min(1, (r - l) / Math.max(r11 + r21, r12 + r22), (b - t) / Math.max(r11 + r12, r21 + r22)); + const tl = k * (ix ? (iy ? r22 : r21) : iy ? r12 : r11); + const tr = k * (ix ? (iy ? r12 : r11) : iy ? r22 : r21); + const br = k * (ix ? (iy ? r11 : r12) : iy ? r21 : r22); + const bl = k * (ix ? (iy ? r21 : r22) : iy ? r11 : r12); + return ( + `M${l},${t + tl}A${tl},${tl} 0 0 1 ${l + tl},${t}` + + `H${r - tr}A${tr},${tr} 0 0 1 ${r},${t + tr}` + + `V${b - br}A${br},${br} 0 0 1 ${r - br},${b}` + + `H${l + bl}A${bl},${bl} 0 0 1 ${l},${b - bl}` + + `Z` + ); } export function rect(data, options) { diff --git a/test/output/roundedRectR.svg b/test/output/roundedRectR.svg new file mode 100644 index 0000000000..8b0c2c4a28 --- /dev/null +++ b/test/output/roundedRectR.svg @@ -0,0 +1,67 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/roundedRectRx.svg b/test/output/roundedRectRx.svg new file mode 100644 index 0000000000..d7c63747fd --- /dev/null +++ b/test/output/roundedRectRx.svg @@ -0,0 +1,67 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/roundedRectRy.svg b/test/output/roundedRectRy.svg new file mode 100644 index 0000000000..13225cf88d --- /dev/null +++ b/test/output/roundedRectRy.svg @@ -0,0 +1,67 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + \ No newline at end of file From 2c34dda382c5374015f9e3ef2caa2f6e338f6797 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 14 Jul 2024 14:39:37 -0400 Subject: [PATCH 07/20] remove unused applyRoundedRect --- src/marks/rect.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/marks/rect.js b/src/marks/rect.js index c3a41e1e1e..1c99ddb5fb 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -149,10 +149,6 @@ export function corners( } } -export function applyRoundedRect(selection, mark, X1, Y1, X2, Y2) { - selection.attr("d", (i) => pathRoundedRect(X1(i), Y1(i), X2(i), Y2(i), mark)); -} - export function pathRoundedRect(x1, y1, x2, y2, mark) { const {insetTop, insetRight, insetBottom, insetLeft} = mark; const {rx1y1: r11, rx1y2: r12, rx2y1: r21, rx2y2: r22} = mark; From 98481c125d4493b2bf3bddb599e0d50b3cf0e393 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 14 Jul 2024 15:13:18 -0400 Subject: [PATCH 08/20] rounded bar & cell --- docs/marks/box.md | 1 + src/marks/bar.js | 52 +++++++++++++++++---------- src/marks/box.js | 6 ++-- src/marks/frame.js | 20 +++-------- src/marks/rect.js | 56 +++++++++++++++-------------- test/output/roundedBarYR.svg | 67 +++++++++++++++++++++++++++++++++++ test/output/roundedBarYRx.svg | 67 +++++++++++++++++++++++++++++++++++ test/output/roundedBarYRy.svg | 67 +++++++++++++++++++++++++++++++++++ test/plots/rounded-rect.ts | 48 +++++++++++++++++++++++++ 9 files changed, 322 insertions(+), 62 deletions(-) create mode 100644 test/output/roundedBarYR.svg create mode 100644 test/output/roundedBarYRx.svg create mode 100644 test/output/roundedBarYRy.svg diff --git a/docs/marks/box.md b/docs/marks/box.md index a4bdfa5970..2ea4b49278 100644 --- a/docs/marks/box.md +++ b/docs/marks/box.md @@ -121,6 +121,7 @@ The given *options* are passed through to these underlying marks, with the excep * **stroke** - the stroke color of the rule, tick, and dot; defaults to *currentColor* * **strokeOpacity** - the stroke opacity of the rule, tick, and dot; defaults to 1 * **strokeWidth** - the stroke width of the tick; defaults to 1 +* **r** - the radius of the dot; defaults to 3 ## boxX(*data*, *options*) {#boxX} diff --git a/src/marks/bar.js b/src/marks/bar.js index b0ddd21b2c..cf4568775a 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -1,26 +1,29 @@ import {create} from "../context.js"; import {Mark} from "../mark.js"; -import {hasXY, identity, indexOf, number} from "../options.js"; +import {constant, hasXY, identity, indexOf} from "../options.js"; import {isCollapsed} from "../scales.js"; import {applyAttr, applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; -import {impliedString} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; +import {pathRoundedRect, rectInsets, rectRadii} from "./rect.js"; export class AbstractBar extends Mark { constructor(data, channels, options = {}, defaults) { super(data, channels, options, defaults); - const {inset = 0, insetTop = inset, insetRight = inset, insetBottom = inset, insetLeft = inset, rx, ry} = options; - this.insetTop = number(insetTop); - this.insetRight = number(insetRight); - this.insetBottom = number(insetBottom); - this.insetLeft = number(insetLeft); - this.rx = impliedString(rx, "auto"); // number or percentage - this.ry = impliedString(ry, "auto"); + rectInsets(this, options); + rectRadii(this, options); } render(index, scales, channels, dimensions, context) { - const {rx, ry} = this; + const {rx, ry, rx1y1, rx1y2, rx2y1, rx2y2} = this; + let x = this._x(scales, channels, dimensions); + let y = this._y(scales, channels, dimensions); + let w = this._width(scales, channels, dimensions); + let h = this._height(scales, channels, dimensions); + if (typeof x !== "function") x = constant(x); // TODO optimize + if (typeof y !== "function") y = constant(y); // TODO optimize + if (typeof w !== "function") w = constant(w); // TODO optimize + if (typeof h !== "function") h = constant(h); // TODO optimize return create("svg:g", context) .call(applyIndirectStyles, this, dimensions, context) .call(this._transform, this, scales) @@ -29,15 +32,26 @@ export class AbstractBar extends Mark { .selectAll() .data(index) .enter() - .append("rect") - .call(applyDirectStyles, this) - .attr("x", this._x(scales, channels, dimensions)) - .attr("width", this._width(scales, channels, dimensions)) - .attr("y", this._y(scales, channels, dimensions)) - .attr("height", this._height(scales, channels, dimensions)) - .call(applyAttr, "rx", rx) - .call(applyAttr, "ry", ry) - .call(applyChannelStyles, this, channels) + .call( + rx1y1 || rx1y2 || rx2y1 || rx2y2 + ? (g) => + g + .append("path") + .call(applyDirectStyles, this) + .attr("d", (i) => pathRoundedRect(x(i), y(i), x(i) + w(i), y(i) + h(i), this)) + .call(applyChannelStyles, this, channels) + : (g) => + g + .append("rect") + .call(applyDirectStyles, this) + .attr("x", x) + .attr("width", w) + .attr("y", y) + .attr("height", h) + .call(applyAttr, "rx", rx) + .call(applyAttr, "ry", ry) + .call(applyChannelStyles, this, channels) + ) ) .node(); } diff --git a/src/marks/box.js b/src/marks/box.js index 7d74db6972..27d8fa6ee5 100644 --- a/src/marks/box.js +++ b/src/marks/box.js @@ -15,6 +15,7 @@ export function boxX( { x = identity, y = null, + r, fill = "#ccc", fillOpacity, stroke = "currentColor", @@ -29,7 +30,7 @@ export function boxX( ruleY(data, group({x1: loqr1, x2: hiqr2}, {x, y, stroke, strokeOpacity, ...options})), barX(data, group({x1: "p25", x2: "p75"}, {x, y, fill, fillOpacity, ...options})), tickX(data, group({x: "p50"}, {x, y, stroke, strokeOpacity, strokeWidth, sort, ...options})), - dot(data, map({x: oqr}, {x, y, z: y, stroke, strokeOpacity, ...options})) + dot(data, map({x: oqr}, {x, y, z: y, r, stroke, strokeOpacity, ...options})) ); } @@ -40,6 +41,7 @@ export function boxY( { y = identity, x = null, + r, fill = "#ccc", fillOpacity, stroke = "currentColor", @@ -54,7 +56,7 @@ export function boxY( ruleX(data, group({y1: loqr1, y2: hiqr2}, {x, y, stroke, strokeOpacity, ...options})), barY(data, group({y1: "p25", y2: "p75"}, {x, y, fill, fillOpacity, ...options})), tickY(data, group({y: "p50"}, {x, y, stroke, strokeOpacity, strokeWidth, sort, ...options})), - dot(data, map({y: oqr}, {x, y, z: x, stroke, strokeOpacity, ...options})) + dot(data, map({y: oqr}, {x, y, z: x, r, stroke, strokeOpacity, ...options})) ); } diff --git a/src/marks/frame.js b/src/marks/frame.js index 531a090e8f..dce1559673 100644 --- a/src/marks/frame.js +++ b/src/marks/frame.js @@ -1,8 +1,8 @@ import {create} from "../context.js"; import {Mark} from "../mark.js"; -import {maybeKeyword, number, singleton} from "../options.js"; +import {maybeKeyword, singleton} from "../options.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; -import {corners, pathRoundedRect} from "./rect.js"; +import {rectInsets, rectRadii, pathRoundedRect} from "./rect.js"; const defaults = { ariaLabel: "frame", @@ -21,21 +21,11 @@ const lineDefaults = { export class Frame extends Mark { constructor(options = {}) { - const { - anchor = null, - inset = 0, - insetTop = inset, - insetRight = inset, - insetBottom = inset, - insetLeft = inset - } = options; + const {anchor = null} = options; super(singleton, undefined, options, anchor == null ? defaults : lineDefaults); this.anchor = maybeKeyword(anchor, "anchor", ["top", "right", "bottom", "left"]); - this.insetTop = number(insetTop); - this.insetRight = number(insetRight); - this.insetBottom = number(insetBottom); - this.insetLeft = number(insetLeft); - if (!anchor) corners(this, options); + rectInsets(this, options); + if (!anchor) rectRadii(this, options); } render(index, scales, channels, dimensions, context) { const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; diff --git a/src/marks/rect.js b/src/marks/rect.js index 1c99ddb5fb..d64e00e75b 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -14,17 +14,7 @@ const defaults = { export class Rect extends Mark { constructor(data, options = {}) { - const { - x1, - y1, - x2, - y2, - inset = 0, - insetTop = inset, - insetRight = inset, - insetBottom = inset, - insetLeft = inset - } = options; + const {x1, y1, x2, y2} = options; super( data, { @@ -36,11 +26,8 @@ export class Rect extends Mark { options, defaults ); - this.insetTop = number(insetTop); - this.insetRight = number(insetRight); - this.insetBottom = number(insetBottom); - this.insetLeft = number(insetLeft); - corners(this, options); + rectInsets(this, options); + rectRadii(this, options); } render(index, scales, channels, dimensions, context) { const {x, y} = scales; @@ -69,10 +56,18 @@ export class Rect extends Mark { .call(applyDirectStyles, this) .attr("d", (i) => pathRoundedRect( - X1 ? X1[i] : marginLeft, - Y1 ? Y1[i] : marginTop, - X1 ? (X2 ? X2[i] : X1[i] + bx) : width - marginRight, - Y1 ? (Y2 ? Y2[i] : Y1[i] + by) : height - marginBottom, + X1 ? X1[i] + (X2 && X2[i] < X1[i] ? -insetRight : insetLeft) : marginLeft + insetLeft, + Y1 ? Y1[i] + (Y2 && Y2[i] < Y1[i] ? -insetBottom : insetTop) : marginTop + insetTop, + X1 + ? X2 + ? X2[i] - (X2[i] < X1[i] ? -insetLeft : insetRight) + : X1[i] + bx - insetRight + : width - marginRight - insetRight, + Y1 + ? Y2 + ? Y2[i] - (Y2[i] < Y1[i] ? -insetTop : insetBottom) + : Y1[i] + by - insetBottom + : height - marginBottom - insetBottom, this ) ) @@ -122,7 +117,17 @@ export class Rect extends Mark { } } -export function corners( +export function rectInsets( + mark, + {inset = 0, insetTop = inset, insetRight = inset, insetBottom = inset, insetLeft = inset} = {} +) { + mark.insetTop = number(insetTop); + mark.insetRight = number(insetRight); + mark.insetBottom = number(insetBottom); + mark.insetLeft = number(insetLeft); +} + +export function rectRadii( mark, { r, @@ -150,14 +155,13 @@ export function corners( } export function pathRoundedRect(x1, y1, x2, y2, mark) { - const {insetTop, insetRight, insetBottom, insetLeft} = mark; const {rx1y1: r11, rx1y2: r12, rx2y1: r21, rx2y2: r22} = mark; const ix = x1 > x2; const iy = y1 > y2; - const l = (ix ? x2 : x1) + insetLeft; - const r = (ix ? x1 : x2) - insetRight; - const t = (iy ? y2 : y1) + insetTop; - const b = (iy ? y1 : y2) - insetBottom; + const l = ix ? x2 : x1; + const r = ix ? x1 : x2; + const t = iy ? y2 : y1; + const b = iy ? y1 : y2; const k = Math.min(1, (r - l) / Math.max(r11 + r21, r12 + r22), (b - t) / Math.max(r11 + r12, r21 + r22)); const tl = k * (ix ? (iy ? r22 : r21) : iy ? r12 : r11); const tr = k * (ix ? (iy ? r12 : r11) : iy ? r22 : r21); diff --git a/test/output/roundedBarYR.svg b/test/output/roundedBarYR.svg new file mode 100644 index 0000000000..1bc8edbd3b --- /dev/null +++ b/test/output/roundedBarYR.svg @@ -0,0 +1,67 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/roundedBarYRx.svg b/test/output/roundedBarYRx.svg new file mode 100644 index 0000000000..494b7a90a5 --- /dev/null +++ b/test/output/roundedBarYRx.svg @@ -0,0 +1,67 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/roundedBarYRy.svg b/test/output/roundedBarYRy.svg new file mode 100644 index 0000000000..83b67acfd1 --- /dev/null +++ b/test/output/roundedBarYRy.svg @@ -0,0 +1,67 @@ + + + + + 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1 + 0.0 + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/rounded-rect.ts b/test/plots/rounded-rect.ts index 1ed0176be1..f918f221a2 100644 --- a/test/plots/rounded-rect.ts +++ b/test/plots/rounded-rect.ts @@ -1,5 +1,53 @@ import * as Plot from "@observablehq/plot"; +export function roundedBarYR() { + const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; + return Plot.plot({ + x: {inset: 2}, + y: {reverse: true}, + padding: 0, + marks: [ + Plot.frame(), + Plot.barY({length: 1}, {x: 0, ...xy, r: 25}), + Plot.barY({length: 1}, {x: 1, ...xy, r: 50}), + Plot.barY({length: 1}, {x: 2, ...xy, r: 75}), + Plot.barY({length: 1}, {x: 3, ...xy, r: 100}) + ] + }); +} + +export function roundedBarYRx() { + const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; + return Plot.plot({ + x: {inset: 2}, + y: {reverse: true}, + padding: 0, + marks: [ + Plot.frame(), + Plot.barY({length: 1}, {x: 0, ...xy, rx: 25}), + Plot.barY({length: 1}, {x: 1, ...xy, rx: 50}), + Plot.barY({length: 1}, {x: 2, ...xy, rx: 75}), + Plot.barY({length: 1}, {x: 3, ...xy, rx: 100}) + ] + }); +} + +export function roundedBarYRy() { + const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; + return Plot.plot({ + x: {inset: 2}, + y: {reverse: true}, + padding: 0, + marks: [ + Plot.frame(), + Plot.barY({length: 1}, {x: 0, ...xy, ry: 25}), + Plot.barY({length: 1}, {x: 1, ...xy, ry: 50}), + Plot.barY({length: 1}, {x: 2, ...xy, ry: 75}), + Plot.barY({length: 1}, {x: 3, ...xy, ry: 100}) + ] + }); +} + export function roundedRectR() { const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; return Plot.plot({ From 83e3f55caf4a952308152ce6d1158766024bddc5 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 14 Jul 2024 15:22:15 -0400 Subject: [PATCH 09/20] cleaner --- src/marks/bar.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/marks/bar.js b/src/marks/bar.js index cf4568775a..299fb8d074 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -16,14 +16,10 @@ export class AbstractBar extends Mark { } render(index, scales, channels, dimensions, context) { const {rx, ry, rx1y1, rx1y2, rx2y1, rx2y2} = this; - let x = this._x(scales, channels, dimensions); - let y = this._y(scales, channels, dimensions); - let w = this._width(scales, channels, dimensions); - let h = this._height(scales, channels, dimensions); - if (typeof x !== "function") x = constant(x); // TODO optimize - if (typeof y !== "function") y = constant(y); // TODO optimize - if (typeof w !== "function") w = constant(w); // TODO optimize - if (typeof h !== "function") h = constant(h); // TODO optimize + const x = this._x(scales, channels, dimensions); + const y = this._y(scales, channels, dimensions); + const w = this._width(scales, channels, dimensions); + const h = this._height(scales, channels, dimensions); return create("svg:g", context) .call(applyIndirectStyles, this, dimensions, context) .call(this._transform, this, scales) @@ -38,7 +34,13 @@ export class AbstractBar extends Mark { g .append("path") .call(applyDirectStyles, this) - .attr("d", (i) => pathRoundedRect(x(i), y(i), x(i) + w(i), y(i) + h(i), this)) + .attr("d", (i) => { + const x1 = typeof x === "function" ? x(i) : x; + const y1 = typeof y === "function" ? y(i) : y; + const x2 = x1 + (typeof w === "function" ? w(i) : w); + const y2 = y1 + (typeof h === "function" ? h(i) : h); + return pathRoundedRect(x1, y1, x2, y2, this); + }) .call(applyChannelStyles, this, channels) : (g) => g From 00c82edf11d91db0cf3c3b1f472ca2238abfe417 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 14 Jul 2024 15:25:24 -0400 Subject: [PATCH 10/20] remove unused import --- src/marks/bar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/marks/bar.js b/src/marks/bar.js index 299fb8d074..7cb5398286 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -1,6 +1,6 @@ import {create} from "../context.js"; import {Mark} from "../mark.js"; -import {constant, hasXY, identity, indexOf} from "../options.js"; +import {hasXY, identity, indexOf} from "../options.js"; import {isCollapsed} from "../scales.js"; import {applyAttr, applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; From 6b2f510787099eeaa451a1e2e30db2d21d212298 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 16 Jul 2024 10:36:53 -0400 Subject: [PATCH 11/20] slightly tidier --- src/marks/bar.js | 20 ++++++----- src/marks/frame.js | 4 +-- src/marks/rect.js | 89 +++++++++++++++++++++++++++------------------- 3 files changed, 67 insertions(+), 46 deletions(-) diff --git a/src/marks/bar.js b/src/marks/bar.js index 7cb5398286..a17453730a 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -6,7 +6,7 @@ import {applyAttr, applyChannelStyles, applyDirectStyles, applyIndirectStyles, a import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; -import {pathRoundedRect, rectInsets, rectRadii} from "./rect.js"; +import {applyRoundedRect, rectInsets, rectRadii} from "./rect.js"; export class AbstractBar extends Mark { constructor(data, channels, options = {}, defaults) { @@ -34,13 +34,7 @@ export class AbstractBar extends Mark { g .append("path") .call(applyDirectStyles, this) - .attr("d", (i) => { - const x1 = typeof x === "function" ? x(i) : x; - const y1 = typeof y === "function" ? y(i) : y; - const x2 = x1 + (typeof w === "function" ? w(i) : w); - const y2 = y1 + (typeof h === "function" ? h(i) : h); - return pathRoundedRect(x1, y1, x2, y2, this); - }) + .call(applyRoundedRect, x, y, add(x, w), add(y, h), this) .call(applyChannelStyles, this, channels) : (g) => g @@ -77,6 +71,16 @@ export class AbstractBar extends Mark { } } +function add(a, b) { + return typeof a === "function" && typeof b === "function" + ? (i) => a(i) + b(i) + : typeof a === "function" + ? (i) => a(i) + b + : typeof b === "function" + ? (i) => a + b(i) + : a + b; +} + const defaults = { ariaLabel: "bar" }; diff --git a/src/marks/frame.js b/src/marks/frame.js index dce1559673..eca4084d26 100644 --- a/src/marks/frame.js +++ b/src/marks/frame.js @@ -2,7 +2,7 @@ import {create} from "../context.js"; import {Mark} from "../mark.js"; import {maybeKeyword, singleton} from "../options.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; -import {rectInsets, rectRadii, pathRoundedRect} from "./rect.js"; +import {applyRoundedRect, rectInsets, rectRadii} from "./rect.js"; const defaults = { ariaLabel: "frame", @@ -51,7 +51,7 @@ export class Frame extends Mark { : anchor === "bottom" ? (line) => line.attr("x1", x1).attr("x2", x2).attr("y1", y2).attr("y2", y2) : rx1y1 || rx1y2 || rx2y1 || rx2y2 - ? (path) => path.attr("d", pathRoundedRect(x1, y1, x2, y2, this)) + ? (path) => path.call(applyRoundedRect, x1, y1, x2, y2, this) : (rect) => rect .attr("x", x1) diff --git a/src/marks/rect.js b/src/marks/rect.js index d64e00e75b..40e23001f4 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -1,6 +1,6 @@ import {create} from "../context.js"; import {Mark} from "../mark.js"; -import {hasXY, identity, indexOf, number} from "../options.js"; +import {constant, hasXY, identity, indexOf, number} from "../options.js"; import {isCollapsed} from "../scales.js"; import {applyAttr, applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; import {impliedString} from "../style.js"; @@ -54,22 +54,29 @@ export class Rect extends Mark { g .append("path") .call(applyDirectStyles, this) - .attr("d", (i) => - pathRoundedRect( - X1 ? X1[i] + (X2 && X2[i] < X1[i] ? -insetRight : insetLeft) : marginLeft + insetLeft, - Y1 ? Y1[i] + (Y2 && Y2[i] < Y1[i] ? -insetBottom : insetTop) : marginTop + insetTop, - X1 - ? X2 - ? X2[i] - (X2[i] < X1[i] ? -insetLeft : insetRight) - : X1[i] + bx - insetRight - : width - marginRight - insetRight, - Y1 - ? Y2 - ? Y2[i] - (Y2[i] < Y1[i] ? -insetTop : insetBottom) - : Y1[i] + by - insetBottom - : height - marginBottom - insetBottom, - this - ) + .call( + applyRoundedRect, + X1 && X2 + ? (i) => X1[i] + (X2[i] < X1[i] ? -insetRight : insetLeft) + : X1 + ? (i) => X1[i] + insetLeft + : marginLeft + insetLeft, + Y1 && Y2 + ? (i) => Y1[i] + (Y2[i] < Y1[i] ? -insetBottom : insetTop) + : Y1 + ? (i) => Y1[i] + insetTop + : marginTop + insetTop, + X1 && X2 + ? (i) => X2[i] - (X2[i] < X1[i] ? -insetLeft : insetRight) + : X1 + ? (i) => X1[i] + bx - insetRight + : width - marginRight - insetRight, + Y1 && Y2 + ? (i) => Y2[i] - (Y2[i] < Y1[i] ? -insetTop : insetBottom) + : Y1 + ? (i) => Y1[i] + by - insetBottom + : height - marginBottom - insetBottom, + this ) .call(applyChannelStyles, this, channels) : (g) => @@ -154,26 +161,36 @@ export function rectRadii( } } -export function pathRoundedRect(x1, y1, x2, y2, mark) { +export function applyRoundedRect(selection, X1, Y1, X2, Y2, mark) { const {rx1y1: r11, rx1y2: r12, rx2y1: r21, rx2y2: r22} = mark; - const ix = x1 > x2; - const iy = y1 > y2; - const l = ix ? x2 : x1; - const r = ix ? x1 : x2; - const t = iy ? y2 : y1; - const b = iy ? y1 : y2; - const k = Math.min(1, (r - l) / Math.max(r11 + r21, r12 + r22), (b - t) / Math.max(r11 + r12, r21 + r22)); - const tl = k * (ix ? (iy ? r22 : r21) : iy ? r12 : r11); - const tr = k * (ix ? (iy ? r12 : r11) : iy ? r22 : r21); - const br = k * (ix ? (iy ? r11 : r12) : iy ? r21 : r22); - const bl = k * (ix ? (iy ? r21 : r22) : iy ? r11 : r12); - return ( - `M${l},${t + tl}A${tl},${tl} 0 0 1 ${l + tl},${t}` + - `H${r - tr}A${tr},${tr} 0 0 1 ${r},${t + tr}` + - `V${b - br}A${br},${br} 0 0 1 ${r - br},${b}` + - `H${l + bl}A${bl},${bl} 0 0 1 ${l},${b - bl}` + - `Z` - ); + if (typeof X1 !== "function") X1 = constant(X1); + if (typeof Y1 !== "function") Y1 = constant(Y1); + if (typeof X2 !== "function") X2 = constant(X2); + if (typeof Y2 !== "function") Y2 = constant(Y2); + selection.attr("d", (i) => { + const x1 = X1(i); + const y1 = Y1(i); + const x2 = X2(i); + const y2 = Y2(i); + const ix = x1 > x2; + const iy = y1 > y2; + const l = ix ? x2 : x1; + const r = ix ? x1 : x2; + const t = iy ? y2 : y1; + const b = iy ? y1 : y2; + const k = Math.min(1, (r - l) / Math.max(r11 + r21, r12 + r22), (b - t) / Math.max(r11 + r12, r21 + r22)); + const tl = k * (ix ? (iy ? r22 : r21) : iy ? r12 : r11); + const tr = k * (ix ? (iy ? r12 : r11) : iy ? r22 : r21); + const br = k * (ix ? (iy ? r11 : r12) : iy ? r21 : r22); + const bl = k * (ix ? (iy ? r21 : r22) : iy ? r11 : r12); + return ( + `M${l},${t + tl}A${tl},${tl} 0 0 1 ${l + tl},${t}` + + `H${r - tr}A${tr},${tr} 0 0 1 ${r},${t + tr}` + + `V${b - br}A${br},${br} 0 0 1 ${r - br},${b}` + + `H${l + bl}A${bl},${bl} 0 0 1 ${l},${b - bl}` + + `Z` + ); + }); } export function rect(data, options) { From aed6ad911e66a818807f8f889dc3eb8fb1f53d9e Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 16 Jul 2024 11:53:24 -0400 Subject: [PATCH 12/20] docs --- docs/features/marks.md | 8 ++------ docs/marks/rect.md | 21 ++++++++++++++++++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/features/marks.md b/docs/features/marks.md index 5c9c2a3ff2..47ac9d5feb 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -531,18 +531,14 @@ Plot.dot(numbers, {x: {transform: (data) => data}}) The **title**, **href**, and **ariaLabel** options can *only* be specified as channels. When these options are specified as a string, the string refers to the name of a column in the mark’s associated data. If you’d like every instance of a particular mark to have the same value, specify the option as a function that returns the desired value, *e.g.* `() => "Hello, world!"`. -The rectangular marks ([bar](../marks/bar.md), [cell](../marks/cell.md), [frame](../marks/frame.md), and [rect](../marks/rect.md)) support insets and rounded corner constant options: +Marks with horizontal or vertical extents ([rects](../marks/rect.md), [bars](../marks/bar.md), [cells](../marks/cell.md), [frames](../marks/frame.md), [rules](../marks/rule.md), and [ticks](../marks/tick.md)), support insets to adjust position. Insets are specified in pixels using the following options: * **insetTop** - inset the top edge * **insetRight** - inset the right edge * **insetBottom** - inset the bottom edge * **insetLeft** - inset the left edge -* **rx** - the [*x*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx) for rounded corners -* **ry** - the [*y*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry) for rounded corners -TODO The rounded corner options should move to the rect mark documentation, since they are ballooning into quite a few additional options. The inset options also apply to the rule and tick marks. - -Insets are specified in pixels. Corner radii are specified in either pixels or percentages (strings). Both default to zero. Insets are typically used to ensure a one-pixel gap between adjacent bars; note that the [bin transform](../transforms/bin.md) provides default insets, and that the [band scale padding](./scales.md#position-scale-options) defaults to 0.1, which also provides separation. +Insets default to zero. Insets are typically used to ensure a one-pixel gap between adjacent bars; the [bin transform](../transforms/bin.md) provides default insets. (Note that the [band scale padding](./scales.md#position-scale-options) defaults to 0.1 as an alternative to insets.) For marks that support the **frameAnchor** option, it may be specified as one of the four sides (*top*, *right*, *bottom*, *left*), one of the four corners (*top-left*, *top-right*, *bottom-right*, *bottom-left*), or the *middle* of the frame. diff --git a/docs/marks/rect.md b/docs/marks/rect.md index ef87e6d665..52ab0dc3a4 100644 --- a/docs/marks/rect.md +++ b/docs/marks/rect.md @@ -169,8 +169,7 @@ Plot.plot({ aspectRatio: 1, y: {ticks: 12, tickFormat: Plot.formatMonth("en", "narrow")}, marks: [ - Plot.rect(seattle, { - filter: (d) => d.date.getUTCFullYear() === 2015, + Plot.rect(seattle.filter((d) => d.date.getUTCFullYear() === 2015), { x: (d) => d.date.getUTCDate(), y: (d) => d.date.getUTCMonth(), interval: 1, @@ -199,7 +198,23 @@ If **x1** is specified but **x2** is not specified, then *x* must be a *band* sc If an **interval** is specified, such as d3.utcDay, **x1** and **x2** can be derived from **x**: *interval*.floor(*x*) is invoked for each **x** to produce **x1**, and *interval*.offset(*x1*) is invoked for each **x1** to produce **x2**. The same is true for **y**, **y1**, and **y2**, respectively. If the interval is specified as a number *n*, **x1** and **x2** are taken as the two consecutive multiples of *n* that bracket **x**. Named UTC intervals such as *day* are also supported; see [scale options](../features/scales.md#scale-options). -The rect mark supports the [standard mark options](../features/marks.md#mark-options), including insets and rounded corners. The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise. +The rect mark supports rounded corners. Each corner (or side) is individually addressable using the following options : + +* **r** - the radius for all four corners +* **rx1** - the radius for the **x1**-**y1** and **x1**-**y2** corners +* **rx2** - the radius for the **x2**-**y1** and **x2**-**y2** corners +* **ry1** - the radius for the **x1**-**y1** and **x2**-**y1** corners +* **ry2** - the radius for the **x1**-**y2** and **x2**-**y2** corners +* **rx1y1** - the radius for the **x1**-**y1** corner +* **rx1y2** - the radius for the **x1**-**y2** corner +* **rx2y1** - the radius for the **x2**-**y1** corner +* **rx2y2** - the radius for the **x2**-**y2** corner +* **rx** - the [*x*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx) for elliptical corners +* **ry** - the [*y*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry) for elliptical corners + +Corner radii are specified in either pixels or, for **rx** and **ry**, as percentages (strings) or the keyword *auto*. If the corner radii are too big, they are reduced proportionally. TODO The rounded corner options also apply to the [bar](./bar.md), [cell](./cell.md), and [frame](./frame.md) marks. + +The rect mark supports the [standard mark options](../features/marks.md#mark-options). The **stroke** defaults to *none*. The **fill** defaults to *currentColor* if the stroke is *none*, and to *none* otherwise. ## rect(*data*, *options*) {#rect} From 2fd036b20cc96d16d4fb24c880325d7303783dc4 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 17 Jul 2024 10:58:27 -0400 Subject: [PATCH 13/20] negative radii --- docs/features/marks.md | 4 +- docs/marks/rect.md | 14 ++ src/marks/rect.js | 22 ++- test/output/roundedRectNegativeX.html | 232 ++++++++++++++++++++++++++ test/output/roundedRectNegativeY.html | 228 +++++++++++++++++++++++++ test/plots/rounded-rect.ts | 24 +++ 6 files changed, 513 insertions(+), 11 deletions(-) create mode 100644 test/output/roundedRectNegativeX.html create mode 100644 test/output/roundedRectNegativeY.html diff --git a/docs/features/marks.md b/docs/features/marks.md index 47ac9d5feb..f520c6a0a2 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -531,14 +531,14 @@ Plot.dot(numbers, {x: {transform: (data) => data}}) The **title**, **href**, and **ariaLabel** options can *only* be specified as channels. When these options are specified as a string, the string refers to the name of a column in the mark’s associated data. If you’d like every instance of a particular mark to have the same value, specify the option as a function that returns the desired value, *e.g.* `() => "Hello, world!"`. -Marks with horizontal or vertical extents ([rects](../marks/rect.md), [bars](../marks/bar.md), [cells](../marks/cell.md), [frames](../marks/frame.md), [rules](../marks/rule.md), and [ticks](../marks/tick.md)), support insets to adjust position. Insets are specified in pixels using the following options: +Marks with horizontal or vertical extents ([rects](../marks/rect.md), [bars](../marks/bar.md), [cells](../marks/cell.md), [frames](../marks/frame.md), [rules](../marks/rule.md), and [ticks](../marks/tick.md)), support insets to adjust position: a positive inset moves the respective side in, whereas a negative inset moves the side out. Insets are specified in pixels using the following options: * **insetTop** - inset the top edge * **insetRight** - inset the right edge * **insetBottom** - inset the bottom edge * **insetLeft** - inset the left edge -Insets default to zero. Insets are typically used to ensure a one-pixel gap between adjacent bars; the [bin transform](../transforms/bin.md) provides default insets. (Note that the [band scale padding](./scales.md#position-scale-options) defaults to 0.1 as an alternative to insets.) +Insets default to zero. Insets are commonly used to create a one-pixel gap between adjacent bars in histograms; the [bin transform](../transforms/bin.md) provides default insets. (Note that the [band scale padding](./scales.md#position-scale-options) defaults to 0.1 as an alternative to insets.) For marks that support the **frameAnchor** option, it may be specified as one of the four sides (*top*, *right*, *bottom*, *left*), one of the four corners (*top-left*, *top-right*, *bottom-right*, *bottom-left*), or the *middle* of the frame. diff --git a/docs/marks/rect.md b/docs/marks/rect.md index 52ab0dc3a4..b78152a52d 100644 --- a/docs/marks/rect.md +++ b/docs/marks/rect.md @@ -137,6 +137,20 @@ Plot.plot({ A similar plot can be made with the [dot mark](./dot.md), if you’d prefer a size encoding. ::: +TODO Describe rounding. + +:::plot defer +```js +Plot.plot({ + color: {legend: true}, + marks: [ + Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex", ry2: 4, ry1: -4, clip: "frame"})), + Plot.ruleY([0]) + ] +}) +``` +::: + Below we recreate an uncommon [chart by Max Roser](https://ourworldindata.org/poverty-minimum-growth-needed) that visualizes global poverty. Each rect represents a country: *x* encodes the country’s population, while *y* encodes the proportion of that population living in poverty; hence area represents the number of people living in poverty. Rects are [stacked](../transforms/stack.md) along *x* in order of descending *y*. :::plot defer https://observablehq.com/@observablehq/plot-cumulative-distribution-of-poverty diff --git a/src/marks/rect.js b/src/marks/rect.js index 40e23001f4..6a7be5e11d 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -151,10 +151,10 @@ export function rectRadii( } = {} ) { if (rx1y1 || rx1y2 || rx2y1 || rx2y2) { - mark.rx1y1 = Math.max(0, rx1y1); - mark.rx1y2 = Math.max(0, rx1y2); - mark.rx2y1 = Math.max(0, rx2y1); - mark.rx2y2 = Math.max(0, rx2y2); + mark.rx1y1 = rx1y1; + mark.rx1y2 = rx1y2; + mark.rx2y1 = rx2y1; + mark.rx2y2 = rx2y2; } else { mark.rx = impliedString(rx, "auto"); // number or percentage mark.ry = impliedString(ry, "auto"); @@ -167,6 +167,8 @@ export function applyRoundedRect(selection, X1, Y1, X2, Y2, mark) { if (typeof Y1 !== "function") Y1 = constant(Y1); if (typeof X2 !== "function") X2 = constant(X2); if (typeof Y2 !== "function") Y2 = constant(Y2); + const f1 = Math.sign(r11) !== Math.sign(r12) || Math.sign(r21) !== Math.sign(r22) ? Math.abs : Number; + const f2 = Math.sign(r11) !== Math.sign(r21) || Math.sign(r12) !== Math.sign(r22) ? Math.abs : Number; selection.attr("d", (i) => { const x1 = X1(i); const y1 = Y1(i); @@ -178,16 +180,18 @@ export function applyRoundedRect(selection, X1, Y1, X2, Y2, mark) { const r = ix ? x1 : x2; const t = iy ? y2 : y1; const b = iy ? y1 : y2; - const k = Math.min(1, (r - l) / Math.max(r11 + r21, r12 + r22), (b - t) / Math.max(r11 + r12, r21 + r22)); + const kx = (r - l) / Math.max(Math.abs(r11 + r21), Math.abs(r12 + r22)); + const ky = (b - t) / Math.max(Math.abs(r11 + r12), Math.abs(r21 + r22)); + const k = Math.min(1, kx, ky); const tl = k * (ix ? (iy ? r22 : r21) : iy ? r12 : r11); const tr = k * (ix ? (iy ? r12 : r11) : iy ? r22 : r21); const br = k * (ix ? (iy ? r11 : r12) : iy ? r21 : r22); const bl = k * (ix ? (iy ? r21 : r22) : iy ? r11 : r12); return ( - `M${l},${t + tl}A${tl},${tl} 0 0 1 ${l + tl},${t}` + - `H${r - tr}A${tr},${tr} 0 0 1 ${r},${t + tr}` + - `V${b - br}A${br},${br} 0 0 1 ${r - br},${b}` + - `H${l + bl}A${bl},${bl} 0 0 1 ${l},${b - bl}` + + `M${l},${t + f2(tl)}A${tl},${tl} 0 0 ${tl < 0 ? 0 : 1} ${l + f1(tl)},${t}` + + `H${r - f1(tr)}A${tr},${tr} 0 0 ${tr < 0 ? 0 : 1} ${r},${t + f2(tr)}` + + `V${b - f2(br)}A${br},${br} 0 0 ${br < 0 ? 0 : 1} ${r - f1(br)},${b}` + + `H${l + f1(bl)}A${bl},${bl} 0 0 ${bl < 0 ? 0 : 1} ${l},${b - f2(bl)}` + `Z` ); }); diff --git a/test/output/roundedRectNegativeX.html b/test/output/roundedRectNegativeX.html new file mode 100644 index 0000000000..04bfce0cab --- /dev/null +++ b/test/output/roundedRectNegativeX.html @@ -0,0 +1,232 @@ +
+
+ + + female + + male +
+ + + + 30 + 40 + 50 + 60 + 70 + 80 + 90 + 100 + 110 + 120 + 130 + 140 + 150 + 160 + 170 + + + ↑ weight + + + + 0 + 100 + 200 + 300 + 400 + 500 + 600 + + + Frequency → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/roundedRectNegativeY.html b/test/output/roundedRectNegativeY.html new file mode 100644 index 0000000000..bacbed2611 --- /dev/null +++ b/test/output/roundedRectNegativeY.html @@ -0,0 +1,228 @@ +
+
+ + + female + + male +
+ + + + 0 + 50 + 100 + 150 + 200 + 250 + 300 + 350 + 400 + 450 + 500 + 550 + 600 + + + ↑ Frequency + + + + 40 + 60 + 80 + 100 + 120 + 140 + 160 + + + weight → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/rounded-rect.ts b/test/plots/rounded-rect.ts index f918f221a2..8534437b71 100644 --- a/test/plots/rounded-rect.ts +++ b/test/plots/rounded-rect.ts @@ -1,4 +1,5 @@ import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; export function roundedBarYR() { const xy = {y1: 0, y2: 1, inset: 4, insetLeft: 2, insetRight: 2}; @@ -206,3 +207,26 @@ export function roundedRectSides() { ] }); } + +export async function roundedRectNegativeX() { + const olympians = await d3.csv("data/athletes.csv", d3.autoType); + return Plot.plot({ + color: {legend: true}, + height: 640, + marks: [ + Plot.rectX(olympians, Plot.binY({x: "count"}, {rx2: 4, rx1: -4, clip: "frame", y: "weight", fill: "sex"})), + Plot.ruleX([0]) + ] + }); +} + +export async function roundedRectNegativeY() { + const olympians = await d3.csv("data/athletes.csv", d3.autoType); + return Plot.plot({ + color: {legend: true}, + marks: [ + Plot.rectY(olympians, Plot.binX({y: "count"}, {ry2: 4, ry1: -4, clip: "frame", x: "weight", fill: "sex"})), + Plot.ruleY([0]) + ] + }); +} From 0469a04eff6e459c63ac15771833c266f7aac55c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 20 Jul 2024 12:50:26 -0400 Subject: [PATCH 14/20] inset api index --- docs/data/api.data.ts | 21 +++++++++++++-------- docs/features/marks.md | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/data/api.data.ts b/docs/data/api.data.ts index bbabdd8cce..8c038a869e 100644 --- a/docs/data/api.data.ts +++ b/docs/data/api.data.ts @@ -49,8 +49,6 @@ function getHref(name: string, path: string): string { case "features/plot": case "features/projection": return `${path}s`; - case "features/inset": - return "features/scales"; case "features/options": return "features/transforms"; case "marks/axis": { @@ -85,8 +83,8 @@ function getInterfaceName(name: string, path: string): string { name = name.replace(/([a-z0-9])([A-Z])/, (_, a, b) => `${a} ${b}`); // camel case conversion name = name.toLowerCase(); if (name === "curve auto") name = "curve"; - if (name === "plot facet") name = "plot"; - if (name === "bollinger window") name = "bollinger map method"; + else if (name === "plot facet") name = "plot"; + else if (name === "bollinger window") name = "bollinger map method"; else if (path.startsWith("marks/")) name += " mark"; else if (path.startsWith("transforms/")) name += " transform"; return name; @@ -105,10 +103,15 @@ export default { if (Node.isInterfaceDeclaration(declaration)) { if (isInternalInterface(name)) continue; for (const property of declaration.getProperties()) { - const path = index.getRelativePathTo(declaration.getSourceFile()); - const href = getHref(name, path); if (property.getJsDocs().some((d) => d.getTags().some((d) => Node.isJSDocDeprecatedTag(d)))) continue; - allOptions.push({name: property.getName(), context: {name: getInterfaceName(name, path), href}}); + if (name === "InsetOptions") { + allOptions.push({name: property.getName(), context: {name: "mark", href: "features/marks"}}); + allOptions.push({name: property.getName(), context: {name: "scale", href: "features/scales"}}); + } else { + const path = index.getRelativePathTo(declaration.getSourceFile()); + const href = getHref(name, path); + allOptions.push({name: property.getName(), context: {name: getInterfaceName(name, path), href}}); + } } } else if (Node.isFunctionDeclaration(declaration)) { const comment = getDescription(declaration); @@ -141,7 +144,9 @@ export default { throw new Error(`anchor not found: ${href}#${name}`); } } - for (const {context: {href}} of allOptions) { + for (const { + context: {href} + } of allOptions) { if (!anchors.has(`/${href}.md`)) { throw new Error(`file not found: ${href}`); } diff --git a/docs/features/marks.md b/docs/features/marks.md index f520c6a0a2..52c078f305 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -531,7 +531,7 @@ Plot.dot(numbers, {x: {transform: (data) => data}}) The **title**, **href**, and **ariaLabel** options can *only* be specified as channels. When these options are specified as a string, the string refers to the name of a column in the mark’s associated data. If you’d like every instance of a particular mark to have the same value, specify the option as a function that returns the desired value, *e.g.* `() => "Hello, world!"`. -Marks with horizontal or vertical extents ([rects](../marks/rect.md), [bars](../marks/bar.md), [cells](../marks/cell.md), [frames](../marks/frame.md), [rules](../marks/rule.md), and [ticks](../marks/tick.md)), support insets to adjust position: a positive inset moves the respective side in, whereas a negative inset moves the side out. Insets are specified in pixels using the following options: +Marks with horizontal or vertical extents ([rects](../marks/rect.md), [bars](../marks/bar.md), [cells](../marks/cell.md), [frames](../marks/frame.md), [rules](../marks/rule.md), and [ticks](../marks/tick.md)), support insets: a positive inset moves the respective side in (towards the opposing side), whereas a negative inset moves the respective side out (away from the opposing side). Insets are specified in pixels using the following options: * **insetTop** - inset the top edge * **insetRight** - inset the right edge From f2b8f96a085b1f5688f8cff8a67aae36f1e32032 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 21 Jul 2024 08:28:57 -0400 Subject: [PATCH 15/20] more docs --- docs/features/facets.md | 2 +- docs/features/plots.md | 2 +- docs/features/scales.md | 2 +- docs/marks/rect.md | 81 ++++++--- src/marks/rect.js | 8 +- test/output/roundedRectNegativeY1.html | 228 +++++++++++++++++++++++++ test/plots/rounded-rect.ts | 11 ++ 7 files changed, 310 insertions(+), 24 deletions(-) create mode 100644 test/output/roundedRectNegativeY1.html diff --git a/docs/features/facets.md b/docs/features/facets.md index a7215e4ba6..2a7ec21a00 100644 --- a/docs/features/facets.md +++ b/docs/features/facets.md @@ -246,7 +246,7 @@ Faceting can be explicitly enabled or disabled on a mark with the **facet** opti When mark-level faceting is used, the default *auto* setting is equivalent to *include*: the mark will be faceted if either the **fx** or **fy** channel option (or both) is specified. The null or false option will disable faceting, while *exclude* draws the subset of the mark’s data *not* in the current facet. When a mark uses *super* faceting, it is not allowed to use position scales (*x*, *y*, *fx*, or *fy*); *super* faceting is intended for decorations, such as labels and legends. -The **facetAnchor** option controls the placement of the mark with respect to the facets. Based on the value, the mark will be displayed on: +The **facetAnchor** option controls the placement of the mark with respect to the facets. Based on the value, the mark will be displayed on: * null - non-empty facets * *top*, *right*, *bottom*, or *left* - the given side diff --git a/docs/features/plots.md b/docs/features/plots.md index d4191244d3..34548522e7 100644 --- a/docs/features/plots.md +++ b/docs/features/plots.md @@ -218,7 +218,7 @@ The default **width** is 640. On Observable, the width can be set to the [standa Plot does not adjust margins automatically to make room for long tick labels. If your *y* axis labels are too long, you can increase the **marginLeft** to make more room. Also consider using a different **tickFormat** for short labels (*e.g.*, `s` for SI prefix notation), or a scale **transform** (say to convert units to millions or billions). ::: -The **aspectRatio** option , if not null, computes a default **height** such that a variation of one unit in the *x* dimension is represented by the corresponding number of pixels as a variation in the *y* dimension of one unit. +The **aspectRatio** option , if not null, computes a default **height** such that a variation of one unit in the *x* dimension is represented by the corresponding number of pixels as a variation in the *y* dimension of one unit.

+
+ + + female + + male +
+ + + + 0 + 50 + 100 + 150 + 200 + 250 + 300 + 350 + 400 + 450 + 500 + 550 + 600 + + + ↑ Frequency + + + + 40 + 60 + 80 + 100 + 120 + 140 + 160 + + + weight → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/rounded-rect.ts b/test/plots/rounded-rect.ts index 8534437b71..e7a664b52a 100644 --- a/test/plots/rounded-rect.ts +++ b/test/plots/rounded-rect.ts @@ -230,3 +230,14 @@ export async function roundedRectNegativeY() { ] }); } + +export async function roundedRectNegativeY1() { + const olympians = await d3.csv("data/athletes.csv", d3.autoType); + return Plot.plot({ + color: {legend: true}, + marks: [ + Plot.rectY(olympians, Plot.binX({y: "count"}, {rx1y2: 4, rx1y1: -4, clip: "frame", x: "weight", fill: "sex"})), + Plot.ruleY([0]) + ] + }); +} From e2295edc51db60a60c10f6b5fd9513e7f9bdb5f9 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 21 Jul 2024 14:49:50 -0400 Subject: [PATCH 16/20] Update docs/marks/rect.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Philippe Rivière --- docs/marks/rect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/marks/rect.md b/docs/marks/rect.md index 1e1657b621..7010c06be9 100644 --- a/docs/marks/rect.md +++ b/docs/marks/rect.md @@ -186,7 +186,7 @@ Plot.plot({ A similar chart could be made with the [cell mark](./cell.md) using ordinal *x* and *y* scales instead, or with the [dot mark](./dot.md) as a scatterplot. ::: -To round corners, use the **r** option. If the combined corner radii excede the width or height of the rect, the radii are proportionally reduced to produce a pill shape with circular caps. Try increasing the radii below. +To round corners, use the **r** option. If the combined corner radii exceed the width or height of the rect, the radii are proportionally reduced to produce a pill shape with circular caps. Try increasing the radii below.