// widget_xygraph.ysl2
widget_desc("XYGraph") {
longdesc
||
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") {
||
frequency = 1;
init() {
let x_duration_s;
[x_duration_s,
this.x_format, this.y_format] = this.args;
let timeunit = x_duration_s.slice(-1);
let factor = {
"s":1,
"m":60,
"h":3600,
"d":86400}[timeunit];
if(factor == undefined){
this.max_data_length = Number(x_duration_s);
this.x_duration = undefined;
}else{
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,
// not to clip data.
this.clip = false;
let y_min = Infinity, y_max = -Infinity;
// Compute visible Y range by merging fixed curves Y ranges
for(let minmax of this.minmaxes){
if(minmax){
let [min,max] = minmax;
if(min < y_min)
y_min = min;
if(max > y_max)
y_max = max;
}
}
if(y_min !== Infinity && y_max !== -Infinity){
this.fixed_y_range = true;
} else {
this.fixed_y_range = false;
}
this.ymin = y_min;
this.ymax = y_max;
this.curves = [];
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.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 + ")");
}
this.curves_data = [];
}
dispatch(value,oldval, index) {
// TODO: get PLC time instead of browser time
let time = Date.now();
// 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;
let overflow;
if(this.max_data_length == undefined){
let peremption = time - this.x_duration;
let oldest = this.curves_data[index][0][0]
this.xmin = peremption;
if(oldest < peremption){
// remove first item
overflow = this.curves_data[index].shift()[1];
data_length = data_length - 1;
}
} else {
if(data_length > this.max_data_length){
// remove first item
[this.xmin, overflow] = this.curves_data[index].shift();
data_length = data_length - 1;
} else {
if(this.xmin == undefined){
this.xmin = time;
}
}
}
this.xmax = time;
let Xrange = this.xmax - this.xmin;
if(!this.fixed_y_range){
ymin_damaged = overflow <= this.ymin;
ymax_damaged = overflow >= this.ymax;
if(value > this.ymax){
ymax_damaged = false;
this.ymax = value;
}
if(value < this.ymin){
ymin_damaged = false;
this.ymin = value;
}
}
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
// 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();
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;
if(!this.fixed_y_range){
// 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;
});
new_d.unshift("M ");
return new_d.join('');
});
// computed curves "d" attr is applied to svg curve during animate();
this.request_animate();
}
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" {
param "curve_elts";
param "number_to_check";
const "res" choose {
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)";
}
}
otherwise {
value "concat('missing curve_', string($number_to_check))";
}
}
result "$res";
}
widget_defs("XYGraph") {
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 > XYGraph 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» */
}
| }
}
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);
};
function randomId(){
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;
if(parent !== null)
parent.removeChild(element);
newgroup.appendChild(element);
}
return newgroup;
}
function getLinesIntesection(l1, l2) {
let [l1start, l1vect] = l1;
let [l2start, l2vect] = l2;
/*
Compute intersection of two lines
=================================
^ l2vect
/
/
/
l1start ----------X--------------> l1vect
/ intersection
/
/
l2start
*/
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)) {
return false
}
denominator = ((y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1))
// Lines are parallel
if (denominator === 0) {
return false
}
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);
};
class ReferenceFrame {
constructor(
// [[Xminor,Xmajor], [Yminor,Ymajor]]
marks,
// [Xlabel, Ylabel]
labels,
// [Xline, Yline]
lines,
// [Xformat, Yformat] printf-like formating strings
formats
){
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
this.clipPathPathDattr =
"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);
}
}
getLengths(){
return this.lengths;
}
getBaseRef(){
return this.base_ref;
}
getClipPathPathDattr(){
return this.clipPathPathDattr;
}
applyRanges(ranges){
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));
}
getBasePoint() {
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.
^ given Y axis (yvect)
/ /
/ /
/ /
xstart *---------*--------------> given X axis (xvect)
/ /origin
/ /
*---------*--------------
base_point ystart
*/
let base_point = getLinesIntesection([xstart,yvect],[ystart,xvect]);
return base_point;
}
}
class Axis {
constructor(label, marks, line, format){
this.lineElement = line;
this.line = lineFromPath(line);
this.format = format;
this.label = label;
this.marks = marks;
// 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.duplicates = [];
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.
// _|_______ _|________
// | ' | ==> '
// | 0 0
// | |
for(let [markname,mark] of zip(["minor", "major"],this.marks)){
let pos = vector(
// Marks are expected to be paths
// paths are expected to be lines
// intersection with axis line is taken
// as reference for mark position
getLinesIntesection(
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);
}
}
}
moveOrigin(vect){
this.origin_transform.setTranslate(vect.x, vect.y);
}
applyRange(min, max){
let range = max - min;
// 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 :
// 1 -> ms
// 10 -> s/100
// 100 -> s/10
// 1000 -> s
// 60000 -> min
// 3600000 -> hour
// ...
//
// Compute position of origin along axis [0...range]
// min < 0, max > 0, offset = -min
// _____________|________________
// ... -3 -2 -1 |0 1 2 3 4 ...
// <--offset---> ^
// |_original
// min > 0, max > 0, offset = 0
// |________________
// |6 7 8 9 10...
// ^
// |_original
// min < 0, max < 0, offset = max-min (range)
// _____________|_
// ... -5 -4 -3 |-2
// <--offset---> ^
// |_original
let offset = (max>=0 && min>=0) ? 0 : (
(max<0 && min<0) ? range : -min);
// compute unit vector
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
// ^
// | unit_vect
// |<--->
// _________|__________>
// ^ | ' | ' | '
// |yoffset | 1
// | |
// v xoffset|
// X<------>|
// base_point
// 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
// ^
// |________>
// '| | ' |
// | 6 7
// X
// base_point
//
// min < 0, max > 0, offset = -min
// ^
// _________|__________>
// ' | ' | ' | '
// -1 | 1
// offset |
// X<------>|
// base_point
//
// min < 0, max < 0, offset = range
// ^
// ____________|
// ' | ' | |'
// -5 -4 |
// offset |
// X<--------->|
// base_point
let duplicate_index = 0;
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;
} else {
let [transform,element] = this.duplicates[duplicate_index++];
// apply unit vector*N to marks and label groups
transform.setTranslate(vec.x, vec.y);
// update label text
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);
}
}
||