Skip to content

Instantly share code, notes, and snippets.

@brattonc
Last active November 20, 2023 17:14
Show Gist options
  • Save brattonc/5e5ce9beee483220e2f6 to your computer and use it in GitHub Desktop.
Save brattonc/5e5ce9beee483220e2f6 to your computer and use it in GitHub Desktop.
D3 Liquid Fill Gauge

Liquid Fill Gauge v1.1 - 7/14/2015

Changes:

  • Added support for updating the gauge value after loading is complete. The loadLiquidFillGauge method now returns an object with an update method which allows the gauge value to be changed. Click any of the gauges above to randomly update their value.

Configurable features include:

  • Changeable min/max values.
  • All colors.
  • Outer circle thickness.
  • Gap between the outer circle and inner fill area.
  • Wave height.
  • Wave speed.
  • Wave count.
  • Wave rise time.
  • Wave height scaling on/off. Reduces the wave height near the min/max values so that the wave won't make the fill area appear total full or totally empty.
  • Wave starting offset. Most useful when wave animation is turned off and you want the wave min or max at a specific horizontal position in the fill area.
  • Wave rising upon load on/off.
  • Wave animation on/off.
  • Text height.
  • Text vertical position.
  • Text increment from min value upon loading.
  • Display of % symbol on/off.

Open source under BSD 2-clause
Copyright (c) 2015, Curtis Bratton
All rights reserved.

<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title></title>
<script src="http://d3js.org/d3.v3.min.js" language="JavaScript"></script>
<script src="liquidFillGauge.js" language="JavaScript"></script>
<style>
.liquidFillGaugeText { font-family: Helvetica; font-weight: bold; }
</style>
</head>
<body>
<svg id="fillgauge1" width="97%" height="250" onclick="gauge1.update(NewValue());"></svg>
<svg id="fillgauge2" width="19%" height="200" onclick="gauge2.update(NewValue());"></svg>
<svg id="fillgauge3" width="19%" height="200" onclick="gauge3.update(NewValue());"></svg>
<svg id="fillgauge4" width="19%" height="200" onclick="gauge4.update(NewValue());"></svg>
<svg id="fillgauge5" width="19%" height="200" onclick="gauge5.update(NewValue());"></svg>
<svg id="fillgauge6" width="19%" height="200" onclick="gauge6.update(NewValue());"></svg>
<script language="JavaScript">
var gauge1 = loadLiquidFillGauge("fillgauge1", 55);
var config1 = liquidFillGaugeDefaultSettings();
config1.circleColor = "#FF7777";
config1.textColor = "#FF4444";
config1.waveTextColor = "#FFAAAA";
config1.waveColor = "#FFDDDD";
config1.circleThickness = 0.2;
config1.textVertPosition = 0.2;
config1.waveAnimateTime = 1000;
var gauge2= loadLiquidFillGauge("fillgauge2", 28, config1);
var config2 = liquidFillGaugeDefaultSettings();
config2.circleColor = "#D4AB6A";
config2.textColor = "#553300";
config2.waveTextColor = "#805615";
config2.waveColor = "#AA7D39";
config2.circleThickness = 0.1;
config2.circleFillGap = 0.2;
config2.textVertPosition = 0.8;
config2.waveAnimateTime = 2000;
config2.waveHeight = 0.3;
config2.waveCount = 1;
var gauge3 = loadLiquidFillGauge("fillgauge3", 60.1, config2);
var config3 = liquidFillGaugeDefaultSettings();
config3.textVertPosition = 0.8;
config3.waveAnimateTime = 5000;
config3.waveHeight = 0.15;
config3.waveAnimate = false;
config3.waveOffset = 0.25;
config3.valueCountUp = false;
config3.displayPercent = false;
var gauge4 = loadLiquidFillGauge("fillgauge4", 50, config3);
var config4 = liquidFillGaugeDefaultSettings();
config4.circleThickness = 0.15;
config4.circleColor = "#808015";
config4.textColor = "#555500";
config4.waveTextColor = "#FFFFAA";
config4.waveColor = "#AAAA39";
config4.textVertPosition = 0.8;
config4.waveAnimateTime = 1000;
config4.waveHeight = 0.05;
config4.waveAnimate = true;
config4.waveRise = false;
config4.waveHeightScaling = false;
config4.waveOffset = 0.25;
config4.textSize = 0.75;
config4.waveCount = 3;
var gauge5 = loadLiquidFillGauge("fillgauge5", 60.44, config4);
var config5 = liquidFillGaugeDefaultSettings();
config5.circleThickness = 0.4;
config5.circleColor = "#6DA398";
config5.textColor = "#0E5144";
config5.waveTextColor = "#6DA398";
config5.waveColor = "#246D5F";
config5.textVertPosition = 0.52;
config5.waveAnimateTime = 5000;
config5.waveHeight = 0;
config5.waveAnimate = false;
config5.waveCount = 2;
config5.waveOffset = 0.25;
config5.textSize = 1.2;
config5.minValue = 30;
config5.maxValue = 150
config5.displayPercent = false;
var gauge6 = loadLiquidFillGauge("fillgauge6", 120, config5);
function NewValue(){
if(Math.random() > .5){
return Math.round(Math.random()*100);
} else {
return (Math.random()*100).toFixed(1);
}
}
</script>
</body>
</html>
/*!
* @license Open source under BSD 2-clause (http://choosealicense.com/licenses/bsd-2-clause/)
* Copyright (c) 2015, Curtis Bratton
* All rights reserved.
*
* Liquid Fill Gauge v1.1
*/
function liquidFillGaugeDefaultSettings(){
return {
minValue: 0, // The gauge minimum value.
maxValue: 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.
waveCount: 1, // The number of full waves per width of the wave circle.
waveRiseTime: 1000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height.
waveAnimateTime: 18000, // The amount of time in milliseconds for a full wave to enter 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.
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.
waveAnimate: true, // Controls if the wave scrolls or is static.
waveColor: "#178BCA", // The color of the fill wave.
waveOffset: 0, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave.
textVertPosition: .5, // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top.
textSize: 1, // The relative height of the text to display in the wave circle. 1 = 50%
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.
displayPercent: true, // If true, a % symbol is displayed after the value.
textColor: "#045681", // The color of the value text when the wave does not overlap it.
waveTextColor: "#A4DBf8" // The color of the value text when the wave overlaps it.
};
}
function loadLiquidFillGauge(elementId, value, config) {
if(config == null) config = liquidFillGaugeDefaultSettings();
var gauge = d3.select("#" + elementId);
var radius = Math.min(parseInt(gauge.style("width")), parseInt(gauge.style("height")))/2;
var locationX = parseInt(gauge.style("width"))/2 - radius;
var locationY = parseInt(gauge.style("height"))/2 - radius;
var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue;
var waveHeightScale;
if(config.waveHeightScaling){
waveHeightScale = d3.scale.linear()
.range([0,config.waveHeight,0])
.domain([0,50,100]);
} else {
waveHeightScale = d3.scale.linear()
.range([config.waveHeight,config.waveHeight])
.domain([0,100]);
}
var textPixels = (config.textSize*radius/2);
var textFinalValue = parseFloat(value).toFixed(2);
var textStartValue = config.valueCountUp?config.minValue:textFinalValue;
var percentText = config.displayPercent?"%":"";
var circleThickness = config.circleThickness * radius;
var circleFillGap = config.circleFillGap * radius;
var fillCircleMargin = circleThickness + circleFillGap;
var fillCircleRadius = radius - fillCircleMargin;
var waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100);
var waveLength = fillCircleRadius*2/config.waveCount;
var waveClipCount = 1+config.waveCount;
var waveClipWidth = waveLength*waveClipCount;
// Rounding functions so that the correct number of decimal places is always displayed as the value counts up.
var textRounder = function(value){ return Math.round(value); };
if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
textRounder = function(value){ return parseFloat(value).toFixed(1); };
}
if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
textRounder = function(value){ return parseFloat(value).toFixed(2); };
}
// Data for building the clip wave area.
var data = [];
for(var i = 0; i <= 40*waveClipCount; i++){
data.push({x: i/(40*waveClipCount), y: (i/(40))});
}
// 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]);
// Scales for controlling the size of the clipping path.
var waveScaleX = d3.scale.linear().range([0,waveClipWidth]).domain([0,1]);
var waveScaleY = d3.scale.linear().range([0,waveHeight]).domain([0,1]);
// Scales for controlling the position of the clipping path.
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([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)])
.domain([0,1]);
var waveAnimateScale = d3.scale.linear()
.range([0, waveClipWidth-fillCircleRadius*2]) // Push the clip area one full wave then snap back.
.domain([0,1]);
// Scale for controlling the position of the text within the gauge.
var textRiseScaleY = d3.scale.linear()
.range([fillCircleMargin+fillCircleRadius*2,(fillCircleMargin+textPixels*0.7)])
.domain([0,1]);
// Center the gauge within the parent SVG.
var gaugeGroup = gauge.append("g")
.attr('transform','translate('+locationX+','+locationY+')');
// Draw the outer circle.
var gaugeCircleArc = d3.svg.arc()
.startAngle(gaugeCircleX(0))
.endAngle(gaugeCircleX(1))
.outerRadius(gaugeCircleY(radius))
.innerRadius(gaugeCircleY(radius-circleThickness));
gaugeGroup.append("path")
.attr("d", gaugeCircleArc)
.style("fill", config.circleColor)
.attr('transform','translate('+radius+','+radius+')');
// Text where the wave does not overlap.
var text1 = gaugeGroup.append("text")
.text(textRounder(textStartValue) + percentText)
.attr("class", "liquidFillGaugeText")
.attr("text-anchor", "middle")
.attr("font-size", textPixels + "px")
.style("fill", config.textColor)
.attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')');
// The clipping wave area.
var clipArea = d3.svg.area()
.x(function(d) { return waveScaleX(d.x); } )
.y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} )
.y1(function(d) { return (fillCircleRadius*2 + waveHeight); } );
var waveGroup = gaugeGroup.append("defs")
.append("clipPath")
.attr("id", "clipWave" + elementId);
var wave = waveGroup.append("path")
.datum(data)
.attr("d", clipArea)
.attr("T", 0);
// The inner circle with the clipping wave attached.
var fillCircleGroup = gaugeGroup.append("g")
.attr("clip-path", "url(#clipWave" + elementId + ")");
fillCircleGroup.append("circle")
.attr("cx", radius)
.attr("cy", radius)
.attr("r", fillCircleRadius)
.style("fill", config.waveColor);
// Text where the wave does overlap.
var text2 = fillCircleGroup.append("text")
.text(textRounder(textStartValue) + percentText)
.attr("class", "liquidFillGaugeText")
.attr("text-anchor", "middle")
.attr("font-size", textPixels + "px")
.style("fill", config.waveTextColor)
.attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')');
// Make the value count up.
if(config.valueCountUp){
var textTween = function(){
var i = d3.interpolate(this.textContent, textFinalValue);
return function(t) { this.textContent = textRounder(i(t)) + percentText; }
};
text1.transition()
.duration(config.waveRiseTime)
.tween("text", textTween);
text2.transition()
.duration(config.waveRiseTime)
.tween("text", textTween);
}
// Make the wave rise. wave and waveGroup are separate so that horizontal and vertical movement can be controlled independently.
var waveGroupXPosition = fillCircleMargin+fillCircleRadius*2-waveClipWidth;
if(config.waveRise){
waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(0)+')')
.transition()
.duration(config.waveRiseTime)
.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')')
.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.
} else {
waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')');
}
if(config.waveAnimate) animateWave();
function animateWave() {
wave.attr('transform','translate('+waveAnimateScale(wave.attr('T'))+',0)');
wave.transition()
.duration(config.waveAnimateTime * (1-wave.attr('T')))
.ease('linear')
.attr('transform','translate('+waveAnimateScale(1)+',0)')
.attr('T', 1)
.each('end', function(){
wave.attr('T', 0);
animateWave(config.waveAnimateTime);
});
}
function GaugeUpdater(){
this.update = function(value){
var newFinalValue = parseFloat(value).toFixed(2);
var textRounderUpdater = function(value){ return Math.round(value); };
if(parseFloat(newFinalValue) != parseFloat(textRounderUpdater(newFinalValue))){
textRounderUpdater = function(value){ return parseFloat(value).toFixed(1); };
}
if(parseFloat(newFinalValue) != parseFloat(textRounderUpdater(newFinalValue))){
textRounderUpdater = function(value){ return parseFloat(value).toFixed(2); };
}
var textTween = function(){
var i = d3.interpolate(this.textContent, parseFloat(value).toFixed(2));
return function(t) { this.textContent = textRounderUpdater(i(t)) + percentText; }
};
text1.transition()
.duration(config.waveRiseTime)
.tween("text", textTween);
text2.transition()
.duration(config.waveRiseTime)
.tween("text", textTween);
var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue;
var waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100);
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([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)])
.domain([0,1]);
var newHeight = waveRiseScale(fillPercent);
var waveScaleX = d3.scale.linear().range([0,waveClipWidth]).domain([0,1]);
var waveScaleY = d3.scale.linear().range([0,waveHeight]).domain([0,1]);
var newClipArea;
if(config.waveHeightScaling){
newClipArea = d3.svg.area()
.x(function(d) { return waveScaleX(d.x); } )
.y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} )
.y1(function(d) { return (fillCircleRadius*2 + waveHeight); } );
} else {
newClipArea = clipArea;
}
var newWavePosition = config.waveAnimate?waveAnimateScale(1):0;
wave.transition()
.duration(0)
.transition()
.duration(config.waveAnimate?(config.waveAnimateTime * (1-wave.attr('T'))):(config.waveRiseTime))
.ease('linear')
.attr('d', newClipArea)
.attr('transform','translate('+newWavePosition+',0)')
.attr('T','1')
.each("end", function(){
if(config.waveAnimate){
wave.attr('transform','translate('+waveAnimateScale(0)+',0)');
animateWave(config.waveAnimateTime);
}
});
waveGroup.transition()
.duration(config.waveRiseTime)
.attr('transform','translate('+waveGroupXPosition+','+newHeight+')')
}
}
return new GaugeUpdater();
}
@anuj3918
Copy link

