# HG changeset patch # User Edouard Tisserant # Date 1656318364 -7200 # Node ID 074046800624eb03f39a3aa9dbe659eabc72d4de # Parent a35bf9c585cfcaf93f455dc6d42acd4cc6efcc1a# Parent c2f7e9bda366b350cfcb8976a8b14fa9a7835ab2 Merge default in wxPython4 branch diff -r a35bf9c585cf -r 074046800624 svghmi/analyse_widget.xslt --- a/svghmi/analyse_widget.xslt Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/analyse_widget.xslt Mon Jun 27 10:26:04 2022 +0200 @@ -2,7 +2,7 @@ - + @@ -263,25 +263,7 @@ animate(){ - if (this.active_elt && this.inactive_elt) { - - - if(this.display == " - - ") - - this. - - _elt.style.display = ""; - - else - - this. - - _elt.style.display = "none"; - - - } + this.set_activation_state(this.display == "active"); } @@ -291,6 +273,8 @@ this.element.addEventListener("pointerdown", this.onmousedown.bind(this)); + this.set_activation_state(undefined); + } @@ -411,7 +395,7 @@ - Printf-like formated text display + Printf-like formated text display printf-like format string when not given as svg:text @@ -885,6 +869,73 @@ Boolean variable + + + + + + XYGraph draws a cartesian trend graph re-using styles given for axis, + + grid/marks, legends and curves. + + + + Elements labeled "x_axis" and "y_axis" are svg:groups containg: + + - "axis_label" svg:text gives style an alignment for axis labels. + + - "interval_major_mark" and "interval_minor_mark" are svg elements to be + + duplicated along axis line to form intervals marks. + + - "axis_line" svg:path is the axis line. Paths must be intersect and their + + bounding box is the chart wall. + + + + Elements labeled "curve_0", "curve_1", ... are paths whose styles are used + + to draw curves corresponding to data from variables passed as HMI tree paths. + + "curve_0" is mandatory. HMI variables outnumbering given curves are ignored. + + + + + + Cartesian trend graph showing values of given variables over time + + + value + + + X axis range expressed either in samples or duration. + + + format string for X label + + + format string for Y label + + + + + + + + + + + + + + + + + + + diff -r a35bf9c585cf -r 074046800624 svghmi/gen_dnd_widget_svg.xslt --- a/svghmi/gen_dnd_widget_svg.xslt Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/gen_dnd_widget_svg.xslt Mon Jun 27 10:26:04 2022 +0200 @@ -4,7 +4,7 @@ - + diff -r a35bf9c585cf -r 074046800624 svghmi/gen_index_xhtml.xslt --- a/svghmi/gen_index_xhtml.xslt Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/gen_index_xhtml.xslt Mon Jun 27 10:26:04 2022 +0200 @@ -1,5 +1,5 @@ - + @@ -159,7 +159,7 @@ - + @@ -1108,6 +1108,11 @@ + + + + + @@ -1453,6 +1458,72 @@ + function _hide(elt, placeholder){ + + if(elt.parentNode != null) + + placeholder.parentNode.removeChild(elt); + + } + + function _show(elt, placeholder){ + + placeholder.parentNode.insertBefore(elt, placeholder); + + } + + + + function set_activation_state(eltsub, state){ + + if(eltsub.active_elt != undefined){ + + if(eltsub.active_elt_placeholder == undefined){ + + eltsub.active_elt_placeholder = document.createComment(""); + + eltsub.active_elt.parentNode.insertBefore(eltsub.active_elt_placeholder, eltsub.active_elt); + + } + + (state?_show:_hide)(eltsub.active_elt, eltsub.active_elt_placeholder); + + } + + if(eltsub.inactive_elt != undefined){ + + if(eltsub.inactive_elt_placeholder == undefined){ + + eltsub.inactive_elt_placeholder = document.createComment(""); + + eltsub.inactive_elt.parentNode.insertBefore(eltsub.inactive_elt_placeholder, eltsub.inactive_elt); + + } + + ((state || state==undefined)?_hide:_show)(eltsub.inactive_elt, eltsub.inactive_elt_placeholder); + + } + + } + + + + function activate_activable(eltsub) { + + set_activation_state(eltsub, true); + + } + + + + function inactivate_activable(eltsub) { + + set_activation_state(eltsub, false); + + } + + + class Widget { offset = 0; @@ -1485,7 +1556,19 @@ this.pending = indexes.map(() => undefined); - this.bound_unhinibit = this.unhinibit.bind(this); + this.bound_uninhibit = this.uninhibit.bind(this); + + + + this.lastdispatch = indexes.map(() => undefined); + + this.deafen = indexes.map(() => undefined); + + this.incoming = indexes.map(() => undefined); + + this.bound_undeafen = this.undeafen.bind(this); + + this.forced_frequency = freq; @@ -1573,10 +1656,22 @@ this.lastapply[i] = undefined; - this.unhinibit(i); + this.uninhibit(i); } + let deafened = this.deafen[i]; + + if(deafened != undefined){ + + clearTimeout(deafened); + + this.lastdispatch[i] = undefined; + + this.undeafen(i); + + } + let index = this.indexes[i]; if(this.relativeness[i]) @@ -1749,7 +1844,7 @@ - unhinibit(index){ + uninhibit(index){ this.inhibit[index] = undefined; @@ -1787,7 +1882,7 @@ this.pending[index] = new_val; - this.inhibit[index] = setTimeout(this.bound_unhinibit, min_interval - elapsed, index); + this.inhibit[index] = setTimeout(this.bound_uninhibit, min_interval - elapsed, index); } @@ -1831,22 +1926,68 @@ + undeafen(index){ + + this.deafen[index] = undefined; + + let [new_val, old_val] = this.incoming[index]; + + this.incoming[index] = undefined; + + this.dispatch(new_val, old_val, index); + + } + + + _dispatch(value, oldval, varnum) { let dispatch = this.dispatch; if(dispatch != undefined){ - try { - - dispatch.call(this, value, oldval, varnum); - - } catch(err) { - - console.log(err); + if(this.deafen[varnum] == undefined){ + + let now = Date.now(); + + let min_interval = 1000/this.frequency; + + let lastdispatch = this.lastdispatch[varnum]; + + if(lastdispatch == undefined || now > lastdispatch + min_interval){ + + this.lastdispatch[varnum] = now; + + try { + + dispatch.call(this, value, oldval, varnum); + + } catch(err) { + + console.log(err); + + } + + } + + else { + + let elapsed = now - lastdispatch; + + this.incoming[varnum] = [value, oldval]; + + this.deafen[varnum] = setTimeout(this.bound_undeafen, min_interval - elapsed, varnum); + + } } + else { + + this.incoming[varnum] = [value, oldval]; + + } + } } @@ -1875,27 +2016,13 @@ } - - - } - - - - activate_activable(eltsub) { - - eltsub.inactive.style.display = "none"; - - eltsub.active.style.display = ""; - - } - - - - inactivate_activable(eltsub) { - - eltsub.active.style.display = "none"; - - eltsub.inactive.style.display = ""; + } + + + + set_activation_state(state){ + + set_activation_state(this.activable_sub, state); } @@ -1960,6 +2087,7 @@ + @@ -1967,13 +2095,27 @@ - - + + - widget must have a + widget (id= + + ) must have a element - + + + + + + + + + + + + + @@ -1993,15 +2135,29 @@ - - + + - widget must have a + widget (id= + + ) must have a / element - + + + + + + + + + + + + + /* missing @@ -2013,7 +2169,7 @@ " - ": id(" + _elt": id(" ") @@ -2530,25 +2686,7 @@ animate(){ - if (this.active_elt && this.inactive_elt) { - - - if(this.display == " - - ") - - this. - - _elt.style.display = ""; - - else - - this. - - _elt.style.display = "none"; - - - } + this.set_activation_state(this.display == "active"); } @@ -2558,6 +2696,8 @@ this.element.addEventListener("pointerdown", this.onmousedown.bind(this)); + this.set_activation_state(undefined); + } @@ -2577,13 +2717,17 @@ + activable_sub:{ + - active inactive + /active /inactive - + + } + class @@ -2601,13 +2745,17 @@ + activable_sub:{ + - active inactive + /active /inactive - + + } + @@ -3377,7 +3525,7 @@ - Printf-like formated text display + Printf-like formated text display printf-like format string when not given as svg:text @@ -3395,7 +3543,15 @@ dispatch(value, oldval, index) { - this.fields[index] = value; + this.fields[index] = value; + + if(!this.ready){ + + this.readyfields[index] = true; + + this.ready = this.readyfields.every(x=>x); + + } this.request_animate(); @@ -3443,6 +3599,20 @@ ], + + + false + + , + + + + readyfields: [ + + ], + + ready: false, + animate: function(){ @@ -3457,28 +3627,30 @@ let str = vsprintf(this.format,this.fields); - multiline_to_svg_text(this.format_elt, str); + multiline_to_svg_text(this.format_elt, str, !this.ready); let str = this.args.length == 1 ? vsprintf(this.args[0],this.fields) : this.fields.join(' '); - multiline_to_svg_text(this.element, str); + multiline_to_svg_text(this.element, str, !this.ready); }, - + + + init: function() { - init: function() { - this.format = svg_text_to_multiline(this.format_elt); - }, - + this.animate(); + + }, + @@ -4211,7 +4383,7 @@ "); - this.content = langs; + this.content = langs.map(([lname,lcode]) => lname); @@ -4677,6 +4849,10 @@ } + + + display = ""; + } @@ -4757,6 +4933,8 @@ this.value_elt.style.pointerEvents = "none"; + this.animate(); + id(" @@ -4766,6 +4944,10 @@ "); + + this.value_elt.textContent = ""; + + }, @@ -5367,13 +5549,11 @@ would be an HMI_TREE index and then jump to a relative page not hard-coded in advance */ - - if(!that.disabled) { const index = that.indexes.length > 0 ? that.indexes[0] + that.offset : undefined; - switch_page(name, index); + fading_page_switch(name, index); } @@ -5504,6 +5684,37 @@ + + + + + /* + + */ + + + + .fade-out-page { + + animation: fadeOut 0.6s both; + + } + + + + @keyframes fadeOut { + + 0% { opacity: 1; } + + 100% { opacity: 0; } + + } + + + + + + @@ -5594,6 +5805,10 @@ this.shift |= this.caps; + if(this.virgin) + + this.editstr = ""; + this.editstr += syms[this.shift?syms.length-1:0]; this.shift = false; @@ -5730,7 +5945,9 @@ this.result_callback_obj = callback_obj; - this.Info_elt.textContent = info; + if(this.Info_elt) + + this.Info_elt.textContent = info; this.shift = false; @@ -5742,6 +5959,8 @@ this.update(); + this.virgin = true; + } @@ -5750,6 +5969,8 @@ if(this.editstr != this._editstr){ + this.virgin = false; + this._editstr = this.editstr; this.Value_elt.textContent = this.editstr; @@ -5760,7 +5981,7 @@ this._shift = this.shift; - (this.shift?this.activate_activable:this.inactivate_activable)(this.Shift_sub); + set_activation_state(this.Shift_sub, this.shift); } @@ -5768,7 +5989,7 @@ this._caps = this.caps; - (this.caps?this.activate_activable:this.inactivate_activable)(this.CapsLock_sub); + set_activation_state(this.CapsLock_sub, this.caps); } @@ -5782,13 +6003,13 @@ - Esc Enter BackSpace Keys Info Value + Esc Enter BackSpace Keys Value - Sign Space NumDot + Sign Space NumDot Info @@ -5837,6 +6058,8 @@ ], + virgin: false, + @@ -7503,17 +7726,51 @@ frequency = 5; + current_value = undefined; + + + + init(){ + + this.animate(); + + } + + + dispatch(value) { + this.current_value = value; + + this.request_animate(); + + } + + + + animate(){ + for(let choice of this.choices){ - if(value != choice.value){ - - choice.elt.setAttribute("style", "display:none"); + if(this.current_value != choice.value){ + + if(choice.parent == undefined){ + + choice.parent = choice.elt.parentElement; + + choice.parent.removeChild(choice.elt); + + } } else { - choice.elt.setAttribute("style", choice.style); + if(choice.parent != undefined){ + + choice.parent.insertBefore(choice.elt,choice.sibling); + + choice.parent = undefined; + + } } @@ -7532,18 +7789,30 @@ - + + + { elt:id(" "), - style:" - - ", - + parent:undefined, + + + + sibling:null, + + + + sibling:id(" + + "), + + + value: @@ -7703,27 +7972,11 @@ - activate(val) { - - let [active, inactive] = val ? ["","none"] : ["none", ""]; - - if (this.active_elt) - - this.active_elt.style.display = active; - - if (this.inactive_elt) - - this.inactive_elt.style.display = inactive; - - } - - - animate(){ // redraw toggle button on screen refresh - this.activate(this.state); + this.set_activation_state(this.state); } @@ -7731,10 +7984,10 @@ init() { - this.activate(false); - this.element.onclick = (evt) => this.on_click(evt); + this.set_activation_state(undefined); + } } @@ -7742,13 +7995,1351 @@ + activable_sub:{ + + + + + /active /inactive + + + + } + + + + + + + + XYGraph draws a cartesian trend graph re-using styles given for axis, + + grid/marks, legends and curves. + + + + Elements labeled "x_axis" and "y_axis" are svg:groups containg: + + - "axis_label" svg:text gives style an alignment for axis labels. + + - "interval_major_mark" and "interval_minor_mark" are svg elements to be + + duplicated along axis line to form intervals marks. + + - "axis_line" svg:path is the axis line. Paths must be intersect and their + + bounding box is the chart wall. + + + + Elements labeled "curve_0", "curve_1", ... are paths whose styles are used + + to draw curves corresponding to data from variables passed as HMI tree paths. + + "curve_0" is mandatory. HMI variables outnumbering given curves are ignored. + + + + + + Cartesian trend graph showing values of given variables over time + + + value + + + X axis range expressed either in samples or duration. + + + format string for X label + + + format string for Y label + + + + class + XYGraphWidget + extends Widget{ + + frequency = 1; + + init() { + + let x_duration_s; + + [x_duration_s, + + this.x_format, this.y_format] = this.args; + + + + let timeunit = x_duration_s.slice(-1); + + let factor = { + + "s":1, + + "m":60, + + "h":3600, + + "d":86400}[timeunit]; + + if(factor == undefined){ + + this.max_data_length = Number(x_duration_s); + + this.x_duration = undefined; + + }else{ + + let duration = factor*Number(x_duration_s.slice(0,-1)); + + this.max_data_length = undefined; + + this.x_duration = duration*1000; + + } + + + + + + // Min and Max given with paths are meant to describe visible range, + + // not to clip data. + + this.clip = false; + + + + let y_min = Infinity, y_max = -Infinity; + + + + // Compute visible Y range by merging fixed curves Y ranges + + for(let minmax of this.minmaxes){ + + if(minmax){ + + let [min,max] = minmax; + + if(min < y_min) + + y_min = min; + + if(max > y_max) + + y_max = max; + + } + + } + + + + if(y_min !== Infinity && y_max !== -Infinity){ + + this.fixed_y_range = true; + + } else { + + this.fixed_y_range = false; + + } + + + + this.ymin = y_min; + + this.ymax = y_max; + + + + this.curves = []; + + this.init_specific(); + + + + this.reference = new ReferenceFrame( + + [[this.x_interval_minor_mark_elt, this.x_interval_major_mark_elt], + + [this.y_interval_minor_mark_elt, this.y_interval_major_mark_elt]], + + [this.x_axis_label_elt, this.y_axis_label_elt], + + [this.x_axis_line_elt, this.y_axis_line_elt], + + [this.x_format, this.y_format]); + + + + let max_stroke_width = 0; + + for(let curve of this.curves){ + + if(curve.style.strokeWidth > max_stroke_width){ + + max_stroke_width = curve.style.strokeWidth; + + } + + } + + + + this.Margins=this.reference.getLengths().map(length => max_stroke_width/length); + + + + // create <clipPath> path and attach it to widget + + let clipPath = document.createElementNS(xmlns,"clipPath"); + + let clipPathPath = document.createElementNS(xmlns,"path"); + + let clipPathPathDattr = document.createAttribute("d"); + + clipPathPathDattr.value = this.reference.getClipPathPathDattr(); + + clipPathPath.setAttributeNode(clipPathPathDattr); + + clipPath.appendChild(clipPathPath); + + clipPath.id = randomId(); + + this.element.appendChild(clipPath); + + + + // assign created clipPath to clip-path property of curves + + for(let curve of this.curves){ + + curve.setAttribute("clip-path", "url(#" + clipPath.id + ")"); + + } + + + + this.curves_data = []; + + } + + + + dispatch(value,oldval, index) { + + // TODO: get PLC time instead of browser time + + let time = Date.now(); + + + + // naive local buffer impl. + + // data is updated only when graph is visible + + // TODO: replace with separate recording + + + + if(this.curves_data[index] === undefined){ + + this.curves_data[index] = []; + + } + + this.curves_data[index].push([time, value]); + + let data_length = this.curves_data[index].length; + + let ymin_damaged = false; + + let ymax_damaged = false; + + let overflow; + + + + if(this.max_data_length == undefined){ + + let peremption = time - this.x_duration; + + let oldest = this.curves_data[index][0][0] + + this.xmin = peremption; + + if(oldest < peremption){ + + // remove first item + + overflow = this.curves_data[index].shift()[1]; + + data_length = data_length - 1; + + } + + } else { + + if(data_length > this.max_data_length){ + + // remove first item + + [this.xmin, overflow] = this.curves_data[index].shift(); + + data_length = data_length - 1; + + } else { + + if(this.xmin == undefined){ + + this.xmin = time; + + } + + } + + } + + + + this.xmax = time; + + let Xrange = this.xmax - this.xmin; + + + + if(!this.fixed_y_range){ + + ymin_damaged = overflow <= this.ymin; + + ymax_damaged = overflow >= this.ymax; + + if(value > this.ymax){ + + ymax_damaged = false; + + this.ymax = value; + + } + + if(value < this.ymin){ + + ymin_damaged = false; + + this.ymin = value; + + } + + } + + let Yrange = this.ymax - this.ymin; + + + + // apply margin by moving min and max to enlarge range + + let [xMargin,yMargin] = zip(this.Margins, [Xrange, Yrange]).map(([m,l]) => m*l); + + [[this.dxmin, this.dxmax],[this.dymin,this.dymax]] = + + [[this.xmin-xMargin, this.xmax+xMargin], + + [this.ymin-yMargin, this.ymax+yMargin]]; + + Xrange += 2*xMargin; + + Yrange += 2*yMargin; + + + + // recompute curves "d" attribute + + // FIXME: use SVG getPathData and setPathData when available. + + // https://svgwg.org/specs/paths/#InterfaceSVGPathData + + // https://github.com/jarek-foksa/path-data-polyfill + + + + let [base_point, xvect, yvect] = this.reference.getBaseRef(); + + this.curves_d_attr = + + zip(this.curves_data, this.curves).map(([data,curve]) => { + + let new_d = data.map(([x,y], i) => { + + // compute curve point from data, ranges, and base_ref + + let xv = vectorscale(xvect, (x - this.dxmin) / Xrange); + + let yv = vectorscale(yvect, (y - this.dymin) / Yrange); + + let px = base_point.x + xv.x + yv.x; + + let py = base_point.y + xv.y + yv.y; + + if(!this.fixed_y_range){ + + // update min and max from curve data if needed + + if(ymin_damaged && y < this.ymin) this.ymin = y; + + if(ymax_damaged && y > this.ymax) this.ymax = y; + + } + + + + return " " + px + "," + py; + + }); + + + + new_d.unshift("M "); + + + + return new_d.join(''); + + }); + + + + // computed curves "d" attr is applied to svg curve during animate(); + + + + this.request_animate(); + + } + + + + animate(){ + + + + // move elements only if enough data + + if(this.curves_data.some(data => data.length > 1)){ + + + + // move marks and update labels + + this.reference.applyRanges([[this.dxmin, this.dxmax], + + [this.dymin, this.dymax]]); + + + + // apply computed curves "d" attributes + + for(let [curve, d_attr] of zip(this.curves, this.curves_d_attr)){ + + curve.setAttribute("d", d_attr); + + } + + } + + } + + + + } + + + + + + + + + + + + + + + + + + + + + - active inactive + /x_interval_minor_mark /x_axis_line /x_interval_major_mark /x_axis_label - + + + + /y_interval_minor_mark /y_axis_line /y_interval_major_mark /y_axis_label + + + init_specific() { + + + + + + XYGraph id=" + + ", label=" + + " : + + + + + + + + this.curves[ + + ] = id(" + + "); /* + + */ + + + } + + + + + + + /* + + */ + + + + function lineFromPath(path_elt) { + + let start = path_elt.getPointAtLength(0); + + let end = path_elt.getPointAtLength(path_elt.getTotalLength()); + + return [start, new DOMPoint(end.x - start.x , end.y - start.y)]; + + }; + + + + function vector(p1, p2) { + + return new DOMPoint(p2.x - p1.x , p2.y - p1.y); + + }; + + + + function vectorscale(p1, p2) { + + return new DOMPoint(p2 * p1.x , p2 * p1.y); + + }; + + + + function vectorLength(p1) { + + return Math.sqrt(p1.x*p1.x + p1.y*p1.y); + + }; + + + + function randomId(){ + + return Date.now().toString(36) + Math.random().toString(36).substr(2); + + } + + + + function move_elements_to_group(elements) { + + let newgroup = document.createElementNS(xmlns,"g"); + + newgroup.id = randomId(); + + + + for(let element of elements){ + + let parent = element.parentElement; + + if(parent !== null) + + parent.removeChild(element); + + newgroup.appendChild(element); + + } + + return newgroup; + + } + + function getLinesIntesection(l1, l2) { + + let [l1start, l1vect] = l1; + + let [l2start, l2vect] = l2; + + + + + + /* + + Compute intersection of two lines + + ================================= + + + + ^ l2vect + + / + + / + + / + + l1start ----------X--------------> l1vect + + / intersection + + / + + / + + l2start + + + + */ + + let [x1, y1, x3, y3] = [l1start.x, l1start.y, l2start.x, l2start.y]; + + let [x2, y2, x4, y4] = [x1+l1vect.x, y1+l1vect.y, x3+l2vect.x, y3+l2vect.y]; + + + + // line intercept math by Paul Bourke http://paulbourke.net/geometry/pointlineplane/ + + // Determine the intersection point of two line segments + + // Return FALSE if the lines don't intersect + + + + // Check if none of the lines are of length 0 + + if ((x1 === x2 && y1 === y2) || (x3 === x4 && y3 === y4)) { + + return false + + } + + + + denominator = ((y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)) + + + + // Lines are parallel + + if (denominator === 0) { + + return false + + } + + + + let ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator + + let ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denominator + + + + // Return a object with the x and y coordinates of the intersection + + let x = x1 + ua * (x2 - x1) + + let y = y1 + ua * (y2 - y1) + + + + return new DOMPoint(x,y); + + }; + + + + class ReferenceFrame { + + constructor( + + // [[Xminor,Xmajor], [Yminor,Ymajor]] + + marks, + + // [Xlabel, Ylabel] + + labels, + + // [Xline, Yline] + + lines, + + // [Xformat, Yformat] printf-like formating strings + + formats + + ){ + + this.axes = zip(labels,marks,lines,formats).map(args => new Axis(...args)); + + + + let [lx,ly] = this.axes.map(axis => axis.line); + + let [[xstart, xvect], [ystart, yvect]] = [lx,ly]; + + let base_point = this.getBasePoint(); + + + + // setup clipping for curves + + this.clipPathPathDattr = + + "m " + base_point.x + "," + base_point.y + " " + + + xvect.x + "," + xvect.y + " " + + + yvect.x + "," + yvect.y + " " + + + -xvect.x + "," + -xvect.y + " " + + + -yvect.x + "," + -yvect.y + " z"; + + + + this.base_ref = [base_point, xvect, yvect]; + + + + this.lengths = [xvect,yvect].map(v => vectorLength(v)); + + + + for(let axis of this.axes){ + + axis.setBasePoint(base_point); + + } + + } + + + + getLengths(){ + + return this.lengths; + + } + + + + getBaseRef(){ + + return this.base_ref; + + } + + + + getClipPathPathDattr(){ + + return this.clipPathPathDattr; + + } + + + + applyRanges(ranges){ + + let origin_moves = zip(ranges,this.axes).map(([range,axis]) => axis.applyRange(...range)); + + zip(origin_moves.reverse(),this.axes).forEach(([vect,axis]) => axis.moveOrigin(vect)); + + } + + + + getBasePoint() { + + let [[xstart, xvect], [ystart, yvect]] = this.axes.map(axis => axis.line); + + + + /* + + Compute graph clipping region base point + + ======================================== + + + + Clipping region is a parallelogram containing axes lines, + + and whose sides are parallel to axes line respectively. + + Given axes lines are not starting at the same point, hereafter is + + calculus of parallelogram base point. + + + + ^ given Y axis (yvect) + + / / + + / / + + / / + + xstart *---------*--------------> given X axis (xvect) + + / /origin + + / / + + *---------*-------------- + + base_point ystart + + + + */ + + + + let base_point = getLinesIntesection([xstart,yvect],[ystart,xvect]); + + + + return base_point; + + + + } + + + + } + + + + class Axis { + + constructor(label, marks, line, format){ + + this.lineElement = line; + + this.line = lineFromPath(line); + + this.format = format; + + + + this.label = label; + + this.marks = marks; + + + + + + // add transforms for elements sliding along the axis line + + for(let [elementname,element] of zip(["minor", "major", "label"],[...marks,label])){ + + for(let name of ["base","slide"]){ + + let transform = svg_root.createSVGTransform(); + + element.transform.baseVal.insertItemBefore(transform,0); + + this[elementname+"_"+name+"_transform"]=transform; + + }; + + }; + + + + // group marks an labels together + + let parent = line.parentElement; + + this.marks_group = move_elements_to_group(marks); + + this.marks_and_label_group = move_elements_to_group([this.marks_group, label]); + + this.group = move_elements_to_group([this.marks_and_label_group,line]); + + parent.appendChild(this.group); + + + + // Add transforms to group + + for(let name of ["base","origin"]){ + + let transform = svg_root.createSVGTransform(); + + this.group.transform.baseVal.appendItem(transform); + + this[name+"_transform"]=transform; + + }; + + + + this.marks_and_label_group_transform = svg_root.createSVGTransform(); + + this.marks_and_label_group.transform.baseVal.appendItem(this.marks_and_label_group_transform); + + + + this.duplicates = []; + + this.last_duplicate_index = 0; + + } + + + + setBasePoint(base_point){ + + // move Axis to base point + + let [start, _vect] = this.line; + + let v = vector(start, base_point); + + this.base_transform.setTranslate(v.x, v.y); + + + + // Move marks and label to base point. + + // _|_______ _|________ + + // | ' | ==> ' + + // | 0 0 + + // | | + + + + for(let [markname,mark] of zip(["minor", "major"],this.marks)){ + + let pos = vector( + + // Marks are expected to be paths + + // paths are expected to be lines + + // intersection with axis line is taken + + // as reference for mark position + + getLinesIntesection( + + this.line, lineFromPath(mark)),base_point); + + this[markname+"_base_transform"].setTranslate(pos.x - v.x, pos.y - v.y); + + if(markname == "major"){ // label follow major mark + + this.label_base_transform.setTranslate(pos.x - v.x, pos.y - v.y); + + } + + } + + } + + + + moveOrigin(vect){ + + this.origin_transform.setTranslate(vect.x, vect.y); + + } + + + + applyRange(min, max){ + + let range = max - min; + + + + // compute how many units for a mark + + // + + // - Units are expected to be an order of magnitude smaller than range, + + // so that marks are not too dense and also not too sparse. + + // Order of magnitude of range is log10(range) + + // + + // - Units are necessarily power of ten, otherwise it is complicated to + + // fill the text in labels... + + // Unit is pow(10, integer_number ) + + // + + // - To transform order of magnitude to an integer, floor() is used. + + // This results in a count of mark fluctuating in between 10 and 100. + + // + + // - To spare resources result is better in between 3 and 30, + + // and log10(3) is substracted to order of magnitude to obtain this + + let unit = Math.pow(10, Math.floor(Math.log10(range)-Math.log10(3))); + + + + // TODO: for time values (ms), units may be : + + // 1 -> ms + + // 10 -> s/100 + + // 100 -> s/10 + + // 1000 -> s + + // 60000 -> min + + // 3600000 -> hour + + // ... + + // + + + + // Compute position of origin along axis [0...range] + + + + // min < 0, max > 0, offset = -min + + // _____________|________________ + + // ... -3 -2 -1 |0 1 2 3 4 ... + + // <--offset---> ^ + + // |_original + + + + // min > 0, max > 0, offset = 0 + + // |________________ + + // |6 7 8 9 10... + + // ^ + + // |_original + + + + // min < 0, max < 0, offset = max-min (range) + + // _____________|_ + + // ... -5 -4 -3 |-2 + + // <--offset---> ^ + + // |_original + + + + let offset = (max>=0 && min>=0) ? 0 : ( + + (max<0 && min<0) ? range : -min); + + + + // compute unit vector + + let [_start, vect] = this.line; + + let unit_vect = vectorscale(vect, 1/range); + + let [mark_min, mark_max, mark_offset] = [min,max,offset].map(val => Math.round(val/unit)); + + let mark_count = mark_max-mark_min; + + + + // apply unit vector to marks and label + + // offset is a representing position of an + + // axis along the opposit axis line, expressed in major marks units + + // unit_vect is unit vector + + + + // ^ + + // | unit_vect + + // |<---> + + // _________|__________> + + // ^ | ' | ' | ' + + // |yoffset | 1 + + // | | + + // v xoffset| + + // X<------>| + + // base_point + + + + // move major marks and label to first positive mark position + + // let v = vectorscale(unit_vect, unit); + + // this.label_slide_transform.setTranslate(v.x, v.y); + + // this.major_slide_transform.setTranslate(v.x, v.y); + + // move minor mark to first half positive mark position + + let v = vectorscale(unit_vect, unit/2); + + this.minor_slide_transform.setTranslate(v.x, v.y); + + + + // duplicate marks and labels as needed + + let current_mark_count = this.duplicates.length; + + for(let i = current_mark_count; i <= mark_count; i++){ + + // cloneNode() label and add a svg:use of marks in a new group + + let newgroup = document.createElementNS(xmlns,"g"); + + let transform = svg_root.createSVGTransform(); + + let newlabel = this.label.cloneNode(true); + + let newuse = document.createElementNS(xmlns,"use"); + + let newuseAttr = document.createAttribute("href"); + + newuseAttr.value = "#"+this.marks_group.id; + + newuse.setAttributeNode(newuseAttr); + + newgroup.transform.baseVal.appendItem(transform); + + newgroup.appendChild(newlabel); + + newgroup.appendChild(newuse); + + this.duplicates.push([transform,newgroup]); + + } + + + + // move marks and labels, set labels + + // + + // min > 0, max > 0, offset = 0 + + // ^ + + // |________> + + // '| | ' | + + // | 6 7 + + // X + + // base_point + + // + + // min < 0, max > 0, offset = -min + + // ^ + + // _________|__________> + + // ' | ' | ' | ' + + // -1 | 1 + + // offset | + + // X<------>| + + // base_point + + // + + // min < 0, max < 0, offset = range + + // ^ + + // ____________| + + // ' | ' | |' + + // -5 -4 | + + // offset | + + // X<--------->| + + // base_point + + + + let duplicate_index = 0; + + for(let mark_index = 0; mark_index <= mark_count; mark_index++){ + + let val = (mark_min + mark_index) * unit; + + let vec = vectorscale(unit_vect, val - min); + + let text = this.format ? sprintf(this.format, val) : val.toString(); + + if(mark_index == mark_offset){ + + // apply offset to original marks and label groups + + this.marks_and_label_group_transform.setTranslate(vec.x, vec.y); + + + + // update original label text + + this.label.getElementsByTagName("tspan")[0].textContent = text; + + } else { + + let [transform,element] = this.duplicates[duplicate_index++]; + + + + // apply unit vector*N to marks and label groups + + transform.setTranslate(vec.x, vec.y); + + + + // update label text + + element.getElementsByTagName("tspan")[0].textContent = text; + + + + // Attach to group if not already + + if(element.parentElement == null){ + + this.group.appendChild(element); + + } + + } + + } + + + + let save_duplicate_index = duplicate_index; + + // dettach marks and label from group if not anymore visible + + for(;duplicate_index < this.last_duplicate_index; duplicate_index++){ + + let [transform,element] = this.duplicates[duplicate_index]; + + this.group.removeChild(element); + + } + + + + this.last_duplicate_index = save_duplicate_index; + + + + return vectorscale(unit_vect, offset); + + } + + } + + + @@ -7758,6 +9349,7 @@ @@ -8027,9 +9619,21 @@ let lang = get_current_lang_code(); - arg = Date(arg).toLocaleString('en-US', options); - - + let f; + + try{ + + f = new Intl.DateTimeFormat(lang, options); + + } catch(e) { + + f = new Intl.DateTimeFormat('en-US', options); + + } + + arg = f.format(arg); + + /* @@ -8349,6 +9953,430 @@ }(); // eslint-disable-line + /* + + + + From https://github.com/keyvan-m-sadeghi/pythonic + + + + Slightly modified in order to be usable in browser (i.e. not as a node.js module) + + + + The MIT License (MIT) + + + + Copyright (c) 2016 Assister.Ai + + + + 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. + + */ + + + + class Iterator { + + constructor(generator) { + + this[Symbol.iterator] = generator; + + } + + + + async * [Symbol.asyncIterator]() { + + for (const element of this) { + + yield await element; + + } + + } + + + + forEach(callback) { + + for (const element of this) { + + callback(element); + + } + + } + + + + map(callback) { + + const result = []; + + for (const element of this) { + + result.push(callback(element)); + + } + + + + return result; + + } + + + + filter(callback) { + + const result = []; + + for (const element of this) { + + if (callback(element)) { + + result.push(element); + + } + + } + + + + return result; + + } + + + + reduce(callback, initialValue) { + + let empty = typeof initialValue === 'undefined'; + + let accumulator = initialValue; + + let index = 0; + + for (const currentValue of this) { + + if (empty) { + + accumulator = currentValue; + + empty = false; + + continue; + + } + + + + accumulator = callback(accumulator, currentValue, index, this); + + index++; + + } + + + + if (empty) { + + throw new TypeError('Reduce of empty Iterator with no initial value'); + + } + + + + return accumulator; + + } + + + + some(callback) { + + for (const element of this) { + + if (callback(element)) { + + return true; + + } + + } + + + + return false; + + } + + + + every(callback) { + + for (const element of this) { + + if (!callback(element)) { + + return false; + + } + + } + + + + return true; + + } + + + + static fromIterable(iterable) { + + return new Iterator(function * () { + + for (const element of iterable) { + + yield element; + + } + + }); + + } + + + + toArray() { + + return Array.from(this); + + } + + + + next() { + + if (!this.currentInvokedGenerator) { + + this.currentInvokedGenerator = this[Symbol.iterator](); + + } + + + + return this.currentInvokedGenerator.next(); + + } + + + + reset() { + + delete this.currentInvokedGenerator; + + } + + } + + + + function rangeSimple(stop) { + + return new Iterator(function * () { + + for (let i = 0; i < stop; i++) { + + yield i; + + } + + }); + + } + + + + function rangeOverload(start, stop, step = 1) { + + return new Iterator(function * () { + + for (let i = start; i < stop; i += step) { + + yield i; + + } + + }); + + } + + + + function range(...args) { + + if (args.length < 2) { + + return rangeSimple(...args); + + } + + + + return rangeOverload(...args); + + } + + + + function enumerate(iterable) { + + return new Iterator(function * () { + + let index = 0; + + for (const element of iterable) { + + yield [index, element]; + + index++; + + } + + }); + + } + + + + const _zip = longest => (...iterables) => { + + if (iterables.length < 2) { + + throw new TypeError("zip takes 2 iterables at least, "+iterables.length+" given"); + + } + + + + return new Iterator(function * () { + + const iterators = iterables.map(iterable => Iterator.fromIterable(iterable)); + + while (true) { + + const row = iterators.map(iterator => iterator.next()); + + const check = longest ? row.every.bind(row) : row.some.bind(row); + + if (check(next => next.done)) { + + return; + + } + + + + yield row.map(next => next.value); + + } + + }); + + }; + + + + const zip = _zip(false), zipLongest= _zip(true); + + + + function items(obj) { + + let {keys, get} = obj; + + if (obj instanceof Map) { + + keys = keys.bind(obj); + + get = get.bind(obj); + + } else { + + keys = function () { + + return Object.keys(obj); + + }; + + + + get = function (key) { + + return obj[key]; + + }; + + } + + + + return new Iterator(function * () { + + for (const key of keys()) { + + yield [key, get(key)]; + + } + + }); + + } + + + + /* + + module.exports = {Iterator, range, enumerate, zip: _zip(false), zipLongest: _zip(true), items}; + + */ + // svghmi.js @@ -8757,6 +10785,30 @@ + + + var page_fading_in_progress = false; + + function fading_page_switch(...args){ + + svg_root.classList.add("fade-out-page"); + + page_fading_in_progress = true; + + + + setTimeout(function(){ + + switch_page(...args); + + },1); + + } + + document.body.style.backgroundColor = "black"; + + + // subscribe to per instance current page hmi variable // PLC must prefix page name with "!" for page switch to happen @@ -8771,7 +10823,7 @@ if(value.startsWith("!")) - switch_page(value.slice(1)); + fading_page_switch(value.slice(1)); } @@ -8787,9 +10839,9 @@ - function multiline_to_svg_text(elt, str) { - - str.split('\n').map((line,i) => {elt.children[i].textContent = line;}); + function multiline_to_svg_text(elt, str, blank) { + + str.split('\n').map((line,i) => {elt.children[i].textContent = blank?"":line;}); } @@ -9357,6 +11409,12 @@ svg_root.setAttribute('viewBox',new_desc.bbox.join(" ")); + if(page_fading_in_progress) + + svg_root.classList.remove("fade-out-page"); + + page_fading_in_progress = false; + current_visible_page = page_name; }; diff -r a35bf9c585cf -r 074046800624 svghmi/gen_index_xhtml.ysl2 --- a/svghmi/gen_index_xhtml.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/gen_index_xhtml.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -34,6 +34,7 @@ xmlns:declarations="declarations" xmlns:definitions="definitions" xmlns:epilogue="epilogue" + xmlns:cssdefs="cssdefs" /* Namespace to invoke python code */ xmlns:ns="beremiz" @@ -74,6 +75,7 @@ head { style type="text/css" media="screen" { value "ns:GetFonts()"; + apply "document('')/*/cssdefs:*"; } } // prevents user selection by mouse click / touch and drag diff -r a35bf9c585cf -r 074046800624 svghmi/parse_labels.ysl2 --- a/svghmi/parse_labels.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/parse_labels.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -13,7 +13,7 @@ // path value="path4" index="path4" type="HMI_LOCAL"; // } // -const "pathregex",!"'^([^\[,]+)(\[[^\]]+\])?([\d,]*)$'"!; +const "pathregex",!"'^([^\[,]+)(\[[^\]]+\])?([-.\d,]*)$'"!; template "*", mode="parselabel" { diff -r a35bf9c585cf -r 074046800624 svghmi/svghmi.js --- a/svghmi/svghmi.js Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/svghmi.js Mon Jun 27 10:26:04 2022 +0200 @@ -202,6 +202,18 @@ }); } + +var page_fading_in_progress = false; +function fading_page_switch(...args){ + svg_root.classList.add("fade-out-page"); + page_fading_in_progress = true; + + setTimeout(function(){ + switch_page(...args); + },1); +} +document.body.style.backgroundColor = "black"; + // subscribe to per instance current page hmi variable // PLC must prefix page name with "!" for page switch to happen subscribers(current_page_var_index).add({ @@ -209,7 +221,7 @@ indexes: [current_page_var_index], new_hmi_value: function(index, value, oldval) { if(value.startsWith("!")) - switch_page(value.slice(1)); + fading_page_switch(value.slice(1)); } }); @@ -217,8 +229,8 @@ return(Array.prototype.map.call(elt.children, x=>x.textContent).join("\\\\n")); } -function multiline_to_svg_text(elt, str) { - str.split('\\\\n').map((line,i) => {elt.children[i].textContent = line;}); +function multiline_to_svg_text(elt, str, blank) { + str.split('\\\\n').map((line,i) => {elt.children[i].textContent = blank?"":line;}); } function switch_langnum(langnum) { @@ -502,6 +514,9 @@ } svg_root.setAttribute('viewBox',new_desc.bbox.join(" ")); + if(page_fading_in_progress) + svg_root.classList.remove("fade-out-page"); + page_fading_in_progress = false; current_visible_page = page_name; }; diff -r a35bf9c585cf -r 074046800624 svghmi/widget_button.ysl2 --- a/svghmi/widget_button.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/widget_button.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -183,19 +183,13 @@ apply "$fsm", mode="actions"; | animate(){ - | if (this.active_elt && this.inactive_elt) { - foreach "str:split('active inactive')" { - | if(this.display == "«.»") - | this.«.»_elt.style.display = ""; - | else - | this.«.»_elt.style.display = "none"; - } - | } + | this.set_activation_state(this.display == "active"); | } | init() { | this.bound_onmouseup = this.onmouseup.bind(this); | this.element.addEventListener("pointerdown", this.onmousedown.bind(this)); + | this.set_activation_state(undefined); | } } @@ -207,7 +201,7 @@ } widget_defs("Button") { - optional_labels("active inactive"); + activable(); } widget_class("PushButton"){ @@ -217,6 +211,6 @@ } widget_defs("PushButton") { - optional_labels("active inactive"); -} - + activable(); +} + diff -r a35bf9c585cf -r 074046800624 svghmi/widget_display.ysl2 --- a/svghmi/widget_display.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/widget_display.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -15,12 +15,12 @@ format string as first argument. || - shortdesc > Printf-like formated text display + shortdesc > Printf-like formated text display arg name="format" count="optional" accepts="string" > printf-like format string when not given as svg:text path name="fields" count="many" accepts="HMI_INT,HMI_REAL,HMI_STRING,HMI_BOOL" > variables to be displayed - + } @@ -28,7 +28,11 @@ || frequency = 5; dispatch(value, oldval, index) { - this.fields[index] = value; + this.fields[index] = value; + if(!this.ready){ + this.readyfields[index] = true; + this.ready = this.readyfields.every(x=>x); + } this.request_animate(); } || @@ -49,6 +53,12 @@ if "position()!=last()" > , } | fields: [«$field_initializer»], + const "readyfield_initializer" foreach "path" { + > false + if "position()!=last()" > , + } + | readyfields: [«$readyfield_initializer»], + | ready: false, | animate: function(){ choose { when "$has_format" { @@ -57,18 +67,19 @@ | this.format_elt.removeAttribute("lang"); | } | let str = vsprintf(this.format,this.fields); - | multiline_to_svg_text(this.format_elt, str); + | multiline_to_svg_text(this.format_elt, str, !this.ready); } otherwise { | let str = this.args.length == 1 ? vsprintf(this.args[0],this.fields) : this.fields.join(' '); - | multiline_to_svg_text(this.element, str); + | multiline_to_svg_text(this.element, str, !this.ready); } } | }, - | + | + | init: function() { if "$has_format" { - | init: function() { | this.format = svg_text_to_multiline(this.format_elt); + } + | this.animate(); | }, - } } diff -r a35bf9c585cf -r 074046800624 svghmi/widget_input.ysl2 --- a/svghmi/widget_input.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/widget_input.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -51,6 +51,8 @@ undershot(new_val, min) { this.alert("min"); } + + display = ""; || widget_defs("Input") { @@ -65,7 +67,6 @@ if "$have_value" | frequency: 5, - | dispatch: function(value) { @@ -100,11 +101,15 @@ if "$have_value" { | this.value_elt.style.pointerEvents = "none"; } + | this.animate(); } foreach "$hmi_element/*[regexp:test(@inkscape:label,'^[=+\-].+')]" { | id("«@id»").onclick = () => this.on_op_click("«func:escape_quotes(@inkscape:label)»"); } + if "$have_value" { + | this.value_elt.textContent = ""; + } | }, } diff -r a35bf9c585cf -r 074046800624 svghmi/widget_jump.ysl2 --- a/svghmi/widget_jump.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/widget_jump.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -66,10 +66,9 @@ /* TODO: in order to allow jumps to page selected through for exemple a dropdown, support path pointing to local variable whom value would be an HMI_TREE index and then jump to a relative page not hard-coded in advance */ - if(!that.disabled) { const index = that.indexes.length > 0 ? that.indexes[0] + that.offset : undefined; - switch_page(name, index); + fading_page_switch(name, index); } } } @@ -143,6 +142,19 @@ } } +emit "cssdefs:jump" +|| +.fade-out-page { + animation: fadeOut 0.6s both; +} + +@keyframes fadeOut { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +|| + emit "declarations:jump" || var jumps_need_update = false; diff -r a35bf9c585cf -r 074046800624 svghmi/widget_keypad.ysl2 --- a/svghmi/widget_keypad.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/widget_keypad.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -30,6 +30,8 @@ on_key_click(symbols) { var syms = symbols.split(" "); this.shift |= this.caps; + if(this.virgin) + this.editstr = ""; this.editstr += syms[this.shift?syms.length-1:0]; this.shift = false; this.update(); @@ -98,33 +100,36 @@ show_modal.call(this,size); this.editstr = String(initial); this.result_callback_obj = callback_obj; - this.Info_elt.textContent = info; + if(this.Info_elt) + this.Info_elt.textContent = info; this.shift = false; this.caps = false; this.initial = initial; this.update(); + this.virgin = true; } update() { if(this.editstr != this._editstr){ + this.virgin = false; this._editstr = this.editstr; this.Value_elt.textContent = this.editstr; } if(this.Shift_sub && this.shift != this._shift){ this._shift = this.shift; - (this.shift?this.activate_activable:this.inactivate_activable)(this.Shift_sub); + set_activation_state(this.Shift_sub, this.shift); } if(this.CapsLock_sub && this.caps != this._caps){ this._caps = this.caps; - (this.caps?this.activate_activable:this.inactivate_activable)(this.CapsLock_sub); + set_activation_state(this.CapsLock_sub, this.caps); } } || widget_defs("Keypad") { - labels("Esc Enter BackSpace Keys Info Value"); - optional_labels("Sign Space NumDot"); + labels("Esc Enter BackSpace Keys Value"); + optional_labels("Sign Space NumDot Info"); activable_labels("CapsLock Shift"); | init: function() { foreach "$hmi_element/*[@inkscape:label = 'Keys']/*" { @@ -138,4 +143,5 @@ | const "g", "$geometry[@Id = $hmi_element/@id]"; | coordinates: [«$g/@x», «$g/@y»], + | virgin: false, } diff -r a35bf9c585cf -r 074046800624 svghmi/widget_switch.ysl2 --- a/svghmi/widget_switch.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/widget_switch.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -23,12 +23,29 @@ widget_class("Switch") || frequency = 5; + current_value = undefined; + + init(){ + this.animate(); + } + dispatch(value) { + this.current_value = value; + this.request_animate(); + } + + animate(){ for(let choice of this.choices){ - if(value != choice.value){ - choice.elt.setAttribute("style", "display:none"); + if(this.current_value != choice.value){ + if(choice.parent == undefined){ + choice.parent = choice.elt.parentElement; + choice.parent.removeChild(choice.elt); + } } else { - choice.elt.setAttribute("style", choice.style); + if(choice.parent != undefined){ + choice.parent.insertBefore(choice.elt,choice.sibling); + choice.parent = undefined; + } } } } @@ -38,16 +55,26 @@ | choices: [ const "regex",!"'^(\"[^\"].*\"|\-?[0-9]+|false|true)(#.*)?$'"!; + // this prevents matching element in sub-widgets const "subelts", "$result_widgets[@id = $hmi_element/@id]//*"; const "subwidgets", "$subelts//*[@id = $hmi_widgets/@id]"; const "accepted", "$subelts[not(ancestor-or-self::*/@id = $subwidgets/@id)]"; - foreach "$accepted[regexp:test(@inkscape:label,$regex)]" { + const "choices", "$accepted[regexp:test(@inkscape:label,$regex)]"; + foreach "$choices" { const "literal", "regexp:match(@inkscape:label,$regex)[2]"; + const "sibling", "following-sibling::*[not(@id = $choices/@id)][position()=1]"; | { | elt:id("«@id»"), - // TODO : use style.display = "none" to hide element - | style:"«@style»", + | parent:undefined, + choose { + when "count($sibling)=0" { + | sibling:null, + } + otherwise { + | sibling:id("«$sibling/@id»"), + } + } | value:«$literal» | }`if "position()!=last()" > ,` } diff -r a35bf9c585cf -r 074046800624 svghmi/widget_tooglebutton.ysl2 --- a/svghmi/widget_tooglebutton.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/widget_tooglebutton.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -37,26 +37,19 @@ this.request_animate(); } - activate(val) { - let [active, inactive] = val ? ["","none"] : ["none", ""]; - if (this.active_elt) - this.active_elt.style.display = active; - if (this.inactive_elt) - this.inactive_elt.style.display = inactive; - } - animate(){ // redraw toggle button on screen refresh - this.activate(this.state); + this.set_activation_state(this.state); } init() { - this.activate(false); this.element.onclick = (evt) => this.on_click(evt); + this.set_activation_state(undefined); } || } widget_defs("ToggleButton") { - optional_labels("active inactive"); + activable(); } + diff -r a35bf9c585cf -r 074046800624 svghmi/widget_xygraph.ysl2 --- a/svghmi/widget_xygraph.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/widget_xygraph.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -22,20 +22,35 @@ path name="value" count="1+" accepts="HMI_INT,HMI_REAL" > value - arg name="size" accepts="int" > buffer size + arg name="xrange" accepts="int,time" > X axis range expressed either in samples or duration. arg name="xformat" count="optional" accepts="string" > format string for X label arg name="yformat" count="optional" accepts="string" > format string for Y label - arg name="ymin" count="optional" accepts="int,real" > minimum value foe Y axis - arg name="ymax" count="optional" accepts="int,real" > maximum value for Y axis } widget_class("XYGraph") { || frequency = 1; init() { - [this.x_size, + let x_duration_s; + [x_duration_s, this.x_format, this.y_format] = this.args; + let timeunit = x_duration_s.slice(-1); + let factor = { + "s":1, + "m":60, + "h":3600, + "d":86400}[timeunit]; + if(factor == undefined){ + this.max_data_length = Number(x_duration_s); + this.x_duration = undefined; + }else{ + let duration = factor*Number(x_duration_s.slice(0,-1)); + this.max_data_length = undefined; + this.x_duration = duration*1000; + } + + // Min and Max given with paths are meant to describe visible range, // not to clip data. this.clip = false; @@ -96,8 +111,7 @@ curve.setAttribute("clip-path", "url(#" + clipPath.id + ")"); } - this.curves_data = this.curves.map(_unused => []); - this.max_data_length = this.args[0]; + this.curves_data = []; } dispatch(value,oldval, index) { @@ -108,19 +122,33 @@ // data is updated only when graph is visible // TODO: replace with separate recording + if(this.curves_data[index] === undefined){ + this.curves_data[index] = []; + } this.curves_data[index].push([time, value]); let data_length = this.curves_data[index].length; let ymin_damaged = false; let ymax_damaged = false; let overflow; - if(data_length > this.max_data_length){ - // remove first item - [this.xmin, overflow] = this.curves_data[index].shift(); - data_length = data_length - 1; + if(this.max_data_length == undefined){ + let peremption = time - this.x_duration; + let oldest = this.curves_data[index][0][0] + this.xmin = peremption; + if(oldest < peremption){ + // remove first item + overflow = this.curves_data[index].shift()[1]; + data_length = data_length - 1; + } } else { - if(this.xmin == undefined){ - this.xmin = time; + if(data_length > this.max_data_length){ + // remove first item + [this.xmin, overflow] = this.curves_data[index].shift(); + data_length = data_length - 1; + } else { + if(this.xmin == undefined){ + this.xmin = time; + } } } @@ -141,6 +169,7 @@ } let Yrange = this.ymax - this.ymin; + // apply margin by moving min and max to enlarge range let [xMargin,yMargin] = zip(this.Margins, [Xrange, Yrange]).map(([m,l]) => m*l); [[this.dxmin, this.dxmax],[this.dymin,this.dymax]] = [[this.xmin-xMargin, this.xmax+xMargin], @@ -163,6 +192,7 @@ let px = base_point.x + xv.x + yv.x; let py = base_point.y + xv.y + yv.y; if(!this.fixed_y_range){ + // update min and max from curve data if needed if(ymin_damaged && y < this.ymin) this.ymin = y; if(ymax_damaged && y > this.ymax) this.ymax = y; } @@ -199,6 +229,22 @@ || } +def "func:check_curves_label_consistency" { + param "curve_elts"; + param "number_to_check"; + const "res" choose { + when "$curve_elts[@inkscape:label = concat('curve_', string($number_to_check))]"{ + if "$number_to_check > 0"{ + value "func:check_curves_label_consistency($curve_elts, $number_to_check - 1)"; + } + } + otherwise { + value "concat('missing curve_', string($number_to_check))"; + } + } + result "$res"; +} + widget_defs("XYGraph") { labels("/x_interval_minor_mark /x_axis_line /x_interval_major_mark /x_axis_label"); labels("/y_interval_minor_mark /y_axis_line /y_interval_major_mark /y_axis_label"); @@ -206,15 +252,15 @@ | init_specific() { // collect all curve_n labelled children - foreach "$hmi_element/*[regexp:test(@inkscape:label,'^curve_[0-9]+$')]" { + const "curves","$hmi_element/*[regexp:test(@inkscape:label,'^curve_[0-9]+$')]"; + const "curves_error", "func:check_curves_label_consistency($curves,count($curves)-1)"; + if "string-length($curves_error)" + error > XYGraph id="«@id»", label="«@inkscape:label»" : «$curves_error» + foreach "$curves" { const "label","@inkscape:label"; - const "id","@id"; - - // detect non-unique names - if "$hmi_element/*[not($id = @id) and @inkscape:label=$label]"{ - error > XYGraph id="«$id»", label="«$label»" : elements with data_n label must be unique. - } - | this.curves[«substring(@inkscape:label, 7)»] = id("«@id»"); /* «@inkscape:label» */ + const "_id","@id"; + const "curve_num", "substring(@inkscape:label, 7)"; + | this.curves[«$curve_num»] = id("«@id»"); /* «@inkscape:label» */ } | } diff -r a35bf9c585cf -r 074046800624 svghmi/widgets_common.ysl2 --- a/svghmi/widgets_common.ysl2 Mon Jun 13 19:22:31 2022 +0200 +++ b/svghmi/widgets_common.ysl2 Mon Jun 27 10:26:04 2022 +0200 @@ -14,6 +14,20 @@ } }; +decl warning_labels(*ptr) alias - { + labels(*ptr){ + with "mandatory","'warn'"; + content; + } +}; + +decl activable() alias - { + | activable_sub:{ + warning_labels("/active /inactive") { + content; + } + | } +}; decl activable_labels(*ptr) alias - { optional_labels(*ptr) { with "subelements","'active inactive'"; @@ -165,6 +179,39 @@ || var pending_widget_animates = []; + function _hide(elt, placeholder){ + if(elt.parentNode != null) + placeholder.parentNode.removeChild(elt); + } + function _show(elt, placeholder){ + placeholder.parentNode.insertBefore(elt, placeholder); + } + + function set_activation_state(eltsub, state){ + if(eltsub.active_elt != undefined){ + if(eltsub.active_elt_placeholder == undefined){ + eltsub.active_elt_placeholder = document.createComment(""); + eltsub.active_elt.parentNode.insertBefore(eltsub.active_elt_placeholder, eltsub.active_elt); + } + (state?_show:_hide)(eltsub.active_elt, eltsub.active_elt_placeholder); + } + if(eltsub.inactive_elt != undefined){ + if(eltsub.inactive_elt_placeholder == undefined){ + eltsub.inactive_elt_placeholder = document.createComment(""); + eltsub.inactive_elt.parentNode.insertBefore(eltsub.inactive_elt_placeholder, eltsub.inactive_elt); + } + ((state || state==undefined)?_hide:_show)(eltsub.inactive_elt, eltsub.inactive_elt_placeholder); + } + } + + function activate_activable(eltsub) { + set_activation_state(eltsub, true); + } + + function inactivate_activable(eltsub) { + set_activation_state(eltsub, false); + } + class Widget { offset = 0; frequency = 10; /* FIXME arbitrary default max freq. Obtain from config ? */ @@ -181,7 +228,13 @@ this.lastapply = indexes.map(() => undefined); this.inhibit = indexes.map(() => undefined); this.pending = indexes.map(() => undefined); - this.bound_unhinibit = this.unhinibit.bind(this); + this.bound_uninhibit = this.uninhibit.bind(this); + + this.lastdispatch = indexes.map(() => undefined); + this.deafen = indexes.map(() => undefined); + this.incoming = indexes.map(() => undefined); + this.bound_undeafen = this.undeafen.bind(this); + this.forced_frequency = freq; this.clip = true; } @@ -225,7 +278,13 @@ if(inhibition != undefined){ clearTimeout(inhibition); this.lastapply[i] = undefined; - this.unhinibit(i); + this.uninhibit(i); + } + let deafened = this.deafen[i]; + if(deafened != undefined){ + clearTimeout(deafened); + this.lastdispatch[i] = undefined; + this.undeafen(i); } let index = this.indexes[i]; if(this.relativeness[i]) @@ -313,7 +372,7 @@ return apply_hmi_value(realindex, new_val); } - unhinibit(index){ + uninhibit(index){ this.inhibit[index] = undefined; let new_val = this.pending[index]; this.pending[index] = undefined; @@ -332,7 +391,7 @@ else { let elapsed = now - lastapply; this.pending[index] = new_val; - this.inhibit[index] = setTimeout(this.bound_unhinibit, min_interval - elapsed, index); + this.inhibit[index] = setTimeout(this.bound_uninhibit, min_interval - elapsed, index); } } else { @@ -354,13 +413,36 @@ } } + undeafen(index){ + this.deafen[index] = undefined; + let [new_val, old_val] = this.incoming[index]; + this.incoming[index] = undefined; + this.dispatch(new_val, old_val, index); + } + _dispatch(value, oldval, varnum) { let dispatch = this.dispatch; if(dispatch != undefined){ - try { - dispatch.call(this, value, oldval, varnum); - } catch(err) { - console.log(err); + if(this.deafen[varnum] == undefined){ + let now = Date.now(); + let min_interval = 1000/this.frequency; + let lastdispatch = this.lastdispatch[varnum]; + if(lastdispatch == undefined || now > lastdispatch + min_interval){ + this.lastdispatch[varnum] = now; + try { + dispatch.call(this, value, oldval, varnum); + } catch(err) { + console.log(err); + } + } + else { + let elapsed = now - lastdispatch; + this.incoming[varnum] = [value, oldval]; + this.deafen[varnum] = setTimeout(this.bound_undeafen, min_interval - elapsed, varnum); + } + } + else { + this.incoming[varnum] = [value, oldval]; } } } @@ -376,17 +458,10 @@ this.pending_animate = true; requestHMIAnimation(); } - - } - - activate_activable(eltsub) { - eltsub.inactive.style.display = "none"; - eltsub.active.style.display = ""; - } - - inactivate_activable(eltsub) { - eltsub.active.style.display = "none"; - eltsub.inactive.style.display = ""; + } + + set_activation_state(state){ + set_activation_state(this.activable_sub, state); } } || @@ -427,6 +502,7 @@ param "subelements","/.."; param "hmi_element"; const "widget_type","@type"; + const "widget_id","@id"; foreach "str:split($labels)" { const "absolute", "starts-with(., '/')"; const "name","substring(.,number($absolute)+1)"; @@ -434,8 +510,16 @@ const "elt","($widget//*[not($absolute) and @inkscape:label=$name] | $widget/*[$absolute and @inkscape:label=$name])[1]"; choose { when "not($elt/@id)" { - if "$mandatory='yes'" { - error > «$widget_type» widget must have a «$name» element + if "$mandatory!='no'" { + const "errmsg" > «$widget_type» widget (id=«$widget_id») must have a «$name» element + choose { + when "$mandatory='yes'" { + error > «$errmsg» + } + otherwise { + warning > «$errmsg» + } + } } // otherwise produce nothing } @@ -448,13 +532,21 @@ const "subelt","$elt/*[@inkscape:label=$subname][1]"; choose { when "not($subelt/@id)" { - if "$mandatory='yes'" { - error > «$widget_type» widget must have a «$name»/«$subname» element + if "$mandatory!='no'" { + const "errmsg" > «$widget_type» widget (id=«$widget_id») must have a «$name»/«$subname» element + choose { + when "$mandatory='yes'" { + error > «$errmsg» + } + otherwise { + warning > «$errmsg» + } + } } | /* missing «$name»/«$subname» element */ } otherwise { - | "«$subname»": id("«$subelt/@id»")`if "position()!=last()" > ,` + | "«$subname»_elt": id("«$subelt/@id»")`if "position()!=last()" > ,` } } } diff -r a35bf9c585cf -r 074046800624 tests/projects/svghmi/svghmi_0@svghmi/svghmi.svg --- a/tests/projects/svghmi/svghmi_0@svghmi/svghmi.svg Mon Jun 13 19:22:31 2022 +0200 +++ b/tests/projects/svghmi/svghmi_0@svghmi/svghmi.svg Mon Jun 27 10:26:04 2022 +0200 @@ -125,12 +125,12 @@ inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:document-units="px" - inkscape:current-layer="hmi0" + inkscape:current-layer="g5053" showgrid="false" units="px" - inkscape:zoom="0.20046201" - inkscape:cx="1401.1703" - inkscape:cy="-1495.7332" + inkscape:zoom="0.80184804" + inkscape:cx="784.66046" + inkscape:cy="-449.34319" inkscape:window-width="1600" inkscape:window-height="836" inkscape:window-x="0" @@ -2244,6 +2244,42 @@ inkscape:label="HMI:Switch@/PUMP0/BOOLOUT" transform="translate(43.983597,40.477445)"> + + + + + + + + @@ -2278,6 +2314,15 @@ id="path3461" inkscape:connector-curvature="0" /> + + inkscape:current-layer="hmi0" /> - - - - - - - 10 - - - - 10 - - + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#00b4cf;stroke-width:1.74884677;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:url(#marker19820-3);marker-end:url(#marker25117-7);color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> 1 + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ff0000;stroke-width:1.74884677;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:url(#marker19820-3);marker-end:url(#marker25117-7);color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> blup + x="603.72882" + y="663.89832">blup + + + + + + + + 10 + + + + 10 + + + + + + + + + 10 + + + + 10 + + + + + + + + + 10 + + + + 10 + + + + + + + + + 10 + + + + 10 +