svghmi/widgets_common.ysl2
author Edouard Tisserant
Thu, 22 Sep 2022 09:42:38 +0200
changeset 3624 770c613c424f
parent 3623 0237c28cd172
child 3685 570a738239f4
permissions -rw-r--r--
SVGHMI: remove intermediate "updates" Map and apply_updates()

It was used initially to decouple DOM updates from reception of data through
websocket, but now since all widget SHOULD use animate() to modify DOM, and
dispatch() only change internal state, apply_update is unnecessary.
// widgets_common.ysl2

in xsl decl labels(*ptr, name="defs_by_labels") alias call-template {
    with "hmi_element", "$hmi_element";
    with "labels"{text *ptr};
    content;
};

decl optional_labels(*ptr) alias - {
    /* TODO add some per label xslt variable to check if exist */
    labels(*ptr){
        with "mandatory","'no'";
        content;
    }
};

decl warning_labels(*ptr) alias - {
    labels(*ptr){
        with "mandatory","'warn'";
        content;
    }
};

decl _activable(*level) alias - {
    |     activable_sub:{
    const "activity" labels("/active /inactive") {
        with "mandatory"{text *level};
        content;
    }
    value "$activity";
    const "has_activity","string-length($activity)>0";
    |     },
    |     has_activity: «$has_activity»,
};

decl activable() alias - {
    _activable("warn")
};
decl optional_activable() alias - {
    _activable("no")
};

decl activable_labels(*ptr) alias - {
    optional_labels(*ptr) {
        with "subelements","'active inactive'";
        content;
    }
};

in xsl decl widget_desc(%name, match="widget[@type='%name']", mode="widget_desc") alias template {
    type > «@type»
    content;
};

in xsl decl widget_class(%name, *clsname="%nameWidget", match="widget[@type='%name']", mode="widget_class") alias template {
    | class `text **clsname` extends Widget{
    content;
    | }
};

in xsl decl widget_defs(%name, match="widget[@type='%name']", mode="widget_defs") alias template {
    param "hmi_element";
    // all widget potentially has a "disabled" labeled element
    const "disability" optional_labels("/disabled");
    value "$disability";
    const "has_disability","string-length($disability)>0";
    content;
};

in xsl decl widget_page(%name, match="widget[@type='%name']", mode="widget_page") alias template {
    param "page_desc";
    content;
};

decl gen_index_xhtml alias - {
    content;
};

template "svg:*", mode="hmi_widgets" {
    const "widget", "func:widget(@id)";
    const "eltid","@id";
    const "args" foreach "$widget/arg" > "«func:escape_quotes(@value)»"`if "position()!=last()" > ,`
    const "indexes" foreach "$widget/path" {
        if "position()!=last()" > ,
    }

    const "variables" foreach "$widget/path" {
        > [
        choose {
            when "not(@index)" {
                choose {
                    when "not(@type)" {
                        warning > Widget «$widget/@type» id="«$eltid»" : No match for path "«@value»" in HMI tree
                        > undefined
                    }
                    when "@type = 'PAGE_LOCAL'" 
                        > "«@value»"
                    when "@type = 'HMI_LOCAL'" 
                        > hmi_local_index("«@value»")
                    otherwise 
                        error > Internal error while processing widget's non indexed HMI tree path : unknown type
                }
            }
            otherwise {
                > «@index»
            }
        }
        > , {
        if "@min and @max"{
                > minmax:[«@min», «@max»]
                if "@assign"
                    > ,
        }
        if "@assign"
                > assign:"«@assign»"
        > }]
        if "position()!=last()" > ,
    }

    const "freq" choose {
        when "$widget/@freq"
            > "«$widget/@freq»"
        otherwise
            > undefined
    }

    const "enable_expr" choose{
        when "$widget/@enable_expr"
            > true
        otherwise
            > false
    }

    |   "«@id»": new «$widget/@type»Widget ("«@id»",«$freq»,[«$args»],[«$variables»],«$enable_expr»,{
    if "$widget/@enable_expr" {

    |       assignments: [],
    |       compute_enable: function(value, oldval, varnum) {
    |         let result = false;
    |         do {
        foreach "$widget/path" {
            const "varid","generate-id()";
            const "varnum","position()-1";
            if "@assign" foreach "$widget/path[@assign]" if "$varid = generate-id()" {
    |           if(varnum == «$varnum») this.assignments[«position()-1»] = value;
    |           let «@assign» = this.assignments[«position()-1»];
    |           if(«@assign» == undefined) break;
            }
        }
    |           result = «$widget/@enable_expr»;
    |         } while(0);
    |         this.enable(result);
    |       },
    }
    apply "$widget", mode="widget_defs" with "hmi_element",".";
    |   })`if "position()!=last()" > ,`
}