Hi, I was trying if I could change the colour of Circle/Gauge depending on the newValue generated randomly. I could not do it as one the Gauge is configured using some settings, I had to build a completely new Gauge with a different colour. Please help me if you can figure out a way to change the colour of existing gauge only. Thanks.

@zouning
Copy link

zouning commented Jun 29, 2016

Hi, This is really awesome! Would you mind me to make it become a customize visulization in Splunk base. Kind of like an open source chart app and splunk user could install it and use it in their dashboard?

@rlugojr
Copy link

rlugojr commented Jul 4, 2016

Dude, this is fantastic! Really a work of digital art. Simple, effective and mesmerizing. I have been trying to get a similar effect on a chart, did a search on Google which led to a few other versions until I reached the source. Thank you for sharing!

@jongio
Copy link

jongio commented Jul 18, 2016

FYI - When waveAnimate is false the fill doesn't render: See the 50% gauge here: http://bl.ocks.org/brattonc/raw/5e5ce9beee483220e2f6/

@jongio
Copy link

jongio commented Jul 18, 2016

I resolved this clip-path issue: https://stackoverflow.com/questions/37056050/d3-js-liquid-fill-gauge-clip-path-not-working/ by adding location.href to the clip-path. The fix is in my fork of your gist is here: https://gist.github.com/jonbgallant/e85bc5440a4372aff9452e15a4e3276c

@ArisBee
Copy link

ArisBee commented Aug 18, 2016

Hi, I'm trying to load several gauges dynamically in a bootstrap nav-tab, but only the active tab is rendering, the others are empty.
Would you have any idea where this bug could come from?
cap

Copy link

ghost commented Sep 3, 2016

Has anyone tried to use this with D3 v4? Also, using Webpack instead of SystemJS?

@vikarm
Copy link

vikarm commented Sep 13, 2016

Hi, I liked the liquidgauge chart with animation.
Is there any possibility to change the colors of the liquid dynamically according to the value?

@AdrienLZ
Copy link

Broken with D3 v4

Uncaught TypeError: Cannot read property 'linear' of undefined
http://localhost:1444/js/liquidFillGauge.js:44:35

@magneticnorth
Copy link

magneticnorth commented Sep 27, 2016

I'd also like to port this to d3.v4. I tried the obvious: d3.scale.linear to d3.scaleLinear, d3.svg.arc to d3.arc, each("start", ...) to on("start",...) etc. The result runs, but meter labels don't move from 0%. A slightly sanitized version of my attempt is attached. What's wrong? It appears tween() is invoked for this update, but maybe not correctly? (p.s. I tried to attach a txt file but was denied by github sorry sorry sorry)

/*!
 * @license Open source under BSD 2-clause (http://choosealicense.com/licenses/bsd-2-clause/)
 * Copyright (c) 2015, Curtis Bratton
 * All rights reserved.
 *
 * Liquid Fill Gauge v1.1
 */
function liquidFillGaugeDefaultSettings(){
    return {
        minValue: 0, // The gauge minimum value.
        maxValue: 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.1, // The wave height as a percentage of the radius of the wave circle.
        waveCount: 2, // The number of full waves per width of the wave circle.
        waveRiseTime: 2000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height.
        waveAnimateTime: 1500, // The amount of time in milliseconds for a full wave to enter 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.
        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.
        waveAnimate: true, // Controls if the wave scrolls or is static.
        waveColor: "#178BCA", // The color of the fill wave.
        waveOffset: .25, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave.
        textVertPosition: .5, // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top.
        textSize: 1, // The relative height of the text to display in the wave circle. 1 = 50%
        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.
        displayPercent: true, // If true, a % symbol is displayed after the value.
        textColor: "#045681", // The color of the value text when the wave does not overlap it.
        waveTextColor: "#A4DBf8" // The color of the value text when the wave overlaps it.
    };
}

