Source file: /~heha/ewa/Motor/cdesk.zip/js/app-plot.js

"use strict"

function mySetTimeout(callback, delay /*, ... */) {
  const self = this, aArgs = Array.prototype.slice.call(arguments, 2);
  return setTimeout(function () {callback.apply(self, aArgs);},delay);
};

function roundWithMethod(x,method,dir=1) {
  if (!x) return 0;
  if (!isFinite(x)) throw new Error("Nee!");
  if (x<0) return -Round(-x,-dir,method);
  let base=10;
  let points=[1];	// ganze Zahlen
  switch (method) {
    case "13": points=[1,3]; break;
    case "125": points=[1,2,5]; break;
    case "60": base=60; points=[1,2,5,10,30]; break;
    case "E12": points=[1,1.2,1.5,1.8,2.2,2.7,3.3,3.9,4.7,5.6,6.8,8.2]; break;
    case "2": base=2; break;
    case "pi": base=Math.PI; break;
  }
  points.push(base);	// hintenan für Algorithmusvereinfachung
  const fl=Math.floor(Math.log(x)/Math.log(base));
  const fb=Math.pow(base,fl);
  let y=x/fb;	// nun im Bereich 1..9,999
  switch (dir) {
    case 1: y=points.find(p => y<=p); break;		// aufrunden
    case -1: y=points.reverse.find(p => y>=p); break;	// abrunden
  }
  return y*fb;	// Kommastelle setzen
}

function roundToMultiple(x,mult) {	// <mult> negativ: abrunden,
  return Math.ceil(x/mult)*mult;	// <mult> positiv: aufrunden
}
function roundToLog(x,base) {	// base positiv: aufrunden, negativ: abrunden
  let cf=Math.ceil;
  if (base<0) {base=-base; cf=Math.floor;}
  return Math.pow(base,cf(Math.log(x)/Math.log(base)));
}
 
class Range{
  constructor(a,e) {this.init(a,e);Object.seal(this);}
  init(a,e) {
    if (a===undefined) return this.reset();
    if (a instanceof Range) return this.init(a.a,a.e);
    this.a=a; this.e=e;
  }
  expand(a,e) {
    if (a instanceof Range) return this.expand(a.a,a.e);
    if (e===undefined) e=a;
    let r=false;
    if (isFinite(a) && this.a>a) {this.a=a; r=true;}	// niemals auf ±Inf setzen
    if (isFinite(e) && this.e<e) {this.e=e; r=true;}
    return r;
  }
  reset() {this.init(Infinity,-Infinity);}
  get delta() {return this.e-this.a;}
  get regular() {return this.delta>=0;}
  get hasSpan() {return this.delta>0;}
  equals(a,e) {
    if (a instanceof Range) return this.equals(a.a,a.e);
    return this.a==a && this.e==e;
  }
}

class Tick{
  constructor(p,t) {
    this.init(p,t);
  }
  init(p,t) {
    this.p=p;			// Position
    this.t=t===undefined?numberToPrec(p).replace('.',',')
         :t instanceof Function?t(p)
	 :t;	// Text (an der Position)
  }
}

class DisplayRange {
  constructor(axis){
    this.a=0;
    this.e=0;		// analog: Bei init() true liefern lassen
    if (axis.isDigital) this.e=1;	// Voreinstellung digital 0..1
    this.axis=axis;	// TODO: Logarithmisch erfordert andere Einteilung
    Object.seal(this);
  }
  init(a,e) {
    if (a===undefined) return this.reset();
    if (a instanceof Range) return this.init(a.a,a.e);
    if (!isFinite(a) || !isFinite(e)) return this.reset();
    if (!(e>a)) e = this.axis.isLog ? a*10 : a+10;	// Immer gültigen Bereich annehmen
    const größer = a<this.a || this.e<e;
    this.a=a; this.e=e;
    return größer;
  } 
  reset() {this.init(this.axis.isLog?0.1:0,10);}	// Hier niemals Unendlich-Werte!
  genLinTicks(steps,auflös=0) {
    let dx=(this.e-this.a)/steps;
    if (dx<auflös) dx=auflös;
    dx=roundWithMethod(dx,this.axis.autotickmethod);
    let first;
    if (this.axis.autoroundends) {
      first=this.a=roundToMultiple(this.a,-dx);
      this.e=roundToMultiple(this.e,dx);
    }else{	// Im Fall der Zeitachse (für Historie) Grenzen nicht erweitern
		// dadurch „rollt“ die Skale bei voller Historie gleichmäßig
      first=roundToMultiple(this.a,dx);
    }
    const ticks=[];
    for (let p=first; p<=this.e; p+=dx) ticks.push(new Tick(p,this.axis.autotickformat));
    return ticks;
  }
  genLogTicks(steps) {
    let dx=Math.log10(this.e/this.a);
    dx=Math.floor(dx/steps);		// 10 oder 100 oder 1000 ?
    if (dx<1) dx=1;		// mindestens 10
    dx=Math.pow(10,dx);
    let p;
    if (this.axis.autoroundends) {
      p=this.a=roundToLog(this.a,-10);
    }else{
      p=roundToLog(this.a,10);
    }
    const ticks=[];
    for(;;p*=dx){
      if (!this.axis.autoroundends && p>this.e) break;
      ticks.push(new Tick(p,this.axis.autotickformat));
      if (p>=this.e) break;
    }
    if (this.axis.autoroundends) this.e=p;
//    console.log(ticks);
    return ticks;
  }
// Den Bereich trotz Autoscale so festlegen, dass bei bekannter Auflösung
// (des A/D-Wandlers) nicht das Quantisierungsrauschen den bestimmenden Teil ausmacht,
// d.h. das Quantisierungsrauschen unter bspw. 10 Pixel zu drücken
  usefulRange(minimumOfTicks,auflös=0) {
    if (!(auflös>0)) return;	// Ohne gegebene Auflösung nichts zu machen
    if (!(minimumOfTicks>2)) return;
    const l=minimumOfTicks*auflös;
    if (this.e-this.a>=l) return;
// Zu wenig dargestellter Bereich:
// Bei unterschiedlichen Vorzeichen von this.a und this.e um die Mitte herum
    if (this.a<0 && this.e>0) {
      if (Math.max(-this.a,this.e)<=l/2) {
        this.a=-l/2; this.e=l/2;
      }else if (this.e>-this.a) this.a=this.e-l;
      else this.e=this.a+l;
    }else if (this.e<=0) {	// Bei gleichen Vorzeichen zur Null hin
      if (this.a<-l) this.e=this.a+l;	// bleibt negativ;
      else {this.a=-l; this.e=0;}
    }else if (this.e>l) this.a=this.e-l;
    else{
      this.a=0; this.e=l;	// häufigster Fall!
    }
    const e=this.a+minimumOfTicks*auflös;
    if (this.e<e) this.e=e;
  }
}

