Repository navigation
Add auto text positioning for scatter traces #8065
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b51bae7
2736e30
401b1d1
5cd4ee7
151ad8f
98c16d6
19de71b
7738193
c69d486
0461094
c99ddd0
f908b75
83a9b49
74c3265
535bc91
8468b5d
99c2ccd
b284f55
c040aa0
c07bf59
cae1cc4
3f011eb
245fb7f
9afdb1b
03bacb7
d4c1610
19beab8
f720ca2
6a34d6c
58c506a
61a64ff
f02cb61
5aef8e5
0f6a0d6
b309d9f
9bf73b3
8dbaaa1
4e4e33f
0007711
6a0ac06
b31e7f9
4292592
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| - Add *auto* to the `textposition` of `scatter`, `scatterpolar`, `scatterternary`, `scattercarpet` and `scattersmith` traces, to place text labels without overlaps and with leader lines for labels moved away from their points, and `textrank` to set the order in which those labels take their positions [[#8065](https://github.com/plotly/plotly.js/pull/8065)] |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1241,32 +1241,194 @@ var TEXTOFFSETSIGN = { | |||||||||||||
| top: -1 | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| function textPointPosition(s, textPosition, fontSize, markerRadius, dontTouchParent) { | ||||||||||||||
| var group = d3.select(s.node().parentNode); | ||||||||||||||
| // px between the end of a leader line and the label it points at | ||||||||||||||
| var LEADER_END_GAP = 2; | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Compute the geometry of a text label relative to its point. | ||||||||||||||
| * | ||||||||||||||
| * @param textPosition - a `textposition` value other than *auto* | ||||||||||||||
| * @param fontSize - the font size in px | ||||||||||||||
| * @param markerRadius - the calculated marker radius in px, or 0 without markers | ||||||||||||||
| * @param numLines - the line count of the label | ||||||||||||||
| * @returns `anchor` for the `text-anchor` attribute, and `dx` and `dy` of the label group in px | ||||||||||||||
| */ | ||||||||||||||
| function textPointOffset(textPosition, fontSize, markerRadius, numLines) { | ||||||||||||||
| var v = textPosition.indexOf('top') !== -1 ? 'top' : textPosition.indexOf('bottom') !== -1 ? 'bottom' : 'middle'; | ||||||||||||||
| var h = textPosition.indexOf('left') !== -1 ? 'end' : textPosition.indexOf('right') !== -1 ? 'start' : 'middle'; | ||||||||||||||
| var sx = TEXTOFFSETSIGN[h]; | ||||||||||||||
| var sy = TEXTOFFSETSIGN[v]; | ||||||||||||||
|
|
||||||||||||||
| // if markers are shown, offset a little more than | ||||||||||||||
| // the nominal marker size | ||||||||||||||
| // ie 2/1.6 * nominal, bcs some markers are a bit bigger | ||||||||||||||
| var r = markerRadius ? markerRadius / 0.8 + 1 : 0; | ||||||||||||||
|
|
||||||||||||||
| var numLines = (svgTextUtils.lineCount(s) - 1) * LINE_SPACING + 1; | ||||||||||||||
| var dx = TEXTOFFSETSIGN[h] * r; | ||||||||||||||
| var dy = fontSize * 0.75 + TEXTOFFSETSIGN[v] * r + ((TEXTOFFSETSIGN[v] - 1) * numLines * fontSize) / 2; | ||||||||||||||
| var lineSpan = ((numLines || 1) - 1) * LINE_SPACING + 1; | ||||||||||||||
| return { | ||||||||||||||
| anchor: h, | ||||||||||||||
| dx: sx * r, | ||||||||||||||
| dy: fontSize * 0.75 + sy * r + ((sy - 1) * lineSpan * fontSize) / 2 | ||||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Compute the geometry of an *auto* text label from its measured box, so that | ||||||||||||||
| * every position keeps the same clearance between the marker and the text. | ||||||||||||||
| * | ||||||||||||||
| * @param textPosition - a `textposition` value other than *auto* | ||||||||||||||
| * @param markerRadius - the calculated marker radius in px, or 0 without markers | ||||||||||||||
| * @param markerSymbol - the marker symbol of the point, name or number | ||||||||||||||
| * @param bb - the label box from `drawing.bBox`, relative to the text anchor | ||||||||||||||
| * @param gap - extra px between the point and the label, set by the *auto* placement | ||||||||||||||
| * @returns the same shape as the fixed-position geometry: `anchor`, `dx`, `dy`, and `leader` | ||||||||||||||
| */ | ||||||||||||||
| drawing.textPointBoxOffset = function (textPosition, markerRadius, markerSymbol, bb, gap) { | ||||||||||||||
| var v = textPosition.indexOf('top') !== -1 ? 'top' : textPosition.indexOf('bottom') !== -1 ? 'bottom' : 'middle'; | ||||||||||||||
| var h = textPosition.indexOf('left') !== -1 ? 'end' : textPosition.indexOf('right') !== -1 ? 'start' : 'middle'; | ||||||||||||||
| var sx = TEXTOFFSETSIGN[h]; | ||||||||||||||
| var sy = TEXTOFFSETSIGN[v]; | ||||||||||||||
| var r = markerRadius || 0; | ||||||||||||||
|
|
||||||||||||||
| // the clearance the side positions of the fixed-position geometry leave | ||||||||||||||
| var clearance = (r ? r / 4 + 1 : 0) + (gap || 0); | ||||||||||||||
|
|
||||||||||||||
| // a corner label keeps the same clearance along the diagonal: from the | ||||||||||||||
| // edge of a circle, or from the corner of a square, which reaches the | ||||||||||||||
| // farthest of all symbols on the diagonal | ||||||||||||||
| var reach = r + clearance; | ||||||||||||||
| if (sx && sy) { | ||||||||||||||
| var isCircle = r && drawing.symbolNumber(markerSymbol) % 100 === 0; | ||||||||||||||
| reach = isCircle ? (r + clearance) / Math.SQRT2 : r + clearance / Math.SQRT2; | ||||||||||||||
| } | ||||||||||||||
| var ax = sx * reach; | ||||||||||||||
| var ay = sy * reach; | ||||||||||||||
|
|
||||||||||||||
| var out = { | ||||||||||||||
| anchor: h, | ||||||||||||||
| dx: ax, | ||||||||||||||
| dy: sy < 0 ? ay - bb.bottom : sy > 0 ? ay - bb.top : -(bb.top + bb.bottom) / 2, | ||||||||||||||
| leader: null | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| if (gap > 0 && (sx || sy)) { | ||||||||||||||
| var norm = Math.sqrt(sx * sx + sy * sy); | ||||||||||||||
| var ux = sx / norm; | ||||||||||||||
| var uy = sy / norm; | ||||||||||||||
| out.leader = [ux * (r + 1), uy * (r + 1), ax - ux * LEADER_END_GAP, ay - uy * LEADER_END_GAP]; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return out; | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Resolve the `textposition` of one point. | ||||||||||||||
| * | ||||||||||||||
| * @returns the position, or null when the *auto* placement found no free spot for the label | ||||||||||||||
| */ | ||||||||||||||
| function getTextPosition(d, trace) { | ||||||||||||||
| var pos = d.tp || trace.textposition; | ||||||||||||||
| if (pos !== 'auto') return pos; | ||||||||||||||
| // the scatter *auto* placement sets `_tpAuto` after it draws all points of the subplot | ||||||||||||||
| return d._tpAuto === undefined ? 'middle center' : d._tpAuto; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Measure a text label, from its `<text>` node or from the MathJax render that replaces it. | ||||||||||||||
| * | ||||||||||||||
| * Caches the box without `text-anchor` in the key, so a label reuses its first measurement at every position. | ||||||||||||||
| * | ||||||||||||||
| * @param s - d3 selection of one `<text>` element, inside its own `<g>` | ||||||||||||||
| * @returns `width`, `height`, and the `top` and `bottom` of the box relative to the text anchor, in px | ||||||||||||||
| */ | ||||||||||||||
| drawing.textPointBBox = function (s) { | ||||||||||||||
| const node = s.node(); | ||||||||||||||
| const math = d3.select(node.parentNode).select('g.text-math-group'); | ||||||||||||||
| let bb; | ||||||||||||||
| if (math.size()) { | ||||||||||||||
| bb = drawing.bBox(math.node()); | ||||||||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MathJax label boxes are cached without the font size
Repro (MathJax v3 loaded): Plotly.newPlot(gd, [
{ x: [0.5], y: [0.5], mode: 'markers+text', text: ['$\\alpha$'], textposition: 'auto', textfont: { size: 10 } },
{ x: [1, 1.3, 1.6, 1.9, 2.2, 2.5], y: [2, 2.1, 2, 2.1, 2, 2.1], mode: 'markers+text',
text: Array(6).fill('$\\alpha$'), textposition: 'auto', textfont: { size: 40 } }
], { width: 500, height: 400, xaxis: { range: [0, 3] }, yaxis: { range: [0, 3] }, showlegend: false });The placement measures the 40px labels as 5.9×4.7 px, the size of the 10px one. The real box is 23.6×18.8 px. On screen, one α gets cut off at the right edge of the plot, one sits on a marker, and the leaders stop short of the glyphs. Per-point Proposed fix: key the render by its source if (math.size()) {
// The default key of the render has no font, so key it by its source `<text>` node, which has the font
const hash = textPointHash(node);
bb = drawing.bBox(math.node(), false, hash && hash + '~math');With this applied locally, the placement measures 23.6×18.8 px, every label clears the markers, and the one with no free spot is hidden. Generated by Claude Code |
||||||||||||||
| // the MathJax svg is placed in the frame of the `<text>` node | ||||||||||||||
| bb.top = +math.select('svg').attr('y') - +s.attr('y'); | ||||||||||||||
| bb.bottom = bb.top + bb.height; | ||||||||||||||
| } else { | ||||||||||||||
| bb = drawing.bBox(node, false, textPointHash(node)); | ||||||||||||||
| } | ||||||||||||||
| // The key has no anchor, so `left` and `right` can belong to another anchor | ||||||||||||||
| return { width: bb.width, height: bb.height, top: bb.top, bottom: bb.bottom }; | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| // Leave `text-anchor` out of the `nodeHash` key, because the anchor moves a label box sideways but keeps its size | ||||||||||||||
| function textPointHash(node) { | ||||||||||||||
| const text = node.getAttribute('data-unformatted'); | ||||||||||||||
| if (text === null) return; | ||||||||||||||
| return 'textpoint~' + text + node.getAttribute('data-math') + node.getAttribute('style'); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Position a text label relative to its point, and join its leader line. | ||||||||||||||
| * | ||||||||||||||
| * @param s - d3 selection of one `<text>` element, inside its own `<g>` | ||||||||||||||
| * @param d - the calcdata point | ||||||||||||||
| * @param trace - the full trace | ||||||||||||||
| * @param markerRadius - the calculated marker radius in px, or undefined without markers | ||||||||||||||
| * @param dontTouchParent - true to leave the `transform` of the parent `<g>` alone | ||||||||||||||
| */ | ||||||||||||||
| drawing.textPointPosition = function (s, d, trace, markerRadius, dontTouchParent) { | ||||||||||||||
| var group = d3.select(s.node().parentNode); | ||||||||||||||
| var isAuto = (d.tp || trace.textposition) === 'auto'; | ||||||||||||||
| var textPosition = getTextPosition(d, trace); | ||||||||||||||
|
|
||||||||||||||
| var offset; | ||||||||||||||
| // Skip the box for an *auto* label that the placement did not reach yet, | ||||||||||||||
| // because the placement measures every label in one pass right after this draw | ||||||||||||||
| if (isAuto && textPosition && d._tpAuto !== undefined) { | ||||||||||||||
| var symbol = d.mx || (trace.marker || {}).symbol; | ||||||||||||||
| var bb = drawing.textPointBBox(s); | ||||||||||||||
| offset = drawing.textPointBoxOffset(textPosition, markerRadius, symbol, bb, d._tpAutoGap); | ||||||||||||||
|
|
||||||||||||||
| // a MathJax render is aligned to the anchor it had when it rendered | ||||||||||||||
| group.select('svg.text-math').attr('x', +s.attr('x') - (bb.width * (1 - TEXTOFFSETSIGN[offset.anchor])) / 2); | ||||||||||||||
| } else { | ||||||||||||||
| offset = textPointOffset( | ||||||||||||||
| textPosition || 'middle center', | ||||||||||||||
| drawing.textPointFontSize(d, trace), | ||||||||||||||
| markerRadius, | ||||||||||||||
| svgTextUtils.lineCount(s) | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // fix the overall text group position | ||||||||||||||
| s.attr('text-anchor', h); | ||||||||||||||
| s.attr('text-anchor', offset.anchor); | ||||||||||||||
| if (!dontTouchParent) { | ||||||||||||||
| group.attr('transform', strTranslate(dx, dy)); | ||||||||||||||
| // Write `display` on the whole group, so that a MathJax render is hidden with its source text. | ||||||||||||||
| // Skip the write for a label without an inline `display`, because only *auto* sets one. | ||||||||||||||
| if (isAuto || group.node().style.display) group.style('display', textPosition === null ? 'none' : null); | ||||||||||||||
| group.attr('transform', strTranslate(offset.dx, offset.dy)); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function extracTextFontSize(d, trace) { | ||||||||||||||
| // Skip the join when no leader is due and none is left over, because only *auto* draws leaders | ||||||||||||||
| const first = group.node().firstChild; | ||||||||||||||
| const hasLeader = first && first.nodeName === 'path' && first.classList.contains('textleader'); | ||||||||||||||
| if (!d._tpAutoLeader && !hasLeader) return; | ||||||||||||||
|
|
||||||||||||||
| var leader = group.selectAll('path.textleader').data(d._tpAutoLeader ? [d._tpAutoLeader] : []); | ||||||||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leader lines detach from their labels after a selection
Repro: 40 random points,
Fixed labels already move with var offset;
var leaderSeg = d._tpAutoLeader;
if (isAuto && textPosition && d._tpAuto !== undefined) {
var symbol = d.mx || (trace.marker || {}).symbol;
var bb = drawing.textPointBBox(s);
offset = drawing.textPointBoxOffset(textPosition, markerRadius, symbol, bb, d._tpAutoGap);
// The placement set the leader for `d.mrc`, so move its ends with a selected or unselected marker size
if (leaderSeg && markerRadius !== d.mrc) {
const placed = drawing.textPointBoxOffset(textPosition, d.mrc, symbol, bb, d._tpAutoGap).leader;
leaderSeg = leaderSeg.map((v, k) => v - placed[k] + offset.leader[k]);
}
// ...
}
// ...
if (!leaderSeg && !hasLeader) return;
var leader = group.selectAll('path.textleader').data(leaderSeg ? [leaderSeg] : []);Known limit: the placement doesn't run again on select, so a label pushed out by a bigger selected marker can still overlap a neighbor (2 label pairs in the repro). Fixed labels act the same way today. Re-running the placement on every select Generated by Claude Code |
||||||||||||||
| leader.exit().remove(); | ||||||||||||||
| leader.enter().insert('path', ':first-child').classed('textleader', true).style('fill', 'none'); | ||||||||||||||
| leader.each(function (seg) { | ||||||||||||||
| // the leader is set in the plot frame, and the group is translated by (dx, dy) | ||||||||||||||
| var dx = offset.dx; | ||||||||||||||
| var dy = offset.dy; | ||||||||||||||
| d3.select(this) | ||||||||||||||
| .attr('d', 'M' + (seg[0] - dx) + ',' + (seg[1] - dy) + 'L' + (seg[2] - dx) + ',' + (seg[3] - dy)) | ||||||||||||||
| .style('stroke-width', 1) | ||||||||||||||
| .call(Color.stroke, s.node().style.fill); | ||||||||||||||
| }); | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| drawing.textPointFontSize = function (d, trace) { | ||||||||||||||
| var fontSize = d.ts || trace.textfont.size; | ||||||||||||||
| return isNumeric(fontSize) && fontSize > 0 ? fontSize : 0; | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| // draw text at points | ||||||||||||||
| drawing.textPointStyle = function (s, trace, gd) { | ||||||||||||||
|
|
@@ -1307,8 +1469,7 @@ drawing.textPointStyle = function (s, trace, gd) { | |||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| var pos = d.tp || trace.textposition; | ||||||||||||||
| var fontSize = extracTextFontSize(d, trace); | ||||||||||||||
| var fontSize = drawing.textPointFontSize(d, trace); | ||||||||||||||
| var fontColor = selectedTextColorFn ? selectedTextColorFn(d) : d.tc || trace.textfont.color; | ||||||||||||||
|
|
||||||||||||||
| p.call(drawing.font, { | ||||||||||||||
|
|
@@ -1323,8 +1484,10 @@ drawing.textPointStyle = function (s, trace, gd) { | |||||||||||||
| color: fontColor | ||||||||||||||
| }) | ||||||||||||||
| .text(text) | ||||||||||||||
| .call(svgTextUtils.convertToTspans, gd) | ||||||||||||||
| .call(textPointPosition, pos, fontSize, d.mrc); | ||||||||||||||
| .call(svgTextUtils.convertToTspans, gd, function () { | ||||||||||||||
| // a MathJax label is positioned once it is rendered | ||||||||||||||
| drawing.textPointPosition(p, d, trace, d.mrc); | ||||||||||||||
| }); | ||||||||||||||
| }); | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -1336,12 +1499,10 @@ drawing.selectedTextStyle = function (s, trace) { | |||||||||||||
| s.each(function (d) { | ||||||||||||||
| var tx = d3.select(this); | ||||||||||||||
| var tc = fns.selectedTextColorFn(d); | ||||||||||||||
| var tp = d.tp || trace.textposition; | ||||||||||||||
| var fontSize = extracTextFontSize(d, trace); | ||||||||||||||
|
|
||||||||||||||
| Color.fill(tx, tc); | ||||||||||||||
| var dontTouchParent = Registry.traceIs(trace, 'bar-like'); | ||||||||||||||
| textPointPosition(tx, tp, fontSize, d.mrc2 || d.mrc, dontTouchParent); | ||||||||||||||
| drawing.textPointPosition(tx, d, trace, d.mrc2 || d.mrc, dontTouchParent); | ||||||||||||||
| }); | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quick explanation of this part: In my reference scatter plot, I noticed that the text was closer to the squares than the circles, because the circles' corners are folded in. In dense scatter plots with text, I find myself comparing the text to different points to try to understand which text is associated with which point, and this discrepancy of distance was throwing me off.
Circles are the default case, so that's handled. And then squares are the largest symbol from the corner, so that's the default otherwise for the rest of the symbols.
I recognize this is getting kind of hardcore, and would also be OK removing this logic as well.