function loadLiquidFillGauge(elementId, value, config) {
    if(config == null) config = liquidFillGaugeDefaultSettings();

    var gauge = d3.select("#" + elementId);
    var radius = Math.min(parseInt(gauge.style("width")), parseInt(gauge.style("height")))/2;

    console.log("width: " + parseInt(gauge.style("width")))
    console.log("height: " + parseInt(gauge.style("height")))
    console.log(radius)

    var locationX = parseInt(gauge.style("width"))/2 - radius;
    var locationY = parseInt(gauge.style("height"))/2 - radius;
    var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue;

    var waveHeightScale;
    if(config.waveHeightScaling){
        waveHeightScale = d3.scaleLinear()
            .range([0,config.waveHeight,0])
            .domain([0,50,100]);
    } else {
        waveHeightScale = d3.scaleLinear()
            .range([config.waveHeight,config.waveHeight])
            .domain([0,100]);
    }

    var textPixels = (config.textSize*radius/2);
    var textFinalValue = parseFloat(value).toFixed(2);
    var textStartValue = config.valueCountUp?config.minValue:textFinalValue;
    var percentText = config.displayPercent?"%":"";
    var circleThickness = config.circleThickness * radius;
    var circleFillGap = config.circleFillGap * radius;
    var fillCircleMargin = circleThickness + circleFillGap;
    var fillCircleRadius = radius - fillCircleMargin;
    var waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100);

    var waveLength = fillCircleRadius*2/config.waveCount;
    var waveClipCount = 1+config.waveCount;
    var waveClipWidth = waveLength*waveClipCount;

    // Rounding functions so that the correct number of decimal places is always displayed as the value counts up.
    var textRounder = function(value){ return Math.round(value); };
    if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
        textRounder = function(value){ return parseFloat(value).toFixed(1); };
    }
    if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
        textRounder = function(value){ return parseFloat(value).toFixed(2); };
    }

    // Data for building the clip wave area.
    var data = [];
    for(var i = 0; i <= 40*waveClipCount; i++){
        data.push({x: i/(40*waveClipCount), y: (i/(40))});
    }

    // Scales for drawing the outer circle.
    var gaugeCircleX = d3.scaleLinear().range([0,2*Math.PI]).domain([0,1]);
    var gaugeCircleY = d3.scaleLinear().range([0,radius]).domain([0,radius]);

    // Scales for controlling the size of the clipping path.
    var waveScaleX = d3.scaleLinear().range([0,waveClipWidth]).domain([0,1]);
    var waveScaleY = d3.scaleLinear().range([0,waveHeight]).domain([0,1]);

    // Scales for controlling the position of the clipping path.
    var waveRiseScale = d3.scaleLinear()
        // 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([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)])
        .domain([0,1]);
    var waveAnimateScale = d3.scaleLinear()
        .range([0, waveClipWidth-fillCircleRadius*2]) // Push the clip area one full wave then snap back.
        .domain([0,1]);

    // Scale for controlling the position of the text within the gauge.
    var textRiseScaleY = d3.scaleLinear()
        .range([fillCircleMargin+fillCircleRadius*2,(fillCircleMargin+textPixels*0.7)])
        .domain([0,1]);

    // Center the gauge within the parent SVG.
    var gaugeGroup = gauge.append("g")
        .attr('transform','translate('+locationX+','+locationY+')');

    // Draw the outer circle.
    var gaugeCircleArc = d3.arc()
        .startAngle(gaugeCircleX(0))
        .endAngle(gaugeCircleX(1))
        .outerRadius(gaugeCircleY(radius))
        .innerRadius(gaugeCircleY(radius-circleThickness));
    gaugeGroup.append("path")
        .attr("d", gaugeCircleArc)
        .style("fill", config.circleColor)
        .attr('transform','translate('+radius+','+radius+')');

    // Text where the wave does not overlap.
    var text1 = gaugeGroup.append("text")
        .text(textRounder(textStartValue) + percentText)
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.textColor)
        .attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')');

    // The clipping wave area.
    var clipArea = d3.area()
        .x(function(d) { return waveScaleX(d.x); } )
        .y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} )
        .y1(function(d) { return (fillCircleRadius*2 + waveHeight); } );
    var waveGroup = gaugeGroup.append("defs")
        .append("clipPath")
        .attr("id", "clipWave" + elementId);
    var wave = waveGroup.append("path")
        .datum(data)
        .attr("d", clipArea)
        .attr("T", 0);

    // The inner circle with the clipping wave attached.
    var fillCircleGroup = gaugeGroup.append("g")
        .attr("clip-path", "url(#clipWave" + elementId + ")");
    fillCircleGroup.append("circle")
        .attr("cx", radius)
        .attr("cy", radius)
        .attr("r", fillCircleRadius)
        .style("fill", config.waveColor);

    // Text where the wave does overlap.
    var text2 = fillCircleGroup.append("text")
        .text(textRounder(textStartValue) + percentText)
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.waveTextColor)
        .attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')');

    // Make the value count up.
    if(config.valueCountUp){
        var textTween = function(){
            var i = d3.interpolate(this.textContent, textFinalValue);
            return function(t) { this.textContent = textRounder(i(t)) + percentText; }
        };
        text1.transition()
            .duration(config.waveRiseTime)
            .tween("text", textTween);
        text2.transition()
            .duration(config.waveRiseTime)
            .tween("text", textTween);
    }

    // Make the wave rise. wave and waveGroup are separate so that horizontal and vertical movement can be controlled independently.
    var waveGroupXPosition = fillCircleMargin+fillCircleRadius*2-waveClipWidth;
    if(config.waveRise){
        waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(0)+')')
            .transition()
            .duration(config.waveRiseTime)
            .attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')')
            .on("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.
    } else {
        waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')');
    }

    if(config.waveAnimate) animateWave();

    function animateWave() {
        console.log("ANIMATING WAVE")
        wave.attr('transform','translate('+waveAnimateScale(wave.attr('T'))+',0)');
        wave.transition()
            .duration(config.waveAnimateTime * (1-wave.attr('T')))
            .ease(d3.easeLinear)
            .attr('transform','translate('+waveAnimateScale(1)+',0)')
            .attr('T', 1)
            .on('end', function(){
                wave.attr('T', 0);
                animateWave(config.waveAnimateTime);
            });
    }

    function GaugeUpdater(){
        this.update = function(value){
            var newFinalValue = parseFloat(value).toFixed(2);
            var textRounderUpdater = function(value){ return Math.round(value); };
            if(parseFloat(newFinalValue) != parseFloat(textRounderUpdater(newFinalValue))){
                textRounderUpdater = function(value){ return parseFloat(value).toFixed(1); };
            }
            if(parseFloat(newFinalValue) != parseFloat(textRounderUpdater(newFinalValue))){
                textRounderUpdater = function(value){ return parseFloat(value).toFixed(2); };
            }

            var textTween = function(){
                var i = d3.interpolate(this.textContent, parseFloat(value).toFixed(2));
                return function(t) { this.textContent = textRounderUpdater(i(t)) + percentText; }
            };

            text1.transition()
                .duration(config.waveRiseTime)
                .tween("text", textTween);
            text2.transition()
                .duration(config.waveRiseTime)
                .tween("text", textTween);

            var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue;
            var waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100);
            var waveRiseScale = d3.scaleLinear()
                // 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([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)])
                .domain([0,1]);
            var newHeight = waveRiseScale(fillPercent);
            var waveScaleX = d3.scaleLinear().range([0,waveClipWidth]).domain([0,1]);
            var waveScaleY = d3.scaleLinear().range([0,waveHeight]).domain([0,1]);
            var newClipArea;
            if(config.waveHeightScaling){
                newClipArea = d3.area()
                    .x(function(d) { return waveScaleX(d.x); } )
                    .y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} )
                    .y1(function(d) { return (fillCircleRadius*2 + waveHeight); } );
            } else {
                newClipArea = clipArea;
            }

            var newWavePosition = config.waveAnimate?waveAnimateScale(1):0;
            wave.transition()
                .duration(0)
                .transition()
                .duration(config.waveAnimate?(config.waveAnimateTime * (1-wave.attr('T'))):(config.waveRiseTime))
                .ease('linear')
                .attr('d', newClipArea)
                .attr('transform','translate('+newWavePosition+',0)')
                .attr('T','1')
                .on('end', function(){
                    if(config.waveAnimate){
                        wave.attr('transform','translate('+waveAnimateScale(0)+',0)');
                        animateWave(config.waveAnimateTime);
                    }
                });
            waveGroup.transition()
                .duration(config.waveRiseTime)
                .attr('transform','translate('+waveGroupXPosition+','+newHeight+')')
        }
    }

    return new GaugeUpdater();
}

@cheton
Copy link

cheton commented Dec 19, 2016

I wrote a React Liquid Gauge component based on current work of Liquid Fill Gauge v1.1 and react-liquidchart.

You can see a demo at https://trendmicro-frontend.github.io/react-liquid-gauge.

GitHub: https://github.com/trendmicro-frontend/react-liquid-gauge

@Programmer-D
Copy link

