Search code examples
angularjssvgprogress-barprogress

Convert SVG semi-circle arc to full-circle arc using Path


I'm trying to convert this semi-circle arc into a full circle, or at least an almost full circle with small disconnected notch at the bottom.

I've tried changing the 180 to 360.

I have also tried changing the 0, 0, 1 ,parameters to change the rotation of the arc, but that seems to have a rotation / flipping effect, rather than elongating the arc.

Gauge widget

This code is in AngularJS, but should not detract from math involved to solve this.

This example is modified from from Pluralsight's Scalable Dynamic Graphs & Charts Using AngularJS and SVG Course.

Plunker Link

gauge.component.js

angular.module('app.gauge', []);
angular.module('app.gauge')
  .component('gauge', {
    require: {
      parent: '^appMain'
    },
    bindings: {
      centerX: '=',
      centerY: '=',
      radius: '<',
      maxValue: '<',
      gradientInterval: '<',
      currentValue: '<',
      gradientsOffset: '<'
    },
    controller: GaugeCtrl,
    controllerAs: 'gauge',
    templateUrl: 'gauge.html',
    bindToController: true
  });

function GaugeCtrl(d3, $scope) {
  var gauge = this;
  // preset defaults
  gauge.specs = {
    centerX: 0, // pass in 300
    centerY: 0, // pass in 300
    radius: 0, // pass in 200
    maxValue: 0, // pass in 180
    gradientInterval: 0,
    currentValue: 0, // 45 passed in
    gradients: [],
    gradientsOffset: 0, // 10 
    maxValueCoordinates: null
  };

  // pass in values from component passed-in values
  function initPassedInValues() {
    // grab all props from controller
    var keys = Object.keys(gauge);
    // if ctrl key is in gauge.specs object, copy over to specs
    keys.forEach(function(key,idx){
      if (gauge.specs.hasOwnProperty(key)) {
        gauge.specs[key] = gauge[key];
      }
    });   
  }

  // passedin padding
  gauge.$onInit = function() {
    initPassedInValues(); // process passed-in values from component
    initGauge();
    initGradients();
  }

  gauge.$postLink = function() {
  
  }

  // function defs
  var getCoordinatesForAngle = function(centerX, centerY, radius, angleInDegrees) {
    var angleInRadians = ((angleInDegrees - 180.0) * Math.PI / 180.0);
    return {
      x: parseInt(centerX + (radius * Math.cos(angleInRadians))),
      y: parseInt(centerY + (radius * Math.sin(angleInRadians)))
    };
  };

  // calc background and value arc
    // radius as param - diff for circle vs. text path 
  var getArcPathForAngle = function(startingAngle, endingAngle, radius){
    var startingPt = getCoordinatesForAngle(
        gauge.specs.centerX,
        gauge.specs.centerY,
        radius,
        startingAngle);

    var endingPt = getCoordinatesForAngle(
        gauge.specs.centerX,
        gauge.specs.centerY,
        radius,
        endingAngle);

    return ["M", startingPt.x, startingPt.y, "A", radius, radius, 0, 0, 1, endingPt.x, endingPt.y].join(' ');
  };

  // textPath ticks
  function initGradients() {
    // use < instead of <= so doesn't show last value, taken care of with fixLastGradientTextValue fn
    for (var value = 0, offset = 0; value < gauge.specs.maxValue; value += gauge.specs.gradientInterval, offset += 100/18) {
      gauge.specs.gradients.push({value: value, offset: offset});
    }
  }

  function initGauge() {
    // draw background
    gauge.background = getArcPathForAngle(0, gauge.specs.maxValue, gauge.specs.radius);
    // draw gauge value
    gauge.value = getArcPathForAngle(0, gauge.specs.currentValue, gauge.specs.radius);
    // draw gradient tick values
    gauge.gradients = getArcPathForAngle(0, gauge.specs.maxValue, gauge.specs.radius + gauge.specs.gradientsOffset);
    // fix last text value and rotate
    gauge.specs.maxValueCoordinates = getCoordinatesForAngle(
      gauge.specs.centerX,
      gauge.specs.centerY,
      gauge.specs.radius + gauge.specs.gradientsOffset,
      gauge.specs.maxValue);
  }

  // additional watcher for currentValue
  $scope.$watch('gauge.specs.currentValue', function(oldValue, newValue) {
    initGauge();
  }, true);
}

gauge.html