class Margin{
  constructor(l,t,r,b) {this.init(l,t,r,b);/*Object.seal(this);*/}
  init(l,t,r,b) {
    if (l===undefined) return this.init(0,0,0,0);
    if (l instanceof Margin) return this.init(l.l,l.t,l.r,l.b);
    this.l=l;this.t=t;this.r=r;this.b=b;
  }
  expand(l,t,r,b) {
    if (l instanceof Margin) return this.expand(l.l,l.t,l.r,l.b);
    if (this.l<l) this.l=l;
    if (this.t<t) this.t=t;
    if (this.r<r) this.r=r;
    if (this.b<b) this.b=b;
  }
  get w() {return this.l+this.r;}	// Gesamtrand horizontal
  get h() {return this.t+this.b;}	// Gesamtrand vertikal
  byIndex(i) {switch(i) {
    case 0: return this.l;
    case 1: return this.t;
    case 2: return this.r;
    case 3: return this.b;
    case 4: return this.w;
    case 5: return this.h;
    default: throw new RangeError();}
  }
  addTo(i,v) {switch(i) {
    case 0: return this.l+=v;
    case 1: return this.t+=v; 
    case 2: return this.r+=v;
    case 3: return this.b+=v;
    default: throw new RangeError();}
  }
}

class Rect{
  constructor(l,t,r,b) {this.init(l,t,r,b);Object.seal(this);}
  init(l,t,r,b) {
    if (l===undefined) return this.init(0,0,0,0);
    if (l instanceof Rect) return this.init(l.l,l.t,l.r,l.b);
    this.l=l;this.t=t;this.r=r;this.b=b;}
  get w() {return this.r-this.l;}
  get h() {return this.b-this.t;}
  set w(w) {return this.r=this.l+w;}	// verändert nur rechte Seite!!
  set h(h) {return this.b=this.t+h;}
  get cx() {return (this.r+this.l)/2;}
  get cy() {return (this.t+this.b)/2;}
  set cx(x) {this.offset(x-this.cx,0);}
  set cy(y) {this.offset(0,y-this.cy);}
  move(l,t,r,b) {this.l+=l;this.t+=t;this.r+=r;this.b+=b;}
  offset(x,y) {this.move(x,y,x,y);}
  byIndex(i) {switch(i){
	case 0:return this.l;
	case 1:return this.t;
	case 2:return this.r;
	case 3:return this.b;
	case 4:return this.w;
	case 5:return this.h;
	case 6:return -this.w;
	case 7:return -this.h;
	case 8:return this.cx;
	case 9:return this.cy;
	default:throw new RangeError();}
  }
  deflate(l,t,r,b) {
    if (l instanceof Margin) return this.move(l.l,l.t,-l.r,-l.b);
    if (r===undefined) r=l;
    if (b===undefined) b=t;
    this.move(l,t,-r,-b);
  }
  inflate(l,t,r,b) {
    if (l instanceof Margin) return this.move(-l.l,-l.t,l.r,l.b);
    if (r===undefined) r=l;
    if (b===undefined) b=t;
    this.move(-l,-t,r,b);
  }
}

class NoScale{
  scale(x) {return x;}
  unscale(x) {return x;}
  init(s,o) {if (s!==1 || o!==0) throw new Error("Falsche Skale!");}
  make(x,y,X,Y) {if (x!=X || y!=Y) throw new Error("Falsche Skale!");}
}

class LinScale extends NoScale{
  constructor(s=1,o=0) {super(); this.init(s,o); Object.seal(this);}
  init(s=1,o=0) {this.s=s; this.o=o;}
  make(x,y,X,Y) {Y-=X; y-=x; Y/=y; this.init(Y,X-Y*x);}	// Zweipunktkalibrierung
  scale(x) {return x*this.s+this.o;}
  unscale(X) {return (X-this.o)/this.s;}
}	// Kleinbuchstaben: Skalenwerte, Großbuchstaben: Bildschirmkordinaten (Pixel)

class LogScale extends NoScale{
  constructor(s=1,o=0) {super(); this.init(s,o); Object.seal(this);}
  init(s=1,o=0) {this.s=s; this.o=o;}
  make(x,y,X,Y) {
// Y-X = s*(log(y)-log(x)) = s*log(y/x), d.h. s = (Y-X) / log(y-x)
    if (x*y<=0) throw "Falsche Skale!";
    Y-=X; y/=x;	// Y = Differenz, y = Verhältnis (stets positiv)
    y=Math.log(y);
    Y/=y;
    this.init(Y,X-Y*Math.log(x));
  }
  scale(x) {return Math.log(x)*this.s+this.o;}
  unscale(X) {return Math.exp((X-this.o)/this.s);}
}

function toUnitPrefix(x) {
  let b=Math.floor(Math.log(x)/Math.log(1000));
  x/=Math.pow(1000,b);
  return numberToPrec(x) + "afpnµm kMGPTE".charAt(b+6);
}