I got this to work in version 4 by taking magneticnorth's code and modifying it. I'm attaching the code to this post. The code can of course be optimized, but this should be a good starting point. It's best to diff my code with magneticnorth's to see all my changes, but my main changes were updating the text1.tween and text2.tween functions. Turns out we can't use this.textContent to update the percentage values inside the circle anymore.

function liquidFillGaugeDefaultSettings(){
    return {
        minValue: 0, // The gauge minimum value.
        maxValue: 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.1, // The wave height as a percentage of the radius of the wave circle.
        waveCount: 2, // The number of full waves per width of the wave circle.
        waveRiseTime: 2000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height.
        waveAnimateTime: 1500, // The amount of time in milliseconds for a full wave to enter 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.
        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.
        waveAnimate: true, // Controls if the wave scrolls or is static.
        waveColor: "#178BCA", // The color of the fill wave.
        waveOffset: .25, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave.
        textVertPosition: .5, // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top.
        textSize: 1, // The relative height of the text to display in the wave circle. 1 = 50%
        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.
        displayPercent: true, // If true, a % symbol is displayed after the value.
        textColor: "#045681", // The color of the value text when the wave does not overlap it.
        waveTextColor: "#A4DBf8" // The color of the value text when the wave overlaps it.
    };
}

function loadLiquidFillGauge(elementId, value, config) {
    if(config == null) config = liquidFillGaugeDefaultSettings();

    const gauge = d3.select("#" + elementId);
    const radius = Math.min(parseInt(gauge.style("width")), parseInt(gauge.style("height")))/2;

    const locationX = parseInt(gauge.style("width"))/2 - radius;
    const locationY = parseInt(gauge.style("height"))/2 - radius;
    const fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue;

    let waveHeightScale = null;
    if(config.waveHeightScaling){
        waveHeightScale = d3.scaleLinear()
            .range([0,config.waveHeight,0])
            .domain([0,50,100]);
    } else {
        waveHeightScale = d3.scaleLinear()
            .range([config.waveHeight,config.waveHeight])
            .domain([0,100]);
    }

    const textPixels = (config.textSize*radius/2);
    const textFinalValue = parseFloat(value).toFixed(2);
    const textStartValue = config.valueCountUp?config.minValue:textFinalValue;
    const percentText = config.displayPercent?"%":"";
    const circleThickness = config.circleThickness * radius;
    const circleFillGap = config.circleFillGap * radius;
    const fillCircleMargin = circleThickness + circleFillGap;
    const fillCircleRadius = radius - fillCircleMargin;
    const waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100);

    const waveLength = fillCircleRadius*2/config.waveCount;
    const waveClipCount = 1+config.waveCount;
    const waveClipWidth = waveLength*waveClipCount;

    // Rounding functions so that the correct number of decimal places is always displayed as the value counts up.
    let textRounder = function(value){ return Math.round(value); };
    if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
        textRounder = function(value){ return parseFloat(value).toFixed(1); };
    }
    if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
        textRounder = function(value){ return parseFloat(value).toFixed(2); };
    }

    // Data for building the clip wave area.
    const data = [];
    for(let i = 0; i <= 40*waveClipCount; i++){
        data.push({x: i/(40*waveClipCount), y: (i/(40))});
    }

    // Scales for drawing the outer circle.
    const gaugeCircleX = d3.scaleLinear().range([0,2*Math.PI]).domain([0,1]);
    const gaugeCircleY = d3.scaleLinear().range([0,radius]).domain([0,radius]);

    // Scales for controlling the size of the clipping path.
    const waveScaleX = d3.scaleLinear().range([0,waveClipWidth]).domain([0,1]);
    const waveScaleY = d3.scaleLinear().range([0,waveHeight]).domain([0,1]);

    // Scales for controlling the position of the clipping path.
    const waveRiseScale = d3.scaleLinear()
        // 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([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)])
        .domain([0,1]);
    const waveAnimateScale = d3.scaleLinear()
        .range([0, waveClipWidth-fillCircleRadius*2]) // Push the clip area one full wave then snap back.
        .domain([0,1]);

    // Scale for controlling the position of the text within the gauge.
    const textRiseScaleY = d3.scaleLinear()
        .range([fillCircleMargin+fillCircleRadius*2,(fillCircleMargin+textPixels*0.7)])
        .domain([0,1]);

    // Center the gauge within the parent SVG.
    const gaugeGroup = gauge.append("g")
        .attr('transform','translate('+locationX+','+locationY+')');

    // Draw the outer circle.
    const gaugeCircleArc = d3.arc()
        .startAngle(gaugeCircleX(0))
        .endAngle(gaugeCircleX(1))
        .outerRadius(gaugeCircleY(radius))
        .innerRadius(gaugeCircleY(radius-circleThickness));
    gaugeGroup.append("path")
        .attr("d", gaugeCircleArc)
        .style("fill", config.circleColor)
        .attr('transform','translate('+radius+','+radius+')');

    // Text where the wave does not overlap.
    const text1 = gaugeGroup.append("text")
        .text(textRounder(textStartValue) + percentText)
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.textColor)
        .attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')');
    let text1InterpolatorValue = textStartValue;


    // The clipping wave area.
    const clipArea = d3.area()
        .x(function(d) { return waveScaleX(d.x); } )
        .y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} )
        .y1(function(d) { return (fillCircleRadius*2 + waveHeight); } );
    const waveGroup = gaugeGroup.append("defs")
        .append("clipPath")
        .attr("id", "clipWave" + elementId);
    const wave = waveGroup.append("path")
        .datum(data)
        .attr("d", clipArea)
        .attr("T", 0);

    // The inner circle with the clipping wave attached.
    const fillCircleGroup = gaugeGroup.append("g")
        .attr("clip-path", "url(#clipWave" + elementId + ")");
    fillCircleGroup.append("circle")
        .attr("cx", radius)
        .attr("cy", radius)
        .attr("r", fillCircleRadius)
        .style("fill", config.waveColor);

    // Text where the wave does overlap.
    const text2 = fillCircleGroup.append("text")
        .text(textRounder(textStartValue))
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.waveTextColor)
        .attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')');
    let text2InterpolatorValue = textStartValue;

    // Make the value count up.
    if(config.valueCountUp){
        text1.transition()
            .duration(config.waveRiseTime)
            .tween("text", function() {
              const i = d3.interpolateNumber(text1InterpolatorValue, textFinalValue);
              return (t) => {
                text1InterpolatorValue = textRounder(i(t));
                // Set the gauge's text with the new value and append the % sign
                // to the end
                text1.text(text1InterpolatorValue + percentText);
              }
            });
        text2.transition()
            .duration(config.waveRiseTime)
            .tween("text", function() { 
              const i = d3.interpolateNumber(text2InterpolatorValue, textFinalValue);
              return (t) => {
                text2InterpolatorValue = textRounder(i(t));
                // Set the gauge's text with the new value and append the % sign
                // to the end                
                text2.text(text2InterpolatorValue + percentText);
              }
            });
    }

    // Make the wave rise. wave and waveGroup are separate so that horizontal and vertical movement can be controlled independently.
    const waveGroupXPosition = fillCircleMargin+fillCircleRadius*2-waveClipWidth;
    if(config.waveRise){
        waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(0)+')')
            .transition()
            .duration(config.waveRiseTime)
            .attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')')
            .on("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.
    } else {
        waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')');
    }

    if(config.waveAnimate) animateWave();

    function animateWave() {
        wave.attr('transform','translate('+waveAnimateScale(wave.attr('T'))+',0)');
        wave.transition()
            .duration(config.waveAnimateTime * (1-wave.attr('T')))
            .ease(d3.easeLinear)
            .attr('transform','translate('+waveAnimateScale(1)+',0)')
            .attr('T', 1)
            .on('end', function(){
                wave.attr('T', 0);
                animateWave(config.waveAnimateTime);
            });
    }
}

@ma3tk
Copy link

ma3tk commented Jan 18, 2017

@Programmer-D
👍
Hi, I have been confused about this program between D3 v3 and v4 version up.
And you solved the problem :)
So nice. I'm appreciated it. Thank you!

@PraveenKumarNaikLC
Copy link

Hello Sir.
This liquid fill gauge is very cool. But i want it in cylindrical(not circle).
how to convert it as a cylindrical gauge.
Thank you

@dborstelmann
Copy link

Very helpful lib, thanks a bunch for doing it.

@hmm34
Copy link

hmm34 commented Apr 21, 2017

I have no suggestions, just thanks for posting this! Love this :)

@tripishrivastava
Copy link

If i wanted to add a line inside the gauge, how would I go about doing that?

@DanaDee100
Copy link

Hi Curtis, I use the liquid gauge in Power BI - it is a great visual.

I was wondering if there was a way for more animation (apart from the waves moving) that it would continually rise after a few seconds or minutes. Presently it only seems to do this when the page is refreshed\loaded or a value is changed. Thanks - I look forward to hearing from you.

@Mluis31
Copy link

Mluis31 commented Jan 30, 2019

Is it possible to change "%" to other unit?

@guykatz
Copy link

guykatz commented Mar 24, 2019

any work on porting to d3 V5+?
thanks

@bq-gugan
Copy link

bq-gugan commented Apr 7, 2019

