svghmi/widget_xygraph.ysl2
author Edouard Tisserant
Thu, 17 Nov 2022 11:08:36 +0100
changeset 3682 c613afdab571
parent 3509 b5ca17732b1e
child 3691 9289fdda0222
permissions -rw-r--r--
IDE: Optimization of modification events processing in text editors.

Too many modifications types where registered, and then too many events were fired.
Also, in case of uninterrupted sequence of events, updates to the model is deferred to the end of that sequence (wx.Callafter).
// 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="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
}

widget_class("XYGraph") {
    ||
        frequency = 1;
        init() {
            let x_duration_s;
            [x_duration_s,
             this.x_format, this.y_format] = this.args;

            let timeunit = x_duration_s.slice(-1);
            let factor = {
                "s":1,
                "m":60,
                "h":3600,
                "d":86400}[timeunit];
            if(factor == undefined){
                this.max_data_length = Number(x_duration_s);
                this.x_duration = undefined;
            }else{
                let duration = factor*Number(x_duration_s.slice(0,-1));
                this.max_data_length = undefined;
                this.x_duration = duration*1000;
            }


            // Min and Max given with paths are meant to describe visible range,
            // not to clip data.
            this.clip = false;

            let y_min = Infinity, y_max = -Infinity;

            // Compute visible Y range by merging fixed curves Y ranges
            for(let minmax of this.minmaxes){
               if(minmax){
                   let [min,max] = minmax;
                   if(min < y_min)
                       y_min = min;
                   if(max > y_max)
                       y_max = max;
               }
            }

            if(y_min !== Infinity && y_max !== -Infinity){
               this.fixed_y_range = true;
            } else {
               this.fixed_y_range = false;
            }

            this.ymin = y_min;
            this.ymax = y_max;

            this.curves = [];
            this.init_specific();

            this.reference = new ReferenceFrame(
                [[this.x_interval_minor_mark_elt, this.x_interval_major_mark_elt],
                 [this.y_interval_minor_mark_elt, this.y_interval_major_mark_elt]],
                [this.x_axis_label_elt, this.y_axis_label_elt],
                [this.x_axis_line_elt, this.y_axis_line_elt],
                [this.x_format, this.y_format]);

            let max_stroke_width = 0;
            for(let curve of this.curves){
                if(curve.style.strokeWidth > max_stroke_width){
                    max_stroke_width = curve.style.strokeWidth;
                }
            }

            this.Margins=this.reference.getLengths().map(length => max_stroke_width/length);

            // create <clipPath> path and attach it to widget
            let clipPath = document.createElementNS(xmlns,"clipPath");
            let clipPathPath = document.createElementNS(xmlns,"path");
            let clipPathPathDattr = document.createAttribute("d");
            clipPathPathDattr.value = this.reference.getClipPathPathDattr();
            clipPathPath.setAttributeNode(clipPathPathDattr);
            clipPath.appendChild(clipPathPath);
            clipPath.id = randomId();
            this.element.appendChild(clipPath);

            // assign created clipPath to clip-path property of curves
            for(let curve of this.curves){
                curve.setAttribute("clip-path", "url(#" + clipPath.id + ")");
            }

            this.curves_data = [];
        }

        dispatch(value,oldval, index) {
            // TODO: get PLC time instead of browser time
            let time = Date.now();

            // naive local buffer impl. 
            // data is updated only when graph is visible
            // TODO: replace with separate recording

            if(this.curves_data[index] === undefined){
                this.curves_data[index] = [];
            }
            this.curves_data[index].push([time, value]);
            let data_length = this.curves_data[index].length;
            let ymin_damaged = false;
            let ymax_damaged = false;
            let overflow;

            if(this.max_data_length == undefined){
                let peremption = time - this.x_duration;
                let oldest = this.curves_data[index][0][0]
                this.xmin = peremption;
                if(oldest < peremption){
                    // remove first item
                    overflow = this.curves_data[index].shift()[1];
                    data_length = data_length - 1;
                }
            } else {
                if(data_length > this.max_data_length){
                    // remove first item
                    [this.xmin, overflow] = this.curves_data[index].shift();
                    data_length = data_length - 1;
                } else {
                    if(this.xmin == undefined){
                        this.xmin = time;
                    }
                }
            }

            this.xmax = time;
            let Xrange = this.xmax - this.xmin;

            if(!this.fixed_y_range){
                ymin_damaged = overflow <= this.ymin;
                ymax_damaged = overflow >= this.ymax;
                if(value > this.ymax){
                    ymax_damaged = false;
                    this.ymax = value;
                }
                if(value < this.ymin){
                    ymin_damaged = false;
                    this.ymin = value;
                }
            }
            let Yrange = this.ymax - this.ymin;

            // apply margin by moving min and max to enlarge range
            let [xMargin,yMargin] = zip(this.Margins, [Xrange, Yrange]).map(([m,l]) => m*l);
            [[this.dxmin, this.dxmax],[this.dymin,this.dymax]] =
                [[this.xmin-xMargin, this.xmax+xMargin],
                 [this.ymin-yMargin, this.ymax+yMargin]];
            Xrange += 2*xMargin;
            Yrange += 2*yMargin;

            // recompute curves "d" attribute
            // FIXME: use SVG getPathData and setPathData when available.
            //        https://svgwg.org/specs/paths/#InterfaceSVGPathData
            //        https://github.com/jarek-foksa/path-data-polyfill

            let [base_point, xvect, yvect] = this.reference.getBaseRef();
            this.curves_d_attr =
                zip(this.curves_data, this.curves).map(([data,curve]) => {
                    let new_d = data.map(([x,y], i) => {
                        // compute curve point from data, ranges, and base_ref
                        let xv = vectorscale(xvect, (x - this.dxmin) / Xrange);
                        let yv = vectorscale(yvect, (y - this.dymin) / Yrange);
                        let px = base_point.x + xv.x + yv.x;
                        let py = base_point.y + xv.y + yv.y;
                        if(!this.fixed_y_range){
                            // update min and max from curve data if needed
                            if(ymin_damaged && y < this.ymin) this.ymin = y;
                            if(ymax_damaged && y > this.ymax) this.ymax = y;
                        }

                        return " " + px + "," + py;
                    });

                    new_d.unshift("M ");

                    return new_d.join('');
                });

            // computed curves "d" attr is applied to svg curve during animate();

            this.request_animate();
        }

        animate(){

            // move elements only if enough data
            if(this.curves_data.some(data => data.length > 1)){

                // move marks and update labels
                this.reference.applyRanges([[this.dxmin, this.dxmax],
                                            [this.dymin, this.dymax]]);

                // apply computed curves "d" attributes
                for(let [curve, d_attr] of zip(this.curves, this.curves_d_attr)){
                    curve.setAttribute("d", d_attr);
                }
            }
        }

    ||
}