emit "preamble:local-variable-indexes" {
    ||

    let hmi_locals = {};
    var last_remote_index = hmitree_types.length - 1;
    var next_available_index = hmitree_types.length;
    let cookies = new Map(document.cookie.split("; ").map(s=>s.split("=")));

    const local_defaults = {
    ||
    foreach "$parsed_widgets/widget[starts-with(@type,'VarInit')]"{
        if "count(path) != 1" error > VarInit «@id» must have only one variable given.
        if "path/@type != 'PAGE_LOCAL' and path/@type != 'HMI_LOCAL'" error > VarInit «@id» only applies to HMI variable.
        >     "«path/@value»":
        choose {
            when "@type = 'VarInitPersistent'" > cookies.has("«path/@value»")?cookies.get("«path/@value»"):«arg[1]/@value»
            otherwise > «arg[1]/@value»
        }
        > \n
        if "position()!=last()" > ,
    }
    ||
    };

    const persistent_locals = new Set([
    ||
    foreach "$parsed_widgets/widget[@type='VarInitPersistent']"{
    |    "«path/@value»"`if "position()!=last()" > ,`
    }
    ||
    ]);
    var persistent_indexes = new Map();
    var cache = hmitree_types.map(_ignored => undefined);

    function page_local_index(varname, pagename){
        let pagevars = hmi_locals[pagename];
        let new_index;
        if(pagevars == undefined){
            new_index = next_available_index++;
            hmi_locals[pagename] = {[varname]:new_index};
        } else {
            let result = pagevars[varname];
            if(result != undefined) {
                return result;
            }

            new_index = next_available_index++;
            pagevars[varname] = new_index;
        }
        let defaultval = local_defaults[varname];
        if(defaultval != undefined) {
            cache[new_index] = defaultval; 
            if(persistent_locals.has(varname))
                persistent_indexes.set(new_index, varname);
        }
        return new_index;
    }

    function hmi_local_index(varname){
        return page_local_index(varname, "HMI_LOCAL");
    }
    ||
}