Hi,

is anyone of you tried to convert the circular shape into rect in this liquid fill gauge sample?

If so, please share your thoughts with me, Thanks!

@kluchrj
Copy link

kluchrj commented Apr 26, 2019

Here's an updated version that works in d3 v5, based on Programmer-D's modification. I cleaned up the text tween function and added the update() function, along with the ability to toggle the wave animation on/off via setWaveAnimate().

/*!
 * @license Open source under BSD 2-clause (http://choosealicense.com/licenses/bsd-2-clause/)
 * Copyright (c) 2015, Curtis Bratton
 * All rights reserved.
 *
 * Liquid Fill Gauge v1.1
 */

function liquidFillGaugeDefaultSettings() {
    return {
        minValue: 0, // The gauge minimum value.
        maxValue: 100, // The gauge maximum value.

        circleThickness: 0.05, // The outer circle thickness as a percentage of it's radius.
        // The size of the gap between the outer circle and wave circle as a percentage of
        // the outer circles radius.
        circleFillGap: 0.05,
        circleColor: "#178BCA", // The color of the outer circle.
        
        waveHeight: 0.05, // The wave height as a percentage of the radius of the wave circle.
        waveCount: 1, // The number of full waves per width of the wave circle.
        waveRiseTime: 1000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height.
        waveAnimateTime: 18000, // The amount of time in milliseconds for a full wave to enter the wave circle.

        // Control if the wave should rise from 0 to it's full height, or start at it's full height.
        waveRise: 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.
        waveHeightScaling: true, 
        waveAnimate: true, // Controls if the wave scrolls or is static.
        waveColor: "#178BCA", // The color of the fill wave.
        waveOffset: 0, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave.

        // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top.
        textVertPosition: .5,
        textSize: 1, // The relative height of the text to display in the wave circle. 1 = 50%

        // If true, the displayed value counts up from 0 to it's final value upon loading.
        // If false, the final value is displayed.
        valueCountUp: true,
        displayPercent: true, // If true, a % symbol is displayed after the value.
        textColor: "#045681", // The color of the value text when the wave does not overlap it.
        waveTextColor: "#A4DBf8" // The color of the value text when the wave overlaps it.
    };
}

function loadLiquidFillGauge(elementId, value, config) {
    if (config == null) config = liquidFillGaugeDefaultSettings();

    const gauge = d3.select("#" + elementId);
    const radius = Math.min(gauge.style("width"), gauge.style("height")) / 2;

    const locationX = parseInt(gauge.style("width")) / 2 - radius;
    const locationY = parseInt(gauge.style("height")) / 2 - radius;
    const fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value)) / config.maxValue;

    let waveHeightScale = null;
    if (config.waveHeightScaling) {
        waveHeightScale = d3.scaleLinear()
            .range([0, config.waveHeight, 0])
            .domain([0, 50, 100]);
    } else {
        waveHeightScale = d3.scaleLinear()
            .range([config.waveHeight, config.waveHeight])
            .domain([0, 100]);
    }

    const textPixels = (config.textSize * radius / 2);
    const textFinalValue = parseFloat(value).toFixed(2);
    const textStartValue = config.valueCountUp ? config.minValue : textFinalValue;
    const percentText = config.displayPercent ? "%" : "";
    const circleThickness = config.circleThickness * radius;
    const circleFillGap = config.circleFillGap * radius;
    const fillCircleMargin = circleThickness + circleFillGap;
    const fillCircleRadius = radius - fillCircleMargin;
    const waveHeight = fillCircleRadius * waveHeightScale(fillPercent * 100);

    const waveLength = fillCircleRadius * 2 / config.waveCount;
    const waveClipCount = 1 + config.waveCount;
    const waveClipWidth = waveLength * waveClipCount;

    // Rounding functions so that the correct number of decimal places is always displayed
    // as the value counts up.
    const format = d3.format(".0f");

    // Data for building the clip wave area.
    const data = [];
    for (let i = 0; i <= 40 * waveClipCount; i++) {
        data.push({x: i / (40 * waveClipCount), y: (i / (40))});
    }

    // Scales for drawing the outer circle.
    const gaugeCircleX = d3.scaleLinear().range([0, 2 * Math.PI]).domain([0, 1]);
    const gaugeCircleY = d3.scaleLinear().range([0, radius]).domain([0, radius]);

    // Scales for controlling the size of the clipping path.
    const waveScaleX = d3.scaleLinear().range([0, waveClipWidth]).domain([0, 1]);
    const waveScaleY = d3.scaleLinear().range([0, waveHeight]).domain([0, 1]);

    // Scales for controlling the position of the clipping path.
    const waveRiseScale = d3.scaleLinear()
        // 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([(fillCircleMargin + fillCircleRadius * 2 + waveHeight), (fillCircleMargin - waveHeight)])
        .domain([0, 1]);

    const waveAnimateScale = d3.scaleLinear()
        .range([0, waveClipWidth - fillCircleRadius * 2]) // Push the clip area one full wave then snap back.
        .domain([0, 1]);

    // Scale for controlling the position of the text within the gauge.
    const textRiseScaleY = d3.scaleLinear()
        .range([fillCircleMargin + fillCircleRadius * 2,(fillCircleMargin + textPixels * 0.7)])
        .domain([0, 1]);

    // Center the gauge within the parent SVG.
    const gaugeGroup = gauge.append("g")
        .attr('transform','translate(' + locationX + ',' + locationY + ')');

    // Draw the outer circle.
    const gaugeCircleArc = d3.arc()
        .startAngle(gaugeCircleX(0))
        .endAngle(gaugeCircleX(1))
        .outerRadius(gaugeCircleY(radius))
        .innerRadius(gaugeCircleY(radius - circleThickness));

    gaugeGroup.append("path")
        .attr("d", gaugeCircleArc)
        .style("fill", config.circleColor)
        .attr('transform','translate(' + radius + ',' + radius + ')');

    // Text where the wave does not overlap.
    gaugeGroup.append("text")
        .text(format(textStartValue) + percentText)
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.textColor)
        .attr('transform','translate(' + radius + ',' + textRiseScaleY(config.textVertPosition) + ')');

    // The clipping wave area.
    const clipArea = d3.area()
        .x(function(d) { return waveScaleX(d.x); })
        .y0(function(d) { return waveScaleY(Math.sin(Math.PI * 2 * config.waveOffset * -1 + Math.PI * 2 * (1 - config.waveCount) + d.y * 2 * Math.PI));})
        .y1(function(d) { return (fillCircleRadius *2 + waveHeight); });
    const waveGroup = gaugeGroup.append("defs")
        .append("clipPath")
        .attr("id", "clipWave" + elementId);
    const wave = waveGroup.append("path")
        .datum(data)
        .attr("d", clipArea)
        .attr("T", 0);

    // The inner circle with the clipping wave attached.
    const fillCircleGroup = gaugeGroup.append("g")
        .attr("clip-path", "url(" + location.href + "#clipWave" + elementId + ")");
    
    fillCircleGroup.append("circle")
        .attr("cx", radius)
        .attr("cy", radius)
        .attr("r", fillCircleRadius)
        .style("fill", config.waveColor);

    // Text where the wave does overlap.
    fillCircleGroup.append("text")
        .text(format(textStartValue))
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.waveTextColor)
        .attr('transform','translate(' + radius + ',' + textRiseScaleY(config.textVertPosition) + ')');

    // Make the value count up.
    if (config.valueCountUp) {
        gaugeGroup.selectAll("text.liquidFillGaugeText").transition()
            .duration(config.waveRiseTime)
            .tween("text", function(d) {
                var that = d3.select(this)
                var i = d3.interpolateNumber(that.text().replace("%", ""), textFinalValue);
                return function(t) { that.text(format(i(t)) + percentText); };
            });
    }

    // Make the wave rise. wave and waveGroup are separate so that horizontal and vertical movement
    // can be controlled independently.
    const waveGroupXPosition = fillCircleMargin + fillCircleRadius * 2 - waveClipWidth;

    if (config.waveRise) {
        waveGroup.attr('transform','translate(' + waveGroupXPosition + ',' + waveRiseScale(0) + ')')
            .transition()
            .duration(config.waveRiseTime)
            .attr('transform','translate(' + waveGroupXPosition + ',' + waveRiseScale(fillPercent) + ')')
            .on("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.
    } else {
        waveGroup.attr('transform','translate(' + waveGroupXPosition + ',' + waveRiseScale(fillPercent) + ')');
    }

    if(config.waveAnimate) animateWave();

    function animateWave() {
        wave.attr('transform','translate(' + waveAnimateScale(wave.attr('T')) + ',0)');
        wave.transition()
            .duration(config.waveAnimateTime * (1 - wave.attr('T')))
            .ease(d3.easeLinear)
            .attr('transform','translate(' + waveAnimateScale(1) + ',0)')
            .attr('T', 1)
            .on('end', function() {
                wave.attr('T', 0);
                if (config.waveAnimate) animateWave(config.waveAnimateTime);
            });
    }

    function GaugeUpdater() {
        this.setWaveAnimate = function(value) {
            // Note: must call update after setting value
            config.waveAnimate = value;
        }
        this.update = function(value) {
            gaugeGroup.selectAll("text.liquidFillGaugeText").transition()
                .duration(config.waveRiseTime)
                .tween("text", function(d) {
                    var that = d3.select(this)
                    var i = d3.interpolateNumber(that.text().replace("%", ""), value);
                    return function(t) { that.text(format(i(t)) + percentText); };
                });

            var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value)) / config.maxValue;
            var waveHeight = fillCircleRadius * waveHeightScale(fillPercent * 100);
            var waveRiseScale = d3.scaleLinear()
                // 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([(fillCircleMargin + fillCircleRadius * 2 + waveHeight), (fillCircleMargin - waveHeight)])
                .domain([0,1]);
            var newHeight = waveRiseScale(fillPercent);
            var waveScaleX = d3.scaleLinear().range([0, waveClipWidth]).domain([0, 1]);
            var waveScaleY = d3.scaleLinear().range([0, waveHeight]).domain([0, 1]);
            var newClipArea;

            if (config.waveHeightScaling) {
                newClipArea = d3.area()
                    .x(function(d) { return waveScaleX(d.x); } )
                    .y0(function(d) {
                        return waveScaleY(Math.sin(
                            Math.PI * 2 * config.waveOffset * -1 + Math.PI * 2 * (1 - config.waveCount) + d.y * 2 * Math.PI));
                    })
                    .y1(function(d) { return (fillCircleRadius * 2 + waveHeight); });
            } else {
                newClipArea = clipArea;
            }

            var newWavePosition = config.waveAnimate ? waveAnimateScale(1) : 0;
            wave.transition()
                .duration(0)
                .transition()
                .duration(config.waveAnimate ? (config.waveAnimateTime * (1 - wave.attr('T'))) : config.waveRiseTime)
                .ease(d3.easeLinear)
                .attr('d', newClipArea)
                .attr('transform','translate(' + newWavePosition + ',0)')
                .attr('T','1')
                .on("end", function() {
                    if (config.waveAnimate) {
                        wave.attr('transform','translate(' + waveAnimateScale(0) + ',0)');
                        animateWave(config.waveAnimateTime);
                    }
                });

            waveGroup.transition()
                .duration(config.waveRiseTime)
                .attr('transform','translate(' + waveGroupXPosition + ',' + newHeight + ')')
        }
    }

    return new GaugeUpdater();
}

