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.
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.
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);
}
<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>
...
<!-- Gauge component -->
<gauge center-x="300"
center-y="300"
radius="200"
max-value="180"
gradient-interval="10"
current-value="45"
gradients-offset="10">
</gauge>
...
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
.