Skip to content

Add auto text positioning for scatter traces - #8065

Open
chriddyp wants to merge 25 commits into
mainfrom
claude/youthful-shannon-s2pabd
Open

chriddyp wants to merge 25 commits into
mainfrom
claude/youthful-shannon-s2pabd

Conversation

@chriddyp

@chriddyp chriddyp commented Sep 21, 2026

Copy link
Copy Markdown
Member

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:
image

After:
image

Intuitively, we try to find text position that:

  1. Is one of the 8 possible directions (top, top-right, right, etc).
  2. Prefers a direction that is "away" from points that are nearby (to give space for those points' text positions). This is the "repulsion vector".
  3. If there aren't any nearby points, then defaults to top
  4. If there isn't any room because of overlap, then it'll try to find a spot on the next "ring" outwards (ring is equal to "1 1 x fontSize")
  5. It'll go in a circle around that ring, using same position algorithm as 2-4. If it's on a further ring, it'll draw a line connecting the text with the point (a "leader line")
  6. Continue through 6 rings
  7. If it can't find a spot, then it'll be hidden

For avoidance, it will avoid:

  • Other marker text

It won't:

  • Take into consideration other annotation text or other shapes. (This can overlap)
  • Expand the axes ranges to "make room" for the annotation

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.

image

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.

  • New module 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:
    • Tries 8 candidate positions around each point in order of preference
    • Adds leader lines when labels must be moved farther out due to nearby markers or other labels
    • Hides labels that have no available space
    • Respects fixed-position labels and per-point textposition arrays

API Updates

  • Updated src/traces/scatter/attributes.js to add 'auto' as a valid textposition value
  • Updated src/traces/scatter3d/attributes.js, src/traces/quiver/attributes.js, src/traces/scattergeo/attributes.js, and src/traces/scattergl/attributes.js to exclude 'auto' from non-2D scatter variants (with comments explaining why)

Drawing & Positioning

  • Modified src/components/drawing/index.js:
    • Added textPointBoxOffset() to compute label geometry from measured bounding boxes
    • Refactored textPointPosition() to support both fixed and auto-positioned labels
    • Added getTextPosition() helper to resolve 'auto' to computed positions
    • Added support for leader lines with configurable gap and clearance

Integration

  • Updated src/traces/scatter/plot.js to call the auto-positioning algorithm after rendering all traces on a subplot
  • Updated src/plot_api/helpers.js to preserve 'auto' in cleanTextPosition()

Tests & Examples

  • Test suite in test/jasmine/tests/scatter_test.js covering:
    • Basic auto positioning with stacked markers
    • Isolated labels taking first candidate position
    • Leader lines in clusters
    • Text-only labels on points
    • Mixed fixed and auto positions
    • Repositioning after zoom/relayout
  • Added test mock test/image/mocks/text_chart_autoposition.json with diverse label scenarios
  • Added real-world example test/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:

  1. 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.

  2. Build the repulsion vector. Sum (dx/d², dy/d²) over every other point's marker within mrc + 3 × fontSize of this point, where d is the distance and the vector points away from each marker. Nearer markers contribute more. Only markers count, not other labels.

  3. 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 at top center. This ordering is computed once and reused at every ring.

  4. 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.

  5. 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.

  6. Nothing free at any ring: hide the label (display: none).

  7. 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:

  • Labels don't overlap with markers or other labels
  • Leader lines are added when labels move away from points
  • Text-only labels can sit on their points
  • Mixed fixed/auto positions work correctly
  • Repositioning works after zoom/relayout operations

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
@camdecoster

Copy link
Copy Markdown
Contributor

This is a great new feature! I have a few comments:

  • I'd like to separate the placement engine from the trace so that it can be reused throughout the library. geometry2d.js seems like a good place to move things. Ideally, the API would be set up such that the algorithm could change under the hood and the traces wouldn't have to be updated.
  • What do you think of allowing for pixel positioning rather than a set of predetermined positions? That could make the fit more flexible/compact.
  • I'd argue that we should include a label importance/priority sort from the beginning. If you use a helper function to do this, we can tweak the logic as needed.
  • What do you think of the label "popping" that occurs when you pan? It looks a little odd, but I don't have a good suggestion at the moment. It's probably fine to start from this point and iterate if necessary.

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
@chriddyp

Copy link
Copy Markdown
Member Author

TYSM for the review! Addressed some of the concerns.


Zoom & Pan: Feeling pretty good to me now:
zoom text autopositioning


geometry2d.js

✅ Done


What do you think of allowing for pixel positioning rather than a set of predetermined positions? That could make the fit more flexible/compact.

One thing I'm keeping in mind in this design is trying to make the "visual interpretation" of the text feel consistent and easy to navigate.

I think that limiting the available directions to be 1/8 possible ones rather than an arbitrary angle, it can help a bit with this: if a text label is near two points, then users can get a hint that the text that is associated with the point is the one that has a "clean" angle (top, top-left, right, etc) rather than a "random" angle. It's sort of like how graphic design software will provide the "snap-to" mechanism.

So I think I'd like to keep the angle restricted. The distance of the vector could be more arbitrary, but I think there's enough granularity right now.

All to say, I'm pretty happy with the results so far, so I'm not too inclined to change the algorithm. I think doing arbitrary positioning instead of iteratively trying a series of positions would require a pretty different algorithm.


On another note, I found another case of non-ideal placement: The leader lines can cross the actual line chart
image

Fixing this one feels more complex, so I'm OK with punting on it and fixing in the future.


Speaking of fixing in the future, I'm thinking about adding a note to the attribute description that says something like "Auto placement uses an algorithm to find the correct placement. This algorithm is subject to change in future minor and patch versions."

Just so that we feel like we can improve the algorithm in the future without issuing a major version release.


I'd argue that we should include a label importance/priority sort from the beginning.

Done! textpriority.

Here's an example without it, notice the point I'm hovering over and it's neighbor doesn't have text:
image

And here it is with those points:
image

Another example without it (notice that not all peaks have labels):
image

And then with textpriority and the peaks have labels:
image

@camdecoster camdecoster self-assigned this Sep 22, 2026
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

textposition: "avoid overlap"

3 participants