Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Word wrap for long labels #382

Open
eugenesvk opened this issue Apr 1, 2016 · 24 comments
Open

Word wrap for long labels #382

eugenesvk opened this issue Apr 1, 2016 · 24 comments
Assignees
Labels
feature something new P3 backlog

Comments

@eugenesvk
Copy link

Is there an option to reflow long text labels?

  • For example, a long label without reflow (plotly tries to autocorrect by slanting the labels instead of reflowing the text, but it still doesn't fit)
    plotlylonglabels1
  • And a simple manual fix with a few <br> in the code that I'd like to have Plotly be able to do with a simple wrap: true option
    plotlylonglabels2

JS code for this chart

var data = [{
// first screenshot labels
    //x: ['giraffes', 'orangutans', "Very Long Text Label That Doesn't Fit Properly"],
// second screenshot labels
    x: ['giraffes', 'orangutans', "Very Long<br>Text Label That<br>Doesn't Fit<br>Properly"],
    y: [20, 14, 23],
    type: 'bar'
}]
Plotly.newPlot('PlotlyDivSimple', data, {})
@mdtusz
Copy link
Contributor

mdtusz commented Apr 1, 2016

Unfortunately, there isn't this option built into plotly.js at the moment. The text labels are created using SVG elements, or in WebGL where text-reflow is done manually and it's not quite as easy just constraining the width.

This likely won't be of high priority for us in the immediate future, but we're always open to pull requests and are happy to help on them!

@mdtusz mdtusz added feature something new community community contribution labels Apr 1, 2016
@eugenesvk
Copy link
Author

Ok, understood. Would help with a PR if I had a clue how to do that :)

Pardon my ignorance, but is there no way to introduce a JavaScript function that would be applied to all labels before they're fed to SVG/WebGL, where it becomes complicated?
For example, I've google-found this function (it's not great since there is an issue with long words, but just as an illustration)

function stringDivider(str, width, spaceReplacer) {
    if (str.length>width) {
        var p=width
        for (;p>0 && str[p]!=' ';p--) {
        }
        if (p>0) {
            var left = str.substring(0, p);
            var right = str.substring(p+1);
            return left + spaceReplacer + stringDivider(right, width, spaceReplacer);
        }
    }
    return str;
}

that I can use internally to shorten the labels

var label = "Very Long Text Label That Doesn't Fit Properly"
x: ['giraffes', 'orangutans', stringDivider( label, 20, "<br>") ],

Also, as a side question — is there a way to read the bar width in plotly so I don't have to adjust the width manually and can make it reflow properly when chart size is adjusted.

@datapanda
Copy link

Any update on this?

@grahamscott
Copy link

@mdtusz I'm happy to try to help with this if you're able to point me in the right direction in the codebase.

@etpinard
Copy link
Contributor

etpinard commented Jul 7, 2017

#1834 should see some foundation work on this.

@pietersv
Copy link

A workaround is to line break svg elements after rendering. This assumes that there are <br/> tags in the text, and splits text elements into multiple text elements.

      var insertLinebreaks = function (d) {

        if (typeof d.text === 'string') {
          var words = d.text.split(/<br[/]?>/);
          var el = d3.select(this);
          el.text('')
          for (var i = 0; i < words.length; i++) {
            var tspan = el.append('tspan').text(words[i]);
            if (i > 0)
              tspan.attr('x', 0).attr('dy', '15');
          }
        }
      };   

Then it's a matter of selecting svg text elements and calling the above function:

d3.select('#c_Q1 svg').selectAll('g text').each(insertLinebreaks);

You can get fancier with the selector so line break just axis labels for instance.

It could also be used in conjunction with above method by @eugenesvk to evaluate long strings and insert such line break markers.

@punitaojha
Copy link

Has the plotly library managed to solve this issue now. Looking for some better alternatives.

@wiznotwiz
Copy link

wiznotwiz commented Aug 31, 2021

I also wanted to know if Plotly has solved for this yet - this discussion has been going on since 2016. Adding
s manually doesn't make sense - most of us are probably grabbing data from an API.

@paulouliana1
Copy link

I'd also like to know whether Plotly intends to solve this. Thanks!

@archmoj
Copy link
Contributor

archmoj commented Oct 9, 2021

Wondering if using overflow CSS could become helpful?
See https://developer.mozilla.org/en-US/docs/Web/CSS/overflow

@nicolaskruchten
Copy link
Contributor

whether Plotly intends to solve this

No one from our team is actively working on this but we would happily work with anyone who wants to help :)

@m-podlesny
Copy link

whether Plotly intends to solve this

No one from our team is actively working on this but we would happily work with anyone who wants to help :)

