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="xrange" accepts="int,time" > X axis range expressed either in samples or duration.
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("XYGraph") {
this.x_format, this.y_format] = this.args;
let timeunit = x_duration_s.slice(-1);
this.max_data_length = Number(x_duration_s);
this.x_duration = undefined;
let duration = factor*Number(x_duration_s.slice(0,-1));
this.max_data_length = undefined;
this.x_duration = duration*1000;
// 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 varopts of this.variables_options){
let minmax = varopts.minmax
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_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.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 + ")");
dispatch(value,oldval, index) {
// TODO: get PLC time instead of browser time
// naive local buffer impl.
// data is updated only when graph is visible
// TODO: replace with separate recording
if(this.curves_data[index] === undefined){
this.curves_data[index] = [];
this.curves_data[index].push([time, value]);
let data_length = this.curves_data[index].length;
let ymin_damaged = false;
let ymax_damaged = false;
if(this.max_data_length == undefined){
let peremption = time - this.x_duration;
let oldest = this.curves_data[index][0][0]
overflow = this.curves_data[index].shift()[1];
data_length = data_length - 1;
if(data_length > this.max_data_length){
[this.xmin, overflow] = this.curves_data[index].shift();
data_length = data_length - 1;
if(this.xmin == undefined){
let Xrange = this.xmax - this.xmin;
ymin_damaged = overflow <= this.ymin;
ymax_damaged = overflow >= this.ymax;
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
// 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(([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;
// update min and max from curve data if needed
if(ymin_damaged && y < this.ymin) this.ymin = y;
if(ymax_damaged && y > this.ymax) this.ymax = y;
return " " + px + "," + py;
// computed curves "d" attr is applied to svg curve during animate();
// move elements only if enough data
if(this.curves_data.some(data => data.length > 1)){
// move marks and update labels
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);
def "func:check_curves_label_consistency" {
when "$curve_elts[@inkscape:label = concat('curve_', string($number_to_check))]"{
if "$number_to_check > 0"{
value "func:check_curves_label_consistency($curve_elts, $number_to_check - 1)";
value "concat('missing curve_', string($number_to_check))";
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 > XYGraph 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» */
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 vectorLength(p1) {
return Math.sqrt(p1.x*p1.x + p1.y*p1.y);
return Date.now().toString(36) + Math.random().toString(36).substr(2);
function move_elements_to_group(elements) {
let newgroup = document.createElementNS(xmlns,"g");
newgroup.id = randomId();
for(let element of elements){
let parent = element.parentElement;
parent.removeChild(element);
newgroup.appendChild(element);
function getLinesIntesection(l1, l2) {
let [l1start, l1vect] = l1;
let [l2start, l2vect] = l2;
Compute intersection of two lines
=================================
l1start ----------X--------------> l1vect
let [x1, y1, x3, y3] = [l1start.x, l1start.y, l2start.x, l2start.y];
let [x2, y2, x4, y4] = [x1+l1vect.x, y1+l1vect.y, x3+l2vect.x, y3+l2vect.y];
// line intercept math by Paul Bourke http://paulbourke.net/geometry/pointlineplane/
// Determine the intersection point of two line segments
// Return FALSE if the lines don't intersect
// Check if none of the lines are of length 0
if ((x1 === x2 && y1 === y2) || (x3 === x4 && y3 === y4)) {
denominator = ((y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1))
let ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator
let ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denominator
// Return a object with the x and y coordinates of the intersection
let x = x1 + ua * (x2 - x1)
let y = y1 + ua * (y2 - y1)
return new DOMPoint(x,y);
// [[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];
this.lengths = [xvect,yvect].map(v => vectorLength(v));
for(let axis of this.axes){
axis.setBasePoint(base_point);
return this.clipPathPathDattr;
let origin_moves = zip(ranges,this.axes).map(([range,axis]) => axis.applyRange(...range));
zip(origin_moves.reverse(),this.axes).forEach(([vect,axis]) => axis.moveOrigin(vect));
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 (xvect)
*---------*--------------
let base_point = getLinesIntesection([xstart,yvect],[ystart,xvect]);
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.insertItemBefore(transform,0);
this[elementname+"_"+name+"_transform"]=transform;
// group marks an labels together
let parent = line.parentElement;
this.marks_group = move_elements_to_group(marks);
this.marks_and_label_group = move_elements_to_group([this.marks_group, label]);
this.group = move_elements_to_group([this.marks_and_label_group,line]);
parent.appendChild(this.group);
// Add transforms to group
for(let name of ["base","origin"]){
let transform = svg_root.createSVGTransform();
this.group.transform.baseVal.appendItem(transform);
this[name+"_transform"]=transform;
this.marks_and_label_group_transform = svg_root.createSVGTransform();
this.marks_and_label_group.transform.baseVal.appendItem(this.marks_and_label_group_transform);
this.last_duplicate_index = 0;
setBasePoint(base_point){
// move Axis to base point
let [start, _vect] = this.line;
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)){
// Marks are expected to be paths
// paths are expected to be lines
// intersection with axis line is taken
// as reference for mark position
this.line, lineFromPath(mark)),base_point);
this[markname+"_base_transform"].setTranslate(pos.x - v.x, pos.y - v.y);
if(markname == "major"){ // label follow major mark
this.label_base_transform.setTranslate(pos.x - v.x, pos.y - v.y);
this.origin_transform.setTranslate(vect.x, vect.y);
// 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 3 and 30,
// and log10(3) is substracted to order of magnitude to obtain this
let unit = Math.pow(10, Math.floor(Math.log10(range)-Math.log10(3)));
// 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, 1/range);
let [mark_min, mark_max, mark_offset] = [min,max,offset].map(val => Math.round(val/unit));
let mark_count = mark_max-mark_min;
// apply unit vector to marks and label
// offset is a representing position of an
// axis along the opposit axis line, expressed in major marks units
// unit_vect is unit vector
// move major marks and label to first positive mark position
// let v = vectorscale(unit_vect, unit);
// this.label_slide_transform.setTranslate(v.x, v.y);
// this.major_slide_transform.setTranslate(v.x, v.y);
// move minor mark to first half positive mark position
let v = vectorscale(unit_vect, unit/2);
this.minor_slide_transform.setTranslate(v.x, v.y);
// duplicate marks and labels as needed
let current_mark_count = this.duplicates.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 = this.label.cloneNode(true);
let newuse = document.createElementNS(xmlns,"use");
let newuseAttr = document.createAttribute("href");
newuseAttr.value = "#"+this.marks_group.id;
newuse.setAttributeNode(newuseAttr);
newgroup.transform.baseVal.appendItem(transform);
newgroup.appendChild(newlabel);
newgroup.appendChild(newuse);
this.duplicates.push([transform,newgroup]);
// move marks and labels, set labels
// min > 0, max > 0, offset = 0
// min < 0, max > 0, offset = -min
// min < 0, max < 0, offset = range
for(let mark_index = 0; mark_index <= mark_count; mark_index++){
let val = (mark_min + mark_index) * unit;
let vec = vectorscale(unit_vect, val - min);
let text = this.format ? sprintf(this.format, val) : val.toString();
if(mark_index == mark_offset){
// apply offset to original marks and label groups
this.marks_and_label_group_transform.setTranslate(vec.x, vec.y);
// update original label text
this.label.getElementsByTagName("tspan")[0].textContent = text;
let [transform,element] = this.duplicates[duplicate_index++];
// apply unit vector*N to marks and label groups
transform.setTranslate(vec.x, vec.y);
element.getElementsByTagName("tspan")[0].textContent = text;
// Attach to group if not already
if(element.parentElement == null){
this.group.appendChild(element);
let save_duplicate_index = duplicate_index;
// dettach marks and label from group if not anymore visible
for(;duplicate_index < this.last_duplicate_index; duplicate_index++){
let [transform,element] = this.duplicates[duplicate_index];
this.group.removeChild(element);
this.last_duplicate_index = save_duplicate_index;
return vectorscale(unit_vect, offset);