TeaWeb/shared/js/ui/elements/NetGraph.ts
2021-04-24 12:58:32 +02:00

463 lines
No EOL
13 KiB
TypeScript

import {LogCategory, logDebug} from "tc-shared/log";
import {CallOnce} from "tc-shared/proto";
export type Entry = {
timestamp: number;
upload?: number;
download?: number;
highlight?: boolean;
}
export type Style = {
backgroundColor: string;
separatorColor: string;
separatorCount: number;
separatorWidth: number;
upload: {
fill: string;
stroke: string;
strokeWidth: number;
},
download: {
fill: string;
stroke: string;
strokeWidth: number;
}
}
export type TimeSpan = {
origin: {
begin: number;
end: number;
time: number;
},
target: {
begin: number;
end: number;
time: number;
}
}
export class Graph {
private static animateCallbacks: (() => any)[] = [];
private static registerAnimateCallback(callback: () => void) {
this.animateCallbacks.push(callback);
if(this.animateCallbacks.length === 1) {
const animateLoop = () => {
Graph.animateCallbacks.forEach(l => l());
if(Graph.animateCallbacks.length > 0) {
requestAnimationFrame(animateLoop);
} else {
logDebug(LogCategory.GENERAL, tr("NetGraph static terminate"));
}
};
animateLoop();
}
}
private static removerAnimateCallback(callback: () => void) {
this.animateCallbacks.remove(callback);
}
public style: Style = {
backgroundColor: "#28292b",
separatorColor: "#283036",
separatorCount: 10,
separatorWidth: 1,
upload: {
fill: "#2d3f4d",
stroke: "#336e9f",
strokeWidth: 2,
},
download: {
fill: "#532c26",
stroke: "#a9321c",
strokeWidth: 2,
}
};
private canvas: HTMLCanvasElement;
private canvasContext: CanvasRenderingContext2D;
private entries: Entry[] = [];
private entriesMax = {
upload: 1,
download: 1,
};
private maxSpace = 1.12;
private maxGap = 5;
private animateLoop;
timeSpan: TimeSpan = {
origin: {
begin: 0,
end: 1,
time: 0
},
target: {
begin: 0,
end: 1,
time: 1
}
};
private detailShown = false;
callbackDetailedInfo: (upload: number, download: number, timestamp: number, event: MouseEvent) => any;
callbackDetailedHide: () => any;
constructor() {
this.animateLoop = () => this.draw();
this.recalculateCache(); /* initialize cache */
}
@CallOnce
initialize() { }
@CallOnce
finalize() {
this.initializeCanvas(undefined);
}
initializeCanvas(canvas: HTMLCanvasElement | undefined) {
if(this.canvas) {
this.canvas.onmousemove = undefined;
this.canvas.onmouseleave = undefined;
this.canvas = undefined;
this.canvasContext = undefined;
Graph.removerAnimateCallback(this.animateLoop);
}
if(!canvas) {
return;
}
this.canvas = canvas;
this.canvasContext = this.canvas.getContext("2d");
Graph.registerAnimateCallback(this.animateLoop);
this.canvas.onmousemove = this.onMouseMove.bind(this);
this.canvas.onmouseleave = this.onMouseLeave.bind(this);
}
maxGapSize(value?: number) : number { return typeof(value) === "number" ? (this.maxGap = value) : this.maxGap; }
private recalculateCache(timespan?: boolean) {
this.entries = this.entries.sort((a, b) => a.timestamp - b.timestamp);
this.entriesMax = {
download: 1,
upload: 1
};
if(timespan) {
this.timeSpan = {
origin: {
begin: 0,
end: 0,
time: 0
},
target: {
begin: this.entries.length > 0 ? this.entries[0].timestamp : 0,
end: this.entries.length > 0 ? this.entries.last().timestamp : 0,
time: 0
}
};
}
for(const entry of this.entries) {
if(typeof(entry.upload) === "number") {
this.entriesMax.upload = Math.max(this.entriesMax.upload, entry.upload);
}
if(typeof(entry.download) === "number") {
this.entriesMax.download = Math.max(this.entriesMax.download, entry.download);
}
}
this.entriesMax.upload *= this.maxSpace;
this.entriesMax.download *= this.maxSpace;
}
entryCount() : number {
return this.entries.length;
}
pushEntry(entry: Entry) {
if(this.entries.length > 0 && entry.timestamp < this.entries.last().timestamp) {
throw "invalid timestamp";
}
this.entries.push(entry);
if(typeof entry.upload === "number") {
this.entriesMax.upload = Math.max(this.entriesMax.upload, entry.upload * this.maxSpace);
}
if(typeof entry.download === "number") {
this.entriesMax.download = Math.max(this.entriesMax.download, entry.download * this.maxSpace);
}
}
insertEntries(entries: Entry[]) {
this.entries.push(...entries);
this.recalculateCache();
this.cleanup();
}
resize() {
this.canvas.style.height = "100%";
this.canvas.style.width = "100%";
/* TODO: Do this within the next animate loop. We don't have to do this right here! */
const cstyle = getComputedStyle(this.canvas);
this.canvas.width = parseInt(cstyle.width);
this.canvas.height = parseInt(cstyle.height);
}
cleanup() {
const time = this.calculateTimespan();
let index = 0;
for(;index < this.entries.length; index++) {
if(this.entries[index].timestamp < time.begin) {
continue;
}
if(index == 0) {
return;
}
break;
}
/* keep the last entry as a reference point to the left */
if(index > 1) {
this.entries.splice(0, index - 1);
this.recalculateCache();
}
}
calculateTimespan() : { begin: number; end: number } {
const time = Date.now();
if(time >= this.timeSpan.target.time) {
return this.timeSpan.target;
}
if(time <= this.timeSpan.origin.time) {
return this.timeSpan.origin;
}
const ob = this.timeSpan.origin.begin;
const oe = this.timeSpan.origin.end;
const ot = this.timeSpan.origin.time;
const tb = this.timeSpan.target.begin;
const te = this.timeSpan.target.end;
const tt = this.timeSpan.target.time;
const offset = (time - ot) / (tt - ot);
return {
begin: ob + (tb - ob) * offset,
end: oe + (te - oe) * offset,
};
}
private draw() {
let ctx = this.canvasContext;
const height = this.canvas.height;
const width = this.canvas.width;
//console.log("Painting on %ox%o", height, width);
ctx.shadowBlur = 0;
ctx.filter = "";
ctx.lineCap = "square";
ctx.fillStyle = this.style.backgroundColor;
ctx.fillRect(0, 0, width, height);
/* first of all print the separators */
{
const sw = this.style.separatorWidth;
const swh = this.style.separatorWidth / 2;
ctx.lineWidth = sw;
ctx.strokeStyle = this.style.separatorColor;
ctx.beginPath();
/* horizontal */
{
const dw = width / this.style.separatorCount;
let dx = dw / 2;
while(dx < width) {
ctx.moveTo(Math.floor(dx - swh) + .5, .5);
ctx.lineTo(Math.floor(dx - swh) + .5, Math.floor(height) + .5);
dx += dw;
}
}
/* vertical */
{
const dh = height / 3; //tree lines (top, center, bottom)
let dy = dh / 2;
while(dy < height) {
ctx.moveTo(.5, Math.floor(dy - swh) + .5);
ctx.lineTo(Math.floor(width) + .5, Math.floor(dy - swh) + .5);
dy += dh;
}
}
ctx.stroke();
ctx.closePath();
}
/* draw the lines */
{
const t = this.calculateTimespan();
const tb = t.begin; /* time begin */
const dt = t.end - t.begin; /* delta time */
const dtw = width / dt; /* delta time width */
const drawGraph = (type: "upload" | "download", direction: number, max: number) => {
const hy = Math.floor(height / 2); /* half y */
const by = hy - direction * this.style[type].strokeWidth; /* the "base" line */
const marked_points: ({x: number, y: number})[] = [];
ctx.beginPath();
ctx.moveTo(0, by);
let x, y, lx = 0, ly = by; /* last x, last y */
const floor = a => a; //Math.floor;
for(const entry of this.entries) {
x = floor((entry.timestamp - tb) * dtw);
if(typeof entry[type] === "number") {
y = floor(hy - direction * Math.max(hy * (entry[type] / max), this.style[type].strokeWidth));
} else {
y = hy - direction * this.style[type].strokeWidth;
}
if(entry.timestamp < tb) {
lx = x;
ly = y;
continue;
}
if(x - lx > this.maxGap && this.maxGap > 0) {
ctx.lineTo(lx, by);
ctx.lineTo(x, by);
ctx.lineTo(x, y);
lx = x;
ly = y;
continue;
}
ctx.bezierCurveTo((x + lx) / 2, ly, (x + lx) / 2, y, x, y);
if(entry.highlight)
marked_points.push({x: x, y: y});
lx = x;
ly = y;
}
ctx.strokeStyle = this.style[type].stroke;
ctx.lineWidth = this.style[type].strokeWidth;
ctx.lineJoin = "miter";
ctx.stroke();
//Close the path and fill
ctx.lineTo(width, hy);
ctx.lineTo(0, hy);
ctx.fillStyle = this.style[type].fill;
ctx.fill();
ctx.closePath();
{
ctx.beginPath();
const radius = 3;
for(const point of marked_points) {
ctx.moveTo(point.x, point.y);
ctx.ellipse(point.x, point.y, radius, radius, 0, 0, 2 * Math.PI, false);
}
ctx.stroke();
ctx.fill();
ctx.closePath();
}
};
const shared_max = Math.max(this.entriesMax.upload, this.entriesMax.download);
drawGraph("upload", 1, shared_max);
drawGraph("download", -1, shared_max);
}
}
private onMouseMove(event: MouseEvent) {
const offset = event.offsetX;
const max_offset = this.canvas.width;
if(offset < 0) return;
if(offset > max_offset) return;
const time_span = this.calculateTimespan();
const time = time_span.begin + (time_span.end - time_span.begin) * (offset / max_offset);
let index = 0;
for(;index < this.entries.length; index++) {
if(this.entries[index].timestamp > time) {
break;
}
}
const entry_before = this.entries[index - 1]; /* In JS negative array access is allowed and returns undefined */
const entry_next = this.entries[index]; /* In JS negative array access is allowed and returns undefined */
let entry: Entry;
if(!entry_before || !entry_next) {
entry = entry_before || entry_next;
} else {
const dn = entry_next.timestamp - time;
const db = time - entry_before.timestamp;
if(dn > db)
entry = entry_before;
else
entry = entry_next;
}
if(!entry) {
this.onMouseLeave(event);
} else {
this.entries.forEach(e => e.highlight = false);
this.detailShown = true;
entry.highlight = true;
if(this.callbackDetailedInfo) {
this.callbackDetailedInfo(entry.upload, entry.download, entry.timestamp, event);
}
}
}
private onMouseLeave(_event: MouseEvent) {
if(!this.detailShown) {
return;
}
this.detailShown = false;
this.entries.forEach(e => e.highlight = false);
if(this.callbackDetailedHide) {
this.callbackDetailedHide();
}
}
}