Which way to go: #5700 or #2053 ?

@craragon77
Copy link

craragon77 commented Feb 11, 2022

still no updates on this right? cause it would be suuuuper clutch if this there was a way to wrap labels. I'm working with some long, dynamic violin plot labels and it would be so awesome if this feature were addressed

@pietersv
Copy link

@craragon77 SVG doesn't have line wrap / truncation as native features I think , so the code needs to explicitly break/wrap/recenter or truncate text. Below is code that selects and breaks or truncates text in a Plotly chart after rendering.

The ideal solution would be for someone to package and test this nicely for a pull request to Plotly, but for expedience we just select break the text ourselves:

        breakText: function ($el, chartOptions) {
          var self = this;
          var svg = $("svg", $el)
          if (svg && svg.length > 0) {
            var xbreaks = 0;
            d3.selectAll(svg).each(function () {
              if (chartOptions.wrap) {            
                svgLineBreaks.breakElements(this, 'g.xtick text', {
                  maxLineLength: _.get(chartOptions, 'xaxis.maxLabelLength') || 12,
                  verticalAlign: ""
                })
                svgLineBreaks.breakElements(this, 'g.xtick2 text', {
                  maxLineLength: _.get(chartOptions, 'xaxis.maxLabelLength') || 12,
                  verticalAlign: ""
                })
                svgLineBreaks.breakElements(this, 'g.ytick text', {
                  maxLineLength: _.get(chartOptions, 'yaxis.maxLabelLength') || 50,
                  verticalAlign: "middle"
                })
                svgLineBreaks.breakElements(this, 'g.ytick2 text', {
                  maxLineLength: _.get(chartOptions, 'yaxis.maxLabelLength') || 50,
                  verticalAlign: "middle"
                })
              }
              else { /// truncate
                d3.select(this).selectAll('g.xtick text').each(svgLineBreaks.truncateElement(_.get(chartOptions, 'xaxis.maxLabelLength') || 12));
                d3.select(this).selectAll('g.ytick text').each(svgLineBreaks.truncateElement(_.get(chartOptions, 'yaxis.maxLabelLength') || 50));
                d3.select(this).selectAll('g.ytick2 text').each(svgLineBreaks.truncateElement(_.get(chartOptions, 'yaxis.maxLabelLength') || 50));
                d3.select(this).selectAll('g.xtick2 text').each(svgLineBreaks.truncateElement(_.get(chartOptions, 'xaxis.maxLabelLength') || 50));
              }

              d3.select(this).selectAll('text.legendtext').each(svgLineBreaks.truncateElement(_.get(chartOptions, 'legend.maxLabelLength') || 50))
              d3.select(this).selectAll('g.xtick2 text').each(function () {
                var el = d3.select(this)
                el.attr('dy', 20)
              })
            })
          }
        }