// side-Bits:	0 (als X-Achse)	1 (als X-Achse)	0 (als Y-Achse)	1 (als Y-Achse)
// 0 (X/Y)	X-Achse		-		-		Y-Achse
// 1 (Seite)	oben		unten		links		rechts
// 2 (Richtung)	v.l.n.r		v.r.n.l		v.u.n.o		v.u.n.o
// 3 (Teilung)	linear		logarithmisch	linear		logarithmisch
// 4 (Extra)	normal		Zeit		normal		digital
// 5 (Stapel)	-		-		hintereinander	untereinander
// 6 ()
// 7 (da/weg)	weg		sichtbar	weg		sichtbar
// 8 (auto)	fix		Autoscale	fix		Autoscale
// 9 (Ränder)	glatt		Min/Max		glatt		Min/Max
//10 (edit)	fest		editierbar	fest		editierbar
class Axis{
  constructor(plot,side,name,attr) {
    if (typeof attr=="string") attr={unit:attr};
    if (!attr) attr={};
    if (typeof attr.roundends!="boolean") attr.roundends=true;
    if ((side&0x11)==0x10) {	// Zeitachse (x)
      attr.meth="60";		// hh:mm:ss ausgeben lassen
      attr.fmt=toTimeShort;
      attr.roundends=false;
    }
    if (side&0x08 && !attr.fmt) attr.fmt=toUnitPrefix;
    this.plot=plot;
    this.side=side;	// X-Achse unten = 2, Y-Achse links = 5, Y-Achse rechts = 7
    this.name=name;	// Achsenbeschriftung
    this.attr=attr;
    this.rect=new Rect;		// Hauptrechteck
    this.margin=new Margin;	// Überhang, bei X-Achse links/rechts, bei Y-Achse oben/unten
    this.dispRange=new DisplayRange(this);
    this.dataRange=new Range;
    this.traces=[];
    this.labels=[];
    this.scale=this.isLog ? new LogScale : new LinScale;
    this.metrics={
      ticklen:plot.ticklen||5,
      tickwidth:1,
      textycorrect:0.5,		// mglw. je nach Schriftgröße!
      gap:2,	// Platz zwischen Text und Strich, zwischen Text und (oberen) Rand
      fontheight:plot.fontheight||12,
      lastSpaceNeeded:0,
      steppx:8	// Maximale Pixelzahl für minimale Auflösung (verhindert „gefüllte Flächen“ für mickrige Rauschamplituden)
    };
  }
  get isDigital()	{return (this.side&0x11)==0x11;}
  get isTime()		{return (this.side&0x11)==0x10;}
  get isLog()		{return this.side&0x08;}
  get visible()		{return !!(this.side&0x80);}
  set visible(v)	{if (v) this.side|=0x80; else this.side&=~0x80; this.plot.updatemask|=1;}
  get unit()		{return this.attr.unit||"";}
  get userlabels()	{return this.attr.labels||[];}
  get axistext()	{return this.name+(this.unit?" in "+this.unit:"");}
  get autotickmethod()	{return this.attr.meth||"125";}	// a string describing tick distribution
  get autotickformat()	{return this.attr.fmt;}		// a function that converts number to string
  get autoroundends()	{return this.attr.roundends;}
  checkNeed()		{this.visible=this.traces.find(t=>t.visible);}
  static negIf(e,v) {if (e) return -v; return v;}
  textWidth(t) {return this.plot.dc.measureText(t).width;}
  needSpace(force=true) {
    if (force) this.updateScaling(true);	// Labels erzeugen
    if (this.side&1) {	// Y-Achse (this.margin.l und this.margin.r bleiben 0)
      if (this.side&0x10) {	// digital (nur 0 und 1)
        this.margin.t=this.metrics.gap;
        this.margin.b=this.metrics.gap;
	return Math.ceil(this.textWidth(this.axistext))+this.metrics.gap*2;
      }
      this.margin.t=Math.ceil(this.metrics.fontheight/2+this.metrics.gap);
      this.margin.b=Math.ceil(this.metrics.fontheight/2+this.metrics.gap);
      return Math.ceil(this.metrics.gap*3+this.metrics.fontheight
	+Math.max(0,...this.labels.map(lbl => this.textWidth(lbl.t)))
	+this.metrics.ticklen);	// Breite
    }	// X-Achse (this.margin.t und this.margin.b bleiben 0)
    this.margin.l=Math.ceil(this.labels.length?this.textWidth(this.labels[0].t)/2+this.metrics.gap:0);
    this.margin.r=Math.ceil(this.labels.length?this.textWidth(this.labels[this.labels.length-1].t)/2+this.metrics.gap:0);
    return Math.ceil(this.metrics.ticklen+(this.metrics.gap+this.metrics.fontheight)*2);	// Höhe
  }
  needHeight() {if (this.isDigital) return this.metrics.fontheight*2;}	// für Analogskalen: Maximum, Digitalskalen: Fixed
  scaleInf(v) {		// Sonderbehandlung für ±Inf und NaN
    if (isFinite(v)) return this.scale.scale(v);
    if (v!=v) return this.rect.byIndex(this.side&1|8);	// NaN-Symbol in der Mitte
    return this.rect.byIndex(this.side&1|this.side>>1&2^(v>0?2:0));	// ±Inf am jeweiligen Rand
  }
  updateScaling(force=false) {
    const save = {a:this.dispRange.a,e:this.dispRange.e};
    if (this.dispRange.init(this.dataRange)) force=true;
    if (!force) return false;
    if (!this.isDigital) {
      // Achtung - Bei leerem "traces"-Array würde Math.min +Infinity liefern
      const W=this.rect.byIndex(this.side&1|4)-this.metrics.tickwidth;	// X- oder Y-Ausdehnung
      let w=this.metrics.fontheight+this.metrics.gap;	// für Y-Achsen
      if (this.isLog) {
	this.labels=this.dispRange.genLogTicks(W/w);
      }else{
	const auflös = this.traces.length ? Math.min(...this.traces.map(t => t.getData(this.side).dt)) : 0;
	this.dispRange.usefulRange(W/this.metrics.steppx,auflös);	// dispRange ggf. nach oben erweitern
	if (!(this.side&1)) {	// X-Achse: Textbreiten messen
	  w=Math.max(...this.dispRange.genLinTicks(20,auflös).map(lbl => 	// 20 = pauschale Menge an Ticks
	    this.textWidth(lbl.t)))+this.metrics.gap*2;	// mehr Platz bei X-Achsen
	}
	this.labels=this.dispRange.genLinTicks(W/w,auflös);	// verändert dispRange!!
      }
      const sp=this.needSpace(false);
      if (sp>this.metrics.lastSpaceNeeded) this.plot.update(1);	// "arrange" auslösen
      if (sp<this.metrics.lastSpaceNeeded-this.metrics.gap) this.plot.update(1);
    }
    const changed=this.dispRange.a!=save.a || save.e!=this.dispRange.e;
    if (changed|force) {
      let inset=this.metrics.tickwidth/2;	// 1/2 Pixel einziehen
      if (this.isDigital) inset+=this.metrics.gap;	// Digital nicht am Rand platzieren
      inset=Axis.negIf(this.side&4,inset);
      const A=this.rect.byIndex(this.side&1|this.side>>1&2)+inset,
            E=this.rect.byIndex(this.side&1|~this.side>>1&2)-inset;
      this.scale.make(this.dispRange.a,this.dispRange.e,A,E);
    }
    return changed;
  }
  checkRange(force=false) {
    const r=new Range;
    if (this.isDigital) r.init(0,1);	// Digital: Fester Bereich
    else this.traces.forEach(t => r.expand(t.getRange(this.side&1)));
    if (force || !this.dataRange.equals(r)) {
      this.dataRange.init(r);
      if (this.updateScaling(force))	// TODO: runden + verzögertes Shrinken
       this.plot.update(8);	// Neu zeichnen, aber nicht neu arrangieren
    }
  }
  paint() {
    if (!this.visible) return;
    const dc=this.plot.dc;
    dc.save();
    let x=this.rect.l-this.margin.l,
	y=this.rect.t-this.margin.t,
	w=this.rect.w+this.margin.w,
	h=this.rect.h+this.margin.h;
//    dc.rect(x,y,w,h);
//    dc.clip();
//    dc.clearRect(x,y,w,h);
    const ticklen=Axis.negIf(~this.side&2,this.metrics.ticklen);
    const gap=Axis.negIf(~this.side&2,this.metrics.gap);
    dc.beginPath();
    if (this.side&1) {
      if (!(this.side&0x10)) {
        x=this.rect.byIndex(~this.side&2);
        let x1=x + ticklen;
        let x2=x1+ gap;
        dc.textAlign=this.side&2?"left":"right";
        dc.textBaseline="middle";
        this.labels.forEach(lbl => {
	  let y=this.scale.scale(lbl.p);
	  dc.moveTo(x,y);
	  dc.lineTo(x1,y);
          dc.fillText(lbl.t,x2,y+this.metrics.textycorrect);
        });
      }
    }else{
      y=this.rect.byIndex(~this.side&2|1);
      let y1=y + ticklen;
      let y2=y1+ gap;
      dc.textAlign="center";
      dc.textBaseline=this.side&2?"top":"bottom";
      this.labels.forEach(lbl => {
	let x=this.scale.scale(lbl.p);
	dc.moveTo(x,y);
	dc.lineTo(x,y1);
	dc.fillText(lbl.t,x,y2);
      });
    }
    dc.stroke();
    dc.textAlign="center";
    dc.textBaseline=(this.side&3)==2?"bottom":"top";
    if (this.side&1) {
      if (this.side&0x10) {
        dc.textBaseline="middle";
	x=this.rect.cx;
	y=this.rect.cy+this.metrics.textycorrect;
      }else{
        x=this.rect.byIndex(this.side&2);	// links/rechts
        y=this.rect.cy;			// Mitte
        dc.translate(x,y);
        dc.rotate(this.side&2?Math.PI/2:-Math.PI/2);
        x=0; y=this.metrics.gap;
      }
    }else{
      x=this.rect.cx;			// Mitte
      y=this.rect.byIndex(this.side&2|1);// oben/unten
      if (~this.side&2) y+=this.metrics.textycorrect;	// sonst klebt der Text oben
    }
    dc.fillText(this.axistext,x,y);
    dc.restore();
  }
}

