Skip to content

Instantly share code, notes, and snippets.

@ryan-williams
Created March 10, 2026 14:36
Show Gist options
  • Select an option

  • Save ryan-williams/21d4fc59cbb6cd0dd372fac2a1d504c5 to your computer and use it in GitHub Desktop.

Select an option

Save ryan-williams/21d4fc59cbb6cd0dd372fac2a1d504c5 to your computer and use it in GitHub Desktop.

plotly/plotly.js#4674 textposition: "avoid overlap"

Not sure if we have an open issue for this but it would be great to have more-automatic text positioning to avoid text overlapping on e.g. scatters.

As a workaround some people might find this useful. This code from ckjellson is intended for matplotlib but can be adapted to work with dash. I've done that for this dash app and I've included a demo in the readme to show it working. I'm new to dash and a recent graduate so I'm sure there are plenty of issues.

The basic idea is to get the x and y limits in a callback html.Div(id='limits', style={'display': 'none'}):

app.clientside_callback(
    """
    function(fig) {
        const x_range = fig.layout.xaxis.range;
        const y_range = fig.layout.yaxis.range;
        return JSON.stringify([x_range, y_range])
    }
    """,
    Output('limits', 'children'),
    Input('main-plot', 'figure'),
    prevent_initial_call=True
)

Then to calculate the x and y range per pixel dcc.Store(id='xy-per-pixel') (this and the last callback could be combined, but I need two in my app as I calculate x_pixels from the screen width):

