Search code examples
d3.jssvggauge

D3.js - Add label along the arc


I am trying to implement this gauge to show target and actual values.

Here the position of the target value '45%' is given by a fixed number, so that it always stays at the top of the gauge as shown in below image:

enter image description here

How do I make this label stick to the beginning of second arc dynamically, similar to this:

enter image description here

Here is a snippet of current code I am using with hardcoded translate values:

  var barWidth, chart, chartInset, degToRad, repaintGauge,
    height, margin, numSections, padRad, percToDeg, percToRad,
    percent, radius, sectionIndx, svg, totalPercent, width;



  percent = percentValue;

  numSections = 1;
  sectionPerc = 1 / numSections / 2;
  padRad = 0.025;
  chartInset = 10;

  // Orientation of gauge:
  totalPercent = .75;

  el = d3.select('#HSFO');

  margin = {
    top: 12,
    right: 12,
    bottom: 0,
    left: 12
  };

  width = el[0][0].offsetWidth - margin.left - margin.right;
  height = width;
  radius = Math.min(width, height) / 2;
  barWidth = 40 * width / 300;



  //Utility methods 

  percToDeg = function(perc) {
    return perc * 360;
  };

  percToRad = function(perc) {
    return degToRad(percToDeg(perc));
  };

  degToRad = function(deg) {
    return deg * Math.PI / 180;
  };

  // Create SVG element
  svg = el.append('svg').attr('width', width + margin.left + margin.right).attr('height', height + margin.top + margin.bottom);

  // Add layer for the panel
  chart = svg.append('g').attr('transform', "translate(" + ((width + margin.left) / 2) + ", " + ((height + margin.top) / 2) + ")");


  chart.append('path').attr('class', "arc chart-first");
  chart.append('path').attr('class', "arc chart-second");
  chart.append('path').attr('class', "arc chart-third");
  formatValue = d3.format('1%');





  arc3 = d3.svg.arc().outerRadius(radius - chartInset).innerRadius(radius - chartInset - barWidth)
  arc2 = d3.svg.arc().outerRadius(radius - chartInset).innerRadius(radius - chartInset - barWidth)
  arc1 = d3.svg.arc().outerRadius(radius - chartInset).innerRadius(radius - chartInset - barWidth)

repaintGauge = function() {
      perc = 17 / 20;
      var next_start = totalPercent;
      arcStartRad = percToRad(next_start);
      arcEndRad = arcStartRad + percToRad(perc / 2);
      next_start += perc / 2;


      arc1.startAngle(arcStartRad).endAngle(arcEndRad);

      perc = 1 - perc;
      arcStartRad = percToRad(next_start);
      arcEndRad = arcStartRad + percToRad(perc / 2);
      next_start += perc / 2;

      arc2.startAngle(arcStartRad + padRad).endAngle(arcEndRad);

      chart.select(".chart-first").attr('d', arc1);
      chart.select(".chart-second").attr('d', arc2);

      svg.append("text").attr("transform", "translate("+(width + margin.left-35) +","+ (radius - chartInset - barWidth/4.5) +")" + 'rotate('+'70'+')') 
            .attr("text-anchor", "middle").style("font-size", "12").style("font-family", "Helvetica").text('17')

    } 

