# HG changeset patch # User Edouard Tisserant # Date 1654068127 -7200 # Node ID e87a2daace80705b24d702fabd486935ec2d11c9 # Parent ca312be569294eda8870ce6b8ad71a7d4c865a92# Parent a88ac1760faf2f2d7eadc135de5324c2dbe57225 merged diff -r a88ac1760faf -r e87a2daace80 svghmi/analyse_widget.xslt --- a/svghmi/analyse_widget.xslt Thu May 26 23:41:10 2022 +0200 +++ b/svghmi/analyse_widget.xslt Wed Jun 01 09:22:07 2022 +0200 @@ -2,7 +2,7 @@ - + @@ -885,6 +885,56 @@ 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 a88ac1760faf -r e87a2daace80 svghmi/gen_dnd_widget_svg.xslt --- a/svghmi/gen_dnd_widget_svg.xslt Thu May 26 23:41:10 2022 +0200 +++ b/svghmi/gen_dnd_widget_svg.xslt Wed Jun 01 09:22:07 2022 +0200 @@ -4,7 +4,7 @@ - + diff -r a88ac1760faf -r e87a2daace80 svghmi/gen_index_xhtml.xslt --- a/svghmi/gen_index_xhtml.xslt Thu May 26 23:41:10 2022 +0200 +++ b/svghmi/gen_index_xhtml.xslt Wed Jun 01 09:22:07 2022 +0200 @@ -159,7 +159,7 @@ - + @@ -1108,6 +1108,11 @@ + + + + + @@ -1485,7 +1490,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 +1590,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 +1778,7 @@ - unhinibit(index){ + uninhibit(index){ this.inhibit[index] = undefined; @@ -1787,7 +1816,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,19 +1860,65 @@ + 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]; } @@ -4211,7 +4286,7 @@ "); - this.content = langs; + this.content = langs.map(([lname,lcode]) => lname); @@ -7750,6 +7825,1313 @@ + + + + + + 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 = this.curves.map(_unused => []); + + } + + + + 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 + + + + 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); + + } + + } + + } + + + + } + + + + + + + + /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=" + + " : elements with data_n label must be unique. + + + 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); + + } + + } + + + + Made with SVGHMI. https://beremiz.org @@ -8027,9 +9409,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 +9743,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 diff -r a88ac1760faf -r e87a2daace80 svghmi/parse_labels.ysl2 --- a/svghmi/parse_labels.ysl2 Thu May 26 23:41:10 2022 +0200 +++ b/svghmi/parse_labels.ysl2 Wed Jun 01 09:22:07 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 a88ac1760faf -r e87a2daace80 svghmi/widget_xygraph.ysl2 --- a/svghmi/widget_xygraph.ysl2 Thu May 26 23:41:10 2022 +0200 +++ b/svghmi/widget_xygraph.ysl2 Wed Jun 01 09:22:07 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; @@ -97,7 +112,6 @@ } this.curves_data = this.curves.map(_unused => []); - this.max_data_length = this.args[0]; } dispatch(value,oldval, index) { @@ -114,13 +128,24 @@ 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 +166,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 +189,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; } diff -r a88ac1760faf -r e87a2daace80 svghmi/widgets_common.ysl2 --- a/svghmi/widgets_common.ysl2 Thu May 26 23:41:10 2022 +0200 +++ b/svghmi/widgets_common.ysl2 Wed Jun 01 09:22:07 2022 +0200 @@ -181,7 +181,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 +231,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 +325,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 +344,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 +366,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]; } } } diff -r a88ac1760faf -r e87a2daace80 tests/projects/svghmi_xy/svghmi_0@svghmi/svghmi.svg --- a/tests/projects/svghmi_xy/svghmi_0@svghmi/svghmi.svg Thu May 26 23:41:10 2022 +0200 +++ b/tests/projects/svghmi_xy/svghmi_0@svghmi/svghmi.svg Wed Jun 01 09:22:07 2022 +0200 @@ -306,13 +306,13 @@ inkscape:window-height="836" id="namedview4" showgrid="false" - inkscape:zoom="2.6222222" - inkscape:cx="138.92196" - inkscape:cy="243.43713" + inkscape:zoom="0.32777778" + inkscape:cx="-105.99939" + inkscape:cy="106.14218" inkscape:window-x="0" inkscape:window-y="27" inkscape:window-maximized="1" - inkscape:current-layer="g2776" /> + 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 +