const _ = require('lodash')
module.exports = {

    breakStr: function (text, maxLineLength, regexForceBreak, regexSuggestBreak) {
      const resultArr = [];
      maxLineLength = maxLineLength || 12
      regexForceBreak = regexForceBreak || /<br[/]?>|\n|\&/g
      regexSuggestBreak = /\s/g


      function m(str) {
        return !!(str && str.trim())
      }

      var splitStr = text.split(regexForceBreak);
      splitStr.forEach(function (str) {
        if (str.length <= maxLineLength) {
          resultArr.push(str);
        } else {
          var tempStr = str;
          while (tempStr) {
            var suggestedBreakPoints = []
            var convenientBreakPoint = Infinity

            var match, fragment;
            while ((match = regexSuggestBreak.exec(tempStr)) != null) {
              suggestedBreakPoints.push(match.index + 1)
              if (match.index <= maxLineLength) convenientBreakPoint = match.index;
            }

            if (tempStr.trim() && tempStr.length <= maxLineLength) {
              fragment = tempStr
              resultArr.push(fragment)
              tempStr = ''
            }
            else if ((convenientBreakPoint <= maxLineLength) && (maxLineLength > maxLineLength - 5)) {
              fragment = tempStr.substr(0, convenientBreakPoint)
              resultArr.push(fragment)
              tempStr = tempStr.substr(convenientBreakPoint + 1);
            }
            else {
              fragment = tempStr.substr(0, maxLineLength) + "–"
              resultArr.push(fragment)
              tempStr = tempStr.substr(maxLineLength);
            }
          }
        }
      });
      return resultArr;
    },

    /**
     * @method svgLineBreaks
     * @returns <function(d)> Returns a function that breaks an SVG text element into multiple lines
     * @param maxLineLength      Maximum number of lines before truncating with ellipses
     * @param regexForceBreak    Regex to suggest places to always, currently •|<br>|<br/>|\n
     * @param regexSuggestBreak  Regex to suggest preferred breaks, currently <wbr>
     * @param verticalAlign    e.g. 'middle' to optionally center mutiple lines at same ypos as original text
     * @example  svg.selectAll('g.x.axis g text').each(svgLineBreaks);
     */
    breakElement: function (maxLineLength, regexForceBreak, regexSuggestBreak, verticalAlign) {

      maxLineLength = maxLineLength || 12
      regexForceBreak = regexForceBreak || /<br[/]?>|•|\n/ig
      regexSuggestBreak = regexSuggestBreak || /•|<wbr>/ig
      return function (d) {
        var el = d3.select(this);
        var bbox = el.node().getBBox()
        var height = bbox.height
            height = height ||16.5   //hack as Plotly might draw before rendered on screen, so height zero; this is just a guess

        var result = {
          el: this,
          dy: 0,
          lines: []
        }

        var text = el.text()
        var total_dy = 0;
        if (text) {
          var lines = this_module.breakStr(text, maxLineLength, regexForceBreak, regexSuggestBreak)
          result.lines = lines
          for (var i = 0; i < lines.length; i++) {
            if (i == 0) {
              el.text(lines[0])
            }
            else {
              var tspan = el.append('tspan')
              tspan.text(lines[i]);
              tspan.attr('x', el.attr('x') || 0).attr('dy', height);
              total_dy += height
            }
          }

          if (verticalAlign == 'middle') {
            el.attr('y', el.attr('y') - (lines.length - 1) * height / 2)
          }
        }

        result.dy = total_dy
        return result
      }
    },

    /**
     * maxLineLength, regexForceBreak, regexSuggestBreak, adjust
     * @param d3el
     * @param selector
     * @param options.maxLineLength
     * @param options.regexForceBreak
     * @param options.regexSuggestBreak
     * @param options.position   "middle"
     */
    breakElements: function (d3el, selector, options) {
      var self = this;
      options = options || {}
      var breaker = self.breakElement(options.maxLineLength || 12, options.regexForceBreak, options.regexSuggestBreak, options.verticalAlign)
      var max_total_dy = 0

      d3.select(d3el).selectAll(selector).each(function (d, i) {
        var result = breaker.apply(this, arguments)
        if (i > 0 && (result.dy > max_total_dy)) {
          max_total_dy = result.dy
        }
      })
    },

    truncateElement: function (maxLineLength) {
      return function (d) {
        var el = d3.select(this)
        var text = el.text()

        if (text && text.length > maxLineLength) {
          text = (text.substr(0, maxLineLength) + "…")
        }
        el.text(text)
      }
    }
  }

@fedderw
Copy link

fedderw commented Oct 28, 2022

whether Plotly intends to solve this

No one from our team is actively working on this but we would happily work with anyone who wants to help :)

Wow, I came back to plotly after a year and I'm surprised to see this.

It looks SO unprofessional to use any sort of labeling in plotly. It appears to others that there's a bug and I did something wrong, so I don't know why this isn't a bigger issue.

It's enough to have made me use Tableau Public instead - and the entire advantage of plotly is customizability, so this has been a frustrating issue to rediscover several hours into making the plot

@pietersv
Copy link

pietersv commented Oct 28, 2022

Charting seems so simple at first but the diversity of users, use cases, and edge cases is wild.
And word break / line wrapping specifically is surprisingly nuanced.

Plotly is an open source library, so theoretically we can add features we need such as this.
Something that works for us is posted above, but the gap between 'works for us' and 'works for many' is non trivial.
If any one has the skills and interest to create a good patch and/or get a PR accepted for this issue, we'll contribute $.

@fedderw
Copy link

fedderw commented Oct 28, 2022

Charting seems so simple at first but the diversity of users, use cases, and edge cases is wild. And word break / line wrapping specifically is surprisingly nuanced.

I definitely understand that considering the number of tools I've looked at for what I thought was relatively simple (horizontal timeline with groups and text wrapping that my dad could edit), but of course, these things never are! I can't