@isidoreony
Copy link

This is a very good tool. I wanted to use it for rendering water and oil levels in a tank. Oil level is on top while water is usually below. So, i am struggling to add to values to the function to render it. See attached.
image

@dpineiden
Copy link

Here's an updated version that works in d3 v5, based on Programmer-D's modification. I cleaned up the text tween function and added the update() function, along with the ability to toggle the wave animation on/off via setWaveAnimate().

/*!
 * @license Open source under BSD 2-clause (http://choosealicense.com/licenses/bsd-2-clause/)
 * Copyright (c) 2015, Curtis Bratton
 * All rights reserved.
 *
 * Liquid Fill Gauge v1.1
 */

function liquidFillGaugeDefaultSettings() {
    return {
        minValue: 0, // The gauge minimum value.
        maxValue: 100, // The gauge maximum value.

        circleThickness: 0.05, // The outer circle thickness as a percentage of it's radius.
        // The size of the gap between the outer circle and wave circle as a percentage of
        // the outer circles radius.
        circleFillGap: 0.05,
        circleColor: "#178BCA", // The color of the outer circle.
        
        waveHeight: 0.05, // The wave height as a percentage of the radius of the wave circle.
        waveCount: 1, // The number of full waves per width of the wave circle.
        waveRiseTime: 1000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height.
        waveAnimateTime: 18000, // The amount of time in milliseconds for a full wave to enter the wave circle.

        // Control if the wave should rise from 0 to it's full height, or start at it's full height.
        waveRise: 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.
        waveHeightScaling: true, 
        waveAnimate: true, // Controls if the wave scrolls or is static.
        waveColor: "#178BCA", // The color of the fill wave.
        waveOffset: 0, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave.

        // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top.
        textVertPosition: .5,
        textSize: 1, // The relative height of the text to display in the wave circle. 1 = 50%

        // If true, the displayed value counts up from 0 to it's final value upon loading.
        // If false, the final value is displayed.
        valueCountUp: true,
        displayPercent: true, // If true, a % symbol is displayed after the value.
        textColor: "#045681", // The color of the value text when the wave does not overlap it.
        waveTextColor: "#A4DBf8" // The color of the value text when the wave overlaps it.
    };
}

