Sachin Rajput
Sachin Rajput

Reputation: 248

trying to create a donut chart with labels inside the curve using d3.js

I'm trying to figure out how I can fit the text inside the arc and auto-adjust the font size of labels to fit into the arc so that text-overflow doesn't happen.

I'm using D3.js to create a shape and then I'm trying to put the text on the arc and my tweaking my value I'm getting the text inside of the donut shape.

The problem is that the text is not starting from the appropriate position and I want the text to be centralized too.

Below is the code I have been working with and the current output:

enter image description here

function wrap(text, width) {
  let lineNumbers = 1;

  text.each(function () {
    let text = d3.select(this),
      line = [],
      lineNumbers = 1,
      lineNumber = 0,
      words = text.text().split(/\s+/).reverse();
    words2 = text.text().split(/\s+/).reverse();
    console.log("text", text);

    while ((word = words.pop())) {
      line.push(word);
      current_line = line.join(" ");
      console.log("current_line", current_line.length);
      if (current_line.length > width) {
        line.pop();
        current_line = line.join(" ");
        line = [word];
        lineNumbers += 1;
      }
    }
    console.log("lineNumbers", lineNumbers);
    append_line = [];
    (lineHeight = 1), // ems
      (x = text.attr("x")),
      (y = text.attr("y")),
      (dy = 1), //parseFloat(text.attr("dy")),
      (tspan = text
        .text(null)
        .append("tspan")
        .attr("x", x)
        .attr("y", y)
        .attr("dy", dy + "em"));
    while ((word = words2.pop())) {
      line = 0;
      append_line.push(word);
      tspan.text(append_line.join(" "));
      if (tspan.node().getComputedTextLength() > width) {
        append_line.pop();
        line = 1;
        tspan.text(append_line.join(" "));
        append_line = [word];
        if (line == 0) {
          tspan = text
            .append("tspan")
            .attr("x", x)
            .attr("y", y)
            .attr("dy", ++lineNumber * lineHeight + dy + "em")
            .attr("dx", -9 + "em")
            .text(word);
        }
        if (line == 1) {
          tspan = text
            .append("tspan")
            .attr("x", x)
            .attr("y", y)
            .attr("dy", ++lineNumber * lineHeight + dy + "em")
            .attr("dx", -10 + "em")
            .text(word);
        }
      }
    }
  });
}
let screenWidth = window.innerWidth;

let margin = { left: 20, top: 20, right: 20, bottom: 20 },
  width = Math.min(screenWidth, 500) - margin.left - margin.right,
  height = Math.min(screenWidth, 500) - margin.top - margin.bottom;

let svg = d3
  .select("#chart")
  .append("svg")
  .attr("width", width + margin.left + margin.right)
  .attr("height", height + margin.top + margin.bottom)
  .append("g")
  .attr("class", "wrapper")
  .attr(
    "transform",
    "translate(" +
      (width / 2 + margin.left) +
      "," +
      (height / 2 + margin.top) +
      ")"
  );

//////////////////////////////////////////////////////////////
///////////////////// Data &  Scales /////////////////////////
//////////////////////////////////////////////////////////////

//Some random data
let donutData = [
  { name: "Antelope sjvadknk saoindosa savasa slahslas sasas", value: 15 },
  { name: "Bear fhfxhxfhxgxhg hgxhx", value: 9 },
  { name: "Cheetah", value: 19 },
  { name: "Dolphin", value: 12 },
  { name: "Elephant", value: 14 },
  { name: "Flamingo", value: 21 },
  { name: "Giraffe", value: 18 },
  { name: "Other", value: 8 },
];

//Create a color scale
let colorScale = d3.scale
  .linear()
  .domain([1, 3.5, 6])
  .range(["#2c7bb6", "#ffffbf", "#d7191c"])
  .interpolate(d3.interpolateHcl);

//Create an arc function
let arc = d3.svg
  .arc()
  .innerRadius((width * 0.75) / 2)
  .outerRadius((width * 0.75) / 2 + 30);

//Turn the pie chart 90 degrees counter clockwise, so it starts at the left
let pie = d3.layout
  .pie()
  .startAngle((-90 * Math.PI) / 180)
  .endAngle((-90 * Math.PI) / 180 + 2 * Math.PI)
  .value(function (d) {
    return d.value;
  })
  .padAngle(0.01)
  .sort(null);

//////////////////////////////////////////////////////////////
//////////////////// Create Donut Chart //////////////////////
//////////////////////////////////////////////////////////////

