SVGHMI: use requestAnimationFrame to delegate rendering of updates from network. Should help prevent browser collapse leading to watchdog in case of overload.
include yslt_noindent.yml2
// overrides yslt's output function to set CDATA
decl output(method, cdata-section-elements="xhtml:script");
in xsl decl labels(*ptr, name="defs_by_labels") alias call-template {
with "hmi_element", "$hmi_element";
with "labels"{text *ptr};
};
in xsl decl optional_labels(*ptr, name="defs_by_labels") alias call-template {
with "hmi_element", "$hmi_element";
with "labels"{text *ptr};
with "mandatory","'no'";
};
in xsl decl svgtmpl(match, xmlns="http://www.w3.org/2000/svg") alias template;
in xsl decl svgfunc(name, xmlns="http://www.w3.org/2000/svg") alias template;
istylesheet
/* From Inkscape */
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
/* Our namespace to invoke python code */
xmlns:ns="beremiz"
extension-element-prefixes="ns func exsl regexp str dyn"
exclude-result-prefixes="ns str regexp exsl func dyn" {
/* This retrieves geometry obtained through "inkscape -S"
* already parsed by python and presented as a list of
* <bbox x="0" y="0" w="42" h="42">
*/
const "geometry", "ns:GetSVGGeometry()";
const "hmitree", "ns:GetHMITree()";
const "svg_root_id", "/svg:svg/@id";
const "hmi_elements", "//svg:*[starts-with(@inkscape:label, 'HMI:')]";
const "hmi_geometry", "$geometry[@Id = $hmi_elements/@id]";
const "hmi_pages", "$hmi_elements[func:parselabel(@inkscape:label)/widget/@type = 'Page']";
const "default_page" choose {
when "count($hmi_pages) > 1" {
const "Home_page",
"$hmi_pages[func:parselabel(@inkscape:label)/widget/arg[1]/@value = 'Home']";
choose {
when "$Home_page" > Home
otherwise {
error "No Home page defined!";
}
}
}
when "count($hmi_pages) = 0" {
error "No page defined!";
}
otherwise > «func:parselabel($hmi_pages/@inkscape:label)/widget/arg[1]/@value»
}
const "_categories" {
noindex > HMI_ROOT
noindex > HMI_NODE
noindex > HMI_PLC_STATUS
noindex > HMI_CURRENT_PAGE
}
const "categories", "exsl:node-set($_categories)";
const "_indexed_hmitree" apply "$hmitree", mode="index";
const "indexed_hmitree", "exsl:node-set($_indexed_hmitree)";
// returns all directly or indirectly refered elements
def "func:refered_elements" {
param "elems";
const "descend", "$elems/descendant-or-self::svg:*";
const "clones", "$descend[self::svg:use]";
const "originals", "//svg:*[concat('#',@id) = $clones/@xlink:href]";
choose {
when "$originals"
result "$descend | func:refered_elements($originals)";
otherwise
result "$descend";
}
}
def "func:intersect_1d" {
// it is assumed that a1 > a0 and b1 > b0
param "a0";
param "a1";
param "b0";
param "b1";
const "d0", "$a0 >= $b0";
const "d1", "$a1 >= $b1";
choose {
when "not($d0) and $d1"
// b contained in a
// a0<b0 b1<a1
// a +--------+
// b +--+
result "3";
when "$d0 and not($d1)"
// a contained in b
// b0<a0 a1<b1
// a +--+
// b +--------+
result "2";
when "$d0 and $d1 and $a0 < $b1"
// a and b are overlapped
// b0<a0<b1<a1
// a +-----+
// b +-----+
result "1";
when "not($d0) and not($d1) and $b0 < $a1"
// a and b are overlapped
// a0<b0<a1<b1
// a +-----+
// b +-----+
result "1";
otherwise
result "0"; /* no intersection*/
}
}
// returns :
// 0 - no intersection
// .-----.
// .-----. | b|
// | | | |
// | | '-----'
// |a |
// '-----'
//
// 1 - overlapping
// .-----.
// .---|--. b|
// | | | |
// | '-----'
// |a |
// '------'
//
// 2 - overlapping
// .-----.
// | a |
// .---|-----|---.
// | '-----' |
// | b |
// '-------------'
//
// 3 - overlapping
// .-----.
// | b |
// .---|-----|---.
// | '-----' |
// | a |
// '-------------'
//
// 4 - a contained in b
// .-------------.
// | .-----. |
// | | a | |
// |b '-----' |
// '-------------'
//
// 6 - overlapping
// .----.
// | b|
// .---|----|---.
// |a | | |
// '---|----|---'
// '----'
//
// 9 - b contained in a
// .-------------.
// | .-----. |
// | | b | |
// |a '-----' |
// '-------------'
//
def "func:intersect" {
param "a";
param "b";
const "x_intersect", "func:intersect_1d($a/@x, $a/@x+$a/@w, $b/@x, $b/@x+$b/@w)";
choose{
when "$x_intersect != 0"{
const "y_intersect", "func:intersect_1d($a/@y, $a/@y+$a/@h, $b/@y, $b/@y+$b/@h)";
result "$x_intersect * $y_intersect";
}
otherwise result "0";
}
}
// return overlapping geometry for a given element
// all intersercting element are returned
// except groups, that must be contained to be counted in
def "func:overlapping_geometry" {
param "elt";
const "groups", "/svg:svg | //svg:g";
const "g", "$geometry[@Id = $elt/@id]";
const "candidates", "$geometry[@Id != $elt/@id]";
result """$candidates[(@Id = $groups/@id and (func:intersect($g, .) = 9)) or
(not(@Id = $groups/@id) and (func:intersect($g, .) > 0 ))]""";
}
def "func:all_related_elements" {
param "page";
const "page_overlapping_geometry", "func:overlapping_geometry($page)";
const "page_overlapping_elements", "//svg:*[@id = $page_overlapping_geometry/@Id]";
const "page_sub_elements", "func:refered_elements($page | $page_overlapping_elements)";
result "$page_sub_elements";
}
def "func:required_elements" {
param "pages";
choose{
when "$pages"{
result """func:all_related_elements($pages[1])
| func:required_elements($pages[position()!=1])""";
}otherwise{
result "/..";
}
}
}
const "required_elements",
"""//svg:defs/descendant-or-self::svg:*
| func:required_elements($hmi_pages)/ancestor-or-self::svg:*""";
const "discardable_elements", "//svg:*[not(@id = $required_elements/@id)]";
def "func:sumarized_elements" {
param "elements";
const "short_list", "$elements[not(ancestor::*/@id = $elements/@id)]";
const "filled_groups", """$short_list/parent::svg:*[
not(descendant::*[
not(self::svg:g) and
not(@id = $discardable_elements/@id) and
not(@id = $short_list/descendant-or-self::*[not(self::svg:g)]/@id)
])]""";
const "groups_to_add", "$filled_groups[not(ancestor::*/@id = $filled_groups/@id)]";
result "$groups_to_add | $short_list[not(ancestor::svg:g/@id = $filled_groups/@id)]";
}
def "func:detachable_elements" {
param "pages";
choose{
when "$pages"{
result """func:sumarized_elements(func:all_related_elements($pages[1]))
| func:detachable_elements($pages[position()!=1])""";
}otherwise{
result "/..";
}
}
}
// Avoid nested detachables
const "_detachable_elements", "func:detachable_elements($hmi_pages)";
const "detachable_elements", "$_detachable_elements[not(ancestor::*/@id = $_detachable_elements/@id)]";
//////////////// HMI Tree Index
template "*", mode="index" {
param "index", "0";
param "parentpath", "''";
const "content" {
const "path"
choose {
when "local-name() = 'HMI_ROOT'" > «$parentpath»
otherwise > «$parentpath»/«@name»
}
choose {
when "not(local-name() = $categories/noindex)" {
xsl:copy {
attrib "index" > «$index»
attrib "hmipath" > «$path»
foreach "@*" xsl:copy;
}
/* no node expected below value nodes */
}
otherwise {
apply "*[1]", mode="index"{
with "index", "$index";
with "parentpath" > «$path»
}
}
}
}
copy "$content";
apply "following-sibling::*[1]", mode="index" {
with "index", "$index + count(exsl:node-set($content)/*)";
with "parentpath" > «$parentpath»
}
}
//////////////// Inline SVG
// Identity template :
// - copy every attributes
// - copy every sub-elements
template "@* | node()", mode="inline_svg" {
// use real xsl:copy instead copy-of alias from yslt.yml2
if "not(@id = $discardable_elements/@id)"
xsl:copy apply "@* | node()", mode="inline_svg";
}
// replaces inkscape's height and width hints. forces fit
template "svg:svg/@width", mode="inline_svg";
template "svg:svg/@height", mode="inline_svg";
svgtmpl "svg:svg", mode="inline_svg" svg {
attrib "preserveAspectRatio" > none
attrib "height" > 100vh
attrib "width" > 100vw
apply "@* | node()", mode="inline_svg";
}
// ensure that coordinate in CSV file generated by inkscape are in default reference frame
template "svg:svg[@viewBox!=concat('0 0 ', @width, ' ', @height)]", mode="inline_svg" {
error > ViewBox settings other than X=0, Y=0 and Scale=1 are not supported
}
// ensure that coordinate in CSV file generated by inkscape match svg default unit
template "sodipodi:namedview[@units!='px' or @inkscape:document-units!='px']", mode="inline_svg" {
error > All units must be set to "px" in Inkscape's document properties
}
//////////////// Clone Unlinking
// svg:use (inkscape's clones) inside a widgets are
// replaced by real elements they refer in order to :
// - allow finding "needle" element in "meter" widget,
// even if "needle" is in a group refered by a svg use.
// - if "needle" is visible through a svg:use for
// each instance of the widget, then needle would show
// the same position in all instances
//
// For now, clone unlinkink applies to descendants of all widget except HMI:Page
// TODO: narrow application of clone unlinking to active elements,
// while keeping static decoration cloned
const "to_unlink", "$hmi_elements[not(@id = $hmi_pages)]//svg:use";
svgtmpl "svg:use", mode="inline_svg"
{
choose {
when "@id = $to_unlink/@id"
call "unlink_clone";
otherwise
xsl:copy apply "@* | node()", mode="inline_svg";
}
}
// to unlink a clone, an group containing a copy of target element is created
// that way, style and transforms can be preserved
const "_excluded_use_attrs" {
name > href
name > width
name > height
name > x
name > y
}
const "excluded_use_attrs","exsl:node-set($_excluded_use_attrs)";
svgfunc "unlink_clone"{
g{
// include non excluded attributes
foreach "@*[not(local-name() = $excluded_use_attrs/name)]"
attrib "{name()}" > «.»
const "targetid","substring-after(@xlink:href,'#')";
apply "//svg:*[@id = $targetid]", mode="unlink_clone"{
with "seed","@id";
}
}
}
// clone unlinking is really similar to deep-copy
// all nodes are sytematically copied
svgtmpl "@id", mode="unlink_clone" {
param "seed";
attrib "id" > «$seed»_«.»
}
svgtmpl "@*", mode="unlink_clone" xsl:copy;
// copying widgets would have unwanted effect
// instead widget is refered through a svg:use.
svgtmpl "svg:*", mode="unlink_clone" {
param "seed";
choose {
// node recursive copy ends when finding a widget
when "@id = $hmi_elements/@id" {
// place a clone instead of copying
use{
attrib "xlink:href" > «concat('#',@id)»
}
}
otherwise {
xsl:copy apply "@* | node()", mode="unlink_clone" {
with "seed","$seed";
}
}
}
}
/*const "mark" > =HMI=\n*/
const "result_svg" apply "/", mode="inline_svg";
const "result_svg_ns", "exsl:node-set($result_svg)";
/* copy root node and add geometry as comment for a test */
template "/" {
comment > Made with SVGHMI. https://beremiz.org
/* DEBUG DATA */
comment {
apply "$hmi_geometry", mode="testgeo";
}
comment {
apply "$hmitree", mode="testtree";
}
comment {
apply "$indexed_hmitree", mode="testtree";
}
comment {
| Detachable :
foreach "$detachable_elements"{
| «@id»
}
}
comment {
| Discardable :
foreach "$discardable_elements"{
| «@id»
}
}
comment {
| Unlinked :
foreach "$to_unlink"{
| «@id»
}
}
/**/
html xmlns="http://www.w3.org/1999/xhtml"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" {
head;
body style="margin:0;overflow:hidden;" {
copy "$result_svg";
script{
call "scripts";
}
}
}
}
/*
Parses:
"HMI:WidgetType:param1:param2@path1@path2"
Into:
widget type="WidgetType" {
arg value="param1";
arg value="param2";
path value="path1";
path value="path2";
}
*/
def "func:parselabel" {
param "label";
const "description", "substring-after($label,'HMI:')";
const "_args", "substring-before($description,'@')";
const "args" choose {
when "$_args" value "$_args";
otherwise value "$description";
}
const "_type", "substring-before($args,':')";
const "type" choose {
when "$_type" value "$_type";
otherwise value "$args";
}
const "ast" if "$type" widget {
attrib "type" > «$type»
foreach "str:split(substring-after($args, ':'), ':')" {
arg {
attrib "value" > «.»
}
}
const "paths", "substring-after($description,'@')";
foreach "str:split($paths, '@')" {
path {
attrib "value" > «.»
}
}
}
result "exsl:node-set($ast)";
}
function "scripts"
{
| //(function(){
|
| id = idstr => document.getElementById(idstr);
|
| var hmi_hash = [«$hmitree/@hash»];
/* TODO re-enable
||
function evaluate_js_from_descriptions() {
var Page;
var Input;
var Display;
var res = [];
||
const "midmark" > \n«$mark»
apply """//*[contains(child::svg:desc, $midmark) or \
starts-with(child::svg:desc, $mark)]""",2
mode="code_from_descs";
||
return res;
}
||
*/
| var hmi_widgets = {
foreach "$hmi_elements" {
const "widget", "func:parselabel(@inkscape:label)/widget";
const "eltid","@id";
| "«@id»": {
| type: "«$widget/@type»",
| args: [
foreach "$widget/arg"
| "«@value»"`if "position()!=last()" > ,`
| ],
| indexes: [
foreach "$widget/path" {
const "hmipath","@value";
const "hmitree_match","$indexed_hmitree/*[@hmipath = $hmipath]";
choose {
when "count($hmitree_match) = 0" {
warning > Widget «$widget/@type» id="«$eltid»" : No match for path "«$hmipath»" in HMI tree
}
otherwise {
| «$hmitree_match/@index»`if "position()!=last()" > ,`
}
}
}
| ],
| element: id("«@id»"),
apply "$widget", mode="widget_defs" with "hmi_element",".";
| }`if "position()!=last()" > ,`
}
| }
|
| var heartbeat_index = «$indexed_hmitree/*[@hmipath = '/HEARTBEAT']/@index»;
|
| var hmitree_types = [
foreach "$indexed_hmitree/*" {
| /* «@index» «@hmipath» */ "«substring(local-name(), 5)»"`if "position()!=last()" > ,`
}
| ]
|
| var detachable_elements = {
foreach "$detachable_elements"{
| "«@id»":[id("«@id»"), id("«../@id»")]`if "position()!=last()" > ,`
}
| }
|
| var page_desc = {
foreach "$hmi_pages" {
const "desc", "func:parselabel(@inkscape:label)/widget";
const "page", ".";
const "p", "$geometry[@Id = $page/@id]";
const "page_all_elements", "func:all_related_elements($page)";
const "all_page_ids","$page_all_elements[@id = $hmi_elements/@id and @id != $page/@id]/@id";
// Take closest ancestor in detachable_elements
// since nested detachable elements are filtered out
const "required_detachables",
"""func:sumarized_elements($page_all_elements)/
ancestor-or-self::*[@id = $detachable_elements/@id]""";
| "«$desc/arg[1]/@value»": {
| widget: hmi_widgets["«@id»"],
| bbox: [«$p/@x», «$p/@y», «$p/@w», «$p/@h»],
| widgets: [
foreach "$all_page_ids" {
| hmi_widgets["«.»"]`if "position()!=last()" > ,`
}
| ],
| required_detachables: {
foreach "$required_detachables" {
| "«@id»": detachable_elements["«@id»"]`if "position()!=last()" > ,`
}
| }
| }`if "position()!=last()" > ,`
}
| }
|
| var default_page = "«$default_page»";
| var svg_root = id("«$svg_root_id»");
include text svghmi.js
| //})();
}
// template "*", mode="code_from_descs" {
// ||
// {
// var path, role, name, priv;
// var id = "«@id»";
// ||
// /* if label is used, use it as default name */
// if "@inkscape:label"
// |> name = "«@inkscape:label»";
// | /* -------------- */
// // this breaks indent, but fixing indent could break string literals
// value "substring-after(svg:desc, $mark)";
// // nobody reads generated code anyhow...
// ||
// /* -------------- */
// res.push({
// path:path,
// role:role,
// name:name,
// priv:priv
// })
// }
// ||
// }
/**/
template "bbox", mode="testgeo"{
| ID: «@Id» x: «@x» y: «@y» w: «@w» h: «@h»
}
template "*", mode="testtree"{
param "indent", "''";
> «$indent» «local-name()»
foreach "@*" > «local-name()»=«.»
> \n
apply "*", mode="testtree" {
with "indent" value "concat($indent,'>')"
};
}
/**/
function "defs_by_labels" {
param "labels","''";
param "mandatory","'yes'";
param "hmi_element";
const "widget_type","@type";
foreach "str:split($labels)" {
const "name",".";
const "elt_id","$result_svg_ns//*[@id = $hmi_element/@id]//*[@inkscape:label=$name][1]/@id";
choose {
when "not($elt_id)" {
if "$mandatory='yes'" {
// TODO FIXME error > «$widget_type» widget must have a «$name» element
warning > «$widget_type» widget must have a «$name» element
}
// otherwise produce nothing
}
otherwise {
| «$name»_elt: id("«$elt_id»"),
}
}
}
}
template "widget[@type='Display']", mode="widget_defs" {
param "hmi_element";
| frequency: 5,
| dispatch: function(value) {
choose {
when "$hmi_element[self::svg:text]"{
// TODO : care about <tspan> ?
| this.element.textContent = String(value);
}
otherwise {
warning > Display widget as a group not implemented
}
}
| },
}
template "widget[@type='Meter']", mode="widget_defs" {
param "hmi_element";
| frequency: 10,
labels("needle range");
optional_labels("value min max");
| dispatch: function(value) {
| if(this.value_elt)
| this.value_elt.textContent = String(value);
| let [min,max,totallength] = this.range;
| let length = Math.max(0,Math.min(totallength,(Number(value)-min)*totallength/(max-min)));
| let tip = this.range_elt.getPointAtLength(length);
| this.needle_elt.setAttribute('d', "M "+this.origin.x+","+this.origin.y+" "+tip.x+","+tip.y);
| },
| origin: undefined,
| range: undefined,
| init: function() {
| let min = this.min_elt ?
| Number(this.min_elt.textContent) :
| this.args.length >= 1 ? this.args[0] : 0;
| let max = this.max_elt ?
| Number(this.max_elt.textContent) :
| this.args.length >= 2 ? this.args[1] : 100;
| this.range = [min, max, this.range_elt.getTotalLength()]
| this.origin = this.needle_elt.getPointAtLength(0);
| },
}
def "func:escape_quotes" {
param "txt";
// have to use a python string to enter escaped quote
const "frst", !"substring-before($txt,'\"')"!;
const "frstln", "string-length($frst)";
choose {
when "$frstln > 0 and string-length($txt) > $frstln" {
result !"concat($frst,'\\\"',func:escape_quotes(substring-after($txt,'\"')))"!;
}
otherwise {
result "$txt";
}
}
}
template "widget[@type='Input']", mode="widget_defs" {
param "hmi_element";
const "value_elt" {
optional_labels("value");
}
value "$value_elt";
if "$value_elt"
| frequency: 5,
| dispatch: function(value) {
if "$value_elt"
| this.value_elt.textContent = String(value);
| },
const "edit_elt_id","$hmi_element/*[@inkscape:label='edit'][1]/@id";
| init: function() {
if "$edit_elt_id" {
| id("«$edit_elt_id»").addEventListener(
| "click",
| evt => alert('XXX TODO : Edit value'));
}
foreach "$hmi_element/*[regexp:test(@inkscape:label,'^[=+\-].+')]" {
| id("«@id»").addEventListener(
| "click",
| evt => {let new_val = change_hmi_value(this.indexes[0], "«func:escape_quotes(@inkscape:label)»");
| this.value_elt.textContent = String(new_val);});
/* TODO gray out value until refreshed */
}
| },
}
template "widget[@type='Button']", mode="widget_defs" {
}
template "widget[@type='Toggle']", mode="widget_defs" {
| frequency: 5,
}
template "widget[@type='Switch']", mode="widget_defs" {
param "hmi_element";
| frequency: 5,
| dispatch: function(value) {
| for(let choice of this.choices){
| if(value != choice.value){
| choice.elt.setAttribute("style", "display:none");
| } else {
| choice.elt.setAttribute("style", choice.style);
| }
| }
| },
| init: function() {
| // Hello Switch
| },
| choices: [
const "regex",!"'^(\"[^\"].*\"|\-?[0-9]+)(#.*)?$'"!;
foreach "$hmi_element/*[regexp:test(@inkscape:label,$regex)]" {
const "literal", "regexp:match(@inkscape:label,$regex)[2]";
| {
| elt:id("«@id»"),
| style:"«@style»",
| value:«$literal»
| }`if "position()!=last()" > ,`
}
| ],
}
template "widget[@type='Jump']", mode="widget_defs" {
param "hmi_element";
| on_click: function(evt) {
| console.log(evt);
| switch_page(this.args[0]);
| },
| init: function() {
/* registering event this way doies not "click" through svg:use
| this.element.onclick = evt => switch_page(this.args[0]);
event must be registered by adding attribute to element instead
TODO : generalize mouse event handling by global event capture + getElementsAtPoint()
*/
| this.element.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_click(evt)");
| },
}
}