Conversation
Labels on dense scatter plots overlap each other and their markers, and users place every label by hand or with annotations to work around it. With `textposition: 'auto'`, each label takes the first free position next to its point, moves farther out with a leader line when those are taken, and hides when nothing fits. Placement is a deterministic greedy pass over a spatial index, so redraws stay fast and repeatable. The svg scatter family shares the placement through scatter/plot.js. The gl, geo, 3d and quiver traces keep the old value list, because their text rendering does not go through that code. Closes #4674 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A label next to its point reads as ambiguous when other points sit close by. A point with a neighbor within 1.5 font sizes now skips the positions next to it and starts at the first leader ring. The ring step drops from 1.5 to 1 font size, with 6 rings instead of 4, so the shortest leader is 10px and the reach stays at 6 font sizes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The leader test ignored any obstacle that covered the leader's start. The start sits at the marker edge, so a neighbor's marker next to the point was ignored too and the leader ran through it. The exemption now applies only to obstacles that cover the point itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A point with a neighbor on one side can still take a label on the other side without doubt about which point owns it. The check now rejects a position next to the point only when another marker sits within 1.5 font sizes of the label box at that position. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The padded-box check flagged markers off a label's corner as if they sat beside it, and 1.5 font sizes of margin put arrows on labels that read fine without one. The check now measures the distance from the nearest edge or corner of the label box and uses one font size. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The fixed `textposition` geometry stands the text anchor off the marker, and the anchor is a different part of the text for each position. So *auto* charts, which mix positions, showed uneven gaps: a side label sat 2px from its marker and a corner label 8px. *auto* labels now place the measured text box with one clearance from the marker for every position. Fixed positions keep their geometry. This is self-contained in `textPointBoxOffset` and its two call sites, so it can be removed on its own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A corner label kept its clearance from the corner of a square marker, which left circles, the common case, 2px farther than side labels. Circle symbols now get the circle geometry. Every other symbol keeps the square geometry, because a square reaches the farthest of all symbols on the diagonal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The mock now holds mixed symbols, per-point font sizes, multi-line labels, bubbles, text-only points, fixed positions mixed with *auto*, points on the plot edges and corners, and a tight cluster. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
After a zoom, a point past the edge of the plot area could still get a label when one candidate box fit inside the axes, which left the label with no visible marker, or a leader that pointed off the plot. Such a point now hides its label. A test zooms into a cluster, then past some of its points, and back out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Twelve points on a small circle in clockwise order show whether the labels around a cluster follow the order of their points. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The mock now holds scenarios where a better placement would show: a clock and a ring for the natural side of a label, a row and a diagonal chain for consistent sides, an order trap where the first point in data order takes the only spot of the second, two clusters for leader crossings, and a blob for how many labels fit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Every label tried the same fixed list of positions, so labels around a cluster took whichever spot came first in the list instead of the spot that faces away from the cluster. The candidates are now sorted by the direction that points away from the markers near the point, with the fixed order as the tiebreak, so an isolated point places as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Sixty-seven labeled conditions on log axes, in three styled traces, at a size where the placement must use leader lines and hide labels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The placement no longer needs the line count or the fixed-position offset, which only the drawing module uses now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
5,040 readings at four-minute steps with the value as the label, where only a few hundred labels can fit and each one needs a leader. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A trace without markers could sit its label on the point, which put the text on top of the line. Only a trace with neither markers nor lines uses that position now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The auto pass runs inside the trace plot step, before annotations, shapes, images, and the legend draw. State this in the attribute description so users know to set a fixed position on points that sit under those elements. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
|
This is a great new feature! I have a few comments:
This should fix issue #4674. Could you add that in the description? |
The map layer symbol and the scattermap trace reuse the scatter `textposition` object, so *auto* leaked into their enum through `Lib.extendFlat`. Map-gl has no placement pass and drew such labels at the center without a warning. Filter the value out, as the gl and geo traces already do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
`textPointPosition` set `display` on the `<text>` node, which undid the hiding that `convertToTspans` relies on for the tex source. A selected point then showed the raw tex over the rendered math. Hide the `g.textpoint` group instead, so the MathJax render goes with it. A MathJax label renders after the placement pass, so the pass measured the hidden source text and reserved an empty box. Skip a pending label and run the pass again once every render is done, as the axis tick labels do. Measure a rendered label from its math group, and align the math svg to the anchor the placement picks. A leader line was drawn from the `x` and `y` attributes of the text node, which still hold the old values in the middle of a transition. Keep the leader segment on the calcdata point in plot coordinates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
The scatter *auto* textposition code held both the placement algorithm and the trace-specific parts: measuring labels, the box geometry of a position, and applying the result to calcdata. Other traces could not reuse it, and a change to the algorithm would have touched the trace code. `Lib.placeLabels` in `geometry2d.js` now takes marker boxes, fixed boxes, and labels that supply a `rect(position, gap)` callback, and returns a position, gap, and leader per label. The scatter code only gathers the inputs and applies the results. The engine places labels by priority through a sort helper, so that rule can change in one place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
A dense trace can show only some of its labels, and the placement order decided which ones. `textpriority`, per point or per trace, lets the figure say which labels matter: they take their position first, so they stay next to their point and are hidden last. It is coerced only when `textposition` has *auto*, and only the SVG scatter traces carry it. The time-series mock marks the extrema of each four-hour window with priority 1, so the peaks and troughs keep their value labels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
Every redraw placed the labels from scratch, so a pan that moved a neighbour by a few px could flip a label to the other side of its point, and one such move could cascade through the labels around it. A label now names the position it had before, and the engine tries that one first in every ring. The label still moves when its old spot is taken, and still comes closer when a nearer ring frees up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
Trying the old position first still let a label step through the rings on every pan, so its leader line came and went. The label now goes back next to its point at its old position when it can, else it keeps its old position and gap, and only then it searches again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
| if (sx && sy) { | ||
| var isCircle = r && drawing.symbolNumber(markerSymbol) % 100 === 0; | ||
| reach = isCircle ? (r + clearance) / Math.SQRT2 : r + clearance / Math.SQRT2; | ||
| } |
There was a problem hiding this comment.
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.






