Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

D3.js v4+ : truncate text to fit in fixed space

It is not uncommon to require text elements in the SVG we are manipulating via d3 e.g. categorical tick labels. This is somewhat unfortunate as the <text> element in SVG is not the best... The size of fonts rendered is often slightly larger than that of how much one thinks the font should take. For example, if choosing a mono-space font with a width / height ratio of 0.6 (e.g. if font size is 12px then the width of a character should be 7.2px), the element's computed bounding rectangle might be 14.2px by n*8px where n is the number of characters.

Further complicating the issue is the fact that more often than not, people use fonts which are not monospaced.

It is easy enough to truncate a string which is "too long" by

string.slice(0, numChars-3)+'...'

but knowing the correct number of characters to fit within a fixed width seems non trivial.

function truncateText(t, text, space) {
  // make sure it is a string
  text = String(text)
  // set text
  t.text(text)
  // get space it takes up
  var rect = t.node().getBoundingClientRect()

  while (Math.max(rect.width, rect.height) > space) {
    text = text.slice(0, text.length - 1)
    t.text(text + '...')
    rect = t.node().getBoundingClientRect()
    if (text.length == 0) break
  }
}

the above function takes a d3.selection, the text and the space in which the text should fit in. By constantly manipulating the DOM, we can get perfect fit, however this is computationally very expensive.

To clarify, to fit text in a fix space, I mean that if I have a string var string = "this is my very long string", I want the direction of the string being rendered (left to right, i.e. we are looking at string length) to fit within a fixed space (e.g. var fixedSpace = 100 //px)

The above truncate text function works well for just a few strings, but if there are many strings that call this function, it gets laggy.

Of course we could optimized by just picking the longest string, calculate truncateText on that string, and take the number of characters as a result (although this is still somewhat buggy as not all characters have same width).

Is there a more efficient way to truncate text into a fixed space with d3

like image 873
SumNeuron Avatar asked May 29 '18 08:05

SumNeuron


People also ask

How to create a tick format function in D3?

The d3.axis.tickFormat() Function in D3.js is used to control which ticks are labelled. This function is used to implement your own tick format function. Syntax: axis.tickFormat([format]) Parameters: This function accepts the following parameter. format: These parameters are format to set the tick format function.

What is the format () function in D3?

The format () function in D3.js is used to format the numbers in different styles available in D3. It is the alias for locale.format.

What is the use of selection text in D3?

Last Updated : 23 Aug, 2020 The selection.text () function in d3.js is used to set the text content to the specified value of the selected elements thus, it replaces any existing child elements. If the value that is given is constant than all elements will be given that constant value.

How do you truncate text in a cell in Excel?

Three of those functions help you truncate text in a cell. These are RIGHT, LEFT, and MID, and each has a variation for using bytes instead of characters. The RIGHT function uses the number of characters for a single-byte character set (SBCS) while RIGHTB uses a number of bytes for a double-byte character set (DBCS).


2 Answers

I agree that the approach you've suggested is computationally expensive, but it's about the only one I can think of. However if you're only running it occasionally (i.e. just on page load, rather than on mouseover), then it shouldn't be too bad, depending on how many text elements you're applying it to.

You might want to try comparing the performance of your approach to the one in this example by Mike Bostock, which uses node().getComputedTextLength() instead of node().getBoundingClientRect() and breaks up the text by word:

function wrap(text, width) {
  text.each(function() {
    var text = d3.select(this),
        words = text.text().split(/\s+/).reverse(),
        word,
        line = [],
        lineNumber = 0,
        lineHeight = 1.1, // ems
        y = text.attr("y"),
        dy = parseFloat(text.attr("dy")),
        tspan = text.text(null).append("tspan").attr("x", 0).attr("y", y).attr("dy", dy + "em");
    while (word = words.pop()) {
      line.push(word);
      tspan.text(line.join(" "));
      if (tspan.node().getComputedTextLength() > width) {
        line.pop();
        tspan.text(line.join(" "));
        line = [word];
        tspan = text.append("tspan").attr("x", 0).attr("y", y).attr("dy", ++lineNumber * lineHeight + dy + "em").text(word);
      }
    }
  });
}

PS/ There's a CSS technique for truncating text with an ellipsis, but unfortunately it doesn't work in SVG :(

like image 151
richardwestenra Avatar answered Sep 19 '22 14:09

richardwestenra


Another option would be to use a 100px wide rect as the clipPath for your text element -- something like so:

d3.selectAll("text.label")
  .attr("x", function(t) {
    return Math.max(0, 100-this.textLength.baseVal.value);
  });
#text-box {
  stroke: green;
  fill: white;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<svg>
  <defs>
    <rect id="text-box" x="0" y="0" width="100" height="1.2em" />
    <clipPath id="clip-box">
      <use href="#text-box" />
    </clipPath>
  </defs>

  <g transform="translate(0, 0)">
    <use x="0" y="0" href="#text-box" />
    <text x="0" y="1em" class="label" clip-path="url(#clip-box)">Long text that should be clipped</text>
  </g>

  <g transform="translate(0, 50)">
    <use x="0" y="0" href="#text-box" />
    <text x="0" y="1em" class="label" clip-path="url(#clip-box)">Another long string</text>
  </g>

  <g transform="translate(0, 100)">
    <use x="0" y="0" href="#text-box" />
    <text x="0" y="1em" class="label" clip-path="url(#clip-box)">Short text</text>
  </g>
</svg>

Update: After the labels are rendered, use this function to get their lengths, and adjust the "x" attribute if the text is shorter than the clipping width:

d3.selectAll("text.label")
  .attr("x", function(t) {
    return Math.max(0, 100-this.textLength.baseVal.value);
  });
like image 41
SteveR Avatar answered Sep 18 '22 14:09

SteveR