//Create the donut slices and also the invisible arcs for the text
svg
  .selectAll(".donutArcs")
  .data(pie(donutData))
  .enter()
  .append("path")
  .attr("class", "donutArcs")
  .attr("d", arc)
  .style("fill", function (d, i) {
    if (i === 7) return "#CCCCCC";
    //Other
    else return colorScale(i);
  })
  .each(function (d, i) {
    //Search pattern for everything between the start and the first capital L
    let firstArcSection = /(^.+?)L/;

    //Grab everything up to the first Line statement
    let newArc = firstArcSection.exec(d3.select(this).attr("d"))[1];
    //Replace all the comma's so that IE can handle it
    newArc = newArc.replace(/,/g, " ");

    //If the end angle lies beyond a quarter of a circle (90 degrees or pi/2)
    //flip the end and start position
    if (d.endAngle > (90 * Math.PI) / 180) {
      let startLoc = /M(.*?)A/, //Everything between the first capital M and first capital A
        middleLoc = /A(.*?)0 0 1/, //Everything between the first capital A and 0 0 1
        endLoc = /0 0 1 (.*?)$/; //Everything between the first 0 0 1 and the end of the string (denoted by $)
      //Flip the direction of the arc by switching the start en end point (and sweep flag)
      //of those elements that are below the horizontal line
      let newStart = endLoc.exec(newArc)[1];
      let newEnd = startLoc.exec(newArc)[1];
      let middleSec = middleLoc.exec(newArc)[1];

      //Build up the new arc notation, set the sweep-flag to 0
      newArc = "M" + newStart + "A" + middleSec + "0 0 0 " + newEnd;
    } //if

    //Create a new invisible arc that the text can flow along
    svg
      .append("path")
      .attr("class", "hiddenDonutArcs")
      .attr("id", "donutArc" + i)
      .attr("d", newArc)
      .style("fill", "none");
  });

//Append the label names on the outside
svg
  .selectAll(".donutText")
  .data(pie(donutData))
  .enter()
  .append("text")
  .attr("class", "donutText")
  //Move the labels below the arcs for those slices with an end angle greater than 90 degrees
  .attr("dy", 20)
  .append("textPath")
  .attr("startOffset", "50%")
  .style("text-anchor", "middle")
  .attr("xlink:href", function (d, i) {
    return "#donutArc" + i;
  })
  .attr("font-size", function (d, i) {
    return 10;
  })
  .text(function (d) {
    return d.data.name;
  })
  .call(wrap, 100);

Any help is will be great and thanks in advance :)

Upvotes: 1

Views: 1082

Answers (1)

Michael Rovinsky
Michael Rovinsky

Reputation: 7210

Here is my attempt to solve the problem by splitting long labels and calculating text path for each part:

const data = [
  {value: 30, text: 'First', color: 'red'},
  {value: 40, text: 'Second', color: 'green'},
  {value: 60, text: 'Third', color: 'blue'},
  {value: 50, text: 'A very very very very very very very very long text', color: 'yellow'},
];


const splitLongString = (str, count) => {
  const partLength = Math.round(str.length / count);
  const words = str.split(' ');
  const parts = [];
  str.split(' ').forEach(part => {
  if (!parts.length) {
    parts.push(part);
  }
  else {
    const last = parts[parts.length - 1];
    if (parts[parts.length - 1].length >= partLength)
    parts.push(part);
  else  
    parts[parts.length - 1] += ' ' + part;
  }
});
return parts;
};

const svg = d3.select('svg');
const width = parseInt(svg.attr('width'));
const height = parseInt(svg.attr('height'));

const margin = 10;
const arcWidth = 50;
const radius = Math.min(width/2 - margin, height/2 - margin) - arcWidth / 2;
const center = {x: width / 2, y: height / 2};

let anglePos = 0;
const angleOffset = 0.025;

const sum = data.reduce((s, {value}) => s + value, 0);
data.forEach(({value, text, color}, index) => {
    const angle = Math.PI * 2 * value / sum;
  const startAngle = anglePos + angleOffset;
  anglePos += angle;
  const endAngle = anglePos - angleOffset;
  const start = {
    x: center.x + radius * Math.sin(startAngle),
    y: center.y + radius * -Math.cos(startAngle),
  };
  const end = {
    x: center.x + radius * Math.sin(endAngle),
    y: center.y + radius * -Math.cos(endAngle),
  };
  const flags = value / sum >= 0.5 ? '1 1 1' : '0 0 1';
  const pathId = `my-pie-chart-path-${index}`;
  const path = svg.append('path')
    .attr('id', pathId)
    .attr('d', `M ${start.x},${start.y} A ${radius},${radius} ${flags} ${end.x},${end.y}`)
    .style('stroke', color)
    .style('fill', 'none')
    .style('stroke-width', arcWidth);
    
  const len = path.node().getTotalLength();
  
  const textElement = svg.append('text')
    .text(text)
    .attr('dy', 0)
    .attr('text-anchor', 'middle');
  const width = textElement.node().getBBox().width;  
  let texts = [text];
  if (width > len)
    texts = splitLongString(text, Math.ceil(width / len));
        
  textElement.text(null);
  
  // const midAngle = anglePos - angle / 2;
  
  texts.forEach((t, i) => {
    const textPathId = `my-pie-chart-path-${index}-${i}`;
    const textRadius = radius - i * 12;
    const textStart = {
    x: center.x + textRadius * Math.sin(startAngle),
    y: center.y + textRadius * -Math.cos(startAngle),
  };
  const textEnd = {
    x: center.x + textRadius * Math.sin(endAngle),
    y: center.y + textRadius * -Math.cos(endAngle),
  };

  const path = svg.append('path')
    .attr('id', textPathId)
    .attr('d', `M ${textStart.x},${textStart.y} A ${textRadius},${textRadius} ${flags} ${textEnd.x},${textEnd.y}`)
    .style('stroke', 'none')
    .style('fill', 'none');
  
  textElement.append('textPath')
    .text(t)
    .attr('startOffset', (endAngle - startAngle) * textRadius / 2)
    .attr('href', `#${textPathId}`)
  });

});
text {
  font-family: Calibri;
  font-size: 12px;
  font-weight: bold;
  fill: black;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>

<svg width="300" height="300"></svg>

Upvotes: 2

Related Questions