Fixed #4674
Overview written in my own words
I was making the graph below and running into the classic text overlap issues. This PR adds
textposition: 'auto'to avoid text overlap.Before:

After:

Intuitively, we try to find text position that:
For avoidance, it will avoid:
It won't:
This works very well for the example above.
It doesn't work as well for dense time series. In these cases, I can imagine that we should update the algorithm to focus on showing text for local minima and maxima first, rather than going in the order of the traces. I think we could do so in the future.
In that example, performance is about 1s slower than not displaying the text.
The positioning is also done on zooming and panning. Here's a video.
textposition-auto-zoom-pan.webm
Below is written with the use of AI
This PR adds support for
textposition: 'auto'on scatter traces, enabling automatic placement of text labels without overlaps. When set to'auto', labels are positioned around their points in a priority order, with leader lines drawn when labels are moved away from their markers due to space constraints.src/traces/scatter/auto_text_position.js: Implements the auto-positioning algorithm using a spatial index to detect overlaps and place labels efficiently. The algorithm:textpositionarraysAPI Updates
src/traces/scatter/attributes.jsto add'auto'as a validtextpositionvaluesrc/traces/scatter3d/attributes.js,src/traces/quiver/attributes.js,src/traces/scattergeo/attributes.js, andsrc/traces/scattergl/attributes.jsto exclude'auto'from non-2D scatter variants (with comments explaining why)Drawing & Positioning
src/components/drawing/index.js:textPointBoxOffset()to compute label geometry from measured bounding boxestextPointPosition()to support both fixed and auto-positioned labelsgetTextPosition()helper to resolve'auto'to computed positionsIntegration
src/traces/scatter/plot.jsto call the auto-positioning algorithm after rendering all traces on a subplotsrc/plot_api/helpers.jsto preserve'auto'incleanTextPosition()Tests & Examples
test/jasmine/tests/scatter_test.jscovering:test/image/mocks/text_chart_autoposition.jsonwith diverse label scenariostest/image/mocks/text_chart_autoposition_real.json(NIH funding data)Technical Details
Setup, once per subplot, before any label moves:
Index every marker from every visible scatter trace on the subplot as an obstacle, plus every label that has a fixed (non-auto) position. Collect the auto labels in trace draw order, then data order within a trace.
Then, for each auto label in that order:
Bail if the point is off-plot. If the point's pixel coords fall outside the plot area, hide the label and stop. No candidates are tried.
Build the repulsion vector. Sum
(dx/d², dy/d²)over every other point's marker withinmrc + 3 × fontSizeof this point, wheredis the distance and the vector points away from each marker. Nearer markers contribute more. Only markers count, not other labels.Score and sort all 8 positions once. Each position is a unit direction (
top right=(1,-1)/√2, and so on). Score is the dot product with the repulsion vector. Sort descending, ties broken by the default preference order. If the repulsion vector came out zero, skip the sort and use the default order as-is, which starts attop center. This ordering is computed once and reused at every ring.Ring 0: try each position touching the point, in that order. A candidate wins if its box stays inside the plot area, overlaps no marker, no already-placed label, and no already-placed leader line, and no foreign marker sits within one font size of the box. That last check is the one people miss: a spot can be geometrically free and still get rejected as too ambiguous to read.
No winner at ring 0, step out one font size and run the same list again. Now the label gets a leader line back to the marker edge, and the leader itself has to be clear of obstacles too. Keep stepping out one font size at a time, up to ring 6, re-running the full ordered list at each distance.
Nothing free at any ring: hide the label (
display: none).On a win, insert the box and its leader into the index, so this label becomes an obstacle for every label placed after it.
It's greedy and single-pass. No backtracking, no relaxation, no global optimization.
A point early in the data can permanently push a later point's label into hiding, and reordering your traces changes the output.
Labels are processed in trace and data order, so earlier points win contested positions.
Test Plan
Existing test suite passes. New tests verify: