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