UNPKG

node-red-contrib-ui-state-trail

Version:

A Node-RED dashboard ui node. Trail of state changes over time

1,206 lines (1,124 loc) 36.5 kB
/* MIT License Copyright (c) 2019 hotNipi 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. */ module.exports = function (RED) { function HTML(config) { var data = JSON.stringify(config.initial); var sizes = JSON.stringify(config.stripe); var styles = String.raw` <style> .txt-{{unique}} { font-size:1em; fill: currentColor; } .txt-{{unique}}.small{ font-size:0.7em; } .statra-{{unique}}.legend{ cursor:pointer; } .statra-{{unique}}.split{ position:absolute; width:${config.exactwidth}px; top:${config.stripe.y}%; } .statra-{{unique}}.split .splitter{ position:absolute; height:${config.stripe.height}px; border-left:1px solid ${config.bgrColor}; } .statra-gradi-{{unique}}{ width:${config.exactwidth}px; height:${config.stripe.height}px; position:absolute; top:${config.stripe.y}%; } </style>` var layout = String.raw` <svg preserveAspectRatio="xMidYMid meet" id="statra_svg_{{unique}}" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" ng-init='init(` + data + `,` + sizes + `)'> <text ng-if="${config.height > 1}"> <tspan ng-if="${config.legend > 0}" id="statra_label_{{unique}}" class="txt-{{unique}}" text-anchor="middle" dominant-baseline="hanging" x=` + config.exactwidth / 2 + ` y="1%"> ` + config.label + ` </tspan> <tspan ng-if="${config.legend == 0}" id="statra_label_{{unique}}" class="txt-{{unique}}" text-anchor="middle" dominant-baseline="middle" x=` + config.exactwidth / 2 + ` y="25%"> ` + config.label + ` </tspan> </text> <g class="statra-{{unique}} legend" id="statra_legend_{{unique}}" ng-if="${(config.height > 1 && config.legend > 0)}" style="outline: none; border: 0;" ng-click='toggle()'></g> <text ng-if="${config.blanklabel != ""}" font-style="italic"> <tspan id="statra_blank_{{unique}}" class="txt-{{unique}}" text-anchor="middle" dominant-baseline="hanging" x=` + config.exactwidth / 2 + ` y="` + config.stripe.y + `%">` + config.blanklabel + `</tspan> </text> <g class="statra-{{unique}} split" id="statra_dots_{{unique}}" style="outline: none; border: 0;"></g> <g class="statra-{{unique}} ticks" id="statra_ticks_{{unique}}" style="outline: none; border: 0;"></g> </svg> <div class="statra-gradi-{{unique}}" id=statra_gradi_{{unique}} ng-click='onClick($event)'></div> <div class="statra-{{unique}} split" id="statra_splitters_{{unique}}" ng-if="${!config.combine}"></div>` return String.raw`${styles}${layout}`; } function checkConfig(node, conf) { if (!conf || !conf.hasOwnProperty("group")) { node.error(RED._("ui_statetrail.error.no-group")); return false; } return true; } var ui = undefined; function StateTrailNode(config) { try { var node = this; if (ui === undefined) { ui = RED.require("node-red-dashboard")(RED); } RED.nodes.createNode(this, config); var done = null; var range = null; var site = null; var getSiteProperties = null; var getPosition = null; var getTimeFromPos = null; var checkPayload = null; var validReferenceInArray = null var checkReference = null; var store = null; var generateGradient = null; var generateTicks = null; var getColor = null; var formatTime = null; var validType = null; var validObject = null; var storage = null; var references = null var storeInContext = null; var prepareStorage = null; var stroageSpace = null; var showInfo = null; var collectSummary = null; var getStateFromCoordinates = null; var generateOutMessage = null; var addToStore = null; var addToRef = null; var findSplitters = null; var findDots = null; var isValidStateConf = null; var splitters = []; var dots = []; var ctx = node.context() if (checkConfig(node, config)) { checkPayload = function (input) { var ret = null if (Array.isArray(input)) { if (input.length == 0) { return [] } if (input.every(ob => validObject(ob))) { return input } else { return [] } } if (typeof input === 'object' && input !== null) { if (validObject(input)) { if (validType(input.state)) { ret = { state: input.state, timestamp: input.timestamp } } } } else { if (validType(input)) { ret = { state: input, timestamp: new Date().getTime() } } } return ret } checkReference = function (input,validPayload){ if(!input){ return null } //console.log("checkReference",input,validPayload) if (Array.isArray(validPayload)){ if (input.every(ob => validReferenceInArray(ob))) { return input } else{ return [] } }else{ if (Array.isArray(input)){ // cant be array if payload isn't return null } else{ if(input.hasOwnProperty("timestamp")){ return input } else{ return { data:input, timestamp:validPayload.timestamp } } } } } validReferenceInArray = function (input) { if (input == undefined) { return false } if (input.hasOwnProperty("timestamp")) { return true } return false } validObject = function (input) { if (input == undefined) { return false } if (input.hasOwnProperty('state') && input.hasOwnProperty("timestamp")) { return true } return false } validType = function (input) { for (var i = 0; i < config.states.length; i++) { if (config.states[i].state === input) { return true } } return false } isValidStateConf = function (input) { var hasAllProps = function (el) { return ( typeof el === 'object' && el !== null && el.hasOwnProperty('state') && el.hasOwnProperty('col') && el.hasOwnProperty('t') && el.hasOwnProperty('label')) } var e = 3 if (Array.isArray(input)) { e-- if (input.length > 1) { e-- if (input.every(hasAllProps)) { e-- var unique = [...new Set(input.map(s => s.state))] if (unique.length == input.length) { return true } } } } var err = ['States must be unique.', 'State object must have all required properties.', 'At least 2 states must be configured.', 'Expected an array of objects.' ] node.warn('Configuration for states is not valid! ' + err[e]) return false } findSplitters = function () { function checkSpilt(el, idx, arr) { if (idx > 0) { if (el.state == arr[idx - 1].state) { splitters.push({ x: getPosition(el.timestamp, config.insidemin, config.max), width: '1px' }) } if (idx < arr.length - 2) { if (el.hasOwnProperty('end')) { var diff = arr[idx + 1].timestamp - el.end if (diff < 0) { node.warn("overlapping the states is not supported!") } if (diff > 0) { // gap var xp = getPosition(el.end, config.insidemin, config.max) var nxp = getPosition(arr[idx + 1].timestamp, config.insidemin, config.max) var wp = nxp - xp splitters.push({x: xp,width: wp + '%'}) } } } } } storage.forEach(checkSpilt) } findDots = function () { dots = [] if (storage.length < 2) { return } var total = storage[storage.length - 1].timestamp - storage[0].timestamp var safe = total / config.exactwidth / 2 function checkDot(el, idx, arr) { if (idx > 0) { if (el.timestamp - arr[idx - 1].timestamp < safe) { dots.push({ x: getPosition(arr[idx - 1].timestamp, config.insidemin, config.max), col: getColor(arr[idx - 1].state) }) } } } storage.forEach(checkDot) } addToStore = function (s) { if (storage.length > 0) { var temp = [...storage] temp = temp.filter(el => el.timestamp != s.timestamp) temp.push(s) temp = temp.sort((a, b) => a.timestamp - b.timestamp) if (config.combine) { var idx = temp.length - 1 if (idx > 2) { if (temp[idx - 2].state === temp[idx - 1].state) { temp.splice(idx - 1, 1) } } } var time = temp[temp.length - 1].timestamp - config.period temp = temp.filter(el => el.timestamp > time); storage = temp } else { storage.push(s) } config.min = storage[0].timestamp config.insidemin = config.min if (storage.length > 2) { config.insidemin = storage[1].timestamp } config.max = storage[storage.length - 1].timestamp } addToRef = function(r){ references.push(r) var temp = [...references] //console.log('ref before:',references,storage) function matchedTimestamp(r){ var m = storage.find(e => e.timestamp == r.timestamp) if(m){ return true } return false } temp = temp.filter(r => matchedTimestamp(r)) //console.log('ref after filter:',temp) references = temp } store = function (val,ref) { if (Array.isArray(val)) { if (val.length == 0) { storage = [] references = [] config.max = new Date().getTime() config.min = config.max - config.period storeInContext() return } else { storage = [] val = val.sort((a, b) => a.timestamp - b.timestamp) val.forEach(s => addToStore(s)) references = [] if(ref){ ref.forEach(r => addToRef(r)) } } } else { addToStore(val) if(ref){ addToRef(ref) } } showInfo() storeInContext() } collectSummary = function () { var ret = [] if(storage.length == 0){ return ret } var sum = {} var i var total = 0 var z = 0 var p = 0 var len = storage.length for (i = 0; i < len -1; i++) { if(storage[i].end){ z = storage[i].end - storage[i].timestamp } else{ z = storage[i+1].timestamp - storage[i].timestamp } if(!sum[storage[i].state]){ sum[storage[i].state] = 0 } sum[storage[i].state] += z } total = storage[len - 1].timestamp - storage[0].timestamp for (i = 0; i < config.states.length; i++) { if(!sum[config.states[i].state]){ sum[config.states[i].state] = 0 } p = (100 * sum[config.states[i].state] / total) if (isNaN(p)) { p = 0 } if (config.legend == 2 && p <= 0) { continue } if (config.legend == 3) { if (len < 1) { continue } if (config.states[i].state != storage[storage.length - 1].state) { continue } } p = p.toFixed(2) + "%" var n = config.states[i].label == "" ? config.states[i].state.toString() : config.states[i].label ret.push({ name: n, col: config.states[i].col, val: formatTime(sum[config.states[i].state], true,'HH:mm:ss'), per: p }) } return ret } getSiteProperties = function () { var opts = {} opts.sizes = {sx: 48,sy: 48,gx: 4,gy: 4,cx: 4,cy: 4,px: 4,py: 4} opts.theme = { 'widget-borderColor': { value: "#097479" } } if (typeof ui.getSizes === "function") { if(ui.getSizes()){ opts.sizes = ui.getSizes(); } if(ui.getTheme()){ opts.theme = ui.getTheme(); } } return opts } range = function (n, p, a, r) { if (a == "clamp") { if (n < p.minin) { n = p.minin; } if (n > p.maxin) { n = p.maxin; } } if (a == "roll") { var d = p.maxin - p.minin; n = ((n - p.minin) % d + d) % d + p.minin; } var v = ((n - p.minin) / (p.maxin - p.minin) * (p.maxout - p.minout)) + p.minout; if (r) { v = Math.round(v); } return v } getColor = function (type) { for (var i = 0; i < config.states.length; i++) { if (config.states[i].state === type) { return config.states[i].col } } return 'black' } generateGradient = function () { var ret = [] splitters = [] if (storage.length < 2) { return ret } var o = {p: 0, c: getColor(storage[0].state)} ret.push(o) o = {p: config.stripe.left, c: getColor(storage[0].state)} ret.push(o) var i var po po = getPosition(storage[1].timestamp, config.insidemin, config.max) for (i = 1; i < storage.length - 1; i++) { if (isNaN(po)) { continue } o = {p: po, c: getColor(storage[i - 1].state)} ret.push(o) o = {p: po, c: getColor(storage[i].state)} ret.push(o) po = getPosition(storage[i + 1].timestamp, config.insidemin, config.max) } o = {p: config.stripe.right,c: getColor(storage[storage.length - 2].state)} ret.push(o) o = {p: config.stripe.right,c: getColor(storage[storage.length - 1].state)} ret.push(o) o = {p: 100,c: getColor(storage[storage.length - 1].state)} ret.push(o) if (!config.combine) { findSplitters() } findDots() return ret } formatTime = function (stamp, utc, format) { var d = new Date(stamp); var hours = utc ? d.getUTCHours() : d.getHours(); var minutes = d.getMinutes(); var seconds = d.getSeconds(); var f = format ? format : config.timeformat; var t switch (f) { case 'HH:mm:ss': t = hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0'); break; case 'HH:mm': t = hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0'); break; case 'HH': t = hours.toString().padStart(2, '0') break; case 'mm:ss': t = minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0'); break; case 'mm': t = minutes.toString().padStart(2, '0'); break; case 'ss': t = seconds.toString().padStart(2, '0'); break; default: break; } return t } generateTicks = function () { //console.log(storage[1].timestamp - storage[0].timestamp, config.period) var ret = [] if (storage.length < 2) { return ret } var o var po var t var vis if (config.exactticks){ for (let i = 0; i < storage.length; i++) { t = storage[i].timestamp po = i == 0 ? 0 : getPosition(t, config.insidemin, config.max) o = { x: po, v: formatTime(t), id: i } ret.push(o) } } else { var total = config.max - config.insidemin var step = (total / (config.tickmarks - 1)) for (let i = 0; i < config.tickmarks; i++) { t = storage[1].timestamp + (step * i) po = getPosition(t, config.insidemin, config.max) o = { x: po, v: formatTime(t), id: i } ret.push(o) } } return ret } prepareStorage = function () { var contextStores = RED.settings.get('contextStorage') if (contextStores == undefined) { return } if (Object.keys(contextStores).length === 0 && contextStores.constructor === Object) { return } for (var key in contextStores) { if (contextStores[key].hasOwnProperty('module')) { if (contextStores[key].module == 'localfilesystem') { stroageSpace = key return } } } } storeInContext = function (force) { if (stroageSpace == null) { return } if (force == true || config.persist == true) { ctx.set('stateTrailStorage', storage, stroageSpace) ctx.set('stateTrailReferences', references, stroageSpace) ctx.set('stateTrailMax', config.max, stroageSpace) ctx.set('stateTrailMin', config.min, stroageSpace) } } showInfo = function () { if (config.persist == false) { node.status({}); return } if (stroageSpace == null) { node.status({ fill: 'grey', shape: "ring", text: "store: N/A" }); return } var total = storage.length + 2 var f = total > 1000 ? "red" : total > 700 ? "blue" : total > 400 ? "yellow" : "green" var s = total > 200 ? "dot" : "ring" node.status({ fill: f, shape: s, text: "store: " + stroageSpace + " count: " + total }); } getPosition = function (target, min, max) { var p = { minin: min, maxin: max, minout: config.stripe.left, maxout: config.stripe.right } return range(target, p, 'clamp', false) } getTimeFromPos = function (pos, min, max) { var p = { minin: min, maxin: max, minout: config.insidemin, maxout: config.max } return range(pos, p, 'clamp', true) } getStateFromCoordinates = function (c) { if (c > config.stripe.mousemax || c < config.stripe.mousemin) { return null } if (storage.length == 0) { return null } var time = getTimeFromPos(c, config.stripe.mousemin, config.stripe.mousemax) var idx = -1 + storage.findIndex(function (state) { return state.timestamp > time; }) if (idx == -1) { return null } var current = storage[idx] var next = storage[idx + 1] var dur = next.timestamp - current.timestamp if(current.end){ dur = current.end - current.timestamp } var end = current.end || next.timestamp var stateRef = config.states.find(s => s.state == current.state) var lab = "" if(stateRef){ lab = stateRef.label } var ret = { state: current.state, timestamp: current.timestamp, end: end, duration: dur, label: lab } return ret } generateOutMessage = function (evt) { var pl = getStateFromCoordinates(evt.targetX) delete evt.targetX var ret = { payload: pl, event: evt } var ref = references.find(el => el.timestamp == pl.timestamp) if(ref){ ret.reference = ref } return ret } var group = RED.nodes.getNode(config.group); var site = getSiteProperties(); if (config.width == 0) { config.width = parseInt(group.config.width) || 1 } if (config.height == 0) { config.height = parseInt(group.config.height) || 1 } config.width = parseInt(config.width) config.height = parseInt(config.height) config.exactwidth = parseInt(site.sizes.sx * config.width + site.sizes.cx * (config.width - 1)) - 12; config.exactheight = parseInt(site.sizes.sy * config.height + site.sizes.cy * (config.height - 1)) - 12; var sh = (site.sizes.sy / 2) + (site.sizes.cy * (config.height - 1)) - 6 if (config.height > 2){ sh += ((config.height - 2) * (site.sizes.sy)) - (config.height - 2) * 3 } var sy = config.height == 1 ? 0 : Math.floor(1/config.height*100) if(config.height > 2){ sy += site.sizes.cx } var leg = sy - (100 * 18 / config.exactheight) var dot = sy - (100 * 6 / config.exactheight) var tyb = config.exactheight - (site.sizes.sy/10) var tyt = tyb - 5 var tybt = 99.5 var edge = Math.max(config.timeformat.length, 6) * 4 * 100 / config.exactwidth config.stripe = { height: sh, x: 0, y: sy, left: edge, right: (100 - edge), tyt: tyt, leg:leg, tyb: tyb, tybt: tybt, dot: dot, padding: { hor: '6px', vert: (site.sizes.sy / 16) + 'px' } } //console.log(config.stripe) config.stripe.mousemin = config.stripe.left * config.exactwidth / 100 config.stripe.mousemax = config.stripe.right * config.exactwidth / 100 config.period = (parseInt(config.periodLimit) * parseInt(config.periodLimitUnit) * 1000) + 1000 config.tickmarks = config.tickmarks || 4 config.legend = parseInt(config.legend) prepareStorage() storage = (config.persist && stroageSpace != null) ? ctx.get('stateTrailStorage', stroageSpace) || [] : [] references = (config.persist && stroageSpace != null) ? ctx.get('stateTrailReferences', stroageSpace) || [] : [] config.max = (config.persist && stroageSpace != null) ? ctx.get('stateTrailMax', stroageSpace) || new Date().getTime() : new Date().getTime() config.min = (config.persist && stroageSpace != null) ? ctx.get('stateTrailMin', stroageSpace) || (config.max - config.period) : (config.max - config.period) config.insidemin = storage.length < 3 ? config.min : storage[1].timestamp storeInContext(true) config.bgrColor = site.theme['widget-borderColor'].value config.initial = { stops: generateGradient(), ticks: generateTicks(), legend: collectSummary(), splits: splitters, dots: dots } var html = HTML(config); done = ui.addWidget({ node: node, order: config.order, group: config.group, width: config.width, height: config.height, format: html, templateScope: "local", emitOnlyNewValues: false, forwardInputMessages: false, storeFrontEndInputAsState: true, beforeEmit: function (msg) { if (msg.control && msg.control.period) { config.period = parseInt(msg.control.period) } if (msg.control && msg.control.label) { config.label = msg.control.label } if (msg.control && msg.control.states) { if (isValidStateConf(msg.control.states)) { config.states = msg.control.states } } if (msg.payload === undefined) { return {} } var validated = checkPayload(msg.payload) if (validated === null) { return {} } var reference = checkReference(msg.reference,validated) store(validated,reference) msg.payload = { stops: generateGradient(), ticks: generateTicks(), legend: collectSummary(), splits: splitters, dots: dots, label:config.label } return { msg }; }, beforeSend: function (msg, orig) { try { if (!orig || !orig.msg) { return; } return generateOutMessage(orig.msg.clickevent); } catch (error) { node.error(error); } }, initController: function ($scope) { $scope.unique = $scope.$eval('$id') $scope.svgns = 'http://www.w3.org/2000/svg'; $scope.timeout = null $scope.legendvalues = ['name', 'val', 'per'] $scope.legendvalue = 'name' $scope.legend = null $scope.sizes = null $scope.mouselock = 0 $scope.init = function (data, sizes) { $scope.sizes = sizes //console.log('initial data',data.stops.length,data.legend.length) if(data.stops.length == 0 && data.legend.length == 0){ updateBlankLabel() return } update(data) } $scope.onClick = function (e) { if ($scope.mouselock < 2) { return } if (e.originalEvent.offsetX < $scope.sizes.mousemin || e.originalEvent.offsetX > $scope.sizes.mousemax) { return } var bbc = e.originalEvent.target.getBoundingClientRect() var box = [bbc.left, bbc.bottom, bbc.right, bbc.top] var coord = { pageX: e.originalEvent.screenX, pageY: e.originalEvent.screenY, screenX: e.originalEvent.screenX, screenY: e.originalEvent.screenY, clientX: e.originalEvent.clientX, clientY: e.originalEvent.clientY, targetX: e.originalEvent.offsetX, bbox: box } $scope.send({ clickevent: coord }); } $scope.toggle = function () { var idx = $scope.legendvalues.indexOf($scope.legendvalue) + 1 if (idx == $scope.legendvalues.length) { idx = 0 } $scope.legendvalue = $scope.legendvalues[idx] if ($scope.legend != null) { updateLegend($scope.legend) } } var update = function (data) { //console.log("update",$scope.unique,data) var main = document.getElementById("statra_svg_" + $scope.unique); if (!main) { //console.log('no main',$scope.unique) $scope.timeout = setTimeout(update.bind(null, data), 40); return } $scope.timeout = null updateContainerStyle(main, $scope.sizes.padding) updateGradient(data.stops) updateTicks(data.ticks) updateLegend(data.legend) updateSplitters(data.splits) updateDots(data.dots) updateLabel(data.label) updateBlankLabel() } var updateLabel = function(label){ if(!label){ return } var el = document.getElementById("statra_label_" + $scope.unique); if (el) { $(el).text(label) } } var updateBlankLabel = function () { var el = document.getElementById("statra_blank_" + $scope.unique); if (el) { $(el).attr('visibility', $scope.mouselock > 1 ? 'hidden' : 'visible') } } var updateContainerStyle = function (el, padding) { el = el.parentElement if (el && el.classList.contains('nr-dashboard-template')) { if ($(el).css('paddingLeft') == '0px') { el.style.paddingLeft = el.style.paddingRight = padding.hor el.style.paddingTop = el.style.paddingBottom = padding.vert } } } var updateSplitters = function (splits) { if (!splits) { return } var g = document.getElementById("statra_splitters_" + $scope.unique); if (!g) { return } if (g.children.length > 0) { while (g.firstChild) { g.removeChild(g.firstChild); } } var split for (var i = 0; i < splits.length; i++) { split = document.createElement('div') split.className = 'splitter'; split.style.left = splits[i].x + '%' document.getElementById("statra_splitters_" + $scope.unique).appendChild(split); } } var updateDots = function (dots) { if (!dots) { return } var g = document.getElementById("statra_dots_" + $scope.unique); if (!g) { return } if (g.children.length > 0) { while (g.firstChild) { g.removeChild(g.firstChild); } } var dot for (var i = 0; i < dots.length; i++) { dot = document.createElementNS($scope.svgns, 'circle'); dot.setAttribute('id', 'statra_dot_' + $scope.unique + "_" + i) dot.setAttribute('cx', dots[i].x + '%'); dot.setAttribute('cy', $scope.sizes.dot + '%'); dot.setAttribute('r', '3'); dot.setAttributeNS(null, 'fill', dots[i].col); document.getElementById("statra_dots_" + $scope.unique).appendChild(dot); } } var updateLegend = function (legend) { if (!legend) { return } var g = document.getElementById("statra_legend_" + $scope.unique); if (!g) { return } $scope.legend = legend if (g.children.length * 2 != $scope.legend.length) { while (g.firstChild) { g.removeChild(g.firstChild); } } var xp = 0 if (g.children.length == 0) { var rect var txt for (var i = 0; i < legend.length; i++) { rect = document.createElementNS($scope.svgns, 'rect'); rect.setAttributeNS(null, 'x', xp); rect.setAttributeNS(null, 'y', $scope.sizes.leg+'%'); rect.setAttributeNS(null, 'height', '11'); rect.setAttributeNS(null, 'width', '8'); rect.setAttributeNS(null, 'fill', legend[i].col); rect.setAttribute('id', 'statra_rect_legend_' + $scope.unique + "_" + i) document.getElementById("statra_legend_" + $scope.unique).appendChild(rect); xp += rect.getBoundingClientRect().width + 5 txt = document.createElementNS($scope.svgns, 'text'); txt.setAttributeNS(null, 'x', xp); txt.setAttributeNS(null, 'y', $scope.sizes.leg+'%'); txt.setAttributeNS(null, 'dominant-baseline', 'hanging'); txt.setAttributeNS(null, 'fill', legend[i].col) txt.setAttribute('class', 'txt-' + $scope.unique + ' small') txt.setAttribute('id', 'statra_txt_legend_' + $scope.unique + "_" + i) txt.textContent = legend[i][$scope.legendvalue] document.getElementById("statra_legend_" + $scope.unique).appendChild(txt); xp += txt.getBoundingClientRect().width + 5 } } else { var xp = 0 var el for (var i = 0; i < legend.length; i++) { el = document.getElementById("statra_rect_legend_" + $scope.unique + "_" + i) if (el) { $(el).attr("fill", legend[i].col) $(el).attr("x", xp) xp += el.getBoundingClientRect().width + 5 } el = document.getElementById("statra_txt_legend_" + $scope.unique + "_" + i) if (el) { $(el).text(legend[i][$scope.legendvalue]); $(el).attr("x", xp) xp += el.getBoundingClientRect().width + 5 } } } } var updateGradient = function (stops) { var gradient = document.getElementById("statra_gradi_" + $scope.unique); $scope.mouselock = stops.length if (gradient) { const perChunk = 9 const result = stops.reduce((resultArray, item, index) => { const chunkIndex = Math.floor(index/perChunk) if(!resultArray[chunkIndex]) { resultArray[chunkIndex] = [] // start a new chunk } resultArray[chunkIndex].push(item) return resultArray }, [] ) function getBgr(inp){ let bg = "linear-gradient(to right, " let last = 0 inp.forEach(e => { bg += e.c+" "+e.p+"%"+ ", " last = e.p }) bg +="transparent "+last+"%" bg += "), "; return bg } let bgrstring = "" result.forEach(e => { bgrstring += getBgr(e) }) bgrstring = bgrstring.slice(0, -2); gradient.style.background = bgrstring } } var updateTicks = function (times) { var len = times.length var g = document.getElementById("statra_ticks_" + $scope.unique); if (!g) { return } if (g.children.length > 0) { while (g.firstChild) { g.removeChild(g.firstChild); } } var tick var txt for (let i = 0; i < len; i++) { tick = document.createElementNS($scope.svgns, 'line'); tick.setAttribute('id', 'statra_tick_' + $scope.unique + "_" + i) tick.setAttributeNS(null, 'x1', times[i].x + "%"); tick.setAttributeNS(null, 'x2', times[i].x + "%"); tick.setAttributeNS(null, 'y1', $scope.sizes.tyt); tick.setAttributeNS(null, 'y2', $scope.sizes.tyb); tick.setAttributeNS(null, 'style', "stroke:currentColor;stroke-width:1"); tick.setAttributeNS(null, 'visibility', times[i].x == 0 ? 'hidden':'visible'); g.appendChild(tick); txt = document.createElementNS($scope.svgns, 'text'); txt.setAttribute('id', 'statra_tickval_' + $scope.unique + "_" + i) txt.setAttributeNS(null, 'x', times[i].x + "%"); txt.setAttributeNS(null, 'y', $scope.sizes.tybt + "%"); txt.setAttributeNS(null, 'dominant-baseline', 'baseline'); txt.setAttributeNS(null, 'text-anchor', 'middle'); txt.setAttribute('class', 'txt-' + $scope.unique + ' small statra_tickval_' + $scope.unique) txt.setAttributeNS(null, 'visibility', times[i].x == 0 ? 'hidden':'visible'); txt.textContent = times[i].v g.appendChild(txt); } clearOverlap() } var clearOverlap = function(){ let values = document.querySelectorAll('.statra_tickval_'+ $scope.unique) let rectas = [] Array.from(values).forEach((el,i) => { rectas.push({idx:i,box:el.getBBox()}) }) //console.log(rectas) rectas.sort((a, b) => { return b.box.x - a.box.x; }) let last = Number.MAX_SAFE_INTEGER; rectas.forEach((r,i) => { //console.log(r.idx, r.box.x + r.box.width, last) if(r.box.x + r.box.width >= last){ //console.log("cut",r.idx) var tick = document.getElementById("statra_tick_" + $scope.unique + "_" + r.idx); if (tick ) { $(tick).remove() } tick = document.getElementById("statra_tickval_" + $scope.unique + "_" + r.idx); if (tick) { $(tick).remove() } } else{ last = r.box.x - 2 ; } }) } $scope.$watch('msg', function (msg) { if (!msg) { return; } if (msg.payload) { //console.log('msg',$scope.unique,msg.payload) update(msg.payload) } }); $scope.$on('$destroy', function () { if ($scope.timeout != null) { clearTimeout($scope.timeout) $scope.timeout = null } }); } }); } } catch (e) { console.log(e); } node.on("close", function () { if (done) { done(); } }); } RED.nodes.registerType("ui_statetrail", StateTrailNode); };