// in etwa kompatibel zum Array aber mit voreingestellter Maximalgröße
class HistoryBuffer{
  constructor(size) {
    this.a=new Array(size);
    this.wi=this.fill=this.drop=0;
//    Object.seal(this);
  }
  push(v) {
    this.a[this.wi]=v;
    this.wi=this.next(this.wi);
    if (this.fill<this.size) this.fill++;
    else this.drop++;
  }
  clear(clearDrop=true) {this.drop+=this.fill; if (clearDrop) this.drop=0; this.wi=this.fill=0;}
  get size() {return this.a.length;}
  set size(siz) {
    if (!(10<=siz && siz<=1E6)) throw new Error("Ungültige Historienlänge");
    if (siz==this.size) return;
    if (siz<this.fill) {
      this.drop+=this.fill-siz;	// Datenbestand schrumpft
      this.fill=siz;
    }
    const na=new Array(siz);
    this.forEach((el,i) => na[i]=el);	// zu neuem Array umstapeln (besser wäre inplace)
    this.wi=this.fill==siz?0:this.fill;
    this.a=na;
/*inplace - zu testen
    let delta=this.size-siz;
// 1. Die zu verwerfenden Daten liegen genau im abzuschneidenden Bereich (wi==siz)
//	Einfach abschneiden, wi=0 (Sonderfall von 2.)
// 2. Die zu verwerfenden Daten beginnen hinter der Abschneidegrenze (wi>=siz)
//	von siz nach 0 schaufeln, Länge wi-siz, wi-=siz
// 3. Die zu verwerfenden Daten überlappen die Abschneidegrenze (wi<siz && wi+delta>siz)
//	von wi+delta nach wi schaufeln, Länge siz-wi, wi unverändert
// 4. Die zu verwerfenden Daten liegen vollständig vor der Abschneidegrenze (wi+delta<=siz)
//	von siz nach wi schaufeln, Länge delta, wi unverändert
// Beim Vergrößern müssen ebenfalls verschiedene Fälle beachtet werden.
// Nicht-volle Puffer, die durch Vergrößern während this.wi!=0 entstehen können
// (this.fill<this.size), müssten beim Verkleinern ebenfalls beachtet werden,
// aber nicht unbedingt.
// In C++ ist das o.a. Kopieren ohnehin die beste Lösung,
// es sei denn, man reserviert für HistoryBuffer.a reichlich Adressraum.
    let ri=this.fill-siz;	// zu verwerfende Datenmenge
    ri=readidx(ri);
    let wi=this.wi;
    if (wi>
    while(l--) a[wi++]=a[ri++];
*/
  }
  get length() {return this.fill;}
  forEach(callback,that) {
    let ri=this.begin();
    for (let i=0; i<this.fill; i++) {
      callback.call(that,this.a[ri],i);	// tut so als wäre es ein normales Array
      ri=this.next(ri);
    }
  }
// C++-ähnlich, ABER Schleife muss do-while sein oder von 0..this.fill laufen
  begin() {return this.size==this.fill?this.wi:0;}
  end() {return this.wi;}
  next(i) {if (++i==this.size) i=0; return i;}
  
  readidx(i) {i+=this.begin(); if (i>=this.size) i-=this.size; return i;}
  byIndex(i) {return this.a[this.readidx(i)];}
}

