# HG changeset patch # User Edouard Tisserant # Date 1651744344 -7200 # Node ID b3675417153507c93087a78546a9a38116b5feb5 # Parent 2716cd8e498d58b224a423c7f2a7d2793ffb0a1e SVGHMI: Add premature implementation of XY chart. diff -r 2716cd8e498d -r b36754171535 svghmi/widget_xygraph.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_xygraph.ysl2 Thu May 05 11:52:24 2022 +0200 @@ -0,0 +1,610 @@ +// widget_xygraph.ysl2 +widget_desc("XYGraph") { + longdesc + || + 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. + + || + + shortdesc > Cartesian trend graph showing values of given variables over time + + path name="value" count="1+" accepts="HMI_INT,HMI_REAL" > value + + arg name="size" accepts="int" > buffer size + 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, + this.x_format, this.y_format] = this.args; + + // 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, this.x_interval_major_mark], + [this.y_interval_minor_mark, this.y_interval_major_mark]], + [this.y_axis_label, this.x_axis_label], + [this.x_axis_line, this.y_axis_line], + [this.x_format, this.y_format]); + + // create path and attach it to widget + clipPath = document.createElementNS(xmlns,"clipPath"); + clipPathPath = document.createElementNS(xmlns,"path"); + clipPathPathDattr = document.createAttribute("d"); + clipPathPathDattr.value = this.reference.getClipPathPathDattr(); + clipPathPath.setAttributeNode(clipPathPathDattr); + clipPath.appendChild(clipPathPath); + 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.max_data_length = this.args[0]; + } + + dispatch(value,oldval, index) { + // naive local buffer impl. + // data is updated only when graph is visible + // TODO: replace with separate recording + + this.curves_data[index].push(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 + overflow = this.curves_data[index].shift(); + data_length = data_length - 1; + } + + 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; + } + } + + // recompute X range based on curent time ad buffer depth + // TODO: get PLC time instead of browser time + const d = new Date(); + let time = d.getTime(); + + // FIXME: this becomes wrong when graph is not visible and updated all the time + [this.xmin, this.xmax] = [time - data_length*1000/this.frequency, time]; + let Xlength = this.xmax - this.xmin; + + + // 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(function([data,curve]){ + let new_d = data.map(function([y, i]){ + // compute curve point from data, ranges, and base_ref + let x = Xmin + i * Xlength / data_length; + let xv = vectorscale(xvect, (x - Xmin) / Xlength); + let yv = vectorscale(yvect, (y - Ymin) / Ylength); + let px = base_point.x + xv.x + yv.x; + let py = base_point.y + xv.y + yv.y; + if(!this.fixed_y_range){ + if(ymin_damaged && y > this.ymin) this.ymin = y; + if(xmin_damaged && x > this.xmin) this.xmin = x; + } + + return " " + px + "," + py; + }); + + new_d.unshift("M "); + new_d.push(" z"); + + return new_d.join(''); + } + + // computed curves "d" attr is applied to svg curve during animate(); + + this.request_animate(); + } + + animate(){ + + // move marks and update labels + this.reference.applyRanges([this.XRange, this.YRange]); + + // apply computed curves "d" attributes + for(let [curve, d_attr] of zip(this.curves, this.curves_d_attr)){ + curve.setAttribute("d", d_attr); + } + } + + || +} + +widget_defs("XYGraph") { + labels(""" + /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() { + + // collect all curve_n labelled children + foreach "$hmi_element/*[regexp:test(@inkscape:label,'^curve_[0-9]+$')]" { + 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» */ + } + + | } + +} + +emit "declarations:XYGraph" +|| +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 move_elements_to_group(elements) { + let newgroup = document.createElementNS(xmlns,"g"); + for(let element of elements){ + element.parentElement().removeChild(element); + newgroup.appendChild(element); + } + return newgroup; +} +function getLinesIntesection(l1, l2) { + /* + Compute intersection of two lines + ================================= + + ^ l2vect + / + / + / + l1start ----------X--------------> l1vect + / intersection + / + / + l2start + + intersection = l1start + l1vect * a + intersection = l2start + l2vect * b + ==> solve : "l1start + l1vect * a = l2start + l2vect * b" to find a and b and then intersection + + (1) l1start.x + l1vect.x * a = l2start.x + l2vect.x * b + (2) l1start.y + l1vect.y * a = l2start.y + l2vect.y * b + + // express a + (1) a = (l2start.x + l2vect.x * b) / (l1start.x + l1vect.x) + + // substitute a to have only b + (1+2) l1start.y + l1vect.y * (l2start.x + l2vect.x * b) / (l1start.x + l1vect.x) = l2start.y + l2vect.y * b + + // expand to isolate b + (2) l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) + (l1vect.y * l2vect.x * b) / (l1start.x + l1vect.x) = l2start.y + l2vect.y * b + (2) l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) - l2start.y = l2vect.y * b - (l1vect.y * l2vect.x * b) / (l1start.x + l1vect.x) + + // factorize b + (2) l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) - l2start.y = b * (l2vect.y - (l1vect.y * l2vect.x ) / (l1start.x + l1vect.x)) + + // extract b + (2) b = (l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) - l2start.y) / ((l2vect.y - (l1vect.y * l2vect.x ) / (l1start.x + l1vect.x))) + */ + + let [l1start, l1vect] = l1; + let [l1start, l2vect] = l2; + + let b = (l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) - l2start.y) / ((l2vect.y - (l1vect.y * l2vect.x ) / (l1start.x + l1vect.x))); + + return new DOMPoint(l2start.x + l2vect.x * b, l2start.y + l2vect.y * b); +}; + + +// From https://stackoverflow.com/a/48293566 +function *zip (...iterables){ + let iterators = iterables.map(i => i[Symbol.iterator]() ) + while (true) { + let results = iterators.map(iter => iter.next() ) + if (results.some(res => res.done) ) return + else yield results.map(res => res.value ) + } +} + +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]; + + for(let axis of this.axes){ + axis.setBasePoint(base_point); + } + } + + getBaseRef(){ + return this.base_ref; + } + + getClipPathPathDattr(){ + return this.clipPathPathDattr; + } + + applyRanges(ranges){ + for(let [range,axis] of zip(ranges,this.axes)){ + axis.applyRange(range); + } + } + + 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 + / / + / / + / / + xstart /---------/--------------> given X axis + / / + / / + /---------/-------------- + base_point ystart + + base_point = xstart + yvect * a + base_point = ystart + xvect * b + ==> solve : "xstart + yvect * a = ystart + xvect * b" to find a and b and then base_point + + (1) xstart.x + yvect.x * a = ystart.x + xvect.x * b + (2) xstart.y + yvect.y * a = ystart.y + xvect.y * b + + // express a + (1) a = (ystart.x + xvect.x * b) / (xstart.x + yvect.x) + + // substitute a to have only b + (1+2) xstart.y + yvect.y * (ystart.x + xvect.x * b) / (xstart.x + yvect.x) = ystart.y + xvect.y * b + + // expand to isolate b + (2) xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) + (yvect.y * xvect.x * b) / (xstart.x + yvect.x) = ystart.y + xvect.y * b + (2) xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) - ystart.y = xvect.y * b - (yvect.y * xvect.x * b) / (xstart.x + yvect.x) + + // factorize b + (2) xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) - ystart.y = b * (xvect.y - (yvect.y * xvect.x ) / (xstart.x + yvect.x)) + + // extract b + (2) b = (xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) - ystart.y) / ((xvect.y - (yvect.y * xvect.x ) / (xstart.x + yvect.x))) + */ + + let b = (xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) - ystart.y) / ((xvect.y - (yvect.y * xvect.x ) / (xstart.x + yvect.x))); + let base_point = new DOMPoint(ystart.x + xvect.x * b, ystart.y + xvect.y * b); + + // // compute given origin + // // from drawing : given_origin = xstart - xvect * b + // let given_origin = new DOMPoint(xstart.x - xvect.x * b, xstart.y - xvect.y * b); + + 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.appendItem(transform); + this[elementname+"_"+name+"_transform"]=transform; + }; + }; + + // group marks an labels together + let parent = line.parentElement() + marks_group = move_elements_to_group(marks); + marks_and_label_group = move_elements_to_group([marks_group_use, label]); + group = move_elements_to_group([marks_and_label_group,line]); + parent.appendChild(group); + + // Add transforms to group + for(let name of ["base","origin"]){ + let transform = svg_root.createSVGTransform(); + group.transform.baseVal.appendItem(transform); + this[name+"_transform"]=transform; + }; + + this.group = group; + this.marks_group = marks_group; + this.marks_and_label_group = marks_and_label_group; + + this.mlg_clones = []; + this.last_mark_count = 0; + } + + setBasePoint(base_point){ + // move Axis to base point + let [start, _vect] = this.lineElement; + 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 transform = this[markname+"_base_transform"]; + 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 + base_point, getLinesIntesection( + this.line, lineFromPath(mark))); + this[markname+"_base_transform"].setTranslate(-pos.x, -pos.x); + if(markname == "major"){ // label follow major mark + this.label_base_transform.setTranslate(-pos.x, -pos.x); + } + } + } + + applyOriginAndUnitVector(offset, unit_vect){ + // offset is a representing position of an + // axis along the opposit axis line, expressed in major marks units + // unit_vect is the translation in between to major marks + + // ^ + // | unit_vect + // |<---> + // _________|__________> + // ^ | ' | ' | ' + // |yoffset | 1 + // | | + // v xoffset| + // X<------>| + // base_point + + // move major marks and label to first positive mark position + let v = vectorscale(unit_vect, offset+1); + this.label_slide_transform.setTranslate(v.x, v.x); + this.major_slide_transform.setTranslate(v.x, v.x); + // move minor mark to first half positive mark position + let h = vectorscale(unit_vect, offset+0.5); + this.minor_slide_transform.setTranslate(h.x, h.x); + } + + 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 5 and 50, + // and log10(5) is substracted to order of magnitude to obtain this + // log10(5) ~= 0.69897 + + + let unit = Math.pow(10, Math.floor(Math.log10(range)-0.69897)); + + // 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, unit/range); + let [umin, umax, uoffset] = [min,max,offset].map(val => Math.round(val/unit)); + let mark_count = umax-umin; + + // apply unit vector to marks and label + this.label_and_marks.applyOriginAndUnitVector(offset, unit_vect); + + // duplicate marks and labels as needed + let current_mark_count = this.mlg_clones.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 = cloneNode(this.label); + let newuse = document.createElementNS(xmlns,"use"); + let newuseAttr = document.createAttribute("xlink:href"); + newuseAttr.value = "#"+this.marks_group.id; + newuse.setAttributeNode(newuseAttr.value); + newgroup.transform.baseVal.appendItem(transform); + newgroup.appendChild(newlabel); + newgroup.appendChild(newuse); + this.mlg_clones.push([tranform,newgroup]); + } + + // move marks and labels, set labels + for(let u = 0; u <= mark_count; u++){ + let i = 0; + let val = (umin + u) * unit; + let vec = vectorscale(unit_vect, offset + val); + let text = this.format ? sprintf(this.format, val) : val.toString(); + if(u == uoffset){ + // apply offset to original marks and label groups + this.origin_transform.setTranslate(vec.x, vec.x); + + // update original label text + this.label_and_mark.label.textContent = text; + } else { + let [transform,element] = this.mlg_clones[i++]; + + // apply unit vector*N to marks and label groups + transform.setTranslate(vec.x, vec.x); + + // update label text + element.getElementsByTagName("tspan")[0].textContent = text; + + // Attach to group if not already + if(i >= this.last_mark_count){ + this.group.appendChild(element); + } + } + } + + // dettach marks and label from group if not anymore visible + for(let i = current_mark_count; i < this.last_mark_count; i++){ + let [transform,element] = this.mlg_clones[i]; + this.group.removeChild(element); + } + + this.last_mark_count = current_mark_count; + } +} +||