'use strict'; // heavily inspired by: http://bl.ocks.org/tomerd/1499279 // var xtend = require('xtend'); function xtend() { var target = {} for (var i = 0; i < arguments.length; i++) { var source = arguments[i] for (var key in source) { if (source.hasOwnProperty(key)) { target[key] = source[key] } } } return target } // var defaultOpts = require('./defaults/simple'); var defaultOpts = { size : 125 , min : 0 , max : 100 , transitionDuration : 500 , clazz : 'simple' , label : 'label.text' , minorTicks : 4 , majorTicks : 5 , needleWidthRatio : 0.6 , needleContainerRadiusRatio : 0.7 , zones: [ { clazz: 'yellow-zone', from: 0.73, to: 0.9 } , { clazz: 'red-zone', from: 0.9, to: 1.0 } ] }; // var d3 = require('d3'); // var go = module.exports = Gauge; //var go = Gauge; var proto = Gauge.prototype; /** * Creates a gauge appended to the given DOM element. * * Example: * * ```js * var simpleOpts = { * size : 100 * , min : 0 * , max : 50 * , transitionDuration : 500 * * , label : 'label.text' * , minorTicks : 4 * , majorTicks : 5 * , needleWidthRatio : 0.6 * , needleContainerRadiusRatio : 0.7 * * , zones: [ * { clazz: 'yellow-zone', from: 0.73, to: 0.9 } * , { clazz: 'red-zone', from: 0.9, to: 1.0 } * ] * } * var gauge = Gauge(document.getElementById('simple-gauge'), simpleOpts); * gauge.write(39); * ``` * * @name Gauge * @function * @param el {DOMElement} to which the gauge is appended * @param opts {Object} gauge configuration with the following properties all of which have sensible defaults: * - label {String} that appears in the top portion of the gauge * - clazz {String} class to apply to the gauge element in order to support custom styling * - size {Number} the over all size (radius) of the gauge * - preserveAspectRatio {String} default 'xMinYMin meet', see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio * - min {Number} the minimum value that the gauge measures * - max {Number} the maximum value that the gauge measures * - majorTicks {Number} the number of major ticks to draw * - minorTicks {Number} the number of minor ticks to draw in between two major ticks * - needleWidthRatio {Number} tweaks the gauge's needle width * - needleConatinerRadiusRatio {Number} tweaks the gauge's needle container circumference * - transitionDuration {Number} the time in ms it takes for the needle to move to a new position * - zones {Array[Object]} each with the following properties * - clazz {String} class to apply to the zone element in order to style its fill * - from {Number} between 0 and 1 to determine zone's start * - to {Number} between 0 and 1 to determine zone's end * @return {Object} the gauge with a `write` method */ function Gauge() { } Gauge.prototype.draw = function (el, opts) { if (!(this instanceof Gauge)) return new Gauge(el, opts); this._el = el; this._opts = xtend(defaultOpts, opts); this._size = this._opts.size; this._radius = this._size * 0.9 / 2; this._cx = this._size / 2; this._cy = this._cx; this._preserveAspectRatio = this._opts.preserveAspectRatio; this._min = this._opts.min; this._max = this._opts.max; this._range = this._max - this._min; this._majorTicks = this._opts.majorTicks; this._minorTicks = this._opts.minorTicks; this._needleWidthRatio = this._opts.needleWidthRatio; this._needleContainerRadiusRatio = this._opts.needleContainerRadiusRatio; this._transitionDuration = this._opts.transitionDuration; this._label = this._opts.label; this._zones = this._opts.zones || []; this._clazz = this._opts.clazz; this._initZones(); this._render(); } /** * Writes a value to the gauge and updates its state, i.e. needle position, accordingly. * @name write * @function * @param value {Number} the new gauge value, should be in between min and max * @param transitionDuration {Number} (optional) transition duration, if not supplied the configured duration is used */ proto.write = function(value, transitionDuration) { var self = this; function transition () { var needleValue = value , overflow = value > self._max , underflow = value < self._min; if (overflow) needleValue = self._max + 0.02 * self._range; else if (underflow) needleValue = self._min - 0.02 * self._range; var targetRotation = self._toDegrees(needleValue) - 90 , currentRotation = self._currentRotation || targetRotation; self._currentRotation = targetRotation; return function (step) { var rotation = currentRotation + (targetRotation - currentRotation) * step; return 'translate(' + self._cx + ', ' + self._cy + ') rotate(' + rotation + ')'; } } var needleContainer = this._gauge.select('.needle-container'); needleContainer .selectAll('text') .attr('class', 'current-value') .text(Math.round(value)); var needle = needleContainer.selectAll('path'); needle .transition() .duration(transitionDuration ? transitionDuration : this._transitionDuration) .attrTween('transform', transition); } proto._initZones = function () { var self = this; function percentToVal (percent) { return self._min + self._range * percent; } function initZone (zone) { return { clazz: zone.clazz , from: percentToVal(zone.from) , to: percentToVal(zone.to) } } // create new zones to not mess with the passed in args this._zones = this._zones.map(initZone); } proto._render = function () { this._initGauge(); this._drawOuterCircle(); this._drawInnerCircle(); this._drawLabel(); this._drawZones(); this._drawTicks(); this._drawNeedle(); this.write(this._min, 0); } proto._initGauge = function () { this._gauge = d3.select(this._el) .append('svg:svg') .attr('class' , 'd3-gauge' + (this._clazz ? ' ' + this._clazz : '')) .attr('width' , this._size) .attr('height' , this._size) .attr('viewBox', '0 0 ' + this._size + ' ' + this._size) .attr('preserveAspectRatio', this._preserveAspectRatio || 'xMinYMin meet') } proto._drawOuterCircle = function () { this._gauge .append('svg:circle') .attr('class' , 'outer-circle') .attr('cx' , this._cx) .attr('cy' , this._cy) .attr('r' , this._radius) } proto._drawInnerCircle = function () { this._gauge .append('svg:circle') .attr('class' , 'inner-circle') .attr('cx' , this._cx) .attr('cy' , this._cy) .attr('r' , 0.9 * this._radius) } proto._drawLabel = function () { if (typeof this._label === undefined) return; var fontSize = Math.round(this._size / 9); var halfFontSize = fontSize / 2; this._gauge .append('svg:text') .attr('class', 'label') .attr('x', this._cx) .attr('y', this._cy / 2 + halfFontSize) .attr('dy', halfFontSize) .attr('text-anchor', 'middle') .text(this._label) } proto._drawTicks = function () { var majorDelta = this._range / (this._majorTicks - 1) , minorDelta = majorDelta / this._minorTicks , point ; for (var major = this._min; major <= this._max; major += majorDelta) { var minorMax = Math.min(major + majorDelta, this._max); for (var minor = major + minorDelta; minor < minorMax; minor += minorDelta) { this._drawLine(this._toPoint(minor, 0.75), this._toPoint(minor, 0.85), 'minor-tick'); } this._drawLine(this._toPoint(major, 0.7), this._toPoint(major, 0.85), 'major-tick'); if (major === this._min || major === this._max) { point = this._toPoint(major, 0.63); this._gauge .append('svg:text') .attr('class', 'major-tick-label') .attr('x', point.x) .attr('y', point.y) .attr('text-anchor', major === this._min ? 'start' : 'end') .text(major) } } } proto._drawLine = function (p1, p2, clazz) { this._gauge .append('svg:line') .attr('class' , clazz) .attr('x1' , p1.x) .attr('y1' , p1.y) .attr('x2' , p2.x) .attr('y2' , p2.y) } proto._drawZones = function () { var self = this; function drawZone (zone) { self._drawBand(zone.from, zone.to, zone.clazz); } this._zones.forEach(drawZone); } proto._drawBand = function (start, end, clazz) { var self = this; function transform () { return 'translate(' + self._cx + ', ' + self._cy +') rotate(270)'; } var arc = d3.svg.arc() .startAngle(this._toRadians(start)) .endAngle(this._toRadians(end)) .innerRadius(0.65 * this._radius) .outerRadius(0.85 * this._radius) ; this._gauge .append('svg:path') .attr('class', clazz) .attr('d', arc) .attr('transform', transform) } proto._drawNeedle = function () { var needleContainer = this._gauge .append('svg:g') .attr('class', 'needle-container'); var midValue = (this._min + this._max) / 2; var needlePath = this._buildNeedlePath(midValue); var needleLine = d3.svg.line() .x(function(d) { return d.x }) .y(function(d) { return d.y }) .interpolate('basis'); needleContainer .selectAll('path') .data([ needlePath ]) .enter() .append('svg:path') .attr('class' , 'needle') .attr('d' , needleLine) needleContainer .append('svg:circle') .attr('cx' , this._cx) .attr('cy' , this._cy) .attr('r' , this._radius * this._needleContainerRadiusRatio / 10) // TODO: not styling font-size since we need to calculate other values from it // how do I extract style value? var fontSize = Math.round(this._size / 10); needleContainer .selectAll('text') .data([ midValue ]) .enter() .append('svg:text') .attr('x' , this._cx) .attr('y' , this._size - this._cy / 4 - fontSize) .attr('dy' , fontSize / 2) .attr('text-anchor' , 'middle') } proto._buildNeedlePath = function (value) { var self = this; function valueToPoint(value, factor) { var point = self._toPoint(value, factor); point.x -= self._cx; point.y -= self._cy; return point; } var delta = this._range * this._needleWidthRatio / 10 , tailValue = value - (this._range * (1/ (270/360)) / 2) var head = valueToPoint(value, 0.85) , head1 = valueToPoint(value - delta, 0.12) , head2 = valueToPoint(value + delta, 0.12) var tail = valueToPoint(tailValue, 0.28) , tail1 = valueToPoint(tailValue - delta, 0.12) , tail2 = valueToPoint(tailValue + delta, 0.12) return [head, head1, tail2, tail, tail1, head2, head]; } proto._toDegrees = function (value) { // Note: tried to factor out 'this._range * 270' but that breaks things, most likely due to rounding behavior return value / this._range * 270 - (this._min / this._range * 270 + 45); } proto._toRadians = function (value) { return this._toDegrees(value) * Math.PI / 180; } proto._toPoint = function (value, factor) { var len = this._radius * factor; var inRadians = this._toRadians(value); return { x: this._cx - len * Math.cos(inRadians), y: this._cy - len * Math.sin(inRadians) }; }