From 2690abcfed8710392c0a38dc7a883718351fa95f Mon Sep 17 00:00:00 2001 From: Jan Grewe <jan@faked.org> Date: Tue, 16 Nov 2021 13:19:51 +0100 Subject: [PATCH] make UI nicer with charts and gauge TODO: - BME680 state --- data/airqmon.css | 11 + data/airqmon.js | 162 +++++- data/gauge.min.js | 1 + data/gauge.min.js.gz | Bin 0 -> 5046 bytes data/index.html | 15 +- data/smoothie.js | 1112 ++++++++++++++++++++++++++++++++++++++++++ data/smoothie.js.gz | Bin 0 -> 12011 bytes src/main.cpp | 40 +- 8 files changed, 1311 insertions(+), 30 deletions(-) create mode 100644 data/gauge.min.js create mode 100644 data/gauge.min.js.gz create mode 100644 data/smoothie.js create mode 100644 data/smoothie.js.gz diff --git a/data/airqmon.css b/data/airqmon.css index fdc6b04..9d23b2c 100644 --- a/data/airqmon.css +++ b/data/airqmon.css @@ -3,6 +3,17 @@ h1 { letter-spacing: 3px; } +.sensorName, +.sensorValue { + font-size: 1.2rem; + font-weight: bolder; +} + +.chart canvas { + width: 100%; + height: 100px; +} + #wsSpinner { position: fixed; bottom: 15px; diff --git a/data/airqmon.js b/data/airqmon.js index c14b5be..5e6fada 100644 --- a/data/airqmon.js +++ b/data/airqmon.js @@ -1,9 +1,108 @@ +var gauge = new Gauge($('#gauge')[0]); var websock; +var timeseries = {}; +var charts = {}; + +var gaugeModes = { + staticZones: [ + {strokeStyle: "#00E400", min: 0, max: 50}, // Green + {strokeStyle: "#FFFF00", min: 51, max: 100}, // Yellow + {strokeStyle: "#FF7E00", min: 101, max: 150}, // Orange + {strokeStyle: "#FF0000", min: 151, max: 200}, // Red + {strokeStyle: "#8F3F97", min: 201, max: 300}, // Violet + {strokeStyle: "#7E0023", min: 301, max: 500} // Brown + ], + percentColors: [ + [0.0, "#00E400" ], + [0.1, "#FFFF00" ], + [0.2, "#FF7E00"], + [0.3, "#FF0000"], + [0.4, "#8F3F97"], + [0.6, "#7E0023"] + ] +} + + +var metrics = { + "iaq": {name: "IAQ", has_accuracy: true, decimals: 0}, + "siaq": {name: "Static IAQ", has_accuracy: true, decimals: 0}, + "eco2": {name: "CO2", unit: " ppm", has_accuracy: true, decimals: 0}, + "bvoc": {name: "Breath VOC", unit: " ppm", has_accuracy: true, decimals: 3}, + "pm1": {name: "PM ≤1.0µm", unit: " μg/m³"}, + "pm2p5": {name: "PM ≤2.5µm", unit: " μg/m³"}, + "pm10": {name: "PM ≤10µm", unit: " μg/m³"}, + "nc0p3": {name: "NC ≥0.3µm", unit: "/100cm³"}, + "nc1": {name: "NC ≥0.5µm", unit: "/100cm³"}, + "nc2p5": {name: "NC ≥1.0µm", unit: "/100cm³"}, + "nc5": {name: "NC ≥5.0µm", unit: "/100cm³"}, + "nc10": {name: "NC ≥10µm", unit: "/100cm³"}, + "temperature": {name: "Temperature", unit: " °C", decimals: 1}, + "humidity": {name: "Humidity", unit: "%", decimals: 1}, + "pressure": {name: "Pressure", unit: " hPa", decimals: 0} +} + +var accuracyStatus = { + 0: {name: "Stabilizing", class: "danger"}, + 1: {name: "Uncertain", class: "warning"}, + 2: {name: "Calibrating", class: "primary"}, + 3: {name: "Calibrated", class: "success"} +} + +var gaugeOptions = { + angle: 0, // The span of the gauge arc + lineWidth: 0.45, // The line thickness + radiusScale: 1, // Relative radius + pointer: { + length: 0.5, // // Relative to gauge radius + strokeWidth: 0.100, // The thickness + color: '#000000' // Fill color + }, + colorStart: '#6F6EA0', // Colors + colorStop: '#C0C0DB', // just experiment with them + strokeColor: '#EEEEEE', // to see which ones work best for you + generateGradient: true, + highDpiSupport: true, // High resolution support + staticLabels: { + font: "10px sans-serif", // Specifies font + labels: [50, 100, 150, 200, 300, 500], // Print labels at these values + color: "#000000", // Optional: Label text color + fractionDigits: 0 // Optional: Numerical precision. 0=round off. + } +}; + +var chartOptions = { + responsive: true, + millisPerPixel: 100, + grid: { + fillStyle:'rgba(255,255,255, 0.75)', + strokeStyle:'rgba(128,128,128, 0.10)', + verticalSections: 5 + }, + labels: { + fillStyle:'rgba(0,0,0,0.75)' + } +} + +var lineOptions = { + lineWidth: 2, + strokeStyle:'#00ff00' +} $(document).ready(function(event) { - startWebsocket(); + setupGauge('staticZones'); + startWebsocket(); }); +function setupGauge(mode) { + var objColor = {} + objColor[mode] = gaugeModes[mode]; + var opt = Object.assign(gaugeOptions, objColor); + gauge.setOptions(opt); + gauge.setMinValue(0); + gauge.maxValue = 500; + gauge.animationSpeed = 128; +} + function startWebsocket() { websock = new WebSocket('ws://' + window.location.hostname + ':81/'); websock.onopen = function(evt) { @@ -21,13 +120,70 @@ function startWebsocket() { }; websock.onmessage = function(evt) { data = JSON.parse(evt.data); + //console.log(data); handleWebsocketMessage(data); }; } function handleWebsocketMessage(data) { - console.log(data); - $('#data').text(JSON.stringify(data, null, 2)); + if ('siaq' in data) { + gauge.set(data.siaq); + } + $.each(metrics, function(name, sensor) { + if ('has_accuracy' in metrics[name]) { + updateMetric(name, data[name], data[name+'_acc']); + } else { + updateMetric(name, data[name], false); + } + }); +} + +function updateMetric(name, value, accuracy) { + var numericValue = value; + if ('decimals' in metrics[name]) { + value = value.toFixed(metrics[name].decimals); + } + if ('unit' in metrics[name]) { + value += metrics[name].unit; + } + if (accuracy !== false) { + value = '<span class="badge bg-'+accuracyStatus[accuracy].class+'">'+accuracyStatus[accuracy].name+'</span> '+value; + } + if(!$('#metric_'+name).length && name in metrics) { + timeseries[name] = new TimeSeries(); + timeseries[name].append(new Date().getTime(), numericValue); + var metric = $('<li class="list-group-item list-group-item-action" id="metric_'+name+'">'+ + '<div class="row my-2 rowMetric">'+ + '<div class="d-flex w-100 justify-content-between">'+ + '<div class="sensorName">'+metrics[name].name+'</div>'+ + '<div class="sensorValue">'+value+'</div>'+ + '</div>'+ + '</div>'+ + '</li>)'); + metric.find('.rowMetric').on('click', function() { + toggleChart(name); + }); + $('#metrics').append(metric); + } else { + $('#metric_'+name+' .sensorValue').html(value); + timeseries[name].append(new Date().getTime(), numericValue); + } +} + +function toggleChart(name) { + if(!$('#chart_'+name).length) { + charts[name] = new SmoothieChart(chartOptions); + charts[name].addTimeSeries(timeseries[name], lineOptions); + $('#metric_'+name).append($('<div class="row rowChart">'+ + '<div class="chart my-2"><canvas id="chart_'+name+'"></canvas></div>'+ + '</div>')); + charts[name].streamTo(document.getElementById("chart_"+name), 1000); + } else { + charts[name].removeTimeSeries(timeseries[name]); + charts[name].stop(); + delete charts[name]; + $('#metric_'+name+' .rowChart').remove(); + } } jQuery.fn.visible = function() { diff --git a/data/gauge.min.js b/data/gauge.min.js new file mode 100644 index 0000000..1b77c1c --- /dev/null +++ b/data/gauge.min.js @@ -0,0 +1 @@ +(function(){function t(t,i){for(var e in i)m.call(i,e)&&(t[e]=i[e]);function s(){this.constructor=t}return s.prototype=i.prototype,t.prototype=new s,t.__super__=i.prototype,t}var i,e,s,n,o,p,a,h,r,l,g,c,u,d=[].slice,m={}.hasOwnProperty,x=[].indexOf||function(t){for(var i=0,e=this.length;i<e;i++)if(i in this&&this[i]===t)return i;return-1};function f(t,i){null==t&&(t=!0),this.clear=null==i||i,t&&AnimationUpdater.add(this)}function v(){return v.__super__.constructor.apply(this,arguments)}function y(t,i){this.el=t,this.fractionDigits=i}function V(t,i){if(this.elem=t,this.text=null!=i&&i,V.__super__.constructor.call(this),void 0===this.elem)throw new Error("The element isn't defined.");this.value=1*this.elem.innerHTML,this.text&&(this.value=0)}function w(t){if(this.gauge=t,void 0===this.gauge)throw new Error("The element isn't defined.");this.ctx=this.gauge.ctx,this.canvas=this.gauge.canvas,w.__super__.constructor.call(this,!1,!1),this.setOptions()}function S(t){this.elem=t}function M(t){var i,e;this.canvas=t,M.__super__.constructor.call(this),this.percentColors=null,"undefined"!=typeof G_vmlCanvasManager&&(this.canvas=window.G_vmlCanvasManager.initElement(this.canvas)),this.ctx=this.canvas.getContext("2d"),i=this.canvas.clientHeight,e=this.canvas.clientWidth,this.canvas.height=i,this.canvas.width=e,this.gp=[new p(this)],this.setOptions()}function C(t){this.canvas=t,C.__super__.constructor.call(this),"undefined"!=typeof G_vmlCanvasManager&&(this.canvas=window.G_vmlCanvasManager.initElement(this.canvas)),this.ctx=this.canvas.getContext("2d"),this.setOptions(),this.render()}function _(){return _.__super__.constructor.apply(this,arguments)}!function(){var s,n,t,o,i,e,a;for(t=0,i=(a=["ms","moz","webkit","o"]).length;t<i&&(e=a[t],!window.requestAnimationFrame);t++)window.requestAnimationFrame=window[e+"RequestAnimationFrame"],window.cancelAnimationFrame=window[e+"CancelAnimationFrame"]||window[e+"CancelRequestAnimationFrame"];s=null,o=0,n={},requestAnimationFrame?window.cancelAnimationFrame||(s=window.requestAnimationFrame,window.requestAnimationFrame=function(t,i){var e;return e=++o,s(function(){if(!n[e])return t()},i),e},window.cancelAnimationFrame=function(t){return n[t]=!0}):(window.requestAnimationFrame=function(t,i){var e,s,n,o;return e=(new Date).getTime(),o=Math.max(0,16-(e-n)),s=window.setTimeout(function(){return t(e+o)},o),n=e+o,s},window.cancelAnimationFrame=function(t){return clearTimeout(t)})}(),u=function(t){var i,e;for(t-=3600*(i=Math.floor(t/3600))+60*(e=Math.floor((t-3600*i)/60)),t+="",e+="";e.length<2;)e="0"+e;for(;t.length<2;)t="0"+t;return(i=i?i+":":"")+e+":"+t},g=function(){var t,i,e;return e=(i=1<=arguments.length?d.call(arguments,0):[])[0],t=i[1],r(e.toFixed(t))},c=function(t,i){var e,s,n;for(e in s={},t)m.call(t,e)&&(n=t[e],s[e]=n);for(e in i)m.call(i,e)&&(n=i[e],s[e]=n);return s},r=function(t){var i,e,s,n;for(s=(e=(t+="").split("."))[0],n="",1<e.length&&(n="."+e[1]),i=/(\d+)(\d{3})/;i.test(s);)s=s.replace(i,"$1,$2");return s+n},l=function(t){return"#"===t.charAt(0)?t.substring(1,7):t},f.prototype.animationSpeed=32,f.prototype.update=function(t){var i;return null==t&&(t=!1),!(!t&&this.displayedValue===this.value||(this.ctx&&this.clear&&this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height),i=this.value-this.displayedValue,Math.abs(i/this.animationSpeed)<=.001?this.displayedValue=this.value:this.displayedValue=this.displayedValue+i/this.animationSpeed,this.render(),0))},t(v,h=f),v.prototype.displayScale=1,v.prototype.forceUpdate=!0,v.prototype.setTextField=function(t,i){return this.textField=t instanceof a?t:new a(t,i)},v.prototype.setMinValue=function(t,i){var e,s,n,o,a;if(this.minValue=t,null==i&&(i=!0),i){for(this.displayedValue=this.minValue,a=[],s=0,n=(o=this.gp||[]).length;s<n;s++)e=o[s],a.push(e.displayedValue=this.minValue);return a}},v.prototype.setOptions=function(t){return null==t&&(t=null),this.options=c(this.options,t),this.textField&&(this.textField.el.style.fontSize=t.fontSize+"px"),.5<this.options.angle&&(this.options.angle=.5),this.configDisplayScale(),this},v.prototype.configDisplayScale=function(){var t,i,e,s,n;return s=this.displayScale,!1===this.options.highDpiSupport?delete this.displayScale:(i=window.devicePixelRatio||1,t=this.ctx.webkitBackingStorePixelRatio||this.ctx.mozBackingStorePixelRatio||this.ctx.msBackingStorePixelRatio||this.ctx.oBackingStorePixelRatio||this.ctx.backingStorePixelRatio||1,this.displayScale=i/t),this.displayScale!==s&&(n=this.canvas.G__width||this.canvas.width,e=this.canvas.G__height||this.canvas.height,this.canvas.width=n*this.displayScale,this.canvas.height=e*this.displayScale,this.canvas.style.width=n+"px",this.canvas.style.height=e+"px",this.canvas.G__width=n,this.canvas.G__height=e),this},v.prototype.parseValue=function(t){return t=parseFloat(t)||Number(t),isFinite(t)?t:0},s=v,y.prototype.render=function(t){return this.el.innerHTML=g(t.displayedValue,this.fractionDigits)},a=y,t(V,h),V.prototype.displayedValue=0,V.prototype.value=0,V.prototype.setVal=function(t){return this.value=1*t},V.prototype.render=function(){var t;return t=this.text?u(this.displayedValue.toFixed(0)):r(g(this.displayedValue)),this.elem.innerHTML=t},i=V,t(w,h),w.prototype.displayedValue=0,w.prototype.value=0,w.prototype.options={strokeWidth:.035,length:.1,color:"#000000",iconPath:null,iconScale:1,iconAngle:0},w.prototype.img=null,w.prototype.setOptions=function(t){if(null==t&&(t=null),this.options=c(this.options,t),this.length=2*this.gauge.radius*this.gauge.options.radiusScale*this.options.length,this.strokeWidth=this.canvas.height*this.options.strokeWidth,this.maxValue=this.gauge.maxValue,this.minValue=this.gauge.minValue,this.animationSpeed=this.gauge.animationSpeed,this.options.angle=this.gauge.options.angle,this.options.iconPath)return this.img=new Image,this.img.src=this.options.iconPath},w.prototype.render=function(){var t,i,e,s,n,o,a,h,r;if(t=this.gauge.getAngle.call(this,this.displayedValue),h=Math.round(this.length*Math.cos(t)),r=Math.round(this.length*Math.sin(t)),o=Math.round(this.strokeWidth*Math.cos(t-Math.PI/2)),a=Math.round(this.strokeWidth*Math.sin(t-Math.PI/2)),i=Math.round(this.strokeWidth*Math.cos(t+Math.PI/2)),e=Math.round(this.strokeWidth*Math.sin(t+Math.PI/2)),this.ctx.beginPath(),this.ctx.fillStyle=this.options.color,this.ctx.arc(0,0,this.strokeWidth,0,2*Math.PI,!1),this.ctx.fill(),this.ctx.beginPath(),this.ctx.moveTo(o,a),this.ctx.lineTo(h,r),this.ctx.lineTo(i,e),this.ctx.fill(),this.img)return s=Math.round(this.img.width*this.options.iconScale),n=Math.round(this.img.height*this.options.iconScale),this.ctx.save(),this.ctx.translate(h,r),this.ctx.rotate(t+Math.PI/180*(90+this.options.iconAngle)),this.ctx.drawImage(this.img,-s/2,-n/2,s,n),this.ctx.restore()},p=w,S.prototype.updateValues=function(t){return this.value=t[0],this.maxValue=t[1],this.avgValue=t[2],this.render()},S.prototype.render=function(){var t,i;return this.textField&&this.textField.text(g(this.value)),0===this.maxValue&&(this.maxValue=2*this.avgValue),i=this.value/this.maxValue*100,t=this.avgValue/this.maxValue*100,$(".bar-value",this.elem).css({width:i+"%"}),$(".typical-value",this.elem).css({width:t+"%"})},t(M,s),M.prototype.elem=null,M.prototype.value=[20],M.prototype.maxValue=80,M.prototype.minValue=0,M.prototype.displayedAngle=0,M.prototype.displayedValue=0,M.prototype.lineWidth=40,M.prototype.paddingTop=.1,M.prototype.paddingBottom=.1,M.prototype.percentColors=null,M.prototype.options={colorStart:"#6fadcf",colorStop:void 0,gradientType:0,strokeColor:"#e0e0e0",pointer:{length:.8,strokeWidth:.035,iconScale:1},angle:.15,lineWidth:.44,radiusScale:1,fontSize:40,limitMax:!1,limitMin:!1},M.prototype.setOptions=function(t){var i,e,s,n,o;for(null==t&&(t=null),M.__super__.setOptions.call(this,t),this.configPercentColors(),this.extraPadding=0,this.options.angle<0&&(n=Math.PI*(1+this.options.angle),this.extraPadding=Math.sin(n)),this.availableHeight=this.canvas.height*(1-this.paddingTop-this.paddingBottom),this.lineWidth=this.availableHeight*this.options.lineWidth,this.radius=(this.availableHeight-this.lineWidth/2)/(1+this.extraPadding),this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height),e=0,s=(o=this.gp).length;e<s;e++)(i=o[e]).setOptions(this.options.pointer),i.render();return this.render(),this},M.prototype.configPercentColors=function(){var t,i,e,s,n,o,a;if(this.percentColors=null,void 0!==this.options.percentColors){for(this.percentColors=new Array,o=[],e=s=0,n=this.options.percentColors.length-1;0<=n?s<=n:n<=s;e=0<=n?++s:--s)a=parseInt(l(this.options.percentColors[e][1]).substring(0,2),16),i=parseInt(l(this.options.percentColors[e][1]).substring(2,4),16),t=parseInt(l(this.options.percentColors[e][1]).substring(4,6),16),o.push(this.percentColors[e]={pct:this.options.percentColors[e][0],color:{r:a,g:i,b:t}});return o}},M.prototype.set=function(t){var i,e,s,n,o,a,h,r,l;for(t instanceof Array||(t=[t]),e=s=0,h=t.length-1;0<=h?s<=h:h<=s;e=0<=h?++s:--s)t[e]=this.parseValue(t[e]);if(t.length>this.gp.length)for(e=n=0,r=t.length-this.gp.length;0<=r?n<r:r<n;e=0<=r?++n:--n)(i=new p(this)).setOptions(this.options.pointer),this.gp.push(i);else t.length<this.gp.length&&(this.gp=this.gp.slice(this.gp.length-t.length));for(a=e=0,o=t.length;a<o;a++)(l=t[a])>this.maxValue?this.options.limitMax?l=this.maxValue:this.maxValue=l+1:l<this.minValue&&(this.options.limitMin?l=this.minValue:this.minValue=l-1),this.gp[e].value=l,this.gp[e++].setOptions({minValue:this.minValue,maxValue:this.maxValue,angle:this.options.angle});return this.value=Math.max(Math.min(t[t.length-1],this.maxValue),this.minValue),AnimationUpdater.add(this),AnimationUpdater.run(this.forceUpdate),this.forceUpdate=!1},M.prototype.getAngle=function(t){return(1+this.options.angle)*Math.PI+(t-this.minValue)/(this.maxValue-this.minValue)*(1-2*this.options.angle)*Math.PI},M.prototype.getColorForPercentage=function(t,i){var e,s,n,o,a,h,r;if(0===t)e=this.percentColors[0].color;else for(e=this.percentColors[this.percentColors.length-1].color,n=o=0,h=this.percentColors.length-1;0<=h?o<=h:h<=o;n=0<=h?++o:--o)if(t<=this.percentColors[n].pct){e=!0===i?(r=this.percentColors[n-1]||this.percentColors[0],s=this.percentColors[n],a=(t-r.pct)/(s.pct-r.pct),{r:Math.floor(r.color.r*(1-a)+s.color.r*a),g:Math.floor(r.color.g*(1-a)+s.color.g*a),b:Math.floor(r.color.b*(1-a)+s.color.b*a)}):this.percentColors[n].color;break}return"rgb("+[e.r,e.g,e.b].join(",")+")"},M.prototype.getColorForValue=function(t,i){var e;return e=(t-this.minValue)/(this.maxValue-this.minValue),this.getColorForPercentage(e,i)},M.prototype.renderStaticLabels=function(t,i,e,s){var n,o,a,h,r,l,p,c,u,d;for(this.ctx.save(),this.ctx.translate(i,e),l=/\d+\.?\d?/,r=(n=t.font||"10px Times").match(l)[0],c=n.slice(r.length),o=parseFloat(r)*this.displayScale,this.ctx.font=o+c,this.ctx.fillStyle=t.color||"#000000",this.ctx.textBaseline="bottom",this.ctx.textAlign="center",a=0,h=(p=t.labels).length;a<h;a++)void 0!==(d=p[a]).label?(!this.options.limitMin||d>=this.minValue)&&(!this.options.limitMax||d<=this.maxValue)&&(r=(n=d.font||t.font).match(l)[0],c=n.slice(r.length),o=parseFloat(r)*this.displayScale,this.ctx.font=o+c,u=this.getAngle(d.label)-3*Math.PI/2,this.ctx.rotate(u),this.ctx.fillText(g(d.label,t.fractionDigits),0,-s-this.lineWidth/2),this.ctx.rotate(-u)):(!this.options.limitMin||d>=this.minValue)&&(!this.options.limitMax||d<=this.maxValue)&&(u=this.getAngle(d)-3*Math.PI/2,this.ctx.rotate(u),this.ctx.fillText(g(d,t.fractionDigits),0,-s-this.lineWidth/2),this.ctx.rotate(-u));return this.ctx.restore()},M.prototype.renderTicks=function(t,i,e,s){var n,o,a,h,r,l,p,c,u,d,g,m,x,f,v,y,V,w,S,M;if(t!=={}){for(l=t.divisions||0,w=t.subDivisions||0,a=t.divColor||"#fff",f=t.subColor||"#fff",h=t.divLength||.7,y=t.subLength||.2,u=parseFloat(this.maxValue)-parseFloat(this.minValue),d=parseFloat(u)/parseFloat(t.divisions),v=parseFloat(d)/parseFloat(t.subDivisions),n=parseFloat(this.minValue),o=0+v,r=(c=u/400)*(t.divWidth||1),V=c*(t.subWidth||1),m=[],S=p=0,g=l+1;p<g;S=p+=1)this.ctx.lineWidth=this.lineWidth*h,x=this.lineWidth/2*(1-h),M=this.radius*this.options.radiusScale+x,this.ctx.strokeStyle=a,this.ctx.beginPath(),this.ctx.arc(0,0,M,this.getAngle(n-r),this.getAngle(n+r),!1),this.ctx.stroke(),o=n+v,n+=d,S!==t.divisions&&0<w?m.push(function(){var t,i,e;for(e=[],t=0,i=w-1;t<i;t+=1)this.ctx.lineWidth=this.lineWidth*y,x=this.lineWidth/2*(1-y),M=this.radius*this.options.radiusScale+x,this.ctx.strokeStyle=f,this.ctx.beginPath(),this.ctx.arc(0,0,M,this.getAngle(o-V),this.getAngle(o+V),!1),this.ctx.stroke(),e.push(o+=v);return e}.call(this)):m.push(void 0);return m}},M.prototype.render=function(){var t,i,e,s,n,o,a,h,r,l,p,c,u,d,g,m;if(g=this.canvas.width/2,e=this.canvas.height*this.paddingTop+this.availableHeight-(this.radius+this.lineWidth/2)*this.extraPadding,t=this.getAngle(this.displayedValue),this.textField&&this.textField.render(this),this.ctx.lineCap="butt",l=this.radius*this.options.radiusScale,this.options.staticLabels&&this.renderStaticLabels(this.options.staticLabels,g,e,l),this.options.staticZones)for(this.ctx.save(),this.ctx.translate(g,e),this.ctx.lineWidth=this.lineWidth,s=0,o=(p=this.options.staticZones).length;s<o;s++)r=(m=p[s]).min,this.options.limitMin&&r<this.minValue&&(r=this.minValue),h=m.max,this.options.limitMax&&h>this.maxValue&&(h=this.maxValue),d=this.radius*this.options.radiusScale,m.height&&(this.ctx.lineWidth=this.lineWidth*m.height,u=this.lineWidth/2*(m.offset||1-m.height),d=this.radius*this.options.radiusScale+u),this.ctx.strokeStyle=m.strokeStyle,this.ctx.beginPath(),this.ctx.arc(0,0,d,this.getAngle(r),this.getAngle(h),!1),this.ctx.stroke();else void 0!==this.options.customFillStyle?i=this.options.customFillStyle(this):null!==this.percentColors?i=this.getColorForValue(this.displayedValue,this.options.generateGradient):void 0!==this.options.colorStop?((i=0===this.options.gradientType?this.ctx.createRadialGradient(g,e,9,g,e,70):this.ctx.createLinearGradient(0,0,g,0)).addColorStop(0,this.options.colorStart),i.addColorStop(1,this.options.colorStop)):i=this.options.colorStart,this.ctx.strokeStyle=i,this.ctx.beginPath(),this.ctx.arc(g,e,l,(1+this.options.angle)*Math.PI,t,!1),this.ctx.lineWidth=this.lineWidth,this.ctx.stroke(),this.ctx.strokeStyle=this.options.strokeColor,this.ctx.beginPath(),this.ctx.arc(g,e,l,t,(2-this.options.angle)*Math.PI,!1),this.ctx.stroke(),this.ctx.save(),this.ctx.translate(g,e);for(this.options.renderTicks&&this.renderTicks(this.options.renderTicks,g,e,l),this.ctx.restore(),this.ctx.translate(g,e),n=0,a=(c=this.gp).length;n<a;n++)c[n].update(!0);return this.ctx.translate(-g,-e)},o=M,t(C,s),C.prototype.lineWidth=15,C.prototype.displayedValue=0,C.prototype.value=33,C.prototype.maxValue=80,C.prototype.minValue=0,C.prototype.options={lineWidth:.1,colorStart:"#6f6ea0",colorStop:"#c0c0db",strokeColor:"#eeeeee",shadowColor:"#d5d5d5",angle:.35,radiusScale:1},C.prototype.getAngle=function(t){return(1-this.options.angle)*Math.PI+(t-this.minValue)/(this.maxValue-this.minValue)*(2+this.options.angle-(1-this.options.angle))*Math.PI},C.prototype.setOptions=function(t){return null==t&&(t=null),C.__super__.setOptions.call(this,t),this.lineWidth=this.canvas.height*this.options.lineWidth,this.radius=this.options.radiusScale*(this.canvas.height/2-this.lineWidth/2),this},C.prototype.set=function(t){return this.value=this.parseValue(t),this.value>this.maxValue?this.options.limitMax?this.value=this.maxValue:this.maxValue=this.value:this.value<this.minValue&&(this.options.limitMin?this.value=this.minValue:this.minValue=this.value),AnimationUpdater.add(this),AnimationUpdater.run(this.forceUpdate),this.forceUpdate=!1},C.prototype.render=function(){var t,i,e,s;return t=this.getAngle(this.displayedValue),s=this.canvas.width/2,e=this.canvas.height/2,this.textField&&this.textField.render(this),(i=this.ctx.createRadialGradient(s,e,39,s,e,70)).addColorStop(0,this.options.colorStart),i.addColorStop(1,this.options.colorStop),this.radius,this.lineWidth,this.radius,this.lineWidth,this.ctx.strokeStyle=this.options.strokeColor,this.ctx.beginPath(),this.ctx.arc(s,e,this.radius,(1-this.options.angle)*Math.PI,(2+this.options.angle)*Math.PI,!1),this.ctx.lineWidth=this.lineWidth,this.ctx.lineCap="round",this.ctx.stroke(),this.ctx.strokeStyle=i,this.ctx.beginPath(),this.ctx.arc(s,e,this.radius,(1-this.options.angle)*Math.PI,t,!1),this.ctx.stroke()},t(_,e=C),_.prototype.strokeGradient=function(t,i,e,s){var n;return(n=this.ctx.createRadialGradient(t,i,e,t,i,s)).addColorStop(0,this.options.shadowColor),n.addColorStop(.12,this.options._orgStrokeColor),n.addColorStop(.88,this.options._orgStrokeColor),n.addColorStop(1,this.options.shadowColor),n},_.prototype.setOptions=function(t){var i,e,s,n;return null==t&&(t=null),_.__super__.setOptions.call(this,t),n=this.canvas.width/2,i=this.canvas.height/2,e=this.radius-this.lineWidth/2,s=this.radius+this.lineWidth/2,this.options._orgStrokeColor=this.options.strokeColor,this.options.strokeColor=this.strokeGradient(n,i,e,s),this},n=_,window.AnimationUpdater={elements:[],animId:null,addAll:function(t){var i,e,s,n;for(n=[],e=0,s=t.length;e<s;e++)i=t[e],n.push(AnimationUpdater.elements.push(i));return n},add:function(t){if(x.call(AnimationUpdater.elements,t)<0)return AnimationUpdater.elements.push(t)},run:function(t){var i,e,s,n,o,a,h;if(null==t&&(t=!1),isFinite(parseFloat(t))||!0===t){for(i=!0,h=[],s=e=0,o=(a=AnimationUpdater.elements).length;e<o;s=++e)a[s].update(!0===t)?i=!1:h.push(s);for(n=h.length-1;0<=n;n+=-1)s=h[n],AnimationUpdater.elements.splice(s,1);return AnimationUpdater.animId=i?null:requestAnimationFrame(AnimationUpdater.run)}if(!1===t)return!0===AnimationUpdater.animId&&cancelAnimationFrame(AnimationUpdater.animId),AnimationUpdater.animId=requestAnimationFrame(AnimationUpdater.run)}},"function"==typeof window.define&&null!=window.define.amd?define(function(){return{Gauge:o,Donut:n,BaseDonut:e,TextRenderer:a}}):"undefined"!=typeof module&&null!=module.exports?module.exports={Gauge:o,Donut:n,BaseDonut:e,TextRenderer:a}:(window.Gauge=o,window.Donut=n,window.BaseDonut=e,window.TextRenderer=a)}).call(this); \ No newline at end of file diff --git a/data/gauge.min.js.gz b/data/gauge.min.js.gz new file mode 100644 index 0000000000000000000000000000000000000000..d0010ff1ae3e134cf1375bbcb055025901489290 GIT binary patch literal 5046 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 Y*1e@tH1hPxZ~s|$3ckqP;l{uK0O@DGYXATM literal 0 HcmV?d00001 diff --git a/data/index.html b/data/index.html index 04d6855..2b9f250 100644 --- a/data/index.html +++ b/data/index.html @@ -27,14 +27,13 @@ <h1>Air Quality Monitor</h1> </div> - <div class="mb-4"> - <div class="row"> - - <div class="col-lg-10 col-12 my-4"> - <div id="data"><div> - </div> + <div class="d-flex flex-row justify-content-center mb-3"> + <canvas id="gauge"></canvas> + </div> - </div> <!-- row --> + <div class="row mx-1 mb-3"> + <div class="list-group col-12 col-lg-6 offset-lg-3" id="metrics"></div> + </div> <div class="spinner-border" role="status" id="wsSpinner"> <span class="visually-hidden">Loading...</span> @@ -44,6 +43,8 @@ <script src="jquery-3.5.1.min.js"></script> <script src="bootstrap.min.js"></script> +<script src="gauge.min.js"></script> +<script src="smoothie.js"></script> <script src="airqmon.js"></script> </body> </html> \ No newline at end of file diff --git a/data/smoothie.js b/data/smoothie.js new file mode 100644 index 0000000..ad40ed5 --- /dev/null +++ b/data/smoothie.js @@ -0,0 +1,1112 @@ +// MIT License: +// +// Copyright (c) 2010-2013, Joe Walnes +// 2013-2018, Drew Noakes +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +/** + * Smoothie Charts - http://smoothiecharts.org/ + * (c) 2010-2013, Joe Walnes + * 2013-2018, Drew Noakes + * + * v1.0: Main charting library, by Joe Walnes + * v1.1: Auto scaling of axis, by Neil Dunn + * v1.2: fps (frames per second) option, by Mathias Petterson + * v1.3: Fix for divide by zero, by Paul Nikitochkin + * v1.4: Set minimum, top-scale padding, remove timeseries, add optional timer to reset bounds, by Kelley Reynolds + * v1.5: Set default frames per second to 50... smoother. + * .start(), .stop() methods for conserving CPU, by Dmitry Vyal + * options.interpolation = 'bezier' or 'line', by Dmitry Vyal + * options.maxValue to fix scale, by Dmitry Vyal + * v1.6: minValue/maxValue will always get converted to floats, by Przemek Matylla + * v1.7: options.grid.fillStyle may be a transparent color, by Dmitry A. Shashkin + * Smooth rescaling, by Kostas Michalopoulos + * v1.8: Set max length to customize number of live points in the dataset with options.maxDataSetLength, by Krishna Narni + * v1.9: Display timestamps along the bottom, by Nick and Stev-io + * (https://groups.google.com/forum/?fromgroups#!topic/smoothie-charts/-Ywse8FCpKI%5B1-25%5D) + * Refactored by Krishna Narni, to support timestamp formatting function + * v1.10: Switch to requestAnimationFrame, removed the now obsoleted options.fps, by Gergely Imreh + * v1.11: options.grid.sharpLines option added, by @drewnoakes + * Addressed warning seen in Firefox when seriesOption.fillStyle undefined, by @drewnoakes + * v1.12: Support for horizontalLines added, by @drewnoakes + * Support for yRangeFunction callback added, by @drewnoakes + * v1.13: Fixed typo (#32), by @alnikitich + * v1.14: Timer cleared when last TimeSeries removed (#23), by @davidgaleano + * Fixed diagonal line on chart at start/end of data stream, by @drewnoakes + * v1.15: Support for npm package (#18), by @dominictarr + * Fixed broken removeTimeSeries function (#24) by @davidgaleano + * Minor performance and tidying, by @drewnoakes + * v1.16: Bug fix introduced in v1.14 relating to timer creation/clearance (#23), by @drewnoakes + * TimeSeries.append now deals with out-of-order timestamps, and can merge duplicates, by @zacwitte (#12) + * Documentation and some local variable renaming for clarity, by @drewnoakes + * v1.17: Allow control over font size (#10), by @drewnoakes + * Timestamp text won't overlap, by @drewnoakes + * v1.18: Allow control of max/min label precision, by @drewnoakes + * Added 'borderVisible' chart option, by @drewnoakes + * Allow drawing series with fill but no stroke (line), by @drewnoakes + * v1.19: Avoid unnecessary repaints, and fixed flicker in old browsers having multiple charts in document (#40), by @asbai + * v1.20: Add SmoothieChart.getTimeSeriesOptions and SmoothieChart.bringToFront functions, by @drewnoakes + * v1.21: Add 'step' interpolation mode, by @drewnoakes + * v1.22: Add support for different pixel ratios. Also add optional y limit formatters, by @copacetic + * v1.23: Fix bug introduced in v1.22 (#44), by @drewnoakes + * v1.24: Fix bug introduced in v1.23, re-adding parseFloat to y-axis formatter defaults, by @siggy_sf + * v1.25: Fix bug seen when adding a data point to TimeSeries which is older than the current data, by @Nking92 + * Draw time labels on top of series, by @comolosabia + * Add TimeSeries.clear function, by @drewnoakes + * v1.26: Add support for resizing on high device pixel ratio screens, by @copacetic + * v1.27: Fix bug introduced in v1.26 for non whole number devicePixelRatio values, by @zmbush + * v1.28: Add 'minValueScale' option, by @megawac + * Fix 'labelPos' for different size of 'minValueString' 'maxValueString', by @henryn + * v1.29: Support responsive sizing, by @drewnoakes + * v1.29.1: Include types in package, and make property optional, by @TrentHouliston + * v1.30: Fix inverted logic in devicePixelRatio support, by @scanlime + * v1.31: Support tooltips, by @Sly1024 and @drewnoakes + * v1.32: Support frame rate limit, by @dpuyosa + * v1.33: Use Date static method instead of instance, by @nnnoel + * Fix bug with tooltips when multiple charts on a page, by @jpmbiz70 + * v1.34: Add disabled option to TimeSeries, by @TechGuard (#91) + * Add nonRealtimeData option, by @annazhelt (#92, #93) + * Add showIntermediateLabels option, by @annazhelt (#94) + * Add displayDataFromPercentile option, by @annazhelt (#95) + * Fix bug when hiding tooltip element, by @ralphwetzel (#96) + * Support intermediate y-axis labels, by @beikeland (#99) + * v1.35: Fix issue with responsive mode at high DPI, by @drewnoakes (#101) + * v1.36: Add tooltipLabel to ITimeSeriesPresentationOptions. + * If tooltipLabel is present, tooltipLabel displays inside tooltip + * next to value, by @jackdesert (#102) + * Fix bug rendering issue in series fill when using scroll backwards, by @olssonfredrik + * Add title option, by @mesca + * Fix data drop stoppage by rejecting NaNs in append(), by @timdrysdale + * Allow setting interpolation per time series, by @WofWca (#123) + */ + +;(function(exports) { + + // Date.now polyfill + Date.now = Date.now || function() { return new Date().getTime(); }; + + var Util = { + extend: function() { + arguments[0] = arguments[0] || {}; + for (var i = 1; i < arguments.length; i++) + { + for (var key in arguments[i]) + { + if (arguments[i].hasOwnProperty(key)) + { + if (typeof(arguments[i][key]) === 'object') { + if (arguments[i][key] instanceof Array) { + arguments[0][key] = arguments[i][key]; + } else { + arguments[0][key] = Util.extend(arguments[0][key], arguments[i][key]); + } + } else { + arguments[0][key] = arguments[i][key]; + } + } + } + } + return arguments[0]; + }, + binarySearch: function(data, value) { + var low = 0, + high = data.length; + while (low < high) { + var mid = (low + high) >> 1; + if (value < data[mid][0]) + high = mid; + else + low = mid + 1; + } + return low; + } + }; + + /** + * Initialises a new <code>TimeSeries</code> with optional data options. + * + * Options are of the form (defaults shown): + * + * <pre> + * { + * resetBounds: true, // enables/disables automatic scaling of the y-axis + * resetBoundsInterval: 3000 // the period between scaling calculations, in millis + * } + * </pre> + * + * Presentation options for TimeSeries are specified as an argument to <code>SmoothieChart.addTimeSeries</code>. + * + * @constructor + */ + function TimeSeries(options) { + this.options = Util.extend({}, TimeSeries.defaultOptions, options); + this.disabled = false; + this.clear(); + } + + TimeSeries.defaultOptions = { + resetBoundsInterval: 3000, + resetBounds: true + }; + + /** + * Clears all data and state from this TimeSeries object. + */ + TimeSeries.prototype.clear = function() { + this.data = []; + this.maxValue = Number.NaN; // The maximum value ever seen in this TimeSeries. + this.minValue = Number.NaN; // The minimum value ever seen in this TimeSeries. + }; + + /** + * Recalculate the min/max values for this <code>TimeSeries</code> object. + * + * This causes the graph to scale itself in the y-axis. + */ + TimeSeries.prototype.resetBounds = function() { + if (this.data.length) { + // Walk through all data points, finding the min/max value + this.maxValue = this.data[0][1]; + this.minValue = this.data[0][1]; + for (var i = 1; i < this.data.length; i++) { + var value = this.data[i][1]; + if (value > this.maxValue) { + this.maxValue = value; + } + if (value < this.minValue) { + this.minValue = value; + } + } + } else { + // No data exists, so set min/max to NaN + this.maxValue = Number.NaN; + this.minValue = Number.NaN; + } + }; + + /** + * Adds a new data point to the <code>TimeSeries</code>, preserving chronological order. + * + * @param timestamp the position, in time, of this data point + * @param value the value of this data point + * @param sumRepeatedTimeStampValues if <code>timestamp</code> has an exact match in the series, this flag controls + * whether it is replaced, or the values summed (defaults to false.) + */ + TimeSeries.prototype.append = function(timestamp, value, sumRepeatedTimeStampValues) { + // Reject NaN + if (isNaN(timestamp) || isNaN(value)){ + return + } + // Rewind until we hit an older timestamp + var i = this.data.length - 1; + while (i >= 0 && this.data[i][0] > timestamp) { + i--; + } + + if (i === -1) { + // This new item is the oldest data + this.data.splice(0, 0, [timestamp, value]); + } else if (this.data.length > 0 && this.data[i][0] === timestamp) { + // Update existing values in the array + if (sumRepeatedTimeStampValues) { + // Sum this value into the existing 'bucket' + this.data[i][1] += value; + value = this.data[i][1]; + } else { + // Replace the previous value + this.data[i][1] = value; + } + } else if (i < this.data.length - 1) { + // Splice into the correct position to keep timestamps in order + this.data.splice(i + 1, 0, [timestamp, value]); + } else { + // Add to the end of the array + this.data.push([timestamp, value]); + } + + this.maxValue = isNaN(this.maxValue) ? value : Math.max(this.maxValue, value); + this.minValue = isNaN(this.minValue) ? value : Math.min(this.minValue, value); + }; + + TimeSeries.prototype.dropOldData = function(oldestValidTime, maxDataSetLength) { + // We must always keep one expired data point as we need this to draw the + // line that comes into the chart from the left, but any points prior to that can be removed. + var removeCount = 0; + while (this.data.length - removeCount >= maxDataSetLength && this.data[removeCount + 1][0] < oldestValidTime) { + removeCount++; + } + if (removeCount !== 0) { + this.data.splice(0, removeCount); + } + }; + + /** + * Initialises a new <code>SmoothieChart</code>. + * + * Options are optional, and should be of the form below. Just specify the values you + * need and the rest will be given sensible defaults as shown: + * + * <pre> + * { + * minValue: undefined, // specify to clamp the lower y-axis to a given value + * maxValue: undefined, // specify to clamp the upper y-axis to a given value + * maxValueScale: 1, // allows proportional padding to be added above the chart. for 10% padding, specify 1.1. + * minValueScale: 1, // allows proportional padding to be added below the chart. for 10% padding, specify 1.1. + * yRangeFunction: undefined, // function({min: , max: }) { return {min: , max: }; } + * scaleSmoothing: 0.125, // controls the rate at which y-value zoom animation occurs + * millisPerPixel: 20, // sets the speed at which the chart pans by + * enableDpiScaling: true, // support rendering at different DPI depending on the device + * yMinFormatter: function(min, precision) { // callback function that formats the min y value label + * return parseFloat(min).toFixed(precision); + * }, + * yMaxFormatter: function(max, precision) { // callback function that formats the max y value label + * return parseFloat(max).toFixed(precision); + * }, + * yIntermediateFormatter: function(intermediate, precision) { // callback function that formats the intermediate y value labels + * return parseFloat(intermediate).toFixed(precision); + * }, + * maxDataSetLength: 2, + * interpolation: 'bezier' // one of 'bezier', 'linear', or 'step' + * timestampFormatter: null, // optional function to format time stamps for bottom of chart + * // you may use SmoothieChart.timeFormatter, or your own: function(date) { return ''; } + * scrollBackwards: false, // reverse the scroll direction of the chart + * horizontalLines: [], // [ { value: 0, color: '#ffffff', lineWidth: 1 } ] + * grid: + * { + * fillStyle: '#000000', // the background colour of the chart + * lineWidth: 1, // the pixel width of grid lines + * strokeStyle: '#777777', // colour of grid lines + * millisPerLine: 1000, // distance between vertical grid lines + * sharpLines: false, // controls whether grid lines are 1px sharp, or softened + * verticalSections: 2, // number of vertical sections marked out by horizontal grid lines + * borderVisible: true // whether the grid lines trace the border of the chart or not + * }, + * labels + * { + * disabled: false, // enables/disables labels showing the min/max values + * fillStyle: '#ffffff', // colour for text of labels, + * fontSize: 15, + * fontFamily: 'sans-serif', + * precision: 2, + * showIntermediateLabels: false, // shows intermediate labels between min and max values along y axis + * intermediateLabelSameAxis: true, + * }, + * title + * { + * text: '', // the text to display on the left side of the chart + * fillStyle: '#ffffff', // colour for text + * fontSize: 15, + * fontFamily: 'sans-serif', + * verticalAlign: 'middle' // one of 'top', 'middle', or 'bottom' + * }, + * tooltip: false // show tooltip when mouse is over the chart + * tooltipLine: { // properties for a vertical line at the cursor position + * lineWidth: 1, + * strokeStyle: '#BBBBBB' + * }, + * tooltipFormatter: SmoothieChart.tooltipFormatter, // formatter function for tooltip text + * nonRealtimeData: false, // use time of latest data as current time + * displayDataFromPercentile: 1, // display not latest data, but data from the given percentile + * // useful when trying to see old data saved by setting a high value for maxDataSetLength + * // should be a value between 0 and 1 + * responsive: false, // whether the chart should adapt to the size of the canvas + * limitFPS: 0 // maximum frame rate the chart will render at, in FPS (zero means no limit) + * } + * </pre> + * + * @constructor + */ + function SmoothieChart(options) { + this.options = Util.extend({}, SmoothieChart.defaultChartOptions, options); + this.seriesSet = []; + this.currentValueRange = 1; + this.currentVisMinValue = 0; + this.lastRenderTimeMillis = 0; + this.lastChartTimestamp = 0; + + this.mousemove = this.mousemove.bind(this); + this.mouseout = this.mouseout.bind(this); + } + + /** Formats the HTML string content of the tooltip. */ + SmoothieChart.tooltipFormatter = function (timestamp, data) { + var timestampFormatter = this.options.timestampFormatter || SmoothieChart.timeFormatter, + lines = [timestampFormatter(new Date(timestamp))], + label; + + for (var i = 0; i < data.length; ++i) { + label = data[i].series.options.tooltipLabel || '' + if (label !== ''){ + label = label + ' '; + } + lines.push('<span style="color:' + data[i].series.options.strokeStyle + '">' + + label + + this.options.yMaxFormatter(data[i].value, this.options.labels.precision) + '</span>'); + } + + return lines.join('<br>'); + }; + + SmoothieChart.defaultChartOptions = { + millisPerPixel: 20, + enableDpiScaling: true, + yMinFormatter: function(min, precision) { + return parseFloat(min).toFixed(precision); + }, + yMaxFormatter: function(max, precision) { + return parseFloat(max).toFixed(precision); + }, + yIntermediateFormatter: function(intermediate, precision) { + return parseFloat(intermediate).toFixed(precision); + }, + maxValueScale: 1, + minValueScale: 1, + interpolation: 'bezier', + scaleSmoothing: 0.125, + maxDataSetLength: 2, + scrollBackwards: false, + displayDataFromPercentile: 1, + grid: { + fillStyle: '#000000', + strokeStyle: '#777777', + lineWidth: 1, + sharpLines: false, + millisPerLine: 1000, + verticalSections: 2, + borderVisible: true + }, + labels: { + fillStyle: '#ffffff', + disabled: false, + fontSize: 10, + fontFamily: 'monospace', + precision: 2, + showIntermediateLabels: false, + intermediateLabelSameAxis: true, + }, + title: { + text: '', + fillStyle: '#ffffff', + fontSize: 15, + fontFamily: 'monospace', + verticalAlign: 'middle' + }, + horizontalLines: [], + tooltip: false, + tooltipLine: { + lineWidth: 1, + strokeStyle: '#BBBBBB' + }, + tooltipFormatter: SmoothieChart.tooltipFormatter, + nonRealtimeData: false, + responsive: false, + limitFPS: 0 + }; + + // Based on http://inspirit.github.com/jsfeat/js/compatibility.js + SmoothieChart.AnimateCompatibility = (function() { + var requestAnimationFrame = function(callback, element) { + var requestAnimationFrame = + window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function(callback) { + return window.setTimeout(function() { + callback(Date.now()); + }, 16); + }; + return requestAnimationFrame.call(window, callback, element); + }, + cancelAnimationFrame = function(id) { + var cancelAnimationFrame = + window.cancelAnimationFrame || + function(id) { + clearTimeout(id); + }; + return cancelAnimationFrame.call(window, id); + }; + + return { + requestAnimationFrame: requestAnimationFrame, + cancelAnimationFrame: cancelAnimationFrame + }; + })(); + + SmoothieChart.defaultSeriesPresentationOptions = { + lineWidth: 1, + strokeStyle: '#ffffff' + }; + + /** + * Adds a <code>TimeSeries</code> to this chart, with optional presentation options. + * + * Presentation options should be of the form (defaults shown): + * + * <pre> + * { + * lineWidth: 1, + * strokeStyle: '#ffffff', + * fillStyle: undefined, + * interpolation: undefined; + * tooltipLabel: undefined + * } + * </pre> + */ + SmoothieChart.prototype.addTimeSeries = function(timeSeries, options) { + this.seriesSet.push({timeSeries: timeSeries, options: Util.extend({}, SmoothieChart.defaultSeriesPresentationOptions, options)}); + if (timeSeries.options.resetBounds && timeSeries.options.resetBoundsInterval > 0) { + timeSeries.resetBoundsTimerId = setInterval( + function() { + timeSeries.resetBounds(); + }, + timeSeries.options.resetBoundsInterval + ); + } + }; + + /** + * Removes the specified <code>TimeSeries</code> from the chart. + */ + SmoothieChart.prototype.removeTimeSeries = function(timeSeries) { + // Find the correct timeseries to remove, and remove it + var numSeries = this.seriesSet.length; + for (var i = 0; i < numSeries; i++) { + if (this.seriesSet[i].timeSeries === timeSeries) { + this.seriesSet.splice(i, 1); + break; + } + } + // If a timer was operating for that timeseries, remove it + if (timeSeries.resetBoundsTimerId) { + // Stop resetting the bounds, if we were + clearInterval(timeSeries.resetBoundsTimerId); + } + }; + + /** + * Gets render options for the specified <code>TimeSeries</code>. + * + * As you may use a single <code>TimeSeries</code> in multiple charts with different formatting in each usage, + * these settings are stored in the chart. + */ + SmoothieChart.prototype.getTimeSeriesOptions = function(timeSeries) { + // Find the correct timeseries to remove, and remove it + var numSeries = this.seriesSet.length; + for (var i = 0; i < numSeries; i++) { + if (this.seriesSet[i].timeSeries === timeSeries) { + return this.seriesSet[i].options; + } + } + }; + + /** + * Brings the specified <code>TimeSeries</code> to the top of the chart. It will be rendered last. + */ + SmoothieChart.prototype.bringToFront = function(timeSeries) { + // Find the correct timeseries to remove, and remove it + var numSeries = this.seriesSet.length; + for (var i = 0; i < numSeries; i++) { + if (this.seriesSet[i].timeSeries === timeSeries) { + var set = this.seriesSet.splice(i, 1); + this.seriesSet.push(set[0]); + break; + } + } + }; + + /** + * Instructs the <code>SmoothieChart</code> to start rendering to the provided canvas, with specified delay. + * + * @param canvas the target canvas element + * @param delayMillis an amount of time to wait before a data point is shown. This can prevent the end of the series + * from appearing on screen, with new values flashing into view, at the expense of some latency. + */ + SmoothieChart.prototype.streamTo = function(canvas, delayMillis) { + this.canvas = canvas; + this.delay = delayMillis; + this.start(); + }; + + SmoothieChart.prototype.getTooltipEl = function () { + // Create the tool tip element lazily + if (!this.tooltipEl) { + this.tooltipEl = document.createElement('div'); + this.tooltipEl.className = 'smoothie-chart-tooltip'; + this.tooltipEl.style.pointerEvents = 'none'; + this.tooltipEl.style.position = 'absolute'; + this.tooltipEl.style.display = 'none'; + document.body.appendChild(this.tooltipEl); + } + return this.tooltipEl; + }; + + SmoothieChart.prototype.updateTooltip = function () { + if(!this.options.tooltip){ + return; + } + var el = this.getTooltipEl(); + + if (!this.mouseover || !this.options.tooltip) { + el.style.display = 'none'; + return; + } + + var time = this.lastChartTimestamp; + + // x pixel to time + var t = this.options.scrollBackwards + ? time - this.mouseX * this.options.millisPerPixel + : time - (this.canvas.offsetWidth - this.mouseX) * this.options.millisPerPixel; + + var data = []; + + // For each data set... + for (var d = 0; d < this.seriesSet.length; d++) { + var timeSeries = this.seriesSet[d].timeSeries; + if (timeSeries.disabled) { + continue; + } + + // find datapoint closest to time 't' + var closeIdx = Util.binarySearch(timeSeries.data, t); + if (closeIdx > 0 && closeIdx < timeSeries.data.length) { + data.push({ series: this.seriesSet[d], index: closeIdx, value: timeSeries.data[closeIdx][1] }); + } + } + + if (data.length) { + el.innerHTML = this.options.tooltipFormatter.call(this, t, data); + el.style.display = 'block'; + } else { + el.style.display = 'none'; + } + }; + + SmoothieChart.prototype.mousemove = function (evt) { + this.mouseover = true; + this.mouseX = evt.offsetX; + this.mouseY = evt.offsetY; + this.mousePageX = evt.pageX; + this.mousePageY = evt.pageY; + if(!this.options.tooltip){ + return; + } + var el = this.getTooltipEl(); + el.style.top = Math.round(this.mousePageY) + 'px'; + el.style.left = Math.round(this.mousePageX) + 'px'; + this.updateTooltip(); + }; + + SmoothieChart.prototype.mouseout = function () { + this.mouseover = false; + this.mouseX = this.mouseY = -1; + if (this.tooltipEl) + this.tooltipEl.style.display = 'none'; + }; + + /** + * Make sure the canvas has the optimal resolution for the device's pixel ratio. + */ + SmoothieChart.prototype.resize = function () { + var dpr = !this.options.enableDpiScaling || !window ? 1 : window.devicePixelRatio, + width, height; + if (this.options.responsive) { + // Newer behaviour: Use the canvas's size in the layout, and set the internal + // resolution according to that size and the device pixel ratio (eg: high DPI) + width = this.canvas.offsetWidth; + height = this.canvas.offsetHeight; + + if (width !== this.lastWidth) { + this.lastWidth = width; + this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); + this.canvas.getContext('2d').scale(dpr, dpr); + } + if (height !== this.lastHeight) { + this.lastHeight = height; + this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); + this.canvas.getContext('2d').scale(dpr, dpr); + } + } else if (dpr !== 1) { + // Older behaviour: use the canvas's inner dimensions and scale the element's size + // according to that size and the device pixel ratio (eg: high DPI) + width = parseInt(this.canvas.getAttribute('width')); + height = parseInt(this.canvas.getAttribute('height')); + + if (!this.originalWidth || (Math.floor(this.originalWidth * dpr) !== width)) { + this.originalWidth = width; + this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); + this.canvas.style.width = width + 'px'; + this.canvas.getContext('2d').scale(dpr, dpr); + } + + if (!this.originalHeight || (Math.floor(this.originalHeight * dpr) !== height)) { + this.originalHeight = height; + this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); + this.canvas.style.height = height + 'px'; + this.canvas.getContext('2d').scale(dpr, dpr); + } + } + }; + + /** + * Starts the animation of this chart. + */ + SmoothieChart.prototype.start = function() { + if (this.frame) { + // We're already running, so just return + return; + } + + this.canvas.addEventListener('mousemove', this.mousemove); + this.canvas.addEventListener('mouseout', this.mouseout); + + // Renders a frame, and queues the next frame for later rendering + var animate = function() { + this.frame = SmoothieChart.AnimateCompatibility.requestAnimationFrame(function() { + if(this.options.nonRealtimeData){ + var dateZero = new Date(0); + // find the data point with the latest timestamp + var maxTimeStamp = this.seriesSet.reduce(function(max, series){ + var dataSet = series.timeSeries.data; + var indexToCheck = Math.round(this.options.displayDataFromPercentile * dataSet.length) - 1; + indexToCheck = indexToCheck >= 0 ? indexToCheck : 0; + indexToCheck = indexToCheck <= dataSet.length -1 ? indexToCheck : dataSet.length -1; + if(dataSet && dataSet.length > 0) + { + // timestamp corresponds to element 0 of the data point + var lastDataTimeStamp = dataSet[indexToCheck][0]; + max = max > lastDataTimeStamp ? max : lastDataTimeStamp; + } + return max; + }.bind(this), dateZero); + // use the max timestamp as current time + this.render(this.canvas, maxTimeStamp > dateZero ? maxTimeStamp : null); + } else { + this.render(); + } + animate(); + }.bind(this)); + }.bind(this); + + animate(); + }; + + /** + * Stops the animation of this chart. + */ + SmoothieChart.prototype.stop = function() { + if (this.frame) { + SmoothieChart.AnimateCompatibility.cancelAnimationFrame(this.frame); + delete this.frame; + this.canvas.removeEventListener('mousemove', this.mousemove); + this.canvas.removeEventListener('mouseout', this.mouseout); + } + }; + + SmoothieChart.prototype.updateValueRange = function() { + // Calculate the current scale of the chart, from all time series. + var chartOptions = this.options, + chartMaxValue = Number.NaN, + chartMinValue = Number.NaN; + + for (var d = 0; d < this.seriesSet.length; d++) { + // TODO(ndunn): We could calculate / track these values as they stream in. + var timeSeries = this.seriesSet[d].timeSeries; + if (timeSeries.disabled) { + continue; + } + + if (!isNaN(timeSeries.maxValue)) { + chartMaxValue = !isNaN(chartMaxValue) ? Math.max(chartMaxValue, timeSeries.maxValue) : timeSeries.maxValue; + } + + if (!isNaN(timeSeries.minValue)) { + chartMinValue = !isNaN(chartMinValue) ? Math.min(chartMinValue, timeSeries.minValue) : timeSeries.minValue; + } + } + + // Scale the chartMaxValue to add padding at the top if required + if (chartOptions.maxValue != null) { + chartMaxValue = chartOptions.maxValue; + } else { + chartMaxValue *= chartOptions.maxValueScale; + } + + // Set the minimum if we've specified one + if (chartOptions.minValue != null) { + chartMinValue = chartOptions.minValue; + } else { + chartMinValue -= Math.abs(chartMinValue * chartOptions.minValueScale - chartMinValue); + } + + // If a custom range function is set, call it + if (this.options.yRangeFunction) { + var range = this.options.yRangeFunction({min: chartMinValue, max: chartMaxValue}); + chartMinValue = range.min; + chartMaxValue = range.max; + } + + if (!isNaN(chartMaxValue) && !isNaN(chartMinValue)) { + var targetValueRange = chartMaxValue - chartMinValue; + var valueRangeDiff = (targetValueRange - this.currentValueRange); + var minValueDiff = (chartMinValue - this.currentVisMinValue); + this.isAnimatingScale = Math.abs(valueRangeDiff) > 0.1 || Math.abs(minValueDiff) > 0.1; + this.currentValueRange += chartOptions.scaleSmoothing * valueRangeDiff; + this.currentVisMinValue += chartOptions.scaleSmoothing * minValueDiff; + } + + this.valueRange = { min: chartMinValue, max: chartMaxValue }; + }; + + SmoothieChart.prototype.render = function(canvas, time) { + var nowMillis = Date.now(); + + // Respect any frame rate limit. + if (this.options.limitFPS > 0 && nowMillis - this.lastRenderTimeMillis < (1000/this.options.limitFPS)) + return; + + if (!this.isAnimatingScale) { + // We're not animating. We can use the last render time and the scroll speed to work out whether + // we actually need to paint anything yet. If not, we can return immediately. + + // Render at least every 1/6th of a second. The canvas may be resized, which there is + // no reliable way to detect. + var maxIdleMillis = Math.min(1000/6, this.options.millisPerPixel); + + if (nowMillis - this.lastRenderTimeMillis < maxIdleMillis) { + return; + } + } + + this.resize(); + + this.lastRenderTimeMillis = nowMillis; + + canvas = canvas || this.canvas; + time = time || nowMillis - (this.delay || 0); + + // Round time down to pixel granularity, so motion appears smoother. + time -= time % this.options.millisPerPixel; + + this.lastChartTimestamp = time; + + var context = canvas.getContext('2d'), + chartOptions = this.options, + dimensions = { top: 0, left: 0, width: canvas.clientWidth, height: canvas.clientHeight }, + // Calculate the threshold time for the oldest data points. + oldestValidTime = time - (dimensions.width * chartOptions.millisPerPixel), + valueToYPixel = function(value) { + var offset = value - this.currentVisMinValue; + return this.currentValueRange === 0 + ? dimensions.height + : dimensions.height - (Math.round((offset / this.currentValueRange) * dimensions.height)); + }.bind(this), + timeToXPixel = function(t) { + if(chartOptions.scrollBackwards) { + return Math.round((time - t) / chartOptions.millisPerPixel); + } + return Math.round(dimensions.width - ((time - t) / chartOptions.millisPerPixel)); + }; + + this.updateValueRange(); + + context.font = chartOptions.labels.fontSize + 'px ' + chartOptions.labels.fontFamily; + + // Save the state of the canvas context, any transformations applied in this method + // will get removed from the stack at the end of this method when .restore() is called. + context.save(); + + // Move the origin. + context.translate(dimensions.left, dimensions.top); + + // Create a clipped rectangle - anything we draw will be constrained to this rectangle. + // This prevents the occasional pixels from curves near the edges overrunning and creating + // screen cheese (that phrase should need no explanation). + context.beginPath(); + context.rect(0, 0, dimensions.width, dimensions.height); + context.clip(); + + // Clear the working area. + context.save(); + context.fillStyle = chartOptions.grid.fillStyle; + context.clearRect(0, 0, dimensions.width, dimensions.height); + context.fillRect(0, 0, dimensions.width, dimensions.height); + context.restore(); + + // Grid lines... + context.save(); + context.lineWidth = chartOptions.grid.lineWidth; + context.strokeStyle = chartOptions.grid.strokeStyle; + // Vertical (time) dividers. + if (chartOptions.grid.millisPerLine > 0) { + context.beginPath(); + for (var t = time - (time % chartOptions.grid.millisPerLine); + t >= oldestValidTime; + t -= chartOptions.grid.millisPerLine) { + var gx = timeToXPixel(t); + if (chartOptions.grid.sharpLines) { + gx -= 0.5; + } + context.moveTo(gx, 0); + context.lineTo(gx, dimensions.height); + } + context.stroke(); + context.closePath(); + } + + // Horizontal (value) dividers. + for (var v = 1; v < chartOptions.grid.verticalSections; v++) { + var gy = Math.round(v * dimensions.height / chartOptions.grid.verticalSections); + if (chartOptions.grid.sharpLines) { + gy -= 0.5; + } + context.beginPath(); + context.moveTo(0, gy); + context.lineTo(dimensions.width, gy); + context.stroke(); + context.closePath(); + } + // Bounding rectangle. + if (chartOptions.grid.borderVisible) { + context.beginPath(); + context.strokeRect(0, 0, dimensions.width, dimensions.height); + context.closePath(); + } + context.restore(); + + // Draw any horizontal lines... + if (chartOptions.horizontalLines && chartOptions.horizontalLines.length) { + for (var hl = 0; hl < chartOptions.horizontalLines.length; hl++) { + var line = chartOptions.horizontalLines[hl], + hly = Math.round(valueToYPixel(line.value)) - 0.5; + context.strokeStyle = line.color || '#ffffff'; + context.lineWidth = line.lineWidth || 1; + context.beginPath(); + context.moveTo(0, hly); + context.lineTo(dimensions.width, hly); + context.stroke(); + context.closePath(); + } + } + + // For each data set... + for (var d = 0; d < this.seriesSet.length; d++) { + context.save(); + var timeSeries = this.seriesSet[d].timeSeries; + if (timeSeries.disabled) { + continue; + } + + var dataSet = timeSeries.data, + seriesOptions = this.seriesSet[d].options; + + // Delete old data that's moved off the left of the chart. + timeSeries.dropOldData(oldestValidTime, chartOptions.maxDataSetLength); + + // Set style for this dataSet. + context.lineWidth = seriesOptions.lineWidth; + context.strokeStyle = seriesOptions.strokeStyle; + // Draw the line... + context.beginPath(); + // Retain lastX, lastY for calculating the control points of bezier curves. + var firstX = 0, firstY = 0, lastX = 0, lastY = 0; + for (var i = 0; i < dataSet.length && dataSet.length !== 1; i++) { + var x = timeToXPixel(dataSet[i][0]), + y = valueToYPixel(dataSet[i][1]); + + if (i === 0) { + firstX = x; + firstY = y; + context.moveTo(x, y); + } else { + switch (seriesOptions.interpolation || chartOptions.interpolation) { + case "linear": + case "line": { + context.lineTo(x,y); + break; + } + case "bezier": + default: { + // Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves + // + // Assuming A was the last point in the line plotted and B is the new point, + // we draw a curve with control points P and Q as below. + // + // A---P + // | + // | + // | + // Q---B + // + // Importantly, A and P are at the same y coordinate, as are B and Q. This is + // so adjacent curves appear to flow as one. + // + context.bezierCurveTo( // startPoint (A) is implicit from last iteration of loop + Math.round((lastX + x) / 2), lastY, // controlPoint1 (P) + Math.round((lastX + x)) / 2, y, // controlPoint2 (Q) + x, y); // endPoint (B) + break; + } + case "step": { + context.lineTo(x,lastY); + context.lineTo(x,y); + break; + } + } + } + + lastX = x; lastY = y; + } + + if (dataSet.length > 1) { + if (seriesOptions.fillStyle) { + // Close up the fill region. + if (chartOptions.scrollBackwards) { + context.lineTo(lastX, dimensions.height + seriesOptions.lineWidth); + context.lineTo(firstX, dimensions.height + seriesOptions.lineWidth); + context.lineTo(firstX, firstY); + } else { + context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, lastY); + context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, dimensions.height + seriesOptions.lineWidth + 1); + context.lineTo(firstX, dimensions.height + seriesOptions.lineWidth); + } + context.fillStyle = seriesOptions.fillStyle; + context.fill(); + } + + if (seriesOptions.strokeStyle && seriesOptions.strokeStyle !== 'none') { + context.stroke(); + } + context.closePath(); + } + context.restore(); + } + + if (chartOptions.tooltip && this.mouseX >= 0) { + // Draw vertical bar to show tooltip position + context.lineWidth = chartOptions.tooltipLine.lineWidth; + context.strokeStyle = chartOptions.tooltipLine.strokeStyle; + context.beginPath(); + context.moveTo(this.mouseX, 0); + context.lineTo(this.mouseX, dimensions.height); + context.closePath(); + context.stroke(); + } + this.updateTooltip(); + + // Draw the axis values on the chart. + if (!chartOptions.labels.disabled && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)) { + var maxValueString = chartOptions.yMaxFormatter(this.valueRange.max, chartOptions.labels.precision), + minValueString = chartOptions.yMinFormatter(this.valueRange.min, chartOptions.labels.precision), + maxLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(maxValueString).width - 2, + minLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(minValueString).width - 2; + context.fillStyle = chartOptions.labels.fillStyle; + context.fillText(maxValueString, maxLabelPos, chartOptions.labels.fontSize); + context.fillText(minValueString, minLabelPos, dimensions.height - 2); + } + + // Display intermediate y axis labels along y-axis to the left of the chart + if ( chartOptions.labels.showIntermediateLabels + && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max) + && chartOptions.grid.verticalSections > 0) { + // show a label above every vertical section divider + var step = (this.valueRange.max - this.valueRange.min) / chartOptions.grid.verticalSections; + var stepPixels = dimensions.height / chartOptions.grid.verticalSections; + for (var v = 1; v < chartOptions.grid.verticalSections; v++) { + var gy = dimensions.height - Math.round(v * stepPixels); + if (chartOptions.grid.sharpLines) { + gy -= 0.5; + } + var yValue = chartOptions.yIntermediateFormatter(this.valueRange.min + (v * step), chartOptions.labels.precision); + //left of right axis? + intermediateLabelPos = + chartOptions.labels.intermediateLabelSameAxis + ? (chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(yValue).width - 2) + : (chartOptions.scrollBackwards ? dimensions.width - context.measureText(yValue).width - 2 : 0); + + context.fillText(yValue, intermediateLabelPos, gy - chartOptions.grid.lineWidth); + } + } + + // Display timestamps along x-axis at the bottom of the chart. + if (chartOptions.timestampFormatter && chartOptions.grid.millisPerLine > 0) { + var textUntilX = chartOptions.scrollBackwards + ? context.measureText(minValueString).width + : dimensions.width - context.measureText(minValueString).width + 4; + for (var t = time - (time % chartOptions.grid.millisPerLine); + t >= oldestValidTime; + t -= chartOptions.grid.millisPerLine) { + var gx = timeToXPixel(t); + // Only draw the timestamp if it won't overlap with the previously drawn one. + if ((!chartOptions.scrollBackwards && gx < textUntilX) || (chartOptions.scrollBackwards && gx > textUntilX)) { + // Formats the timestamp based on user specified formatting function + // SmoothieChart.timeFormatter function above is one such formatting option + var tx = new Date(t), + ts = chartOptions.timestampFormatter(tx), + tsWidth = context.measureText(ts).width; + + textUntilX = chartOptions.scrollBackwards + ? gx + tsWidth + 2 + : gx - tsWidth - 2; + + context.fillStyle = chartOptions.labels.fillStyle; + if(chartOptions.scrollBackwards) { + context.fillText(ts, gx, dimensions.height - 2); + } else { + context.fillText(ts, gx - tsWidth, dimensions.height - 2); + } + } + } + } + + // Display title. + if (chartOptions.title.text !== '') { + context.font = chartOptions.title.fontSize + 'px ' + chartOptions.title.fontFamily; + var titleXPos = chartOptions.scrollBackwards ? dimensions.width - context.measureText(chartOptions.title.text).width - 2 : 2; + if (chartOptions.title.verticalAlign == 'bottom') { + context.textBaseline = 'bottom'; + var titleYPos = dimensions.height; + } else if (chartOptions.title.verticalAlign == 'middle') { + context.textBaseline = 'middle'; + var titleYPos = dimensions.height / 2; + } else { + context.textBaseline = 'top'; + var titleYPos = 0; + } + context.fillStyle = chartOptions.title.fillStyle; + context.fillText(chartOptions.title.text, titleXPos, titleYPos); + } + + context.restore(); // See .save() above. + }; + + // Sample timestamp formatting function + SmoothieChart.timeFormatter = function(date) { + function pad2(number) { return (number < 10 ? '0' : '') + number } + return pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds()); + }; + + exports.TimeSeries = TimeSeries; + exports.SmoothieChart = SmoothieChart; + +})(typeof exports === 'undefined' ? this : exports); + diff --git a/data/smoothie.js.gz b/data/smoothie.js.gz new file mode 100644 index 0000000000000000000000000000000000000000..78f2b22d1c88af45e39a0cdfb6bfd9a75a1fa8ce GIT binary patch literal 12011 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 A_5c6? literal 0 HcmV?d00001 diff --git a/src/main.cpp b/src/main.cpp index 92aad42..7ccd1d0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -330,12 +330,12 @@ void readPms() if (pms) { // successfull read sensorData->put("pm1", pms.pm01); - sensorData->put("pm2.5", pms.pm25); + sensorData->put("pm2p5", pms.pm25); sensorData->put("pm10", pms.pm10); - sensorData->put("nc0.3", pms.n0p3); - sensorData->put("nc0.5", pms.n0p5); + sensorData->put("nc0p3", pms.n0p3); + sensorData->put("nc0p5", pms.n0p5); sensorData->put("nc1", pms.n1p0); - sensorData->put("nc2.5", pms.n2p5); + sensorData->put("nc2p5", pms.n2p5); sensorData->put("nc5", pms.n5p0); sensorData->put("nc10", pms.n10p0); } @@ -376,12 +376,12 @@ void readPms() void logPms() { Serial.printf("PM <= 1µm: %5d µg/m³\r\n", (int)sensorData->get("pm1")); - Serial.printf("PM <=2.5µm: %5d µg/m³\r\n", (int)sensorData->get("pm2.5")); + Serial.printf("PM <=2.5µm: %5d µg/m³\r\n", (int)sensorData->get("pm2p5")); Serial.printf("PM <= 10µm: %5d µg/m³\r\n", (int)sensorData->get("pm10")); - Serial.printf("NC >=0.3µm: %5d #/100cm³\r\n", (int)sensorData->get("nc0.3")); - Serial.printf("NC >=0.5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc0.5")); + Serial.printf("NC >=0.3µm: %5d #/100cm³\r\n", (int)sensorData->get("nc0p3")); + Serial.printf("NC >=0.5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc0p5")); Serial.printf("NC >= 1µm: %5d #/100cm³\r\n", (int)sensorData->get("nc1")); - Serial.printf("NC >=2.5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc2.5")); + Serial.printf("NC >=2.5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc2p5")); Serial.printf("NC >= 5µm: %5d #/100cm³\r\n", (int)sensorData->get("nc5")); Serial.printf("NC >= 10µm: %5d #/100cm³\r\n", (int)sensorData->get("nc10")); } @@ -392,15 +392,15 @@ void readBme() { // If new data is available sensorData->put("temperature", iaqSensor.temperature); sensorData->put("humidity", iaqSensor.humidity); - sensorData->put("pressure", iaqSensor.pressure); + sensorData->put("pressure", iaqSensor.pressure / 100); // Pa -> hPa sensorData->put("iaq", iaqSensor.iaq); - sensorData->put("iaq_acc", iaqSensor.iaqAccuracy); + sensorData->put("iaq_acc", (int)iaqSensor.iaqAccuracy); sensorData->put("siaq", iaqSensor.staticIaq); - sensorData->put("siaq_acc", iaqSensor.staticIaqAccuracy); + sensorData->put("siaq_acc", (int)iaqSensor.staticIaqAccuracy); sensorData->put("eco2", iaqSensor.co2Equivalent); - sensorData->put("eco2_acc", iaqSensor.co2Accuracy); + sensorData->put("eco2_acc", (int)iaqSensor.co2Accuracy); sensorData->put("bvoc", iaqSensor.breathVocEquivalent); - sensorData->put("bvoc_acc", iaqSensor.breathVocAccuracy); + sensorData->put("bvoc_acc", (int)iaqSensor.breathVocAccuracy); } else { @@ -410,16 +410,16 @@ void readBme() void logBme() { - Serial.printf("Temperature : %.2f °C\r\n", sensorData->get("temperature")); - Serial.printf("Humidity : %.2f %%\r\n", sensorData->get("humidity")); - Serial.printf("Pressure : %d hPa\r\n", (int)sensorData->get("pressure")); - Serial.printf("IAQ : %.2f\r\n", sensorData->get("iaq")); + Serial.printf("Temperature : %f °C\r\n", sensorData->get("temperature")); + Serial.printf("Humidity : %f %%\r\n", sensorData->get("humidity")); + Serial.printf("Pressure : %f hPa\r\n", sensorData->get("pressure")); + Serial.printf("IAQ : %f\r\n", sensorData->get("iaq")); Serial.printf("IAQ Accuracy : %d\r\n", (int)sensorData->get("iaq_acc")); - Serial.printf("Static IAQ : %.2f\r\n", sensorData->get("siaq")); + Serial.printf("Static IAQ : %f\r\n", sensorData->get("siaq")); Serial.printf("Stat IAQ Acc : %d\r\n", (int)sensorData->get("siaq_acc")); - Serial.printf("CO2 Equiv : %.2f ppm\r\n", sensorData->get("eco2")); + Serial.printf("CO2 Equiv : %f ppm\r\n", sensorData->get("eco2")); Serial.printf("CO2 Accuracy : %d\r\n", (int)sensorData->get("eco2_acc")); - Serial.printf("bVOC Equiv : %.2f ppm\r\n", sensorData->get("bvoc")); + Serial.printf("bVOC Equiv : %f ppm\r\n", sensorData->get("bvoc")); Serial.printf("bVOC Accuracy: %d\r\n", (int)sensorData->get("bvoc_acc")); //Serial.printf("Gas Percent : %f %%\r\n", iaqSensor.gasPercentage); //Serial.printf("Gas Per Accur: %d\r\n", iaqSensor.gasPercentageAcccuracy); -- GitLab