emit "preamble:widget-base-class" {
    ||
    var pending_widget_animates = [];

    function _hide(elt, placeholder){
        if(elt.parentNode != null)
            placeholder.parentNode.removeChild(elt);
    }
    function _show(elt, placeholder){
        placeholder.parentNode.insertBefore(elt, placeholder);
    }

    function set_activity_state(eltsub, state){
        if(eltsub.active_elt != undefined){
            if(eltsub.active_elt_placeholder == undefined){
                eltsub.active_elt_placeholder = document.createComment("");
                eltsub.active_elt.parentNode.insertBefore(eltsub.active_elt_placeholder, eltsub.active_elt);
            }
            (state?_show:_hide)(eltsub.active_elt, eltsub.active_elt_placeholder);
        }
        if(eltsub.inactive_elt != undefined){
            if(eltsub.inactive_elt_placeholder == undefined){
                eltsub.inactive_elt_placeholder = document.createComment("");
                eltsub.inactive_elt.parentNode.insertBefore(eltsub.inactive_elt_placeholder, eltsub.inactive_elt);
            }
            ((state || state==undefined)?_hide:_show)(eltsub.inactive_elt, eltsub.inactive_elt_placeholder);
        }
    }

    class Widget {
        offset = 0;
        frequency = 10; /* FIXME arbitrary default max freq. Obtain from config ? */
        unsubscribable = false;
        pending_animate = false;

        constructor(elt_id, freq, args, variables, enable_expr, members){
            this.element_id = elt_id;
            this.element = id(elt_id);
            this.args = args;
            
            [this.indexes, this.variables_options] = (variables.length>0) ? zip(...variables) : [[],[]];
            this.indexes_length = this.indexes.length;

            this.enable_expr = enable_expr;
            this.enable_state = true;
            this.enable_displayed_state = true;
            this.enabled_elts = [];

            Object.keys(members).forEach(prop => this[prop]=members[prop]);
            this.lastapply = this.indexes.map(() => undefined);
            this.inhibit = this.indexes.map(() => undefined);
            this.pending = this.indexes.map(() => undefined);
            this.bound_uninhibit = this.uninhibit.bind(this);

            this.lastdispatch = this.indexes.map(() => undefined);
            this.deafen = this.indexes.map(() => undefined);
            this.incoming = this.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);
                }
            }

            if(this.enable_expr){
                this.enable_state = false;
                this.enable_displayed_state = false;
                for(let child of Array.from(this.element.children)){
                    let label = child.getAttribute("inkscape:label");
                    if(label!="disabled"){
                        this.enabled_elts.push(child);
                        this.element.removeChild(child);
                    }
                }
            }
        }

        unsub(){
            /* remove subsribers */
            for(let i = 0; i < this.indexes_length; i++) {
                /* flush updates pending because of inhibition */
                let inhibition = this.inhibit[i];
                if(inhibition != undefined){
                    clearTimeout(inhibition);
                    this.lastapply[i] = undefined;
                    this.uninhibit(i);
                }
                let deafened = this.deafen[i];
                if(deafened != undefined){
                    clearTimeout(deafened);
                    this.lastdispatch[i] = undefined;
                    this.undeafen(i);
                }
                let index = this.get_variable_index(i);
                subscribers(index).delete(this);
            }
            this.offset = 0;
            this.relativeness = undefined;
        }

        sub(new_offset=0, relativeness, container_id){
            this.offset = new_offset;
            this.relativeness = relativeness;
            this.container_id = container_id ;
            /* add this's subsribers */
            for(let i = 0; i < this.indexes_length; i++) {
                let index = this.get_variable_index(i);
                if(index == undefined) continue;
                subscribers(index).add(this);
            }
            this.apply_cache(); 
        }

        apply_cache() {
            for(let i = 0; i < this.indexes_length; i++) {
                /* dispatch current cache in newly opened page widgets */
                let realindex = this.get_variable_index(i);
                if(realindex == undefined) continue;
                let cached_val = cache[realindex];
                if(cached_val != undefined)
                    this.feed_data_for_dispatch(cached_val, cached_val, i);
            }
        }

        get_variable_index(varnum) {
            let index = this.indexes[varnum];
            if(typeof(index) == "string"){
                index = page_local_index(index, this.container_id);
            } else {
                if(this.relativeness[varnum]){
                    index += this.offset;
                }
            }
            return index;
        }

        overshot(new_val, max) {
        }

        undershot(new_val, min) {
        }

        clip_min_max(index, new_val) {
            let minmax = this.variables_options[index].minmax;
            if(minmax !== undefined && typeof new_val == "number") {
                let [min,max] = minmax;
                if(new_val < min){
                    this.undershot(new_val, min);
                    return min;
                }
                if(new_val > max){
                    this.overshot(new_val, max);
                    return max;
                }
            }
            return new_val;
        }

        change_hmi_value(index, opstr) {
            let realindex = this.get_variable_index(index);
            if(realindex == undefined) return undefined;
            let old_val = cache[realindex];
            let new_val = eval_operation_string(old_val, opstr);
            if(this.clip)
                new_val = this.clip_min_max(index, new_val);
            return apply_hmi_value(realindex, new_val);
        }

        _apply_hmi_value(index, new_val) {
            let realindex = this.get_variable_index(index);
            if(realindex == undefined) return undefined;
            if(this.clip)
                new_val = this.clip_min_max(index, new_val);
            return apply_hmi_value(realindex, new_val);
        }

        uninhibit(index){
            this.inhibit[index] = undefined;
            let new_val = this.pending[index];
            this.pending[index] = undefined;
            return this.apply_hmi_value(index, new_val);
        }

        apply_hmi_value(index, new_val) {
            if(this.inhibit[index] == undefined){
                let now = Date.now();
                let min_interval = 1000/this.frequency;
                let lastapply = this.lastapply[index];
                if(lastapply == undefined || now > lastapply + min_interval){
                    this.lastapply[index] = now;
                    return this._apply_hmi_value(index, new_val);
                }
                else {
                    let elapsed = now - lastapply;
                    this.pending[index] = new_val;
                    this.inhibit[index] = setTimeout(this.bound_uninhibit, min_interval - elapsed, index);
                }
            }
            else {
                this.pending[index] = new_val;
                return new_val;
            }
        }

        new_hmi_value(index, value, oldval) {
            // TODO avoid searching, store index at sub()
            for(let i = 0; i < this.indexes_length; i++) {
                let refindex = this.get_variable_index(i);
                if(refindex == undefined) continue;

                if(index == refindex) {
                    this.feed_data_for_dispatch(value, oldval, i);
                    break;
                }
            }
        }
        
        undeafen(index){
            this.deafen[index] = undefined;
            let [new_val, old_val] = this.incoming[index];
            this.incoming[index] = undefined;
            this.do_dispatch(new_val, old_val, index);
        }

        enable(enabled){
            if(this.enable_state != enabled){
                this.enable_state = enabled;
                this.request_animate();
            }
        }

        animate_enable(){
            if(this.enable_state && !this.enable_displayed_state){
                //show widget
                for(let child of this.enabled_elts){
                    this.element.appendChild(child);
                }

                //hide disabled content
                if(this.disabled_elt && this.disabled_elt.parentNode != null)
                    this.element.removeChild(this.disabled_elt);

                this.enable_displayed_state = true;

            }else if(!this.enable_state && this.enable_displayed_state){

                //hide widget
                for(let child of this.enabled_elts){
                    if(child.parentNode != null)
                        this.element.removeChild(child);
                }

                //show disabled content
                if(this.disabled_elt)
                    this.element.appendChild(this.disabled_elt);

                this.enable_displayed_state = false;

                // once disabled activity display is lost
                this.activity_displayed_state = undefined;
            }
        }

        feed_data_for_dispatch(value, oldval, varnum) {
            if(this.dispatch || this.enable_expr){
                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;
                        this.do_dispatch(value, oldval, varnum)
                    }
                    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];
                }
            }
        }

        do_dispatch(value, oldval, varnum) {
            if(this.dispatch) try {
                this.dispatch(value, oldval, varnum);
            } catch(err) {
                console.log(err);
            }
            if(this.enable_expr) try {
                this.compute_enable(value, oldval, varnum);
            } catch(err) {
                console.log(err);
            }
        }

        _animate(){
            if(this.enable_expr)
                this.animate_enable();
            // inhibit widget animation when disabled
            if(!this.enable_expr || this.enable_state){
                if(this.has_activity)
                    this.animate_activity();
                if(this.animate != undefined)
                    this.animate();
            }
            this.pending_animate = false;
        }

        request_animate(){
            if(!this.pending_animate){
                pending_widget_animates.push(this);
                this.pending_animate = true;
                requestHMIAnimation();
            }
        }

        animate_activity(){
            if(this.activity_displayed_state != this.activity_state){
                set_activity_state(this.activable_sub, this.activity_state);
                this.activity_displayed_state = this.activity_state;
            }
        }
    }
    ||
}

