plotly / plotly.js

Open-source JavaScript charting library behind Plotly and Dash
https://plotly.com/javascript/
MIT License
16.97k stars 1.86k forks source link

Word wrap for long labels #382

Open eugenesvk opened 8 years ago

eugenesvk commented 8 years ago

Is there an option to reflow long text labels?

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 commented 8 years ago

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!

eugenesvk commented 8 years ago

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 commented 7 years ago

Any update on this?

grahamscott commented 7 years ago

@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 commented 7 years ago

https://github.com/plotly/plotly.js/pull/1834 should see some foundation work on this.

pietersv commented 4 years ago

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 commented 3 years ago

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

wiznotwiz commented 3 years ago

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 commented 3 years ago

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

archmoj commented 3 years ago

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

nicolaskruchten commented 3 years ago

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 commented 3 years ago

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 commented 2 years ago

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 commented 2 years ago

@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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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

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 commented 1 year ago

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 commented 6 months ago

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 commented 4 months ago

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 commented 4 months ago

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

archmoj commented 4 months ago

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 commented 4 months ago

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