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="size" accepts="int" > buffer size
arg name="xformat" count="optional" accepts="string" > format string for X label
arg name="yformat" count="optional" accepts="string" > format string for Y label
arg name="ymin" count="optional" accepts="int,real" > minimum value foe Y axis
arg name="ymax" count="optional" accepts="int,real" > maximum value for Y axis
widget_class("XYGraph") {
this.x_format, this.y_format] = this.args;
// Min and Max given with paths are meant to describe visible range,
let y_min = -Infinity, y_max = Infinity;
// Compute visible Y range by merging fixed curves Y ranges
for(let minmax of this.minmaxes){
if(y_min !== -Infinity && y_max !== Infinity){
this.fixed_y_range = true;
this.fixed_y_range = false;
this.reference = new ReferenceFrame(
[[this.x_interval_minor_mark, this.x_interval_major_mark],
[this.y_interval_minor_mark, this.y_interval_major_mark]],
[this.y_axis_label, this.x_axis_label],
[this.x_axis_line, this.y_axis_line],
[this.x_format, this.y_format]);
// create <clipPath> path and attach it to widget
clipPath = document.createElementNS(xmlns,"clipPath");
clipPathPath = document.createElementNS(xmlns,"path");
clipPathPathDattr = document.createAttribute("d");
clipPathPathDattr.value = this.reference.getClipPathPathDattr();
clipPathPath.setAttributeNode(clipPathPathDattr);
clipPath.appendChild(clipPathPath);
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.max_data_length = this.args[0];
dispatch(value,oldval, index) {
// naive local buffer impl.
// data is updated only when graph is visible
// TODO: replace with separate recording
this.curves_data[index].push(value);
let data_length = this.curves_data[index].length;
let ymin_damaged = false;
let ymax_damaged = false;
if(data_length > this.max_data_length){
overflow = this.curves_data[index].shift();
data_length = data_length - 1;
ymin_damaged = overflow <= this.ymin;
ymax_damaged = overflow >= this.ymax;
// recompute X range based on curent time ad buffer depth
// TODO: get PLC time instead of browser time
// FIXME: this becomes wrong when graph is not visible and updated all the time
[this.xmin, this.xmax] = [time - data_length*1000/this.frequency, time];
let Xlength = this.xmax - this.xmin;
// 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();
zip(this.curves_data, this.curves).map(function([data,curve]){
let new_d = data.map(function([y, i]){
// compute curve point from data, ranges, and base_ref
let x = Xmin + i * Xlength / data_length;
let xv = vectorscale(xvect, (x - Xmin) / Xlength);
let yv = vectorscale(yvect, (y - Ymin) / Ylength);
let px = base_point.x + xv.x + yv.x;
let py = base_point.y + xv.y + yv.y;
if(ymin_damaged && y > this.ymin) this.ymin = y;
if(xmin_damaged && x > this.xmin) this.xmin = x;
return " " + px + "," + py;
// computed curves "d" attr is applied to svg curve during animate();
// move marks and update labels
this.reference.applyRanges([this.XRange, this.YRange]);
// apply computed curves "d" attributes
for(let [curve, d_attr] of zip(this.curves, this.curves_d_attr)){
curve.setAttribute("d", d_attr);
// collect all curve_n labelled children
foreach "$hmi_element/*[regexp:test(@inkscape:label,'^curve_[0-9]+$')]" {
const "label","@inkscape:label";
// detect non-unique names
if "$hmi_element/*[not($id = @id) and @inkscape:label=$label]"{
error > XYGraph id="«$id»", label="«$label»" : elements with data_n label must be unique.
| this.curves[«substring(@inkscape:label, 7)»] = 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 move_elements_to_group(elements) {
let newgroup = document.createElementNS(xmlns,"g");
for(let element of elements){
element.parentElement().removeChild(element);
newgroup.appendChild(element);
function getLinesIntesection(l1, l2) {
Compute intersection of two lines
=================================
l1start ----------X--------------> l1vect
intersection = l1start + l1vect * a
intersection = l2start + l2vect * b
==> solve : "l1start + l1vect * a = l2start + l2vect * b" to find a and b and then intersection
(1) l1start.x + l1vect.x * a = l2start.x + l2vect.x * b
(2) l1start.y + l1vect.y * a = l2start.y + l2vect.y * b
(1) a = (l2start.x + l2vect.x * b) / (l1start.x + l1vect.x)
// substitute a to have only b
(1+2) l1start.y + l1vect.y * (l2start.x + l2vect.x * b) / (l1start.x + l1vect.x) = l2start.y + l2vect.y * b
(2) l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) + (l1vect.y * l2vect.x * b) / (l1start.x + l1vect.x) = l2start.y + l2vect.y * b
(2) l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) - l2start.y = l2vect.y * b - (l1vect.y * l2vect.x * b) / (l1start.x + l1vect.x)
(2) l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) - l2start.y = b * (l2vect.y - (l1vect.y * l2vect.x ) / (l1start.x + l1vect.x))
(2) b = (l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) - l2start.y) / ((l2vect.y - (l1vect.y * l2vect.x ) / (l1start.x + l1vect.x)))
let [l1start, l1vect] = l1;
let [l1start, l2vect] = l2;
let b = (l1start.y + (l1vect.y * l2start.x) / (l1start.x + l1vect.x) - l2start.y) / ((l2vect.y - (l1vect.y * l2vect.x ) / (l1start.x + l1vect.x)));
return new DOMPoint(l2start.x + l2vect.x * b, l2start.y + l2vect.y * b);
// From https://stackoverflow.com/a/48293566
function *zip (...iterables){
let iterators = iterables.map(i => i[Symbol.iterator]() )
let results = iterators.map(iter => iter.next() )
if (results.some(res => res.done) ) return
else yield results.map(res => res.value )
// [[Xminor,Xmajor], [Yminor,Ymajor]]
// [Xformat, Yformat] printf-like formating strings
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
"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];
for(let axis of this.axes){
axis.setBasePoint(base_point);
return this.clipPathPathDattr;
for(let [range,axis] of zip(ranges,this.axes)){
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.
xstart /---------/--------------> given X axis
/---------/--------------
base_point = xstart + yvect * a
base_point = ystart + xvect * b
==> solve : "xstart + yvect * a = ystart + xvect * b" to find a and b and then base_point
(1) xstart.x + yvect.x * a = ystart.x + xvect.x * b
(2) xstart.y + yvect.y * a = ystart.y + xvect.y * b
(1) a = (ystart.x + xvect.x * b) / (xstart.x + yvect.x)
// substitute a to have only b
(1+2) xstart.y + yvect.y * (ystart.x + xvect.x * b) / (xstart.x + yvect.x) = ystart.y + xvect.y * b
(2) xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) + (yvect.y * xvect.x * b) / (xstart.x + yvect.x) = ystart.y + xvect.y * b
(2) xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) - ystart.y = xvect.y * b - (yvect.y * xvect.x * b) / (xstart.x + yvect.x)
(2) xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) - ystart.y = b * (xvect.y - (yvect.y * xvect.x ) / (xstart.x + yvect.x))
(2) b = (xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) - ystart.y) / ((xvect.y - (yvect.y * xvect.x ) / (xstart.x + yvect.x)))
let b = (xstart.y + (yvect.y * ystart.x) / (xstart.x + yvect.x) - ystart.y) / ((xvect.y - (yvect.y * xvect.x ) / (xstart.x + yvect.x)));
let base_point = new DOMPoint(ystart.x + xvect.x * b, ystart.y + xvect.y * b);
// // compute given origin
// // from drawing : given_origin = xstart - xvect * b
// let given_origin = new DOMPoint(xstart.x - xvect.x * b, xstart.y - xvect.y * b);
constructor(label, marks, line, format){
this.line = lineFromPath(line);
// 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.appendItem(transform);
this[elementname+"_"+name+"_transform"]=transform;
// group marks an labels together
let parent = line.parentElement()
marks_group = move_elements_to_group(marks);
marks_and_label_group = move_elements_to_group([marks_group_use, label]);
group = move_elements_to_group([marks_and_label_group,line]);
parent.appendChild(group);
// Add transforms to group
for(let name of ["base","origin"]){
let transform = svg_root.createSVGTransform();
group.transform.baseVal.appendItem(transform);
this[name+"_transform"]=transform;
this.marks_group = marks_group;
this.marks_and_label_group = marks_and_label_group;
this.last_mark_count = 0;
setBasePoint(base_point){
// move Axis to base point
let [start, _vect] = this.lineElement;
let v = vector(start, base_point);
this.base_transform.setTranslate(v.x, v.y);
// Move marks and label to base point.
for(let [markname,mark] of zip(["minor", "major"],this.marks)){
let transform = this[markname+"_base_transform"];
// Marks are expected to be paths
// paths are expected to be lines
// intersection with axis line is taken
// as reference for mark position
base_point, getLinesIntesection(
this.line, lineFromPath(mark)));
this[markname+"_base_transform"].setTranslate(-pos.x, -pos.x);
if(markname == "major"){ // label follow major mark
this.label_base_transform.setTranslate(-pos.x, -pos.x);
applyOriginAndUnitVector(offset, unit_vect){
// offset is a representing position of an
// axis along the opposit axis line, expressed in major marks units
// unit_vect is the translation in between to major marks
// move major marks and label to first positive mark position
let v = vectorscale(unit_vect, offset+1);
this.label_slide_transform.setTranslate(v.x, v.x);
this.major_slide_transform.setTranslate(v.x, v.x);
// move minor mark to first half positive mark position
let h = vectorscale(unit_vect, offset+0.5);
this.minor_slide_transform.setTranslate(h.x, h.x);
// 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 5 and 50,
// and log10(5) is substracted to order of magnitude to obtain this
let unit = Math.pow(10, Math.floor(Math.log10(range)-0.69897));
// TODO: for time values (ms), units may be :
// Compute position of origin along axis [0...range]
// min < 0, max > 0, offset = -min
// _____________|________________
// ... -3 -2 -1 |0 1 2 3 4 ...
// min > 0, max > 0, offset = 0
// min < 0, max < 0, offset = max-min (range)
let offset = (max>=0 && min>=0) ? 0 : (
(max<0 && min<0) ? range : -min);
let [_start, vect] = this.line;
let unit_vect = vectorscale(vect, unit/range);
let [umin, umax, uoffset] = [min,max,offset].map(val => Math.round(val/unit));
let mark_count = umax-umin;
// apply unit vector to marks and label
this.label_and_marks.applyOriginAndUnitVector(offset, unit_vect);
// duplicate marks and labels as needed
let current_mark_count = this.mlg_clones.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 = cloneNode(this.label);
let newuse = document.createElementNS(xmlns,"use");
let newuseAttr = document.createAttribute("xlink:href");
newuseAttr.value = "#"+this.marks_group.id;
newuse.setAttributeNode(newuseAttr.value);
newgroup.transform.baseVal.appendItem(transform);
newgroup.appendChild(newlabel);
newgroup.appendChild(newuse);
this.mlg_clones.push([tranform,newgroup]);
// move marks and labels, set labels
for(let u = 0; u <= mark_count; u++){
let val = (umin + u) * unit;
let vec = vectorscale(unit_vect, offset + val);
let text = this.format ? sprintf(this.format, val) : val.toString();
// apply offset to original marks and label groups
this.origin_transform.setTranslate(vec.x, vec.x);
// update original label text
this.label_and_mark.label.textContent = text;
let [transform,element] = this.mlg_clones[i++];
// apply unit vector*N to marks and label groups
transform.setTranslate(vec.x, vec.x);
element.getElementsByTagName("tspan")[0].textContent = text;
// Attach to group if not already
if(i >= this.last_mark_count){
this.group.appendChild(element);
// dettach marks and label from group if not anymore visible
for(let i = current_mark_count; i < this.last_mark_count; i++){
let [transform,element] = this.mlg_clones[i];
this.group.removeChild(element);
this.last_mark_count = current_mark_count;