<div class="svg-container gauge">
  <!-- gauge -->
  <svg class="svg-scalable" viewBox="0 0 600 400" preserveAspectRation="xMidYMid meet">

    <g>
      <!-- background -->
      <path id="gaugeBackground" ng-attr-d="{{ gauge.background }}" stroke-width="10" stroke="black" fill="none"/>
  
      <!-- gauge value -->
      <path ng-attr-d="{{ gauge.value }}" stroke-width="10" stroke="#2a9fbc" fill="none"/>
  
      <!-- invisible arc for textPath to follow, slightly larger -->
      <path id="gradients" ng-attr-d="{{ gauge.gradients }}" stroke width="0" fill="none" />
  
      <!-- gradient ticks -->
      <text ng-repeat="gradient in gauge.specs.gradients" dx="0" dy="0" text-anchor="middle" style="font: bold large arial">
        <textPath xlink:href="#gradients" startOffset="{{ gradient.offset }}%">
          {{ gradient.value }}
        </textPath>
      </text>
  
      <!-- Fix for last tick-->
      <text dx="{{ gauge.specs.maxValueCoordinates.x }}" dy="{{ gauge.specs.maxValueCoordinates.y }}" text-anchor="middle" style="font: bold large arial" transform="rotate(90, {{ gauge.specs.maxValueCoordinates.x}}, {{ gauge.specs.maxValueCoordinates.y }} )">
       {{ gauge.specs.maxValue }}
      </text>
  
      <text dx="50%" dy="50%" text-anchor="middle" 
    alignment-baseline="hanging" style="font-size: 7rem">
        {{ gauge.specs.currentValue }}
      </text> 
    </g>
  </svg>
</div>

app-main.html -- passed in defaults

...
<!-- Gauge component -->
<gauge center-x="300"
  center-y="300"
  radius="200"
  max-value="180"
  gradient-interval="10"
  current-value="45"
  gradients-offset="10"> 
</gauge>
...

Solution

  • Okay, I relented, and made the modifications for you :)

    The original gauge has the "sweep angle" and the max gauge value both hardwired to 180. It breaks if you try and change the max-value attribute.

    My version fixes that and introduces a new attribute gauge-sweep that sets the angle that the gauge covers (up to 360 degrees). You can also set the max-value independently (eg. 100).

    The main changes to the code are in the following three functions:

      var getCoordinatesForAngle = function(centerX, centerY, radius, angleInDegrees) {
        var angleInRadians = ((angleInDegrees - 90 - gauge.specs.gaugeSweep/2) * Math.PI / 180.0);
        return {
          x: parseInt(centerX + (radius * Math.cos(angleInRadians))),
          y: parseInt(centerY + (radius * Math.sin(angleInRadians)))
        };
      };
    

    This needed to be modified from having the start angle of the gauge being on the left (west). We now vary it based on the gaugeSweep value.

      // calc background and value arc
      // radius as param - diff for circle vs. text path 
      // Divided into three arcs to ensure accuracy over the largest possible range (360deg)
      var getArcPathForAngle = function(startingAngle, endingAngle, radius, maxAngle) {
        var startingPt = getCoordinatesForAngle(
            gauge.specs.centerX,
            gauge.specs.centerY,
            radius,
            startingAngle);
        var midPt1 = getCoordinatesForAngle(
            gauge.specs.centerX,
            gauge.specs.centerY,
            radius,
            (startingAngle + endingAngle)/3);
        var midPt2 = getCoordinatesForAngle(
            gauge.specs.centerX,
            gauge.specs.centerY,
            radius,
            (startingAngle + endingAngle)*2/3);
        var endingPt = getCoordinatesForAngle(
            gauge.specs.centerX,
            gauge.specs.centerY,
            radius,
            endingAngle);
    
        return ["M", startingPt.x, startingPt.y,
                "A", radius, radius, 0, 0, 1, midPt1.x, midPt1.y,
                "A", radius, radius, 0, 0, 1, midPt2.x, midPt2.y,
                "A", radius, radius, 0, 0, 1, endingPt.x, endingPt.y].join(' ');
      };
    

    Path arc (A) commands have a tendency to get a little inaccurate if they cover 180 or more degrees. To avoid that, we now use three arcs for the gauge so that we can safely cover any sweep up to 360 degrees.

      // textPath ticks
      function initGradients() {
        // use < instead of <= so doesn't show last value, taken care of with fixLastGradientTextValue fn
        var offsetStep = (gauge.specs.gradientInterval * 100) / gauge.specs.maxValue;
        for (var value = 0, offset = 0; value < gauge.specs.maxValue; value += gauge.specs.gradientInterval, offset += offsetStep) {
          gauge.specs.gradients.push({value: value, offset: offset});
        }
      }
    

    This function calculates the tick positions of the gauge. It was hardwired to expect a maxValue of 180 degrees. It needed to be fixed.

    There were also minor changes to app-main.html and gauge.html.

    My updated plunkr is here