TeaWeb/shared/js/ui/elements/NetGraph.ts
2020-03-30 13:44:18 +02:00

433 lines
No EOL
13 KiB
TypeScript

export type Entry = {
timestamp: number;
upload?: number;
download?: number;
highlight?: boolean;
}
export type Style = {
background_color: string;
separator_color: string;
separator_count: number;
separator_width: number;
upload: {
fill: string;
stroke: string;
strike_width: number;
},
download: {
fill: string;
stroke: string;
strike_width: number;
}
}
export type TimeSpan = {
origin: {
begin: number;
end: number;
time: number;
},
target: {
begin: number;
end: number;
time: number;
}
}
/* Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves
*
* Assuming A was the last point in the line plotted and B is the new point,
* we draw a curve with control points P and Q as below.
*
* A---P
* |
* |
* |
* Q---B
*
* Importantly, A and P are at the same y coordinate, as are B and Q. This is
* so adjacent curves appear to flow as one.
*/
export class Graph {
private static _loops: (() => any)[] = [];
readonly canvas: HTMLCanvasElement;
public style: Style = {
background_color: "#28292b",
//background_color: "red",
separator_color: "#283036",
//separator_color: 'blue',
separator_count: 10,
separator_width: 1,
upload: {
fill: "#2d3f4d",
stroke: "#336e9f",
strike_width: 2,
},
download: {
fill: "#532c26",
stroke: "#a9321c",
strike_width: 2,
}
};
private _canvas_context: CanvasRenderingContext2D;
private _entries: Entry[] = [];
private _entry_max = {
upload: 1,
download: 1,
};
private _max_space = 1.12;
private _max_gap = 5;
private _listener_mouse_move;
private _listener_mouse_out;
private _animate_loop;
_time_span: TimeSpan = {
origin: {
begin: 0,
end: 1,
time: 0
},
target: {
begin: 0,
end: 1,
time: 1
}
};
private _detailed_shown = false;
callback_detailed_info: (upload: number, download: number, timestamp: number, event: MouseEvent) => any;
callback_detailed_hide: () => any;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this._animate_loop = () => this.draw();
this.recalculate_cache(); /* initialize cache */
}
initialize() {
this._canvas_context = this.canvas.getContext("2d");
Graph._loops.push(this._animate_loop);
if(Graph._loops.length == 1) {
const static_loop = () => {
Graph._loops.forEach(l => l());
if(Graph._loops.length > 0)
requestAnimationFrame(static_loop);
else
console.log("STATIC terminate!");
};
static_loop();
}
this.canvas.onmousemove = this.on_mouse_move.bind(this);
this.canvas.onmouseleave = this.on_mouse_leave.bind(this);
}
terminate() {
Graph._loops.remove(this._animate_loop);
}
max_gap_size(value?: number) : number { return typeof(value) === "number" ? (this._max_gap = value) : this._max_gap; }
private recalculate_cache(time_span?: boolean) {
this._entries = this._entries.sort((a, b) => a.timestamp - b.timestamp);
this._entry_max = {
download: 1,
upload: 1
};
if(time_span) {
this._time_span = {
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._entry_max.upload = Math.max(this._entry_max.upload, entry.upload);
if(typeof(entry.download) === "number")
this._entry_max.download = Math.max(this._entry_max.download, entry.download);
}
this._entry_max.upload *= this._max_space;
this._entry_max.download *= this._max_space;
}
insert_entry(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._entry_max.upload = Math.max(this._entry_max.upload, entry.upload * this._max_space);
if(typeof(entry.download) === "number")
this._entry_max.download = Math.max(this._entry_max.download, entry.download * this._max_space);
}
insert_entries(entries: Entry[]) {
this._entries.push(...entries);
this.recalculate_cache();
this.cleanup();
}
resize() {
this.canvas.style.height = "100%";
this.canvas.style.width = "100%";
const cstyle = getComputedStyle(this.canvas);
this.canvas.width = parseInt(cstyle.width);
this.canvas.height = parseInt(cstyle.height);
}
cleanup() {
const time = this.calculate_time_span();
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.recalculate_cache();
}
}
calculate_time_span() : { begin: number; end: number } {
const time = Date.now();
if(time >= this._time_span.target.time)
return this._time_span.target;
if(time <= this._time_span.origin.time)
return this._time_span.origin;
const ob = this._time_span.origin.begin;
const oe = this._time_span.origin.end;
const ot = this._time_span.origin.time;
const tb = this._time_span.target.begin;
const te = this._time_span.target.end;
const tt = this._time_span.target.time;
const offset = (time - ot) / (tt - ot);
return {
begin: ob + (tb - ob) * offset,
end: oe + (te - oe) * offset,
};
}
draw() {
let ctx = this._canvas_context;
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.background_color;
ctx.fillRect(0, 0, width, height);
/* first of all print the separators */
{
const sw = this.style.separator_width;
const swh = this.style.separator_width / 2;
ctx.lineWidth = sw;
ctx.strokeStyle = this.style.separator_color;
ctx.beginPath();
/* horizontal */
{
const dw = width / this.style.separator_count;
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.calculate_time_span();
const tb = t.begin; /* time begin */
const dt = t.end - t.begin; /* delta time */
const dtw = width / dt; /* delta time width */
const draw_graph = (type: "upload" | "download", direction: number, max: number) => {
const hy = Math.floor(height / 2); /* half y */
const by = hy - direction * this.style[type].strike_width; /* 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].strike_width));
else
y = hy - direction * this.style[type].strike_width;
if(entry.timestamp < tb) {
lx = x;
ly = y;
continue;
}
if(x - lx > this._max_gap && this._max_gap > 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].strike_width;
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._entry_max.upload, this._entry_max.download);
draw_graph("upload", 1, shared_max);
draw_graph("download", -1, shared_max);
}
}
private on_mouse_move(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.calculate_time_span();
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.on_mouse_leave(event);
} else {
this._entries.forEach(e => e.highlight = false);
this._detailed_shown = true;
entry.highlight = true;
if(this.callback_detailed_info)
this.callback_detailed_info(entry.upload, entry.download, entry.timestamp, event);
}
}
private on_mouse_leave(event: MouseEvent) {
if(!this._detailed_shown) return;
this._detailed_shown = false;
this._entries.forEach(e => e.highlight = false);
if(this.callback_detailed_hide)
this.callback_detailed_hide();
}
}