Skip to content

textposition: "avoid overlap" #4674

Open

Description

@nicolaskruchten

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.

Activity

  1. Karim-53 commented on May 26, 2022

    @Karim-53

    One solution is to alternate the text position, see: plotly/plotly.py#925 (comment)

  2. tprior01 commented on Apr 18, 2023

    @tprior01

    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)
    
  3. phisanti commented on Aug 24, 2023

    @phisanti

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

  4. DustinCai commented on Jun 25, 2024

    @DustinCai

    Anyone updates on this for Scattergeo maps?

  5. gvwilson commented on Jul 8, 2024

    @gvwilson
    Contributor

    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

  6. ceds commented on Aug 26, 2024

    @ceds

    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

  7. Ferreira-B commented on Nov 7, 2024

    @Ferreira-B

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

  8. camdecoster commented on Feb 13, 2025

    @camdecoster
    Contributor

    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.

  9. added
    P1needed for current cycle
    and removed
    P3backlog
    on Aug 7, 2025
  10. added
    P2considered for next cycle
    and removed
    P1needed for current cycle
    on Dec 4, 2025
  11. cameronboroumand commented on Dec 11, 2025

    @cameronboroumand

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

    Image

    Plotly figure:

    Image
  12. volmart commented on Feb 25, 2026

    @volmart

    Another example:

    Image
  13. DustinCai commented on Feb 28, 2026

    @DustinCai

    Any updates on this?

  14. added a commit that references this issue on Mar 2, 2026
  15. phisanti commented on Mar 2, 2026

    @phisanti

    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!

  16. phisanti commented on Mar 2, 2026

    @phisanti

    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.

  17. camdecoster commented on May 14, 2026

    @camdecoster
    Contributor

    Thanks for letting us know! We've got a big backlog of work, so I'm not sure when this will get prioritized. If someone wants to take a crack at it, we can provide support and prioritize a review. Someone else just mentioned this as an issue today, so here's another example of why this is needed (taken from a Codepen):

      <script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
      <div id="chart" style="width:700px;height:500px;"></div>
    
      <script>
        // Sample data — mean and median land close together so labels overlap
        const data = [];
        for (let i = 0; i < 500; i++) {
          data.push(Math.random() * 10 + Math.random() * 10);
        }
    
        const mean = data.reduce((a, b) => a + b, 0) / data.length;
        const sorted = [...data].sort((a, b) => a - b);
        const median = sorted[Math.floor(sorted.length / 2)];
    
        const trace = {
          x: data,
          type: 'histogram',
          nbinsx: 30,
          marker: { color: 'steelblue' }
        };
    
        const layout = {
          title: 'Histogram with Overlapping Mean & Median Annotations',
          xaxis: { title: 'Value' },
          yaxis: { title: 'Frequency' },
          shapes: [
            { type: 'line', x0: mean, x1: mean, y0: 0, y1: 1, yref: 'paper',
              line: { color: 'red', width: 2, dash: 'dash' } },
            { type: 'line', x0: median, x1: median, y0: 0, y1: 1, yref: 'paper',
              line: { color: 'green', width: 2, dash: 'dot' } }
          ],
          annotations: [
            { x: mean, y: 1, yref: 'paper',
              text: 'Mean: ' + mean.toFixed(2),
              showarrow: true, arrowhead: 2, ax: 0, ay: -20,
              font: { color: 'red', size: 14 },
              bgcolor: 'rgba(255,255,255,0.7)' },
            { x: median, y: 1, yref: 'paper',
              text: 'Median: ' + median.toFixed(2),
              showarrow: true, arrowhead: 2, ax: 0, ay: -20,
              font: { color: 'green', size: 14 },
              bgcolor: 'rgba(255,255,255,0.7)' }
          ]
        };
    
        Plotly.newPlot('chart', [trace], layout);
      </script>
  18. chriddyp commented on Sep 22, 2026

    @chriddyp
    Member

    Hello all - I have a PR up for this feature in #8065. Please take it for a spin and let me know what you think! And thanks for all of the discussion above

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2considered for next cyclefeaturesomething new

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions