diff --git a/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.axislabels.js b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.axislabels.js new file mode 100644 index 0000000..c4b3bca --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.axislabels.js @@ -0,0 +1,466 @@ +/* +Axis Labels Plugin for flot. +http://github.com/markrcote/flot-axislabels + +Original code is Copyright (c) 2010 Xuan Luo. +Original code was released under the GPLv3 license by Xuan Luo, September 2010. +Original code was rereleased under the MIT license by Xuan Luo, April 2012. + +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. + */ + +(function ($) { + var options = { + axisLabels: { + show: true + } + }; + + function canvasSupported() { + return !!document.createElement('canvas').getContext; + } + + function canvasTextSupported() { + if (!canvasSupported()) { + return false; + } + var dummy_canvas = document.createElement('canvas'); + var context = dummy_canvas.getContext('2d'); + return typeof context.fillText == 'function'; + } + + function css3TransitionSupported() { + var div = document.createElement('div'); + return typeof div.style.MozTransition != 'undefined' // Gecko + || typeof div.style.OTransition != 'undefined' // Opera + || typeof div.style.webkitTransition != 'undefined' // WebKit + || typeof div.style.transition != 'undefined'; + } + + + function AxisLabel(axisName, position, padding, plot, opts) { + this.axisName = axisName; + this.position = position; + this.padding = padding; + this.plot = plot; + this.opts = opts; + this.width = 0; + this.height = 0; + } + + AxisLabel.prototype.cleanup = function() { + }; + + + CanvasAxisLabel.prototype = new AxisLabel(); + CanvasAxisLabel.prototype.constructor = CanvasAxisLabel; + function CanvasAxisLabel(axisName, position, padding, plot, opts) { + AxisLabel.prototype.constructor.call(this, axisName, position, padding, + plot, opts); + } + + CanvasAxisLabel.prototype.calculateSize = function() { + if (!this.opts.axisLabelFontSizePixels) + this.opts.axisLabelFontSizePixels = 14; + if (!this.opts.axisLabelFontFamily) + this.opts.axisLabelFontFamily = 'sans-serif'; + + var textWidth = this.opts.axisLabelFontSizePixels + this.padding; + var textHeight = this.opts.axisLabelFontSizePixels + this.padding; + if (this.position == 'left' || this.position == 'right') { + this.width = this.opts.axisLabelFontSizePixels + this.padding; + this.height = 0; + } else { + this.width = 0; + this.height = this.opts.axisLabelFontSizePixels + this.padding; + } + }; + + CanvasAxisLabel.prototype.draw = function(box) { + if (!this.opts.axisLabelColour) + this.opts.axisLabelColour = 'black'; + var ctx = this.plot.getCanvas().getContext('2d'); + ctx.save(); + ctx.font = this.opts.axisLabelFontSizePixels + 'px ' + + this.opts.axisLabelFontFamily; + ctx.fillStyle = this.opts.axisLabelColour; + var width = ctx.measureText(this.opts.axisLabel).width; + var height = this.opts.axisLabelFontSizePixels; + var x, y, angle = 0; + if (this.position == 'top') { + x = box.left + box.width/2 - width/2; + y = box.top + height*0.72; + } else if (this.position == 'bottom') { + x = box.left + box.width/2 - width/2; + y = box.top + box.height - height*0.72; + } else if (this.position == 'left') { + x = box.left + height*0.72; + y = box.height/2 + box.top + width/2; + angle = -Math.PI/2; + } else if (this.position == 'right') { + x = box.left + box.width - height*0.72; + y = box.height/2 + box.top - width/2; + angle = Math.PI/2; + } + ctx.translate(x, y); + ctx.rotate(angle); + ctx.fillText(this.opts.axisLabel, 0, 0); + ctx.restore(); + }; + + + HtmlAxisLabel.prototype = new AxisLabel(); + HtmlAxisLabel.prototype.constructor = HtmlAxisLabel; + function HtmlAxisLabel(axisName, position, padding, plot, opts) { + AxisLabel.prototype.constructor.call(this, axisName, position, + padding, plot, opts); + this.elem = null; + } + + HtmlAxisLabel.prototype.calculateSize = function() { + var elem = $('
' + + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(elem); + // store height and width of label itself, for use in draw() + this.labelWidth = elem.outerWidth(true); + this.labelHeight = elem.outerHeight(true); + elem.remove(); + + this.width = this.height = 0; + if (this.position == 'left' || this.position == 'right') { + this.width = this.labelWidth + this.padding; + } else { + this.height = this.labelHeight + this.padding; + } + }; + + HtmlAxisLabel.prototype.cleanup = function() { + if (this.elem) { + this.elem.remove(); + } + }; + + HtmlAxisLabel.prototype.draw = function(box) { + this.plot.getPlaceholder().find('#' + this.axisName + 'Label').remove(); + this.elem = $('
' + + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(this.elem); + if (this.position == 'top') { + this.elem.css('left', box.left + box.width/2 - this.labelWidth/2 + + 'px'); + this.elem.css('top', box.top + 'px'); + } else if (this.position == 'bottom') { + this.elem.css('left', box.left + box.width/2 - this.labelWidth/2 + + 'px'); + this.elem.css('top', box.top + box.height - this.labelHeight + + 'px'); + } else if (this.position == 'left') { + this.elem.css('top', box.top + box.height/2 - this.labelHeight/2 + + 'px'); + this.elem.css('left', box.left + 'px'); + } else if (this.position == 'right') { + this.elem.css('top', box.top + box.height/2 - this.labelHeight/2 + + 'px'); + this.elem.css('left', box.left + box.width - this.labelWidth + + 'px'); + } + }; + + + CssTransformAxisLabel.prototype = new HtmlAxisLabel(); + CssTransformAxisLabel.prototype.constructor = CssTransformAxisLabel; + function CssTransformAxisLabel(axisName, position, padding, plot, opts) { + HtmlAxisLabel.prototype.constructor.call(this, axisName, position, + padding, plot, opts); + } + + CssTransformAxisLabel.prototype.calculateSize = function() { + HtmlAxisLabel.prototype.calculateSize.call(this); + this.width = this.height = 0; + if (this.position == 'left' || this.position == 'right') { + this.width = this.labelHeight + this.padding; + } else { + this.height = this.labelHeight + this.padding; + } + }; + + CssTransformAxisLabel.prototype.transforms = function(degrees, x, y) { + var stransforms = { + '-moz-transform': '', + '-webkit-transform': '', + '-o-transform': '', + '-ms-transform': '' + }; + if (x != 0 || y != 0) { + var stdTranslate = ' translate(' + x + 'px, ' + y + 'px)'; + stransforms['-moz-transform'] += stdTranslate; + stransforms['-webkit-transform'] += stdTranslate; + stransforms['-o-transform'] += stdTranslate; + stransforms['-ms-transform'] += stdTranslate; + } + if (degrees != 0) { + var rotation = degrees / 90; + var stdRotate = ' rotate(' + degrees + 'deg)'; + stransforms['-moz-transform'] += stdRotate; + stransforms['-webkit-transform'] += stdRotate; + stransforms['-o-transform'] += stdRotate; + stransforms['-ms-transform'] += stdRotate; + } + var s = 'top: 0; left: 0; '; + for (var prop in stransforms) { + if (stransforms[prop]) { + s += prop + ':' + stransforms[prop] + ';'; + } + } + s += ';'; + return s; + }; + + CssTransformAxisLabel.prototype.calculateOffsets = function(box) { + var offsets = { x: 0, y: 0, degrees: 0 }; + if (this.position == 'bottom') { + offsets.x = box.left + box.width/2 - this.labelWidth/2; + offsets.y = box.top + box.height - this.labelHeight; + } else if (this.position == 'top') { + offsets.x = box.left + box.width/2 - this.labelWidth/2; + offsets.y = box.top; + } else if (this.position == 'left') { + offsets.degrees = -90; + offsets.x = box.left - this.labelWidth/2 + this.labelHeight/2; + offsets.y = box.height/2 + box.top; + } else if (this.position == 'right') { + offsets.degrees = 90; + offsets.x = box.left + box.width - this.labelWidth/2 + - this.labelHeight/2; + offsets.y = box.height/2 + box.top; + } + offsets.x = Math.round(offsets.x); + offsets.y = Math.round(offsets.y); + + return offsets; + }; + + CssTransformAxisLabel.prototype.draw = function(box) { + this.plot.getPlaceholder().find("." + this.axisName + "Label").remove(); + var offsets = this.calculateOffsets(box); + this.elem = $('
' + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(this.elem); + }; + + + IeTransformAxisLabel.prototype = new CssTransformAxisLabel(); + IeTransformAxisLabel.prototype.constructor = IeTransformAxisLabel; + function IeTransformAxisLabel(axisName, position, padding, plot, opts) { + CssTransformAxisLabel.prototype.constructor.call(this, axisName, + position, padding, + plot, opts); + this.requiresResize = false; + } + + IeTransformAxisLabel.prototype.transforms = function(degrees, x, y) { + // I didn't feel like learning the crazy Matrix stuff, so this uses + // a combination of the rotation transform and CSS positioning. + var s = ''; + if (degrees != 0) { + var rotation = degrees/90; + while (rotation < 0) { + rotation += 4; + } + s += ' filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=' + rotation + '); '; + // see below + this.requiresResize = (this.position == 'right'); + } + if (x != 0) { + s += 'left: ' + x + 'px; '; + } + if (y != 0) { + s += 'top: ' + y + 'px; '; + } + return s; + }; + + IeTransformAxisLabel.prototype.calculateOffsets = function(box) { + var offsets = CssTransformAxisLabel.prototype.calculateOffsets.call( + this, box); + // adjust some values to take into account differences between + // CSS and IE rotations. + if (this.position == 'top') { + // FIXME: not sure why, but placing this exactly at the top causes + // the top axis label to flip to the bottom... + offsets.y = box.top + 1; + } else if (this.position == 'left') { + offsets.x = box.left; + offsets.y = box.height/2 + box.top - this.labelWidth/2; + } else if (this.position == 'right') { + offsets.x = box.left + box.width - this.labelHeight; + offsets.y = box.height/2 + box.top - this.labelWidth/2; + } + return offsets; + }; + + IeTransformAxisLabel.prototype.draw = function(box) { + CssTransformAxisLabel.prototype.draw.call(this, box); + if (this.requiresResize) { + this.elem = this.plot.getPlaceholder().find("." + this.axisName + + "Label"); + // Since we used CSS positioning instead of transforms for + // translating the element, and since the positioning is done + // before any rotations, we have to reset the width and height + // in case the browser wrapped the text (specifically for the + // y2axis). + this.elem.css('width', this.labelWidth); + this.elem.css('height', this.labelHeight); + } + }; + + + function init(plot) { + plot.hooks.processOptions.push(function (plot, options) { + + if (!options.axisLabels.show) + return; + + // This is kind of a hack. There are no hooks in Flot between + // the creation and measuring of the ticks (setTicks, measureTickLabels + // in setupGrid() ) and the drawing of the ticks and plot box + // (insertAxisLabels in setupGrid() ). + // + // Therefore, we use a trick where we run the draw routine twice: + // the first time to get the tick measurements, so that we can change + // them, and then have it draw it again. + var secondPass = false; + + var axisLabels = {}; + var axisOffsetCounts = { left: 0, right: 0, top: 0, bottom: 0 }; + + var defaultPadding = 2; // padding between axis and tick labels + plot.hooks.draw.push(function (plot, ctx) { + var hasAxisLabels = false; + if (!secondPass) { + // MEASURE AND SET OPTIONS + $.each(plot.getAxes(), function(axisName, axis) { + var opts = axis.options // Flot 0.7 + || plot.getOptions()[axisName]; // Flot 0.6 + + // Handle redraws initiated outside of this plug-in. + if (axisName in axisLabels) { + axis.labelHeight = axis.labelHeight - + axisLabels[axisName].height; + axis.labelWidth = axis.labelWidth - + axisLabels[axisName].width; + opts.labelHeight = axis.labelHeight; + opts.labelWidth = axis.labelWidth; + axisLabels[axisName].cleanup(); + delete axisLabels[axisName]; + } + + if (!opts || !opts.axisLabel || !axis.show) + return; + + hasAxisLabels = true; + var renderer = null; + + if (!opts.axisLabelUseHtml && + navigator.appName == 'Microsoft Internet Explorer') { + var ua = navigator.userAgent; + var re = new RegExp("MSIE ([0-9]{1,}[\.0-9]{0,})"); + if (re.exec(ua) != null) { + rv = parseFloat(RegExp.$1); + } + if (rv >= 9 && !opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) { + renderer = CssTransformAxisLabel; + } else if (!opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) { + renderer = IeTransformAxisLabel; + } else if (opts.axisLabelUseCanvas) { + renderer = CanvasAxisLabel; + } else { + renderer = HtmlAxisLabel; + } + } else { + if (opts.axisLabelUseHtml || (!css3TransitionSupported() && !canvasTextSupported()) && !opts.axisLabelUseCanvas) { + renderer = HtmlAxisLabel; + } else if (opts.axisLabelUseCanvas || !css3TransitionSupported()) { + renderer = CanvasAxisLabel; + } else { + renderer = CssTransformAxisLabel; + } + } + + var padding = opts.axisLabelPadding === undefined ? + defaultPadding : opts.axisLabelPadding; + + axisLabels[axisName] = new renderer(axisName, + axis.position, padding, + plot, opts); + + // flot interprets axis.labelHeight and .labelWidth as + // the height and width of the tick labels. We increase + // these values to make room for the axis label and + // padding. + + axisLabels[axisName].calculateSize(); + + // AxisLabel.height and .width are the size of the + // axis label and padding. + // Just set opts here because axis will be sorted out on + // the redraw. + + opts.labelHeight = axis.labelHeight + + axisLabels[axisName].height; + opts.labelWidth = axis.labelWidth + + axisLabels[axisName].width; + }); + + // If there are axis labels, re-draw with new label widths and + // heights. + + if (hasAxisLabels) { + secondPass = true; + plot.setupGrid(); + plot.draw(); + } + } else { + secondPass = false; + // DRAW + $.each(plot.getAxes(), function(axisName, axis) { + var opts = axis.options // Flot 0.7 + || plot.getOptions()[axisName]; // Flot 0.6 + if (!opts || !opts.axisLabel || !axis.show) + return; + + axisLabels[axisName].draw(axis.box); + }); + } + }); + }); + } + + + $.plot.plugins.push({ + init: init, + options: options, + name: 'axisLabels', + version: '2.0' + }); +})(jQuery); diff --git a/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.js b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.js new file mode 100644 index 0000000..ff606c6 --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.js @@ -0,0 +1,606 @@ +/* + * jquery.flot.tooltip + * + * description: easy-to-use tooltips for Flot charts + * version: 0.9.0 + * authors: Krzysztof Urbas @krzysu [myviews.pl],Evan Steinkerchner @Roundaround + * website: https://github.com/krzysu/flot.tooltip + * + * build on 2016-07-26 + * released under MIT License, 2012 +*/ +(function ($) { + // plugin options, default values + var defaultOptions = { + tooltip: { + show: false, + cssClass: "flotTip", + content: "%s | X: %x | Y: %y", + // allowed templates are: + // %s -> series label, + // %c -> series color, + // %lx -> x axis label (requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels), + // %ly -> y axis label (requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels), + // %x -> X value, + // %y -> Y value, + // %x.2 -> precision of X value, + // %p -> percent + // %n -> value (not percent) of pie chart + xDateFormat: null, + yDateFormat: null, + monthNames: null, + dayNames: null, + shifts: { + x: 10, + y: 20 + }, + defaultTheme: true, + snap: true, + lines: false, + clickTips: false, + + // callbacks + onHover: function (flotItem, $tooltipEl) {}, + + $compat: false + } + }; + + // dummy default options object for legacy code (<0.8.5) - is deleted later + defaultOptions.tooltipOpts = defaultOptions.tooltip; + + // object + var FlotTooltip = function (plot) { + // variables + this.tipPosition = {x: 0, y: 0}; + + this.init(plot); + }; + + // main plugin function + FlotTooltip.prototype.init = function (plot) { + var that = this; + + // detect other flot plugins + var plotPluginsLength = $.plot.plugins.length; + this.plotPlugins = []; + + if (plotPluginsLength) { + for (var p = 0; p < plotPluginsLength; p++) { + this.plotPlugins.push($.plot.plugins[p].name); + } + } + + plot.hooks.bindEvents.push(function (plot, eventHolder) { + + // get plot options + that.plotOptions = plot.getOptions(); + + // for legacy (<0.8.5) implementations + if (typeof(that.plotOptions.tooltip) === 'boolean') { + that.plotOptions.tooltipOpts.show = that.plotOptions.tooltip; + that.plotOptions.tooltip = that.plotOptions.tooltipOpts; + delete that.plotOptions.tooltipOpts; + } + + // if not enabled return + if (that.plotOptions.tooltip.show === false || typeof that.plotOptions.tooltip.show === 'undefined') return; + + // shortcut to access tooltip options + that.tooltipOptions = that.plotOptions.tooltip; + + if (that.tooltipOptions.$compat) { + that.wfunc = 'width'; + that.hfunc = 'height'; + } else { + that.wfunc = 'innerWidth'; + that.hfunc = 'innerHeight'; + } + + // create tooltip DOM element + var $tip = that.getDomElement(); + + // bind event + $( plot.getPlaceholder() ).bind("plothover", plothover); + if (that.tooltipOptions.clickTips) { + $( plot.getPlaceholder() ).bind("plotclick", plotclick); + } + that.clickmode = false; + + $(eventHolder).bind('mousemove', mouseMove); + }); + + plot.hooks.shutdown.push(function (plot, eventHolder){ + $(plot.getPlaceholder()).unbind("plothover", plothover); + $(plot.getPlaceholder()).unbind("plotclick", plotclick); + plot.removeTooltip(); + $(eventHolder).unbind("mousemove", mouseMove); + }); + + function mouseMove(e){ + var pos = {}; + pos.x = e.pageX; + pos.y = e.pageY; + plot.setTooltipPosition(pos); + } + + /** + * open the tooltip (if not already open) and freeze it on the current position till the next click + */ + function plotclick(event, pos, item) { + if (! that.clickmode) { + // it is the click activating the clicktip + plothover(event, pos, item); + if (that.getDomElement().is(":visible")) { + $(plot.getPlaceholder()).unbind("plothover", plothover); + that.clickmode = true; + } + } else { + // it is the click deactivating the clicktip + $( plot.getPlaceholder() ).bind("plothover", plothover); + plot.hideTooltip(); + that.clickmode = false; + } + } + + function plothover(event, pos, item) { + // Simple distance formula. + var lineDistance = function (p1x, p1y, p2x, p2y) { + return Math.sqrt((p2x - p1x) * (p2x - p1x) + (p2y - p1y) * (p2y - p1y)); + }; + + // Here is some voodoo magic for determining the distance to a line form a given point {x, y}. + var dotLineLength = function (x, y, x0, y0, x1, y1, o) { + if (o && !(o = + function (x, y, x0, y0, x1, y1) { + if (typeof x0 !== 'undefined') return { x: x0, y: y }; + else if (typeof y0 !== 'undefined') return { x: x, y: y0 }; + + var left, + tg = -1 / ((y1 - y0) / (x1 - x0)); + + return { + x: left = (x1 * (x * tg - y + y0) + x0 * (x * -tg + y - y1)) / (tg * (x1 - x0) + y0 - y1), + y: tg * left - tg * x + y + }; + } (x, y, x0, y0, x1, y1), + o.x >= Math.min(x0, x1) && o.x <= Math.max(x0, x1) && o.y >= Math.min(y0, y1) && o.y <= Math.max(y0, y1)) + ) { + var l1 = lineDistance(x, y, x0, y0), l2 = lineDistance(x, y, x1, y1); + return l1 > l2 ? l2 : l1; + } else { + var a = y0 - y1, b = x1 - x0, c = x0 * y1 - y0 * x1; + return Math.abs(a * x + b * y + c) / Math.sqrt(a * a + b * b); + } + }; + + if (item) { + plot.showTooltip(item, that.tooltipOptions.snap ? item : pos); + } else if (that.plotOptions.series.lines.show && that.tooltipOptions.lines === true) { + var maxDistance = that.plotOptions.grid.mouseActiveRadius; + + var closestTrace = { + distance: maxDistance + 1 + }; + + var ttPos = pos; + + $.each(plot.getData(), function (i, series) { + var xBeforeIndex = 0, + xAfterIndex = -1; + + // Our search here assumes our data is sorted via the x-axis. + // TODO: Improve efficiency somehow - search smaller sets of data. + for (var j = 1; j < series.data.length; j++) { + if (series.data[j - 1][0] <= pos.x && series.data[j][0] >= pos.x) { + xBeforeIndex = j - 1; + xAfterIndex = j; + } + } + + if (xAfterIndex === -1) { + plot.hideTooltip(); + return; + } + + var pointPrev = { x: series.data[xBeforeIndex][0], y: series.data[xBeforeIndex][1] }, + pointNext = { x: series.data[xAfterIndex][0], y: series.data[xAfterIndex][1] }; + + var distToLine = dotLineLength(series.xaxis.p2c(pos.x), series.yaxis.p2c(pos.y), series.xaxis.p2c(pointPrev.x), + series.yaxis.p2c(pointPrev.y), series.xaxis.p2c(pointNext.x), series.yaxis.p2c(pointNext.y), false); + + if (distToLine < closestTrace.distance) { + + var closestIndex = lineDistance(pointPrev.x, pointPrev.y, pos.x, pos.y) < + lineDistance(pos.x, pos.y, pointNext.x, pointNext.y) ? xBeforeIndex : xAfterIndex; + + var pointSize = series.datapoints.pointsize; + + // Calculate the point on the line vertically closest to our cursor. + var pointOnLine = [ + pos.x, + pointPrev.y + ((pointNext.y - pointPrev.y) * ((pos.x - pointPrev.x) / (pointNext.x - pointPrev.x))) + ]; + + var item = { + datapoint: pointOnLine, + dataIndex: closestIndex, + series: series, + seriesIndex: i + }; + + closestTrace = { + distance: distToLine, + item: item + }; + + if (that.tooltipOptions.snap) { + ttPos = { + pageX: series.xaxis.p2c(pointOnLine[0]), + pageY: series.yaxis.p2c(pointOnLine[1]) + }; + } + } + }); + + if (closestTrace.distance < maxDistance + 1) + plot.showTooltip(closestTrace.item, ttPos); + else + plot.hideTooltip(); + } else { + plot.hideTooltip(); + } + } + + // Quick little function for setting the tooltip position. + plot.setTooltipPosition = function (pos) { + var $tip = that.getDomElement(); + + var totalTipWidth = $tip.outerWidth() + that.tooltipOptions.shifts.x; + var totalTipHeight = $tip.outerHeight() + that.tooltipOptions.shifts.y; + if ((pos.x - $(window).scrollLeft()) > ($(window)[that.wfunc]() - totalTipWidth)) { + pos.x -= totalTipWidth; + pos.x = Math.max(pos.x, 0); + } + if ((pos.y - $(window).scrollTop()) > ($(window)[that.hfunc]() - totalTipHeight)) { + pos.y -= totalTipHeight; + } + + /* + The section applies the new positioning ONLY if pos.x and pos.y + are numbers. If they are undefined or not a number, use the last + known numerical position. This hack fixes a bug that kept pie + charts from keeping their tooltip positioning. + */ + + if (isNaN(pos.x)) { + that.tipPosition.x = that.tipPosition.xPrev; + } + else { + that.tipPosition.x = pos.x; + that.tipPosition.xPrev = pos.x; + } + if (isNaN(pos.y)) { + that.tipPosition.y = that.tipPosition.yPrev; + } + else { + that.tipPosition.y = pos.y; + that.tipPosition.yPrev = pos.y; + } + + }; + + // Quick little function for showing the tooltip. + plot.showTooltip = function (target, position, targetPosition) { + var $tip = that.getDomElement(); + + // convert tooltip content template to real tipText + var tipText = that.stringFormat(that.tooltipOptions.content, target); + if (tipText === '') + return; + + $tip.html(tipText); + plot.setTooltipPosition({ x: that.tipPosition.x, y: that.tipPosition.y }); + $tip.css({ + left: that.tipPosition.x + that.tooltipOptions.shifts.x, + top: that.tipPosition.y + that.tooltipOptions.shifts.y + }).show(); + + // run callback + if (typeof that.tooltipOptions.onHover === 'function') { + that.tooltipOptions.onHover(target, $tip); + } + }; + + // Quick little function for hiding the tooltip. + plot.hideTooltip = function () { + that.getDomElement().hide().html(''); + }; + + plot.removeTooltip = function() { + that.getDomElement().remove(); + }; + }; + + /** + * get or create tooltip DOM element + * @return jQuery object + */ + FlotTooltip.prototype.getDomElement = function () { + var $tip = $('
'); + if (this.tooltipOptions && this.tooltipOptions.cssClass) { + $tip = $('.' + this.tooltipOptions.cssClass); + + if( $tip.length === 0 ){ + $tip = $('
').addClass(this.tooltipOptions.cssClass); + $tip.appendTo('body').hide().css({position: 'absolute'}); + + if(this.tooltipOptions.defaultTheme) { + $tip.css({ + 'background': '#fff', + 'z-index': '1040', + 'padding': '0.4em 0.6em', + 'border-radius': '0.5em', + 'font-size': '0.8em', + 'border': '1px solid #111', + 'display': 'none', + 'white-space': 'nowrap' + }); + } + } + } + + return $tip; + }; + + /** + * core function, create tooltip content + * @param {string} content - template with tooltip content + * @param {object} item - Flot item + * @return {string} real tooltip content for current item + */ + FlotTooltip.prototype.stringFormat = function (content, item) { + var percentPattern = /%p\.{0,1}(\d{0,})/; + var seriesPattern = /%s/; + var colorPattern = /%c/; + var xLabelPattern = /%lx/; // requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels, will be ignored if plugin isn't loaded + var yLabelPattern = /%ly/; // requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels, will be ignored if plugin isn't loaded + var xPattern = /%x\.{0,1}(\d{0,})/; + var yPattern = /%y\.{0,1}(\d{0,})/; + var xPatternWithoutPrecision = "%x"; + var yPatternWithoutPrecision = "%y"; + var customTextPattern = "%ct"; + var nPiePattern = "%n"; + + var x, y, customText, p, n; + + // for threshold plugin we need to read data from different place + if (typeof item.series.threshold !== "undefined") { + x = item.datapoint[0]; + y = item.datapoint[1]; + customText = item.datapoint[2]; + } + + // for CurvedLines plugin we need to read data from different place + else if (typeof item.series.curvedLines !== "undefined") { + x = item.datapoint[0]; + y = item.datapoint[1]; + } + + else if (typeof item.series.lines !== "undefined" && item.series.lines.steps) { + x = item.series.datapoints.points[item.dataIndex * 2]; + y = item.series.datapoints.points[item.dataIndex * 2 + 1]; + // TODO: where to find custom text in this variant? + customText = ""; + } else { + x = item.series.data[item.dataIndex][0]; + y = item.series.data[item.dataIndex][1]; + customText = item.series.data[item.dataIndex][2]; + } + + // I think this is only in case of threshold plugin + if (item.series.label === null && item.series.originSeries) { + item.series.label = item.series.originSeries.label; + } + + // if it is a function callback get the content string + if (typeof(content) === 'function') { + content = content(item.series.label, x, y, item); + } + + // the case where the passed content is equal to false + if (typeof(content) === 'boolean' && !content) { + return ''; + } + + /* replacement of %ct and other multi-character templates must + precede the replacement of single-character templates + to avoid conflict between '%c' and '%ct' and similar substrings + */ + if (customText) { + content = content.replace(customTextPattern, customText); + } + + // percent match for pie charts and stacked percent + if (typeof (item.series.percent) !== 'undefined') { + p = item.series.percent; + } else if (typeof (item.series.percents) !== 'undefined') { + p = item.series.percents[item.dataIndex]; + } + if (typeof p === 'number') { + content = this.adjustValPrecision(percentPattern, content, p); + } + + // replace %n with number of items represented by slice in pie charts + if (item.series.hasOwnProperty('pie')) { + if (typeof item.series.data[0][1] !== 'undefined') { + n = item.series.data[0][1]; + } + } + if (typeof n === 'number') { + content = content.replace(nPiePattern, n); + } + + // series match + if (typeof(item.series.label) !== 'undefined') { + content = content.replace(seriesPattern, item.series.label); + } else { + //remove %s if label is undefined + content = content.replace(seriesPattern, ""); + } + + // color match + if (typeof(item.series.color) !== 'undefined') { + content = content.replace(colorPattern, item.series.color); + } else { + //remove %s if color is undefined + content = content.replace(colorPattern, ""); + } + + // x axis label match + if (this.hasAxisLabel('xaxis', item)) { + content = content.replace(xLabelPattern, item.series.xaxis.options.axisLabel); + } else { + //remove %lx if axis label is undefined or axislabels plugin not present + content = content.replace(xLabelPattern, ""); + } + + // y axis label match + if (this.hasAxisLabel('yaxis', item)) { + content = content.replace(yLabelPattern, item.series.yaxis.options.axisLabel); + } else { + //remove %ly if axis label is undefined or axislabels plugin not present + content = content.replace(yLabelPattern, ""); + } + + // time mode axes with custom dateFormat + if (this.isTimeMode('xaxis', item) && this.isXDateFormat(item)) { + content = content.replace(xPattern, this.timestampToDate(x, this.tooltipOptions.xDateFormat, item.series.xaxis.options)); + } + if (this.isTimeMode('yaxis', item) && this.isYDateFormat(item)) { + content = content.replace(yPattern, this.timestampToDate(y, this.tooltipOptions.yDateFormat, item.series.yaxis.options)); + } + + // set precision if defined + if (typeof x === 'number') { + content = this.adjustValPrecision(xPattern, content, x); + } + if (typeof y === 'number') { + content = this.adjustValPrecision(yPattern, content, y); + } + + // change x from number to given label, if given + if (typeof item.series.xaxis.ticks !== 'undefined') { + + var ticks; + if (this.hasRotatedXAxisTicks(item)) { + // xaxis.ticks will be an empty array if tickRotor is being used, but the values are available in rotatedTicks + ticks = 'rotatedTicks'; + } else { + ticks = 'ticks'; + } + + // see https://github.com/krzysu/flot.tooltip/issues/65 + var tickIndex = item.dataIndex + item.seriesIndex; + + for (var xIndex in item.series.xaxis[ticks]) { + if (item.series.xaxis[ticks].hasOwnProperty(tickIndex) && !this.isTimeMode('xaxis', item)) { + var valueX = (this.isCategoriesMode('xaxis', item)) ? item.series.xaxis[ticks][tickIndex].label : item.series.xaxis[ticks][tickIndex].v; + if (valueX === x) { + content = content.replace(xPattern, item.series.xaxis[ticks][tickIndex].label.replace(/\$/g, '$$$$')); + } + } + } + } + + // change y from number to given label, if given + if (typeof item.series.yaxis.ticks !== 'undefined') { + for (var yIndex in item.series.yaxis.ticks) { + if (item.series.yaxis.ticks.hasOwnProperty(yIndex)) { + var valueY = (this.isCategoriesMode('yaxis', item)) ? item.series.yaxis.ticks[yIndex].label : item.series.yaxis.ticks[yIndex].v; + if (valueY === y) { + content = content.replace(yPattern, item.series.yaxis.ticks[yIndex].label.replace(/\$/g, '$$$$')); + } + } + } + } + + // if no value customization, use tickFormatter by default + if (typeof item.series.xaxis.tickFormatter !== 'undefined') { + //escape dollar + content = content.replace(xPatternWithoutPrecision, item.series.xaxis.tickFormatter(x, item.series.xaxis).replace(/\$/g, '$$')); + } + if (typeof item.series.yaxis.tickFormatter !== 'undefined') { + //escape dollar + content = content.replace(yPatternWithoutPrecision, item.series.yaxis.tickFormatter(y, item.series.yaxis).replace(/\$/g, '$$')); + } + + return content; + }; + + // helpers just for readability + FlotTooltip.prototype.isTimeMode = function (axisName, item) { + return (typeof item.series[axisName].options.mode !== 'undefined' && item.series[axisName].options.mode === 'time'); + }; + + FlotTooltip.prototype.isXDateFormat = function (item) { + return (typeof this.tooltipOptions.xDateFormat !== 'undefined' && this.tooltipOptions.xDateFormat !== null); + }; + + FlotTooltip.prototype.isYDateFormat = function (item) { + return (typeof this.tooltipOptions.yDateFormat !== 'undefined' && this.tooltipOptions.yDateFormat !== null); + }; + + FlotTooltip.prototype.isCategoriesMode = function (axisName, item) { + return (typeof item.series[axisName].options.mode !== 'undefined' && item.series[axisName].options.mode === 'categories'); + }; + + // + FlotTooltip.prototype.timestampToDate = function (tmst, dateFormat, options) { + var theDate = $.plot.dateGenerator(tmst, options); + return $.plot.formatDate(theDate, dateFormat, this.tooltipOptions.monthNames, this.tooltipOptions.dayNames); + }; + + // + FlotTooltip.prototype.adjustValPrecision = function (pattern, content, value) { + + var precision; + var matchResult = content.match(pattern); + if( matchResult !== null ) { + if(RegExp.$1 !== '') { + precision = RegExp.$1; + value = value.toFixed(precision); + + // only replace content if precision exists, in other case use thickformater + content = content.replace(pattern, value); + } + } + return content; + }; + + // other plugins detection below + + // check if flot-axislabels plugin (https://github.com/markrcote/flot-axislabels) is used and that an axis label is given + FlotTooltip.prototype.hasAxisLabel = function (axisName, item) { + return ($.inArray('axisLabels', this.plotPlugins) !== -1 && typeof item.series[axisName].options.axisLabel !== 'undefined' && item.series[axisName].options.axisLabel.length > 0); + }; + + // check whether flot-tickRotor, a plugin which allows rotation of X-axis ticks, is being used + FlotTooltip.prototype.hasRotatedXAxisTicks = function (item) { + return ($.inArray('tickRotor',this.plotPlugins) !== -1 && typeof item.series.xaxis.rotatedTicks !== 'undefined'); + }; + + // + var init = function (plot) { + new FlotTooltip(plot); + }; + + // define Flot plugin + $.plot.plugins.push({ + init: init, + options: defaultOptions, + name: 'tooltip', + version: '0.8.5' + }); + +})(jQuery); diff --git a/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.min.js b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.min.js new file mode 100644 index 0000000..451811a --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.min.js @@ -0,0 +1,12 @@ +/* + * jquery.flot.tooltip + * + * description: easy-to-use tooltips for Flot charts + * version: 0.9.0 + * authors: Krzysztof Urbas @krzysu [myviews.pl],Evan Steinkerchner @Roundaround + * website: https://github.com/krzysu/flot.tooltip + * + * build on 2016-07-26 + * released under MIT License, 2012 +*/ +!function(a){var b={tooltip:{show:!1,cssClass:"flotTip",content:"%s | X: %x | Y: %y",xDateFormat:null,yDateFormat:null,monthNames:null,dayNames:null,shifts:{x:10,y:20},defaultTheme:!0,snap:!0,lines:!1,clickTips:!1,onHover:function(a,b){},$compat:!1}};b.tooltipOpts=b.tooltip;var c=function(a){this.tipPosition={x:0,y:0},this.init(a)};c.prototype.init=function(b){function c(a){var c={};c.x=a.pageX,c.y=a.pageY,b.setTooltipPosition(c)}function d(c,d,g){f.clickmode?(a(b.getPlaceholder()).bind("plothover",e),b.hideTooltip(),f.clickmode=!1):(e(c,d,g),f.getDomElement().is(":visible")&&(a(b.getPlaceholder()).unbind("plothover",e),f.clickmode=!0))}function e(c,d,e){var g=function(a,b,c,d){return Math.sqrt((c-a)*(c-a)+(d-b)*(d-b))},h=function(a,b,c,d,e,f,h){if(!h||(h=function(a,b,c,d,e,f){if("undefined"!=typeof c)return{x:c,y:b};if("undefined"!=typeof d)return{x:a,y:d};var g,h=-1/((f-d)/(e-c));return{x:g=(e*(a*h-b+d)+c*(a*-h+b-f))/(h*(e-c)+d-f),y:h*g-h*a+b}}(a,b,c,d,e,f),h.x>=Math.min(c,e)&&h.x<=Math.max(c,e)&&h.y>=Math.min(d,f)&&h.y<=Math.max(d,f))){var i=d-f,j=e-c,k=c*f-d*e;return Math.abs(i*a+j*b+k)/Math.sqrt(i*i+j*j)}var l=g(a,b,c,d),m=g(a,b,e,f);return l>m?m:l};if(e)b.showTooltip(e,f.tooltipOptions.snap?e:d);else if(f.plotOptions.series.lines.show&&f.tooltipOptions.lines===!0){var i=f.plotOptions.grid.mouseActiveRadius,j={distance:i+1},k=d;a.each(b.getData(),function(a,c){for(var e=0,i=-1,l=1;l=d.x&&(e=l-1,i=l);if(-1===i)return void b.hideTooltip();var m={x:c.data[e][0],y:c.data[e][1]},n={x:c.data[i][0],y:c.data[i][1]},o=h(c.xaxis.p2c(d.x),c.yaxis.p2c(d.y),c.xaxis.p2c(m.x),c.yaxis.p2c(m.y),c.xaxis.p2c(n.x),c.yaxis.p2c(n.y),!1);if(oh;h++)this.plotPlugins.push(a.plot.plugins[h].name);b.hooks.bindEvents.push(function(b,g){if(f.plotOptions=b.getOptions(),"boolean"==typeof f.plotOptions.tooltip&&(f.plotOptions.tooltipOpts.show=f.plotOptions.tooltip,f.plotOptions.tooltip=f.plotOptions.tooltipOpts,delete f.plotOptions.tooltipOpts),f.plotOptions.tooltip.show!==!1&&"undefined"!=typeof f.plotOptions.tooltip.show){f.tooltipOptions=f.plotOptions.tooltip,f.tooltipOptions.$compat?(f.wfunc="width",f.hfunc="height"):(f.wfunc="innerWidth",f.hfunc="innerHeight");f.getDomElement();a(b.getPlaceholder()).bind("plothover",e),f.tooltipOptions.clickTips&&a(b.getPlaceholder()).bind("plotclick",d),f.clickmode=!1,a(g).bind("mousemove",c)}}),b.hooks.shutdown.push(function(b,f){a(b.getPlaceholder()).unbind("plothover",e),a(b.getPlaceholder()).unbind("plotclick",d),b.removeTooltip(),a(f).unbind("mousemove",c)}),b.setTooltipPosition=function(b){var c=f.getDomElement(),d=c.outerWidth()+f.tooltipOptions.shifts.x,e=c.outerHeight()+f.tooltipOptions.shifts.y;b.x-a(window).scrollLeft()>a(window)[f.wfunc]()-d&&(b.x-=d,b.x=Math.max(b.x,0)),b.y-a(window).scrollTop()>a(window)[f.hfunc]()-e&&(b.y-=e),isNaN(b.x)?f.tipPosition.x=f.tipPosition.xPrev:(f.tipPosition.x=b.x,f.tipPosition.xPrev=b.x),isNaN(b.y)?f.tipPosition.y=f.tipPosition.yPrev:(f.tipPosition.y=b.y,f.tipPosition.yPrev=b.y)},b.showTooltip=function(a,c,d){var e=f.getDomElement(),g=f.stringFormat(f.tooltipOptions.content,a);""!==g&&(e.html(g),b.setTooltipPosition({x:f.tipPosition.x,y:f.tipPosition.y}),e.css({left:f.tipPosition.x+f.tooltipOptions.shifts.x,top:f.tipPosition.y+f.tooltipOptions.shifts.y}).show(),"function"==typeof f.tooltipOptions.onHover&&f.tooltipOptions.onHover(a,e))},b.hideTooltip=function(){f.getDomElement().hide().html("")},b.removeTooltip=function(){f.getDomElement().remove()}},c.prototype.getDomElement=function(){var b=a("
");return this.tooltipOptions&&this.tooltipOptions.cssClass&&(b=a("."+this.tooltipOptions.cssClass),0===b.length&&(b=a("
").addClass(this.tooltipOptions.cssClass),b.appendTo("body").hide().css({position:"absolute"}),this.tooltipOptions.defaultTheme&&b.css({background:"#fff","z-index":"1040",padding:"0.4em 0.6em","border-radius":"0.5em","font-size":"0.8em",border:"1px solid #111",display:"none","white-space":"nowrap"}))),b},c.prototype.stringFormat=function(a,b){var c,d,e,f,g,h=/%p\.{0,1}(\d{0,})/,i=/%s/,j=/%c/,k=/%lx/,l=/%ly/,m=/%x\.{0,1}(\d{0,})/,n=/%y\.{0,1}(\d{0,})/,o="%x",p="%y",q="%ct",r="%n";if("undefined"!=typeof b.series.threshold?(c=b.datapoint[0],d=b.datapoint[1],e=b.datapoint[2]):"undefined"!=typeof b.series.curvedLines?(c=b.datapoint[0],d=b.datapoint[1]):"undefined"!=typeof b.series.lines&&b.series.lines.steps?(c=b.series.datapoints.points[2*b.dataIndex],d=b.series.datapoints.points[2*b.dataIndex+1],e=""):(c=b.series.data[b.dataIndex][0],d=b.series.data[b.dataIndex][1],e=b.series.data[b.dataIndex][2]),null===b.series.label&&b.series.originSeries&&(b.series.label=b.series.originSeries.label),"function"==typeof a&&(a=a(b.series.label,c,d,b)),"boolean"==typeof a&&!a)return"";if(e&&(a=a.replace(q,e)),"undefined"!=typeof b.series.percent?f=b.series.percent:"undefined"!=typeof b.series.percents&&(f=b.series.percents[b.dataIndex]),"number"==typeof f&&(a=this.adjustValPrecision(h,a,f)),b.series.hasOwnProperty("pie")&&"undefined"!=typeof b.series.data[0][1]&&(g=b.series.data[0][1]),"number"==typeof g&&(a=a.replace(r,g)),a="undefined"!=typeof b.series.label?a.replace(i,b.series.label):a.replace(i,""),a="undefined"!=typeof b.series.color?a.replace(j,b.series.color):a.replace(j,""),a=this.hasAxisLabel("xaxis",b)?a.replace(k,b.series.xaxis.options.axisLabel):a.replace(k,""),a=this.hasAxisLabel("yaxis",b)?a.replace(l,b.series.yaxis.options.axisLabel):a.replace(l,""),this.isTimeMode("xaxis",b)&&this.isXDateFormat(b)&&(a=a.replace(m,this.timestampToDate(c,this.tooltipOptions.xDateFormat,b.series.xaxis.options))),this.isTimeMode("yaxis",b)&&this.isYDateFormat(b)&&(a=a.replace(n,this.timestampToDate(d,this.tooltipOptions.yDateFormat,b.series.yaxis.options))),"number"==typeof c&&(a=this.adjustValPrecision(m,a,c)),"number"==typeof d&&(a=this.adjustValPrecision(n,a,d)),"undefined"!=typeof b.series.xaxis.ticks){var s;s=this.hasRotatedXAxisTicks(b)?"rotatedTicks":"ticks";var t=b.dataIndex+b.seriesIndex;for(var u in b.series.xaxis[s])if(b.series.xaxis[s].hasOwnProperty(t)&&!this.isTimeMode("xaxis",b)){var v=this.isCategoriesMode("xaxis",b)?b.series.xaxis[s][t].label:b.series.xaxis[s][t].v;v===c&&(a=a.replace(m,b.series.xaxis[s][t].label.replace(/\$/g,"$$$$")))}}if("undefined"!=typeof b.series.yaxis.ticks)for(var w in b.series.yaxis.ticks)if(b.series.yaxis.ticks.hasOwnProperty(w)){var x=this.isCategoriesMode("yaxis",b)?b.series.yaxis.ticks[w].label:b.series.yaxis.ticks[w].v;x===d&&(a=a.replace(n,b.series.yaxis.ticks[w].label.replace(/\$/g,"$$$$")))}return"undefined"!=typeof b.series.xaxis.tickFormatter&&(a=a.replace(o,b.series.xaxis.tickFormatter(c,b.series.xaxis).replace(/\$/g,"$$"))),"undefined"!=typeof b.series.yaxis.tickFormatter&&(a=a.replace(p,b.series.yaxis.tickFormatter(d,b.series.yaxis).replace(/\$/g,"$$"))),a},c.prototype.isTimeMode=function(a,b){return"undefined"!=typeof b.series[a].options.mode&&"time"===b.series[a].options.mode},c.prototype.isXDateFormat=function(a){return"undefined"!=typeof this.tooltipOptions.xDateFormat&&null!==this.tooltipOptions.xDateFormat},c.prototype.isYDateFormat=function(a){return"undefined"!=typeof this.tooltipOptions.yDateFormat&&null!==this.tooltipOptions.yDateFormat},c.prototype.isCategoriesMode=function(a,b){return"undefined"!=typeof b.series[a].options.mode&&"categories"===b.series[a].options.mode},c.prototype.timestampToDate=function(b,c,d){var e=a.plot.dateGenerator(b,d);return a.plot.formatDate(e,c,this.tooltipOptions.monthNames,this.tooltipOptions.dayNames)},c.prototype.adjustValPrecision=function(a,b,c){var d,e=b.match(a);return null!==e&&""!==RegExp.$1&&(d=RegExp.$1,c=c.toFixed(d),b=b.replace(a,c)),b},c.prototype.hasAxisLabel=function(b,c){return-1!==a.inArray("axisLabels",this.plotPlugins)&&"undefined"!=typeof c.series[b].options.axisLabel&&c.series[b].options.axisLabel.length>0},c.prototype.hasRotatedXAxisTicks=function(b){return-1!==a.inArray("tickRotor",this.plotPlugins)&&"undefined"!=typeof b.series.xaxis.rotatedTicks};var d=function(a){new c(a)};a.plot.plugins.push({init:d,options:b,name:"tooltip",version:"0.8.5"})}(jQuery); \ No newline at end of file diff --git a/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.source.js b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.source.js new file mode 100644 index 0000000..0e49edc --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.source.js @@ -0,0 +1,595 @@ +(function ($) { + // plugin options, default values + var defaultOptions = { + tooltip: { + show: false, + cssClass: "flotTip", + content: "%s | X: %x | Y: %y", + // allowed templates are: + // %s -> series label, + // %c -> series color, + // %lx -> x axis label (requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels), + // %ly -> y axis label (requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels), + // %x -> X value, + // %y -> Y value, + // %x.2 -> precision of X value, + // %p -> percent + // %n -> value (not percent) of pie chart + xDateFormat: null, + yDateFormat: null, + monthNames: null, + dayNames: null, + shifts: { + x: 10, + y: 20 + }, + defaultTheme: true, + snap: true, + lines: false, + clickTips: false, + + // callbacks + onHover: function (flotItem, $tooltipEl) {}, + + $compat: false + } + }; + + // dummy default options object for legacy code (<0.8.5) - is deleted later + defaultOptions.tooltipOpts = defaultOptions.tooltip; + + // object + var FlotTooltip = function (plot) { + // variables + this.tipPosition = {x: 0, y: 0}; + + this.init(plot); + }; + + // main plugin function + FlotTooltip.prototype.init = function (plot) { + var that = this; + + // detect other flot plugins + var plotPluginsLength = $.plot.plugins.length; + this.plotPlugins = []; + + if (plotPluginsLength) { + for (var p = 0; p < plotPluginsLength; p++) { + this.plotPlugins.push($.plot.plugins[p].name); + } + } + + plot.hooks.bindEvents.push(function (plot, eventHolder) { + + // get plot options + that.plotOptions = plot.getOptions(); + + // for legacy (<0.8.5) implementations + if (typeof(that.plotOptions.tooltip) === 'boolean') { + that.plotOptions.tooltipOpts.show = that.plotOptions.tooltip; + that.plotOptions.tooltip = that.plotOptions.tooltipOpts; + delete that.plotOptions.tooltipOpts; + } + + // if not enabled return + if (that.plotOptions.tooltip.show === false || typeof that.plotOptions.tooltip.show === 'undefined') return; + + // shortcut to access tooltip options + that.tooltipOptions = that.plotOptions.tooltip; + + if (that.tooltipOptions.$compat) { + that.wfunc = 'width'; + that.hfunc = 'height'; + } else { + that.wfunc = 'innerWidth'; + that.hfunc = 'innerHeight'; + } + + // create tooltip DOM element + var $tip = that.getDomElement(); + + // bind event + $( plot.getPlaceholder() ).bind("plothover", plothover); + if (that.tooltipOptions.clickTips) { + $( plot.getPlaceholder() ).bind("plotclick", plotclick); + } + that.clickmode = false; + + $(eventHolder).bind('mousemove', mouseMove); + }); + + plot.hooks.shutdown.push(function (plot, eventHolder){ + $(plot.getPlaceholder()).unbind("plothover", plothover); + $(plot.getPlaceholder()).unbind("plotclick", plotclick); + plot.removeTooltip(); + $(eventHolder).unbind("mousemove", mouseMove); + }); + + function mouseMove(e){ + var pos = {}; + pos.x = e.pageX; + pos.y = e.pageY; + plot.setTooltipPosition(pos); + } + + /** + * open the tooltip (if not already open) and freeze it on the current position till the next click + */ + function plotclick(event, pos, item) { + if (! that.clickmode) { + // it is the click activating the clicktip + plothover(event, pos, item); + if (that.getDomElement().is(":visible")) { + $(plot.getPlaceholder()).unbind("plothover", plothover); + that.clickmode = true; + } + } else { + // it is the click deactivating the clicktip + $( plot.getPlaceholder() ).bind("plothover", plothover); + plot.hideTooltip(); + that.clickmode = false; + } + } + + function plothover(event, pos, item) { + // Simple distance formula. + var lineDistance = function (p1x, p1y, p2x, p2y) { + return Math.sqrt((p2x - p1x) * (p2x - p1x) + (p2y - p1y) * (p2y - p1y)); + }; + + // Here is some voodoo magic for determining the distance to a line form a given point {x, y}. + var dotLineLength = function (x, y, x0, y0, x1, y1, o) { + if (o && !(o = + function (x, y, x0, y0, x1, y1) { + if (typeof x0 !== 'undefined') return { x: x0, y: y }; + else if (typeof y0 !== 'undefined') return { x: x, y: y0 }; + + var left, + tg = -1 / ((y1 - y0) / (x1 - x0)); + + return { + x: left = (x1 * (x * tg - y + y0) + x0 * (x * -tg + y - y1)) / (tg * (x1 - x0) + y0 - y1), + y: tg * left - tg * x + y + }; + } (x, y, x0, y0, x1, y1), + o.x >= Math.min(x0, x1) && o.x <= Math.max(x0, x1) && o.y >= Math.min(y0, y1) && o.y <= Math.max(y0, y1)) + ) { + var l1 = lineDistance(x, y, x0, y0), l2 = lineDistance(x, y, x1, y1); + return l1 > l2 ? l2 : l1; + } else { + var a = y0 - y1, b = x1 - x0, c = x0 * y1 - y0 * x1; + return Math.abs(a * x + b * y + c) / Math.sqrt(a * a + b * b); + } + }; + + if (item) { + plot.showTooltip(item, that.tooltipOptions.snap ? item : pos); + } else if (that.plotOptions.series.lines.show && that.tooltipOptions.lines === true) { + var maxDistance = that.plotOptions.grid.mouseActiveRadius; + + var closestTrace = { + distance: maxDistance + 1 + }; + + var ttPos = pos; + + $.each(plot.getData(), function (i, series) { + var xBeforeIndex = 0, + xAfterIndex = -1; + + // Our search here assumes our data is sorted via the x-axis. + // TODO: Improve efficiency somehow - search smaller sets of data. + for (var j = 1; j < series.data.length; j++) { + if (series.data[j - 1][0] <= pos.x && series.data[j][0] >= pos.x) { + xBeforeIndex = j - 1; + xAfterIndex = j; + } + } + + if (xAfterIndex === -1) { + plot.hideTooltip(); + return; + } + + var pointPrev = { x: series.data[xBeforeIndex][0], y: series.data[xBeforeIndex][1] }, + pointNext = { x: series.data[xAfterIndex][0], y: series.data[xAfterIndex][1] }; + + var distToLine = dotLineLength(series.xaxis.p2c(pos.x), series.yaxis.p2c(pos.y), series.xaxis.p2c(pointPrev.x), + series.yaxis.p2c(pointPrev.y), series.xaxis.p2c(pointNext.x), series.yaxis.p2c(pointNext.y), false); + + if (distToLine < closestTrace.distance) { + + var closestIndex = lineDistance(pointPrev.x, pointPrev.y, pos.x, pos.y) < + lineDistance(pos.x, pos.y, pointNext.x, pointNext.y) ? xBeforeIndex : xAfterIndex; + + var pointSize = series.datapoints.pointsize; + + // Calculate the point on the line vertically closest to our cursor. + var pointOnLine = [ + pos.x, + pointPrev.y + ((pointNext.y - pointPrev.y) * ((pos.x - pointPrev.x) / (pointNext.x - pointPrev.x))) + ]; + + var item = { + datapoint: pointOnLine, + dataIndex: closestIndex, + series: series, + seriesIndex: i + }; + + closestTrace = { + distance: distToLine, + item: item + }; + + if (that.tooltipOptions.snap) { + ttPos = { + pageX: series.xaxis.p2c(pointOnLine[0]), + pageY: series.yaxis.p2c(pointOnLine[1]) + }; + } + } + }); + + if (closestTrace.distance < maxDistance + 1) + plot.showTooltip(closestTrace.item, ttPos); + else + plot.hideTooltip(); + } else { + plot.hideTooltip(); + } + } + + // Quick little function for setting the tooltip position. + plot.setTooltipPosition = function (pos) { + var $tip = that.getDomElement(); + + var totalTipWidth = $tip.outerWidth() + that.tooltipOptions.shifts.x; + var totalTipHeight = $tip.outerHeight() + that.tooltipOptions.shifts.y; + if ((pos.x - $(window).scrollLeft()) > ($(window)[that.wfunc]() - totalTipWidth)) { + pos.x -= totalTipWidth; + pos.x = Math.max(pos.x, 0); + } + if ((pos.y - $(window).scrollTop()) > ($(window)[that.hfunc]() - totalTipHeight)) { + pos.y -= totalTipHeight; + } + + /* + The section applies the new positioning ONLY if pos.x and pos.y + are numbers. If they are undefined or not a number, use the last + known numerical position. This hack fixes a bug that kept pie + charts from keeping their tooltip positioning. + */ + + if (isNaN(pos.x)) { + that.tipPosition.x = that.tipPosition.xPrev; + } + else { + that.tipPosition.x = pos.x; + that.tipPosition.xPrev = pos.x; + } + if (isNaN(pos.y)) { + that.tipPosition.y = that.tipPosition.yPrev; + } + else { + that.tipPosition.y = pos.y; + that.tipPosition.yPrev = pos.y; + } + + }; + + // Quick little function for showing the tooltip. + plot.showTooltip = function (target, position, targetPosition) { + var $tip = that.getDomElement(); + + // convert tooltip content template to real tipText + var tipText = that.stringFormat(that.tooltipOptions.content, target); + if (tipText === '') + return; + + $tip.html(tipText); + plot.setTooltipPosition({ x: that.tipPosition.x, y: that.tipPosition.y }); + $tip.css({ + left: that.tipPosition.x + that.tooltipOptions.shifts.x, + top: that.tipPosition.y + that.tooltipOptions.shifts.y + }).show(); + + // run callback + if (typeof that.tooltipOptions.onHover === 'function') { + that.tooltipOptions.onHover(target, $tip); + } + }; + + // Quick little function for hiding the tooltip. + plot.hideTooltip = function () { + that.getDomElement().hide().html(''); + }; + + plot.removeTooltip = function() { + that.getDomElement().remove(); + }; + }; + + /** + * get or create tooltip DOM element + * @return jQuery object + */ + FlotTooltip.prototype.getDomElement = function () { + var $tip = $('
'); + if (this.tooltipOptions && this.tooltipOptions.cssClass) { + $tip = $('.' + this.tooltipOptions.cssClass); + + if( $tip.length === 0 ){ + $tip = $('
').addClass(this.tooltipOptions.cssClass); + $tip.appendTo('body').hide().css({position: 'absolute'}); + + if(this.tooltipOptions.defaultTheme) { + $tip.css({ + 'background': '#fff', + 'z-index': '1040', + 'padding': '0.4em 0.6em', + 'border-radius': '0.5em', + 'font-size': '0.8em', + 'border': '1px solid #111', + 'display': 'none', + 'white-space': 'nowrap' + }); + } + } + } + + return $tip; + }; + + /** + * core function, create tooltip content + * @param {string} content - template with tooltip content + * @param {object} item - Flot item + * @return {string} real tooltip content for current item + */ + FlotTooltip.prototype.stringFormat = function (content, item) { + var percentPattern = /%p\.{0,1}(\d{0,})/; + var seriesPattern = /%s/; + var colorPattern = /%c/; + var xLabelPattern = /%lx/; // requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels, will be ignored if plugin isn't loaded + var yLabelPattern = /%ly/; // requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels, will be ignored if plugin isn't loaded + var xPattern = /%x\.{0,1}(\d{0,})/; + var yPattern = /%y\.{0,1}(\d{0,})/; + var xPatternWithoutPrecision = "%x"; + var yPatternWithoutPrecision = "%y"; + var customTextPattern = "%ct"; + var nPiePattern = "%n"; + + var x, y, customText, p, n; + + // for threshold plugin we need to read data from different place + if (typeof item.series.threshold !== "undefined") { + x = item.datapoint[0]; + y = item.datapoint[1]; + customText = item.datapoint[2]; + } + + // for CurvedLines plugin we need to read data from different place + else if (typeof item.series.curvedLines !== "undefined") { + x = item.datapoint[0]; + y = item.datapoint[1]; + } + + else if (typeof item.series.lines !== "undefined" && item.series.lines.steps) { + x = item.series.datapoints.points[item.dataIndex * 2]; + y = item.series.datapoints.points[item.dataIndex * 2 + 1]; + // TODO: where to find custom text in this variant? + customText = ""; + } else { + x = item.series.data[item.dataIndex][0]; + y = item.series.data[item.dataIndex][1]; + customText = item.series.data[item.dataIndex][2]; + } + + // I think this is only in case of threshold plugin + if (item.series.label === null && item.series.originSeries) { + item.series.label = item.series.originSeries.label; + } + + // if it is a function callback get the content string + if (typeof(content) === 'function') { + content = content(item.series.label, x, y, item); + } + + // the case where the passed content is equal to false + if (typeof(content) === 'boolean' && !content) { + return ''; + } + + /* replacement of %ct and other multi-character templates must + precede the replacement of single-character templates + to avoid conflict between '%c' and '%ct' and similar substrings + */ + if (customText) { + content = content.replace(customTextPattern, customText); + } + + // percent match for pie charts and stacked percent + if (typeof (item.series.percent) !== 'undefined') { + p = item.series.percent; + } else if (typeof (item.series.percents) !== 'undefined') { + p = item.series.percents[item.dataIndex]; + } + if (typeof p === 'number') { + content = this.adjustValPrecision(percentPattern, content, p); + } + + // replace %n with number of items represented by slice in pie charts + if (item.series.hasOwnProperty('pie')) { + if (typeof item.series.data[0][1] !== 'undefined') { + n = item.series.data[0][1]; + } + } + if (typeof n === 'number') { + content = content.replace(nPiePattern, n); + } + + // series match + if (typeof(item.series.label) !== 'undefined') { + content = content.replace(seriesPattern, item.series.label); + } else { + //remove %s if label is undefined + content = content.replace(seriesPattern, ""); + } + + // color match + if (typeof(item.series.color) !== 'undefined') { + content = content.replace(colorPattern, item.series.color); + } else { + //remove %s if color is undefined + content = content.replace(colorPattern, ""); + } + + // x axis label match + if (this.hasAxisLabel('xaxis', item)) { + content = content.replace(xLabelPattern, item.series.xaxis.options.axisLabel); + } else { + //remove %lx if axis label is undefined or axislabels plugin not present + content = content.replace(xLabelPattern, ""); + } + + // y axis label match + if (this.hasAxisLabel('yaxis', item)) { + content = content.replace(yLabelPattern, item.series.yaxis.options.axisLabel); + } else { + //remove %ly if axis label is undefined or axislabels plugin not present + content = content.replace(yLabelPattern, ""); + } + + // time mode axes with custom dateFormat + if (this.isTimeMode('xaxis', item) && this.isXDateFormat(item)) { + content = content.replace(xPattern, this.timestampToDate(x, this.tooltipOptions.xDateFormat, item.series.xaxis.options)); + } + if (this.isTimeMode('yaxis', item) && this.isYDateFormat(item)) { + content = content.replace(yPattern, this.timestampToDate(y, this.tooltipOptions.yDateFormat, item.series.yaxis.options)); + } + + // set precision if defined + if (typeof x === 'number') { + content = this.adjustValPrecision(xPattern, content, x); + } + if (typeof y === 'number') { + content = this.adjustValPrecision(yPattern, content, y); + } + + // change x from number to given label, if given + if (typeof item.series.xaxis.ticks !== 'undefined') { + + var ticks; + if (this.hasRotatedXAxisTicks(item)) { + // xaxis.ticks will be an empty array if tickRotor is being used, but the values are available in rotatedTicks + ticks = 'rotatedTicks'; + } else { + ticks = 'ticks'; + } + + // see https://github.com/krzysu/flot.tooltip/issues/65 + var tickIndex = item.dataIndex + item.seriesIndex; + + for (var xIndex in item.series.xaxis[ticks]) { + if (item.series.xaxis[ticks].hasOwnProperty(tickIndex) && !this.isTimeMode('xaxis', item)) { + var valueX = (this.isCategoriesMode('xaxis', item)) ? item.series.xaxis[ticks][tickIndex].label : item.series.xaxis[ticks][tickIndex].v; + if (valueX === x) { + content = content.replace(xPattern, item.series.xaxis[ticks][tickIndex].label.replace(/\$/g, '$$$$')); + } + } + } + } + + // change y from number to given label, if given + if (typeof item.series.yaxis.ticks !== 'undefined') { + for (var yIndex in item.series.yaxis.ticks) { + if (item.series.yaxis.ticks.hasOwnProperty(yIndex)) { + var valueY = (this.isCategoriesMode('yaxis', item)) ? item.series.yaxis.ticks[yIndex].label : item.series.yaxis.ticks[yIndex].v; + if (valueY === y) { + content = content.replace(yPattern, item.series.yaxis.ticks[yIndex].label.replace(/\$/g, '$$$$')); + } + } + } + } + + // if no value customization, use tickFormatter by default + if (typeof item.series.xaxis.tickFormatter !== 'undefined') { + //escape dollar + content = content.replace(xPatternWithoutPrecision, item.series.xaxis.tickFormatter(x, item.series.xaxis).replace(/\$/g, '$$')); + } + if (typeof item.series.yaxis.tickFormatter !== 'undefined') { + //escape dollar + content = content.replace(yPatternWithoutPrecision, item.series.yaxis.tickFormatter(y, item.series.yaxis).replace(/\$/g, '$$')); + } + + return content; + }; + + // helpers just for readability + FlotTooltip.prototype.isTimeMode = function (axisName, item) { + return (typeof item.series[axisName].options.mode !== 'undefined' && item.series[axisName].options.mode === 'time'); + }; + + FlotTooltip.prototype.isXDateFormat = function (item) { + return (typeof this.tooltipOptions.xDateFormat !== 'undefined' && this.tooltipOptions.xDateFormat !== null); + }; + + FlotTooltip.prototype.isYDateFormat = function (item) { + return (typeof this.tooltipOptions.yDateFormat !== 'undefined' && this.tooltipOptions.yDateFormat !== null); + }; + + FlotTooltip.prototype.isCategoriesMode = function (axisName, item) { + return (typeof item.series[axisName].options.mode !== 'undefined' && item.series[axisName].options.mode === 'categories'); + }; + + // + FlotTooltip.prototype.timestampToDate = function (tmst, dateFormat, options) { + var theDate = $.plot.dateGenerator(tmst, options); + return $.plot.formatDate(theDate, dateFormat, this.tooltipOptions.monthNames, this.tooltipOptions.dayNames); + }; + + // + FlotTooltip.prototype.adjustValPrecision = function (pattern, content, value) { + + var precision; + var matchResult = content.match(pattern); + if( matchResult !== null ) { + if(RegExp.$1 !== '') { + precision = RegExp.$1; + value = value.toFixed(precision); + + // only replace content if precision exists, in other case use thickformater + content = content.replace(pattern, value); + } + } + return content; + }; + + // other plugins detection below + + // check if flot-axislabels plugin (https://github.com/markrcote/flot-axislabels) is used and that an axis label is given + FlotTooltip.prototype.hasAxisLabel = function (axisName, item) { + return ($.inArray('axisLabels', this.plotPlugins) !== -1 && typeof item.series[axisName].options.axisLabel !== 'undefined' && item.series[axisName].options.axisLabel.length > 0); + }; + + // check whether flot-tickRotor, a plugin which allows rotation of X-axis ticks, is being used + FlotTooltip.prototype.hasRotatedXAxisTicks = function (item) { + return ($.inArray('tickRotor',this.plotPlugins) !== -1 && typeof item.series.xaxis.rotatedTicks !== 'undefined'); + }; + + // + var init = function (plot) { + new FlotTooltip(plot); + }; + + // define Flot plugin + $.plot.plugins.push({ + init: init, + options: defaultOptions, + name: 'tooltip', + version: '0.8.5' + }); + +})(jQuery); diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js index 4dceaf8..5f4ac19 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js @@ -47,6 +47,10 @@ config.app = { "auto_detect_cooling":{"type":"checkbox", "default":false, "name": "Auto detect cooling", "description":"Auto detect summer cooling if cooling status feed is not present"}, "enable_process_daily":{"type":"checkbox", "default":false, "name": "Enable daily pre-processor", "description":"Enable split between water and space heating in daily view"}, "start_date": { "type": "value", "default": 0, "name": "Start date", "description": _("Start date for all time values (unix timestamp)") }, + + // solar + "solar_elec_kwh": { "type": "feed", "autoname": "solar_elec_kwh", "optional": true, "description": "Cumulative solar energy kWh" }, + }; config.feeds = feed.list(); @@ -446,6 +450,11 @@ function resize() { placeholder.height(height - top_offset); + // Add resize logic for the heat loss plot if it's visible + if ($("#heatloss-block").is(":visible")) { + plotHeatLossScatter(); // <<< CALL REMAINS + } + if (viewmode == "bargraph") { bargraph_draw(); @@ -586,6 +595,155 @@ $("#clear-daily-data").click(function () { }); }); + +// --- Heat Loss Panel Toggle --- +$("#heatloss-toggle").click(function () { + var $contentBlock = $("#heatloss-block"); + var $toggleText = $("#heatloss-toggle-text"); + var $arrow = $("#heatloss-arrow"); + + if ($contentBlock.is(":visible")) { + // Hiding Logic (Stays the same) + $contentBlock.slideUp(); // Start hiding animation + $(this).css("background-color", ""); + $toggleText.text("SHOW HEAT DEMAND ANALYSIS"); + $arrow.html("►"); + + } else { + // Showing Logic (Modified) + // These updates can happen immediately + $(this).css("background-color", "#4a6d8c"); + $toggleText.text("HIDE HEAT DEMAND ANALYSIS"); + $arrow.html("▼"); + + // Start the slideDown animation AND provide a callback function + $contentBlock.slideDown(function() { + // --- This code runs AFTER slideDown completes --- + console.log("Heat Loss Panel: slideDown complete."); + + // Now it's safe to resize and plot + resize(); // Ensure container dimensions are recalculated based on final state + plotHeatLossScatter(); // Plot into the correctly sized, visible container + + // Debugging log at the correct time + if (typeof daily_data !== 'undefined' && daily_data.combined_elec_kwh) { + console.log("Heat Loss Panel (Post-Slide): Accessing daily_data..."); + } else { + console.log("Heat Loss Panel (Post-Slide): daily_data not yet available..."); + } + // --- End of callback --- + }); + } +}); +// --- End Heat Loss Panel Toggle --- + +// 1. Minimum Delta T Input Change +$("#heatloss_min_deltaT").on('input change', function() { + // Only replot if the panel is actually visible + if ($("#heatloss-block").is(":visible")) { + // Basic validation if needed (e.g., ensure it's a number) + // let value = parseFloat($(this).val()); + // if (!isNaN(value)) { ... } + plotHeatLossScatter(); // Call the plotting function (defined in heatloss.js) + } +}); + +// 2. Fixed Room Temperature Checkbox Change +$("#heatloss_fixed_roomT_check").on('change', function() { + var isChecked = $(this).is(":checked"); + // Enable/disable the associated value input based on checkbox state + $("#heatloss_fixed_roomT_value").prop('disabled', !isChecked); + + // Replot if the panel is visible + if ($("#heatloss-block").is(":visible")) { + plotHeatLossScatter(); + } +}); + +// 3. Fixed Room Temperature Value Input Change +$("#heatloss_fixed_roomT_value").on('input change', function() { + // Only replot if the panel is visible AND the checkbox is checked + if ($("#heatloss-block").is(":visible") && $("#heatloss_fixed_roomT_check").is(":checked")) { + plotHeatLossScatter(); + } +}); + +// 4. Split Data Checkbox Change Event +$("#heatloss_split_data_check").on('change', function() { + var isChecked = $(this).is(":checked"); + var $radioButtons = $('input[name="heatloss_split_by"]'); + var $regressionCheck = $("#heatloss_split_regression_check"); + + // Enable/disable radio buttons + $radioButtons.prop('disabled', !isChecked); + // Enable/disable the regression checkbox + $regressionCheck.prop('disabled', !isChecked); + + // If main checkbox is unchecked, also uncheck radios and regression checkbox + if (!isChecked) { + $radioButtons.prop('checked', false); + $regressionCheck.prop('checked', false); + } + // Optional: If checking, and nothing is selected, select a default (e.g., year) + else if (isChecked && $radioButtons.filter(':checked').length === 0) { + $('#heatloss_split_by_year').prop('checked', true); + // Note: Manually setting 'checked' won't trigger its 'change' event here. + // If the plot needs to update immediately based on the default selection, + // you might need to explicitly call plotHeatLossScatter() here too, + // or trigger the change event: $('#heatloss_split_by_year').trigger('change'); + } + + // Replot if the panel is visible + if ($("#heatloss-block").is(":visible")) { + // Assuming plotHeatLossScatter is defined elsewhere + plotHeatLossScatter(); + } +}); + +// 5. Split Data Radio Button Change Event +$('input[name="heatloss_split_by"]').on('change', function() { + // Only replot if the panel is visible AND the main split checkbox is checked + if ($("#heatloss-block").is(":visible") && $("#heatloss_split_data_check").is(":checked")) { + // Assuming plotHeatLossScatter is defined elsewhere + plotHeatLossScatter(); + } +}); + +// 6. Split Regression Checkbox Change Event +$("#heatloss_split_regression_check").on('change', function() { + // Only replot if the panel is visible AND the main split checkbox is checked + if ($("#heatloss-block").is(":visible") && $("#heatloss_split_data_check").is(":checked")) { + // Assuming plotHeatLossScatter is defined elsewhere + plotHeatLossScatter(); + } +}); + + +// 7. Solar Gain Coloring Change event +$("#heatloss_solar_gain_color").on('change', function() { + // Only replot if the panel is visible + if ($("#heatloss-block").is(":visible")) { + plotHeatLossScatter(); // Call the main plotting function + } +}); + +// 8. Minimum Quality Input Change +$("#heatloss_min_quality").on('input change', function() { + // Only replot if the panel is actually visible + if ($("#heatloss-block").is(":visible")) { + plotHeatLossScatter(); // Call the plotting function (defined in heatloss.js) + } +}); + +// 8. Minimum Heat Input Change +$("#heatloss_min_heat").on('input change', function() { + // Only replot if the panel is actually visible + if ($("#heatloss-block").is(":visible")) { + plotHeatLossScatter(); // Call the plotting function (defined in heatloss.js) + } +}); +// --- End Heat Loss Control Event Listeners --- + $("#show_dhw_temp").click(function () { if ($("#show_dhw_temp")[0].checked) { show_dhw_temp = true; @@ -595,4 +753,5 @@ $("#show_dhw_temp").click(function () { if (viewmode == "powergraph") { powergraph_draw(); } -}); \ No newline at end of file +}); + diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 107c176..2f03eeb 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -13,6 +13,10 @@ + + + + @@ -426,7 +430,117 @@
+ + +
+
+ +
+ +
+ SHOW HEAT DEMAND ANALYSIS + +
+
+ + + +
+
+ +
@@ -466,8 +580,13 @@ config.db = ; - + + + + + + diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_bargraph.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_bargraph.js index d10fc61..8146f8f 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_bargraph.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_bargraph.js @@ -427,6 +427,16 @@ function bargraph_draw() { var plot = $.plot($('#placeholder'), bargraph_series, options); $('#placeholder').append("
"); } + + if ($("#heatloss-block").is(":visible")) { + // Check if the function exists before calling (good practice) + if (typeof plotHeatLossScatter === 'function') { + console.log("Bargraph drawn, updating visible Heat Loss plot."); + plotHeatLossScatter(); + } else { + console.warn("bargraph_draw: plotHeatLossScatter function not found. Ensure myheatpump_heatloss.js is loaded."); + } + } } function bargraph_tooltip(item) @@ -544,6 +554,11 @@ $(".bargraph_mode").click(function () { bargraph_mode = mode; bargraph_draw(); + + // If the heat loss panel is visible, update its plot too + if ($("#heatloss-block").is(":visible")) { + plotHeatLossScatter(); // <<< CALL REMAINS + } }); $('.bargraph-day').click(function () { diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js new file mode 100644 index 0000000..3606887 --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -0,0 +1,941 @@ +/* Plotly Heat Loss Plot Implementation */ + +// --- Color Management (Optional but helps match scatter/line) --- +const plotColors = [ + '#FF6347', // Tomato + '#4682B4', // SteelBlue + '#32CD32', // LimeGreen + '#FFD700', // Gold + '#6A5ACD', // SlateBlue + '#DAA520', // GoldenRod + '#8A2BE2', // BlueViolet + '#5F9EA0', // CadetBlue + '#D2691E', // Chocolate + '#FF7F50', // Coral +]; +let colorIndex = 0; + +function getNextPlotColor() { + const color = plotColors[colorIndex % plotColors.length]; + colorIndex++; + return color; +} +function resetPlotColorIndex() { + colorIndex = 0; +} + +/** + * Reads and validates inputs from the Heat Loss plot settings UI. + * (Identical to original - no changes needed here) + * @returns {object|null} An object containing validated configuration, or null if validation fails. + */ +function getHeatLossInputs() { + const config = {}; + + // Basic settings + config.bargraph_mode = bargraph_mode; // Use global bargraph_mode + config.minDeltaT = parseFloat($("#heatloss_min_deltaT").val()); + if (isNaN(config.minDeltaT)) { + console.warn("Heat Loss Plot: Invalid Minimum ΔT input, using no minimum."); + config.minDeltaT = -Infinity; + } + + // Fixed Room Temperature settings + config.useFixedRoomT_input = $("#heatloss_fixed_roomT_check").is(":checked"); + config.fixedRoomTValue_input = parseFloat($("#heatloss_fixed_roomT_value").val()); + if (isNaN(config.fixedRoomTValue_input)) { + config.fixedRoomTValue_input = 20; // Default if invalid + console.warn("Heat Loss Plot: Invalid Fixed Room Temp input, using default 20°C."); + } + // Determine actual value to use (considering feed availability later) + config.fixedRoomTValue = config.fixedRoomTValue_input; + + // Feed configuration status + config.roomTempFeedConfigured = !!feeds["heatpump_roomT"]; + config.outsideTempFeedConfigured = !!feeds["heatpump_outsideT"]; + + // Decide if fixed T should be used + config.shouldUseFixedRoomT = config.useFixedRoomT_input || !config.roomTempFeedConfigured; + + // Splitting settings + config.splitDataEnabled = $("#heatloss_split_data_check").is(":checked"); + config.splitByValue = null; + if (config.splitDataEnabled) { + var $checkedRadio = $('input[name="heatloss_split_by"]:checked'); + if ($checkedRadio.length > 0) { + config.splitByValue = $checkedRadio.val(); // 'year' or 'season' + } else { + console.warn("Heat Loss Plot: Split data enabled, but no split dimension selected. Disabling split."); + config.splitDataEnabled = false; // Disable if no dimension selected + } + } + config.splitRegressionEnabled = config.splitDataEnabled && $("#heatloss_split_regression_check").is(":checked"); // Regression split only possible if data is split + + // Color by solar gain? + config.solarColoringEnabled = $("#heatloss_solar_gain_color").is(":checked"); + + // filtering parameters + config.minQuality = parseFloat($("#heatloss_min_quality").val()); + if (isNaN(config.minQuality)) { + console.warn("Heat Loss Plot: Invalid Minimum Quality input, using no minimum."); + config.minQuality = 0; + } + + config.minHeat = parseFloat($("#heatloss_min_heat").val()); + if (isNaN(config.minHeat)) { + console.warn("Heat Loss Plot: Invalid Minimum Heat input, using no minimum."); + config.minHeat = 0; + } + + // Keys for accessing data + config.heatKey = config.bargraph_mode + "_heat_kwh"; + config.insideTKey = "combined_roomT_mean"; + config.outsideTKey = "combined_outsideT_mean"; + config.solarEKey = "combined_solar_kwh"; + config.quality_heatKey = "quality_heat"; + config.quality_insideTKey = "quality_roomT"; + config.quality_outsideTKey = "quality_outsideT"; + + console.log("Heat Loss Inputs:", config); + return config; +} + + +/** + * Checks if the necessary data is available for plotting based on the configuration. + * (Identical to original - no changes needed here) + * @param {object} config - The configuration object from getHeatLossInputs. + * @param {object} daily_data - The global daily_data object. + * @returns {{sufficient: boolean, messages: string[]}} An object indicating sufficiency and any error/warning messages. + */ +function checkDataSufficiency(config, daily_data) { + let isDataSufficient = true; + const messages = []; + + if (typeof daily_data === 'undefined' || $.isEmptyObject(daily_data)) { + return { sufficient: false, messages: ["Daily data not loaded yet."] }; + } + + // 1. Check Heat Data + if (!daily_data[config.heatKey] || daily_data[config.heatKey].length === 0) { + isDataSufficient = false; + messages.push(`Heat data ('${config.heatKey}') not found for the selected mode.`); + console.log("Heat Loss Plot: Missing or empty data for key:", config.heatKey); + } + + // 2. Check Outside Temp Data + if (!config.outsideTempFeedConfigured) { + isDataSufficient = false; + messages.push("Outside Temperature feed not configured."); + console.log("Heat Loss Plot: Outside Temperature feed not configured."); + } else if (!daily_data[config.outsideTKey] || daily_data[config.outsideTKey].length === 0) { + isDataSufficient = false; + messages.push(`Outside temperature data ('${config.outsideTKey}') not found or empty.`); + console.log("Heat Loss Plot: Missing or empty data for key:", config.outsideTKey); + } + + // 3. Check Inside Temp Data (Only if needed) + if (!config.shouldUseFixedRoomT) { + if (!config.roomTempFeedConfigured) { + isDataSufficient = false; + messages.push("Room Temperature feed not configured (and fixed value not selected)."); + console.log("Heat Loss Plot: Room Temperature feed not configured (and fixed value not selected)."); + } else if (!daily_data[config.insideTKey] || daily_data[config.insideTKey].length === 0) { + isDataSufficient = false; + messages.push(`Inside temperature data ('${config.insideTKey}') not found or empty (and fixed value not selected).`); + console.log("Heat Loss Plot: Missing or empty inside temp data (and fixed value not selected):", config.insideTKey); + } + } + + return { sufficient: isDataSufficient, messages: messages }; +} + +/** + * Prepares the data for the heat loss scatter plot, handling splitting if enabled. + * MODIFIED: Added 'timestamps' array to groups. Removed color assignment here. + * @param {object} config - The configuration object. + * @param {object} daily_data - The global daily_data object. + * @returns {object|null} An object containing grouped data { groupKey: { xValues:[], yValues:[], timestamps:[], label:'' } }, + * and overallMinX, overallMaxX, totalPoints. Returns null if no valid points found. + */ +function prepareHeatLossPlotData(config, daily_data) { + console.log("Heat Loss Plot: Preparing data..."); + if (config.shouldUseFixedRoomT) { + console.log("Heat Loss Plot: Using fixed room temperature:", config.fixedRoomTValue, "°C"); + if (!config.roomTempFeedConfigured && config.useFixedRoomT_input) { + console.log("Heat Loss Plot: Note - Fixed room temperature is being used because the Room Temperature feed is not configured."); + } + } else { + console.log("Heat Loss Plot: Using dynamic room temperature feed data ('" + config.insideTKey + "')."); + } + + const outsideTMap = new Map(daily_data[config.outsideTKey]); + const insideTMap = (!config.shouldUseFixedRoomT && daily_data[config.insideTKey]) ? new Map(daily_data[config.insideTKey]) : null; + const heatDataArray = daily_data[config.heatKey]; + const qualityHeatDataArray = daily_data[config.quality_heatKey]; + + // --- START: Fetch and Map Solar Data --- + const solarDataArray = daily_data[config.solarEKey]; + let solarDataMap = null; + let solarDataAvailable = false; + if (solarDataArray && solarDataArray.length > 0) { + solarDataMap = new Map(solarDataArray); + solarDataAvailable = true; + console.log("Heat Loss Plot: Solar data found and mapped for key:", config.solarEKey); + } else { + console.log("Heat Loss Plot: Solar data not found or empty for key:", config.solarEKey); + // Continue without solar data, coloring will default later if enabled + } + // --- END: Fetch and Map Solar Data --- + + console.log("Heat Loss Plot: Processing", heatDataArray.length, "days of data for mode", config.bargraph_mode); + + // --- Data Grouping Logic --- + const groupedData = {}; + let overallMinX = Infinity; + let overallMaxX = -Infinity; + let totalPoints = 0; + // Color assignment moved to plotting stage + + for (let i = 0; i < heatDataArray.length; i++) { + const timestamp = heatDataArray[i][0]; // Assuming timestamp is in milliseconds + const heatValue = heatDataArray[i][1] / 24.0; // kWh to kW + const qualityValue = qualityHeatDataArray[i][1]; + const outsideTValue = outsideTMap.get(timestamp); + let insideTValue; + + if (config.shouldUseFixedRoomT) { + insideTValue = config.fixedRoomTValue; + } else if (insideTMap) { + insideTValue = insideTMap.get(timestamp); + } else { + insideTValue = null; // Should not happen if sufficiency check passed + } + + // --- START: Get Solar Value --- + let solarValue = null; // Default to null if not available or not found + if (solarDataAvailable && solarDataMap.has(timestamp)) { + const rawSolarValue = solarDataMap.get(timestamp); + // Ensure it's a valid number, otherwise keep it null + if (rawSolarValue !== null && typeof rawSolarValue === 'number' && !isNaN(rawSolarValue)) { + solarValue = rawSolarValue; + } + } + // --- END: Get Solar Value --- + + // Check validity + if (heatValue !== null && typeof heatValue === 'number' && !isNaN(heatValue) && + insideTValue !== null && typeof insideTValue === 'number' && !isNaN(insideTValue) && + outsideTValue !== null && typeof outsideTValue === 'number' && !isNaN(outsideTValue) && + qualityValue >= config.minQuality && heatValue >= config.minHeat / 24.0) + { + const deltaT = insideTValue - outsideTValue; + + if (heatValue > 0 && deltaT > config.minDeltaT) { + let groupKey = "all_data"; // Default if splitting is disabled + let groupLabel = 'Daily Heat Demand
(' + config.bargraph_mode + (config.shouldUseFixedRoomT ? ', Fixed T_in=' + config.fixedRoomTValue + '°C' : '') + ')'; + // groupColor removed + + // --- Determine Group Key if Splitting --- + if (config.splitDataEnabled) { + const date = new Date(timestamp); // Ensure timestamp is in ms + const year = date.getFullYear(); + const month = date.getMonth(); // 0-11 + + if (config.splitByValue === 'year') { + groupKey = String(year); + groupLabel = `${year}`; + } else if (config.splitByValue === 'season') { + // Season: July 1st to June 30th + if (month >= 6) { // July or later + groupKey = `${year}/${year + 1}`; + groupLabel = `Season ${year}/${year + 1}`; + } else { // Before July + groupKey = `${year - 1}/${year}`; + groupLabel = `Season ${year - 1}/${year}`; + } + } + } + + // --- Initialize group if it doesn't exist --- + if (!groupedData[groupKey]) { + groupedData[groupKey] = { + xValues: [], // Holds deltaT + yValues: [], // Holds heatValue (kW) + timestamps: [], // Holds original timestamp for hover info + solarValues: [], // Holds solar values if available + label: groupLabel + // color property removed + }; + } + + // --- Add data to the group --- + groupedData[groupKey].xValues.push(deltaT); + groupedData[groupKey].yValues.push(heatValue); + groupedData[groupKey].timestamps.push(timestamp); + groupedData[groupKey].solarValues.push(solarValue); + // Update overall bounds + if (deltaT < overallMinX) overallMinX = deltaT; + if (deltaT > overallMaxX) overallMaxX = deltaT; + totalPoints++; + } + } + } + + console.log("Heat Loss Plot: Prepared", totalPoints, "valid scatter points into", Object.keys(groupedData).length, "groups."); + + if (totalPoints === 0) { + return null; // Indicate no data + } + + // Sort groups by key (e.g., year or season start year) for consistent legend order + const sortedGroupKeys = Object.keys(groupedData).sort(); + const sortedGroupedData = {}; + sortedGroupKeys.forEach(key => { + sortedGroupedData[key] = groupedData[key]; + }); + + + return { + groups: sortedGroupedData, + overallMinX: overallMinX, + overallMaxX: overallMaxX, + totalPoints: totalPoints + }; +} + + +/** + * Calculates linear regression and formats the result as a Plotly trace object + * for a line segment. USES THE USER-PROVIDED linearRegression FUNCTION. + * @param {number[]} xValues - Array of x-coordinates. + * @param {number[]} yValues - Array of y-coordinates. + * @param {string} traceNamePrefix - Prefix for the trace name (legend label). + * @param {string} color - Color for the regression line. + * @param {number} minPlotX - Minimum x value boundary for plotting the line (e.g., 0). + * @param {number} maxPlotX - Maximum x value boundary for plotting the line (e.g., 35). + * @returns {object|null} A Plotly trace object for the regression line, or null if regression fails or line is invalid. + */ +function calculatePlotlyRegressionTrace(xValues, yValues, traceNamePrefix, color, minPlotX = 0, maxPlotX = 35) { + if (!xValues || xValues.length < 2) { + console.warn("Heat Loss Plot: Not enough data points for regression for group:", traceNamePrefix); + return null; + } + + // Call the user-provided linearRegression function + const regressionResult = linearRegression(xValues, yValues); + + let regressionLabel = `${traceNamePrefix}: N/A`; + let lineX = []; + let lineY = []; + + // Check if regressionResult is valid (not null and contains slope/intercept) + if (regressionResult && typeof regressionResult.slope === 'number' && typeof regressionResult.intercept === 'number') { + const { slope, intercept, r2 } = regressionResult; + + // Format label string using the calculated r2 + regressionLabel = `HLC=${(slope * 1000).toFixed(0)} W/K` + + `, Int=${(intercept * 1000).toFixed(0)} W` + + ` (R²=${r2 !== undefined && r2 !== null ? r2.toFixed(2) : 'N/A'})`; + + + // --- Determine the actual range for the line segment [startX, endX] (respecting y >= 0 and plot bounds) --- + // (Logic copied and adapted from original) + let startX = minPlotX; + let endX = maxPlotX; + const epsilon = 1e-9; + + if (Math.abs(slope) > epsilon) { + const xIntercept = -intercept / slope; + const yAtMinPlotX = slope * minPlotX + intercept; + const yAtMaxPlotX = slope * maxPlotX + intercept; + + // Adjust startX + if (yAtMinPlotX < -epsilon && xIntercept > minPlotX) startX = xIntercept; + else if (yAtMinPlotX < -epsilon && xIntercept <= minPlotX) { + if (yAtMaxPlotX < -epsilon) { + console.warn(`Regression line for ${traceNamePrefix} is entirely below y=0 within plot range. Not plotting.`); + return null; // Entirely negative in range + } + startX = Math.max(minPlotX, xIntercept); + } else startX = minPlotX; + + // Adjust endX + if (yAtMaxPlotX < -epsilon && xIntercept < maxPlotX) endX = xIntercept; + else if (yAtMaxPlotX < -epsilon && xIntercept >= maxPlotX) endX = Math.min(maxPlotX, xIntercept); + else endX = maxPlotX; + + } else { // Horizontal line + if (intercept < -epsilon) { + console.warn(`Regression line for ${traceNamePrefix} is horizontal and below y=0. Not plotting.`); + return null; // Below y=0 + } + startX = minPlotX; + endX = maxPlotX; + } + + // Ensure startX <= endX and clamp + if (startX > endX + epsilon) { + console.warn(`Regression line for ${traceNamePrefix}: Calculated startX (${startX.toFixed(2)}) is greater than endX (${endX.toFixed(2)}). Not plotting line.`); + return null; + } + startX = Math.max(minPlotX, startX); + endX = Math.min(maxPlotX, endX); + if (startX > endX + epsilon) { + console.warn(`Regression line for ${traceNamePrefix}: Clamped startX (${startX.toFixed(2)}) is greater than clamped endX (${endX.toFixed(2)}). Not plotting line.`); + return null; + } + + + // --- Generate points for the line segment --- + // For Plotly, we only strictly need the start and end points of the valid segment + const startY = Math.max(0, slope * startX + intercept); + const endY = Math.max(0, slope * endX + intercept); + + // Check if the calculated points are valid numbers + if (isNaN(startX) || isNaN(startY) || isNaN(endX) || isNaN(endY)) { + console.warn(`Regression line for ${traceNamePrefix}: Invalid coordinates calculated (NaN). Not plotting line.`); + return null; + } + + // Ensure we have distinct points to draw a line segment + if (Math.abs(startX - endX) < epsilon && Math.abs(startY - endY) < epsilon) { + // Points are virtually identical, don't draw a zero-length line + console.warn(`Regression line for ${traceNamePrefix}: Start and end points are too close. Not plotting line.`); + return null; + } + + + lineX = [startX, endX]; + lineY = [startY, endY]; + + } else { + // linearRegression returned null or invalid data + console.warn("Heat Loss Plot: Linear regression calculation failed or returned invalid result for group:", traceNamePrefix); + return null; + } + + // Return the Plotly trace object + return { + x: lineX, + y: lineY, + mode: 'lines', + type: 'scatter', // Lines are a mode of scatter traces + name: regressionLabel, // This label contains the calculated values + line: { + color: color, + width: 2 + }, + hoverinfo: 'skip' // Don't show hover info for the line itself by default + }; +} + +/** + * Configures the layout options for the Plotly plot. + * @returns {object} The Plotly layout object. + */ +function getPlotlyLayoutOptions() { + return { + xaxis: { + title: { + text: "Temperature Difference (Tinside - Toutside) [K or °C]" // Use subscript tags + }, + rangemode: 'tozero', // Ensures axis starts at 0 (or less if data is negative) + // range: [0, 35], // Optional: set fixed range like [min, max] + gridcolor: '#eee', // Lighter grid lines + }, + yaxis: { + title: { + text: "Average Heat Output [kW]" + }, + rangemode: 'tozero', // Ensures axis starts at 0 + gridcolor: '#eee', + }, + hovermode: 'closest', // Show tooltip for the nearest point + legend: { + x: 0.01, // Position slightly offset from left + y: 0.99, // Position slightly offset from top + bgcolor: 'rgba(255, 255, 255, 0.7)', // Semi-transparent background + bordercolor: '#ccc', + borderwidth: 1 + }, + margin: { l: 60, r: 20, t: 30, b: 50 }, // Adjust margins for labels + // title: { text: "Building Heat Loss Characteristic" } // Optional main title + }; +} + +/** + * Main function to plot the Heat Loss Scatter graph using Plotly. + * Orchestrates input reading, data preparation, calculation, and plotting. + */ +function plotHeatLossScatter() { + console.log("Attempting to plot Heat Loss Scatter using Plotly..."); + const plotDiv = $("#heatloss-plot"); // Get the jQuery object + const plotElement = plotDiv[0]; // Get the raw DOM element for Plotly + + if (!plotElement) { + console.error("Heat Loss Plot: Plot container #heatloss-plot not found."); + return; + } + + // 1. Get Inputs & Config + const config = getHeatLossInputs(); + if (!config) return; + + // 2. Check Data Sufficiency + const sufficiency = checkDataSufficiency(config, daily_data); + if (!sufficiency.sufficient) { + const messageHtml = "
Cannot plot heat loss:
" + sufficiency.messages.join("
") + "
"; + plotDiv.html(messageHtml); // Use div for better centering + return; + } + + // 3. Prepare Data (Handles Splitting) + const preparedData = prepareHeatLossPlotData(config, daily_data); + if (!preparedData || preparedData.totalPoints === 0) { + let noDataReason = "No valid data points found for this mode"; + if (config.shouldUseFixedRoomT) noDataReason += ` using fixed T_in=${config.fixedRoomTValue}°C`; + if (config.minDeltaT > -Infinity) noDataReason += ` with Min ΔT > ${config.minDeltaT}°C`; + if (config.splitDataEnabled) noDataReason += ` split by ${config.splitByValue}`; + noDataReason += "."; + plotDiv.html("
" + noDataReason + "
"); + return; + } + + // 4. Generate Plotly Traces (Scatter + Regression) + const plotData = []; // Array to hold Plotly traces + const allXValues = []; // For overall regression + const allYValues = []; // For overall regression + const allSolarValues = []; + + resetPlotColorIndex(); // Reset colors for this plot generation + + // --- Determine overall Solar Min/Max for consistent colorscale --- + let overallMinSolar = Infinity; + let overallMaxSolar = -Infinity; + let hasAnySolarData = false; + if (config.solarColoringEnabled) { + for (const groupKey in preparedData.groups) { + const group = preparedData.groups[groupKey]; + if (group.solarValues && group.solarValues.length > 0) { + group.solarValues.forEach(val => { + if (val !== null && typeof val === 'number' && !isNaN(val)) { + hasAnySolarData = true; + if (val < overallMinSolar) overallMinSolar = val; + if (val > overallMaxSolar) overallMaxSolar = val; + } + }); + } + } + // Handle case where no valid solar data exists despite checkbox being ticked + if (!hasAnySolarData) { + console.log("Heat Loss Plot: Solar coloring enabled, but no valid solar data found. Reverting to group colors."); + // config.solarColoringEnabled = false; // Or handle directly in loop + } else { + console.log(`Heat Loss Plot: Applying solar coloring. Solar range: [${overallMinSolar}, ${overallMaxSolar}]`); + } + } + + // Create Scatter and potentially individual Regression Traces + for (const groupKey in preparedData.groups) { + const group = preparedData.groups[groupKey]; + if (group.xValues.length > 0) { + const groupColor = getNextPlotColor(); // Assign color per group + + // --- Prepare Customdata for Hover --- + const customDataForHover = group.timestamps.map((ts, index) => { + let dateStr = "Invalid Date"; + try { + if (ts !== null && ts !== undefined && !isNaN(ts)) { + const dateObj = new Date(ts); + if (!isNaN(dateObj.getTime())) { + dateStr = dateObj.toLocaleDateString(); + } + } + } catch (e) { console.warn("Error formatting date:", e); dateStr = "Date Error"; } + + const solarVal = group.solarValues[index]; + // Format solar value nicely for hover, handle null/undefined + const solarStr = (solarVal !== null && typeof solarVal === 'number' && !isNaN(solarVal)) + ? solarVal.toFixed(2) + ' kWh' + : 'N/A'; + + return { date: dateStr, solar: solarStr, rawSolar: solarVal }; // Include raw value if needed for filtering later + }); + + let useSolarColoringForThisGroup = config.solarColoringEnabled && hasAnySolarData; + + let markerConfig = { + size: 6, + opacity: 0.7, + color: groupColor // Default to group color + }; + + if (useSolarColoringForThisGroup) { + // Check if this specific group has any valid solar values + const groupHasSolar = group.solarValues.some(sv => sv !== null && typeof sv === 'number' && !isNaN(sv)); + if (groupHasSolar) { + markerConfig = { + ...markerConfig, // Keep size, opacity + color: group.solarValues, // Use the array of solar values for color + colorscale: 'Jet', // Example: Yellow-Green-Blue + // Use cmin/cmax for consistent scale across groups if splitting + cmin: overallMinSolar, + cmax: overallMaxSolar, + colorbar: { + title: { + text: 'Solar Gain (kWh/day)', // Add units, allow line break + side: 'right' + } + }, + // Ensure points with null solar value get a specific color (e.g., grey) + // Note: Plotly behavior with nulls in 'color' array can vary. + // A common approach is pre-filtering or assigning a value outside the cmin/cmax range. + // For simplicity here, we rely on Plotly's default (often transparent or lowest color). + }; + console.log(`Applying solar colorscale to group: ${group.label}`); + } else { + // This group has no solar data, use the group color + console.log(`Solar coloring enabled, but group ${group.label} has no solar data. Using group color.`); + useSolarColoringForThisGroup = false; // Fallback for this specific group + markerConfig.color = groupColor; // Already set, but being explicit + } + } else { + // Solar coloring not enabled OR no valid solar data found overall + markerConfig.color = groupColor; + } + + // Create Scatter Trace + // --- Create Scatter Trace --- + const scatterTrace = { + x: group.xValues, + y: group.yValues, + mode: 'markers', + type: 'scatter', + name: group.label + ` (N=${group.xValues.length})`, + marker: markerConfig, // Assign the configured marker object + customdata: customDataForHover, // Use the enhanced custom data + hovertemplate: // Updated hover template + `Date: %{customdata.date}
` + + `ΔT: %{x:.1f} K
` + + `Avg Heat: %{y:.2f} kW
` + + `Solar Gain: %{customdata.solar}` + // Add solar value + ``, + legendgroup: groupKey, + // If using solar coloring, consider hiding individual groups from legend + // unless splitting is also active, to avoid clutter. + // showlegend: !(useSolarColoringForThisGroup && !config.splitDataEnabled) + // Let's keep legend showing for now. + }; + plotData.push(scatterTrace); + + // Create Regression Trace for this group if enabled + if (config.splitRegressionEnabled) { + const regressionTrace = calculatePlotlyRegressionTrace( + group.xValues, + group.yValues, + `${group.label} Fit`, // Prefix for the trace name + groupColor, // Use the same color + 0, // Min X for line + 35 // Max X for line (adjust as needed) + ); + if (regressionTrace) { + // Prepend group label to the detailed regression fit label for clarity + regressionTrace.name = `${group.label} Fit:
${regressionTrace.name}`; + regressionTrace.legendgroup = groupKey; // Match legend group + // Optionally shorten the scatter name if the fit line has full details + // scatterTrace.name = group.label; + plotData.push(regressionTrace); + } + } else { + // Collect all points for a single overall regression + allXValues.push(...group.xValues); + allYValues.push(...group.yValues); + allSolarValues.push(...group.solarValues); + } + } + } + + // Perform the MULTILINEAR regression test on the overall aggregated data + console.log("--- Running Multilinear Regression Test (Overall Data) ---"); + performMultilinearRegressionTest(allXValues, allSolarValues, allYValues); + console.log("--- End Multilinear Regression Test ---"); + + // Create Overall Regression Trace if not splitting regression + if (!config.splitRegressionEnabled && allXValues.length >= 2) { + const overallRegressionTrace = calculatePlotlyRegressionTrace( + allXValues, + allYValues, + "Overall Fit", + 'rgba(0, 0, 0, 0.7)', // Distinct color (e.g., black) + 0, + 35 + ); + if (overallRegressionTrace) { + overallRegressionTrace.name = `Overall Fit:
${overallRegressionTrace.name}`; // Add prefix + plotData.push(overallRegressionTrace); + } else { + // Warning already logged in calculatePlotlyRegressionTrace or linearRegression + console.log("Heat Loss Plot: Overall regression line not plotted (insufficient data or calculation failed)."); + } + } + + + // 5. Get Plot Layout Options + const layout = getPlotlyLayoutOptions(); + // Dynamically adjust x-axis range based on data if needed, otherwise uses defaults/rangemode + // layout.xaxis.range = [0, Math.max(35, preparedData.overallMaxX * 1.1)]; + // If solar coloring was used, adjust right margin slightly for colorbar + + if (config.solarColoringEnabled && hasAnySolarData) { + layout.margin = { l: 60, r: 80, t: 30, b: 50 }; // Increased right margin + } + + // 6. Plotting with Plotly + const plotConfig = { + responsive: true, // Allow plot to resize dynamically + displaylogo: false, // Hide Plotly logo + modeBarButtonsToRemove: ['select2d', 'lasso2d'] // Optional: remove unused buttons + }; + + try { + // Ensure plotDiv is cleared before rendering (important for updates) + Plotly.purge(plotElement); // More robust way to clear Plotly plots + Plotly.newPlot(plotElement, plotData, layout, plotConfig); + console.log("Heat Loss Plot: Plotly plot generated successfully."); + } catch (e) { + console.error("Heat Loss Plot: Error during Plotly plotting:", e); + plotDiv.html("
Error generating plot. Check console.
"); + } +} + + +/** + * Performs a multilinear regression test using Delta T and Solar Gain + * as independent variables to predict Heat Output. + * Logs the results to the console AND displays them in the dedicated text box, + * starting with a simplified summary sentence. + * + * Filters out data points where any of the required values (heat, deltaT, solar) + * are null or non-numeric. + * + * @param {number[]} deltaTValues - Array of temperature differences (X1). + * @param {number[]} solarValues - Array of solar gain values (X2). + * @param {number[]} heatOutputValues - Array of heat output values (Y, dependent). + * @returns {object|null} The result object from multilinearRegression, or null if it fails. + */ +function performMultilinearRegressionTest(deltaTValues, solarValues, heatOutputValues) { + console.log("Attempting Multilinear Regression Test: Heat ~ DeltaT + SolarGain"); + let summarySentence = ""; // Initialize summary sentence string + let detailedOutputString = ""; // Initialize string for detailed stats + const resultsTextArea = document.getElementById('heatloss-mlr-results'); + + // Helper function to clear/update text area + const updateResultsDisplay = (message) => { + if (resultsTextArea) { + resultsTextArea.value = message; + } + console.log(message); // Also log simple status messages + }; + + // Clear previous results immediately + if (resultsTextArea) resultsTextArea.value = "Processing..."; + + if (!deltaTValues || !solarValues || !heatOutputValues) { + const msg = "Multilinear Test: Missing one or more input data arrays."; + updateResultsDisplay(msg); + return null; + } + + const n_initial = heatOutputValues.length; + if (deltaTValues.length !== n_initial || solarValues.length !== n_initial) { + const msg = `Multilinear Test: Input array lengths mismatch. Heat: ${n_initial}, DeltaT: ${deltaTValues.length}, Solar: ${solarValues.length}`; + updateResultsDisplay(msg); + return null; + } + + // Filter data: Keep only points where Heat, DeltaT, AND Solar are valid numbers + const filteredHeat = []; + const filteredDeltaT = []; + const filteredSolar = []; + + for (let i = 0; i < n_initial; i++) { + const heat = heatOutputValues[i]; + const deltaT = deltaTValues[i]; + const solar = solarValues[i]; + + if (heat !== null && typeof heat === 'number' && !isNaN(heat) && + deltaT !== null && typeof deltaT === 'number' && !isNaN(deltaT) && + solar !== null && typeof solar === 'number' && !isNaN(solar)) + { + filteredHeat.push(heat); + filteredDeltaT.push(deltaT); + filteredSolar.push(solar); + } + } + + const n_filtered = filteredHeat.length; + const filterMsg = `Multilinear Test: Filtered data points from ${n_initial} to ${n_filtered} (removing points with missing heat, deltaT, or solar).`; + console.log(filterMsg); // Log filtering info + + // Check if enough data points remain for regression + const num_independent_vars = 2; // DeltaT, Solar + const p_params = num_independent_vars + 1; // Number of parameters + if (n_filtered <= p_params) { + const msg = `Multilinear Test: Not enough valid data points (${n_filtered}). Need more than ${p_params} for regression inference. Cannot perform analysis.\n(${filterMsg})`; + updateResultsDisplay(msg); + return null; + } + + // Prepare independent variables array + const independentVars = [filteredDeltaT, filteredSolar]; + + // Call the multilinear regression function + let regressionResult = null; + try { + regressionResult = multilinearRegression(independentVars, filteredHeat); + } catch (e) { + console.error("Multilinear Test: Error calling multilinearRegression function:", e); + const msg = `Multilinear Test: Error during calculation: ${e.message || e}\nSee console for details.`; + updateResultsDisplay(msg); + return null; + } + + console.log("Raw Regression Result Object:", regressionResult); + + // Log and build formatted results string + if (regressionResult) { + // --- START: Build Simplified Summary Sentence --- + let interceptTerm = "N/A"; + let deltaTTerm = "N/A"; + let solarTerm = "N/A"; + const hasCI = regressionResult.confidenceIntervals && regressionResult.confidenceIntervals.length === regressionResult.p; + + // Helper to format estimate with CI range or just point estimate + const formatTerm = (estimateKW, ciKW, unit = "W", precision = 0) => { + const factor = unit === "W" ? 1000 : 1; + if (hasCI && ciKW && ciKW.length === 2 && !isNaN(ciKW[0]) && !isNaN(ciKW[1])) { + // Format with CI range + const lower = (ciKW[0] * factor).toFixed(precision); + const upper = (ciKW[1] * factor).toFixed(precision); + // Ensure lower is numerically less than upper for display + const minVal = Math.min(lower, upper); + const maxVal = Math.max(lower, upper); + return `between ${minVal} and ${maxVal} ${unit}`; + } else if (!isNaN(estimateKW)) { + // Format with point estimate only + return `around ${(estimateKW * factor).toFixed(precision)} ${unit}`; + } else { + return `N/A`; + } + }; + // Helper to format the *reduction* term for solar (handles negative CI correctly) + const formatReductionTerm = (estimateKW, ciKW, unit = "W", precision = 0) => { + const factor = unit === "W" ? 1000 : 1; + // Ensure estimate is negative for reduction + const pointEstimateReduction = estimateKW < 0 ? Math.abs(estimateKW * factor) : 0; + + if (hasCI && ciKW && ciKW.length === 2 && !isNaN(ciKW[0]) && !isNaN(ciKW[1])) { + // CI bounds for reduction are based on the absolute values of the original CI + // The lower bound of reduction comes from the upper bound of the original CI (less negative) + // The upper bound of reduction comes from the lower bound of the original CI (more negative) + const lowerReduction = Math.abs(ciKW[1] * factor); // ciKW[1] is typically the less negative value + const upperReduction = Math.abs(ciKW[0] * factor); // ciKW[0] is typically the more negative value + + // Ensure lower is numerically less than upper for display, and they are positive + const minVal = Math.max(0, Math.min(lowerReduction, upperReduction)).toFixed(precision); + const maxVal = Math.max(0, Math.max(lowerReduction, upperReduction)).toFixed(precision); + + if (minVal > 0 || maxVal > 0) { // Only show range if it indicates reduction + return `between ${minVal} and ${maxVal} ${unit}`; + } else { // If CI includes or is above zero, say reduction is uncertain/negligible + return `an uncertain amount (CI includes zero or positive effect)`; + } + + } else if (!isNaN(estimateKW) && estimateKW < 0) { + // Format with point estimate only + return `around ${pointEstimateReduction.toFixed(precision)} ${unit}`; + } else { + return `an uncertain amount`; // No CI and point estimate not negative or NaN + } + }; + + + if (regressionResult.beta) { + interceptTerm = formatTerm(regressionResult.beta[0], hasCI ? regressionResult.confidenceIntervals[0] : null); + deltaTTerm = formatTerm(regressionResult.beta[1], hasCI ? regressionResult.confidenceIntervals[1] : null); + // Use specific formatter for solar reduction + solarTerm = formatReductionTerm(regressionResult.beta[2], hasCI ? regressionResult.confidenceIntervals[2] : null); + } + + summarySentence = `Interpretation: For every 1°C increase in temperature difference (ΔT inside-outside), the required heating power increases by ${deltaTTerm}. Each kWh of daily solar gain (as per the defined input source) reduces this required power by ${solarTerm}. The model estimates a baseline heat load of ${interceptTerm} when ΔT and solar gain are zero (this may represent standing losses or model extrapolation).`; + summarySentence += `\n(R-squared: ${regressionResult.r2.toFixed(3)} - See full stats below for details and precision.)`; + // --- END: Build Simplified Summary Sentence --- + + // --- START: Build Detailed Output String --- + detailedOutputString += "--- Multilinear Regression Fit Details ---\n"; + detailedOutputString += `Model: Heat_kW = β₀ + β₁*DeltaT + β₂*SolarGain_kWh\n`; + detailedOutputString += `N = ${regressionResult.n}, Parameters (p) = ${regressionResult.p}, DF = ${regressionResult.degreesOfFreedom}\n`; + detailedOutputString += `R-squared: ${regressionResult.r2.toFixed(4)}\n`; + detailedOutputString += `SSE: ${regressionResult.sse.toFixed(4)}\n`; + + const paramNames = ['Intercept (β₀)', 'DeltaT (β₁)', 'SolarGain (β₂)']; + const header1 = ` ${'Parameter'.padEnd(18)} ${'Estimate'.padStart(12)} ${'Std. Error'.padStart(12)} ${'t-statistic'.padStart(12)} ${'p-value'.padStart(12)} ${'95% CI'.padStart(25)}`; + const header2 = ` ${'-'.repeat(18)} ${'-'.repeat(12)} ${'-'.repeat(12)} ${'-'.repeat(12)} ${'-'.repeat(12)} ${'-'.repeat(25)}`; + + detailedOutputString += "\nParameter Estimates:\n"; + detailedOutputString += header1 + "\n"; + detailedOutputString += header2 + "\n"; + + if (regressionResult.beta && regressionResult.standardErrors && regressionResult.tStats && regressionResult.pValues && regressionResult.confidenceIntervals) { + regressionResult.beta.forEach((coeff, i) => { + const name = paramNames[i] || `Var_${i}`; + const estimateStr = coeff.toFixed(4).padStart(12); + const seStr = regressionResult.standardErrors[i].toFixed(4).padStart(12); + const tStatStr = regressionResult.tStats[i].toFixed(3).padStart(12); + let pValStr = "N/A"; + if (regressionResult.pValues[i] !== null && !isNaN(regressionResult.pValues[i])) { + pValStr = regressionResult.pValues[i] < 0.001 ? "<0.001" : regressionResult.pValues[i].toFixed(3); + } + pValStr = pValStr.padStart(12); + + const ci = regressionResult.confidenceIntervals[i]; + const ciStr = (ci && ci.length === 2 && !isNaN(ci[0]) && !isNaN(ci[1])) + ? `[${ci[0].toFixed(4)}, ${ci[1].toFixed(4)}]`.padStart(25) + : '[N/A]'.padStart(25); + + const line = ` ${name.padEnd(18)} ${estimateStr} ${seStr} ${tStatStr} ${pValStr} ${ciStr}`; + detailedOutputString += line + "\n"; + }); + } else { + const basicInfo = " (Could not calculate full inference statistics - SE, t, p, CI. Check regression function warnings.)"; + detailedOutputString += basicInfo + "\n"; + if (regressionResult.intercept !== undefined) { + const interceptLine = ` Intercept (β₀): ${regressionResult.intercept.toFixed(4)}`; + detailedOutputString += interceptLine + "\n"; + } + if (regressionResult.coefficients) { + regressionResult.coefficients.forEach((coeff, i) => { + const coeffLine = ` Coefficient ${i + 1}: ${coeff.toFixed(4)}`; + detailedOutputString += coeffLine + "\n"; + }); + } + } + detailedOutputString += "------------------------------------------------------------------------------------------"; // Footer line + // --- END: Build Detailed Output String --- + + // Update console with detailed results + console.log(detailedOutputString); + + // Update text area with summary first, then details + if (resultsTextArea) { + resultsTextArea.value = summarySentence + "\n\n" + detailedOutputString; + } + + } else { + const msg = "Multilinear Test: Regression calculation failed or returned null."; + updateResultsDisplay(msg); // Update text box and console + } + + return regressionResult; +} \ No newline at end of file diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_process.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump_process.php index 20f7e20..107918d 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_process.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_process.php @@ -241,6 +241,16 @@ function get_heatpump_stats($feed,$app,$start,$end,$starting_power,$timezone = ' $heat_kwh = get_cumulative_kwh($feed,$app->config->heatpump_heat_kwh,$start,$end); } + $solar_kwh = null; // Initialize solar kWh variable + if (isset($app->config->solar_elec_kwh) && $app->config->solar_elec_kwh > 0) { // Check if solar feed is configured + $solar_kwh = get_cumulative_kwh($feed, $app->config->solar_elec_kwh, $start, $end); + if ($solar_kwh !== null) { + $solar_kwh = number_format($solar_kwh, 4, '.', '') * 1; // Format like the others + } + } + + $cop_stats["combined"]["solar_kwh"] = $solar_kwh; + $cop = null; if ($elec_kwh>0) { $cop = $heat_kwh / $elec_kwh; @@ -461,7 +471,9 @@ function get_quality($data) { $count = count($data); if ($count<1) return 0; - if (!isset($data[0])) return 0; + # If only the first point is missing, this returns a total quality of 0 + # this is wrong + # if (!isset($data[0])) return 0; $null_count = 0; for ($pos = 0; $pos < $count; $pos++) { diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js new file mode 100644 index 0000000..f7bc4d0 --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js @@ -0,0 +1,204 @@ +/** + * Performs linear regression on paired data. + * y = mx + b + * @param {number[]} x - Array of x values (independent variable, e.g., time in seconds). + * @param {number[]} y - Array of y values (dependent variable, e.g., ln(deltaT/deltaT0)). + * @returns {object|null} Object with 'slope' (m) and 'intercept' (b), or null if regression is not possible. + */ +function linearRegression(x, y) { + const n = x.length; + if (n < 2 || n !== y.length) { + console.error("Linear regression requires at least 2 points and equal length arrays."); + return null; // Not enough data or mismatched arrays + } + + let sum_x = 0; + let sum_y = 0; + let sum_xy = 0; + let sum_xx = 0; + let sum_yy = 0; // Needed for R-squared, not strictly required for slope/intercept + + for (let i = 0; i < n; i++) { + sum_x += x[i]; + sum_y += y[i]; + sum_xy += x[i] * y[i]; + sum_xx += x[i] * x[i]; + sum_yy += y[i] * y[i]; + } + + const denominator = (n * sum_xx - sum_x * sum_x); + if (Math.abs(denominator) < 1e-10) { // Avoid division by zero if all x are the same + console.error("Linear regression failed: Denominator is zero (all x values are likely the same)."); + return null; + } + + const slope = (n * sum_xy - sum_x * sum_y) / denominator; + const intercept = (sum_y - slope * sum_x) / n; + + // Optional: Calculate R-squared (coefficient of determination) + let ssr = 0; + for (let i = 0; i < n; i++) { + const fit = slope * x[i] + intercept; + ssr += (fit - sum_y / n) ** 2; + } + const sst = sum_yy - (sum_y * sum_y) / n; + const r2 = (sst === 0) ? 1 : ssr / sst; // Handle case where all y are the same + + return { + slope: slope, + intercept: intercept, + r2: r2 // Uncomment if you want R-squared + }; +} + + + +/** + * Performs Multilinear Regression using matrix calculations via math.js. + * Calculates coefficients, standard errors, t-stats, p-values, and confidence intervals. + * Finds coefficients (β₁, β₂, ...) and intercept (β₀) for the model: + * Y = β₀ + β₁X₁ + β₂X₂ + ... + β<0xE2><0x82><0x99>X<0xE2><0x82><0x99> + * Calculates β = (XᵀX)⁻¹XᵀY + * + * @param {number[][]} independentVars - Array of arrays for independent variables (X₁, X₂, ...). + * @param {number[]} dependentVar - Array for the dependent variable (Y). + * @param {number} [confidenceLevel=0.95] - The desired confidence level for intervals (e.g., 0.95 for 95%). + * + * @returns {object|null} An object containing regression results including: + * - `coefficients`: Array [β₁, β₂, ...]. + * - `intercept`: The intercept (β₀). + * - `beta`: Full coefficient vector [β₀, β₁, β₂, ...]. + * - `r2`: R-squared value. + * - `standardErrors`: Array of SEs for [β₀, β₁, β₂, ...]. + * - `tStats`: Array of t-statistics for [β₀, β₁, β₂, ...]. + * - `pValues`: Array of p-values for [β₀, β₁, β₂, ...]. + * - `confidenceIntervals`: Array of CIs [[lower, upper], ...] for [β₀, β₁, β₂, ...]. + * - `degreesOfFreedom`: Error degrees of freedom (n-p). + * Or null if regression fails. + */ +function multilinearRegression(independentVars, dependentVar, confidenceLevel = 0.95) { + // 1. Check if libraries are available + if (typeof math === 'undefined') { + console.error("Multilinear Regression Error: The 'math.js' library is not loaded."); + return null; + } + if (typeof jStat === 'undefined') { + console.error("Multilinear Regression Error: The 'jStat' library is not loaded (needed for confidence intervals)."); + // Optionally, proceed without CIs, or return null + // return null; + console.warn(" >> Proceeding without calculating confidence intervals, p-values, etc."); + } + + + // 2. Input Validation + if (!independentVars || independentVars.length === 0 || !dependentVar) { /* ... */ return null; } + const numIndependentVars = independentVars.length; + const numDataPoints = dependentVar.length; + const p = numIndependentVars + 1; // Number of parameters (coefficients + intercept) + + if (numDataPoints <= p) { // Need more points than parameters estimated + console.error(`Multilinear Regression Error: Insufficient data points (${numDataPoints}). Need more than ${p} for ${numIndependentVars} independent vars + intercept.`); + return null; + } + // Check array lengths consistency... (same as before) + + try { + // 3. Construct Design Matrix X (with intercept column) + const X_data = []; + for (let i = 0; i < numDataPoints; i++) { + const row = [1]; // Intercept column + for (let j = 0; j < numIndependentVars; j++) { + row.push(independentVars[j][i]); + } + X_data.push(row); + } + const X = math.matrix(X_data); + + // 4. Create Dependent Variable Vector Y + const Y = math.matrix(dependentVar); + + // 5. Calculate Coefficients: β = (XᵀX)⁻¹XᵀY + const XT = math.transpose(X); + const XTX = math.multiply(XT, X); + const XTX_inv = math.inv(XTX); // (XᵀX)⁻¹ + const XTY = math.multiply(XT, Y); + const beta_vector = math.multiply(XTX_inv, XTY); // Result is a column matrix + const beta = beta_vector.valueOf().flat(); // .valueOf().flat() converts math.js matrix to plain 1D array + + // beta array is [intercept, coeff_X1, coeff_X2, ...] + const intercept = beta[0]; + const coefficients = beta.slice(1); + + // 6. Calculate Residuals and SSE + const Y_predicted_vector = math.multiply(X, beta_vector); + const residuals_vector = math.subtract(Y, Y_predicted_vector); + const residuals = residuals_vector.valueOf().flat(); // Plain 1D array of residuals + const ss_res = residuals.reduce((sum, r) => sum + r * r, 0); // Sum of Squared Errors (SSE) + + // 7. Calculate R-squared + const Y_mean = math.mean(dependentVar); + const ss_tot = dependentVar.reduce((sum, y) => sum + Math.pow(y - Y_mean, 2), 0); + const r2 = (ss_tot === 0) ? 1 : 1 - (ss_res / ss_tot); + + // --- Statistical Inference Calculations --- + let standardErrors = null, tStats = null, pValues = null, confidenceIntervals = null; + const degreesOfFreedom = numDataPoints - p; + + if (degreesOfFreedom <= 0) { + console.warn("Multilinear Regression Warning: Degrees of freedom is not positive. Cannot calculate standard errors or confidence intervals."); + } else if (typeof jStat !== 'undefined') { // Only proceed if jStat is loaded and df > 0 + try { + // Estimate variance of residuals: s² = SSE / (n - p) + const residual_variance = ss_res / degreesOfFreedom; + + // Standard Errors: sqrt(diagonal elements of s² * (XᵀX)⁻¹) + // math.diag extracts the diagonal from the matrix + const diag_XTX_inv = math.diag(XTX_inv).valueOf(); // Get diagonal as plain array + standardErrors = diag_XTX_inv.map(d => math.sqrt(residual_variance * d)); + + // t-statistics: coefficient / standard_error + tStats = beta.map((b, i) => standardErrors[i] === 0 ? NaN : b / standardErrors[i]); // Avoid division by zero + + // p-values (two-tailed test: H0: βj = 0) + pValues = tStats.map(t => isNaN(t) ? NaN : jStat.studentt.cdf(-Math.abs(t), degreesOfFreedom) * 2); + + // Confidence Intervals: coeff ± t_crit * SE + const alpha = 1 - confidenceLevel; + const t_crit = jStat.studentt.inv(1 - alpha / 2, degreesOfFreedom); // Critical t-value + + confidenceIntervals = beta.map((b, i) => { + const marginOfError = t_crit * standardErrors[i]; + return [b - marginOfError, b + marginOfError]; + }); + + } catch (statError) { + console.error("Error during statistical inference calculations (SE, CI):", statError); + // Set inference results to null if calculation fails + standardErrors = null; tStats = null; pValues = null; confidenceIntervals = null; + } + } // End if jStat available and df > 0 + + + return { + coefficients: coefficients, // [β₁, β₂, ...] + intercept: intercept, // β₀ + beta: beta, // Full vector [β₀, β₁, β₂, ...] + r2: r2, + standardErrors: standardErrors, // SE for [β₀, β₁, β₂, ...] or null + tStats: tStats, // t-stats for [β₀, β₁, β₂, ...] or null + pValues: pValues, // p-values for [β₀, β₁, β₂, ...] or null + confidenceIntervals: confidenceIntervals, // CIs for [β₀, β₁, β₂, ...] or null + degreesOfFreedom: degreesOfFreedom, + sse: ss_res, + n: numDataPoints, + p: p + }; + + } catch (error) { + console.error("Error during multilinear regression calculation using math.js:", error); + if (error.message && error.message.includes("Cannot calculate inverse")) { + console.error(" >> This often indicates perfect multicollinearity among independent variables."); + } + return null; + } +} \ No newline at end of file diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_schema.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump_schema.php index 1031a8a..89e4f06 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_schema.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_schema.php @@ -11,6 +11,7 @@ // Full period stats 'combined_elec_kwh' => array('type' => 'float', 'name'=>'Electricity consumption', 'group'=>'Stats: Combined', 'dp'=>0, 'unit'=>'kWh'), 'combined_heat_kwh' => array('type' => 'float', 'name'=>'Heat output', 'group'=>'Stats: Combined', 'dp'=>0, 'unit'=>'kWh'), + 'combined_solar_kwh' => array('type' => 'float', 'name'=>'Solar intput', 'group'=>'Stats: Combined', 'dp'=>2, 'unit'=>'kWh'), 'combined_cop' => array('type' => 'float', 'name'=>'COP', 'heading'=>"COP", 'group'=>'Stats: Combined', 'dp'=>1, 'unit'=>''), 'combined_data_length' => array('type' => 'float', 'name'=>'Data length', 'group'=>'Stats: Combined', 'dp'=>0, 'unit'=>''), 'combined_elec_mean' => array('type' => 'float', 'name'=>'Elec mean', 'group'=>'Stats: Combined', 'dp'=>0, 'unit'=>'W'), diff --git a/apps/OpenEnergyMonitor/myheatpump/style.css b/apps/OpenEnergyMonitor/myheatpump/style.css index fcef775..8118d55 100644 --- a/apps/OpenEnergyMonitor/myheatpump/style.css +++ b/apps/OpenEnergyMonitor/myheatpump/style.css @@ -99,4 +99,36 @@ float: right; padding:11px; font-size:14px; +} + +/* Styles for the new Heat Loss Panel */ +#heatloss-toggle { + /* Inherits .bluenav */ + padding: 8px 15px; /* Keep padding inside the toggle */ + text-align: left; + /* width: calc(100% - 30px); */ /* REMOVE THIS LINE */ + box-sizing: border-box; /* Add this for robust padding handling */ + display: block; /* Make it take up available block width */ + transition: background-color 0.3s; + cursor: pointer; + border-bottom: 1px solid #eee; + /* Optional: Add a min-height if needed, but likely not the issue */ + /* min-height: 38px; */ /* Match .bluenav height if necessary */ +} + +#heatloss-toggle:hover { + background-color: #5a7d9c; +} + +#heatloss-arrow { + float: right; + margin-right: 10px; + font-size: 14px; + line-height: 1.5; /* Adjust if vertical alignment is off */ +} + +#heatloss-block { + border: 1px solid #ddd; + border-top: none; + background-color: #f9f9f9; } \ No newline at end of file