Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b51bae7
Add *auto* textposition to scatter traces
claude Sep 21, 2026
2736e30
Give clustered labels a leader line in *auto* textposition
claude Sep 21, 2026
401b1d1
Keep *auto* leader lines off neighboring markers
claude Sep 21, 2026
5cd4ee7
Judge *auto* label ambiguity by the label, not the point
claude Sep 21, 2026
151ad8f
Measure *auto* label ambiguity as a distance of one font size
claude Sep 21, 2026
98c16d6
Place *auto* labels by their measured box
claude Sep 21, 2026
19de71b
Set the corner reach of *auto* labels from the marker symbol
claude Sep 21, 2026
7738193
Cover more cases in the *auto* textposition mock
claude Sep 21, 2026
c69d486
Hide *auto* labels of points outside the plot area
claude Sep 21, 2026
0461094
Use a twelve-point clock as the cluster in the *auto* textposition mock
claude Sep 21, 2026
c99ddd0
Add placement benchmarks to the *auto* textposition mock
claude Sep 21, 2026
f908b75
Try *auto* label positions in the direction away from nearby markers
claude Sep 21, 2026
83a9b49
Add a real-world mock for *auto* textposition
claude Sep 21, 2026
74c3265
Drop dead code from the *auto* textposition placement
claude Sep 21, 2026
535bc91
Add a dense time-series mock for *auto* textposition
claude Sep 21, 2026
8468b5d
Keep *auto* labels off the line of a lines+text trace
claude Sep 21, 2026
99c2ccd
Document what *auto* text placement does not avoid
claude Sep 21, 2026
b284f55
Name the draftlog after the pull request
claude Sep 21, 2026
c040aa0
Point the draftlog at pull request 8065
claude Sep 21, 2026
c07bf59
Keep *auto* out of the map textposition attributes
claude Sep 22, 2026
cae1cc4
Handle MathJax labels and animated leader lines in *auto* textposition
claude Sep 22, 2026
3f011eb
Move the label placement engine into Lib.placeLabels
claude Sep 22, 2026
245fb7f
Add textpriority to order the *auto* text placement
claude Sep 22, 2026
9afdb1b
Keep an *auto* label in place across a pan or zoom while it is free
claude Sep 22, 2026
03bacb7
Keep the leader line of an *auto* label across a pan too
claude Sep 22, 2026
d4c1610
Cache label bbox without text-anchor to avoid extra measurements
camdecoster Oct 12, 2026
19beab8
Only measure auto labels in the placement pass
camdecoster Oct 12, 2026
f720ca2
Skip display and leader updates for fixed-position labels
camdecoster Oct 13, 2026
6a34d6c
Show the source text when a MathJax render fails
camdecoster Oct 13, 2026
58c506a
Don't place auto labels on the axis labels in polar, ternary, smith, …
camdecoster Oct 13, 2026
61a64ff
Don't place auto labels on cartesian tick labels inside the plot area
camdecoster Oct 13, 2026
f02cb61
Keep auto labels out of the range slider, describe text-only placement
camdecoster Oct 14, 2026
5aef8e5
Handle textpriority values that are not plain numbers
camdecoster Oct 14, 2026
0f6a0d6
Fix test for when it runs in Firefox
camdecoster Oct 19, 2026
b309d9f
Merge branch 'main' into claude/youthful-shannon-s2pabd
camdecoster Oct 19, 2026
9bf73b3
Rename textpriority to textrank, update behavior to match legendrank
camdecoster Oct 19, 2026
8dbaaa1
Skip measuring/drawing off-view labels
camdecoster Oct 19, 2026
4e4e33f
Format mock with Biome
camdecoster Oct 19, 2026
0007711
Add/update tests
camdecoster Oct 19, 2026
6a0ac06
Add new baseline images
camdecoster Oct 19, 2026
b31e7f9
Update baseline due to test order change
camdecoster Oct 19, 2026
4292592
Add test for restyle with text only mode
camdecoster Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/8065_add.md
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)]
195 changes: 178 additions & 17 deletions src/components/drawing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Copy link
Copy Markdown
Member Author

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.

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());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MathJax label boxes are cached without the font size

drawing.bBox(math.node()) falls back to the default nodeHash. On the g.text-math-group node that is data-unformatted + 'Y' + null + null, so the key has no font. The first size measured for a TeX string is reused for every other label with the same string.

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 textfont.size arrays hit the same thing.

Proposed fix: key the render by its source <text> node, whose style has the font:

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] : []);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leader lines detach from their labels after a selection

selectedTextStyle (L1505) calls this with d.mrc2 || d.mrc. For an auto label, textPointBoxOffset then moves the text out to the selected marker radius. But d._tpAutoLeader was fixed in the plot frame for d.mrc, so the leader stays where it was while the text moves.

Repro: 40 random points, marker.size: 8, selected.marker.size: 20, textposition: 'auto', dragmode: 'select', then box-select everything. 7 labels have leaders:

leader end to label leader start to marker edge
before select ≤ 2 px ≤ 1 px
after select, this branch up to 9.5 px up to 5 px, inside the bigger marker
after select, fix below ≤ 2 px ≤ 1 px

Fixed labels already move with mrc2, so moving the auto label is consistent. Only the leader has to follow. Shift the stored leader by the change in the two offsets:

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 mousemove would cost too much, so I'd leave that alone.


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) {
Expand Down Expand Up @@ -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, {
Expand All @@ -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);
});
});
};

Expand All @@ -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);
});
};

Expand Down
Loading