diff --git a/docs/marks/text.md b/docs/marks/text.md
index 45fd814a47..ffc5607b36 100644
--- a/docs/marks/text.md
+++ b/docs/marks/text.md
@@ -220,7 +220,7 @@ The following text-specific constant options are also supported:
* **fontStyle** - the [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style); defaults to *normal*
* **fontVariant** - the [font variant](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant); defaults to *normal*
* **fontWeight** - the [font weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight); defaults to *normal*
-* **frameAnchor** - how to position the text within the frame; defaults to *middle*
+* **frameAnchor** - how to position the text within the frame. Options are *middle* (default), *top-left*, *top*, *top-right*, *right*, *bottom-right*, *bottom-left*, *left*
* **rotate** - the rotation angle in degrees clockwise; defaults to 0
If a **lineWidth** is specified, input text values will be wrapped as needed to fit while preserving existing newlines. The line wrapping implementation is rudimentary: it replaces the space before the word that overflows with a line feed (`\n`). Lines might also be split on words that contain a soft-hyphen (`\xad`), replacing it with a hyphen (-). For non-ASCII, non-U.S. English text, or for when a different font is used, you may get better results by hard-wrapping the text yourself (by supplying line feeds in the input). If the **monospace** option is truthy, the default **fontFamily** changes to monospace and the **lineWidth** option is interpreted as characters (ch) rather than ems.
diff --git a/docs/marks/tip.md b/docs/marks/tip.md
index 9ea3c7fe37..1ffdc29b16 100644
--- a/docs/marks/tip.md
+++ b/docs/marks/tip.md
@@ -229,6 +229,7 @@ These tip-specific options control the tip appearance:
- **pointerSize** - the size of the tip’s pointer in pixels; defaults to 12
- **pathFilter** - the image filter for the tip’s box; defaults to a drop shadow
- **textPadding** - the padding around the text in pixels; defaults to 8
+- **radius** - round the corners of the tip - like a CSS border-radius; defaults to null which means square corners
The tip mark does not support the [standard style channels](../features/marks.md#mark-options) such as varying **fill** or **stroke**; channels are used exclusively to control the displayed values rather than the tip’s appearance. You can however use the these options for a constant **fill**, **fillOpacity**, **stroke**, **strokeOpacity**, or **strokeWidth** on the path element surrounding the tip text.
diff --git a/src/marks/tip.d.ts b/src/marks/tip.d.ts
index 7faf45b759..6a5072426d 100644
--- a/src/marks/tip.d.ts
+++ b/src/marks/tip.d.ts
@@ -84,6 +84,9 @@ export interface TipOptions extends MarkOptions, TextStyles {
/** The padding around the text in pixels; defaults to 8. */
textPadding?: number;
+
+ /** The "border-radius" equivalent, in pixels; defaults to null for square corners. */
+ radius?: number;
}
/**
diff --git a/src/marks/tip.js b/src/marks/tip.js
index a23bedb994..a00f88b570 100644
--- a/src/marks/tip.js
+++ b/src/marks/tip.js
@@ -50,7 +50,8 @@ export class Tip extends Mark {
textPadding = 8,
title,
pointerSize = 12,
- pathFilter = "drop-shadow(0 3px 4px rgba(0,0,0,0.2))"
+ pathFilter = "drop-shadow(0 3px 4px rgba(0,0,0,0.2))",
+ radius,
} = options;
super(
data,
@@ -86,13 +87,14 @@ export class Tip extends Mark {
this.splitLines = splitter(this);
this.clipLine = clipper(this);
this.format = typeof format === "string" || typeof format === "function" ? {title: format} : {...format}; // defensive copy before mutate; also promote nullish to empty
- }
+ this.radius = number(radius);
+ }
render(index, scales, values, dimensions, context) {
const mark = this;
const {x, y, fx, fy} = scales;
const {ownerSVGElement: svg, document} = context;
const {anchor, monospace, lineHeight, lineWidth} = this;
- const {textPadding: r, pointerSize: m, pathFilter} = this;
+ const {textPadding: r, pointerSize: m, pathFilter, radius: rad} = this;
const {marginTop, marginLeft} = dimensions;
// The anchor position is the middle of x1 & y1 and x2 & y2, if available,
@@ -238,7 +240,7 @@ export class Tip extends Mark {
}
const path = this.firstChild; // note: assumes exactly two children!
const text = this.lastChild; // note: assumes exactly two children!
- path.setAttribute("d", getPath(a, m, r, w, h));
+ path.setAttribute("d", getPath(a, m, r, w, h, rad));
if (tx) for (const t of text.childNodes) t.setAttribute("x", -tx);
text.setAttribute("y", `${+getLineOffset(a, text.childNodes.length, lineHeight).toFixed(6)}em`);
text.setAttribute("transform", `translate(${getTextTranslate(a, m, r, w, h)})`);
@@ -298,29 +300,38 @@ function getTextTranslate(anchor, m, r, width, height) {
return [r + m / 2, height / 2];
}
}
-
-function getPath(anchor, m, r, width, height) {
- const w = width + r * 2;
- const h = height + r * 2;
- switch (anchor) {
+function getPath(anchor, m, r, width, height, borderRadius) {
+
+ let w = width + r * 2, h = height + r * 2, halfM = m / 2, br = 0, tlc = '', trc = '', brc = '', blc = '';
+ if(borderRadius){
+ br = Math.min(borderRadius,w/2,h/2);
+ w = w - br - br;
+ h = h - br - br;
+ trc = ` q ${br} 0 ${br} ${br} `;//top right corner
+ brc = ` q 0 ${br} ${-br} ${br} `;
+ tlc = ` q 0 ${-br} ${br} ${-br} `;
+ blc = ` q ${-br} 0 ${-br} ${-br} `; //bottom left corner
+ }
+ switch (anchor) {
+ //all paths must go clockwise to use the rounded corners defined above
case "middle":
- return `M${-w / 2},${-h / 2}h${w}v${h}h${-w}z`;
+ return `M${-w / 2},${-h / 2 - br}h${w}${trc}v${h}${brc}h${-w}${blc}v${-h}${tlc}z`;
case "top-left":
- return `M0,0l${m / 2},${m / 2}h${w - m / 2}v${h}h${-w}z`;
+ return `M0,0l${halfM},${halfM}h${w - halfM + br}${trc}v${h}${brc}h${-w}${blc}z`;
case "top":
- return `M0,0l${m / 2},${m / 2}h${(w - m) / 2}v${h}h${-w}v${-h}h${(w - m) / 2}z`;
+ return `M0,0l${halfM},${halfM}h${(w - m) / 2}${trc}v${h}${brc}h${-w}${blc}v${-h}${tlc}h${(w - m) / 2}z`;
case "top-right":
- return `M0,0l${-m / 2},${m / 2}h${m / 2 - w}v${h}h${w}z`;
+ return `M0,0v${h + halfM + br}${brc}h${-w}${blc}v${-h}${tlc}h${w - halfM + br}z`;
case "right":
- return `M0,0l${-m / 2},${-m / 2}v${m / 2 - h / 2}h${-w}v${h}h${w}v${m / 2 - h / 2}z`;
+ return `M0,0l${-halfM},${halfM}v${h / 2 - halfM}${brc}h${-w}${blc}v${-h}${tlc}h${w}${trc}v${h / 2 - halfM}z`;
case "bottom-left":
- return `M0,0l${m / 2},${-m / 2}h${w - m / 2}v${-h}h${-w}z`;
+ return `M0,0v${-h - halfM - br}${tlc}h${w}${trc}v${h}${brc}h${-w + halfM - br }z`;
case "bottom":
- return `M0,0l${m / 2},${-m / 2}h${(w - m) / 2}v${-h}h${-w}v${h}h${(w - m) / 2}z`;
+ return `M0,0l${-halfM},${-halfM}h${(m - w) / 2}${blc}v${-h}${tlc}h${w}${trc}v${h}${brc}h${(m - w) / 2}z`;
case "bottom-right":
- return `M0,0l${-m / 2},${-m / 2}h${m / 2 - w}v${-h}h${w}z`;
+ return `M0,0l${-halfM},${-halfM}h${halfM - w - br}${blc}v${-h}${tlc}h${w}${trc}z`;
case "left":
- return `M0,0l${m / 2},${-m / 2}v${m / 2 - h / 2}h${w}v${h}h${-w}v${m / 2 - h / 2}z`;
+ return `M0,0l${halfM},${-halfM}v${halfM - h / 2}${tlc}h${w}${trc}v${h}${brc}h${-w}${blc}v${halfM - h / 2}z`;
}
}
diff --git a/test/output/tipAnchors.svg b/test/output/tipAnchors.svg
index b2a3d74ba7..c43b1f53ed 100644
--- a/test/output/tipAnchors.svg
+++ b/test/output/tipAnchors.svg
@@ -28,7 +28,7 @@
-
+ right
@@ -37,7 +37,7 @@
-
+ bottom
@@ -64,7 +64,7 @@
-
+ top-right
@@ -82,7 +82,7 @@
-
+ bottom-left
@@ -91,7 +91,7 @@
-
+ middle
diff --git a/test/output/tipBorderRadiusForAnchors.svg b/test/output/tipBorderRadiusForAnchors.svg
new file mode 100644
index 0000000000..1f91b6dc5a
--- /dev/null
+++ b/test/output/tipBorderRadiusForAnchors.svg
@@ -0,0 +1,98 @@
+
\ No newline at end of file
diff --git a/test/output/tipDispatch.svg b/test/output/tipDispatch.svg
index b0d1e210a7..07c6562401 100644
--- a/test/output/tipDispatch.svg
+++ b/test/output/tipDispatch.svg
@@ -399,7 +399,7 @@
-
+ Torgersen
diff --git a/test/plots/tip.ts b/test/plots/tip.ts
index 48c4b30c00..c5ec49c754 100644
--- a/test/plots/tip.ts
+++ b/test/plots/tip.ts
@@ -30,6 +30,33 @@ test(async function tipAnchors() {
return Object.assign(plot, {ready: new Promise((resolve) => setTimeout(resolve, 100))}); // postrender
});
+test(async function tipBorderRadiusForAnchors() {
+ const plot = Plot.plot({
+ style: "overflow: visible;",
+ height: 160,
+ marks: [
+ Plot.frame({strokeOpacity: 0.2}),
+ (
+ [
+ "top",
+ "right",
+ "bottom",
+ "left", // sides
+ "top-left",
+ "top-right",
+ "bottom-right",
+ "bottom-left", // corners
+ "middle"
+ ] as const
+ ).map((anchor) => [
+ Plot.dot({length: 1}, {frameAnchor: anchor, fill: "blue"}),
+ Plot.tip([anchor], { frameAnchor: anchor, anchor, radius: 3})
+ ])
+ ]
+ });
+ return Object.assign(plot, {ready: new Promise((resolve) => setTimeout(resolve, 100))}); // postrender
+});
+
test(async function tipAreaBand() {
const aapl = await d3.csv("data/aapl.csv", d3.autoType);
return Plot.areaY(aapl, {x: "Date", y1: "Low", y2: "High", tip: true, curve: "step", stroke: "currentColor"}).plot();