lpcmanager

Parents 750f686c63af
Children 0027c1079b3b
Logging: added HMI:HistoryXYGraph (accepts streamed data from log and plots XYGraph) and log_append POU (logs data up to maximum number of records defined in config file).
--- a/LPCSVGHMI/analyse_widget.xslt Fri Jan 16 11:50:30 2026 +0100
+++ b/LPCSVGHMI/analyse_widget.xslt Mon Jan 26 11:30:15 2026 +0100
@@ -1162,6 +1162,53 @@
<xsl:text>Image display</xsl:text>
</shortdesc>
</xsl:template>
+ <xsl:template match="widget[@type='HistoryXYGraph']" mode="widget_desc">
+ <type>
+ <xsl:value-of select="@type"/>
+ </type>
+ <longdesc>
+ <xsl:text>HistoryXYGraph draws a cartesian trend graph reusing styles given for axis,
+</xsl:text>
+ <xsl:text>grid/marks, legends and curves.
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text>Elements labeled "x_axis" and "y_axis" are svg:groups containing:
+</xsl:text>
+ <xsl:text> - "axis_label" svg:text gives style an alignment for axis labels.
+</xsl:text>
+ <xsl:text> - "interval_major_mark" and "interval_minor_mark" are svg elements to be
+</xsl:text>
+ <xsl:text> duplicated along axis line to form intervals marks.
+</xsl:text>
+ <xsl:text> - "axis_line" svg:path is the axis line. Paths must be intersect and their
+</xsl:text>
+ <xsl:text> bounding box is the chart wall.
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text>Elements labeled "curve_0", "curve_1", ... are paths whose styles are used
+</xsl:text>
+ <xsl:text>to draw curves corresponding to data from variables passed as HMI tree paths.
+</xsl:text>
+ <xsl:text>"curve_0" is mandatory. HMI variables outnumbering given curves are ignored.
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ </longdesc>
+ <shortdesc>
+ <xsl:text>Cartesian trend graph showing values of given variables over time</xsl:text>
+ </shortdesc>
+ <path name="value" count="1+" accepts="HMI_INT,HMI_REAL">
+ <xsl:text>value</xsl:text>
+ </path>
+ <arg name="xformat" count="optional" accepts="string">
+ <xsl:text>format string for X label</xsl:text>
+ </arg>
+ <arg name="yformat" count="optional" accepts="string">
+ <xsl:text>format string for Y label</xsl:text>
+ </arg>
+ </xsl:template>
<xsl:template match="widget[@type='Swipe']" mode="widget_desc">
<type>
<xsl:value-of select="@type"/>
--- a/LPCSVGHMI/gen_index_xhtml.xslt Fri Jan 16 11:50:30 2026 +0100
+++ b/LPCSVGHMI/gen_index_xhtml.xslt Mon Jan 26 11:30:15 2026 +0100
@@ -9649,6 +9649,516 @@
<xsl:apply-templates mode="inline_svg" select="@*[not(contains(name(), 'href'))] | node()"/>
</xsl:copy>
</xsl:template>
+ <xsl:template match="widget[@type='HistoryXYGraph']" mode="widget_desc">
+ <type>
+ <xsl:value-of select="@type"/>
+ </type>
+ <longdesc>
+ <xsl:text>HistoryXYGraph draws a cartesian trend graph reusing styles given for axis,
+</xsl:text>
+ <xsl:text>grid/marks, legends and curves.
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text>Elements labeled "x_axis" and "y_axis" are svg:groups containing:
+</xsl:text>
+ <xsl:text> - "axis_label" svg:text gives style an alignment for axis labels.
+</xsl:text>
+ <xsl:text> - "interval_major_mark" and "interval_minor_mark" are svg elements to be
+</xsl:text>
+ <xsl:text> duplicated along axis line to form intervals marks.
+</xsl:text>
+ <xsl:text> - "axis_line" svg:path is the axis line. Paths must be intersect and their
+</xsl:text>
+ <xsl:text> bounding box is the chart wall.
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text>Elements labeled "curve_0", "curve_1", ... are paths whose styles are used
+</xsl:text>
+ <xsl:text>to draw curves corresponding to data from variables passed as HMI tree paths.
+</xsl:text>
+ <xsl:text>"curve_0" is mandatory. HMI variables outnumbering given curves are ignored.
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ </longdesc>
+ <shortdesc>
+ <xsl:text>Cartesian trend graph showing values of given variables over time</xsl:text>
+ </shortdesc>
+ <path name="value" count="1+" accepts="HMI_INT,HMI_REAL">
+ <xsl:text>value</xsl:text>
+ </path>
+ <arg name="xformat" count="optional" accepts="string">
+ <xsl:text>format string for X label</xsl:text>
+ </arg>
+ <arg name="yformat" count="optional" accepts="string">
+ <xsl:text>format string for Y label</xsl:text>
+ </arg>
+ </xsl:template>
+ <xsl:template match="widget[@type='HistoryXYGraph']" mode="widget_class">
+ <xsl:text>class </xsl:text>
+ <xsl:text>HistoryXYGraphWidget</xsl:text>
+ <xsl:text> extends Widget{
+</xsl:text>
+ <xsl:text> frequency = 1;
+</xsl:text>
+ <xsl:text> init() {
+</xsl:text>
+ <xsl:text> this.params = [null, null];
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> [this.x_format, this.y_format] = this.args;
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> this.fetch_error_bound = this.fetch_error.bind(this);
+</xsl:text>
+ <xsl:text> this.loading = false;
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> this.curves = [];
+</xsl:text>
+ <xsl:text> this.curves_data = [];
+</xsl:text>
+ <xsl:text> this.init_specific();
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> this.reference = new ReferenceFrame(
+</xsl:text>
+ <xsl:text> [[this.x_interval_minor_mark_elt, this.x_interval_major_mark_elt],
+</xsl:text>
+ <xsl:text> [this.y_interval_minor_mark_elt, this.y_interval_major_mark_elt]],
+</xsl:text>
+ <xsl:text> [this.x_axis_label_elt, this.y_axis_label_elt],
+</xsl:text>
+ <xsl:text> [this.x_axis_line_elt, this.y_axis_line_elt],
+</xsl:text>
+ <xsl:text> [this.x_format, this.y_format]);
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> let max_stroke_width = 0;
+</xsl:text>
+ <xsl:text> for (let curve of this.curves) {
+</xsl:text>
+ <xsl:text> if (curve.style.strokeWidth &gt; max_stroke_width) {
+</xsl:text>
+ <xsl:text> max_stroke_width = curve.style.strokeWidth;
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> this.curves_data.push([]);
+</xsl:text>
+ <xsl:text> this.params.push(null);
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> this.Margins = this.reference.getLengths().map(length =&gt; max_stroke_width / length);
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> // create &lt;clipPath&gt; path and attach it to widget
+</xsl:text>
+ <xsl:text> let clipPath = document.createElementNS(xmlns, "clipPath");
+</xsl:text>
+ <xsl:text> let clipPathPath = document.createElementNS(xmlns, "path");
+</xsl:text>
+ <xsl:text> let clipPathPathDattr = document.createAttribute("d");
+</xsl:text>
+ <xsl:text> clipPathPathDattr.value = this.reference.getClipPathPathDattr();
+</xsl:text>
+ <xsl:text> clipPathPath.setAttributeNode(clipPathPathDattr);
+</xsl:text>
+ <xsl:text> clipPath.appendChild(clipPathPath);
+</xsl:text>
+ <xsl:text> clipPath.id = randomId();
+</xsl:text>
+ <xsl:text> this.element.appendChild(clipPath);
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> // assign created clipPath to clip-path property of curves
+</xsl:text>
+ <xsl:text> for(let curve of this.curves) {
+</xsl:text>
+ <xsl:text> curve.setAttribute("clip-path", "url(#" + clipPath.id + ")");
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> fetch_error(e){
+</xsl:text>
+ <xsl:text> console.log("HTTP fetch error, message = " + e.message + "Widget:" + this.element_id);
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> do_http_request() {
+</xsl:text>
+ <xsl:text> this.abort_controller = new AbortController();
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> const decoder = new TextDecoder();
+</xsl:text>
+ <xsl:text> let partialChunk = '';
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> const query = {
+</xsl:text>
+ <xsl:text> startTime: Date.parse(this.params[0]),
+</xsl:text>
+ <xsl:text> endTime: Date.parse(this.params[1]),
+</xsl:text>
+ <xsl:text> variableNames: this.params.slice(2)
+</xsl:text>
+ <xsl:text> };
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> const options = {
+</xsl:text>
+ <xsl:text> method: 'POST',
+</xsl:text>
+ <xsl:text> body: JSON.stringify(query),
+</xsl:text>
+ <xsl:text> headers: { 'Content-Type': 'application/json' },
+</xsl:text>
+ <xsl:text> signal: this.abort_controller.signal
+</xsl:text>
+ <xsl:text> };
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> return fetch('/history', options)
+</xsl:text>
+ <xsl:text> .then(response =&gt; {
+</xsl:text>
+ <xsl:text> const reader = response.body.getReader();
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> const read = () =&gt; {
+</xsl:text>
+ <xsl:text> return reader.read().then(({ value, done }) =&gt; {
+</xsl:text>
+ <xsl:text> if (done) return;
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> const chunk = decoder.decode(value, { stream: true });
+</xsl:text>
+ <xsl:text> const lines = (partialChunk + chunk).split(String.fromCharCode(10));
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> partialChunk = lines.pop();
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> lines.forEach(line =&gt; {
+</xsl:text>
+ <xsl:text> if (line.trim()) {
+</xsl:text>
+ <xsl:text> const row = JSON.parse(line);
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> const vi = query.variableNames.findIndex(v =&gt; v === row.varname);
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> if (vi !== -1 &amp;&amp; this.curves_data[vi]) {
+</xsl:text>
+ <xsl:text> this.curves_data[vi].push([row.timestamp, row.value]);
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> if (row.value &gt; this.ymax) this.ymax = row.value;
+</xsl:text>
+ <xsl:text> if (row.value &lt; this.ymin) this.ymin = row.value;
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> });
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> return read();
+</xsl:text>
+ <xsl:text> });
+</xsl:text>
+ <xsl:text> };
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> return read();
+</xsl:text>
+ <xsl:text> }).catch(this.fetch_error_bound);
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> unsub() {
+</xsl:text>
+ <xsl:text> if (this.abort_controller) {
+</xsl:text>
+ <xsl:text> this.abort_controller.abort();
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> super.unsub();
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> sub(...args){
+</xsl:text>
+ <xsl:text> super.sub(...args);
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> dispatch(value, oldval, index) {
+</xsl:text>
+ <xsl:text> this.params[index] = value;
+</xsl:text>
+ <xsl:text> if (this.params.every((item) =&gt; item !== null)) {
+</xsl:text>
+ <xsl:text> if(!this.loading){
+</xsl:text>
+ <xsl:text> this.loading = true;
+</xsl:text>
+ <xsl:text> this.curves_data = [];
+</xsl:text>
+ <xsl:text> for (let curve of this.curves) {
+</xsl:text>
+ <xsl:text> this.curves_data.push([]);
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> this.ymin = Infinity;
+</xsl:text>
+ <xsl:text> this.ymax = -Infinity;
+</xsl:text>
+ <xsl:text> this.do_http_request().finally(() =&gt; {
+</xsl:text>
+ <xsl:text> let xmin = Infinity;
+</xsl:text>
+ <xsl:text> let xmax = -Infinity;
+</xsl:text>
+ <xsl:text> let has_data = false;
+</xsl:text>
+ <xsl:text> for (let i = 0; i &lt; this.curves.length; i++) {
+</xsl:text>
+ <xsl:text> const dataLength = this.curves_data[i].length;
+</xsl:text>
+ <xsl:text> if (dataLength &gt; 1) {
+</xsl:text>
+ <xsl:text> const ximin = this.curves_data[i][0][0];
+</xsl:text>
+ <xsl:text> const ximax = this.curves_data[i][dataLength - 1][0];
+</xsl:text>
+ <xsl:text> if (ximin &lt; xmin) xmin = ximin;
+</xsl:text>
+ <xsl:text> if (ximax &gt; xmax) xmax = ximax;
+</xsl:text>
+ <xsl:text> has_data = true;
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> if (has_data) {
+</xsl:text>
+ <xsl:text> this.xmin = xmin;
+</xsl:text>
+ <xsl:text> this.xmax = xmax;
+</xsl:text>
+ <xsl:text> } else {
+</xsl:text>
+ <xsl:text> this.xmin = Date.parse(this.params[0]);
+</xsl:text>
+ <xsl:text> this.xmax = Date.parse(this.params[1]);
+</xsl:text>
+ <xsl:text> this.ymin = -1;
+</xsl:text>
+ <xsl:text> this.ymax = 1;
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> let Xrange = this.xmax - this.xmin;
+</xsl:text>
+ <xsl:text> let Yrange = this.ymax - this.ymin;
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> // apply margin by moving min and max to enlarge range
+</xsl:text>
+ <xsl:text> let [xMargin, yMargin] = zip(this.Margins, [Xrange, Yrange]).map(([m, l]) =&gt; m * l);
+</xsl:text>
+ <xsl:text> [[this.dxmin, this.dxmax], [this.dymin, this.dymax]] =
+</xsl:text>
+ <xsl:text> [[this.xmin - xMargin, this.xmax + xMargin],
+</xsl:text>
+ <xsl:text> [this.ymin - yMargin, this.ymax + yMargin]];
+</xsl:text>
+ <xsl:text> Xrange += 2 * xMargin;
+</xsl:text>
+ <xsl:text> Yrange += 2 * yMargin;
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> // recompute curves "d" attribute
+</xsl:text>
+ <xsl:text> let [base_point, xvect, yvect] = this.reference.getBaseRef();
+</xsl:text>
+ <xsl:text> this.curves_d_attr =
+</xsl:text>
+ <xsl:text> zip(this.curves_data, this.curves).map(([data, curve]) =&gt; {
+</xsl:text>
+ <xsl:text> let new_d = data.map(([x, y], i) =&gt; {
+</xsl:text>
+ <xsl:text> // compute curve point from data, ranges, and base_ref
+</xsl:text>
+ <xsl:text> let xv = vectorscale(xvect, (x - this.dxmin) / Xrange);
+</xsl:text>
+ <xsl:text> let yv = vectorscale(yvect, (y - this.dymin) / Yrange);
+</xsl:text>
+ <xsl:text> let px = base_point.x + xv.x + yv.x;
+</xsl:text>
+ <xsl:text> let py = base_point.y + xv.y + yv.y;
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> return " " + px + "," + py;
+</xsl:text>
+ <xsl:text> });
+</xsl:text>
+ <xsl:text> new_d.unshift("M ");
+</xsl:text>
+ <xsl:text> return new_d.join('');
+</xsl:text>
+ <xsl:text> });
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> // computed curves "d" attr is applied to svg curve during animate();
+</xsl:text>
+ <xsl:text> this.request_animate();
+</xsl:text>
+ <xsl:text> this.loading = false;
+</xsl:text>
+ <xsl:text> });
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> animate() {
+</xsl:text>
+ <xsl:text> this.reference.applyRanges([[this.dxmin, this.dxmax],
+</xsl:text>
+ <xsl:text> [this.dymin, this.dymax]]);
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text> // apply computed curves "d" attributes
+</xsl:text>
+ <xsl:text> for (let [curve, d_attr] of zip(this.curves, this.curves_d_attr)) {
+</xsl:text>
+ <xsl:text> if (d_attr.length &gt; 2)
+</xsl:text>
+ <xsl:text> curve.setAttribute("d", d_attr);
+</xsl:text>
+ <xsl:text> else
+</xsl:text>
+ <xsl:text> curve.setAttribute("d", "M 0 0");
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text> }
+</xsl:text>
+ <xsl:text>
+</xsl:text>
+ <xsl:text>}
+</xsl:text>
+ </xsl:template>
+ <xsl:template match="widget[@type='HistoryXYGraph']" mode="widget_defs">
+ <xsl:param name="hmi_element"/>
+ <xsl:variable name="disability">
+ <xsl:call-template name="defs_by_labels">
+ <xsl:with-param name="hmi_element" select="$hmi_element"/>
+ <xsl:with-param name="labels">
+ <xsl:text>/disabled</xsl:text>
+ </xsl:with-param>
+ <xsl:with-param name="mandatory" select="'no'"/>
+ </xsl:call-template>
+ </xsl:variable>
+ <xsl:value-of select="$disability"/>
+ <xsl:variable name="has_disability" select="string-length($disability)&gt;0"/>
+ <xsl:call-template name="defs_by_labels">
+ <xsl:with-param name="hmi_element" select="$hmi_element"/>
+ <xsl:with-param name="labels">
+ <xsl:text>/x_interval_minor_mark /x_axis_line /x_interval_major_mark /x_axis_label</xsl:text>
+ </xsl:with-param>
+ </xsl:call-template>
+ <xsl:call-template name="defs_by_labels">
+ <xsl:with-param name="hmi_element" select="$hmi_element"/>
+ <xsl:with-param name="labels">
+ <xsl:text>/y_interval_minor_mark /y_axis_line /y_interval_major_mark /y_axis_label</xsl:text>
+ </xsl:with-param>
+ </xsl:call-template>
+ <xsl:text> init_specific() {
+</xsl:text>
+ <xsl:variable name="curves" select="$hmi_element/*[regexp:test(@inkscape:label,'^curve_[0-9]+$')]"/>
+ <xsl:variable name="curves_error" select="func:check_curves_label_consistency($curves,count($curves)-1)"/>
+ <xsl:if test="string-length($curves_error)">
+ <xsl:message terminate="yes">
+ <xsl:text>HistoryXYGraph id="</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>", label="</xsl:text>
+ <xsl:value-of select="@inkscape:label"/>
+ <xsl:text>" : </xsl:text>
+ <xsl:value-of select="$curves_error"/>
+ </xsl:message>
+ </xsl:if>
+ <xsl:for-each select="$curves">
+ <xsl:variable name="label" select="@inkscape:label"/>
+ <xsl:variable name="_id" select="@id"/>
+ <xsl:variable name="curve_num" select="substring(@inkscape:label, 7)"/>
+ <xsl:text> this.curves[</xsl:text>
+ <xsl:value-of select="$curve_num"/>
+ <xsl:text>] = id("</xsl:text>
+ <xsl:value-of select="@id"/>
+ <xsl:text>"); /* </xsl:text>
+ <xsl:value-of select="@inkscape:label"/>
+ <xsl:text> */
+</xsl:text>
+ </xsl:for-each>
+ <xsl:text> }
+</xsl:text>
+ </xsl:template>
<xsl:template match="widget[@type='Swipe']" mode="widget_desc">
<type>
<xsl:value-of select="@type"/>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/LPCSVGHMI/widget_historyxygraph.ysl2 Mon Jan 26 11:30:15 2026 +0100
@@ -0,0 +1,254 @@
+// widget_historyxygraph.ysl2
+widget_desc("HistoryXYGraph") {
+ longdesc
+ ||
+ HistoryXYGraph draws a cartesian trend graph reusing styles given for axis,
+ grid/marks, legends and curves.
+
+ Elements labeled "x_axis" and "y_axis" are svg:groups containing:
+ - "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="xformat" count="optional" accepts="string" > format string for X label
+ arg name="yformat" count="optional" accepts="string" > format string for Y label
+}
+
+widget_class("HistoryXYGraph") {
+ ||
+ frequency = 1;
+ init() {
+ this.params = [null, null];
+
+ [this.x_format, this.y_format] = this.args;
+
+ this.fetch_error_bound = this.fetch_error.bind(this);
+ this.loading = false;
+
+ this.curves = [];
+ this.curves_data = [];
+ 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.curves_data.push([]);
+ this.params.push(null);
+ }
+
+ 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 + ")");
+ }
+ }
+
+ fetch_error(e){
+ console.log("HTTP fetch error, message = " + e.message + "Widget:" + this.element_id);
+ }
+
+ do_http_request() {
+ this.abort_controller = new AbortController();
+
+ const decoder = new TextDecoder();
+ let partialChunk = '';
+
+ const query = {
+ startTime: Date.parse(this.params[0]),
+ endTime: Date.parse(this.params[1]),
+ variableNames: this.params.slice(2)
+ };
+
+ const options = {
+ method: 'POST',
+ body: JSON.stringify(query),
+ headers: { 'Content-Type': 'application/json' },
+ signal: this.abort_controller.signal
+ };
+
+ return fetch('/history', options)
+ .then(response => {
+ const reader = response.body.getReader();
+
+ const read = () => {
+ return reader.read().then(({ value, done }) => {
+ if (done) return;
+
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = (partialChunk + chunk).split(String.fromCharCode(10));
+
+ partialChunk = lines.pop();
+
+ lines.forEach(line => {
+ if (line.trim()) {
+ const row = JSON.parse(line);
+
+ const vi = query.variableNames.findIndex(v => v === row.varname);
+
+ if (vi !== -1 && this.curves_data[vi]) {
+ this.curves_data[vi].push([row.timestamp, row.value]);
+
+ if (row.value > this.ymax) this.ymax = row.value;
+ if (row.value < this.ymin) this.ymin = row.value;
+ }
+ }
+ });
+
+ return read();
+ });
+ };
+
+ return read();
+ }).catch(this.fetch_error_bound);
+ }
+
+ unsub() {
+ if (this.abort_controller) {
+ this.abort_controller.abort();
+ }
+ super.unsub();
+ }
+
+ sub(...args){
+ super.sub(...args);
+ }
+
+ dispatch(value, oldval, index) {
+ this.params[index] = value;
+ if (this.params.every((item) => item !== null)) {
+ if(!this.loading){
+ this.loading = true;
+ this.curves_data = [];
+ for (let curve of this.curves) {
+ this.curves_data.push([]);
+ }
+ this.ymin = Infinity;
+ this.ymax = -Infinity;
+ this.do_http_request().finally(() => {
+ let xmin = Infinity;
+ let xmax = -Infinity;
+ let has_data = false;
+ for (let i = 0; i < this.curves.length; i++) {
+ const dataLength = this.curves_data[i].length;
+ if (dataLength > 1) {
+ const ximin = this.curves_data[i][0][0];
+ const ximax = this.curves_data[i][dataLength - 1][0];
+ if (ximin < xmin) xmin = ximin;
+ if (ximax > xmax) xmax = ximax;
+ has_data = true;
+ }
+ }
+ if (has_data) {
+ this.xmin = xmin;
+ this.xmax = xmax;
+ } else {
+ this.xmin = Date.parse(this.params[0]);
+ this.xmax = Date.parse(this.params[1]);
+ this.ymin = -1;
+ this.ymax = 1;
+ }
+ let Xrange = this.xmax - this.xmin;
+ 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
+ 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;
+
+ 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();
+ this.loading = false;
+ });
+ }
+ }
+ }
+
+ animate() {
+ 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)) {
+ if (d_attr.length > 2)
+ curve.setAttribute("d", d_attr);
+ else
+ curve.setAttribute("d", "M 0 0");
+ }
+ }
+
+ ||
+}
+
+widget_defs("HistoryXYGraph") {
+ 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 > HistoryXYGraph 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» */
+ }
+
+ | }
+
+}
--- a/Pous/pousCommon.xml Fri Jan 16 11:50:30 2026 +0100
+++ b/Pous/pousCommon.xml Mon Jan 26 11:30:15 2026 +0100
@@ -1663,6 +1663,353 @@
</FBD>
</body>
</pou>
+ <pou name="log_append" pouType="functionBlock">
+ <interface>
+ <outputVars>
+ <variable name="ACK">
+ <type>
+ <BOOL/>
+ </type>
+ </variable>
+ <variable name="ERROR">
+ <type>
+ <BOOL/>
+ </type>
+ </variable>
+ <variable name="RESULT">
+ <type>
+ <string/>
+ </type>
+ </variable>
+ </outputVars>
+ <inputVars>
+ <variable name="VARNAME">
+ <type>
+ <string/>
+ </type>
+ </variable>
+ <variable name="VALUE">
+ <type>
+ <string/>
+ </type>
+ </variable>
+ <variable name="TRIG">
+ <type>
+ <BOOL/>
+ </type>
+ </variable>
+ </inputVars>
+ <localVars>
+ <variable name="py_eval0">
+ <type>
+ <derived name="python_eval"/>
+ </type>
+ </variable>
+ <variable name="R_TRIG1">
+ <type>
+ <derived name="R_TRIG"/>
+ </type>
+ </variable>
+ </localVars>
+ </interface>
+ <body>
+ <FBD>
+ <inVariable localId="8" executionOrderId="0" height="30" width="117" negated="false">
+ <position x="432" y="128"/>
+ <connectionPointOut>
+ <relPosition x="117" y="16"/>
+ </connectionPointOut>
+ <expression>'LogAppend("'</expression>
+ </inVariable>
+ <comment localId="29" height="40" width="232">
+ <position x="64" y="32"/>
+ <content>
+ <xhtml:p><![CDATA[Generate python code line]]></xhtml:p>
+ </content>
+ </comment>
+ <block localId="40" width="104" height="80" typeName="python_eval" instanceName="py_eval0" executionOrderId="0">
+ <position x="392" y="416"/>
+ <inputVariables>
+ <variable formalParameter="TRIG">
+ <connectionPointIn>
+ <relPosition x="0" y="32"/>
+ <connection refLocalId="46" formalParameter="Q">
+ <position x="392" y="448"/>
+ <position x="200" y="448"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ <variable formalParameter="CODE">
+ <connectionPointIn>
+ <relPosition x="0" y="64"/>
+ <connection refLocalId="41">
+ <position x="392" y="480"/>
+ <position x="360" y="480"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ </inputVariables>
+ <inOutVariables/>
+ <outputVariables>
+ <variable formalParameter="ACK">
+ <connectionPointOut>
+ <relPosition x="104" y="32"/>
+ </connectionPointOut>
+ </variable>
+ <variable formalParameter="RESULT">
+ <connectionPointOut>
+ <relPosition x="104" y="64"/>
+ </connectionPointOut>
+ </variable>
+ </outputVariables>
+ </block>
+ <continuation name="Code" localId="41" height="28" width="128">
+ <position x="232" y="464"/>
+ <connectionPointOut>
+ <relPosition x="128" y="16"/>
+ </connectionPointOut>
+ </continuation>
+ <inVariable localId="42" height="27" width="64" executionOrderId="0" negated="false">
+ <position x="48" y="432"/>
+ <connectionPointOut>
+ <relPosition x="64" y="16"/>
+ </connectionPointOut>
+ <expression>TRIG</expression>
+ </inVariable>
+ <outVariable localId="43" height="32" width="40" executionOrderId="0" negated="false">
+ <position x="840" y="432"/>
+ <connectionPointIn>
+ <relPosition x="0" y="16"/>
+ <connection refLocalId="40" formalParameter="ACK">
+ <position x="840" y="448"/>
+ <position x="496" y="448"/>
+ </connection>
+ </connectionPointIn>
+ <expression>ACK</expression>
+ </outVariable>
+ <outVariable localId="44" height="27" width="64" executionOrderId="0" negated="false">
+ <position x="840" y="488"/>
+ <connectionPointIn>
+ <relPosition x="0" y="16"/>
+ <connection refLocalId="40" formalParameter="RESULT">
+ <position x="840" y="504"/>
+ <position x="668" y="504"/>
+ <position x="668" y="480"/>
+ <position x="496" y="480"/>
+ </connection>
+ </connectionPointIn>
+ <expression>RESULT</expression>
+ </outVariable>
+ <block localId="46" typeName="R_TRIG" instanceName="R_TRIG1" executionOrderId="0" height="48" width="64">
+ <position x="136" y="416"/>
+ <inputVariables>
+ <variable formalParameter="CLK">
+ <connectionPointIn>
+ <relPosition x="0" y="32"/>
+ <connection refLocalId="42">
+ <position x="136" y="448"/>
+ <position x="112" y="448"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ </inputVariables>
+ <inOutVariables/>
+ <outputVariables>
+ <variable formalParameter="Q">
+ <connectionPointOut>
+ <relPosition x="64" y="32"/>
+ </connectionPointOut>
+ </variable>
+ </outputVariables>
+ </block>
+ <block localId="33" typeName="LEFT" executionOrderId="0" height="64" width="56">
+ <position x="592" y="520"/>
+ <inputVariables>
+ <variable formalParameter="IN">
+ <connectionPointIn>
+ <relPosition x="0" y="32"/>
+ <connection refLocalId="40" formalParameter="RESULT">
+ <position x="592" y="552"/>
+ <position x="540" y="552"/>
+ <position x="540" y="480"/>
+ <position x="496" y="480"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ <variable formalParameter="L">
+ <connectionPointIn>
+ <relPosition x="0" y="56"/>
+ <connection refLocalId="35">
+ <position x="592" y="576"/>
+ <position x="568" y="576"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ </inputVariables>
+ <inOutVariables/>
+ <outputVariables>
+ <variable formalParameter="OUT">
+ <connectionPointOut>
+ <relPosition x="56" y="32"/>
+ </connectionPointOut>
+ </variable>
+ </outputVariables>
+ </block>
+ <block localId="34" typeName="EQ" executionOrderId="0" height="72" width="64">
+ <position x="736" y="520"/>
+ <inputVariables>
+ <variable formalParameter="IN1">
+ <connectionPointIn>
+ <relPosition x="0" y="32"/>
+ <connection refLocalId="33" formalParameter="OUT">
+ <position x="736" y="552"/>
+ <position x="648" y="552"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ <variable formalParameter="IN2">
+ <connectionPointIn>
+ <relPosition x="0" y="56"/>
+ <connection refLocalId="36">
+ <position x="736" y="576"/>
+ <position x="704" y="576"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ </inputVariables>
+ <inOutVariables/>
+ <outputVariables>
+ <variable formalParameter="OUT">
+ <connectionPointOut>
+ <relPosition x="64" y="32"/>
+ </connectionPointOut>
+ </variable>
+ </outputVariables>
+ </block>
+ <inVariable localId="35" executionOrderId="0" height="27" width="24" negated="false">
+ <position x="544" y="560"/>
+ <connectionPointOut>
+ <relPosition x="24" y="16"/>
+ </connectionPointOut>
+ <expression>1</expression>
+ </inVariable>
+ <inVariable localId="36" executionOrderId="0" height="32" width="40" negated="false">
+ <position x="664" y="560"/>
+ <connectionPointOut>
+ <relPosition x="40" y="16"/>
+ </connectionPointOut>
+ <expression>'#'</expression>
+ </inVariable>
+ <outVariable localId="54" executionOrderId="0" width="56" height="32" negated="false">
+ <position x="840" y="536"/>
+ <connectionPointIn>
+ <relPosition x="0" y="16"/>
+ <connection refLocalId="34" formalParameter="OUT">
+ <position x="840" y="552"/>
+ <position x="800" y="552"/>
+ </connection>
+ </connectionPointIn>
+ <expression>ERROR</expression>
+ </outVariable>
+ <block localId="7" typeName="CONCAT" executionOrderId="0" height="246" width="78">
+ <position x="592" y="104"/>
+ <inputVariables>
+ <variable formalParameter="IN1">
+ <connectionPointIn>
+ <relPosition x="0" y="40"/>
+ <connection refLocalId="8">
+ <position x="592" y="144"/>
+ <position x="549" y="144"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ <variable formalParameter="IN2">
+ <connectionPointIn>
+ <relPosition x="0" y="88"/>
+ <connection refLocalId="2">
+ <position x="592" y="192"/>
+ <position x="352" y="192"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ <variable formalParameter="IN3">
+ <connectionPointIn>
+ <relPosition x="0" y="136"/>
+ <connection refLocalId="10">
+ <position x="592" y="240"/>
+ <position x="552" y="240"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ <variable formalParameter="IN4">
+ <connectionPointIn>
+ <relPosition x="0" y="176"/>
+ <connection refLocalId="3">
+ <position x="592" y="280"/>
+ <position x="352" y="280"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ <variable formalParameter="IN5">
+ <connectionPointIn>
+ <relPosition x="0" y="224"/>
+ <connection refLocalId="1">
+ <position x="592" y="328"/>
+ <position x="550" y="328"/>
+ </connection>
+ </connectionPointIn>
+ </variable>
+ </inputVariables>
+ <inOutVariables/>
+ <outputVariables>
+ <variable formalParameter="OUT">
+ <connectionPointOut>
+ <relPosition x="78" y="40"/>
+ </connectionPointOut>
+ </variable>
+ </outputVariables>
+ </block>
+ <inVariable localId="2" executionOrderId="0" height="32" width="112" negated="false">
+ <position x="240" y="176"/>
+ <connectionPointOut>
+ <relPosition x="112" y="16"/>
+ </connectionPointOut>
+ <expression>VARNAME</expression>
+ </inVariable>
+ <inVariable localId="10" executionOrderId="0" height="27" width="112" negated="false">
+ <position x="440" y="224"/>
+ <connectionPointOut>
+ <relPosition x="112" y="16"/>
+ </connectionPointOut>
+ <expression>'","'</expression>
+ </inVariable>
+ <inVariable localId="3" executionOrderId="0" height="32" width="112" negated="false">
+ <position x="240" y="264"/>
+ <connectionPointOut>
+ <relPosition x="112" y="16"/>
+ </connectionPointOut>
+ <expression>VALUE</expression>
+ </inVariable>
+ <connector name="Code" localId="19" height="28" width="128">
+ <position x="728" y="128"/>
+ <connectionPointIn>
+ <relPosition x="0" y="16"/>
+ <connection refLocalId="7" formalParameter="OUT">
+ <position x="728" y="144"/>
+ <position x="670" y="144"/>
+ </connection>
+ </connectionPointIn>
+ </connector>
+ <inVariable localId="1" executionOrderId="0" height="33" width="118" negated="false">
+ <position x="432" y="312"/>
+ <connectionPointOut>
+ <relPosition x="118" y="16"/>
+ </connectionPointOut>
+ <expression>'")'</expression>
+ </inVariable>
+ </FBD>
+ </body>
+ </pou>
</pous>
</types>
<instances>