@app.callback(
    Output('xy-per-pixel', 'data'),
    Input('limits', 'children'),
    prevent_initial_call=True
)
def xy_per_pixel(limits):
    """Calculates the x and y range per pixel""
    limits = literal_eval(limits)
    x_lims, y_lims = limits[0], limits[1]
    x_pixels = y_pixels = 700 # this assumes your graph is a 700x700 plot
    x_per_pixel = (x_lims[1] - x_lims[0]) / x_pixels
    y_per_pixel = (y_lims[1] - y_lims[0]) / y_pixels
    return [x_per_pixel, y_per_pixel]

Then the allocate_text function from the linked repo can be modified to work with dash as below:

def allocate_text(
        x,
        y,
        text_list,
        fig,
        x_lims,
        y_lims,
        x_per_pixel,
        y_per_pixel,
        font,
        text_size,
        x_scatter: Union[np.ndarray, List[float]] = None,
        y_scatter: Union[np.ndarray, List[float]] = None,
        margin: float = 0.00,
        min_distance: float = 0.0075,
        max_distance: float = 0.07,
        draw_lines: bool = True,
        linecolor: str = "grey",
        draw_all: bool = True,
        nbr_candidates: int = 100,
):
    """
    Args:
        x: a list of x coordinates for the labels.
        y: a list of y coordinates for the labels.
        text_list:  a list of strings for the labels.
        fig (_type_): plotly dash figure.
        x_lims: the x limits of the plot.
        y_lims: the y limits of the plot.
        x_per_pixel: the x range per pixel.
        y_per_pixel: the y range per pixel.
        font (_type_): a pillow ImageFont object
        text_size (int): size of text.
        x_scatter (Union[np.ndarray, List[float]], optional): x-coords of all scattered points in plot 1d array/list.
        y_scatter (Union[np.ndarray, List[float]], optional): y-coords of all scattered points in plot 1d array/list.
        text_size (int): size of text.
        margin (float, optional): parameter for margins between objects. Increase for larger margins to points and lines.
        min_distance (float, optional): parameter for min distance between text and origin.
        max_distance (float, optional): parameter for max distance between text and origin.
        draw_lines (bool, optional): draws lines from original points to text-boxes.
        linecolor (str, optional): color code of the lines between points and text-boxes.
        draw_all (bool, optional): Draws all texts after allocating as many as possible despite overlap.
        nbr_candidates (int, optional): Sets the number of candidates used.
    """
    # Ensure good inputs
    x = np.array(x)
    y = np.array(y)
    assert len(x) == len(y)

    if x_scatter is not None:
        assert y_scatter is not None
    if y_scatter is not None:
        assert x_scatter is not None
        assert len(y_scatter) == len(x_scatter)
        x_scatter = x_scatter
        y_scatter = y_scatter
    assert min_distance <= max_distance
    assert min_distance >= margin

    # Create boxes in original plot
    original_boxes = []

    for x_coord, y_coord, s in zip(x, y, text_list):
        w, h = font.getlength(s) * x_per_pixel, text_size * y_per_pixel
        original_boxes.append((x_coord, y_coord, w, h, s))

    # Process extracted textboxes
    if x_scatter is None:
        scatterxy = None
    else:
        scatterxy = np.transpose(np.vstack([x_scatter, y_scatter]))
    non_overlapping_boxes, overlapping_boxes_inds = get_non_overlapping_boxes(
        original_boxes,
        x_lims,
        y_lims,
        margin,
        min_distance,
        max_distance,
        nbr_candidates,
        draw_all,
        scatter_xy=scatterxy,
    )

    if draw_lines:
        for x_coord, y_coord, w, h, s, ind in non_overlapping_boxes:
            x_near, y_near = find_nearest_point_on_box(
                x_coord, y_coord, w, h, x[ind], y[ind]
            )
            if x_near is not None:
                fig.add_annotation(
                    dict(
                        x=x[ind],
                        y=y[ind],
                        ax=x_near,
                        ay=y_near,
                        showarrow=True,
                        arrowcolor=linecolor,
                        text="",
                        axref='x',
                        ayref='y'

                    )
                )
    for x_coord, y_coord, w, h, s, ind in non_overlapping_boxes:
        fig.add_annotation(
            dict(
                x=x_coord,
                y=y_coord,
                showarrow=False,
                text=s,
                font=dict(size=text_size),
                xshift=w / (2 * x_per_pixel),
                yshift=h / (2 * y_per_pixel),
            )
        )

    if draw_all:
        for ind in overlapping_boxes_inds:
            fig.add_annotation(
                dict(
                    x=x[ind],
                    y=y[ind],
                    showarrow=False,
                    text=text_list[ind],
                    font=dict(size=text_size)
                )
            )

Where the font argument is a pillow ImageFont object:

from PIL import ImageFont

text_size = 10
font = ImageFont.truetype('assets/fonts/arial.ttf', text_size)

Is there any timeframe for the implementation a similar feature to ggrepel in R?

Anyone updates on this for Scattergeo maps?

Hi - we are trying to tidy up the stale issues and PRs in Plotly's public repositories so that we can focus on things that are still important to our community. Since this one has been sitting for a while, I'm going to close it; if it is still a concern, please add a comment letting us know what recent version of our software you've checked it with so that I can reopen it and add it to our backlog. Alternatively, if it's a request for tech support, please post in our community forum. Thank you - @gvwilson

This is obviously still an issue. Text labels overlapping. Modern libraries like Syncfusion don't have this problem. Would be nice if Plotly can do the same. Using version 2.34

I'd say this is an important feature that other modern libraries already handle

Does anyone have a good example showing this problem? I'm interested in all charts, though I imagine that this is most apparent on scatter plots.

Tableau handles this well. Tableau visualization with labels "intelligently" visible:

Image

Plotly figure:

Image

Hi everyone, we had this problem in our webapp for a long time. Thus, we recently coded up a solution:

https://github.com/bigomics/plotly.repel

Please, go and test it (still very early release, might have some rough edges). Please, let us know if you encounter any issue and open it in the repo!

cc @cpsievert: you mentioned ggrepel back in 2021; this is exactly that, implemented as an R wrapper over plotly.js. @nicolaskruchten @jackparmer @alexcjohnson: in case this is useful or could inform a native implementation. @DustinCai @camdecoster: this directly addresses what you were asking for.

textposition can only push until one of the corners is just touching the point, it can't push farther out (which would require adding a line or something, like we have for annotations), so you can still connect the text back to the point. Would you be comfortable with a v1 of this feature that simply starts removing some text when we can't accommodate it all touching the points, or do we need to keep pushing farther away and draw lines (and ensuring the lines don't cross each other or other text labels...)?

Looks like there is some nice prior art: https://github.com/tinker10/D3-Labeler (cc @archmoj )

Would you be comfortable with a v1 of this feature that simply starts removing some text when we can't accommodate it all touching the points

Yes! Speaking with @derek179 today, this was apparently the method Tableau uses.

yeah, we'd also need like a textpriority or something to help us determine which text hides which text

Our contour seems to have a similar feature. @alexcjohnson is that correct?

Might be nice if there were a modebar button in scatter plots where you could toggle these labels.

Our contour seems to have a similar feature.

True, contour automatically moves labels around on the contour lines in order to keep them as far apart as possible (with adjustments to bias them toward horizontal and away from the edges). Might be something we could borrow from that...

Might be nice if there were a modebar button in scatter plots where you could toggle these labels.

Or a legend feature, so you could toggle text on each trace independently.

team, the background here is a question was raised by a potential technology partner on how we could build new features, such as the ability for labels to show cleanly on outliers. It's not just this one feature but good to see we approach the idea from multiple perspectives and can offer usability options. thanks.

On Tue, Mar 24, 2020 at 8:19 PM alexcjohnson notifications@github.com wrote:

Our contour seems to have a similar feature.

True, contour automatically moves labels around on the contour lines in order to keep them as far apart as possible (with adjustments to bias them toward horizontal and away from the edges). Might be something we could borrow from that...

Might be nice if there were a modebar button in scatter plots where you could toggle these labels.

Or a legend feature, so you could toggle text on each trace independently.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub plotly/plotly.js#4674 (comment), or unsubscribe https://github.com/notifications/unsubscribe-auth/ANP6QF5FHT2CIZEKL3ZYTI3RJFEY7ANCNFSM4LR7VBZQ .

It would be great also to have an equivalent of loc='best' for placing the legend, as in matplotlib. Not sure whether this belongs here or to another existing/new issue.

That's definitely a new issue :)

Not sure if this is what you meant, @alexj, but we could also add the notion that first, the engine tries to find a combination of textpositions that avoids overlap, by e.g. moving one left, one right, one up, etc, and then if some heuristic fails, then start hiding text. First attempt could just be one-pass greedy.

This issue has been tagged with NEEDS SPON$OR

A community PR for this feature would certainly be welcome, but our experience is deeper features like this are difficult to complete without the Plotly maintainers leading the effort.

What Sponsorship includes:

  • Completion of this feature to the Sponsor's satisfaction, in a manner coherent with the rest of the Plotly.js library and API
  • Tests for this feature
  • Long-term support (continued support of this feature in the latest version of Plotly.js)
  • Documentation at plotly.com/javascript
  • Possibility of integrating this feature with Plotly Graphing Libraries (Python, R, F#, Julia, MATLAB, etc)
  • Possibility of integrating this feature with Dash
  • Feature announcement on community.plotly.com with shout out to Sponsor (or can remain anonymous)
  • Gratification of advancing the world's most downloaded, interactive scientific graphing libraries (>50M downloads across supported languages)

Please include the link to this issue when contacting us to discuss.

FWIW, I'd love to see this feature take inspiration from the R package {ggrepel}, not only since it'd make it feasible to support automatic conversions via ggplotly(), but also because the API and approach seem very well done

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