463 lines
No EOL
13 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
} |