d3.gauge = function() { var config = { radius: 50, x: 0, y: 0, value: 100, valueStart: 0, valueMinimum: 0, // The gauge minimum value. valueMaximum: 100, // The gauge maximum value. circleThickness: 0.05, // The outer circle thickness as a percentage of it's radius. circleFillGap: 0.05, // The size of the gap between the outer circle and wave circle as a percentage of the outer circles radius. circleColor: "#178BCA", // The color of the outer circle. waveHeight: 0.05, // The wave height as a percentage of the radius of the wave circle. waveHeightScaling: true, // Controls wave size scaling at low and high fill percentages. When true, wave height reaches it's maximum at 50% fill, and minimum at 0% and 100% fill. This helps to prevent the wave from making the wave circle from appear totally full or empty when near it's minimum or maximum fill. waveCount: 1, // The number of full waves per width of the wave circle. waveRise: true, // Control if the wave should rise from 0 to it's full height, or start at it's full height. waveRiseTime: 1000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height. waveRiseDelay: 0, waveAnimate: true, // Controls if the wave scrolls or is static. waveAnimateTime: 18000, // The amount of time in milliseconds for a full wave to enter the wave circle. waveOffset: 0, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave. waveColor: "#178BCA", // The color of the fill wave. waveTextColor: "#A4DBf8", // The color of the value text when the wave overlaps it. textVerticalPosition: .5, // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top. textSize: 1.2, // The relative height of the text to display in the wave circle. 1 = 50% textColor: "#045681", // The color of the value text when the wave does not overlap it. textSuffix: '' }; var myGauge = function(selection) { selection.each(function(d, i) { function valueOf(field) { var value = config[field]; return (typeof(value) === 'function' ? value(d, i) : value); } function updateWave(wave, value, circleFillRadius, circleFillMargin, update) { var maxValue = valueOf('valueMaximum'); var minValue = valueOf('valueMinimum'); var startValue = valueOf('valueStart'); var valuePercentage = Math.max(minValue, Math.min(maxValue, value)) / maxValue; var startValuePercentage = Math.max(minValue, Math.min(maxValue, startValue)) / maxValue; var waveCount = valueOf('waveCount'); var waveHeight = valueOf('waveHeight'); var waveHeightScale = valueOf('waveHeightScaling') ? d3.scale.linear() .range([0, waveHeight, 0]) .domain([0, 50, 100]) : d3.scale.linear() .range([waveHeight, waveHeight]) .domain([0, 100]); waveHeight = circleFillRadius * waveHeightScale(valuePercentage * 100); var waveOffset = valueOf('waveOffset'); var waveRise = valueOf('waveRise'); var waveClipCount = waveCount + 1; var waveLength = circleFillRadius * 2 / waveCount; var waveClipWidth = waveLength * waveClipCount; var waveScaleX = d3.scale.linear().range([0,waveClipWidth]).domain([0,1]); var waveScaleY = d3.scale.linear().range([0,waveHeight]).domain([0,1]); var waveArea = d3.svg.area() .x(function(d) { return waveScaleX(d.x); } ) .y0(function(d) { return waveScaleY(Math.sin(Math.PI * 2 * waveOffset * -1 + Math.PI * 2 * (1 - waveCount) + d.y * 2 * Math.PI));} ) .y1(function(d) { return (circleFillRadius * 2 + waveHeight); } ); var data = []; for(var j = 0; j <= 40; j++){ data.push({ x: j / 40, y: (j * waveClipCount / 40) }); } var waveLine = wave.select('path') .datum(data) .attr('d', waveArea) .attr('T', 0); var waveMove = waveClipWidth - circleFillRadius * 2; var waveGroupXPosition = circleFillMargin - waveMove; var waveRiseScale = d3.scale.linear() // The clipping area size is the height of the fill circle + the wave height, so we position the clip wave // such that the it will overlap the fill circle at all when at 0%, and will totally cover the fill // circle at 100%. .range([(circleFillMargin + circleFillRadius * 2 + waveHeight),(circleFillMargin - waveHeight)]) .domain([0,1]); if (waveRise) { var waveRiseTime = valueOf('waveRiseTime'); var waveRiseDelay = valueOf('waveRiseDelay'); if (!update) { wave.attr('transform','translate(' + waveGroupXPosition + ',' + waveRiseScale(startValuePercentage) + ')'); } wave .transition() .delay(waveRiseDelay) .duration(waveRiseTime) .attr('transform','translate(' + waveGroupXPosition + ',' + waveRiseScale(valuePercentage) + ')') .each("start", function(){ waveLine.attr('transform','translate(1,0)'); }); // This transform is necessary to get the clip wave positioned correctly when waveRise=true and waveAnimate=false. The wave will not position correctly without this, but it's not clear why this is actually necessary. } else { wave.attr('transform','translate(' + waveGroupXPosition + ',' + waveRiseScale(valuePercentage) + ')'); } var waveAnimation = valueOf('waveAnimate'); if (waveAnimation) { animateWave(waveLine, waveMove); } } function animateWave(wave, waveMove) { var waveAnimationTime = valueOf('waveAnimateTime'); var waveAnimateScale = d3.scale.linear() .range([0, waveMove]) // Push the clip area one full wave then snap back. .domain([0,1]); wave.attr('transform','translate(' + waveAnimateScale(wave.attr('T')) + ',0)'); wave.transition() .duration(waveAnimationTime * (1 - wave.attr('T'))) .ease('linear') .attr('transform','translate(' + waveAnimateScale(1) + ',0)') .attr('T', 1) .each('end', function(){ wave.attr('T', 0); animateWave(wave, waveMove); }); } function getGaugeOuterCircleArc(radius, circleThickness) { // Scales for drawing the outer circle. var gaugeCircleX = d3.scale.linear().range([0,2*Math.PI]).domain([0,1]); var gaugeCircleY = d3.scale.linear().range([0,radius]).domain([0,radius]); return d3.svg.arc() .startAngle(gaugeCircleX(0)) .endAngle(gaugeCircleX(1)) .outerRadius(gaugeCircleY(radius)) .innerRadius(gaugeCircleY(radius - circleThickness)); } function textRound(textValue) { textValue = textValue + ""; var precision = 0; if (textValue.indexOf('.') > -1) { precision = textValue.split('.').length; } var round = (precision.length > 1) ? Math.pow(10, precision) : 1; var floatValue = parseFloat(textValue); return Math.round(floatValue * round) / round; } function getUpdateTextFunction(endValue, update) { var suffix = valueOf('textSuffix'); var textPixels = valueOf('textSize') * radius / 2; var textVerticalPosition = valueOf('textVerticalPosition'); var textRiseScaleY = d3.scale.linear() .range([circleFillMargin + circleFillRadius * 2,(circleFillMargin + textPixels * 0.7)]) .domain([0,1]); // TODO move statics to skeleton return function(parent, color) { var value = !update ? valueOf('valueStart') : parent.select('.gauge-text').text().replace('€', ''); var textTween, waveRiseTime, waveRiseDelay; if(endValue != value) { waveRiseTime = valueOf('waveRiseTime'); waveRiseDelay = valueOf('waveRiseDelay'); textTween = function () { var i = d3.interpolate(value, endValue); return function (t) { this.textContent = textRound(i(t)) + suffix; } }; } var text = parent.select('.gauge-text') .text(textRound(parseFloat(value).toFixed(2)) + suffix) .attr('text-anchor', 'middle') .attr('font-size', textPixels + 'px') .attr('fill', color) .attr('transform', 'translate(' + radius + ',' + textRiseScaleY(textVerticalPosition) + ')'); if (textTween && waveRiseTime) { text.transition() .delay(waveRiseDelay) .duration(waveRiseTime) .tween('text', textTween); } }; } var update = true; // select svg element, if it exists var gauge = d3.select(this); var wave = gauge.select('defs').select('clippath'); var innerGauge = gauge.select('.gauge-inner'); var outerGauge = gauge.select('.gauge-outer'); if (gauge.selectAll('defs')[0].length == 0) { update = false; // create skeletal chart var gEnter = d3.select(this).selectAll('svg').data([d]).enter(); // outerGauge containing outer circle and uncovered text outerGauge = gEnter.append('g').attr('class', 'gauge-outer'); outerGauge.append('circle').attr('class', 'gauge-circle'); outerGauge.append('text').attr('class', 'gauge-text'); wave = gEnter.append('defs').append('clipPath').attr('id', 'wave'); wave.append('path'); // innerGauge containing inner circle and covered text innerGauge = gEnter.append('g').attr('class', 'gauge-inner') .attr('clip-path', 'url(#' + wave.attr('id') + ')'); innerGauge.append('circle').attr('class', 'gauge-circle'); innerGauge.append('text').attr('class', 'gauge-text'); } var value = valueOf('value'); var radius = valueOf('radius'); var circleThickness = valueOf('circleThickness') * radius; var circleFillGap = valueOf('circleFillGap') * radius; var circleFillMargin = circleThickness + circleFillGap; var circleFillRadius = radius - circleFillMargin; // position gauge var x = valueOf('x') - radius; var y = valueOf('y') - radius; gauge.attr('transform', 'translate(' + x + ',' + y + ')'); // update outer circle outerGauge.select('.gauge-circle') .attr('stroke-width', circleThickness) .style('stroke', valueOf('circleColor')) .style('fill', 'white') .attr('r', circleFillRadius + circleFillGap + circleThickness/ 2) .attr('cx', radius) .attr('cy', radius); // update inner circle innerGauge.select('.gauge-circle') .attr('cx', radius) .attr('cy', radius) .attr('r', circleFillRadius) .style('fill', valueOf('waveColor')); // update wave updateWave(wave, value, circleFillRadius, circleFillMargin, update); var updateText = getUpdateTextFunction(value, update); // update text updateText(outerGauge, valueOf('textColor')); updateText(innerGauge, valueOf('waveTextColor')); }); }; for (var i in config) { if (config.hasOwnProperty(i)) { myGauge[i] = (function(field) { return function(inValue) { return getterSetter(field)(inValue); } })(i); } } function getterSetter(field) { return function(value) { if (!arguments.length) return config[field]; config[field] = value; return myGauge; } } return myGauge; };