// greift auf _Strukturelement_ von HistoryBuffer oder Array zu und erfasst einen Wertebereich (Range)
// Das Strukturelement kann auch verschachtelt und/oder ein Arrayindex sein, je nach Typ von _access_.
// Erlaubt ist auch eine Zugriffsfunktion für _access_.
// Die Zugriffsfunktion muss "number" liefern; NaN steht für nicht vorhandenen Messwert.
// Im Fall "digital" einen Binärstring aus "0","1","L","H","Z","U" usw. nach IEEE 1164.
// Alle Strings mit gleicher Länge, "digital" enthält die Stringlänge = Busbreite, Bit 0 hinten
// (in C++ ein 64-Bit = 16-Nibble-Cluster; maximale Busbreite 16)
// _attr_ enthält optional
// (double)"resolution" und (double)"precision" als Genauigkeitsangaben (absolut + relativ),
// (string)"name", (string)"unit" und (int)"digital" zum Generieren und Zuweisen von Achsen,
// (double)"t0", (double)"dt", (string)"tname", (string)"tunit" zum internen Generieren von TimeViewer,
//  um dem LabVIEW-Signal-Typ ähnlich zu sein
class DataViewer{
  constructor(histbuf,access,attr) {
    if (!attr) attr={};
    this.histbuf=histbuf;	// kann "HistoryBuffer" oder "Array" sein,
    this.access=access;	// kann (1) String, (2) Number, (3) Array aus String und Number, (4) Function sein
    this.attr=attr;	// Auflösung (um unsinnige Ticks in Skalen zu vermeiden)
    this.extractor=
      access===undefined ? DataViewer.none :
      access instanceof Function ? access :
      access instanceof Array ? this.byArray :
      this.byName;
    this.range=new Range;	// und das Ergebnis muss "range" passieren können, d.h. numerisch sein
    this.rangeValidHash=-1;
    this.rising=false;	// Info: Allgemeinfall
    Object.seal(this);
  }
  get length()	{return this.histbuf.length;}	// wie Array
  get dt()	{return this.attr.dt||this.attr.resolution||0;}
  get name()	{return this.attr.name||this.flatname;}
  get flatname(){return this.flatName(this.access);}
  get unit()	{return this.attr.unit||"";}
  get equaldistance() {return 0;}	// hier: kein gleicher Abstand
  flatName(n) {
   if (typeof n=="string" && n) return n;
   if (typeof n!="object") return "["+n+"]";	// Alle sonstigen primitiven Datentypen sowie Leerstring
   if (n instanceof Array) return n.map(e=>this.flatName(e)).join(".").replace(".[","[");
   return "{"+n.toString()+"}";
  }
  static none(v) {return v;}		// extrahiert gar nicht (wenn Array aus Zahlen)
  byName(v) {return v[this.access];}	// extrahiert Object-Member oder Array-Indizes
  byArray(v) {this.access.forEach(n => v=v[n]); return v;}	// ebenenweise aus Struct oder Array extrahieren
  forEach(callback,that) {	// Iteratorfunktion
    this.histbuf.forEach((el,i) => {
      callback.call(that,this.extractor(el),i);
    });
  }
  byIndex(i) {
    return this.extractor(this.histbuf instanceof Array ? this.histbuf[i] : this.histbuf.byIndex(i));
  }
  indexOf(v) {	// kein krummer Index! Sondern der mit dem nächstkleineren Wert (d.h. links davon)
    if (!this.rising) return;		// Nicht steigend: undefined
    if (!this.length) return null;	// Leere Datenmenge: null
    let l=0, r=this.length-1;
    while (l<r-1) {			// bei 1 oder 2 Werten kommt 0 heraus
      let m=(l+r)>>1;
      if (v<this.byIndex(m)) r=m-1; else l=m;
    }
    return l;
  }
  get hash() {
    return this.histbuf.length+(this.histbuf.wi||0);	// eines von beiden ändert sich mit jedem push()
  }
  updateRange(force=false) {
    if (force || this.rangeValidHash!=this.hash || this.histbuf.length<100) {
      this.range.reset();
      this.forEach(v => this.range.expand(v));
      this.rangeValidHash=this.hash;
    }
  }
}

// Index eines Arrays oder HistoryBuffers holen und in Zeit umrechnen
class TimeViewer{
  constructor(histbuf,dt=1,t0=0) {
    this.histbuf=histbuf;
    this.dt=dt;
    this.t0=t0;
    this.rising=true;
    Object.seal(this);
  }
  get length() {return this.histbuf.length;}
  get equaldistance() {return dt;}
  byIndex(i) {return (i+(this.histbuf.drop||0))*this.dt+this.t0;}
  indexOf(t) {return (t-this.t0)/this.dt-(this.histbuf.drop||0);}
  forEach(callback) {	// Iteratorfunktion
    for (let i=0; i<this.length; i++) callback(this.byIndex(i),i);
  }
  updateRange() {}	// nichts tun! Range ist einfach berechenbar.
  get range() {return new Range(this.byIndex(0),this.byIndex(this.length-1));}
}

// Fasst 2 Viewer zusammen und stellt eine forEach-Methode bereit,
// die bei gleichem HistoryBuffer einen schnelleren Zugriff macht,
// d.h. die forEach-Methode des Y-Viewers ausführt.
// Sonst iteriert die Funktion beide Quellen per Index.
class XYViewer{
  constructor(x,y) {
    this.x=x;	// muss DataViewer oder TimeViewer sein
    this.y=y;	// sollte DataViewer sein
    Object.seal(this);
  }
  get length() {return Math.min(this.x.length,this.y.length);}
  forEach(callback) {
// Für den Standardfall, dass sich X- und Y-Daten auf das gleiche Array beziehen,
// gibt es Sonderfunktionen
    if (this.y instanceof DataViewer && this.x.histbuf==this.y.histbuf) {
      if (this.x instanceof DataViewer) {
        this.y.histbuf.forEach((el,i) => {
	  callback(
	   this.x.extractor(el),
	   this.y.extractor(el),
	   i)
	});
	return;
      }
      if (this.x instanceof TimeViewer) {
        this.y.histbuf.forEach((el,i) => {
	  callback(
	   this.x.byIndex(i),
	   this.y.extractor(el),
	   i)
	});
	return;
      }
    }
    const l=this.length;
    for (let i=0; i<l; i++) callback(this.x.byIndex(i),this.y.byIndex(i),i);
  }
  map(callback) {
    const r=new Array(this.length);	// Vorallokation
    this.forEach((x,y,i) => r[i]=callback(x,y,i));
    return r;
  }
}