const "excluded_types", "str:split('Page VarInit VarInitPersistent')";

// Key to filter unique types
key "TypesKey", "widget", "@type";

emit "declarations:hmi-classes" {
    const "used_widget_types", """$parsed_widgets/widget[
                                    generate-id() = generate-id(key('TypesKey', @type)) and 
                                    not(@type = $excluded_types)]""";
    apply "$used_widget_types", mode="widget_class";

}

template "widget", mode="widget_class" {
    ||
    class «@type»Widget extends Widget{
        /* empty class, as «@type» widget didn't provide any */
    }
    ||
    warning > «@type» widget is used in SVG but widget type is not declared
}

const "included_ids","$parsed_widgets/widget[not(@type = $excluded_types) and not(@id = $discardable_elements/@id)]/@id";
const "hmi_widgets","$hmi_elements[@id = $included_ids]";
const "result_widgets","$result_svg_ns//*[@id = $hmi_widgets/@id]";

emit "declarations:hmi-elements" {
    | var hmi_widgets = {
    apply "$hmi_widgets", mode="hmi_widgets";
    | }
    |
}

function "defs_by_labels" {
    param "labels","''";
    param "mandatory","'yes'";
    param "subelements","/..";
    param "hmi_element";
    const "widget_type","@type";
    const "widget_id","@id";
    foreach "str:split($labels)" {
        const "absolute", "starts-with(., '/')";
        const "name","substring(.,number($absolute)+1)";
        const "widget","$result_widgets[@id = $hmi_element/@id]";
        const "elt","($widget//*[not($absolute) and @inkscape:label=$name] | $widget/*[$absolute and @inkscape:label=$name])[1]";
        choose {
            when "not($elt/@id)" {
                if "$mandatory!='no'" {
                    const "errmsg" > «$widget_type» widget (id=«$widget_id») must have a «$name» element
                    choose { 
                        when "$mandatory='yes'" {
                            error > «$errmsg»
                        }
                        otherwise {
                            warning > «$errmsg»
                        }
                    }
                }
                // otherwise produce nothing
            }
            otherwise {
                |     «$name»_elt: id("«$elt/@id»"),
                if "$subelements" {
                |     «$name»_sub: {
                    foreach "str:split($subelements)" {
                        const "subname",".";
                        const "subelt","$elt/*[@inkscape:label=$subname][1]";
                        choose {
                            when "not($subelt/@id)" {
                                if "$mandatory!='no'" {
                                    const "errmsg" > «$widget_type» widget (id=«$widget_id») must have a «$name»/«$subname» element
                                    choose { 
                                        when "$mandatory='yes'" {
                                            error > «$errmsg»
                                        }
                                        otherwise {
                                            warning > «$errmsg»
                                        }
                                    }
                                }
                |         /* missing «$name»/«$subname» element */
                            }
                            otherwise {
                |         "«$subname»_elt": id("«$subelt/@id»")`if "position()!=last()" > ,`
                            }
                        }
                    }
                |     },
                }
            }
        }
    }
}

def "func:escape_quotes" {
    param "txt";
    // have to use a python string to enter escaped quote
    // const "frstln", "string-length($frst)";
    choose {
        when !"contains($txt,'\"')"! {
            result !"concat(substring-before($txt,'\"'),'\\\"',func:escape_quotes(substring-after($txt,'\"')))"!;
        }
        otherwise {
            result "$txt";
        }
    }
}