svghmi/widget_xygraph.ysl2
author Edouard Tisserant <edouard.tisserant@gmail.com>
Fri, 26 Apr 2024 09:24:26 +0200
changeset 3938 fc4af5685aa3
parent 3691 9289fdda0222
permissions -rw-r--r--
merge
// 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 varopts of this.variables_options){
                let minmax = varopts.minmax 
                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);
    }
}
||