class Trace{
  constructor(plot,xdata,ydata,attr) {
    this.plot=plot;
    this.xydata=new XYViewer(xdata,ydata);	// muss DataViewer sein!
    this.attr=attr;	// xaxis, yaxis, penStyle(color,...)
    this.makePenMarkerDataList();
    Object.seal(this);
  }
  get visible()		{return this.attr.visible;}
  set visible(v)	{this.attr.visible=v; this.attr.xaxis.checkNeed(); this.attr.yaxis.checkNeed(); this.plot.update();}
  get name()		{return this.attr.name||this.defaultName;}
  set name(v)		{this.attr.name=v; this.plot.update();}
  get defaultName()	{if (this.attr.yaxis.traces.length==1) return this.attr.yaxis.name; return "Plot"+this.plot.traces.indexOf(this);}
  get penColor()	{return this.attr.color||this.penDefaultColor()||"#00FF80";}
  set penColor(v)	{this.attr.color=v; this.plot.update(8,this);}
  penDefaultColor()	{return ["#A52A2A","#0000FF","#FF0000","#00FF00"][this.plot.traces.indexOf(this)%4];}
  get penWidth()	{return this.attr.width||1;}
  set penWidth(v)	{this.attr.width=v; this.plot.update(8,this);}
  penWidthOptions(el)	{el.type="number"; el.min=0; el.max=10; el.step=0.1;}
  get penDash()		{return this.attr.dash||"solid";}
  set penDash(v)	{this.attr.dash=v; this.plot.update(8,this);}
  penDashOptions(el)	{["solid","dashed"].forEach(o=>el.add(new Option(o)));}
  get penJoin()		{return this.attr.join||"linear";}
  set penJoin(v)	{this.attr.join=v; this.plot.update(8,this);}
  penJoinOptions(el)	{["none","linear","spline","early","middle","late"].forEach(o=>el.add(new Option(o)));}
  get penMarker()	{return this.attr.Marker||"";}
  set penMarker(v)	{this.attr.Marker=v; this.plot.update(8,this);}
  get fillColor()	{return this.attr.brush||"lightgray";}
  set fillColor(v)	{this.attr.brush=v; this.plot.update(8,this);}
  get fillTo()		{return this.attr.fillTo||-4;}
  set fillTo(v)		{this.attr.fillTo=v; this.plot.update(8,this);}
  fillToOptions(el)	{["none","-∞","0","+∞"].concat(this.plot.traces.map(t=>t.name)).forEach(n=>el.add(new Option(n)));}
  filterAxes(xy)	{return this.plot.axes.filter(axis=>!((axis.side^xy.side)&1));}
  get xaxis()		{return this.attr.xaxis.name;}
  set xaxis(v)		{(this.attr.xaxis=this.plot.assignAxis(v,this.attr.xaxis.side)).checkNeed();}
  xaxisOptions(el)	{this.filterAxes(this.attr.xaxis).forEach(axis=>el.add(new Option(axis.name)));}
  get yaxis()		{return this.attr.yaxis.name;}
  set yaxis(v)		{(this.attr.yaxis=this.plot.assignAxis(v,this.attr.yaxis.side)).checkNeed();}
  yaxisOptions(el)	{this.filterAxes(this.attr.yaxis).forEach(axis=>el.add(new Option(axis.name)));}
  getData(side) 	{return side?this.xydata.y:this.xydata.x;}
  getRange(side) {
    const data=this.getData(side);
    data.updateRange();
    return data.range;
  }
  clip() {
    const rc=this.plot.rcPlot[this.attr.yaxis.plotIndex];
    this.plot.dc.beginPath();
    this.plot.dc.rect(rc.l,rc.t,rc.w,rc.h);
    this.plot.dc.clip();
  }
  paint() {
    if (!this.visible) return;
    const dc=this.plot.dc;
    const xaxis=this.attr.xaxis;
    const yaxis=this.attr.yaxis;
    dc.save();
    this.clip();
    if (this.fillTo!=-4) {
      dc.beginPath();
      let penop=dc.moveTo;
// Hinlauf (erst mal nur linear verbinden):
      this.xydata.forEach((x,y) => {
        if (isFinite(x) && isFinite(y)) {
          penop.call(dc,xaxis.scaleInf(x),yaxis.scaleInf(y));
	  penop=dc.lineTo;
	}
      });
// Rücklauf:
      let xrange=this.xydata.x.range;
      let y;
      switch (this.fillTo) {
	case -3: y=-Infinity; break;
	case -1: y=Infinity; break;
	default: if (!(this.fillTo>=0 && this.fillTo<this.plot.traces.length)) y=0;
      }
      if (y!==undefined) {
	dc.lineTo(xaxis.scaleInf(xrange.e),yaxis.scaleInf(y));
	dc.lineTo(xaxis.scaleInf(xrange.a),yaxis.scaleInf(y));
      }else{
	let t=this.plot.traces[this.fillTo];
	t.xydata.forEachReverse((x,y) => {
          if (isFinite(x) && isFinite(y)) dc.lineTo(t.attr.xaxis.scaleInf(x),t.attr.yaxis.scaleInf(y));
	});
      }
      dc.fillStyle=this.fillColor;
      dc.fill();
    }
    dc.beginPath();
//    dc.scale(xs.s,ys.s);	// bei linearer Skalierung vielleicht!
//    dc.translate(xs.o,ys.o);
    let penop=dc.moveTo;
    let pre_x=Number.NaN, pre_y=Number.NaN;
    this.xydata.forEach((x,y) => {
      if (!(isFinite(x) && isFinite(y))) /*penop=dc.moveTo*/;
      else{
	if (this.penJoin!="linear") {
	  if (pre_y!=y) {
	    let xm; switch (this.penJoin) {
	      case "early": xm=pre_x; break;
	      case "middle": xm=(pre_x+x)/2; break;
	      case "late": xm=x; break;
	    }
	    penop.call(dc,xaxis.scaleInf(xm),yaxis.scaleInf(pre_y));
            penop=dc.lineTo;
	    penop.call(dc,xaxis.scaleInf(xm),yaxis.scaleInf(y));	// senkrechter Strich
	  }
	  pre_x=x; pre_y=y;
	}else{
	  penop.call(dc,xaxis.scaleInf(x),yaxis.scaleInf(y));
	  penop=dc.lineTo;
	}
      }
    });
    if (this.penJoin!="linear") penop.call(dc,xaxis.scaleInf(pre_x),yaxis.scaleInf(pre_y));
    dc.lineWidth=this.penWidth;
    dc.strokeStyle=this.penColor;
    if (this.penDash!="solid") dc.setLineDash([10,10]);
    dc.stroke();
    if (this.penMarker) {
      dc.textAlign="center";
      dc.textBaseline="middle";
      this.xydata.forEach((x,y) => {
	if (isFinite(x) && isFinite(y))
	 dc.fillText(this.penMarker,xaxis.scaleInf(x),yaxis.scaleInf(y));
      });
    }
    dc.restore();
  }
  static createTableHead() {
    const row=document.createElement("tr");
    function th(txt) {
      return row.appendChild(document.createElement("th"))
		.appendChild(document.createTextNode(txt));
    }
    ["Name","vis","Farbe","Dicke","Strichelung","Verbindung","Marker","FüllBis","FüllFarbe","X-Achse","Y-Achse"]
    .forEach(n=>th(n));
    return row;
  }
  createTableRow() {
    const row=document.createElement("tr");
    function tdinp(inp="input") {
      return row.insertCell(-1)
		.appendChild(document.createElement(inp));
    }
    let el=tdinp();
    el.value=this.name;
    el.size=10;
    el.addEventListener("change",e=>this.name=e.target.value);
    el=tdinp();
    el.type="checkbox";
    el.checked=this.visible;
    el.addEventListener("click",e=>this.visible=e.target.checked);
    el=tdinp();
    el.type="color";
    el.value=this.penColor;
    el.addEventListener("change",e=>this.penColor=e.target.value);
    el=tdinp();
    el.type="number";
    el.style.width="3em";
    el.value=this.penWidth;
    this.penWidthOptions(el);
    el.addEventListener("change",e=>this.penWidth=e.target.value);      
    el=tdinp("select");
    this.penDashOptions(el);
    el.value=this.penDash;
    el.addEventListener("change",e=>this.penDash=e.target.value);
    el=tdinp("select");
    this.penJoinOptions(el);
    el.value=this.penJoin;
    el.addEventListener("change",e=>this.penJoin=e.target.value);
    el=tdinp();
    el.size=2;
    el.setAttribute("list","markersymbols");
    el.value=this.penMarker;
    el.addEventListener("change",e=>this.penMarker=e.target.value);
    el=tdinp("select");
    this.fillToOptions(el);
    el.selectedIndex=this.fillTo+4;
    el.addEventListener("change",e=>this.fillTo=e.target.selectedIndex-4);
    el=tdinp();
    el.type="color";
    el.value=this.fillColor;
    el.addEventListener("change",e=>this.fillColor=e.target.value);
    el=tdinp("select");
    this.xaxisOptions(el);
    el.selectedIndex=this.filterAxes(this.attr.xaxis).indexOf(this.attr.xaxis);
    el=tdinp("select");
    this.yaxisOptions(el);
    el.selectedIndex=this.filterAxes(this.attr.yaxis).indexOf(this.attr.yaxis);
    return row;
  }
  makePenMarkerDataList() {
    if (document.querySelector("datalist#markersymbols")) return;
    const dl=document.body.appendChild(document.createElement("datalist"));
    dl.id="markersymbols";
    ["□","▫","◊","○","△","▽","◁","▷"].forEach(t=>dl.appendChild(new Option(t)));
  }
}