Solution

  • I would use a text path for the percent text and use an arc as the template so that you don't have to worry about manually transforming the text and calculating the angle. This means reorganising your elements slightly and using arc3 (currently unused) as the path for the text.

    The general format for text on a path is:

    <path id="path_for_text" d="M-150,1.8369701987210297e-14A150,150 0 0,1 18.799985034645633,-148.8172051971717L13.45243373590199,-106.4869779410873A107.33333333333334,107.33333333333334 0 0,0 -107.33333333333334,1.3144542310848258e-14Z"></path>
    <text>
        <textPath xlink:href="#path_for_text">my text here</textPath>
    </text>
    

    so the basic alterations that we'll need to do on your code are adding the new arc for the text to go along, and adding in the text path element. So, let's create an appropriate arc generator:

     // we want the text to be offset slightly from the outer edge of the arc, and the arc
     // itself can have identical inner and outer radius measurements
     var arc3 = d3.svg.arc()
       .outerRadius(radius - chartInset + 10)
       .innerRadius(radius - chartInset + 10)
    
     // add the text element and give it a `textPath` element as a child
     var arc_text = chart.append('text')
        .attr('id', 'scale10')
        .attr("font-size", 15)
        .style("fill", "#000000")
    
     // the textPath element will use an element with ID `text_arc` to provide its shape
     arc_text.append('textPath')
        .attr('startOffset','0%')
        .attr('xlink:href', '#text_arc' )
    
     // add the path with the ID `text_arc`
     chart.append('path').attr('class', "arc chart-third")
        .attr('id', 'text_arc')
    

    In repaintGauge, calculate the appropriate arc:

     // append the path to the chart, using the arc3 constructor to generate the arc
     // these numbers will be the same as those for arc2, although I would add a little
     // padding to both start and end angles to ensure that the text doesn't wrap if it
     // is at 0% or 100%
     arc3.startAngle(arcStartRad - 0.15).endAngle(arcEndRad + 0.15);
    
     chart.select('id', 'text_arc')
      .attr('d', arc3)
    

    and update the text:

    arc_text.select('textPath')
       .text( percent + '%')
    

    You can refactor your repaintGauge function to make it significantly simpler as some of the arc figures don't change; arc1's startAngle will always be at 1.5 Pi radians, and arc2's endAngle will always be 2.5 Pi radians. That means you only need to work out what your percent is in terms of radians, which is pretty simple: if 0% is 1.5 Pi and 100% is 2.5 Pi, and you want to represent perc percent, it will be p / 100 * Math.PI + 1.5 * Math.PI.

    repaintGauge = function(perc) {
      var arcOffset = Math.PI * 1.5
      var current = Math.PI * perc / 100 + arcOffset
    
      // arc1's endAngle and arc2, arc3's endAngle can be set to `current`
      arc1.startAngle(arcOffset).endAngle(current)
    
      arc2.startAngle(current + padRad).endAngle(arcOffset + Math.PI)
      arc3.startAngle(current - 0.15).endAngle(arcOffset + Math.PI + 0.15)
    
      chart.select(".chart-first").attr('d', arc1);
      chart.select(".chart-second").attr('d', arc2);
      chart.select(".chart-third").attr('d', arc3);
      arc_text.select('textPath').text(perc + '%');
    };
    

    Here's a demo showing the text at different positions and with different values:

    var name = "Value";
    
    var value = 17;
    
    
    var gaugeMaxValue = 100;
    
    // data to calculate 
    var percentValue = value / gaugeMaxValue;
    
    ////////////////////////
    
    var needleClient;
    
    
    
    (function() {
    
      var barWidth, chart, chartInset, degToRad, repaintGauge,
        height, margin, numSections, padRad, percToDeg, percToRad,
        percent, radius, sectionIndx, svg, totalPercent, width;
    
      percent = percentValue;
    
      numSections = 1;
      sectionPerc = 1 / numSections / 2;
      padRad = 0.025;
      chartInset = 10;
      
      var percStart = 0;
      var arcOffset = Math.PI * 1.5
    
      // Orientation of gauge:
      totalPercent = .75;
    
      el = d3.select('.chart-gauge');
    
      margin = {
        top: 40,
        right: 20,
        bottom: 30,
        left: 60
      };
    
      width = el[0][0].offsetWidth - margin.left - margin.right;
      height = width;
      radius = Math.min(width, height) / 2;
      barWidth = 40 * width / 300;
    
    
    
      //Utility methods 
    
      percToDeg = function(perc) {
        return perc * 360;
      };
    
      percToRad = function(perc) {
        return degToRad(percToDeg(perc));
      };
    
      degToRad = function(deg) {
        return deg * Math.PI / 180;
      };
    
      // Create SVG element
      svg = el.append('svg').attr('width', width + margin.left + margin.right).attr('height', height + margin.top + margin.bottom);
    
      // Add layer for the panel
      chart = svg.append('g').attr('transform', "translate(" + ((width + margin.left) / 2) + ", " + ((height + margin.top) / 2) + ")");
      
      formatValue = d3.format('1%');
    
      var arc3 = d3.svg.arc().outerRadius(radius - chartInset + 10).innerRadius(radius - chartInset + 10),
      arc2 = d3.svg.arc().outerRadius(radius - chartInset).innerRadius(radius - chartInset - barWidth),
      arc1 = d3.svg.arc().outerRadius(radius - chartInset).innerRadius(radius - chartInset - barWidth)
    
      // bind angle data directly to the chart elements
      chart.append('path').attr('class', "arc chart-first")
        .datum({ startAngle: arcOffset, endAngle: arcOffset })
        .attr('d', arc1)
      chart.append('path').attr('class', "arc chart-second")
        .datum({ startAngle: arcOffset, endAngle: arcOffset + padRad + Math.PI })
        .attr('d', arc2)
      chart.append('path').attr('class', "arc chart-third")
        .attr('id', 'text_arc')
        .datum({ startAngle: arcOffset - 0.15, endAngle: arcOffset + Math.PI + 0.15 })
        .attr('d', arc3)
    
      var arc_text = chart.append('text')
        .attr('id', 'scale10')
        .attr("font-size", 15)
        .style("fill", "#000000")
        .attr('text-anchor', 'start')
        
      arc_text.append('textPath')
        .attr('startOffset','0%')
        .attr('xlink:href', '#text_arc' )
    
      var dataset = [{
        metric: name,
        value: value
      }]
    
      var texts = svg.selectAll("text")
        .data(dataset)
        .enter();
    
      texts.append("text")
        .text(function() {
          return dataset[0].metric;
        })
        .attr('id', "Name")
        .attr('transform', "translate(" + ((width + margin.left) / 2) + ", " + ((height + margin.top) / 1.5) + ")")
        .attr("font-size", 25)
        .style("fill", "#000000");
    
      texts.append("text")
        .text(function() {
          return dataset[0].value + "%";
        })
        .attr('id', "Value")
        .attr('transform', "translate(" + ((width + margin.left) / 1.4) + ", " + ((height + margin.top) / 1.5) + ")")
        .attr("font-size", 25)
        .style("fill", "#000000");
    
      texts.append("text")
        .text(function() {
          return 0 + "%";
        })
        .attr('id', 'scale0')
        .attr('transform', "translate(" + ((width + margin.left) / 100) + ", " + ((height + margin.top) / 2) + ")")
        .attr("font-size", 15)
        .style("fill", "#000000");
    
      texts.append("text")
        .text(function() {
          return gaugeMaxValue + "%";
        })
        .attr('id', 'scale20')
        .attr('transform', "translate(" + ((width + margin.left) / 1.08) + ", " + ((height + margin.top) / 2) + ")")
        .attr("font-size", 15)
        .style("fill", "#000000");
    
      repaintGauge = function(perc) {
          var current = Math.PI * perc / 100 + arcOffset
          var t = d3.transition().duration(500)
          
          chart.select(".chart-first")
            .transition(t)
            .attrTween('d', arcEndTween(current, arc1));
    
          chart.select(".chart-second")
            .transition(t)
            .attrTween('d', arcStartTween(current, arc2));
    
          chart.select(".chart-third")
            .transition(t)
            .attrTween('d', arcStartTween(current, arc3) );
    
          arc_text.select('textPath')
            .text( perc.toFixed(1) + '%')
    
    
      }
    
      function arcStartTween(newAngle, arc) {
        return function(d) {
          var interpolate = d3.interpolate(d.startAngle, newAngle);
          return function(t) {
            d.startAngle = interpolate(t);
            return arc(d);
          };
        };
      }
      function arcEndTween(newAngle, arc) {
        return function(d) {
          var interpolate = d3.interpolate(d.endAngle, newAngle);
          return function(t) {
            d.endAngle = interpolate(t);
            return arc(d);
          };
        };
      }
    
        /////////
    
    
      var Needle = (function() {
    
        //Helper function that returns the `d` value for moving the needle
        var recalcPointerPos = function(perc) {
          var centerX, centerY, leftX, leftY, rightX, rightY, thetaRad, topX, topY;
          thetaRad = percToRad(perc / 2);
          centerX = 0;
          centerY = 0;
          topX = centerX - this.len * Math.cos(thetaRad);
          topY = centerY - this.len * Math.sin(thetaRad);
          leftX = centerX - this.radius * Math.cos(thetaRad - Math.PI / 2);
          leftY = centerY - this.radius * Math.sin(thetaRad - Math.PI / 2);
          rightX = centerX - this.radius * Math.cos(thetaRad + Math.PI / 2);
          rightY = centerY - this.radius * Math.sin(thetaRad + Math.PI / 2);
          return "M " + leftX + " " + leftY + " L " + topX + " " + topY + " L " + rightX + " " + rightY;
        };
    
        function Needle(el) {
          this.el = el;
          this.len = width / 2.5;
          this.radius = this.len / 8;
        }
    
        Needle.prototype.render = function() {
          this.el.append('circle').attr('class', 'needle-center').attr('cx', 0).attr('cy', 0).attr('r', this.radius);
    
          return this.el.append('path').attr('class', 'needle').attr('id', 'client-needle').attr('d', recalcPointerPos.call(this, 0));
    
    
        };
    
        Needle.prototype.moveTo = function(perc) {
          var self,
            oldValue = this.perc || 0;
    
          this.perc = perc;
          self = this;
    
          // Reset pointer position
          this.el.transition().delay(100).ease('quad').duration(200).select('.needle').tween('reset-progress', function() {
            return function(percentOfPercent) {
              var progress = (1 - percentOfPercent) * oldValue;
              return d3.select(this).attr('d', recalcPointerPos.call(self, progress));
            };
          });
    
          this.el.transition().delay(300).ease('bounce').duration(1500).select('.needle').tween('progress', function() {
            return function(percentOfPercent) {
              var progress = percentOfPercent * perc;
              return d3.select(this).attr('d', recalcPointerPos.call(self, progress));
            };
          });
    
        };
    
    
        return Needle;
    
      })();
    
      setInterval(function() {
        repaintGauge( Math.floor(Math.random() * 100) )
      }, 1500);
    
      needle = new Needle(chart);
      needle.render();
      needle.moveTo(percent);
      
    
    
    })();
      .chart-gauge
                {
                  width: 400px;
                  margin: 100px auto  
                 } 
                .chart-first
                {
                    fill: #66AB8C;
                }
                .chart-second
                {
                    fill: #ff533d;
                }
      
    
                .needle, .needle-center
                {
                    fill: #000000;
                }
                .text {
                    color: "#112864";
                    font-size: 16px;
                }
    
    
                svg {
                  font: 10px sans-serif;
                }
    <!DOCTYPE html>
    <html>
    
      <head>
        <link rel="stylesheet" href="style.css">
        
      </head>
    
      <body>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.0.0/d3.min.js"></script>
    <div class="chart-gauge"></div>
      </body>
    
    </html>