Merge "Add PartsMessageReader to read part files"
diff --git a/aos/network/www/BUILD b/aos/network/www/BUILD
index 60bea09..ac376db 100644
--- a/aos/network/www/BUILD
+++ b/aos/network/www/BUILD
@@ -72,6 +72,7 @@
ts_library(
name = "reflection_ts",
srcs = ["reflection.ts"],
+ visibility = ["//visibility:public"],
deps =
[
"//aos:configuration_ts_fbs",
@@ -79,6 +80,34 @@
],
)
+ts_library(
+ name = "plotter",
+ srcs = [
+ "plotter.ts",
+ ],
+ visibility = ["//visibility:public"],
+)
+
+ts_library(
+ name = "graph_main",
+ srcs = [
+ "graph_main.ts",
+ ],
+ deps = [
+ ":plotter",
+ ":proxy",
+ ":reflection_ts",
+ ],
+)
+
+rollup_bundle(
+ name = "graph_main_bundle",
+ entry_point = "aos/network/www/graph_main",
+ deps = [
+ ":graph_main",
+ ],
+)
+
aos_config(
name = "test_config",
src = "test_config_file.json",
@@ -104,6 +133,8 @@
srcs = ["web_proxy_demo.sh"],
data = [
":flatbuffers",
+ ":graph.html",
+ ":graph_main_bundle",
":reflection_test.html",
":reflection_test_bundle",
":test_config",
diff --git a/aos/network/www/graph.html b/aos/network/www/graph.html
new file mode 100644
index 0000000..f43f331
--- /dev/null
+++ b/aos/network/www/graph.html
@@ -0,0 +1,10 @@
+<html>
+ <head>
+ <script src="flatbuffers.js"></script>
+ <script src="graph_main_bundle.min.js" defer></script>
+ <link rel="stylesheet" href="styles.css">
+ </head>
+ <body>
+ </body>
+</html>
+
diff --git a/aos/network/www/graph_main.ts b/aos/network/www/graph_main.ts
new file mode 100644
index 0000000..bbd70de
--- /dev/null
+++ b/aos/network/www/graph_main.ts
@@ -0,0 +1,183 @@
+// This file creates a set of two demonstration plots for testing out the
+// plotting utility.
+// To run this and see a demo, run:
+// bazel run -c opt //aos/network/www:web_proxy_demo
+// And then open localhost:8080/graph.html
+//
+// The plots are just the plots of the handler latency / run times of the first
+// timer in the the /aos timing report message (this message was chosen because
+// it is already being published by the web proxy process, so the demo requires
+// very little setup).
+import {Plot, Line} from 'aos/network/www/plotter';
+import {Channel, Configuration} from 'aos/configuration_generated';
+import {Connect} from 'aos/network/connect_generated';
+import {Connection} from 'aos/network/www/proxy';
+import {Parser, Table} from 'aos/network/www/reflection'
+
+const width = 900;
+const height = 400;
+
+const helpDiv = document.createElement('div');
+helpDiv.style.top = 0;
+helpDiv.style.left = 0;
+helpDiv.style.position = 'absolute';
+document.body.appendChild(helpDiv);
+helpDiv.innerHTML =
+ 'Help: click + drag to pan, double click to reset, scroll to zoom. ' +
+ 'Hold the x/y keys to only pan/zoom along the x/y axes.<br>' +
+ 'X-axes of the top two plots are linked together.';
+
+const div = document.createElement('div');
+div.style.top = 40;
+div.style.left = 0;
+div.style.position = 'absolute';
+document.body.appendChild(div);
+
+const div2 = document.createElement('div');
+div2.style.top = parseFloat(div.style.top) + height;
+div2.style.left = 0;
+div2.style.position = 'absolute';
+document.body.appendChild(div2);
+
+const benchmarkDiv = document.createElement('div');
+benchmarkDiv.style.top = parseFloat(div2.style.top) + height;
+benchmarkDiv.style.left = 0;
+benchmarkDiv.style.position = 'absolute';
+document.body.appendChild(benchmarkDiv);
+
+const handlerTimePlot = new Plot(div, width, height);
+const latencyPlot = new Plot(div2, width, height);
+const benchmarkPlot = new Plot(benchmarkDiv, width, height);
+
+// Class to store the lines for plotting a Statistics message.
+class StatLines {
+ private max: Line;
+ private min: Line;
+ private average: Line;
+ private maxPoints: number[] = [];
+ private minPoints: number[] = [];
+ private averagePoints: number[] = [];
+ constructor(plotter: Plot) {
+ this.max = plotter.getDrawer().addLine();
+ this.min = plotter.getDrawer().addLine();
+ this.average = plotter.getDrawer().addLine();
+
+ this.max.setLabel("Max");
+ this.min.setLabel("Min");
+ this.average.setLabel("Average");
+
+ this.max.setColor([1, 0, 0]);
+ this.min.setColor([0, 1, 0]);
+ this.average.setColor([0, 0, 1]);
+ }
+ addPoints(parser: Parser, statsTable: Table, time: number) {
+ this.maxPoints.push(time);
+ this.minPoints.push(time);
+ this.averagePoints.push(time);
+ this.maxPoints.push(parser.readScalar(statsTable, "max") * 1000);
+ this.minPoints.push(parser.readScalar(statsTable, "min") * 1000);
+ this.averagePoints.push(parser.readScalar(statsTable, "average") * 1000);
+
+
+ this.max.setPoints(new Float32Array(this.maxPoints));
+ this.min.setPoints(new Float32Array(this.minPoints));
+ this.average.setPoints(new Float32Array(this.averagePoints));
+ }
+}
+
+function setupPlot(plotter: Plot, title: string): StatLines {
+ plotter.getAxisLabels().setXLabel("Monotonic send time since start (sec)");
+ plotter.getAxisLabels().setYLabel("Time (msec)");
+ plotter.getAxisLabels().setTitle(title);
+ return new StatLines(plotter);
+}
+
+// Sets of the two x-axes to be shared; remove this to be able to zoom/pan the
+// x-axes independently.
+handlerTimePlot.linkXAxis(latencyPlot);
+
+const handlerLines = setupPlot(handlerTimePlot, "Handler Run Times");
+const latencyLines = setupPlot(latencyPlot, "Handler Latencies");
+
+const conn = new Connection();
+
+conn.connect();
+
+const timingReport = {
+ name: '/aos',
+ type: 'aos.timing.Report',
+};
+
+let reportParser = null;
+
+conn.addConfigHandler((config: Configuration) => {
+ // Locate the timing report schema so that we can read the received messages.
+ let reportSchema = null;
+ for (let ii = 0; ii < config.channelsLength(); ++ii) {
+ if (config.channels(ii).type() === timingReport.type) {
+ reportSchema = config.channels(ii).schema();
+ }
+ }
+ if (reportSchema === null) {
+ throw new Error('Couldn\'t find timing report schema in config.');
+ }
+ reportParser = new Parser(reportSchema);
+
+ // Subscribe to the timing report message.
+ const builder = new flatbuffers.Builder(512);
+ const channels: flatbuffers.Offset[] = [];
+ for (const channel of [timingReport]) {
+ const nameFb = builder.createString(channel.name);
+ const typeFb = builder.createString(channel.type);
+ Channel.startChannel(builder);
+ Channel.addName(builder, nameFb);
+ Channel.addType(builder, typeFb);
+ const channelFb = Channel.endChannel(builder);
+ channels.push(channelFb);
+ }
+
+ const channelsFb = Connect.createChannelsToTransferVector(builder, channels);
+ Connect.startConnect(builder);
+ Connect.addChannelsToTransfer(builder, channelsFb);
+ const connect = Connect.endConnect(builder);
+ builder.finish(connect);
+ conn.sendConnectMessage(builder);
+});
+
+const startTime = null;
+conn.addHandler(timingReport.type, (data: Uint8Array, time: number) => {
+ if (startTime === null) {
+ startTime = time;
+ }
+ time = time - startTime;
+ const table = Table.getRootTable(new flatbuffers.ByteBuffer(data));
+
+ const timer = reportParser.readVectorOfTables(table, "timers")[0];
+ handlerLines.addPoints(
+ reportParser, reportParser.readTable(timer, 'handler_time'), time);
+ latencyLines.addPoints(
+ reportParser, reportParser.readTable(timer, 'wakeup_latency'), time);
+});
+
+// Set up and draw the benchmarking plot
+benchmarkPlot.getAxisLabels().setTitle(
+ 'Benchmarkping plot (1M points per line)');
+const line1 = benchmarkPlot.getDrawer().addLine();
+// For demonstration purposes, make line1 only have points and line2 only have
+// lines.
+line1.setDrawLine(false);
+line1.setLabel('LINE ONE');
+const line2 = benchmarkPlot.getDrawer().addLine();
+line2.setPointSize(0);
+line2.setLabel('LINE TWO');
+const NUM_POINTS = 1000000;
+const points1 = [];
+const points2 = [];
+for (let ii = 0; ii < NUM_POINTS; ++ii) {
+ points1.push(ii);
+ points2.push(ii);
+ points1.push(Math.sin(ii * 10 / NUM_POINTS);
+ points2.push(Math.cos(ii * 10 / NUM_POINTS);
+}
+line1.setPoints(new Float32Array(points1));
+line2.setPoints(new Float32Array(points2));
diff --git a/aos/network/www/plotter.ts b/aos/network/www/plotter.ts
new file mode 100644
index 0000000..774f3aa
--- /dev/null
+++ b/aos/network/www/plotter.ts
@@ -0,0 +1,929 @@
+// Multiplies all the values in the provided array by scale.
+function scaleVec(vec: number[], scale: number): number[] {
+ const scaled: number[] = [];
+ for (let num of vec) {
+ scaled.push(num * scale);
+ }
+ return scaled;
+}
+
+// Runs the operation op() over every pair of numbers in a, b and returns
+// the result.
+function cwiseOp(
+ a: number[], b: number[], op: (a: number, b: number) => number): number[] {
+ if (a.length !== b.length) {
+ throw new Error("a and b must be of equal length.");
+ }
+ const min: number[] = [];
+ for (let ii = 0; ii < a.length; ++ii) {
+ min.push(op(a[ii], b[ii]));
+ }
+ return min;
+}
+
+// Adds vectors a and b.
+function addVec(a: number[], b: number[]): number[] {
+ return cwiseOp(a, b, (p, q) => {
+ return p + q;
+ });
+}
+
+// Represents a single line within a plot. Handles rendering the line with
+// all of its points and the appropriate color/markers/lines.
+export class Line {
+ private points: Float32Array = new Float32Array([]);
+ private _drawLine: boolean = true;
+ private _pointSize: number = 3.0;
+ private _hasUpdate: boolean = false;
+ private _minValues: number[] = [0.0, 0.0];
+ private _maxValues: number[] = [0.0, 0.0];
+ private _color: number[] = [1.0, 0.0, 0.0];
+ private pointAttribLocation: number;
+ private colorLocation: WebGLUniformLocation;
+ private pointSizeLocation: WebGLUniformLocation;
+ private _label: string;
+ constructor(
+ private readonly ctx: WebGLRenderingContext,
+ private readonly program: WebGLProgram,
+ private readonly buffer: WebGLBuffer) {
+ this.pointAttribLocation = this.ctx.getAttribLocation(this.program, 'apos');
+ this.colorLocation = this.ctx.getUniformLocation(this.program, 'color');
+ this.pointSizeLocation =
+ this.ctx.getUniformLocation(this.program, 'point_size');
+ }
+
+ // Return the largest x and y values present in the list of points.
+ maxValues(): number[] {
+ return this._maxValues;
+ }
+
+ // Return the smallest x and y values present in the list of points.
+ minValues(): number[] {
+ return this._minValues;
+ }
+
+ // Whether any parameters have changed that would require re-rending the line.
+ hasUpdate(): boolean {
+ return this._hasUpdate;
+ }
+
+ // Get/set the color of the line, returned as an RGB tuple.
+ color(): number[] {
+ return this._color;
+ }
+
+ setColor(newColor: number[]) {
+ this._color = newColor;
+ this._hasUpdate = true;
+ }
+
+ // Get/set the size of the markers to draw, in pixels (zero means no markers).
+ pointSize(): number {
+ return this._pointSize;
+ }
+
+ setPointSize(size: number) {
+ this._pointSize = size;
+ this._hasUpdate = true;
+ }
+
+ // Get/set whether we draw a line between the points (i.e., setting this to
+ // false would effectively create a scatter-plot). If drawLine is false and
+ // pointSize is zero, then no data is rendered.
+ drawLine(): boolean {
+ return this._drawLine;
+ }
+
+ setDrawLine(newDrawLine: boolean) {
+ this._drawLine = newDrawLine;
+ this._hasUpdate = true;
+ }
+
+ // Set the points to render. The points in the line are ordered and should
+ // be of the format:
+ // [x1, y1, x2, y2, x3, y3, ...., xN, yN]
+ setPoints(points: Float32Array) {
+ if (points.length % 2 !== 0) {
+ throw new Error("Must have even number of elements in points array.");
+ }
+ if (points.BYTES_PER_ELEMENT != 4) {
+ throw new Error(
+ 'Must pass in a Float32Array--actual size was ' +
+ points.BYTES_PER_ELEMENT + '.');
+ }
+ this.points = points;
+ this._hasUpdate = true;
+ this._minValues[0] = Infinity;
+ this._minValues[1] = Infinity;
+ this._maxValues[0] = -Infinity;
+ this._maxValues[1] = -Infinity;
+ for (let ii = 0; ii < this.points.length; ii += 2) {
+ const x = this.points[ii];
+ const y = this.points[ii + 1];
+
+ this._minValues = cwiseOp(this._minValues, [x, y], Math.min);
+ this._maxValues = cwiseOp(this._maxValues, [x, y], Math.max);
+ }
+ }
+
+ // Get/set the label to use for the line when drawing the legend.
+ setLabel(label: string) {
+ this._label = label;
+ }
+
+ label(): string {
+ return this._label;
+ }
+
+ // Render the line on the canvas.
+ draw() {
+ this._hasUpdate = false;
+ if (this.points.length === 0) {
+ return;
+ }
+
+ this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.buffer);
+ // Note: if this is generating errors associated with the buffer size,
+ // confirm that this.points really is a Float32Array.
+ this.ctx.bufferData(
+ this.ctx.ARRAY_BUFFER,
+ this.points,
+ this.ctx.STATIC_DRAW);
+ {
+ const numComponents = 2; // pull out 2 values per iteration
+ const numType = this.ctx.FLOAT; // the data in the buffer is 32bit floats
+ const normalize = false; // don't normalize
+ const stride = 0; // how many bytes to get from one set of values to the
+ // next 0 = use type and numComponents above
+ const offset = 0; // how many bytes inside the buffer to start from
+ this.ctx.vertexAttribPointer(
+ this.pointAttribLocation, numComponents, numType,
+ normalize, stride, offset);
+ this.ctx.enableVertexAttribArray(this.pointAttribLocation);
+ }
+
+ this.ctx.uniform1f(this.pointSizeLocation, this._pointSize);
+ this.ctx.uniform4f(
+ this.colorLocation, this._color[0], this._color[1], this._color[2],
+ 1.0);
+
+ if (this._drawLine) {
+ this.ctx.drawArrays(this.ctx.LINE_STRIP, 0, this.points.length / 2);
+ }
+ if (this._pointSize > 0.0) {
+ this.ctx.drawArrays(this.ctx.POINTS, 0, this.points.length / 2);
+ }
+ }
+}
+
+// Parameters used when scaling the lines to the canvas.
+// If a point in a line is at pos then its position in the canvas space will be
+// scale * pos + offset.
+class ZoomParameters {
+ public scale: number[] = [1.0, 1.0];
+ public offset: number[] = [0.0, 0.0];
+}
+
+enum MouseButton {
+ Right,
+ Middle,
+ Left
+}
+
+// The button to use for panning the plot.
+const PAN_BUTTON = MouseButton.Left;
+
+// Returns the mouse button that generated a given event.
+function transitionButton(event: MouseEvent): MouseButton {
+ switch (event.button) {
+ case 0:
+ return MouseButton.Left;
+ case 1:
+ return MouseButton.Right;
+ case 2:
+ return MouseButton.Middle;
+ }
+}
+
+// Returns whether the given button is pressed on the mouse.
+function buttonPressed(event: MouseEvent, button: MouseButton): boolean {
+ switch (button) {
+ // For some reason, the middle/right buttons are swapped relative to where
+ // we would expect them to be given the .button field.
+ case MouseButton.Left:
+ return 0 !== (event.buttons & 0x1);
+ case MouseButton.Middle:
+ return 0 !== (event.buttons & 0x2);
+ case MouseButton.Right:
+ return 0 !== (event.buttons & 0x4);
+ }
+}
+
+// Handles rendering a Legend for a list of lines.
+// This takes a 2d canvas, which is what we use for rendering all the text of
+// the plot and is separate, but overlayed on top of, the WebGL canvas that the
+// lines are drawn on.
+export class Legend {
+ // Location, in pixels, of the legend in the text canvas.
+ private location: number[] = [0, 0];
+ constructor(private ctx: CanvasRenderingContext2D, private lines: Line[]) {
+ this.location = [this.ctx.canvas.width - 100, 30];
+ }
+
+ setPosition(location: number[]): void {
+ this.location = location;
+ }
+
+ draw(): void {
+ this.ctx.save();
+
+ this.ctx.translate(this.location[0], this.location[1]);
+
+ // Space between rows of the legend.
+ const step = 20;
+
+ // Total height of the body of the legend.
+ const height = step * this.lines.length;
+
+ let maxWidth = 0;
+
+ // In the legend, we render both a small line of the appropriate color as
+ // well as the text label--start/endPoint are the relative locations of the
+ // endpoints of the miniature line within the row, and textStart is where
+ // we begin rendering the text within the row.
+ const startPoint = [0, 0];
+ const endPoint = [10, -10];
+ const textStart = endPoint[0] + 5;
+
+ // Calculate how wide the legend needs to be to fit all the text.
+ this.ctx.textAlign = 'left';
+ for (let line of this.lines) {
+ const width =
+ textStart + this.ctx.measureText(line.label()).actualBoundingBoxRight;
+ maxWidth = Math.max(width, maxWidth);
+ }
+
+ // Set the legend background to be white and opaque.
+ this.ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
+ const backgroundBuffer = 5;
+ this.ctx.fillRect(
+ -backgroundBuffer, 0, maxWidth + 2.0 * backgroundBuffer,
+ height + backgroundBuffer);
+
+ // Go through each line and render the little lines and text for each Line.
+ for (let line of this.lines) {
+ this.ctx.translate(0, step);
+ const color = line.color();
+ this.ctx.strokeStyle = `rgb(${255.0 * color[0]}, ${255.0 * color[1]}, ${255.0 * color[2]})`;
+ this.ctx.fillStyle = this.ctx.strokeStyle;
+ if (line.drawLine()) {
+ this.ctx.beginPath();
+ this.ctx.moveTo(startPoint[0], startPoint[1]);
+ this.ctx.lineTo(endPoint[0], endPoint[1]);
+ this.ctx.closePath();
+ this.ctx.stroke();
+ }
+ const pointSize = line.pointSize();
+ if (pointSize > 0) {
+ this.ctx.fillRect(
+ startPoint[0] - pointSize / 2.0, startPoint[1] - pointSize / 2.0,
+ pointSize, pointSize);
+ this.ctx.fillRect(
+ endPoint[0] - pointSize / 2.0, endPoint[1] - pointSize / 2.0,
+ pointSize, pointSize);
+ }
+
+ this.ctx.fillStyle = 'black';
+ this.ctx.textAlign = 'left';
+ this.ctx.fillText(line.label(), textStart, 0);
+ }
+
+ this.ctx.restore();
+ }
+}
+
+// This class manages all the WebGL rendering--namely, drawing the reference
+// grid for the user and then rendering all the actual lines of the plot.
+export class LineDrawer {
+ private program: WebGLProgram|null = null;
+ private scaleLocation: WebGLUniformLocation;
+ private offsetLocation: WebGLUniformLocation;
+ private vertexBuffer: WebGLBuffer;
+ private lines: Line[] = [];
+ private zoom: ZoomParameters = new ZoomParameters();
+ private zoomUpdated: boolean = true;
+ // Maximum grid lines to render at once--this is used provide an upper limit
+ // on the number of Line objects we need to create in order to render the
+ // grid.
+ public readonly MAX_GRID_LINES: number = 5;
+ // Arrays of the points at which we will draw grid lines for the x/y axes.
+ private xTicks: number[] = [];
+ private yTicks: number[] = [];
+ private xGridLines: Line[] = [];
+ private yGridLines: Line[] = [];
+
+ constructor(public readonly ctx: WebGLRenderingContext) {
+ this.program = this.compileShaders();
+ this.scaleLocation = this.ctx.getUniformLocation(this.program, 'scale');
+ this.offsetLocation = this.ctx.getUniformLocation(this.program, 'offset');
+ this.vertexBuffer = this.ctx.createBuffer();
+
+ for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
+ this.xGridLines.push(new Line(this.ctx, this.program, this.vertexBuffer));
+ this.yGridLines.push(new Line(this.ctx, this.program, this.vertexBuffer));
+ }
+ }
+
+ setXGrid(lines: Line[]) {
+ this.xGridLines = lines;
+ }
+
+ getZoom(): ZoomParameters {
+ return this.zoom;
+ }
+
+ plotToCanvasCoordinates(plotPos: number[]): number[] {
+ return addVec(cwiseOp(plotPos, this.zoom.scale, (a, b) => {
+ return a * b;
+ }), this.zoom.offset);
+ }
+
+
+ canvasToPlotCoordinates(canvasPos: number[]): number[] {
+ return cwiseOp(cwiseOp(canvasPos, this.zoom.offset, (a, b) => {
+ return a - b;
+ }), this.zoom.scale, (a, b) => {
+ return a / b;
+ });
+ }
+
+ // Tehse return the max/min rendered points, in plot-space (this is helpful
+ // for drawing axis labels).
+ maxVisiblePoint(): number[] {
+ return this.canvasToPlotCoordinates([1.0, 1.0]);
+ }
+
+ minVisiblePoint(): number[] {
+ return this.canvasToPlotCoordinates([-1.0, -1.0]);
+ }
+
+ getLines(): Line[] {
+ return this.lines;
+ }
+
+ setZoom(zoom: ZoomParameters) {
+ this.zoomUpdated = true;
+ this.zoom = zoom;
+ }
+
+ setXTicks(ticks: number[]): void {
+ this.xTicks = ticks;
+ }
+
+ setYTicks(ticks: number[]): void {
+ this.yTicks = ticks;
+ }
+
+ // Update the grid lines.
+ updateTicks() {
+ for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
+ this.xGridLines[ii].setPoints(new Float32Array([]));
+ this.yGridLines[ii].setPoints(new Float32Array([]));
+ }
+
+ const minValues = this.minVisiblePoint();
+ const maxValues = this.maxVisiblePoint();
+
+ for (let ii = 0; ii < this.xTicks.length; ++ii) {
+ this.xGridLines[ii].setColor([0.0, 0.0, 0.0]);
+ const points = new Float32Array(
+ [this.xTicks[ii], minValues[1], this.xTicks[ii], maxValues[1]]);
+ this.xGridLines[ii].setPointSize(0);
+ this.xGridLines[ii].setPoints(points);
+ this.xGridLines[ii].draw();
+ }
+
+ for (let ii = 0; ii < this.yTicks.length; ++ii) {
+ this.yGridLines[ii].setColor([0.0, 0.0, 0.0]);
+ const points = new Float32Array(
+ [minValues[0], this.yTicks[ii], maxValues[0], this.yTicks[ii]]);
+ this.yGridLines[ii].setPointSize(0);
+ this.yGridLines[ii].setPoints(points);
+ this.yGridLines[ii].draw();
+ }
+ }
+
+ // Handles redrawing any of the WebGL objects, if necessary.
+ draw(): void {
+ let needsUpdate = this.zoomUpdated;
+ this.zoomUpdated = false;
+ for (let line of this.lines) {
+ if (line.hasUpdate()) {
+ needsUpdate = true;
+ break;
+ }
+ }
+ if (!needsUpdate) {
+ return;
+ }
+
+ this.reset();
+
+ this.updateTicks();
+
+ for (let line of this.lines) {
+ line.draw();
+ }
+
+ return;
+ }
+
+ loadShader(shaderType: number, source: string): WebGLShader {
+ const shader = this.ctx.createShader(shaderType);
+ this.ctx.shaderSource(shader, source);
+ this.ctx.compileShader(shader);
+ if (!this.ctx.getShaderParameter(shader, this.ctx.COMPILE_STATUS)) {
+ alert(
+ 'Got an error compiling a shader: ' +
+ this.ctx.getShaderInfoLog(shader));
+ this.ctx.deleteShader(shader);
+ return null;
+ }
+
+ return shader;
+ }
+
+ compileShaders(): WebGLProgram {
+ const vertexShader = 'attribute vec2 apos;' +
+ 'uniform vec2 scale;' +
+ 'uniform vec2 offset;' +
+ 'uniform float point_size;' +
+ 'void main() {' +
+ ' gl_Position.xy = apos.xy * scale.xy + offset.xy;' +
+ ' gl_Position.z = 0.0;' +
+ ' gl_Position.w = 1.0;' +
+ ' gl_PointSize = point_size;' +
+ '}';
+
+ const fragmentShader = 'precision highp float;' +
+ 'uniform vec4 color;' +
+ 'void main() {' +
+ ' gl_FragColor = color;' +
+ '}';
+
+ const compiledVertex =
+ this.loadShader(this.ctx.VERTEX_SHADER, vertexShader);
+ const compiledFragment =
+ this.loadShader(this.ctx.FRAGMENT_SHADER, fragmentShader);
+ const program = this.ctx.createProgram();
+ this.ctx.attachShader(program, compiledVertex);
+ this.ctx.attachShader(program, compiledFragment);
+ this.ctx.linkProgram(program);
+ if (!this.ctx.getProgramParameter(program, this.ctx.LINK_STATUS)) {
+ alert(
+ 'Unable to link the shaders: ' + this.ctx.getProgramInfoLog(program));
+ return null;
+ }
+ return program;
+ }
+
+ addLine(): Line {
+ this.lines.push(new Line(this.ctx, this.program, this.vertexBuffer));
+ return this.lines[this.lines.length - 1];
+ }
+
+ minValues(): number[] {
+ let minValues = [Infinity, Infinity];
+ for (let line of this.lines) {
+ minValues = cwiseOp(minValues, line.minValues(), Math.min);
+ }
+ return minValues;
+ }
+
+ maxValues(): number[] {
+ let maxValues = [-Infinity, -Infinity];
+ for (let line of this.lines) {
+ maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
+ }
+ return maxValues;
+ }
+
+ reset(): void {
+ // Set the background color
+ this.ctx.clearColor(0.5, 0.5, 0.5, 1.0);
+ this.ctx.clearDepth(1.0);
+ this.ctx.enable(this.ctx.DEPTH_TEST);
+ this.ctx.depthFunc(this.ctx.LEQUAL);
+ this.ctx.clear(this.ctx.COLOR_BUFFER_BIT | this.ctx.DEPTH_BUFFER_BIT);
+
+ this.ctx.useProgram(this.program);
+
+ this.ctx.uniform2f(
+ this.scaleLocation, this.zoom.scale[0], this.zoom.scale[1]);
+ this.ctx.uniform2f(
+ this.offsetLocation, this.zoom.offset[0], this.zoom.offset[1]);
+ }
+}
+
+// Class to store how much whitespace we put between the edges of the WebGL
+// canvas (where we draw all the lines) and the edge of the plot. This gives
+// us space to, e.g., draw axis labels, the plot title, etc.
+class WhitespaceBuffers {
+ constructor(
+ public left: number, public right: number, public top: number,
+ public bottom: number) {}
+}
+
+// Class to manage all the annotations associated with the plot--the axis/tick
+// labels and the plot title.
+class AxisLabels {
+ private readonly INCREMENTS: number[] = [2, 4, 5, 10];
+ // Space to leave to create some visual space around the text.
+ private readonly TEXT_BUFFER: number = 5;
+ private title: string = "";
+ private xlabel: string = "";
+ private ylabel: string = "";
+ constructor(
+ private ctx: CanvasRenderingContext2D, private drawer: LineDrawer,
+ private graphBuffers: WhitespaceBuffers) {}
+
+ numberToLabel(num: number): string {
+ return num.toPrecision(5);
+ }
+
+ textWidth(str: string): number {
+ return this.ctx.measureText(str).actualBoundingBoxRight;
+ }
+
+ textHeight(str: string): number {
+ return this.ctx.measureText(str).actualBoundingBoxAscent;
+ }
+
+ textDepth(str: string): number {
+ return this.ctx.measureText(str).actualBoundingBoxDescent;
+ }
+
+ setTitle(title: string) {
+ this.title = title;
+ }
+
+ setXLabel(xlabel: string) {
+ this.xlabel = xlabel;
+ }
+
+ setYLabel(ylabel: string) {
+ this.ylabel = ylabel;
+ }
+
+ getIncrement(range: number[]): number {
+ const diff = Math.abs(range[1] - range[0]);
+ const minDiff = diff / this.drawer.MAX_GRID_LINES;
+ const incrementsRatio = this.INCREMENTS[this.INCREMENTS.length - 1];
+ const order = Math.pow(
+ incrementsRatio,
+ Math.floor(Math.log(minDiff) / Math.log(incrementsRatio)));
+ const normalizedDiff = minDiff / order;
+ for (let increment of this.INCREMENTS) {
+ if (increment > normalizedDiff) {
+ return increment * order;
+ }
+ }
+ return 1.0;
+ }
+
+ getTicks(range: number[]): number[] {
+ const increment = this.getIncrement(range);
+ const start = Math.ceil(range[0] / increment) * increment;
+ const values = [start];
+ for (let ii = 0; ii < this.drawer.MAX_GRID_LINES - 1; ++ii) {
+ const nextValue = values[ii] + increment;
+ if (nextValue > range[1]) {
+ break;
+ }
+ values.push(nextValue);
+ }
+ return values;
+ }
+
+ plotToCanvasCoordinates(plotPos: number[]): number[] {
+ const webglCoord = this.drawer.plotToCanvasCoordinates(plotPos);
+ const webglX = (webglCoord[0] + 1.0) / 2.0 * this.drawer.ctx.canvas.width;
+ const webglY = (1.0 - webglCoord[1]) / 2.0 * this.drawer.ctx.canvas.height;
+ return [webglX + this.graphBuffers.left, webglY + this.graphBuffers.top];
+ }
+
+ drawXTick(x: number) {
+ const text = this.numberToLabel(x);
+ const height = this.textHeight(text);
+ const xpos = this.plotToCanvasCoordinates([x, 0])[0];
+ this.ctx.textAlign = "center";
+ this.ctx.fillText(
+ text, xpos,
+ this.ctx.canvas.height - this.graphBuffers.bottom + height +
+ this.TEXT_BUFFER);
+ }
+
+ drawYTick(y: number) {
+ const text = this.numberToLabel(y);
+ const height = this.textHeight(text);
+ const ypos = this.plotToCanvasCoordinates([0, y])[1];
+ this.ctx.textAlign = "right";
+ this.ctx.fillText(
+ text, this.graphBuffers.left - this.TEXT_BUFFER,
+ ypos + height / 2.0);
+ }
+
+ drawTitle() {
+ if (this.title) {
+ this.ctx.textAlign = 'center';
+ this.ctx.fillText(
+ this.title, this.ctx.canvas.width / 2.0,
+ this.graphBuffers.top - this.TEXT_BUFFER);
+ }
+ }
+
+ drawXLabel() {
+ if (this.xlabel) {
+ this.ctx.textAlign = 'center';
+ this.ctx.fillText(
+ this.xlabel, this.ctx.canvas.width / 2.0,
+ this.ctx.canvas.height - this.TEXT_BUFFER);
+ }
+ }
+
+ drawYLabel() {
+ this.ctx.save();
+ if (this.ylabel) {
+ this.ctx.textAlign = 'center';
+ const height = this.textHeight(this.ylabel);
+ this.ctx.translate(
+ height + this.TEXT_BUFFER, this.ctx.canvas.height / 2.0);
+ this.ctx.rotate(-Math.PI / 2.0);
+ this.ctx.fillText(this.ylabel, 0, 0);
+ }
+ this.ctx.restore();
+ }
+
+ draw() {
+ this.ctx.fillStyle = 'black';
+ const minValues = this.drawer.minVisiblePoint();
+ const maxValues = this.drawer.maxVisiblePoint();
+ let text = this.numberToLabel(maxValues[1]);
+ this.drawYTick(maxValues[1]);
+ this.drawYTick(minValues[1]);
+ this.drawXTick(minValues[0]);
+ this.drawXTick(maxValues[0]);
+ this.ctx.strokeStyle = 'black';
+ this.ctx.strokeRect(
+ this.graphBuffers.left, this.graphBuffers.top,
+ this.drawer.ctx.canvas.width, this.drawer.ctx.canvas.height);
+ this.ctx.strokeRect(
+ 0, 0,
+ this.ctx.canvas.width, this.ctx.canvas.height);
+ const xTicks = this.getTicks([minValues[0], maxValues[0]]);
+ this.drawer.setXTicks(xTicks);
+ const yTicks = this.getTicks([minValues[1], maxValues[1]]);
+ this.drawer.setYTicks(yTicks);
+
+ for (let x of xTicks) {
+ this.drawXTick(x);
+ }
+
+ for (let y of yTicks) {
+ this.drawYTick(y);
+ }
+
+ this.drawTitle();
+ this.drawXLabel();
+ this.drawYLabel();
+ }
+
+ // Draws the current mouse position in the bottom-right of the plot.
+ drawMousePosition(mousePos: number[]) {
+ const plotPos = this.drawer.canvasToPlotCoordinates(mousePos);
+
+ const text =
+ `(${plotPos[0].toPrecision(10)}, ${plotPos[1].toPrecision(10)})`;
+ const textDepth = this.textDepth(text);
+ this.ctx.textAlign = 'right';
+ this.ctx.fillText(
+ text, this.ctx.canvas.width - this.graphBuffers.right,
+ this.ctx.canvas.height - this.graphBuffers.bottom - textDepth);
+ }
+}
+
+// This class manages the entirety of a single plot. Most of the logic in
+// this class is around handling mouse/keyboard events for interacting with
+// the plot.
+export class Plot {
+ private canvas = document.createElement('canvas');
+ private textCanvas = document.createElement('canvas');
+ private drawer: LineDrawer;
+ private static keysPressed: object = {'x': false, 'y': false};
+ // In canvas coordinates (the +/-1 square).
+ private lastMousePanPosition: number[] = null;
+ private axisLabelBuffer: WhitespaceBuffers =
+ new WhitespaceBuffers(50, 20, 20, 30);
+ private axisLabels: AxisLabels;
+ private legend: Legend;
+ private lastMousePosition: number[] = [0.0, 0.0];
+ private autoFollow: boolean = true;
+ private linkedXAxes: Plot[] = [];
+
+ constructor(wrapperDiv: HTMLDivElement, width: number, height: number) {
+ wrapperDiv.appendChild(this.canvas);
+ wrapperDiv.appendChild(this.textCanvas);
+
+ this.canvas.width =
+ width - this.axisLabelBuffer.left - this.axisLabelBuffer.right;
+ this.canvas.height =
+ height - this.axisLabelBuffer.top - this.axisLabelBuffer.bottom;
+ this.canvas.style.left = this.axisLabelBuffer.left.toString();
+ this.canvas.style.top = this.axisLabelBuffer.top.toString();
+ this.canvas.style.position = 'absolute';
+ this.drawer = new LineDrawer(this.canvas.getContext('webgl'));
+
+ this.textCanvas.width = width;
+ this.textCanvas.height = height;
+ this.textCanvas.style.left = '0';
+ this.textCanvas.style.top = '0';
+ this.textCanvas.style.position = 'absolute';
+ this.textCanvas.style.pointerEvents = 'none';
+
+ this.canvas.addEventListener('dblclick', (e) => {
+ this.handleDoubleClick(e);
+ });
+ this.canvas.onwheel = (e) => {
+ this.handleWheel(e);
+ e.preventDefault();
+ };
+ this.canvas.onmousedown = (e) => {
+ this.handleMouseDown(e);
+ };
+ this.canvas.onmouseup = (e) => {
+ this.handleMouseUp(e);
+ };
+ this.canvas.onmousemove = (e) => {
+ this.handleMouseMove(e);
+ };
+ // TODO(james): Deconflict the global state....
+ document.onkeydown = (e) => {
+ this.handleKeyDown(e);
+ };
+ document.onkeyup = (e) => {
+ this.handleKeyUp(e);
+ };
+
+ const textCtx = this.textCanvas.getContext("2d");
+ this.axisLabels =
+ new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
+ this.legend = new Legend(textCtx, this.drawer.getLines());
+
+ this.draw();
+ }
+
+ handleDoubleClick(event: MouseEvent) {
+ this.resetZoom();
+ }
+
+ mouseCanvasLocation(event: MouseEvent): number[] {
+ return [
+ event.offsetX * 2.0 / this.canvas.width - 1.0,
+ -event.offsetY * 2.0 / this.canvas.height + 1.0
+ ];
+ }
+
+ handleWheel(event: WheelEvent) {
+ if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
+ return;
+ }
+ const mousePosition = this.mouseCanvasLocation(event);
+ const kWheelTuningScalar = 1.5;
+ const zoom = -kWheelTuningScalar * event.deltaY / this.canvas.height;
+ let zoomScalar = 1.0 + Math.abs(zoom);
+ if (zoom < 0.0) {
+ zoomScalar = 1.0 / zoomScalar;
+ }
+ const scale = scaleVec(this.drawer.getZoom().scale, zoomScalar);
+ const offset = addVec(
+ scaleVec(mousePosition, 1.0 - zoomScalar),
+ scaleVec(this.drawer.getZoom().offset, zoomScalar));
+ this.setZoom(scale, offset);
+ }
+
+ handleMouseDown(event: MouseEvent) {
+ if (transitionButton(event) === PAN_BUTTON) {
+ this.lastMousePanPosition = this.mouseCanvasLocation(event);
+ }
+ }
+
+ handleMouseUp(event: MouseEvent) {
+ if (transitionButton(event) === PAN_BUTTON) {
+ this.lastMousePanPosition = null;
+ }
+ }
+
+ handleMouseMove(event: MouseEvent) {
+ const mouseLocation = this.mouseCanvasLocation(event);
+ if (buttonPressed(event, PAN_BUTTON) &&
+ (this.lastMousePanPosition !== null)) {
+ const mouseDiff =
+ addVec(mouseLocation, scaleVec(this.lastMousePanPosition, -1));
+ this.setZoom(
+ this.drawer.getZoom().scale,
+ addVec(this.drawer.getZoom().offset, mouseDiff));
+ this.lastMousePanPosition = mouseLocation;
+ }
+ this.lastMousePosition = mouseLocation;
+ }
+
+ setZoom(scale: number[], offset: number[]) {
+ const x_pressed = Plot.keysPressed["x"];
+ const y_pressed = Plot.keysPressed["y"];
+ const zoom = this.drawer.getZoom();
+ if (x_pressed && !y_pressed) {
+ zoom.scale[0] = scale[0];
+ zoom.offset[0] = offset[0];
+ } else if (y_pressed && !x_pressed) {
+ zoom.scale[1] = scale[1];
+ zoom.offset[1] = offset[1];
+ } else {
+ zoom.scale = scale;
+ zoom.offset = offset;
+ }
+
+ for (let plot of this.linkedXAxes) {
+ const otherZoom = plot.drawer.getZoom();
+ otherZoom.scale[0] = zoom.scale[0];
+ otherZoom.offset[0] = zoom.offset[0];
+ plot.drawer.setZoom(otherZoom);
+ plot.autoFollow = false;
+ }
+ this.drawer.setZoom(zoom);
+ this.autoFollow = false;
+ }
+
+
+ setZoomCorners(c1: number[], c2: number[]) {
+ const scale = cwiseOp(c1, c2, (a, b) => {
+ return 2.0 / Math.abs(a - b);
+ });
+ const offset = cwiseOp(scale, cwiseOp(c1, c2, Math.max), (a, b) => {
+ return 1.0 - a * b;
+ });
+ this.setZoom(scale, offset);
+ }
+
+ resetZoom() {
+ this.setZoomCorners(this.drawer.minValues(), this.drawer.maxValues());
+ this.autoFollow = true;
+ for (let plot of this.linkedXAxes) {
+ plot.autoFollow = true;
+ }
+ }
+
+ handleKeyUp(event: KeyboardEvent) {
+ Plot.keysPressed[event.key] = false;
+ }
+
+ handleKeyDown(event: KeyboardEvent) {
+ Plot.keysPressed[event.key] = true;
+ }
+
+ draw() {
+ window.requestAnimationFrame(() => this.draw());
+
+ // Clear the overlay.
+ const textCtx = this.textCanvas.getContext("2d");
+ textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
+
+ this.axisLabels.draw();
+ this.axisLabels.drawMousePosition(this.lastMousePosition);
+ this.legend.draw();
+
+ this.drawer.draw();
+
+ if (this.autoFollow) {
+ this.resetZoom();
+ }
+ }
+
+ getDrawer(): LineDrawer {
+ return this.drawer;
+ }
+
+ getLegend(): Legend {
+ return this.legend;
+ }
+
+ getAxisLabels(): AxisLabels {
+ return this.axisLabels;
+ }
+
+ // Links this plot's x-axis with that of another Plot (e.g., to share time
+ // axes).
+ linkXAxis(other: Plot) {
+ this.linkedXAxes.push(other);
+ other.linkedXAxes.push(this);
+ }
+}