# HG changeset patch # User Edouard Tisserant # Date 1654067726 -7200 # Node ID ca312be569294eda8870ce6b8ad71a7d4c865a92 # Parent a27b5862e36394243e70356c9cce019013aeb607 SVGHMI: update generated xslt. diff -r a27b5862e363 -r ca312be56929 svghmi/analyse_widget.xslt --- a/svghmi/analyse_widget.xslt Wed Jun 01 09:14:19 2022 +0200 +++ b/svghmi/analyse_widget.xslt Wed Jun 01 09:15:26 2022 +0200 @@ -2,7 +2,7 @@ - + @@ -50,6 +50,16 @@ + + + Widget id: + + label: + + has wrong syntax of frequency forcing + + + @@ -223,8 +233,6 @@ - frequency = 5; - display = "inactive"; state = "init"; @@ -717,7 +725,7 @@ - PathSlider - + PathSlider - @@ -877,6 +885,62 @@ 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 + + + buffer size + + + format string for X label + + + format string for Y label + + + minimum value foe X axis + + + maximum value for X axis + + diff -r a27b5862e363 -r ca312be56929 svghmi/gen_dnd_widget_svg.xslt --- a/svghmi/gen_dnd_widget_svg.xslt Wed Jun 01 09:14:19 2022 +0200 +++ b/svghmi/gen_dnd_widget_svg.xslt Wed Jun 01 09:15:26 2022 +0200 @@ -4,7 +4,7 @@ - + @@ -52,6 +52,16 @@ + + + Widget id: + + label: + + has wrong syntax of frequency forcing + + + diff -r a27b5862e363 -r ca312be56929 svghmi/gen_index_xhtml.xslt --- a/svghmi/gen_index_xhtml.xslt Wed Jun 01 09:14:19 2022 +0200 +++ b/svghmi/gen_index_xhtml.xslt Wed Jun 01 09:15:26 2022 +0200 @@ -159,7 +159,7 @@ - + @@ -207,6 +207,16 @@ + + + Widget id: + + label: + + has wrong syntax of frequency forcing + + + @@ -1098,6 +1108,11 @@ + + + + + @@ -1248,7 +1263,9 @@ + " + " undefined @@ -1473,10 +1490,84 @@ 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; + + } + + + + do_init(){ + + let forced = this.forced_frequency; + + if(forced !== undefined){ + + /* + + once every 10 seconds : 10s + + once per minute : 1m + + once per hour : 1h + + once per day : 1d + + */ + + let unit = forced.slice(-1); + + let factor = { + + "s":1, + + "m":60, + + "h":3600, + + "d":86400}[unit]; + + + + this.frequency = factor ? 1/(factor * Number(forced.slice(0,-1))) + + : Number(forced); + + } + + + + let init = this.init; + + if(typeof(init) == "function"){ + + // try { + + init.call(this); + + // } catch(err) { + + // console.log(err); + + // } + + } + } @@ -1499,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]) @@ -1649,7 +1752,9 @@ let new_val = eval_operation_string(old_val, opstr); - new_val = this.clip_min_max(index, new_val); + if(this.clip) + + new_val = this.clip_min_max(index, new_val); return apply_hmi_value(realindex, new_val); @@ -1663,7 +1768,9 @@ if(realindex == undefined) return undefined; - new_val = this.clip_min_max(index, new_val); + if(this.clip) + + new_val = this.clip_min_max(index, new_val); return apply_hmi_value(realindex, new_val); @@ -1671,7 +1778,7 @@ - unhinibit(index){ + uninhibit(index){ this.inhibit[index] = undefined; @@ -1709,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); } @@ -1753,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]; } @@ -1883,8 +2036,10 @@ - - + + + + @@ -2400,10 +2555,6 @@ _action(){ - console.log("Entering state - - ", this.frequency); - } @@ -2424,8 +2575,6 @@ - frequency = 5; - display = "inactive"; state = "init"; @@ -2492,6 +2641,8 @@ ButtonWidget extends Widget{ + frequency = 5; + @@ -2514,6 +2665,8 @@ PushButtonWidget extends Widget{ + frequency = 20; + @@ -4133,7 +4286,7 @@ "); - this.content = langs; + this.content = langs.map(([lname,lcode]) => lname); @@ -6116,7 +6269,7 @@ - PathSlider - + PathSlider - @@ -6159,7 +6312,7 @@ origPt = undefined; - + @@ -6243,7 +6396,7 @@ bestDistance = beforeDistance; - } else if ((afterLength = bestLength + precision) <= this.pathLength && + } else if ((afterLength = bestLength + precision) <= this.pathLength && (afterDistance = distance2(afterPoint = this.path_elt.getPointAtLength(afterLength))) < bestDistance) { @@ -7672,6 +7825,1319 @@ + + + + + + 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 + + + buffer size + + + format string for X label + + + format string for Y label + + + minimum value foe X axis + + + maximum value for X axis + + + + 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 @@ -7751,7 +9217,7 @@ modulo: /^%{2}/, - placeholder: /^%(?:([1-9]\d*)\$|\(([^)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-gijostTuvxX])/, + placeholder: /^%(?:([1-9]\d*)\$|\(([^)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-gijostTuvxXD])/, key: /^([a-z_][a-z_\d]*)/i, @@ -7877,6 +9343,108 @@ break + case 'D': + + /* + + + + select date format with width + + select time format with precision + + %D => 13:31 AM (default) + + %1D => 13:31 AM + + %.1D => 07/07/20 + + %1.1D => 07/07/20, 13:31 AM + + %1.2D => 07/07/20, 13:31:55 AM + + %2.2D => May 5, 2022, 9:29:16 AM + + %3.3D => May 5, 2022 at 9:28:16 AM GMT+2 + + %4.4D => Thursday, May 5, 2022 at 9:26:59 AM Central European Summer Time + + + + see meaning of DateTimeFormat's options "datestyle" and "timestyle" in MDN + + */ + + + + let [datestyle, timestyle] = [ph.width, ph.precision].map(val => ({ + + 1: "short", + + 2: "medium", + + 3: "long", + + 4: "full" + + }[val])); + + + + if(timestyle === undefined && datestyle === undefined){ + + timestyle = "short"; + + } + + + + let options = { + + dateStyle: datestyle, + + timeStyle: timestyle, + + hour12: false + + } + + + + /* get lang from globals */ + + let lang = get_current_lang_code(); + + let f; + + try{ + + f = new Intl.DateTimeFormat(lang, options); + + } catch(e) { + + f = new Intl.DateTimeFormat('en-US', options); + + } + + arg = f.format(arg); + + + + /* + + TODO: select with padding char + + a: absolute time and date (default) + + r: relative time + + */ + + + + break + case 'j': arg = JSON.stringify(arg, null, ph.width ? parseInt(ph.width) : 0) @@ -8181,6 +9749,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 @@ -8221,498 +10213,490 @@ let widget = hmi_widgets[id]; - let init = widget.init; - - if(typeof(init) == "function"){ - - try { - - init.call(widget); - - } catch(err) { - - console.log(err); + widget.do_init(); + + }); + + }; + + + + // Open WebSocket to relative "/ws" address + + var has_watchdog = window.location.hash == "#watchdog"; + + + + var ws_url = + + window.location.href.replace(/^http(s?:\/\/[^\/]*)\/.*$/, 'ws$1/ws') + + + '?mode=' + (has_watchdog ? "watchdog" : "multiclient"); + + + + var ws = new WebSocket(ws_url); + + ws.binaryType = 'arraybuffer'; + + + + const dvgetters = { + + INT: (dv,offset) => [dv.getInt16(offset, true), 2], + + BOOL: (dv,offset) => [dv.getInt8(offset, true), 1], + + NODE: (dv,offset) => [dv.getInt8(offset, true), 1], + + REAL: (dv,offset) => [dv.getFloat32(offset, true), 4], + + STRING: (dv, offset) => { + + const size = dv.getInt8(offset); + + return [ + + String.fromCharCode.apply(null, new Uint8Array( + + dv.buffer, /* original buffer */ + + offset + 1, /* string starts after size*/ + + size /* size of string */ + + )), size + 1]; /* total increment */ + + } + + }; + + + + // Apply updates recieved through ws.onmessage to subscribed widgets + + function apply_updates() { + + updates.forEach((value, index) => { + + dispatch_value(index, value); + + }); + + updates.clear(); + + } + + + + // Called on requestAnimationFrame, modifies DOM + + var requestAnimationFrameID = null; + + function animate() { + + // Do the page swith if any one pending + + if(current_subscribed_page != current_visible_page){ + + switch_visible_page(current_subscribed_page); + + } + + + + while(widget = need_cache_apply.pop()){ + + widget.apply_cache(); + + } + + + + if(jumps_need_update) update_jumps(); + + + + apply_updates(); + + + + pending_widget_animates.forEach(widget => widget._animate()); + + pending_widget_animates = []; + + + + requestAnimationFrameID = null; + + } + + + + function requestHMIAnimation() { + + if(requestAnimationFrameID == null){ + + requestAnimationFrameID = window.requestAnimationFrame(animate); + + } + + } + + + + // Message reception handler + + // Hash is verified and HMI values updates resulting from binary parsing + + // are stored until browser can compute next frame, DOM is left untouched + + ws.onmessage = function (evt) { + + + + let data = evt.data; + + let dv = new DataView(data); + + let i = 0; + + try { + + for(let hash_int of hmi_hash) { + + if(hash_int != dv.getUint8(i)){ + + throw new Error("Hash doesn't match"); + + }; + + i++; + + }; + + + + while(i < data.byteLength){ + + let index = dv.getUint32(i, true); + + i += 4; + + let iectype = hmitree_types[index]; + + if(iectype != undefined){ + + let dvgetter = dvgetters[iectype]; + + let [value, bytesize] = dvgetter(dv,i); + + updates.set(index, value); + + i += bytesize; + + } else { + + throw new Error("Unknown index "+index); } + }; + + // register for rendering on next frame, since there are updates + + requestHMIAnimation(); + + } catch(err) { + + // 1003 is for "Unsupported Data" + + // ws.close(1003, err.message); + + + + // TODO : remove debug alert ? + + alert("Error : "+err.message+"\nHMI will be reloaded."); + + + + // force reload ignoring cache + + location.reload(true); + + } + + }; + + + + hmi_hash_u8 = new Uint8Array(hmi_hash); + + + + function send_blob(data) { + + if(data.length > 0) { + + ws.send(new Blob([hmi_hash_u8].concat(data))); + + }; + + }; + + + + const typedarray_types = { + + INT: (number) => new Int16Array([number]), + + BOOL: (truth) => new Int16Array([truth]), + + NODE: (truth) => new Int16Array([truth]), + + REAL: (number) => new Float32Array([number]), + + STRING: (str) => { + + // beremiz default string max size is 128 + + str = str.slice(0,128); + + binary = new Uint8Array(str.length + 1); + + binary[0] = str.length; + + for(let i = 0; i < str.length; i++){ + + binary[i+1] = str.charCodeAt(i); + } - if(widget.forced_frequency !== undefined) - - widget.frequency = widget.forced_frequency; + return binary; + + } + + /* TODO */ + + }; + + + + function send_reset() { + + send_blob(new Uint8Array([1])); /* reset = 1 */ + + }; + + + + var subscriptions = []; + + + + function subscribers(index) { + + let entry = subscriptions[index]; + + let res; + + if(entry == undefined){ + + res = new Set(); + + subscriptions[index] = [res,0]; + + }else{ + + [res, _ign] = entry; + + } + + return res + + } + + + + function get_subscription_period(index) { + + let entry = subscriptions[index]; + + if(entry == undefined) + + return 0; + + let [_ign, period] = entry; + + return period; + + } + + + + function set_subscription_period(index, period) { + + let entry = subscriptions[index]; + + if(entry == undefined){ + + subscriptions[index] = [new Set(), period]; + + } else { + + entry[1] = period; + + } + + } + + + + if(has_watchdog){ + + // artificially subscribe the watchdog widget to "/heartbeat" hmi variable + + // Since dispatch directly calls change_hmi_value, + + // PLC will periodically send variable at given frequency + + subscribers(heartbeat_index).add({ + + /* type: "Watchdog", */ + + frequency: 1, + + indexes: [heartbeat_index], + + new_hmi_value: function(index, value, oldval) { + + apply_hmi_value(heartbeat_index, value+1); + + } }); - }; - - - - // Open WebSocket to relative "/ws" address - - var has_watchdog = window.location.hash == "#watchdog"; - - - - var ws_url = - - window.location.href.replace(/^http(s?:\/\/[^\/]*)\/.*$/, 'ws$1/ws') - - + '?mode=' + (has_watchdog ? "watchdog" : "multiclient"); - - - - var ws = new WebSocket(ws_url); - - ws.binaryType = 'arraybuffer'; - - - - const dvgetters = { - - INT: (dv,offset) => [dv.getInt16(offset, true), 2], - - BOOL: (dv,offset) => [dv.getInt8(offset, true), 1], - - NODE: (dv,offset) => [dv.getInt8(offset, true), 1], - - REAL: (dv,offset) => [dv.getFloat32(offset, true), 4], - - STRING: (dv, offset) => { - - const size = dv.getInt8(offset); - - return [ - - String.fromCharCode.apply(null, new Uint8Array( - - dv.buffer, /* original buffer */ - - offset + 1, /* string starts after size*/ - - size /* size of string */ - - )), size + 1]; /* total increment */ + } + + + + // 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({ + + frequency: 1, + + indexes: [current_page_var_index], + + new_hmi_value: function(index, value, oldval) { + + if(value.startsWith("!")) + + switch_page(value.slice(1)); } - }; - - - - // Apply updates recieved through ws.onmessage to subscribed widgets - - function apply_updates() { - - updates.forEach((value, index) => { - - dispatch_value(index, value); - - }); - - updates.clear(); + }); + + + + function svg_text_to_multiline(elt) { + + return(Array.prototype.map.call(elt.children, x=>x.textContent).join("\n")); } - // Called on requestAnimationFrame, modifies DOM - - var requestAnimationFrameID = null; - - function animate() { - - // Do the page swith if any one pending - - if(current_subscribed_page != current_visible_page){ - - switch_visible_page(current_subscribed_page); + function multiline_to_svg_text(elt, str) { + + str.split('\n').map((line,i) => {elt.children[i].textContent = line;}); + + } + + + + function switch_langnum(langnum) { + + langnum = Math.max(0, Math.min(langs.length - 1, langnum)); + + + + for (let translation of translations) { + + let [objs, msgs] = translation; + + let msg = msgs[langnum]; + + for (let obj of objs) { + + multiline_to_svg_text(obj, msg); + + obj.setAttribute("lang",langnum); + + } } - - - while(widget = need_cache_apply.pop()){ - - widget.apply_cache(); + return langnum; + + } + + + + // backup original texts + + for (let translation of translations) { + + let [objs, msgs] = translation; + + msgs.unshift(svg_text_to_multiline(objs[0])); + + } + + + + var lang_local_index = hmi_local_index("lang"); + + var langcode_local_index = hmi_local_index("lang_code"); + + var langname_local_index = hmi_local_index("lang_name"); + + subscribers(lang_local_index).add({ + + indexes: [lang_local_index], + + new_hmi_value: function(index, value, oldval) { + + let current_lang = switch_langnum(value); + + let [langname,langcode] = langs[current_lang]; + + apply_hmi_value(langcode_local_index, langcode); + + apply_hmi_value(langname_local_index, langname); + + switch_page(); } - - - if(jumps_need_update) update_jumps(); - - - - apply_updates(); - - - - pending_widget_animates.forEach(widget => widget._animate()); - - pending_widget_animates = []; - - - - requestAnimationFrameID = null; + }); + + + + // returns en_US, fr_FR or en_UK depending on selected language + + function get_current_lang_code(){ + + return cache[langcode_local_index]; } - function requestHMIAnimation() { - - if(requestAnimationFrameID == null){ - - requestAnimationFrameID = window.requestAnimationFrame(animate); - - } - - } - - - - // Message reception handler - - // Hash is verified and HMI values updates resulting from binary parsing - - // are stored until browser can compute next frame, DOM is left untouched - - ws.onmessage = function (evt) { - - - - let data = evt.data; - - let dv = new DataView(data); - - let i = 0; - - try { - - for(let hash_int of hmi_hash) { - - if(hash_int != dv.getUint8(i)){ - - throw new Error("Hash doesn't match"); - - }; - - i++; - - }; - - - - while(i < data.byteLength){ - - let index = dv.getUint32(i, true); - - i += 4; - - let iectype = hmitree_types[index]; - - if(iectype != undefined){ - - let dvgetter = dvgetters[iectype]; - - let [value, bytesize] = dvgetter(dv,i); - - updates.set(index, value); - - i += bytesize; - - } else { - - throw new Error("Unknown index "+index); - - } - - }; - - // register for rendering on next frame, since there are updates - - requestHMIAnimation(); - - } catch(err) { - - // 1003 is for "Unsupported Data" - - // ws.close(1003, err.message); - - - - // TODO : remove debug alert ? - - alert("Error : "+err.message+"\nHMI will be reloaded."); - - - - // force reload ignoring cache - - location.reload(true); - - } - - }; - - - - hmi_hash_u8 = new Uint8Array(hmi_hash); - - - - function send_blob(data) { - - if(data.length > 0) { - - ws.send(new Blob([hmi_hash_u8].concat(data))); - - }; - - }; - - - - const typedarray_types = { - - INT: (number) => new Int16Array([number]), - - BOOL: (truth) => new Int16Array([truth]), - - NODE: (truth) => new Int16Array([truth]), - - REAL: (number) => new Float32Array([number]), - - STRING: (str) => { - - // beremiz default string max size is 128 - - str = str.slice(0,128); - - binary = new Uint8Array(str.length + 1); - - binary[0] = str.length; - - for(let i = 0; i < str.length; i++){ - - binary[i+1] = str.charCodeAt(i); - - } - - return binary; - - } - - /* TODO */ - - }; - - - - function send_reset() { - - send_blob(new Uint8Array([1])); /* reset = 1 */ - - }; - - - - var subscriptions = []; - - - - function subscribers(index) { - - let entry = subscriptions[index]; - - let res; - - if(entry == undefined){ - - res = new Set(); - - subscriptions[index] = [res,0]; - - }else{ - - [res, _ign] = entry; - - } - - return res - - } - - - - function get_subscription_period(index) { - - let entry = subscriptions[index]; - - if(entry == undefined) - - return 0; - - let [_ign, period] = entry; - - return period; - - } - - - - function set_subscription_period(index, period) { - - let entry = subscriptions[index]; - - if(entry == undefined){ - - subscriptions[index] = [new Set(), period]; - - } else { - - entry[1] = period; - - } - - } - - - - if(has_watchdog){ - - // artificially subscribe the watchdog widget to "/heartbeat" hmi variable - - // Since dispatch directly calls change_hmi_value, - - // PLC will periodically send variable at given frequency - - subscribers(heartbeat_index).add({ - - /* type: "Watchdog", */ - - frequency: 1, - - indexes: [heartbeat_index], - - new_hmi_value: function(index, value, oldval) { - - apply_hmi_value(heartbeat_index, value+1); - - } - - }); - - } - - - - // 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({ - - frequency: 1, - - indexes: [current_page_var_index], - - new_hmi_value: function(index, value, oldval) { - - if(value.startsWith("!")) - - switch_page(value.slice(1)); - - } - - }); - - - - function svg_text_to_multiline(elt) { - - 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 switch_langnum(langnum) { - - langnum = Math.max(0, Math.min(langs.length - 1, langnum)); - - - - for (let translation of translations) { - - let [objs, msgs] = translation; - - let msg = msgs[langnum]; - - for (let obj of objs) { - - multiline_to_svg_text(obj, msg); - - obj.setAttribute("lang",langnum); - - } - - } - - return langnum; - - } - - - - // backup original texts - - for (let translation of translations) { - - let [objs, msgs] = translation; - - msgs.unshift(svg_text_to_multiline(objs[0])); - - } - - - - var lang_local_index = hmi_local_index("lang"); - - var langcode_local_index = hmi_local_index("lang_code"); - - var langname_local_index = hmi_local_index("lang_name"); - - subscribers(lang_local_index).add({ - - indexes: [lang_local_index], - - new_hmi_value: function(index, value, oldval) { - - let current_lang = switch_langnum(value); - - let [langname,langcode] = langs[current_lang]; - - apply_hmi_value(langcode_local_index, langcode); - - apply_hmi_value(langname_local_index, langname); - - switch_page(); - - } - - }); - - - function setup_lang(){ let current_lang = cache[lang_local_index];