|
| 1 | +/*! |
| 2 | + * @license Open source under BSD 2-clause (http://choosealicense.com/licenses/bsd-2-clause/) |
| 3 | + * Copyright (c) 2015, Curtis Bratton |
| 4 | + * All rights reserved. |
| 5 | + * |
| 6 | + * Liquid Fill Gauge v1.1 |
| 7 | + */ |
| 8 | +function liquidFillGaugeDefaultSettings(){ |
| 9 | + return { |
| 10 | + minValue: 0, // The gauge minimum value. |
| 11 | + maxValue: 100, // The gauge maximum value. |
| 12 | + circleThickness: 0.05, // The outer circle thickness as a percentage of it's radius. |
| 13 | + circleFillGap: 0.05, // The size of the gap between the outer circle and wave circle as a percentage of the outer circles radius. |
| 14 | + circleColor: "#178BCA", // The color of the outer circle. |
| 15 | + waveHeight: 0.05, // The wave height as a percentage of the radius of the wave circle. |
| 16 | + waveCount: 1, // The number of full waves per width of the wave circle. |
| 17 | + waveRiseTime: 1000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height. |
| 18 | + waveAnimateTime: 18000, // The amount of time in milliseconds for a full wave to enter the wave circle. |
| 19 | + waveRise: true, // Control if the wave should rise from 0 to it's full height, or start at it's full height. |
| 20 | + 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. |
| 21 | + waveAnimate: true, // Controls if the wave scrolls or is static. |
| 22 | + waveColor: "#178BCA", // The color of the fill wave. |
| 23 | + waveOffset: 0, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave. |
| 24 | + textVertPosition: .5, // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top. |
| 25 | + textSize: 1, // The relative height of the text to display in the wave circle. 1 = 50% |
| 26 | + valueCountUp: true, // If true, the displayed value counts up from 0 to it's final value upon loading. If false, the final value is displayed. |
| 27 | + displayPercent: true, // If true, a % symbol is displayed after the value. |
| 28 | + textColor: "#045681", // The color of the value text when the wave does not overlap it. |
| 29 | + waveTextColor: "#A4DBf8" // The color of the value text when the wave overlaps it. |
| 30 | + }; |
| 31 | +} |
| 32 | + |
| 33 | +function loadLiquidFillGauge(elementId, value, config) { |
| 34 | + if(config == null) config = liquidFillGaugeDefaultSettings(); |
| 35 | + |
| 36 | + var gauge = d3.select("#" + elementId); |
| 37 | + var radius = Math.min(parseInt(gauge.style("width")), parseInt(gauge.style("height")))/2; |
| 38 | + var locationX = parseInt(gauge.style("width"))/2 - radius; |
| 39 | + var locationY = parseInt(gauge.style("height"))/2 - radius; |
| 40 | + var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue; |
| 41 | + |
| 42 | + var waveHeightScale; |
| 43 | + if(config.waveHeightScaling){ |
| 44 | + waveHeightScale = d3.scale.linear() |
| 45 | + .range([0,config.waveHeight,0]) |
| 46 | + .domain([0,50,100]); |
| 47 | + } else { |
| 48 | + waveHeightScale = d3.scale.linear() |
| 49 | + .range([config.waveHeight,config.waveHeight]) |
| 50 | + .domain([0,100]); |
| 51 | + } |
| 52 | + |
| 53 | + var textPixels = (config.textSize*radius/2); |
| 54 | + var textFinalValue = parseFloat(value).toFixed(2); |
| 55 | + var textStartValue = config.valueCountUp?config.minValue:textFinalValue; |
| 56 | + var percentText = config.displayPercent?"%":""; |
| 57 | + var circleThickness = config.circleThickness * radius; |
| 58 | + var circleFillGap = config.circleFillGap * radius; |
| 59 | + var fillCircleMargin = circleThickness + circleFillGap; |
| 60 | + var fillCircleRadius = radius - fillCircleMargin; |
| 61 | + var waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100); |
| 62 | + |
| 63 | + var waveLength = fillCircleRadius*2/config.waveCount; |
| 64 | + var waveClipCount = 1+config.waveCount; |
| 65 | + var waveClipWidth = waveLength*waveClipCount; |
| 66 | + |
| 67 | + // Rounding functions so that the correct number of decimal places is always displayed as the value counts up. |
| 68 | + var textRounder = function(value){ return Math.round(value); }; |
| 69 | + if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){ |
| 70 | + textRounder = function(value){ return parseFloat(value).toFixed(1); }; |
| 71 | + } |
| 72 | + if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){ |
| 73 | + textRounder = function(value){ return parseFloat(value).toFixed(2); }; |
| 74 | + } |
| 75 | + |
| 76 | + // Data for building the clip wave area. |
| 77 | + var data = []; |
| 78 | + for(var i = 0; i <= 40*waveClipCount; i++){ |
| 79 | + data.push({x: i/(40*waveClipCount), y: (i/(40))}); |
| 80 | + } |
| 81 | + |
| 82 | + // Scales for drawing the outer circle. |
| 83 | + var gaugeCircleX = d3.scale.linear().range([0,2*Math.PI]).domain([0,1]); |
| 84 | + var gaugeCircleY = d3.scale.linear().range([0,radius]).domain([0,radius]); |
| 85 | + |
| 86 | + // Scales for controlling the size of the clipping path. |
| 87 | + var waveScaleX = d3.scale.linear().range([0,waveClipWidth]).domain([0,1]); |
| 88 | + var waveScaleY = d3.scale.linear().range([0,waveHeight]).domain([0,1]); |
| 89 | + |
| 90 | + // Scales for controlling the position of the clipping path. |
| 91 | + var waveRiseScale = d3.scale.linear() |
| 92 | + // The clipping area size is the height of the fill circle + the wave height, so we position the clip wave |
| 93 | + // such that the it will overlap the fill circle at all when at 0%, and will totally cover the fill |
| 94 | + // circle at 100%. |
| 95 | + .range([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)]) |
| 96 | + .domain([0,1]); |
| 97 | + var waveAnimateScale = d3.scale.linear() |
| 98 | + .range([0, waveClipWidth-fillCircleRadius*2]) // Push the clip area one full wave then snap back. |
| 99 | + .domain([0,1]); |
| 100 | + |
| 101 | + // Scale for controlling the position of the text within the gauge. |
| 102 | + var textRiseScaleY = d3.scale.linear() |
| 103 | + .range([fillCircleMargin+fillCircleRadius*2,(fillCircleMargin+textPixels*0.7)]) |
| 104 | + .domain([0,1]); |
| 105 | + |
| 106 | + // Center the gauge within the parent SVG. |
| 107 | + var gaugeGroup = gauge.append("g") |
| 108 | + .attr('transform','translate('+locationX+','+locationY+')'); |
| 109 | + |
| 110 | + // Draw the outer circle. |
| 111 | + var gaugeCircleArc = d3.svg.arc() |
| 112 | + .startAngle(gaugeCircleX(0)) |
| 113 | + .endAngle(gaugeCircleX(1)) |
| 114 | + .outerRadius(gaugeCircleY(radius)) |
| 115 | + .innerRadius(gaugeCircleY(radius-circleThickness)); |
| 116 | + gaugeGroup.append("path") |
| 117 | + .attr("d", gaugeCircleArc) |
| 118 | + .style("fill", config.circleColor) |
| 119 | + .attr('transform','translate('+radius+','+radius+')'); |
| 120 | + |
| 121 | + // Text where the wave does not overlap. |
| 122 | + var text1 = gaugeGroup.append("text") |
| 123 | + .text(textRounder(textStartValue) + percentText) |
| 124 | + .attr("class", "liquidFillGaugeText") |
| 125 | + .attr("text-anchor", "middle") |
| 126 | + .attr("font-size", textPixels + "px") |
| 127 | + .style("fill", config.textColor) |
| 128 | + .attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')'); |
| 129 | + |
| 130 | + // The clipping wave area. |
| 131 | + var clipArea = d3.svg.area() |
| 132 | + .x(function(d) { return waveScaleX(d.x); } ) |
| 133 | + .y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} ) |
| 134 | + .y1(function(d) { return (fillCircleRadius*2 + waveHeight); } ); |
| 135 | + var waveGroup = gaugeGroup.append("defs") |
| 136 | + .append("clipPath") |
| 137 | + .attr("id", "clipWave" + elementId); |
| 138 | + var wave = waveGroup.append("path") |
| 139 | + .datum(data) |
| 140 | + .attr("d", clipArea) |
| 141 | + .attr("T", 0); |
| 142 | + |
| 143 | + // The inner circle with the clipping wave attached. |
| 144 | + var fillCircleGroup = gaugeGroup.append("g") |
| 145 | + .attr("clip-path", "url(#clipWave" + elementId + ")"); |
| 146 | + fillCircleGroup.append("circle") |
| 147 | + .attr("cx", radius) |
| 148 | + .attr("cy", radius) |
| 149 | + .attr("r", fillCircleRadius) |
| 150 | + .style("fill", config.waveColor); |
| 151 | + |
| 152 | + // Text where the wave does overlap. |
| 153 | + var text2 = fillCircleGroup.append("text") |
| 154 | + .text(textRounder(textStartValue) + percentText) |
| 155 | + .attr("class", "liquidFillGaugeText") |
| 156 | + .attr("text-anchor", "middle") |
| 157 | + .attr("font-size", textPixels + "px") |
| 158 | + .style("fill", config.waveTextColor) |
| 159 | + .attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')'); |
| 160 | + |
| 161 | + // Make the value count up. |
| 162 | + if(config.valueCountUp){ |
| 163 | + var textTween = function(){ |
| 164 | + var i = d3.interpolate(this.textContent, textFinalValue); |
| 165 | + return function(t) { this.textContent = textRounder(i(t)) + percentText; } |
| 166 | + }; |
| 167 | + text1.transition() |
| 168 | + .duration(config.waveRiseTime) |
| 169 | + .tween("text", textTween); |
| 170 | + text2.transition() |
| 171 | + .duration(config.waveRiseTime) |
| 172 | + .tween("text", textTween); |
| 173 | + } |
| 174 | + |
| 175 | + // Make the wave rise. wave and waveGroup are separate so that horizontal and vertical movement can be controlled independently. |
| 176 | + var waveGroupXPosition = fillCircleMargin+fillCircleRadius*2-waveClipWidth; |
| 177 | + if(config.waveRise){ |
| 178 | + waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(0)+')') |
| 179 | + .transition() |
| 180 | + .duration(config.waveRiseTime) |
| 181 | + .attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')') |
| 182 | + .each("start", function(){ wave.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. |
| 183 | + } else { |
| 184 | + waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')'); |
| 185 | + } |
| 186 | + |
| 187 | + if(config.waveAnimate) animateWave(); |
| 188 | + |
| 189 | + function animateWave() { |
| 190 | + wave.attr('transform','translate('+waveAnimateScale(wave.attr('T'))+',0)'); |
| 191 | + wave.transition() |
| 192 | + .duration(config.waveAnimateTime * (1-wave.attr('T'))) |
| 193 | + .ease('linear') |
| 194 | + .attr('transform','translate('+waveAnimateScale(1)+',0)') |
| 195 | + .attr('T', 1) |
| 196 | + .each('end', function(){ |
| 197 | + wave.attr('T', 0); |
| 198 | + animateWave(config.waveAnimateTime); |
| 199 | + }); |
| 200 | + } |
| 201 | + |
| 202 | + function GaugeUpdater(){ |
| 203 | + this.update = function(value){ |
| 204 | + var newFinalValue = parseFloat(value).toFixed(2); |
| 205 | + var textRounderUpdater = function(value){ return Math.round(value); }; |
| 206 | + if(parseFloat(newFinalValue) != parseFloat(textRounderUpdater(newFinalValue))){ |
| 207 | + textRounderUpdater = function(value){ return parseFloat(value).toFixed(1); }; |
| 208 | + } |
| 209 | + if(parseFloat(newFinalValue) != parseFloat(textRounderUpdater(newFinalValue))){ |
| 210 | + textRounderUpdater = function(value){ return parseFloat(value).toFixed(2); }; |
| 211 | + } |
| 212 | + |
| 213 | + var textTween = function(){ |
| 214 | + var i = d3.interpolate(this.textContent, parseFloat(value).toFixed(2)); |
| 215 | + return function(t) { this.textContent = textRounderUpdater(i(t)) + percentText; } |
| 216 | + }; |
| 217 | + |
| 218 | + text1.transition() |
| 219 | + .duration(config.waveRiseTime) |
| 220 | + .tween("text", textTween); |
| 221 | + text2.transition() |
| 222 | + .duration(config.waveRiseTime) |
| 223 | + .tween("text", textTween); |
| 224 | + |
| 225 | + var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue; |
| 226 | + var waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100); |
| 227 | + var waveRiseScale = d3.scale.linear() |
| 228 | + // The clipping area size is the height of the fill circle + the wave height, so we position the clip wave |
| 229 | + // such that the it will overlap the fill circle at all when at 0%, and will totally cover the fill |
| 230 | + // circle at 100%. |
| 231 | + .range([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)]) |
| 232 | + .domain([0,1]); |
| 233 | + var newHeight = waveRiseScale(fillPercent); |
| 234 | + var waveScaleX = d3.scale.linear().range([0,waveClipWidth]).domain([0,1]); |
| 235 | + var waveScaleY = d3.scale.linear().range([0,waveHeight]).domain([0,1]); |
| 236 | + var newClipArea; |
| 237 | + if(config.waveHeightScaling){ |
| 238 | + newClipArea = d3.svg.area() |
| 239 | + .x(function(d) { return waveScaleX(d.x); } ) |
| 240 | + .y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} ) |
| 241 | + .y1(function(d) { return (fillCircleRadius*2 + waveHeight); } ); |
| 242 | + } else { |
| 243 | + newClipArea = clipArea; |
| 244 | + } |
| 245 | + |
| 246 | + var newWavePosition = config.waveAnimate?waveAnimateScale(1):0; |
| 247 | + wave.transition() |
| 248 | + .duration(0) |
| 249 | + .transition() |
| 250 | + .duration(config.waveAnimate?(config.waveAnimateTime * (1-wave.attr('T'))):(config.waveRiseTime)) |
| 251 | + .ease('linear') |
| 252 | + .attr('d', newClipArea) |
| 253 | + .attr('transform','translate('+newWavePosition+',0)') |
| 254 | + .attr('T','1') |
| 255 | + .each("end", function(){ |
| 256 | + if(config.waveAnimate){ |
| 257 | + wave.attr('transform','translate('+waveAnimateScale(0)+',0)'); |
| 258 | + animateWave(config.waveAnimateTime); |
| 259 | + } |
| 260 | + }); |
| 261 | + waveGroup.transition() |
| 262 | + .duration(config.waveRiseTime) |
| 263 | + .attr('transform','translate('+waveGroupXPosition+','+newHeight+')') |
| 264 | + } |
| 265 | + } |
| 266 | + |
| 267 | + return new GaugeUpdater(); |
| 268 | +} |
0 commit comments