def "func:check_curves_label_consistency" {
    param "curve_elts";
    param "number_to_check";
    const "res" choose {
        when "$curve_elts[@inkscape:label = concat('curve_', string($number_to_check))]"{
            if "$number_to_check > 0"{
                value "func:check_curves_label_consistency($curve_elts, $number_to_check - 1)";
            }
        }
        otherwise {
            value "concat('missing curve_', string($number_to_check))";
        }
    }
    result "$res";
}

widget_defs("XYGraph") {
    labels("/x_interval_minor_mark /x_axis_line /x_interval_major_mark /x_axis_label");
    labels("/y_interval_minor_mark /y_axis_line /y_interval_major_mark /y_axis_label");

    |     init_specific() {

    // collect all curve_n labelled children
    const "curves","$hmi_element/*[regexp:test(@inkscape:label,'^curve_[0-9]+$')]";
    const "curves_error", "func:check_curves_label_consistency($curves,count($curves)-1)";
    if "string-length($curves_error)"
        error > XYGraph id="«@id»", label="«@inkscape:label»" : «$curves_error»
    foreach "$curves" {
        const "label","@inkscape:label";
        const "_id","@id";
        const "curve_num", "substring(@inkscape:label, 7)";
    |         this.curves[«$curve_num»] = 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 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);
    }
}
||