This is surprising to me because I don't think it's an edge case at all. It's essential to being able to label data on the graph - it's not presentable if you have overlapping labels/no linebreaks/uneven text sizes (although that's fixed I think), etc.

And labeling data is essential for communication.

It took a very long time and a lot of manual screenshotting for a non-web dev to make this chart look presentable with Plotly in this Dash App!

I would say I spent at least 30% of my time with Plotly trying to get labels to look presentable, and it's why I no longer use the library if I can possibly help it

@mdtusz
Copy link
Contributor

mdtusz commented Oct 28, 2022

I no longer work at Plotly or have any affiliation with them, so this is not an "official" response, but was a core maintainer when this issue was originally opened.

The challenge is that there's not actually a reasonable solution to the problem that will be correct in all cases. Text wrapping in the charts is tricky because there are so many variables that are difficult to actually control for - fonts, font sizes, browsers, zoom level, etc. all contribute to the actual width of a given block of text. While yes it is a challenge to format text with long labels, it's par for the course with what plotly attempts to achieve - being an extensible and flexible library which doesn't really get in the way of making nearly any chart or graph visualization. There's alternatives on either side of its niche - D3 if you want to go lower level, and things like Tableau or Highcharts if you want to go higher level, but those come with their own set of compromises you'll have to adapt to.

I don't imagine this issue will ever be resolved, as text wrapping simply isn't something that plotly.js is positioned for to handle automatically, nor should it (IMO) - it would be like asking React to automatically do the layout of your webapp. Frankly, I'm surprised I still get notifications about this issue more than 6 years later and it hasn't been closed yet 😆

All this to say I would love to be proven wrong and see a community contribution which does implement a robust solution. PR's are always welcome :)

@kbradwell
Copy link

kbradwell commented Jul 26, 2023

Workaround example for spacing and wrapping titles on subplots:

from plotly.subplots import make_subplots
import plotly.graph_objects as go

from textwrap import wrap

def wrap_titles(title_for_wrapping):
    wrappedTitle = "<br>".join(wrap(title_for_wrapping, width=50))
    return wrappedTitle

fig = make_subplots(
    rows=2, cols=2,
    specs=[[{"type": "barpolar"}, {"type": "barpolar"}],
           [{"type": "barpolar"}, {"type": "barpolar"}]],
    subplot_titles=[wrap_titles(plt1_tuple[1]), wrap_titles(plt2_tuple[1]), wrap_titles(plt3_tuple[1]), wrap_titles(plt4_tuple[1])]
)

...
(fig.add_trace code)
...

fig.update_layout(height=700, showlegend=False)

# adjust titles of subplots
for annotation in fig['layout']['annotations']: 
        annotation['height']=60

fig.show()

@asmaier
Copy link

asmaier commented Apr 2, 2024

If you are using Python and pandas you can use wrap() to wrap long text labels. Here is some example code:

import pandas as pd
import plotly.express as px

# these sentences are much too long to use them as labels
sentences = [
"I no longer work at Plotly or have any affiliation with them, so this is not an \"official\" response, but was a core maintainer when this issue was originally opened.",
"The challenge is that there's not actually a reasonable solution to the problem that will be correct in all cases.",
"Text wrapping in the charts is tricky because there are so many variables that are difficult to actually control for - fonts, font sizes, browsers, zoom level, etc. all contribute to the actual width of a given block of text.",
"While yes it is a challenge to format text with long labels, it's par for the course with what plotly attempts to achieve - being an extensible and flexible library which doesn't really get in the way of making nearly any chart or graph visualization."
]

df = pd.DataFrame([(text, len(text)) for text in sentences], columns=["text", "length"])
# Here we wrap the text to 20 columns and a maximum of three lines
df["text_wrapped"] = df["text"].str.wrap(width=20, max_lines=3, placeholder = " ... ")

fig = px.bar(df, x="length", y="text_wrapped", orientation="h", hover_data={"text_wrapped":False, "text":True}, text="length")
fig.update_layout(margin_pad=10, template="ggplot2")
fig.update_yaxes(title = "text", categoryorder='total ascending')
fig.show()

The output looks like
Bildschirmfoto 2024-04-02 um 10 34 12
The untruncated text can be seen while hovering over the bar chart. It would be nicer to show the untruncated text while hovering over the axis labels. Unfortunately hovers over the axis labels don't seem to be possible in plotly.

@archmoj
Copy link
Contributor

archmoj commented Jun 5, 2024

It looks like one could make use of foreignObject https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject to implement this feature.

@alexcjohnson
Copy link
Collaborator

To my knowledge foreignObject does not work outside browsers - so this would break SVG export

@archmoj
Copy link
Contributor

archmoj commented Jun 5, 2024

To my knowledge foreignObject does not work outside browsers - so this would break SVG export

Thanks @alexcjohnson for the note.
I was also thinking about it.
If the PNG export works, this may still be a good option. No?

@archmoj
Copy link
Contributor

archmoj commented Jun 5, 2024

@alexcjohnson Update: It looks like using foreignObject is not a good option as it could introduce security issues.

@archmoj archmoj removed the community community contribution label Jul 11, 2024
@gvwilson gvwilson added the P3 backlog label Aug 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature something new P3 backlog
Projects
None yet
Development

No branches or pull requests