"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-8 | 0
|