IDE: Optimization of modification events processing in text editors.
Too many modifications types where registered, and then too many events were fired.
Also, in case of uninterrupted sequence of events, updates to the model is deferred to the end of that sequence (wx.Callafter).
// 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);
}
}
||