37

I want to display a rect with a text label next to it. The width of the rect should stretch to the width of the svg container, less the width of the the text, which is dynamic and can be of any variable length.

JSFiddle

var text = 'Foobar';
var textWidth = 50; //how to calculate this?
var plotWidth = 400;
var barWidth = plotWidth-textWidth;

var plot = d3.select(container)
        .insert("svg")
        .attr('width', plotWidth)
        .attr('height', 50);

plot.append("rect")
    .style("fill", "steelblue")
    .attr("x", 0)
    .attr("width", barWidth)
    .attr("y", 0)
    .attr("height", 50);

plot.append("text")
    .attr("x", barWidth)
    .attr("y", 28)
    .text(text);

How do I calculate the width of the text using D3, before it is drawn? Or how do I otherwise position and size elements that depend on the dimensions of variable length text?

mtmacdonald
  • 14,216
  • 19
  • 63
  • 99
  • 3
    See http://stackoverflow.com/questions/20224611/d3-position-text-element-dependent-on-length-of-element-before – Lars Kotthoff Mar 13 '15 at 12:07
  • 1
    One simple solution I use in situation where a function to measure the text is not easy to come by is to utilize the font-size in pixels. Width = number of chars * size in pixels. Height = size in pixels. Some minor adjustment may be necessary with height. – jsa Apr 05 '20 at 05:55
  • related: https://stackoverflow.com/q/21486622 – djvg Feb 01 '23 at 09:42

4 Answers4

29

I know you asked about D3, but this might be a native solution to your issue.

The HTML5 canvas 2D context has some built-in functionality to measure text. You might be able to tap into that to measure text for other APIs like SVG. If it's not 100% accurate, surely it's proportional to the correct answer.

var BrowserText = (function () {
    var canvas = document.createElement('canvas'),
        context = canvas.getContext('2d');

    /**
     * Measures the rendered width of arbitrary text given the font size and font face
     * @param {string} text The text to measure
     * @param {number} fontSize The font size in pixels
     * @param {string} fontFace The font face ("Arial", "Helvetica", etc.)
     * @returns {number} The width of the text
     **/
    function getWidth(text, fontSize, fontFace) {
        context.font = fontSize + 'px ' + fontFace;
        return context.measureText(text).width;
    }

    return {
        getWidth: getWidth
    };
})();

// Then call it like this:
console.log(BrowserText.getWidth('hello world', 22, 'Arial')); // 105.166015625
console.log(BrowserText.getWidth('hello world', 22)); // 100.8154296875
TxRegex
  • 2,347
  • 21
  • 20
  • 1
    We found this to be a great approach to avoiding the issue of getComputedTextLength triggering a render for every call. Obviously if you're going to make a large number of calls, it's worth creating the canvas and canvascontext once, then just calling measureText for each string. – Will Dean Mar 08 '19 at 15:36
23

I had a similar problem in a complex chart with lots of interactions between elements and text, which required knowing the text width before displaying any element.

I resorted to creating a dummy text to grab its width and immediately removing it. Note the last line of code of the function in each.

var textData = ['a', 'b', 'c']    // your text here

var textWidth = []

svg.append('g')
    .selectAll('.dummyText')     // declare a new CSS class 'dummyText'
    .data(textData)
    .enter()                     // create new element
    .append("text")              // add element to class
    .attr("font-family", "sans-serif")
    .attr("font-size", "14px")
    //.attr("opacity", 0.0)      // not really necessary
    .text(function(d) { return d})
    .each(function(d,i) {
        var thisWidth = this.getComputedTextLength()
        textWidth.push(thisWidth)
        this.remove() // remove them just after displaying them
    })

console.log(textWidth) // this array contains the on-screen width of each text element
Ian
  • 11,280
  • 3
  • 36
  • 58
Pythonic
  • 2,091
  • 3
  • 21
  • 34
  • 1
    Where does that ".dummyText" come from? Do I have to create it somewhere upfront? Will it remain there for future calculations? – ygoe Aug 29 '18 at 09:36
  • It's a class name for the text you are using to gauge the width, and it will go away by itself thanks to `this.remove()` – Pythonic Aug 30 '18 at 07:29
  • Yeah but it never appears anywhere. The code works with it, I have no idea why, it could possibly be anything. But it fails without that line, so I left it in. – ygoe Aug 30 '18 at 11:21
  • Correct, it should not appear anywhere. When you do `selectAll` you are declaring a new class `dummyText` and then with `enter` and `append` you are creating new text elements belonging to that class. Since you then `remove` each of them, you will never see them appear. – Pythonic Aug 31 '18 at 07:05
  • Ah, so `selectAll` can also create elements, or put another way, the selected class (even though it doesn't exist at selection time) is implicitly applied to later created elements? I couldn't see that in the documentation. – ygoe Aug 31 '18 at 11:05
  • Precisely, the selected class is applied to the elements that you create with `enter()` and `text()` - admittedly it takes a moment to digest once you start with D3 (at least, that was my case) – Pythonic Sep 02 '18 at 08:24
11

Here's a working example based on using getBBox().width getComputedTextLength():

Edit: Updating the answer to use getComputedTextLength due to performance concerns (see comment)

http://jsfiddle.net/henbox/jzkj29nv/27/

var text_element = plot.select("text");
var textWidth = text_element.node().getComputedTextLength()

I've also switched to using text-anchor: end; CSS for the text, so you don't need to calculate the start position of the text (just pass in the end)

Henry S
  • 3,072
  • 1
  • 13
  • 25
  • 2
    It's worth noting that getBBox() forces a render pass in the browser and so if done repeatedly can cause terrible performance issues. – averydev Apr 04 '17 at 03:45
  • Thanks - Good call! I've updated the answer to remove getBBox() and recommend getComputedTextLength – Henry S Apr 04 '17 at 10:38
  • in your example its not the calculation which putting text in the end, but it is the anchor which is showing text in the end of rectangle – Jayesh L Aug 10 '18 at 12:31
  • 1
    From my observations, getComputedTextLength also triggers rendering. – Chris Jan 26 '19 at 23:52
0

It is also possible to use getComputedTextLength() or getBBox() inside a d3 selection.attr.

Here's a minimal example that calculates x and y coordinates based on the size of the text content:

d3.select('svg').append('text')
  .text('some text')
    .attr('x', function() {return this.getComputedTextLength();})
    .attr('y', (d, i, nodes) => nodes[i].getBBox().height);
svg {
  border: 1px dashed black;
}

svg text {
  fill: black;
  text-anchor: start;
  dominant-baseline: hanging;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg width="200" height="100"></svg>

Note that two function styles are used, to illustrate the following:

In a normal function (), you can use either nodes[i] or this to access the current node.

However, in arrow functions =>, we must use nodes[i], because using this raises an error:

this.getComputedTextLength is not a function

Also see d3-selection issue 97 and this SO answer.

djvg
  • 11,722
  • 5
  • 72
  • 103