SVGHMI: Add premature implementation of XY chart.
authorEdouard Tisserant
Thu, 05 May 2022 11:52:24 +0200
changeset 3470 b36754171535
parent 3455 2716cd8e498d
child 3471 3f7b4a2009ba
SVGHMI: Add premature implementation of XY chart.
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 <clipPath> 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;
+    }
+}
+||