From ab6d46cf7046b8bc9da8ffddbac3a9b90e8245cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 13 May 2025 14:11:38 +0200 Subject: [PATCH 1/6] support textOverflow in tips with key/value pairs closes #2321 --- src/marks/tip.js | 16 +++++------- test/output/tipLongTextEllipsisEnd.svg | 31 +++++++++++++++++++++++ test/output/tipLongTextEllipsisMiddle.svg | 31 +++++++++++++++++++++++ test/output/tipLongTextEllipsisStart.svg | 31 +++++++++++++++++++++++ test/plots/tip.ts | 21 +++++++++++++++ 5 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 test/output/tipLongTextEllipsisEnd.svg create mode 100644 test/output/tipLongTextEllipsisMiddle.svg create mode 100644 test/output/tipLongTextEllipsisStart.svg diff --git a/src/marks/tip.js b/src/marks/tip.js index bfb9d04cb2..fc78bb564e 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -45,7 +45,7 @@ export class Tip extends Mark { frameAnchor, format, textAnchor = "start", - textOverflow, + textOverflow = "ellipsis", textPadding = 8, title, pointerSize = 12, @@ -90,7 +90,7 @@ export class Tip extends Mark { const mark = this; const {x, y, fx, fy} = scales; const {ownerSVGElement: svg, document} = context; - const {anchor, monospace, lineHeight, lineWidth} = this; + const {anchor, monospace, lineHeight, lineWidth, textOverflow} = this; const {textPadding: r, pointerSize: m, pathFilter} = this; const {marginTop, marginLeft} = dimensions; @@ -185,13 +185,11 @@ export class Tip extends Mark { title = value.trim(); value = ""; } else { - if (label || (!value && !swatch)) value = " " + value; - const [k] = cut(value, w - widthof(label), widthof, ee); - if (k >= 0) { - // value is truncated - title = value.trim(); - value = value.slice(0, k).trimEnd() + ellipsis; - } + const space = label || (!value && !swatch) ? " " : ""; + const text = clipper({monospace, lineWidth: lineWidth - widthof(label + space) / 100, textOverflow})(value); + // value is truncated + if (text !== value) title = value.trim(); + value = space + text; } const line = selection.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`).text("\u200b"); // zwsp for double-click if (label) line.append("tspan").attr("font-weight", "bold").text(label); diff --git a/test/output/tipLongTextEllipsisEnd.svg b/test/output/tipLongTextEllipsisEnd.svg new file mode 100644 index 0000000000..e3b5788b45 --- /dev/null +++ b/test/output/tipLongTextEllipsisEnd.svg @@ -0,0 +1,31 @@ + + + + + Long sentence that gets clipped at the end after a certain length + + + x + + + + + x Long sentence that gets clipped at th…Long sentence that gets clipped at the end after a certain length + + + \ No newline at end of file diff --git a/test/output/tipLongTextEllipsisMiddle.svg b/test/output/tipLongTextEllipsisMiddle.svg new file mode 100644 index 0000000000..db970a662a --- /dev/null +++ b/test/output/tipLongTextEllipsisMiddle.svg @@ -0,0 +1,31 @@ + + + + + Long sentence that gets clipped in the middle after a certain length + + + x + + + + + x Long sentence tha…ter a certain lengthLong sentence that gets clipped in the middle after a certain length + + + \ No newline at end of file diff --git a/test/output/tipLongTextEllipsisStart.svg b/test/output/tipLongTextEllipsisStart.svg new file mode 100644 index 0000000000..e8734c2303 --- /dev/null +++ b/test/output/tipLongTextEllipsisStart.svg @@ -0,0 +1,31 @@ + + + + + Long sentence that gets clipped at the start after a certain length + + + x + + + + + x …ped at the start after a certain lengthLong sentence that gets clipped at the start after a certain length + + + \ No newline at end of file diff --git a/test/plots/tip.ts b/test/plots/tip.ts index 6c827e7b85..ade88e8cd7 100644 --- a/test/plots/tip.ts +++ b/test/plots/tip.ts @@ -185,6 +185,27 @@ export async function tipLongText() { return Plot.tip([{x: "Long sentence that gets cropped after a certain length"}], {x: "x"}).plot(); } +export async function tipLongTextEllipsisEnd() { + return Plot.tip([{x: "Long sentence that gets clipped at the end after a certain length"}], { + x: "x", + textOverflow: "ellipsis" // "ellipsis-end" + }).plot(); +} + +export async function tipLongTextEllipsisMiddle() { + return Plot.tip([{x: "Long sentence that gets clipped in the middle after a certain length"}], { + x: "x", + textOverflow: "ellipsis-middle" + }).plot(); +} + +export async function tipLongTextEllipsisStart() { + return Plot.tip([{x: "Long sentence that gets clipped at the start after a certain length"}], { + x: "x", + textOverflow: "ellipsis-start" + }).plot(); +} + export async function tipNewLines() { return Plot.plot({ height: 40, From b192a01deaeb03761c2f122b3bf78b9aa4f1d33e Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 11 Apr 2026 12:53:07 -0700 Subject: [PATCH 2/6] move comment --- src/marks/tip.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/marks/tip.js b/src/marks/tip.js index fbe44876b4..89a2bc4c88 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -191,10 +191,9 @@ export class Tip extends Mark { value = ""; } else { const space = label || (!value && !swatch) ? " " : ""; - const text = clipper({monospace, lineWidth: lineWidth - widthof(label + space) / 100, textOverflow})(value); - // value is truncated - if (text !== value) title = value.trim(); - value = space + text; + const clipped = clipper({monospace, lineWidth: lineWidth - widthof(label + space) / 100, textOverflow})(value); + if (clipped !== value) title = value.trim(); // show untruncated value in title + value = space + clipped; } const line = selection.append("tspan").attr("x", 0).attr("dy", `${lineHeight}em`).text("\u200b"); // zwsp for double-click if (label) line.append("tspan").attr("font-weight", "bold").text(label); From 4e70f066ae09df4c493c1ce387c0ce3f5ab7d50c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 11 Apr 2026 12:59:50 -0700 Subject: [PATCH 3/6] more tests --- ...ongText.svg => tipTextOverflowClipEnd.svg} | 4 +-- ...psisEnd.svg => tipTextOverflowDefault.svg} | 4 +-- ...dle.svg => tipTextOverflowEllipsisEnd.svg} | 4 +-- ....svg => tipTextOverflowEllipsisMiddle.svg} | 4 +-- test/output/tipTextOverflowEllipsisStart.svg | 31 ++++++++++++++++ test/output/tipTextOverflowNull.svg | 31 ++++++++++++++++ test/plots/tip.ts | 35 ++++++++++++++----- 7 files changed, 96 insertions(+), 17 deletions(-) rename test/output/{tipLongText.svg => tipTextOverflowClipEnd.svg} (88%) rename test/output/{tipLongTextEllipsisEnd.svg => tipTextOverflowDefault.svg} (87%) rename test/output/{tipLongTextEllipsisMiddle.svg => tipTextOverflowEllipsisEnd.svg} (87%) rename test/output/{tipLongTextEllipsisStart.svg => tipTextOverflowEllipsisMiddle.svg} (82%) create mode 100644 test/output/tipTextOverflowEllipsisStart.svg create mode 100644 test/output/tipTextOverflowNull.svg diff --git a/test/output/tipLongText.svg b/test/output/tipTextOverflowClipEnd.svg similarity index 88% rename from test/output/tipLongText.svg rename to test/output/tipTextOverflowClipEnd.svg index b023ab0a11..c33e2707d2 100644 --- a/test/output/tipLongText.svg +++ b/test/output/tipTextOverflowClipEnd.svg @@ -17,7 +17,7 @@ - Long sentence that gets cropped after a certain length + Long sentence that gets clipped at the end x @@ -25,7 +25,7 @@ - x Long sentence that gets cropped afte…Long sentence that gets cropped after a certain length + x Long sentence that gets clipped at theLong sentence that gets clipped at the end \ No newline at end of file diff --git a/test/output/tipLongTextEllipsisEnd.svg b/test/output/tipTextOverflowDefault.svg similarity index 87% rename from test/output/tipLongTextEllipsisEnd.svg rename to test/output/tipTextOverflowDefault.svg index e3b5788b45..e0f4aa66aa 100644 --- a/test/output/tipLongTextEllipsisEnd.svg +++ b/test/output/tipTextOverflowDefault.svg @@ -17,7 +17,7 @@ - Long sentence that gets clipped at the end after a certain length + Long sentence that gets an ellipsis at the end x @@ -25,7 +25,7 @@ - x Long sentence that gets clipped at th…Long sentence that gets clipped at the end after a certain length + x Long sentence that gets an ellipsis at…Long sentence that gets an ellipsis at the end \ No newline at end of file diff --git a/test/output/tipLongTextEllipsisMiddle.svg b/test/output/tipTextOverflowEllipsisEnd.svg similarity index 87% rename from test/output/tipLongTextEllipsisMiddle.svg rename to test/output/tipTextOverflowEllipsisEnd.svg index db970a662a..e0f4aa66aa 100644 --- a/test/output/tipLongTextEllipsisMiddle.svg +++ b/test/output/tipTextOverflowEllipsisEnd.svg @@ -17,7 +17,7 @@ - Long sentence that gets clipped in the middle after a certain length + Long sentence that gets an ellipsis at the end x @@ -25,7 +25,7 @@ - x Long sentence tha…ter a certain lengthLong sentence that gets clipped in the middle after a certain length + x Long sentence that gets an ellipsis at…Long sentence that gets an ellipsis at the end \ No newline at end of file diff --git a/test/output/tipLongTextEllipsisStart.svg b/test/output/tipTextOverflowEllipsisMiddle.svg similarity index 82% rename from test/output/tipLongTextEllipsisStart.svg rename to test/output/tipTextOverflowEllipsisMiddle.svg index e8734c2303..f24ebc7cd6 100644 --- a/test/output/tipLongTextEllipsisStart.svg +++ b/test/output/tipTextOverflowEllipsisMiddle.svg @@ -17,7 +17,7 @@ - Long sentence that gets clipped at the start after a certain length + Long sentence that gets an ellipsis in the middle x @@ -25,7 +25,7 @@ - x …ped at the start after a certain lengthLong sentence that gets clipped at the start after a certain length + x Long sentence tha…llipsis in the middleLong sentence that gets an ellipsis in the middle \ No newline at end of file diff --git a/test/output/tipTextOverflowEllipsisStart.svg b/test/output/tipTextOverflowEllipsisStart.svg new file mode 100644 index 0000000000..176b8706d0 --- /dev/null +++ b/test/output/tipTextOverflowEllipsisStart.svg @@ -0,0 +1,31 @@ + + + + + Long sentence that gets an ellipsis at the start + + + x + + + + + x …tence that gets an ellipsis at the startLong sentence that gets an ellipsis at the start + + + \ No newline at end of file diff --git a/test/output/tipTextOverflowNull.svg b/test/output/tipTextOverflowNull.svg new file mode 100644 index 0000000000..3262e4b7c8 --- /dev/null +++ b/test/output/tipTextOverflowNull.svg @@ -0,0 +1,31 @@ + + + + + Long sentence that does not get clipped no matter how long it gets; it can be really long + + + x + + + + + x Long sentence that does not get clipped no matter how long it gets; it can be really long + + + \ No newline at end of file diff --git a/test/plots/tip.ts b/test/plots/tip.ts index 79bfd5355c..756ebeef36 100644 --- a/test/plots/tip.ts +++ b/test/plots/tip.ts @@ -182,26 +182,43 @@ test(async function tipLineY() { return Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}).plot(); }); -test(async function tipLongText() { - return Plot.tip([{x: "Long sentence that gets cropped after a certain length"}], {x: "x"}).plot(); +test(async function tipTextOverflowNull() { + return Plot.tip([{x: "Long sentence that does not get clipped no matter how long it gets; it can be really long"}], { + x: "x", + textOverflow: null, + anchor: "top" // otherwise it would be bottom + }).plot(); +}); + +test(async function tipTextOverflowClipEnd() { + return Plot.tip([{x: "Long sentence that gets clipped at the end"}], { + x: "x", + textOverflow: "clip" // shorthand for "clip-end" + }).plot(); +}); + +test(async function tipTextOverflowDefault() { + return Plot.tip([{x: "Long sentence that gets an ellipsis at the end"}], { + x: "x" + }).plot(); }); -test(async function tipLongTextEllipsisEnd() { - return Plot.tip([{x: "Long sentence that gets clipped at the end after a certain length"}], { +test(async function tipTextOverflowEllipsisEnd() { + return Plot.tip([{x: "Long sentence that gets an ellipsis at the end"}], { x: "x", - textOverflow: "ellipsis" // "ellipsis-end" + textOverflow: "ellipsis" // shorthand for "ellipsis-end" }).plot(); }); -test(async function tipLongTextEllipsisMiddle() { - return Plot.tip([{x: "Long sentence that gets clipped in the middle after a certain length"}], { +test(async function tipTextOverflowEllipsisMiddle() { + return Plot.tip([{x: "Long sentence that gets an ellipsis in the middle"}], { x: "x", textOverflow: "ellipsis-middle" }).plot(); }); -test(async function tipLongTextEllipsisStart() { - return Plot.tip([{x: "Long sentence that gets clipped at the start after a certain length"}], { +test(async function tipTextOverflowEllipsisStart() { + return Plot.tip([{x: "Long sentence that gets an ellipsis at the start"}], { x: "x", textOverflow: "ellipsis-start" }).plot(); From 6be54cf45f94bfb780daf7c2b9d8ca24721eec21 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 11 Apr 2026 13:01:48 -0700 Subject: [PATCH 4/6] document default value change --- docs/marks/tip.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/marks/tip.md b/docs/marks/tip.md index ccb4481624..cb734e29c7 100644 --- a/docs/marks/tip.md +++ b/docs/marks/tip.md @@ -251,7 +251,7 @@ These [standard text options](./text.md#text-options) control the display of tex - **textAnchor** - the [text anchor](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor) for horizontal position; *start*, *end*, or *middle* - **lineHeight** - the line height in ems; defaults to 1 - **lineWidth** - the line width in ems, for wrapping; defaults to 20 -- **textOverflow** - how to wrap or clip lines longer than the specified line width +- **textOverflow** - how to wrap or clip lines longer than the specified line width; defaults to *ellipsis* ## tip(*data*, *options*) {#tip} From e0d476874f3b05a42ffc575bb00c8d93e2f64e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 14 Apr 2026 17:03:18 +0200 Subject: [PATCH 5/6] default to ellipsis for key/value pairs --- docs/marks/tip.md | 4 ++-- src/marks/text.js | 2 +- src/marks/tip.js | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/marks/tip.md b/docs/marks/tip.md index 53a45ecd92..34ccb0120c 100644 --- a/docs/marks/tip.md +++ b/docs/marks/tip.md @@ -97,7 +97,7 @@ Plot.plot({ ``` ::: -If no **title** channel is supplied, the tip mark displays all channel values. You can supply additional name-value pairs by registering extra channels using the **channels** mark option. In the scatterplot of Olympic athletes below, you can hover to see the *name* and *sport* of each athlete. This is helpful for noticing patterns — tall basketball players, giant weightlifters and judoka, diminutive gymnasts — and for seeing individuals. +If no **title** channel is supplied, the tip mark displays all channel values as name-value pairs, clipping long values with an ellipsis. Set **textOverflow** to *null* to disable clipping, or use another [text overflow](../marks/text.md) mode such as *ellipsis-middle*. You can supply additional name-value pairs by registering extra channels using the **channels** mark option. In the scatterplot of Olympic athletes below, you can hover to see the *name* and *sport* of each athlete. This is helpful for noticing patterns — tall basketball players, giant weightlifters and judoka, diminutive gymnasts — and for seeing individuals. :::plot defer https://observablehq.com/@observablehq/plot-tips-additional-channels ```js @@ -253,7 +253,7 @@ These [standard text options](./text.md#text-options) control the display of tex - **textAnchor** - the [text anchor](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor) for horizontal position; *start*, *end*, or *middle* - **lineHeight** - the line height in ems; defaults to 1 - **lineWidth** - the line width in ems, for wrapping; defaults to 20 -- **textOverflow** - how to wrap or clip lines longer than the specified line width; defaults to *ellipsis* +- **textOverflow** - how to wrap or clip lines longer than the specified line width; defaults to *ellipsis* for name-value tips ## tip(*data*, *options*) {#tip} diff --git a/src/marks/text.js b/src/marks/text.js index a243c5a1c1..b8e529d812 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -121,7 +121,7 @@ export class Text extends Mark { export function maybeTextOverflow(textOverflow) { return textOverflow == null - ? null + ? textOverflow : keyword(textOverflow, "textOverflow", [ "clip", // shorthand for clip-end "ellipsis", // … ellipsis-end diff --git a/src/marks/tip.js b/src/marks/tip.js index 6ac573a213..4973a37daf 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -46,7 +46,7 @@ export class Tip extends Mark { frameAnchor, format, textAnchor = "start", - textOverflow = "ellipsis", + textOverflow, textPadding = 8, title, pointerSize = 12, @@ -91,7 +91,8 @@ export class Tip extends Mark { const mark = this; const {x, y, fx, fy} = scales; const {ownerSVGElement: svg, document} = context; - const {anchor, monospace, lineHeight, lineWidth, textOverflow} = this; + const {anchor, monospace, lineHeight, lineWidth} = this; + let {textOverflow} = this; const {textPadding: r, pointerSize: m, pathFilter} = this; const {marginTop, marginLeft} = dimensions; @@ -126,6 +127,7 @@ export class Tip extends Mark { } else { sources = getSourceChannels.call(this, values.channels, scales); format = formatChannels; + if (textOverflow === undefined) textOverflow = "ellipsis-end"; } // Format the tip text, skipping any nulls. From d66153b4a3ed8d0b92d82810a09001e7e5027978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 14 Apr 2026 17:47:15 +0200 Subject: [PATCH 6/6] test --- test/output/tipLongText.svg | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 test/output/tipLongText.svg diff --git a/test/output/tipLongText.svg b/test/output/tipLongText.svg new file mode 100644 index 0000000000..b023ab0a11 --- /dev/null +++ b/test/output/tipLongText.svg @@ -0,0 +1,31 @@ + + + + + Long sentence that gets cropped after a certain length + + + x + + + + + x Long sentence that gets cropped afte…Long sentence that gets cropped after a certain length + + + \ No newline at end of file