class Plot{
  constructor(canvas) {
    this.canvas=canvas;
    window.addEventListener("resize",e=>this.onResize(e),false);
    this.onResize();
    this.bkgnd="rgba(0,0,0,0.03125)";	// Schwarz mit 31/32 Transparenz = hellgrau (auf weißem HTML-Grund)
    this.axes=[];
    this.traces=[];
    this.rcPlot=[];	// in Y-Richtung gestapelte Plot-Bereiche
    canvas.addEventListener("contextmenu",e=>this.onContextMenu(e));
  }
  onResize(e) {
    if (e) {
      if (this.canvas.width==this.canvas.clientWidth
      && this.canvas.height==this.canvas.clientHeight) return;
    }
    this.canvas.width=this.canvas.clientWidth;
    this.canvas.height=this.canvas.clientHeight;
    this.dc=this.canvas.getContext("2d");	// neu holen??
//    this.dc.imageSmoothingEnabled=false;
//    this.dc.lineCap="round";
//    this.dc.lineJoin="round";
    this.dc.font="12px Arial";
    if (e) this.update();
  }
  onContextMenu(e) {
    Plot.eat(e);
    const self=this;
    function removeMenu(e) {
// Wenn das Menü selbst getroffen wurde, nichts tun!
      const path=e.path||e.composedPath&&e.composedPath();
      if (path && path.indexOf(self.menu)>=0) return;
      Plot.eat(e);
      self.menu.remove();
      delete self.menu;
      document.removeEventListener("contextmenu",removeMenu);
      document.removeEventListener("click",removeMenu);
    }
    if (!this.menu) {
      this.menu=this.createPlotLegend();
// Kontextmenü (Plotlegende) erstellen
      Object.assign(this.menu.style,{
        "background-color": "#fffbf0",
        border: "1px solid #e7c157",
        "border-radius": "0 .5em .5em",
        position: "absolute",
        padding: 5+"px"
      });
      document.body.appendChild(this.menu);
// Damit ein Mausklick <irgendwohin> das Menü verschwinden lässt,
// globalen Eventhandler installieren, der beim Wegklicken wieder verschwindet
      document.addEventListener("click",removeMenu);
      document.addEventListener("contextmenu",removeMenu);
    }
    Object.assign(this.menu.style,{
      left: e.pageX+"px",
      top: e.pageY+"px",
    });
  }
  static eat(e) {
    e.preventDefault();
    e.stopPropagation();
    return e;
  }
  createPlotLegend() {
    let table=document.createElement("table");
    let thead=table.appendChild(document.createElement("thead"));
    thead.appendChild(Trace.createTableHead());
    let tbody=table.appendChild(document.createElement("tbody"));
    this.traces.forEach(t=>tbody.appendChild(t.createTableRow()));
    return table;
  }
  addAxis(side,name,attr) {
    const a=new Axis(this,side,name,attr);
    this.axes.push(a);
    this.update();
    return a;
  }
  addTrace(xdata,ydata,attr) {
    if (!attr) attr={};
    if (xdata instanceof Array) xdata=new DataViewer(xdata);
    if (ydata instanceof Array) ydata=new DataViewer(ydata);
    attr.xaxis=this.assignAxis(attr.xaxis,xdata instanceof TimeViewer ? 0x12:0x02,xdata.unit);	// X-Achse zuweisen (Standard: unten)
    attr.yaxis=this.assignAxis(attr.yaxis,0x05,ydata.unit);	// Y-Achse zuweisen (Standard: links, v.u.n.o., linear)
    const t=new Trace(this,xdata,ydata,attr);
    this.traces.push(t);
    attr.xaxis.traces.push(t);
    attr.yaxis.traces.push(t);
    /*if (typeof attr.visible != "boolean")*/ t.visible=true;	// setzt beide Achsen sichtbar
    this.update();
    return t;
  }
  assignAxis(a,side,unit) {
    if (a instanceof Axis && a.plot==this) return a;	// bereits zugewiesen
    if (typeof a === "number") {
      a=this.axes[a];	// Index in Achse umwandeln
      if (a && (a.side&1) == (side&1)) return a;	// bei Erfolg
    }
    if (typeof a === "string") {
      a=this.axes.find(axis => axis.name==a);
      if (a && (a.side&1) == (side&1)) return a;
    }
    a=this.axes.find(axis => (axis.side&0x37)==(side&0x37));	// Irgendeine (erste) Achse
    if (a) return a;
    return this.addAxis(side,"Axis["+this.axes.length+"]"+side,unit);	// generate unique name
  }
  get xaxes()	{return this.axes.filter(axis=>!(axis.side&1));}
  get yaxes()	{return this.axes.filter(axis=>axis.side&1);}
  yaxesOfArea(n){return this.axes.filter(axis=>axis.side&1 && axis.plotIndex==n);}
  arrange() {
    const borders=[];	// (vertikale) Liste der von Skalen eingeforderten Ränder sowie Höhen
    let plotIndex=0;
    const margin=new Margin;
// Von Y-Skalen gewünschte Höhen aufnehmen und den Y-Skalen den Plot-Index zuordnen
// Von X-Skalen Breite (= Höhe) ermitteln und summieren
    this.axes.forEach(axis => {if (axis.visible) {
      if (axis.side&1) {	// Y-Achse
        if (axis.side&32 && plotIndex!=borders.length) ++plotIndex;	// vorrücken
        axis.plotIndex=plotIndex;		// in Achse abspeichern
      }
      if (plotIndex==borders.length) borders.push(new Margin);
      if (axis.side&1) {	// Y-Achse
        const b=borders[plotIndex];
	let h=axis.needHeight();
	if (h>0 && !(b.habs>h)) b.habs=h;	// positiv: absolute Höhe (Pixel)
	if ((h=-h)>0 && !(b.hrel>h)) b.hrel=h;	// negativ: relative Höhe (0..100)
	axis.rect.t=margin.t; axis.rect.b=this.canvas.height-margin.b;
        const sp=axis.needSpace();
	b.addTo(axis.side&2,sp);		// mehrere Skalen summieren
	b.expand(axis.margin);
	if (margin.l<b.l) margin.l=b.l;		// Globalen Rand mit vergrößern
	if (margin.r<b.r) margin.r=b.r;
      }else{
	axis.rect.l=margin.l; axis.rect.r=this.canvas.width-margin.r;
	const sp=axis.needSpace();
	margin.addTo(axis.side&2|1,sp);
	margin.expand(axis.margin);
      }
    }});
    if (margin.t<borders[0].t) margin.t=borders[0].t;
    if (margin.b<borders[borders.length-1].b) margin.b=borders[borders.length-1].b;
// Platz in Y-Richtung verteilen: Absolut- und Relativwerte akkumulieren
    let absheight=0,relheight=0;
    borders.forEach(b => {
      if (b.habs) absheight+=b.habs;
      else relheight+=b.hrel||100;	// Bei Nebeneinanderanordnung von abs- und rel-Skalen gewinnt hier (erst mal) die abs-Skale
    });
    if (relheight<100) relheight=100;
    let restheight=this.canvas.height-margin.h-absheight;	// Verbleibende Höhe für relativ-hohe Elemente
    borders.forEach(b => {
      if (!b.habs) b.habs=(b.hrel||100)*restheight/relheight;
      delete b.hrel;			// Eigenschaft wird nicht mehr benötigt
    });
    
    this.rcPlot=new Array(borders.length);
// Plot-Rechtecke anlegen und Platz verteilen
    let top=margin.t;
    borders.forEach((b,i) => {
      this.rcPlot[i]=new Rect(margin.l,top,this.canvas.width-margin.r,top+=borders[i].habs);
// Von Y-Skalen eingeforderten vertikalen Platz abziehen, wenn nicht am Rand (blöd!!)
      if (i) this.rcPlot[i].t+=b.t;
      if (i!=borders.length-1) this.rcPlot[i].b-=b.b;
      b.l=margin.l; b.r=margin.r;
    });
// Skalen-Rechtecke endgültig platzieren (Ganze Pixel)
    this.axes.forEach(axis => {if (axis.visible) {
      const rc=axis.rect;
      rc.init(this.rcPlot[axis.side&1?axis.plotIndex:0]);
      const sp=axis.needSpace();	// noch einmal mit reduzierter Höhe(X) / Breite(Y)
      const b=borders[axis.plotIndex||0];
      switch (axis.side&3) {
	case 0: rc.b=margin.t; rc.t=(margin.t-=sp); break;	// top (selten)
	case 1: rc.r=b.l; rc.l=(b.l-=sp); break;	// left
	case 2: rc.t=this.canvas.height-margin.b; rc.b=this.canvas.height-(margin.b-=sp); break;	// bottom
	case 3: rc.l=this.canvas.width-b.r; rc.r=this.canvas.width-(b.r-=sp); break;
      }
      axis.metrics.lastSpaceNeeded=sp;
    }});
  }
  update(mask=0xFF,el) {
    this.updatemask|=mask;
    this.timeout_is_set = this.timeout_is_set || mySetTimeout(() => {
      let tic=-performance.now();
      this.timeout_is_set=0;
      if (this.updatemask&1) this.arrange();
      if (this.updatemask&4) this.axes.forEach(axis => axis.checkRange(this.updatemask&1));	// verändert ggf. updatemask!
      if (this.updatemask&0x0B) this.paint();
      tic+=performance.now();
      if (tic>200) console.log("Too slow: "+tic+" ms");
    },100);
  }
  paint() {
    if (this.updatemask&8) {
      this.dc.clearRect(0,0,this.canvas.width,this.canvas.height);
      this.axes.forEach(axis => axis.paint());	// Nur wenn geändert
    }
    this.dc.save();
    this.dc.fillStyle=this.bkgnd;
    this.rcPlot.forEach(r => {
      if (!(this.updatemask&8)) this.dc.clearRect(r.l,r.t,r.w,r.h);
      this.dc.fillRect(r.l,r.t,r.w,r.h);
    });
    this.dc.restore();
    this.traces.forEach(trace => trace.paint());
    this.updatemask=0;
  }
}
Detected encoding: UTF-80