From 39989f3cddb33e7be00e8265307b719879e84270 Mon Sep 17 00:00:00 2001 From: Paul DeBruicker Date: Mon, 10 Aug 2026 14:24:14 -0400 Subject: [PATCH 1/7] Update text.md List the frameAnchor options in the docs --- docs/marks/text.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From fec0894ff3cacfe771fd8043d9e0fd5cc98e7563 Mon Sep 17 00:00:00 2001 From: Paul DeBruicker Date: Mon, 17 Aug 2026 15:57:05 -0400 Subject: [PATCH 2/7] initial commit to try to get rounded tips --- src/marks/tip.d.ts | 3 +++ src/marks/tip.js | 43 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) 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..539cf1f80b 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, radius)); 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)})`); @@ -299,7 +301,7 @@ function getTextTranslate(anchor, m, r, width, height) { } } -function getPath(anchor, m, r, width, height) { +/*function getPath(anchor, m, r, width, height) { const w = width + r * 2; const h = height + r * 2; switch (anchor) { @@ -322,6 +324,39 @@ function getPath(anchor, m, r, width, height) { 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`; } +}*/ +function getPath(anchor, m, r, width, height, borderRadius) { + + var w = width + r * 2, h = height + r * 2,tlc='',trc='',brc='',blc=''; + if(borderRadius){ + const br = Math.min(borderRadius,w,h); + 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) { + case "middle": + 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} ${trc} v${h} ${brc} h${-w} ${blc} z`; + case "top": + return `M0,0l${m / 2},${m / 2} 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} ${tlc} v${h} ${blc} h${w} ${brc} z`; + case "right": + return `M0,0l${-m / 2},${-m / 2} v${m / 2 - h / 2} ${trc} h${-w} ${tlc} v${h} ${blc} h${w} ${brc} v${m / 2 - h / 2} z`; + case "bottom-left": + return `M0,0l${m / 2},${-m / 2} h${w - m / 2} ${brc} v${-h} ${trc} h${-w} ${tlc} z`; + case "bottom": + return `M0,0l${m / 2},${-m / 2} h${(w - m) / 2} ${brc} v${-h} ${trc} h${-w} ${tlc} v${h} ${blc} h${(w - m) / 2} z`; + case "bottom-right": + return `M0,0l${-m / 2},${-m / 2} h${m / 2 - w} ${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`; + } } // Note: mutates this.format! From 528e4fd3a35ea1f6af0a094ae79d26f995bbf143 Mon Sep 17 00:00:00 2001 From: Paul DeBruicker Date: Mon, 17 Aug 2026 16:26:09 -0400 Subject: [PATCH 3/7] add a test --- test/plots/tip.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/plots/tip.ts b/test/plots/tip.ts index 48c4b30c00..3399905f7b 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, ind ) => [ + Plot.dot({length: 1}, {frameAnchor: anchor, fill: "blue"}), + Plot.tip([anchor], {radius: ind + 2, frameAnchor: anchor, anchor}) + ]) + ] + }); + 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(); From 0811191ff5ab08ba4cb1a6f6ae1af222029de0fa Mon Sep 17 00:00:00 2001 From: Paul DeBruicker Date: Mon, 17 Aug 2026 18:14:26 -0400 Subject: [PATCH 4/7] think everything works --- src/marks/tip.js | 64 +++++---------- test/output/tipAnchors.svg | 10 +-- test/output/tipBorderRadiusForAnchors.svg | 98 +++++++++++++++++++++++ test/output/tipDispatch.svg | 2 +- test/plots/tip.ts | 2 +- 5 files changed, 125 insertions(+), 51 deletions(-) create mode 100644 test/output/tipBorderRadiusForAnchors.svg diff --git a/src/marks/tip.js b/src/marks/tip.js index 539cf1f80b..a00f88b570 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -87,8 +87,8 @@ 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); - } + this.radius = number(radius); + } render(index, scales, values, dimensions, context) { const mark = this; const {x, y, fx, fy} = scales; @@ -240,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, radius)); + 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)})`); @@ -300,62 +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) { - case "middle": - return `M${-w / 2},${-h / 2}h${w}v${h}h${-w}z`; - case "top-left": - return `M0,0l${m / 2},${m / 2}h${w - m / 2}v${h}h${-w}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`; - case "top-right": - return `M0,0l${-m / 2},${m / 2}h${m / 2 - w}v${h}h${w}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`; - case "bottom-left": - return `M0,0l${m / 2},${-m / 2}h${w - m / 2}v${-h}h${-w}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`; - case "bottom-right": - return `M0,0l${-m / 2},${-m / 2}h${m / 2 - w}v${-h}h${w}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`; - } -}*/ function getPath(anchor, m, r, width, height, borderRadius) { - var w = width + r * 2, h = height + r * 2,tlc='',trc='',brc='',blc=''; + let w = width + r * 2, h = height + r * 2, halfM = m / 2, br = 0, tlc = '', trc = '', brc = '', blc = ''; if(borderRadius){ - const br = Math.min(borderRadius,w,h); + 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 + 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) { + switch (anchor) { + //all paths must go clockwise to use the rounded corners defined above case "middle": - return `M${-w / 2},${-h / 2 - br} h${w} ${trc} v${h} ${brc} h${-w} ${blc} v ${-h} ${tlc} 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} ${trc} v${h} ${brc} h${-w} ${blc} 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} ${trc} v${h} ${brc} h${-w} ${blc} v${-h} ${tlc} 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} ${tlc} v${h} ${blc} h${w} ${brc} 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} ${trc} h${-w} ${tlc} v${h} ${blc} h${w} ${brc} 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} ${brc} v${-h} ${trc} h${-w} ${tlc} 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} ${brc} v${-h} ${trc} h${-w} ${tlc} v${h} ${blc} 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} ${blc} v${-h} ${tlc} h${w} ${trc} 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..0c5028b95e --- /dev/null +++ b/test/output/tipBorderRadiusForAnchors.svg @@ -0,0 +1,98 @@ + + + + + + + + + + ​top + + + + + + + + + ​right + + + + + + + + + ​bottom + + + + + + + + + ​left + + + + + + + + + ​top-left + + + + + + + + + ​top-right + + + + + + + + + ​bottom-right + + + + + + + + + ​bottom-left + + + + + + + + + ​middle + + + \ 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 3399905f7b..a3c8edbf53 100644 --- a/test/plots/tip.ts +++ b/test/plots/tip.ts @@ -50,7 +50,7 @@ test(async function tipBorderRadiusForAnchors() { ] as const ).map((anchor, ind ) => [ Plot.dot({length: 1}, {frameAnchor: anchor, fill: "blue"}), - Plot.tip([anchor], {radius: ind + 2, frameAnchor: anchor, anchor}) + Plot.tip([anchor], { frameAnchor: anchor, anchor, radius: 3}) ]) ] }); From e2e77b2de76ee0bebfcfec426f45f450408a4b43 Mon Sep 17 00:00:00 2001 From: Paul DeBruicker Date: Mon, 17 Aug 2026 20:37:39 -0400 Subject: [PATCH 5/7] update the docs --- docs/marks/tip.md | 1 + 1 file changed, 1 insertion(+) 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. From 14c2397cecc9f612fc663f0cdd98375ddea731a4 Mon Sep 17 00:00:00 2001 From: Paul DeBruicker Date: Tue, 18 Aug 2026 07:45:23 -0400 Subject: [PATCH 6/7] forgot to update the test result --- test/output/tipBorderRadiusForAnchors.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/output/tipBorderRadiusForAnchors.svg b/test/output/tipBorderRadiusForAnchors.svg index 0c5028b95e..1f91b6dc5a 100644 --- a/test/output/tipBorderRadiusForAnchors.svg +++ b/test/output/tipBorderRadiusForAnchors.svg @@ -82,7 +82,7 @@ - + ​bottom-left From 4b0354aeba9ff36b94d3a97eb246c54283146032 Mon Sep 17 00:00:00 2001 From: Paul DeBruicker Date: Tue, 18 Aug 2026 12:31:38 -0400 Subject: [PATCH 7/7] linter found unused variable --- test/plots/tip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plots/tip.ts b/test/plots/tip.ts index a3c8edbf53..c5ec49c754 100644 --- a/test/plots/tip.ts +++ b/test/plots/tip.ts @@ -48,7 +48,7 @@ test(async function tipBorderRadiusForAnchors() { "bottom-left", // corners "middle" ] as const - ).map((anchor, ind ) => [ + ).map((anchor) => [ Plot.dot({length: 1}, {frameAnchor: anchor, fill: "blue"}), Plot.tip([anchor], { frameAnchor: anchor, anchor, radius: 3}) ])