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