blob: b4fc2ebd923742cc540e665accd70c12b9df75a1 [file] [log] [blame]
James Kuszmaula8f2c452020-07-05 21:17:56 -07001// Multiplies all the values in the provided array by scale.
2function scaleVec(vec: number[], scale: number): number[] {
3 const scaled: number[] = [];
4 for (let num of vec) {
5 scaled.push(num * scale);
6 }
7 return scaled;
8}
9
10// Runs the operation op() over every pair of numbers in a, b and returns
11// the result.
12function cwiseOp(
13 a: number[], b: number[], op: (a: number, b: number) => number): number[] {
14 if (a.length !== b.length) {
15 throw new Error("a and b must be of equal length.");
16 }
17 const min: number[] = [];
18 for (let ii = 0; ii < a.length; ++ii) {
19 min.push(op(a[ii], b[ii]));
20 }
21 return min;
22}
23
24// Adds vectors a and b.
25function addVec(a: number[], b: number[]): number[] {
26 return cwiseOp(a, b, (p, q) => {
27 return p + q;
28 });
29}
30
31// Represents a single line within a plot. Handles rendering the line with
32// all of its points and the appropriate color/markers/lines.
33export class Line {
34 private points: Float32Array = new Float32Array([]);
35 private _drawLine: boolean = true;
36 private _pointSize: number = 3.0;
37 private _hasUpdate: boolean = false;
James Kuszmaul71a81932020-12-15 21:08:01 -080038 private _minValues: number[] = [Infinity, Infinity];
39 private _maxValues: number[] = [-Infinity, -Infinity];
James Kuszmaula8f2c452020-07-05 21:17:56 -070040 private _color: number[] = [1.0, 0.0, 0.0];
41 private pointAttribLocation: number;
Philipp Schradere625ba22020-11-16 20:11:37 -080042 private colorLocation: WebGLUniformLocation | null;
43 private pointSizeLocation: WebGLUniformLocation | null;
James Kuszmaul461b0682020-12-22 22:20:21 -080044 private _label: string|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -070045 constructor(
46 private readonly ctx: WebGLRenderingContext,
47 private readonly program: WebGLProgram,
48 private readonly buffer: WebGLBuffer) {
49 this.pointAttribLocation = this.ctx.getAttribLocation(this.program, 'apos');
50 this.colorLocation = this.ctx.getUniformLocation(this.program, 'color');
51 this.pointSizeLocation =
52 this.ctx.getUniformLocation(this.program, 'point_size');
53 }
54
55 // Return the largest x and y values present in the list of points.
56 maxValues(): number[] {
57 return this._maxValues;
58 }
59
60 // Return the smallest x and y values present in the list of points.
61 minValues(): number[] {
62 return this._minValues;
63 }
64
65 // Whether any parameters have changed that would require re-rending the line.
66 hasUpdate(): boolean {
67 return this._hasUpdate;
68 }
69
70 // Get/set the color of the line, returned as an RGB tuple.
71 color(): number[] {
72 return this._color;
73 }
74
Austin Schuh7d63eab2021-03-06 20:15:02 -080075 setColor(newColor: number[]): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -070076 this._color = newColor;
77 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -080078 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -070079 }
80
81 // Get/set the size of the markers to draw, in pixels (zero means no markers).
82 pointSize(): number {
83 return this._pointSize;
84 }
85
Austin Schuh7d63eab2021-03-06 20:15:02 -080086 setPointSize(size: number): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -070087 this._pointSize = size;
88 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -080089 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -070090 }
91
92 // Get/set whether we draw a line between the points (i.e., setting this to
93 // false would effectively create a scatter-plot). If drawLine is false and
94 // pointSize is zero, then no data is rendered.
95 drawLine(): boolean {
96 return this._drawLine;
97 }
98
99 setDrawLine(newDrawLine: boolean) {
100 this._drawLine = newDrawLine;
101 this._hasUpdate = true;
102 }
103
104 // Set the points to render. The points in the line are ordered and should
105 // be of the format:
106 // [x1, y1, x2, y2, x3, y3, ...., xN, yN]
107 setPoints(points: Float32Array) {
108 if (points.length % 2 !== 0) {
109 throw new Error("Must have even number of elements in points array.");
110 }
111 if (points.BYTES_PER_ELEMENT != 4) {
112 throw new Error(
113 'Must pass in a Float32Array--actual size was ' +
114 points.BYTES_PER_ELEMENT + '.');
115 }
116 this.points = points;
117 this._hasUpdate = true;
118 this._minValues[0] = Infinity;
119 this._minValues[1] = Infinity;
120 this._maxValues[0] = -Infinity;
121 this._maxValues[1] = -Infinity;
122 for (let ii = 0; ii < this.points.length; ii += 2) {
123 const x = this.points[ii];
124 const y = this.points[ii + 1];
125
James Kuszmaul71a81932020-12-15 21:08:01 -0800126 if (isNaN(x) || isNaN(y)) {
127 continue;
128 }
129
James Kuszmaula8f2c452020-07-05 21:17:56 -0700130 this._minValues = cwiseOp(this._minValues, [x, y], Math.min);
131 this._maxValues = cwiseOp(this._maxValues, [x, y], Math.max);
132 }
133 }
134
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800135 getPoints(): Float32Array {
136 return this.points;
137 }
138
James Kuszmaula8f2c452020-07-05 21:17:56 -0700139 // Get/set the label to use for the line when drawing the legend.
140 setLabel(label: string) {
141 this._label = label;
142 }
143
James Kuszmaul461b0682020-12-22 22:20:21 -0800144 label(): string|null {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700145 return this._label;
146 }
147
148 // Render the line on the canvas.
149 draw() {
150 this._hasUpdate = false;
151 if (this.points.length === 0) {
152 return;
153 }
154
155 this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.buffer);
156 // Note: if this is generating errors associated with the buffer size,
157 // confirm that this.points really is a Float32Array.
158 this.ctx.bufferData(
159 this.ctx.ARRAY_BUFFER,
160 this.points,
161 this.ctx.STATIC_DRAW);
162 {
163 const numComponents = 2; // pull out 2 values per iteration
164 const numType = this.ctx.FLOAT; // the data in the buffer is 32bit floats
165 const normalize = false; // don't normalize
166 const stride = 0; // how many bytes to get from one set of values to the
167 // next 0 = use type and numComponents above
168 const offset = 0; // how many bytes inside the buffer to start from
169 this.ctx.vertexAttribPointer(
170 this.pointAttribLocation, numComponents, numType,
171 normalize, stride, offset);
172 this.ctx.enableVertexAttribArray(this.pointAttribLocation);
173 }
174
175 this.ctx.uniform1f(this.pointSizeLocation, this._pointSize);
176 this.ctx.uniform4f(
177 this.colorLocation, this._color[0], this._color[1], this._color[2],
178 1.0);
179
180 if (this._drawLine) {
181 this.ctx.drawArrays(this.ctx.LINE_STRIP, 0, this.points.length / 2);
182 }
183 if (this._pointSize > 0.0) {
184 this.ctx.drawArrays(this.ctx.POINTS, 0, this.points.length / 2);
185 }
186 }
187}
188
189// Parameters used when scaling the lines to the canvas.
190// If a point in a line is at pos then its position in the canvas space will be
191// scale * pos + offset.
192class ZoomParameters {
193 public scale: number[] = [1.0, 1.0];
194 public offset: number[] = [0.0, 0.0];
195}
196
197enum MouseButton {
198 Right,
199 Middle,
200 Left
201}
202
203// The button to use for panning the plot.
204const PAN_BUTTON = MouseButton.Left;
James Kuszmaul461b0682020-12-22 22:20:21 -0800205const RECTANGLE_BUTTON = MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700206
207// Returns the mouse button that generated a given event.
208function transitionButton(event: MouseEvent): MouseButton {
209 switch (event.button) {
210 case 0:
211 return MouseButton.Left;
212 case 1:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700213 return MouseButton.Middle;
James Kuszmaulbce45332020-12-15 19:50:01 -0800214 case 2:
215 return MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700216 }
217}
218
219// Returns whether the given button is pressed on the mouse.
220function buttonPressed(event: MouseEvent, button: MouseButton): boolean {
221 switch (button) {
222 // For some reason, the middle/right buttons are swapped relative to where
223 // we would expect them to be given the .button field.
224 case MouseButton.Left:
225 return 0 !== (event.buttons & 0x1);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700226 case MouseButton.Right:
James Kuszmaulbce45332020-12-15 19:50:01 -0800227 return 0 !== (event.buttons & 0x2);
228 case MouseButton.Middle:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700229 return 0 !== (event.buttons & 0x4);
230 }
231}
232
233// Handles rendering a Legend for a list of lines.
234// This takes a 2d canvas, which is what we use for rendering all the text of
235// the plot and is separate, but overlayed on top of, the WebGL canvas that the
236// lines are drawn on.
237export class Legend {
238 // Location, in pixels, of the legend in the text canvas.
239 private location: number[] = [0, 0];
240 constructor(private ctx: CanvasRenderingContext2D, private lines: Line[]) {
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800241 this.location = [80, 30];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700242 }
243
244 setPosition(location: number[]): void {
245 this.location = location;
246 }
247
248 draw(): void {
249 this.ctx.save();
250
251 this.ctx.translate(this.location[0], this.location[1]);
252
253 // Space between rows of the legend.
254 const step = 20;
255
James Kuszmaula8f2c452020-07-05 21:17:56 -0700256 let maxWidth = 0;
257
258 // In the legend, we render both a small line of the appropriate color as
259 // well as the text label--start/endPoint are the relative locations of the
260 // endpoints of the miniature line within the row, and textStart is where
261 // we begin rendering the text within the row.
262 const startPoint = [0, 0];
263 const endPoint = [10, -10];
264 const textStart = endPoint[0] + 5;
265
266 // Calculate how wide the legend needs to be to fit all the text.
267 this.ctx.textAlign = 'left';
James Kuszmaul461b0682020-12-22 22:20:21 -0800268 let numLabels = 0;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700269 for (let line of this.lines) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800270 if (line.label() === null) {
271 continue;
272 }
273 ++numLabels;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700274 const width =
275 textStart + this.ctx.measureText(line.label()).actualBoundingBoxRight;
276 maxWidth = Math.max(width, maxWidth);
277 }
278
James Kuszmaul461b0682020-12-22 22:20:21 -0800279 if (numLabels === 0) {
280 this.ctx.restore();
281 return;
282 }
283
284 // Total height of the body of the legend.
285 const height = step * numLabels;
286
James Kuszmaula8f2c452020-07-05 21:17:56 -0700287 // Set the legend background to be white and opaque.
288 this.ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
289 const backgroundBuffer = 5;
290 this.ctx.fillRect(
291 -backgroundBuffer, 0, maxWidth + 2.0 * backgroundBuffer,
292 height + backgroundBuffer);
293
294 // Go through each line and render the little lines and text for each Line.
295 for (let line of this.lines) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800296 if (line.label() === null) {
297 continue;
298 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700299 this.ctx.translate(0, step);
300 const color = line.color();
301 this.ctx.strokeStyle = `rgb(${255.0 * color[0]}, ${255.0 * color[1]}, ${255.0 * color[2]})`;
302 this.ctx.fillStyle = this.ctx.strokeStyle;
303 if (line.drawLine()) {
304 this.ctx.beginPath();
305 this.ctx.moveTo(startPoint[0], startPoint[1]);
306 this.ctx.lineTo(endPoint[0], endPoint[1]);
307 this.ctx.closePath();
308 this.ctx.stroke();
309 }
310 const pointSize = line.pointSize();
311 if (pointSize > 0) {
312 this.ctx.fillRect(
313 startPoint[0] - pointSize / 2.0, startPoint[1] - pointSize / 2.0,
314 pointSize, pointSize);
315 this.ctx.fillRect(
316 endPoint[0] - pointSize / 2.0, endPoint[1] - pointSize / 2.0,
317 pointSize, pointSize);
318 }
319
320 this.ctx.fillStyle = 'black';
321 this.ctx.textAlign = 'left';
322 this.ctx.fillText(line.label(), textStart, 0);
323 }
324
325 this.ctx.restore();
326 }
327}
328
329// This class manages all the WebGL rendering--namely, drawing the reference
330// grid for the user and then rendering all the actual lines of the plot.
331export class LineDrawer {
332 private program: WebGLProgram|null = null;
333 private scaleLocation: WebGLUniformLocation;
334 private offsetLocation: WebGLUniformLocation;
335 private vertexBuffer: WebGLBuffer;
336 private lines: Line[] = [];
337 private zoom: ZoomParameters = new ZoomParameters();
338 private zoomUpdated: boolean = true;
339 // Maximum grid lines to render at once--this is used provide an upper limit
340 // on the number of Line objects we need to create in order to render the
341 // grid.
342 public readonly MAX_GRID_LINES: number = 5;
343 // Arrays of the points at which we will draw grid lines for the x/y axes.
344 private xTicks: number[] = [];
345 private yTicks: number[] = [];
346 private xGridLines: Line[] = [];
347 private yGridLines: Line[] = [];
348
349 constructor(public readonly ctx: WebGLRenderingContext) {
350 this.program = this.compileShaders();
351 this.scaleLocation = this.ctx.getUniformLocation(this.program, 'scale');
352 this.offsetLocation = this.ctx.getUniformLocation(this.program, 'offset');
353 this.vertexBuffer = this.ctx.createBuffer();
354
355 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
356 this.xGridLines.push(new Line(this.ctx, this.program, this.vertexBuffer));
357 this.yGridLines.push(new Line(this.ctx, this.program, this.vertexBuffer));
358 }
359 }
360
361 setXGrid(lines: Line[]) {
362 this.xGridLines = lines;
363 }
364
365 getZoom(): ZoomParameters {
366 return this.zoom;
367 }
368
369 plotToCanvasCoordinates(plotPos: number[]): number[] {
370 return addVec(cwiseOp(plotPos, this.zoom.scale, (a, b) => {
371 return a * b;
372 }), this.zoom.offset);
373 }
374
375
376 canvasToPlotCoordinates(canvasPos: number[]): number[] {
377 return cwiseOp(cwiseOp(canvasPos, this.zoom.offset, (a, b) => {
378 return a - b;
379 }), this.zoom.scale, (a, b) => {
380 return a / b;
381 });
382 }
383
384 // Tehse return the max/min rendered points, in plot-space (this is helpful
385 // for drawing axis labels).
386 maxVisiblePoint(): number[] {
387 return this.canvasToPlotCoordinates([1.0, 1.0]);
388 }
389
390 minVisiblePoint(): number[] {
391 return this.canvasToPlotCoordinates([-1.0, -1.0]);
392 }
393
394 getLines(): Line[] {
395 return this.lines;
396 }
397
398 setZoom(zoom: ZoomParameters) {
399 this.zoomUpdated = true;
400 this.zoom = zoom;
401 }
402
403 setXTicks(ticks: number[]): void {
404 this.xTicks = ticks;
405 }
406
407 setYTicks(ticks: number[]): void {
408 this.yTicks = ticks;
409 }
410
411 // Update the grid lines.
412 updateTicks() {
413 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
414 this.xGridLines[ii].setPoints(new Float32Array([]));
415 this.yGridLines[ii].setPoints(new Float32Array([]));
416 }
417
418 const minValues = this.minVisiblePoint();
419 const maxValues = this.maxVisiblePoint();
420
421 for (let ii = 0; ii < this.xTicks.length; ++ii) {
422 this.xGridLines[ii].setColor([0.0, 0.0, 0.0]);
423 const points = new Float32Array(
424 [this.xTicks[ii], minValues[1], this.xTicks[ii], maxValues[1]]);
425 this.xGridLines[ii].setPointSize(0);
426 this.xGridLines[ii].setPoints(points);
427 this.xGridLines[ii].draw();
428 }
429
430 for (let ii = 0; ii < this.yTicks.length; ++ii) {
431 this.yGridLines[ii].setColor([0.0, 0.0, 0.0]);
432 const points = new Float32Array(
433 [minValues[0], this.yTicks[ii], maxValues[0], this.yTicks[ii]]);
434 this.yGridLines[ii].setPointSize(0);
435 this.yGridLines[ii].setPoints(points);
436 this.yGridLines[ii].draw();
437 }
438 }
439
440 // Handles redrawing any of the WebGL objects, if necessary.
441 draw(): void {
442 let needsUpdate = this.zoomUpdated;
443 this.zoomUpdated = false;
444 for (let line of this.lines) {
445 if (line.hasUpdate()) {
446 needsUpdate = true;
447 break;
448 }
449 }
450 if (!needsUpdate) {
451 return;
452 }
453
454 this.reset();
455
456 this.updateTicks();
457
458 for (let line of this.lines) {
459 line.draw();
460 }
461
462 return;
463 }
464
465 loadShader(shaderType: number, source: string): WebGLShader {
466 const shader = this.ctx.createShader(shaderType);
467 this.ctx.shaderSource(shader, source);
468 this.ctx.compileShader(shader);
469 if (!this.ctx.getShaderParameter(shader, this.ctx.COMPILE_STATUS)) {
470 alert(
471 'Got an error compiling a shader: ' +
472 this.ctx.getShaderInfoLog(shader));
473 this.ctx.deleteShader(shader);
474 return null;
475 }
476
477 return shader;
478 }
479
480 compileShaders(): WebGLProgram {
481 const vertexShader = 'attribute vec2 apos;' +
482 'uniform vec2 scale;' +
483 'uniform vec2 offset;' +
484 'uniform float point_size;' +
485 'void main() {' +
486 ' gl_Position.xy = apos.xy * scale.xy + offset.xy;' +
487 ' gl_Position.z = 0.0;' +
488 ' gl_Position.w = 1.0;' +
489 ' gl_PointSize = point_size;' +
490 '}';
491
492 const fragmentShader = 'precision highp float;' +
493 'uniform vec4 color;' +
494 'void main() {' +
495 ' gl_FragColor = color;' +
496 '}';
497
498 const compiledVertex =
499 this.loadShader(this.ctx.VERTEX_SHADER, vertexShader);
500 const compiledFragment =
501 this.loadShader(this.ctx.FRAGMENT_SHADER, fragmentShader);
502 const program = this.ctx.createProgram();
503 this.ctx.attachShader(program, compiledVertex);
504 this.ctx.attachShader(program, compiledFragment);
505 this.ctx.linkProgram(program);
506 if (!this.ctx.getProgramParameter(program, this.ctx.LINK_STATUS)) {
507 alert(
508 'Unable to link the shaders: ' + this.ctx.getProgramInfoLog(program));
509 return null;
510 }
511 return program;
512 }
513
514 addLine(): Line {
515 this.lines.push(new Line(this.ctx, this.program, this.vertexBuffer));
516 return this.lines[this.lines.length - 1];
517 }
518
519 minValues(): number[] {
520 let minValues = [Infinity, Infinity];
521 for (let line of this.lines) {
522 minValues = cwiseOp(minValues, line.minValues(), Math.min);
523 }
524 return minValues;
525 }
526
527 maxValues(): number[] {
528 let maxValues = [-Infinity, -Infinity];
529 for (let line of this.lines) {
530 maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
531 }
532 return maxValues;
533 }
534
535 reset(): void {
536 // Set the background color
537 this.ctx.clearColor(0.5, 0.5, 0.5, 1.0);
538 this.ctx.clearDepth(1.0);
539 this.ctx.enable(this.ctx.DEPTH_TEST);
540 this.ctx.depthFunc(this.ctx.LEQUAL);
541 this.ctx.clear(this.ctx.COLOR_BUFFER_BIT | this.ctx.DEPTH_BUFFER_BIT);
542
543 this.ctx.useProgram(this.program);
544
545 this.ctx.uniform2f(
546 this.scaleLocation, this.zoom.scale[0], this.zoom.scale[1]);
547 this.ctx.uniform2f(
548 this.offsetLocation, this.zoom.offset[0], this.zoom.offset[1]);
549 }
550}
551
552// Class to store how much whitespace we put between the edges of the WebGL
553// canvas (where we draw all the lines) and the edge of the plot. This gives
554// us space to, e.g., draw axis labels, the plot title, etc.
555class WhitespaceBuffers {
556 constructor(
557 public left: number, public right: number, public top: number,
558 public bottom: number) {}
559}
560
561// Class to manage all the annotations associated with the plot--the axis/tick
562// labels and the plot title.
563class AxisLabels {
564 private readonly INCREMENTS: number[] = [2, 4, 5, 10];
565 // Space to leave to create some visual space around the text.
566 private readonly TEXT_BUFFER: number = 5;
567 private title: string = "";
568 private xlabel: string = "";
569 private ylabel: string = "";
570 constructor(
571 private ctx: CanvasRenderingContext2D, private drawer: LineDrawer,
572 private graphBuffers: WhitespaceBuffers) {}
573
574 numberToLabel(num: number): string {
575 return num.toPrecision(5);
576 }
577
578 textWidth(str: string): number {
579 return this.ctx.measureText(str).actualBoundingBoxRight;
580 }
581
582 textHeight(str: string): number {
583 return this.ctx.measureText(str).actualBoundingBoxAscent;
584 }
585
586 textDepth(str: string): number {
587 return this.ctx.measureText(str).actualBoundingBoxDescent;
588 }
589
590 setTitle(title: string) {
591 this.title = title;
592 }
593
594 setXLabel(xlabel: string) {
595 this.xlabel = xlabel;
596 }
597
598 setYLabel(ylabel: string) {
599 this.ylabel = ylabel;
600 }
601
602 getIncrement(range: number[]): number {
603 const diff = Math.abs(range[1] - range[0]);
604 const minDiff = diff / this.drawer.MAX_GRID_LINES;
605 const incrementsRatio = this.INCREMENTS[this.INCREMENTS.length - 1];
606 const order = Math.pow(
607 incrementsRatio,
608 Math.floor(Math.log(minDiff) / Math.log(incrementsRatio)));
609 const normalizedDiff = minDiff / order;
610 for (let increment of this.INCREMENTS) {
611 if (increment > normalizedDiff) {
612 return increment * order;
613 }
614 }
615 return 1.0;
616 }
617
618 getTicks(range: number[]): number[] {
619 const increment = this.getIncrement(range);
620 const start = Math.ceil(range[0] / increment) * increment;
621 const values = [start];
622 for (let ii = 0; ii < this.drawer.MAX_GRID_LINES - 1; ++ii) {
623 const nextValue = values[ii] + increment;
624 if (nextValue > range[1]) {
625 break;
626 }
627 values.push(nextValue);
628 }
629 return values;
630 }
631
632 plotToCanvasCoordinates(plotPos: number[]): number[] {
633 const webglCoord = this.drawer.plotToCanvasCoordinates(plotPos);
634 const webglX = (webglCoord[0] + 1.0) / 2.0 * this.drawer.ctx.canvas.width;
635 const webglY = (1.0 - webglCoord[1]) / 2.0 * this.drawer.ctx.canvas.height;
636 return [webglX + this.graphBuffers.left, webglY + this.graphBuffers.top];
637 }
638
639 drawXTick(x: number) {
640 const text = this.numberToLabel(x);
641 const height = this.textHeight(text);
642 const xpos = this.plotToCanvasCoordinates([x, 0])[0];
643 this.ctx.textAlign = "center";
644 this.ctx.fillText(
645 text, xpos,
646 this.ctx.canvas.height - this.graphBuffers.bottom + height +
647 this.TEXT_BUFFER);
648 }
649
650 drawYTick(y: number) {
651 const text = this.numberToLabel(y);
652 const height = this.textHeight(text);
653 const ypos = this.plotToCanvasCoordinates([0, y])[1];
654 this.ctx.textAlign = "right";
655 this.ctx.fillText(
656 text, this.graphBuffers.left - this.TEXT_BUFFER,
657 ypos + height / 2.0);
658 }
659
660 drawTitle() {
661 if (this.title) {
662 this.ctx.textAlign = 'center';
663 this.ctx.fillText(
664 this.title, this.ctx.canvas.width / 2.0,
665 this.graphBuffers.top - this.TEXT_BUFFER);
666 }
667 }
668
669 drawXLabel() {
670 if (this.xlabel) {
671 this.ctx.textAlign = 'center';
672 this.ctx.fillText(
673 this.xlabel, this.ctx.canvas.width / 2.0,
674 this.ctx.canvas.height - this.TEXT_BUFFER);
675 }
676 }
677
678 drawYLabel() {
679 this.ctx.save();
680 if (this.ylabel) {
681 this.ctx.textAlign = 'center';
682 const height = this.textHeight(this.ylabel);
683 this.ctx.translate(
684 height + this.TEXT_BUFFER, this.ctx.canvas.height / 2.0);
685 this.ctx.rotate(-Math.PI / 2.0);
686 this.ctx.fillText(this.ylabel, 0, 0);
687 }
688 this.ctx.restore();
689 }
690
691 draw() {
692 this.ctx.fillStyle = 'black';
693 const minValues = this.drawer.minVisiblePoint();
694 const maxValues = this.drawer.maxVisiblePoint();
695 let text = this.numberToLabel(maxValues[1]);
696 this.drawYTick(maxValues[1]);
697 this.drawYTick(minValues[1]);
698 this.drawXTick(minValues[0]);
699 this.drawXTick(maxValues[0]);
700 this.ctx.strokeStyle = 'black';
701 this.ctx.strokeRect(
702 this.graphBuffers.left, this.graphBuffers.top,
703 this.drawer.ctx.canvas.width, this.drawer.ctx.canvas.height);
704 this.ctx.strokeRect(
705 0, 0,
706 this.ctx.canvas.width, this.ctx.canvas.height);
707 const xTicks = this.getTicks([minValues[0], maxValues[0]]);
708 this.drawer.setXTicks(xTicks);
709 const yTicks = this.getTicks([minValues[1], maxValues[1]]);
710 this.drawer.setYTicks(yTicks);
711
712 for (let x of xTicks) {
713 this.drawXTick(x);
714 }
715
716 for (let y of yTicks) {
717 this.drawYTick(y);
718 }
719
720 this.drawTitle();
721 this.drawXLabel();
722 this.drawYLabel();
723 }
724
725 // Draws the current mouse position in the bottom-right of the plot.
726 drawMousePosition(mousePos: number[]) {
727 const plotPos = this.drawer.canvasToPlotCoordinates(mousePos);
728
729 const text =
730 `(${plotPos[0].toPrecision(10)}, ${plotPos[1].toPrecision(10)})`;
731 const textDepth = this.textDepth(text);
732 this.ctx.textAlign = 'right';
733 this.ctx.fillText(
734 text, this.ctx.canvas.width - this.graphBuffers.right,
735 this.ctx.canvas.height - this.graphBuffers.bottom - textDepth);
736 }
737}
738
739// This class manages the entirety of a single plot. Most of the logic in
740// this class is around handling mouse/keyboard events for interacting with
741// the plot.
742export class Plot {
743 private canvas = document.createElement('canvas');
744 private textCanvas = document.createElement('canvas');
745 private drawer: LineDrawer;
James Kuszmaul461b0682020-12-22 22:20:21 -0800746 private static keysPressed:
747 object = {'x': false, 'y': false, 'Escape': false};
748 // List of all plots to use for propagating key-press events to.
749 private static allPlots: Plot[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700750 // In canvas coordinates (the +/-1 square).
James Kuszmaul461b0682020-12-22 22:20:21 -0800751 private lastMousePanPosition: number[]|null = null;
752 private rectangleStartPosition: number[]|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700753 private axisLabelBuffer: WhitespaceBuffers =
754 new WhitespaceBuffers(50, 20, 20, 30);
755 private axisLabels: AxisLabels;
756 private legend: Legend;
757 private lastMousePosition: number[] = [0.0, 0.0];
758 private autoFollow: boolean = true;
759 private linkedXAxes: Plot[] = [];
James Kuszmaul71a81932020-12-15 21:08:01 -0800760 private lastTimeMs: number = 0;
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800761 private defaultYRange: number[]|null = null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800762 private zoomRectangle: Line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700763
764 constructor(wrapperDiv: HTMLDivElement, width: number, height: number) {
765 wrapperDiv.appendChild(this.canvas);
766 wrapperDiv.appendChild(this.textCanvas);
James Kuszmaul71a81932020-12-15 21:08:01 -0800767 this.lastTimeMs = (new Date()).getTime();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700768
769 this.canvas.width =
770 width - this.axisLabelBuffer.left - this.axisLabelBuffer.right;
771 this.canvas.height =
772 height - this.axisLabelBuffer.top - this.axisLabelBuffer.bottom;
773 this.canvas.style.left = this.axisLabelBuffer.left.toString();
774 this.canvas.style.top = this.axisLabelBuffer.top.toString();
775 this.canvas.style.position = 'absolute';
776 this.drawer = new LineDrawer(this.canvas.getContext('webgl'));
777
778 this.textCanvas.width = width;
779 this.textCanvas.height = height;
780 this.textCanvas.style.left = '0';
781 this.textCanvas.style.top = '0';
782 this.textCanvas.style.position = 'absolute';
783 this.textCanvas.style.pointerEvents = 'none';
784
785 this.canvas.addEventListener('dblclick', (e) => {
786 this.handleDoubleClick(e);
787 });
788 this.canvas.onwheel = (e) => {
789 this.handleWheel(e);
790 e.preventDefault();
791 };
792 this.canvas.onmousedown = (e) => {
793 this.handleMouseDown(e);
794 };
795 this.canvas.onmouseup = (e) => {
796 this.handleMouseUp(e);
797 };
798 this.canvas.onmousemove = (e) => {
799 this.handleMouseMove(e);
800 };
James Kuszmaul461b0682020-12-22 22:20:21 -0800801 this.canvas.addEventListener('contextmenu', event => event.preventDefault());
802 // Note: To handle the fact that only one keypress handle can be registered
803 // per browser tab, we share key-press handlers across all plot instances.
804 Plot.allPlots.push(this);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700805 document.onkeydown = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800806 Plot.handleKeyDown(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700807 };
808 document.onkeyup = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800809 Plot.handleKeyUp(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700810 };
811
812 const textCtx = this.textCanvas.getContext("2d");
813 this.axisLabels =
814 new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
815 this.legend = new Legend(textCtx, this.drawer.getLines());
816
James Kuszmaul461b0682020-12-22 22:20:21 -0800817 this.zoomRectangle = this.getDrawer().addLine();
818 this.zoomRectangle.setColor([1, 1, 1]);
819 this.zoomRectangle.setPointSize(0);
820
James Kuszmaula8f2c452020-07-05 21:17:56 -0700821 this.draw();
822 }
823
824 handleDoubleClick(event: MouseEvent) {
825 this.resetZoom();
826 }
827
828 mouseCanvasLocation(event: MouseEvent): number[] {
829 return [
830 event.offsetX * 2.0 / this.canvas.width - 1.0,
831 -event.offsetY * 2.0 / this.canvas.height + 1.0
832 ];
833 }
834
James Kuszmaul461b0682020-12-22 22:20:21 -0800835 mousePlotLocation(event: MouseEvent): number[] {
836 return this.drawer.canvasToPlotCoordinates(this.mouseCanvasLocation(event));
837 }
838
James Kuszmaula8f2c452020-07-05 21:17:56 -0700839 handleWheel(event: WheelEvent) {
840 if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
841 return;
842 }
843 const mousePosition = this.mouseCanvasLocation(event);
844 const kWheelTuningScalar = 1.5;
845 const zoom = -kWheelTuningScalar * event.deltaY / this.canvas.height;
846 let zoomScalar = 1.0 + Math.abs(zoom);
847 if (zoom < 0.0) {
848 zoomScalar = 1.0 / zoomScalar;
849 }
850 const scale = scaleVec(this.drawer.getZoom().scale, zoomScalar);
851 const offset = addVec(
852 scaleVec(mousePosition, 1.0 - zoomScalar),
853 scaleVec(this.drawer.getZoom().offset, zoomScalar));
854 this.setZoom(scale, offset);
855 }
856
857 handleMouseDown(event: MouseEvent) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800858 const button = transitionButton(event);
859 switch (button) {
860 case PAN_BUTTON:
861 this.lastMousePanPosition = this.mouseCanvasLocation(event);
862 break;
863 case RECTANGLE_BUTTON:
864 this.rectangleStartPosition = this.mousePlotLocation(event);
865 break;
866 default:
867 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700868 }
869 }
870
871 handleMouseUp(event: MouseEvent) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800872 const button = transitionButton(event);
873 switch (button) {
874 case PAN_BUTTON:
875 this.lastMousePanPosition = null;
876 break;
877 case RECTANGLE_BUTTON:
878 if (this.rectangleStartPosition === null) {
879 // We got a right-button release without ever seeing the mouse-down;
880 // just return.
881 return;
882 }
883 this.finishRectangleZoom(event);
884 break;
885 default:
886 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700887 }
888 }
889
James Kuszmaul461b0682020-12-22 22:20:21 -0800890 private finishRectangleZoom(event: MouseEvent) {
891 const currentPosition = this.mousePlotLocation(event);
892 this.setZoomCorners(this.rectangleStartPosition, currentPosition);
893 this.rectangleStartPosition = null;
894 this.zoomRectangle.setPoints(new Float32Array([]));
895 }
896
James Kuszmaula8f2c452020-07-05 21:17:56 -0700897 handleMouseMove(event: MouseEvent) {
898 const mouseLocation = this.mouseCanvasLocation(event);
899 if (buttonPressed(event, PAN_BUTTON) &&
900 (this.lastMousePanPosition !== null)) {
901 const mouseDiff =
902 addVec(mouseLocation, scaleVec(this.lastMousePanPosition, -1));
903 this.setZoom(
904 this.drawer.getZoom().scale,
905 addVec(this.drawer.getZoom().offset, mouseDiff));
906 this.lastMousePanPosition = mouseLocation;
907 }
James Kuszmaul461b0682020-12-22 22:20:21 -0800908 if (this.rectangleStartPosition !== null) {
909 if (buttonPressed(event, RECTANGLE_BUTTON)) {
910 // p0 and p1 are the two corners of the rectangle to draw.
911 const p0 = [...this.rectangleStartPosition];
912 const p1 = [...this.mousePlotLocation(event)];
913 const minVisible = this.drawer.minVisiblePoint();
914 const maxVisible = this.drawer.maxVisiblePoint();
915 // Modify the rectangle corners to display correctly if we are limiting
916 // the zoom to the x/y axis.
917 const x_pressed = Plot.keysPressed['x'];
918 const y_pressed = Plot.keysPressed["y"];
919 if (x_pressed && !y_pressed) {
920 p0[1] = minVisible[1];
921 p1[1] = maxVisible[1];
922 } else if (!x_pressed && y_pressed) {
923 p0[0] = minVisible[0];
924 p1[0] = maxVisible[0];
925 }
926 this.zoomRectangle.setPoints(
927 new Float32Array([p0[0], p0[1]]
928 .concat([p0[0], p1[1]])
929 .concat([p1[0], p1[1]])
930 .concat([p1[0], p0[1]])
931 .concat([p0[0], p0[1]])));
932 } else {
933 this.finishRectangleZoom(event);
934 }
935 } else {
936 this.zoomRectangle.setPoints(new Float32Array([]));
937 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700938 this.lastMousePosition = mouseLocation;
939 }
940
941 setZoom(scale: number[], offset: number[]) {
James Kuszmaul71a81932020-12-15 21:08:01 -0800942 if (!isFinite(scale[0]) || !isFinite(scale[1])) {
943 throw new Error("Doesn't support non-finite scales due to singularities.");
944 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700945 const x_pressed = Plot.keysPressed["x"];
946 const y_pressed = Plot.keysPressed["y"];
947 const zoom = this.drawer.getZoom();
948 if (x_pressed && !y_pressed) {
949 zoom.scale[0] = scale[0];
950 zoom.offset[0] = offset[0];
951 } else if (y_pressed && !x_pressed) {
952 zoom.scale[1] = scale[1];
953 zoom.offset[1] = offset[1];
954 } else {
955 zoom.scale = scale;
956 zoom.offset = offset;
957 }
958
959 for (let plot of this.linkedXAxes) {
960 const otherZoom = plot.drawer.getZoom();
961 otherZoom.scale[0] = zoom.scale[0];
962 otherZoom.offset[0] = zoom.offset[0];
963 plot.drawer.setZoom(otherZoom);
964 plot.autoFollow = false;
965 }
966 this.drawer.setZoom(zoom);
967 this.autoFollow = false;
968 }
969
970
971 setZoomCorners(c1: number[], c2: number[]) {
972 const scale = cwiseOp(c1, c2, (a, b) => {
973 return 2.0 / Math.abs(a - b);
974 });
975 const offset = cwiseOp(scale, cwiseOp(c1, c2, Math.max), (a, b) => {
976 return 1.0 - a * b;
977 });
978 this.setZoom(scale, offset);
979 }
980
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800981 setDefaultYRange(range: number[]|null) {
982 if (range == null) {
983 this.defaultYRange = null;
984 return;
985 }
986 if (range.length != 2) {
987 throw new Error('Range should contain exactly two values.');
988 }
989 this.defaultYRange = range;
990 }
991
James Kuszmaula8f2c452020-07-05 21:17:56 -0700992 resetZoom() {
James Kuszmaul71a81932020-12-15 21:08:01 -0800993 const minValues = this.drawer.minValues();
994 const maxValues = this.drawer.maxValues();
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800995 for (const plot of this.linkedXAxes) {
996 const otherMin = plot.drawer.minValues();
997 const otherMax = plot.drawer.maxValues();
998 // For linked x-axes, only adjust the x limits.
999 minValues[0] = Math.min(minValues[0], otherMin[0]);
1000 maxValues[0] = Math.max(maxValues[0], otherMax[0]);
1001 }
1002 if (!isFinite(minValues[0]) || !isFinite(maxValues[0])) {
1003 minValues[0] = 0;
1004 maxValues[0] = 0;
1005 }
1006 if (!isFinite(minValues[1]) || !isFinite(maxValues[1])) {
1007 minValues[1] = 0;
1008 maxValues[1] = 0;
1009 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001010 if (minValues[0] == maxValues[0]) {
1011 minValues[0] -= 1;
1012 maxValues[0] += 1;
1013 }
1014 if (minValues[1] == maxValues[1]) {
1015 minValues[1] -= 1;
1016 maxValues[1] += 1;
1017 }
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001018 if (this.defaultYRange != null) {
1019 minValues[1] = this.defaultYRange[0];
1020 maxValues[1] = this.defaultYRange[1];
1021 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001022 this.setZoomCorners(minValues, maxValues);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001023 this.autoFollow = true;
1024 for (let plot of this.linkedXAxes) {
1025 plot.autoFollow = true;
1026 }
1027 }
1028
James Kuszmaul461b0682020-12-22 22:20:21 -08001029 static handleKeyUp(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001030 Plot.keysPressed[event.key] = false;
1031 }
1032
James Kuszmaul461b0682020-12-22 22:20:21 -08001033 static handleKeyDown(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001034 Plot.keysPressed[event.key] = true;
James Kuszmaul461b0682020-12-22 22:20:21 -08001035 for (const plot of this.allPlots) {
1036 if (Plot.keysPressed['Escape']) {
1037 // Cancel zoom/pan operations on escape.
1038 plot.lastMousePanPosition = null;
1039 plot.rectangleStartPosition = null;
1040 plot.zoomRectangle.setPoints(new Float32Array([]));
1041 }
1042 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001043 }
1044
1045 draw() {
1046 window.requestAnimationFrame(() => this.draw());
James Kuszmaul71a81932020-12-15 21:08:01 -08001047 const curTime = (new Date()).getTime();
1048 const frameRate = 1000.0 / (curTime - this.lastTimeMs);
1049 this.lastTimeMs = curTime;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001050
1051 // Clear the overlay.
1052 const textCtx = this.textCanvas.getContext("2d");
1053 textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
1054
1055 this.axisLabels.draw();
1056 this.axisLabels.drawMousePosition(this.lastMousePosition);
1057 this.legend.draw();
1058
1059 this.drawer.draw();
1060
1061 if (this.autoFollow) {
1062 this.resetZoom();
1063 }
1064 }
1065
1066 getDrawer(): LineDrawer {
1067 return this.drawer;
1068 }
1069
1070 getLegend(): Legend {
1071 return this.legend;
1072 }
1073
1074 getAxisLabels(): AxisLabels {
1075 return this.axisLabels;
1076 }
1077
1078 // Links this plot's x-axis with that of another Plot (e.g., to share time
1079 // axes).
1080 linkXAxis(other: Plot) {
1081 this.linkedXAxes.push(other);
1082 other.linkedXAxes.push(this);
1083 }
1084}