function loadLiquidFillGauge(elementId, value, config) {
    if (config == null) config = liquidFillGaugeDefaultSettings();

    const gauge = d3.select("#" + elementId);
    const radius = Math.min(gauge.style("width"), gauge.style("height")) / 2;

    const locationX = parseInt(gauge.style("width")) / 2 - radius;
    const locationY = parseInt(gauge.style("height")) / 2 - radius;
    const fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value)) / config.maxValue;

    let waveHeightScale = null;
    if (config.waveHeightScaling) {
        waveHeightScale = d3.scaleLinear()
            .range([0, config.waveHeight, 0])
            .domain([0, 50, 100]);
    } else {
        waveHeightScale = d3.scaleLinear()
            .range([config.waveHeight, config.waveHeight])
            .domain([0, 100]);
    }

    const textPixels = (config.textSize * radius / 2);
    const textFinalValue = parseFloat(value).toFixed(2);
    const textStartValue = config.valueCountUp ? config.minValue : textFinalValue;
    const percentText = config.displayPercent ? "%" : "";
    const circleThickness = config.circleThickness * radius;
    const circleFillGap = config.circleFillGap * radius;
    const fillCircleMargin = circleThickness + circleFillGap;
    const fillCircleRadius = radius - fillCircleMargin;
    const waveHeight = fillCircleRadius * waveHeightScale(fillPercent * 100);

    const waveLength = fillCircleRadius * 2 / config.waveCount;
    const waveClipCount = 1 + config.waveCount;
    const waveClipWidth = waveLength * waveClipCount;

    // Rounding functions so that the correct number of decimal places is always displayed
    // as the value counts up.
    const format = d3.format(".0f");

    // Data for building the clip wave area.
    const data = [];
    for (let i = 0; i <= 40 * waveClipCount; i++) {
        data.push({x: i / (40 * waveClipCount), y: (i / (40))});
    }

    // Scales for drawing the outer circle.
    const gaugeCircleX = d3.scaleLinear().range([0, 2 * Math.PI]).domain([0, 1]);
    const gaugeCircleY = d3.scaleLinear().range([0, radius]).domain([0, radius]);

    // Scales for controlling the size of the clipping path.
    const waveScaleX = d3.scaleLinear().range([0, waveClipWidth]).domain([0, 1]);
    const waveScaleY = d3.scaleLinear().range([0, waveHeight]).domain([0, 1]);

    // Scales for controlling the position of the clipping path.
    const waveRiseScale = d3.scaleLinear()
        // 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([(fillCircleMargin + fillCircleRadius * 2 + waveHeight), (fillCircleMargin - waveHeight)])
        .domain([0, 1]);

    const waveAnimateScale = d3.scaleLinear()
        .range([0, waveClipWidth - fillCircleRadius * 2]) // Push the clip area one full wave then snap back.
        .domain([0, 1]);

    // Scale for controlling the position of the text within the gauge.
    const textRiseScaleY = d3.scaleLinear()
        .range([fillCircleMargin + fillCircleRadius * 2,(fillCircleMargin + textPixels * 0.7)])
        .domain([0, 1]);

    // Center the gauge within the parent SVG.
    const gaugeGroup = gauge.append("g")
        .attr('transform','translate(' + locationX + ',' + locationY + ')');

    // Draw the outer circle.
    const gaugeCircleArc = d3.arc()
        .startAngle(gaugeCircleX(0))
        .endAngle(gaugeCircleX(1))
        .outerRadius(gaugeCircleY(radius))
        .innerRadius(gaugeCircleY(radius - circleThickness));

    gaugeGroup.append("path")
        .attr("d", gaugeCircleArc)
        .style("fill", config.circleColor)
        .attr('transform','translate(' + radius + ',' + radius + ')');

    // Text where the wave does not overlap.
    gaugeGroup.append("text")
        .text(format(textStartValue) + percentText)
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.textColor)
        .attr('transform','translate(' + radius + ',' + textRiseScaleY(config.textVertPosition) + ')');

    // The clipping wave area.
    const clipArea = d3.area()
        .x(function(d) { return waveScaleX(d.x); })
        .y0(function(d) { return waveScaleY(Math.sin(Math.PI * 2 * config.waveOffset * -1 + Math.PI * 2 * (1 - config.waveCount) + d.y * 2 * Math.PI));})
        .y1(function(d) { return (fillCircleRadius *2 + waveHeight); });
    const waveGroup = gaugeGroup.append("defs")
        .append("clipPath")
        .attr("id", "clipWave" + elementId);
    const wave = waveGroup.append("path")
        .datum(data)
        .attr("d", clipArea)
        .attr("T", 0);

    // The inner circle with the clipping wave attached.
    const fillCircleGroup = gaugeGroup.append("g")
        .attr("clip-path", "url(" + location.href + "#clipWave" + elementId + ")");
    
    fillCircleGroup.append("circle")
        .attr("cx", radius)
        .attr("cy", radius)
        .attr("r", fillCircleRadius)
        .style("fill", config.waveColor);

    // Text where the wave does overlap.
    fillCircleGroup.append("text")
        .text(format(textStartValue))
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.waveTextColor)
        .attr('transform','translate(' + radius + ',' + textRiseScaleY(config.textVertPosition) + ')');

    // Make the value count up.
    if (config.valueCountUp) {
        gaugeGroup.selectAll("text.liquidFillGaugeText").transition()
            .duration(config.waveRiseTime)
            .tween("text", function(d) {
                var that = d3.select(this)
                var i = d3.interpolateNumber(that.text().replace("%", ""), textFinalValue);
                return function(t) { that.text(format(i(t)) + percentText); };
            });
    }

    // Make the wave rise. wave and waveGroup are separate so that horizontal and vertical movement
    // can be controlled independently.
    const waveGroupXPosition = fillCircleMargin + fillCircleRadius * 2 - waveClipWidth;

    if (config.waveRise) {
        waveGroup.attr('transform','translate(' + waveGroupXPosition + ',' + waveRiseScale(0) + ')')
            .transition()
            .duration(config.waveRiseTime)
            .attr('transform','translate(' + waveGroupXPosition + ',' + waveRiseScale(fillPercent) + ')')
            .on("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.
    } else {
        waveGroup.attr('transform','translate(' + waveGroupXPosition + ',' + waveRiseScale(fillPercent) + ')');
    }

    if(config.waveAnimate) animateWave();

    function animateWave() {
        wave.attr('transform','translate(' + waveAnimateScale(wave.attr('T')) + ',0)');
        wave.transition()
            .duration(config.waveAnimateTime * (1 - wave.attr('T')))
            .ease(d3.easeLinear)
            .attr('transform','translate(' + waveAnimateScale(1) + ',0)')
            .attr('T', 1)
            .on('end', function() {
                wave.attr('T', 0);
                if (config.waveAnimate) animateWave(config.waveAnimateTime);
            });
    }

    function GaugeUpdater() {
        this.setWaveAnimate = function(value) {
            // Note: must call update after setting value
            config.waveAnimate = value;
        }
        this.update = function(value) {
            gaugeGroup.selectAll("text.liquidFillGaugeText").transition()
                .duration(config.waveRiseTime)
                .tween("text", function(d) {
                    var that = d3.select(this)
                    var i = d3.interpolateNumber(that.text().replace("%", ""), value);
                    return function(t) { that.text(format(i(t)) + percentText); };
                });

            var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value)) / config.maxValue;
            var waveHeight = fillCircleRadius * waveHeightScale(fillPercent * 100);
            var waveRiseScale = d3.scaleLinear()
                // 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([(fillCircleMargin + fillCircleRadius * 2 + waveHeight), (fillCircleMargin - waveHeight)])
                .domain([0,1]);
            var newHeight = waveRiseScale(fillPercent);
            var waveScaleX = d3.scaleLinear().range([0, waveClipWidth]).domain([0, 1]);
            var waveScaleY = d3.scaleLinear().range([0, waveHeight]).domain([0, 1]);
            var newClipArea;

            if (config.waveHeightScaling) {
                newClipArea = d3.area()
                    .x(function(d) { return waveScaleX(d.x); } )
                    .y0(function(d) {
                        return waveScaleY(Math.sin(
                            Math.PI * 2 * config.waveOffset * -1 + Math.PI * 2 * (1 - config.waveCount) + d.y * 2 * Math.PI));
                    })
                    .y1(function(d) { return (fillCircleRadius * 2 + waveHeight); });
            } else {
                newClipArea = clipArea;
            }

            var newWavePosition = config.waveAnimate ? waveAnimateScale(1) : 0;
            wave.transition()
                .duration(0)
                .transition()
                .duration(config.waveAnimate ? (config.waveAnimateTime * (1 - wave.attr('T'))) : config.waveRiseTime)
                .ease(d3.easeLinear)
                .attr('d', newClipArea)
                .attr('transform','translate(' + newWavePosition + ',0)')
                .attr('T','1')
                .on("end", function() {
                    if (config.waveAnimate) {
                        wave.attr('transform','translate(' + waveAnimateScale(0) + ',0)');
                        animateWave(config.waveAnimateTime);
                    }
                });

            waveGroup.transition()
                .duration(config.waveRiseTime)
                .attr('transform','translate(' + waveGroupXPosition + ',' + newHeight + ')')
        }
    }

    return new GaugeUpdater();
}

@kluchrj
With this and D3, v5, i have this errors

6Error: attribute transform: Expected number, "translate(NaN,NaN)".
6Error: attribute transform: Expected number, "translate(NaN,NaN)".
12Error: attribute transform: Expected number, "translate(NaN,NaN)".
6Error: attribute d: Expected number, "MNaN,NaNLNaN,NaNL…".
6Error: attribute cx: Expected length, "NaN".
6Error: attribute cy: Expected length, "NaN".
6Error: attribute r: Expected length, "NaN".
6Error: attribute transform: Expected number, "translate(NaN,NaN)".
188Error: attribute transform: Expected number, "translate(NaN,0)".
3d3.v5.min.js:2 Error: attribute transform: Expected number, "translate(NaN,0)".
(anonymous) @ d3.v5.min.js:2

@dpineiden
Copy link

Add this (the new version gives style values in px) (firefox-chrome)

let w=gauge.style("width").replace('px','');
let h=gauge.style("height").replace('px','');

@dpineiden
Copy link

@iapyeh
Copy link

iapyeh commented Jan 23, 2020

Here is an example of d3 v5 in style of svelte component based on original copy.
By passing a config object, waveColor and circleColor can also be changed by calling updater.
If your gauge has chance to be hidden, you can set waveAnimate to false to stop the animation.
Then set it to true and call updater again to bring back animation when the gauge is visible again.

<script context="module">
// Please note,  I believe the arrangement of this structure is not the best practice in Svelte, 
// you might need to modify to fit into your context.

export const liquidFillGaugeDefaultSettings = () =>{
    return {
        minValue: 0, // The gauge minimum value.
        maxValue: 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: "#17ffCA", // The color of the outer circle.
        waveHeight: 0.05, // The wave height as a percentage of the radius of the wave circle.
        waveCount: 1, // The number of full waves per width of the wave circle.
        waveRiseTime: 1000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height.
        waveAnimateTime: 1800, // The amount of time in milliseconds for a full wave to enter 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.
        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.
        waveAnimate: true, // Controls if the wave scrolls or is static.
        waveColor: "#17ffCA", // The color of the fill wave.
        waveOffset: 0, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave.
        textVertPosition: .5, // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top.
        textSize: 1, // The relative height of the text to display in the wave circle. 1 = 50%
        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.
        displayPercent: true, // If true, a % symbol is displayed after the value.
        textColor: "#045681", // The color of the value text when the wave does not overlap it.
        waveTextColor: 'rgb(24, 164, 237)' // The color of the value text when the wave overlaps it.
    };
}

</script>
<script>
import {onMount} from "svelte"
export let config

let box;
let updater

onMount(async()=>{
    if (!config) {
        config = liquidFillGaugeDefaultSettings()
    }
    updater = loadLiquidFillGauge(80,config)
    
    /*
    // You may subscribe to store to call updater here.
    // For example, suppose count is the example in svelte's tutorial (https://svelte.dev/tutorial/auto-subscriptions)
    // See "Usage Example" below for more.

    count.subscribe((newValue) =>{
        updater(newValue)
    })    
    */
})

/*!
 * @license Open source under BSD 2-clause (http://choosealicense.com/licenses/bsd-2-clause/)
 * Copyright (c) 2015, Curtis Bratton
 * All rights reserved.
 *
 * Liquid Fill Gauge v1.1
 */

