Wrap text within circle
Asked Answered
V

5

13

I'm using d3 to draw a UML diagram and would like to wrap text within the shapes drawn with d3. I've gotten as far as the code below and can't find a solution to make the text 'fit' within my shape (see image below).

var svg =  d3.select('#svg')
    .append('svg')
        .attr('width', 500)
        .attr('height', 200);

var global = svg.append('g');

global.append('circle')
      .attr('cx', 150)
      .attr('cy', 100)
      .attr('r', 50);

global.append('text')
  .attr('x', 150)
  .attr('y', 100)
  .attr('height', 'auto')
  .attr('text-anchor', 'middle')
  .text('Text meant to fit within circle')
  .attr('fill', 'red');

result

Volscian answered 3/1, 2014 at 22:27 Comment(3)
I faced this problem once. D3 does not, to my knowledge at least, have any way to doing this, you will need to programatically break up your words, into separate lines in their own tspan's, adjusting their dy attribute for each line. Then, center it over the circle, compute the radius, and scale down to fit if necessary. I don't have functioning code for this, as I was able to get away with breaking each word onto their own line, but that's the basic idea.Mcintyre
I have a feeling that this is a helluva question that needs a helluva answer.Scutter
Here is a similar question: (#17709985) ... but no, no, there was no satisfactory answer!Scutter
I
10

Here is the best I could do.

enter image description here

I want to center and wrap a text inside a circle or rect in SVG. The text should remain centered (horizontal/vertical) whatever the text length.

svg {
    width: 600px;
    height: 200px;
    background-color: yellow;
}
.circle {
    background-color: blue;
    height: 100%;
    border-radius: 100%;
    text-align: center;
    line-height: 200px;
    font-size: 30px;
}
.circle span {
    line-height: normal;
    display:inline-block;
    vertical-align: middle;
    color: white;
    text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
<svg>
    <foreignObject width="200" height="200" x="100" y="100" transform="translate(-100,-100)">
        <div class="circle">
            <span>Here is a</span>
        </div>
    </foreignObject>

    <foreignObject width="200" height="200" x="300" y="100" transform="translate(-100,-100)">
        <div class="circle">
            <span>Here is a paragraph</span>
        </div>
    </foreignObject>

    <foreignObject width="200" height="200" x="500" y="100" transform="translate(-100,-100)">
        <div class="circle">
            <span>Here is a paragraph that requires word wrap</span>
        </div>
    </foreignObject>
</svg>

The transform attribute is not mandatory, I'm using a translate(-r, -r) so that the (x,y) of the foreignObject is like the (cx, cy) of the SVG circle, and width, height = 2*r with r the radius.

I did this to use as nodes within a D3 force layout. I leave as an exercise to translate this snippet into javascript D3's style.

Incubator answered 19/6, 2015 at 8:3 Comment(0)
V
9

SVG doesn't provide text wrapping, but using foreignObject you can achieve a similar effect. Assuming that radius is the radius of the circle, we can compute the dimensions of a box that will fit inside the circle:

var side = 2 * radius * Math.cos(Math.PI / 4),
    dx = radius - side / 2;

var g = svg.append('g')
    .attr('transform', 'translate(' + [dx, dx] + ')');

g.append("foreignObject")
    .attr("width", side)
    .attr("height", side)
    .append("xhtml:body")
    .html("Lorem ipsum dolor sit amet, ...");

The group should be displaced a small amount to have the text centered. I know that this is not exactly what is asked, but it can be helpful. I wrote a small fiddle. The result will look like this:

enter image description here

Venipuncture answered 4/1, 2014 at 0:25 Comment(2)
Sadly, the foreignObject element can't be centered vertically. If the text is too short, it will be aligned to the top of the box.Venipuncture
Keep in mind that foreignObject does not work with IE.Travel
A
5

If you add your content inside a <text> element immediately below the SVG shape, then you can use D3plus' .textwrap() function to do exactly this. I quote from the documentation:

Using d3plus.textwrap, SVG <text> elements can be broken into separate <tspan> lines, as HTML does with <div> elements.... D3plus automatically detects if there is a <rect> or <circle> element placed directly before the <text> container element in DOM, and uses that element's shape and dimensions to wrap the text. If it can't find one, or that behavior needs to be overridden, they can manually be specified using .shape( ), .width( ), and .height( ).

I've created a codepen to better illustrate this since the examples in the documentation can be a little confusing: http://codepen.io/thdoan/pen/rOPYxE

Allo answered 16/11, 2015 at 17:48 Comment(1)
this is really nice, but it breaks on long words. We could truncate/hyphenate them, but the maximum possible length depends on which line within the circle this word is.Maui
V
1

It's not ideal, but @Pablo.Navarro's answer led me to the following.

var svg =  d3.select('#svg')
  .append('svg')
    .attr('width', 500)
    .attr('height', 200);

var radius = 60,
    x      = 150,
    y      = 100,
    side   = 2 * radius * Math.cos(Math.PI / 4),
    dx     = radius - side / 2;

var global = svg.append('g')
  .attr('transform', 'translate(' + [ dx, dx ] + ')');

global.append('circle')
  .attr('cx', x)
  .attr('cy', y)
  .attr('r', radius);

global.append('foreignObject')
  .attr('x', x - (side/2))
  .attr('y', y - (side/2))
  .attr('width', side)
  .attr('height', side)
  .attr('color', 'red')
  .append('xhtml:p')
    .text('Text meant to fit within circle')
    .attr('style', 'text-align:center;padding:2px;margin:2px;');

Result

result

Volscian answered 4/1, 2014 at 3:5 Comment(4)
Yes, but you need fairly large circle. In other words, the method doesn't efficiently use the circle.Scutter
Agreed, It's not ideal but gives the result I wanted. Still hopeful that the correct answer comes along. Ultimately their needs to be a relationship between the text and circle and for them to scale accordingly.Volscian
You are correct. BTW, its amazing that nobody solved it by now (I mean all those years...). Very good question!Scutter
I added my answer which is a bit better I hope. You still need to take care of the font size if the text becomes too long.Incubator
T
1

For me is the best solution so far.

// based on code: https://observablehq.com/@mbostock/fit-text-to-circle

function createChart(lines, lineHeight) {
      const width = 180;
      const height = width;
      const radius = Math.min(width, height) / 2 - 4;

      const svg = d3
        .select("#graph")
        .append("svg")
        .style("font", "10px sans-serif")
        .style("width", "500px")
        .style("height", "500px")
        .attr("text-anchor", "middle");

      svg
        .append("circle")
        .attr("cx", width / 2)
        .attr("cy", height / 2)
        .attr("fill", "#ccc")
        .attr("r", radius);

      svg
        .append("text")
        .attr(
          "transform",
          `translate(${width / 2},${height / 2}) scale(${
            radius / textRadius(lines, lineHeight)
          })`
        )
        .selectAll("tspan")
        .data(lines)
        .enter()
        .append("tspan")
        .attr("x", 0)
        .attr("y", (d, i) => (i - lines.length / 2 + 0.8) * lineHeight)
        .text((d) => d.text);

      return svg.node();
    }

    function textRadius(lines, lineHeight) {
      let radius = 0;
      for (let i = 0, n = lines.length; i < n; ++i) {
        const dy = (Math.abs(i - n / 2 + 0.5) + 0.5) * lineHeight;
        const dx = lines[i].width / 2;
        radius = Math.max(radius, Math.sqrt(dx ** 2 + dy ** 2));
      }
      return radius;
    }

    function createWords(text) {
      const words = text.split(/\s+/g); // To hyphenate: /\s+|(?<=-)/
      if (!words[words.length - 1]) words.pop();
      if (!words[0]) words.shift();
      return words;
    }

    function createLines(words) {
      let line;
      let lineWidth0 = Infinity;
      const lines = [];
      for (let i = 0, n = words.length; i < n; ++i) {
        let lineText1 = (line ? line.text + " " : "") + words[i];
        let lineWidth1 = measureWidth(lineText1);

        if ((lineWidth0 + lineWidth1) / 2 < targetWidth) {
          line.width = lineWidth0 = lineWidth1;
          line.text = lineText1;
        } else {
          lineWidth0 = measureWidth(words[i]);
          line = { width: lineWidth0, text: words[i] };
          lines.push(line);
        }
      }
      return lines;
    }

    function measureWidth(text) {
      const ctx = document.createElement("canvas").getContext("2d");
      return ctx.measureText(text).width;
    }

    const text =
      "Hello! This notebookshows how to wrap andfit text inside a circle. Itmight be useful forlabelling a bubble chart.You can edit the textbelow, or read the notesand code to learn howit works! 😎";

    const lineHeight = 12;
    const targetWidth = Math.sqrt(measureWidth(text.trim()) * lineHeight);
    const lines = createLines(createWords(text));

    createChart(lines, lineHeight);
 <head>
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.1.1/d3.min.js"
      crossorigin="anonymous"
      referrerpolicy="no-referrer"
    ></script>
  </head>  

<body>
    <div id="graph"></div>
  </body>
Taxidermy answered 9/11, 2021 at 10:18 Comment(1)
Why this is the best solution ? How did you make it ? Can you explain by editing your answer :)Sassafras

© 2022 - 2024 — McMap. All rights reserved.