// widget_historyxygraph.ysl2
widget_desc("HistoryXYGraph") {
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") {
this.params = [null, null];
[this.x_format, this.y_format] = this.args;
this.fetch_error_bound = this.fetch_error.bind(this);
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.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 + ")");
console.log("HTTP fetch error, message = " + e.message + "Widget:" + this.element_id);
this.abort_controller = new AbortController();
const decoder = new TextDecoder();
startTime: Date.parse(this.params[0]),
endTime: Date.parse(this.params[1]),
variableNames: this.params.slice(2)
body: JSON.stringify(query),
headers: { 'Content-Type': 'application/json' },
signal: this.abort_controller.signal
return fetch('/history', options)
const reader = response.body.getReader();
return reader.read().then(({ value, done }) => {
const chunk = decoder.decode(value, { stream: true });
const lines = (partialChunk + chunk).split(String.fromCharCode(10));
partialChunk = lines.pop();
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;
}).catch(this.fetch_error_bound);
if (this.abort_controller) {
this.abort_controller.abort();
dispatch(value, oldval, index) {
this.params[index] = value;
if (this.params.every((item) => item !== null)) {
for (let curve of this.curves) {
this.curves_data.push([]);
this.do_http_request().finally(() => {
for (let i = 0; i < this.curves.length; i++) {
const dataLength = this.curves_data[i].length;
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;
this.xmin = Date.parse(this.params[0]);
this.xmax = Date.parse(this.params[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]];
// recompute curves "d" attribute
let [base_point, xvect, yvect] = this.reference.getBaseRef();
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;
// computed curves "d" attr is applied to svg curve during 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)) {
curve.setAttribute("d", d_attr);
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");
// 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»
const "label","@inkscape:label";
const "curve_num", "substring(@inkscape:label, 7)";
| this.curves[«$curve_num»] = id("«@id»"); /* «@inkscape:label» */