function loadLiquidFillGauge(value, config) {
    if(config == null) config = liquidFillGaugeDefaultSettings();
    let elementId = '_' + new Date().getTime()
    let container = '.'+box.className.replace(/\s+/g,'.')
    let svg = d3.select(container)
        .append('svg:svg')
            //.attr('class', 'liqugauge')
            .attr('width', 200)
            .attr('height', 200);

    var gauge = svg

    var radius = Math.min(parseInt(gauge.style("width")), parseInt(gauge.style("height")))/2;
    var locationX = parseInt(gauge.style("width"))/2 - radius;
    var locationY = parseInt(gauge.style("height"))/2 - radius;
    var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue;

    var waveHeightScale;
    if(config.waveHeightScaling){
        waveHeightScale = d3.scaleLinear()
            .range([0,config.waveHeight,0])
            .domain([0,50,100]);
    } else {
        waveHeightScale = d3.scaleLinear()
            .range([config.waveHeight,config.waveHeight])
            .domain([0,100]);
    }

    var textPixels = (config.textSize*radius/2);
    var textFinalValue = parseFloat(value).toFixed(2);
    var textStartValue = config.valueCountUp?config.minValue:textFinalValue;
    var percentText = config.displayPercent?"%":"";
    var circleThickness = config.circleThickness * radius;
    var circleFillGap = config.circleFillGap * radius;
    var fillCircleMargin = circleThickness + circleFillGap;
    var fillCircleRadius = radius - fillCircleMargin;
    var waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100);

    var waveLength = fillCircleRadius*2/config.waveCount;
    var waveClipCount = 1+config.waveCount;
    var waveClipWidth = waveLength*waveClipCount;

    // Rounding functions so that the correct number of decimal places is always displayed as the value counts up.
    var textRounder = function(value){ return Math.round(value); };
    if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
        textRounder = function(value){ return parseFloat(value).toFixed(1); };
    }
    if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
        textRounder = function(value){ return parseFloat(value).toFixed(2); };
    }

    // Data for building the clip wave area.
    var data = [];
    for(var i = 0; i <= 40*waveClipCount; i++){
        data.push({x: i/(40*waveClipCount), y: (i/(40))});
    }

    // Scales for drawing the outer circle.
    var gaugeCircleX = d3.scaleLinear().range([0,2*Math.PI]).domain([0,1]);
    var gaugeCircleY = d3.scaleLinear().range([0,radius]).domain([0,radius]);

    // Scales for controlling the size of the clipping path.
    var waveScaleX = d3.scaleLinear().range([0,waveClipWidth]).domain([0,1]);
    var waveScaleY = d3.scaleLinear().range([0,waveHeight]).domain([0,1]);

    // Scales for controlling the position of the clipping path.
    var waveRiseScale = d3.scaleLinear()
        // 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([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)])
        .domain([0,1]);
    var waveAnimateScale = d3.scaleLinear()
        .range([0, waveClipWidth-fillCircleRadius*2]) // Push the clip area one full wave then snap back.
        .domain([0,1]);

    // Scale for controlling the position of the text within the gauge.
    var textRiseScaleY = d3.scaleLinear()
        .range([fillCircleMargin+fillCircleRadius*2,(fillCircleMargin+textPixels*0.7)])
        .domain([0,1]);

    // Center the gauge within the parent SVG.
    var gaugeGroup = gauge.append("g")
        .attr('transform','translate('+locationX+','+locationY+')');
    // Draw the outer circle.
    var gaugeCircleArc = d3.arc()
        .startAngle(gaugeCircleX(0))
        .endAngle(gaugeCircleX(1))
        .outerRadius(gaugeCircleY(radius))
        .innerRadius(gaugeCircleY(radius-circleThickness));
    gaugeGroup.append("path")
        .attr("d", gaugeCircleArc)
        .attr('class','gaugeCircle')
        .style("fill", config.circleColor)
        .attr('transform','translate('+radius+','+radius+')');

    // Text where the wave does not overlap.
    var text1 = gaugeGroup.append("text")
        .text(textRounder(textStartValue) + percentText)
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.textColor)
        .attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')');

    // The clipping wave area.
    var clipArea = d3.area()
        .x(function(d) { return waveScaleX(d.x); } )
        .y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} )
        .y1(function(d) { return (fillCircleRadius*2 + waveHeight); } );

    var waveGroup = gaugeGroup.append("defs")
        .append("clipPath")
        .attr("id", "clipWave" + elementId);
    var wave = waveGroup.append("path")
        .datum(data)
        .attr("d", clipArea)
        .attr("T", 0);

    // The inner circle with the clipping wave attached.
    var fillCircleGroup = gaugeGroup.append("g")
        .attr("clip-path", "url(#clipWave" + elementId + ")");
    fillCircleGroup.append("circle")
        .attr("cx", radius)
        .attr("cy", radius)
        .attr("r", fillCircleRadius)
        .style("fill", config.waveColor);

    // Text where the wave does overlap.
    var text2 = fillCircleGroup.append("text")
        .text(textRounder(textStartValue) + percentText)
        .attr("class", "liquidFillGaugeText")
        .attr("text-anchor", "middle")
        .attr("font-size", textPixels + "px")
        .style("fill", config.waveTextColor)
        .attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')');

    // Make the value count up.
    if(config.valueCountUp){
        var textTween = function(){
            var i = d3.interpolate(this.textContent, textFinalValue);
            return function(t) { this.textContent = textRounder(i(t)) + percentText; }
        };
        text1.transition()
            .duration(config.waveRiseTime)
            .tween("text", textTween);
        text2.transition()
            .duration(config.waveRiseTime)
            .tween("text", textTween);
    }
    // Make the wave rise. wave and waveGroup are separate so that horizontal and vertical movement can be controlled independently.
    var waveGroupXPosition = fillCircleMargin+fillCircleRadius*2-waveClipWidth;
    if(config.waveRise){
        waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(0)+')')
            .transition()
            .duration(config.waveRiseTime)
            .attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')')
            .on("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.
    } else {
        waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')');
    }

    if(config.waveAnimate) animateWave();

    function animateWave() {
        wave.attr('transform','translate('+waveAnimateScale(wave.attr('T'))+',0)');
        wave.transition()
            .duration(config.waveAnimateTime * (1-wave.attr('T')))
            .ease(d3.easeLinear)
            .attr('transform','translate('+waveAnimateScale(1)+',0)')
            .attr('T', 1)
            .on('end', function(){
                wave.attr('T', 0);
                if (config.waveAnimate) animateWave(config.waveAnimateTime);
            })
    }

    function GaugeUpdater(){
        this.update = function(value){
            var newFinalValue = parseFloat(value).toFixed(2);
            var textRounderUpdater = function(value){ return Math.round(value); };
            if(parseFloat(newFinalValue) != parseFloat(textRounderUpdater(newFinalValue))){
                textRounderUpdater = function(value){ return parseFloat(value).toFixed(1); };
            }
            if(parseFloat(newFinalValue) != parseFloat(textRounderUpdater(newFinalValue))){
                textRounderUpdater = function(value){ return parseFloat(value).toFixed(2); };
            }

            var textTween = function(){
                var i = d3.interpolate(this.textContent, parseFloat(value).toFixed(2));
                return function(t) { this.textContent = textRounderUpdater(i(t)) + percentText; }
            };

            text1.transition()
                .duration(config.waveRiseTime)
                .tween("text", textTween);
            text2.transition()
                .duration(config.waveRiseTime)
                .tween("text", textTween);

            var fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue;
            var waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100);
            var waveRiseScale = d3.scaleLinear()
                // 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([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)])
                .domain([0,1]);
            var newHeight = waveRiseScale(fillPercent);
            var waveScaleX = d3.scaleLinear().range([0,waveClipWidth]).domain([0,1]);
            var waveScaleY = d3.scaleLinear().range([0,waveHeight]).domain([0,1]);
            var newClipArea;
            if(config.waveHeightScaling){
                newClipArea = d3.area()
                    .x(function(d) { return waveScaleX(d.x); } )
                    .y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} )
                    .y1(function(d) { return (fillCircleRadius*2 + waveHeight); } );
            } else {
                newClipArea = clipArea;
            }
            var newWavePosition = config.waveAnimate?waveAnimateScale(1):0;
            wave.transition()
                .duration(0)
                .transition()
                .duration(config.waveAnimate?(config.waveAnimateTime * (1-wave.attr('T'))):(config.waveRiseTime))
                .ease(d3.easeLinear)
                .attr('d', newClipArea)
                .attr('transform','translate('+newWavePosition+',0)')
                .attr('T','1')
                .on("end", function(){
                    if(config.waveAnimate){
                        wave.attr('transform','translate('+waveAnimateScale(0)+',0)');
                        animateWave(config.waveAnimateTime);
                    }
                });
            waveGroup.transition()
                .duration(config.waveRiseTime)
                .attr('transform','translate('+waveGroupXPosition+','+newHeight+')')
            
            // refresh waveColor
            fillCircleGroup.select('circle').style('fill',config.waveColor)
            gaugeGroup.select('.gaugeCircle').style('fill',config.circleColor)
        }
    }

    return new GaugeUpdater();
}
</script>
<div bind:this={box} class="LiquidFillGauge"></div>

Usage Example:

<script>
// liquidfillgauge.svelte is script of above block
import LiquidFillGauge, {liquidFillGaugeDefaultSettings} from './liquidfillgauge.svelte'
import { count } from './stores.js';

let config = liquidFillGaugeDefaultSettings()
config.maxValue = 120

 new LiquidFillGauge({
            target: box.querySelector(chartSelector),
            props:{
                config:config
            }
        })

setTimeout(()=>{
    config.waveColor = '#ff99ff'
   // suppose you has subscribe "count" in your  liquidfillgauge.svelte
    count.set(90)
})

@Iskezhanov
Copy link

hello, could you help with how to implement it to qlik sense as extension? any kind of template where I should put js code?

@shahsohil25
Copy link

How to display percentage in decimal values in this chart ?

@harshilishere
Copy link

Anybody can please help me how to use this in vue js? I tried a lot but was unable to make a component out of this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment