blob: c10ff80fabcb872ba7690b6b062b84941cbaafb4 [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;
38 private _minValues: number[] = [0.0, 0.0];
39 private _maxValues: number[] = [0.0, 0.0];
40 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;
44 private _label: string = "";
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
124 this._minValues = cwiseOp(this._minValues, [x, y], Math.min);
125 this._maxValues = cwiseOp(this._maxValues, [x, y], Math.max);
126 }
127 }
128
129 // Get/set the label to use for the line when drawing the legend.
130 setLabel(label: string) {
131 this._label = label;
132 }
133
134 label(): string {
135 return this._label;
136 }
137
138 // Render the line on the canvas.
139 draw() {
140 this._hasUpdate = false;
141 if (this.points.length === 0) {
142 return;
143 }
144
145 this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.buffer);
146 // Note: if this is generating errors associated with the buffer size,
147 // confirm that this.points really is a Float32Array.
148 this.ctx.bufferData(
149 this.ctx.ARRAY_BUFFER,
150 this.points,
151 this.ctx.STATIC_DRAW);
152 {
153 const numComponents = 2; // pull out 2 values per iteration
154 const numType = this.ctx.FLOAT; // the data in the buffer is 32bit floats
155 const normalize = false; // don't normalize
156 const stride = 0; // how many bytes to get from one set of values to the
157 // next 0 = use type and numComponents above
158 const offset = 0; // how many bytes inside the buffer to start from
159 this.ctx.vertexAttribPointer(
160 this.pointAttribLocation, numComponents, numType,
161 normalize, stride, offset);
162 this.ctx.enableVertexAttribArray(this.pointAttribLocation);
163 }
164
165 this.ctx.uniform1f(this.pointSizeLocation, this._pointSize);
166 this.ctx.uniform4f(
167 this.colorLocation, this._color[0], this._color[1], this._color[2],
168 1.0);
169
170 if (this._drawLine) {
171 this.ctx.drawArrays(this.ctx.LINE_STRIP, 0, this.points.length / 2);
172 }
173 if (this._pointSize > 0.0) {
174 this.ctx.drawArrays(this.ctx.POINTS, 0, this.points.length / 2);
175 }
176 }
177}
178
179// Parameters used when scaling the lines to the canvas.
180// If a point in a line is at pos then its position in the canvas space will be
181// scale * pos + offset.
182class ZoomParameters {
183 public scale: number[] = [1.0, 1.0];
184 public offset: number[] = [0.0, 0.0];
185}
186
187enum MouseButton {
188 Right,
189 Middle,
190 Left
191}
192
193// The button to use for panning the plot.
194const PAN_BUTTON = MouseButton.Left;
195
196// Returns the mouse button that generated a given event.
197function transitionButton(event: MouseEvent): MouseButton {
198 switch (event.button) {
199 case 0:
200 return MouseButton.Left;
201 case 1:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700202 return MouseButton.Middle;
James Kuszmaulbce45332020-12-15 19:50:01 -0800203 case 2:
204 return MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700205 }
206}
207
208// Returns whether the given button is pressed on the mouse.
209function buttonPressed(event: MouseEvent, button: MouseButton): boolean {
210 switch (button) {
211 // For some reason, the middle/right buttons are swapped relative to where
212 // we would expect them to be given the .button field.
213 case MouseButton.Left:
214 return 0 !== (event.buttons & 0x1);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700215 case MouseButton.Right:
James Kuszmaulbce45332020-12-15 19:50:01 -0800216 return 0 !== (event.buttons & 0x2);
217 case MouseButton.Middle:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700218 return 0 !== (event.buttons & 0x4);
219 }
220}
221
222// Handles rendering a Legend for a list of lines.
223// This takes a 2d canvas, which is what we use for rendering all the text of
224// the plot and is separate, but overlayed on top of, the WebGL canvas that the
225// lines are drawn on.
226export class Legend {
227 // Location, in pixels, of the legend in the text canvas.
228 private location: number[] = [0, 0];
229 constructor(private ctx: CanvasRenderingContext2D, private lines: Line[]) {
230 this.location = [this.ctx.canvas.width - 100, 30];
231 }
232
233 setPosition(location: number[]): void {
234 this.location = location;
235 }
236
237 draw(): void {
238 this.ctx.save();
239
240 this.ctx.translate(this.location[0], this.location[1]);
241
242 // Space between rows of the legend.
243 const step = 20;
244
245 // Total height of the body of the legend.
246 const height = step * this.lines.length;
247
248 let maxWidth = 0;
249
250 // In the legend, we render both a small line of the appropriate color as
251 // well as the text label--start/endPoint are the relative locations of the
252 // endpoints of the miniature line within the row, and textStart is where
253 // we begin rendering the text within the row.
254 const startPoint = [0, 0];
255 const endPoint = [10, -10];
256 const textStart = endPoint[0] + 5;
257
258 // Calculate how wide the legend needs to be to fit all the text.
259 this.ctx.textAlign = 'left';
260 for (let line of this.lines) {
261 const width =
262 textStart + this.ctx.measureText(line.label()).actualBoundingBoxRight;
263 maxWidth = Math.max(width, maxWidth);
264 }
265
266 // Set the legend background to be white and opaque.
267 this.ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
268 const backgroundBuffer = 5;
269 this.ctx.fillRect(
270 -backgroundBuffer, 0, maxWidth + 2.0 * backgroundBuffer,
271 height + backgroundBuffer);
272
273 // Go through each line and render the little lines and text for each Line.
274 for (let line of this.lines) {
275 this.ctx.translate(0, step);
276 const color = line.color();
277 this.ctx.strokeStyle = `rgb(${255.0 * color[0]}, ${255.0 * color[1]}, ${255.0 * color[2]})`;
278 this.ctx.fillStyle = this.ctx.strokeStyle;
279 if (line.drawLine()) {
280 this.ctx.beginPath();
281 this.ctx.moveTo(startPoint[0], startPoint[1]);
282 this.ctx.lineTo(endPoint[0], endPoint[1]);
283 this.ctx.closePath();
284 this.ctx.stroke();
285 }
286 const pointSize = line.pointSize();
287 if (pointSize > 0) {
288 this.ctx.fillRect(
289 startPoint[0] - pointSize / 2.0, startPoint[1] - pointSize / 2.0,
290 pointSize, pointSize);
291 this.ctx.fillRect(
292 endPoint[0] - pointSize / 2.0, endPoint[1] - pointSize / 2.0,
293 pointSize, pointSize);
294 }
295
296 this.ctx.fillStyle = 'black';
297 this.ctx.textAlign = 'left';
298 this.ctx.fillText(line.label(), textStart, 0);
299 }
300
301 this.ctx.restore();
302 }
303}
304
305// This class manages all the WebGL rendering--namely, drawing the reference
306// grid for the user and then rendering all the actual lines of the plot.
307export class LineDrawer {
308 private program: WebGLProgram|null = null;
309 private scaleLocation: WebGLUniformLocation;
310 private offsetLocation: WebGLUniformLocation;
311 private vertexBuffer: WebGLBuffer;
312 private lines: Line[] = [];
313 private zoom: ZoomParameters = new ZoomParameters();
314 private zoomUpdated: boolean = true;
315 // Maximum grid lines to render at once--this is used provide an upper limit
316 // on the number of Line objects we need to create in order to render the
317 // grid.
318 public readonly MAX_GRID_LINES: number = 5;
319 // Arrays of the points at which we will draw grid lines for the x/y axes.
320 private xTicks: number[] = [];
321 private yTicks: number[] = [];
322 private xGridLines: Line[] = [];
323 private yGridLines: Line[] = [];
324
325 constructor(public readonly ctx: WebGLRenderingContext) {
326 this.program = this.compileShaders();
327 this.scaleLocation = this.ctx.getUniformLocation(this.program, 'scale');
328 this.offsetLocation = this.ctx.getUniformLocation(this.program, 'offset');
329 this.vertexBuffer = this.ctx.createBuffer();
330
331 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
332 this.xGridLines.push(new Line(this.ctx, this.program, this.vertexBuffer));
333 this.yGridLines.push(new Line(this.ctx, this.program, this.vertexBuffer));
334 }
335 }
336
337 setXGrid(lines: Line[]) {
338 this.xGridLines = lines;
339 }
340
341 getZoom(): ZoomParameters {
342 return this.zoom;
343 }
344
345 plotToCanvasCoordinates(plotPos: number[]): number[] {
346 return addVec(cwiseOp(plotPos, this.zoom.scale, (a, b) => {
347 return a * b;
348 }), this.zoom.offset);
349 }
350
351
352 canvasToPlotCoordinates(canvasPos: number[]): number[] {
353 return cwiseOp(cwiseOp(canvasPos, this.zoom.offset, (a, b) => {
354 return a - b;
355 }), this.zoom.scale, (a, b) => {
356 return a / b;
357 });
358 }
359
360 // Tehse return the max/min rendered points, in plot-space (this is helpful
361 // for drawing axis labels).
362 maxVisiblePoint(): number[] {
363 return this.canvasToPlotCoordinates([1.0, 1.0]);
364 }
365
366 minVisiblePoint(): number[] {
367 return this.canvasToPlotCoordinates([-1.0, -1.0]);
368 }
369
370 getLines(): Line[] {
371 return this.lines;
372 }
373
374 setZoom(zoom: ZoomParameters) {
375 this.zoomUpdated = true;
376 this.zoom = zoom;
377 }
378
379 setXTicks(ticks: number[]): void {
380 this.xTicks = ticks;
381 }
382
383 setYTicks(ticks: number[]): void {
384 this.yTicks = ticks;
385 }
386
387 // Update the grid lines.
388 updateTicks() {
389 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
390 this.xGridLines[ii].setPoints(new Float32Array([]));
391 this.yGridLines[ii].setPoints(new Float32Array([]));
392 }
393
394 const minValues = this.minVisiblePoint();
395 const maxValues = this.maxVisiblePoint();
396
397 for (let ii = 0; ii < this.xTicks.length; ++ii) {
398 this.xGridLines[ii].setColor([0.0, 0.0, 0.0]);
399 const points = new Float32Array(
400 [this.xTicks[ii], minValues[1], this.xTicks[ii], maxValues[1]]);
401 this.xGridLines[ii].setPointSize(0);
402 this.xGridLines[ii].setPoints(points);
403 this.xGridLines[ii].draw();
404 }
405
406 for (let ii = 0; ii < this.yTicks.length; ++ii) {
407 this.yGridLines[ii].setColor([0.0, 0.0, 0.0]);
408 const points = new Float32Array(
409 [minValues[0], this.yTicks[ii], maxValues[0], this.yTicks[ii]]);
410 this.yGridLines[ii].setPointSize(0);
411 this.yGridLines[ii].setPoints(points);
412 this.yGridLines[ii].draw();
413 }
414 }
415
416 // Handles redrawing any of the WebGL objects, if necessary.
417 draw(): void {
418 let needsUpdate = this.zoomUpdated;
419 this.zoomUpdated = false;
420 for (let line of this.lines) {
421 if (line.hasUpdate()) {
422 needsUpdate = true;
423 break;
424 }
425 }
426 if (!needsUpdate) {
427 return;
428 }
429
430 this.reset();
431
432 this.updateTicks();
433
434 for (let line of this.lines) {
435 line.draw();
436 }
437
438 return;
439 }
440
441 loadShader(shaderType: number, source: string): WebGLShader {
442 const shader = this.ctx.createShader(shaderType);
443 this.ctx.shaderSource(shader, source);
444 this.ctx.compileShader(shader);
445 if (!this.ctx.getShaderParameter(shader, this.ctx.COMPILE_STATUS)) {
446 alert(
447 'Got an error compiling a shader: ' +
448 this.ctx.getShaderInfoLog(shader));
449 this.ctx.deleteShader(shader);
450 return null;
451 }
452
453 return shader;
454 }
455
456 compileShaders(): WebGLProgram {
457 const vertexShader = 'attribute vec2 apos;' +
458 'uniform vec2 scale;' +
459 'uniform vec2 offset;' +
460 'uniform float point_size;' +
461 'void main() {' +
462 ' gl_Position.xy = apos.xy * scale.xy + offset.xy;' +
463 ' gl_Position.z = 0.0;' +
464 ' gl_Position.w = 1.0;' +
465 ' gl_PointSize = point_size;' +
466 '}';
467
468 const fragmentShader = 'precision highp float;' +
469 'uniform vec4 color;' +
470 'void main() {' +
471 ' gl_FragColor = color;' +
472 '}';
473
474 const compiledVertex =
475 this.loadShader(this.ctx.VERTEX_SHADER, vertexShader);
476 const compiledFragment =
477 this.loadShader(this.ctx.FRAGMENT_SHADER, fragmentShader);
478 const program = this.ctx.createProgram();
479 this.ctx.attachShader(program, compiledVertex);
480 this.ctx.attachShader(program, compiledFragment);
481 this.ctx.linkProgram(program);
482 if (!this.ctx.getProgramParameter(program, this.ctx.LINK_STATUS)) {
483 alert(
484 'Unable to link the shaders: ' + this.ctx.getProgramInfoLog(program));
485 return null;
486 }
487 return program;
488 }
489
490 addLine(): Line {
491 this.lines.push(new Line(this.ctx, this.program, this.vertexBuffer));
492 return this.lines[this.lines.length - 1];
493 }
494
495 minValues(): number[] {
496 let minValues = [Infinity, Infinity];
497 for (let line of this.lines) {
498 minValues = cwiseOp(minValues, line.minValues(), Math.min);
499 }
500 return minValues;
501 }
502
503 maxValues(): number[] {
504 let maxValues = [-Infinity, -Infinity];
505 for (let line of this.lines) {
506 maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
507 }
508 return maxValues;
509 }
510
511 reset(): void {
512 // Set the background color
513 this.ctx.clearColor(0.5, 0.5, 0.5, 1.0);
514 this.ctx.clearDepth(1.0);
515 this.ctx.enable(this.ctx.DEPTH_TEST);
516 this.ctx.depthFunc(this.ctx.LEQUAL);
517 this.ctx.clear(this.ctx.COLOR_BUFFER_BIT | this.ctx.DEPTH_BUFFER_BIT);
518
519 this.ctx.useProgram(this.program);
520
521 this.ctx.uniform2f(
522 this.scaleLocation, this.zoom.scale[0], this.zoom.scale[1]);
523 this.ctx.uniform2f(
524 this.offsetLocation, this.zoom.offset[0], this.zoom.offset[1]);
525 }
526}
527
528// Class to store how much whitespace we put between the edges of the WebGL
529// canvas (where we draw all the lines) and the edge of the plot. This gives
530// us space to, e.g., draw axis labels, the plot title, etc.
531class WhitespaceBuffers {
532 constructor(
533 public left: number, public right: number, public top: number,
534 public bottom: number) {}
535}
536
537// Class to manage all the annotations associated with the plot--the axis/tick
538// labels and the plot title.
539class AxisLabels {
540 private readonly INCREMENTS: number[] = [2, 4, 5, 10];
541 // Space to leave to create some visual space around the text.
542 private readonly TEXT_BUFFER: number = 5;
543 private title: string = "";
544 private xlabel: string = "";
545 private ylabel: string = "";
546 constructor(
547 private ctx: CanvasRenderingContext2D, private drawer: LineDrawer,
548 private graphBuffers: WhitespaceBuffers) {}
549
550 numberToLabel(num: number): string {
551 return num.toPrecision(5);
552 }
553
554 textWidth(str: string): number {
555 return this.ctx.measureText(str).actualBoundingBoxRight;
556 }
557
558 textHeight(str: string): number {
559 return this.ctx.measureText(str).actualBoundingBoxAscent;
560 }
561
562 textDepth(str: string): number {
563 return this.ctx.measureText(str).actualBoundingBoxDescent;
564 }
565
566 setTitle(title: string) {
567 this.title = title;
568 }
569
570 setXLabel(xlabel: string) {
571 this.xlabel = xlabel;
572 }
573
574 setYLabel(ylabel: string) {
575 this.ylabel = ylabel;
576 }
577
578 getIncrement(range: number[]): number {
579 const diff = Math.abs(range[1] - range[0]);
580 const minDiff = diff / this.drawer.MAX_GRID_LINES;
581 const incrementsRatio = this.INCREMENTS[this.INCREMENTS.length - 1];
582 const order = Math.pow(
583 incrementsRatio,
584 Math.floor(Math.log(minDiff) / Math.log(incrementsRatio)));
585 const normalizedDiff = minDiff / order;
586 for (let increment of this.INCREMENTS) {
587 if (increment > normalizedDiff) {
588 return increment * order;
589 }
590 }
591 return 1.0;
592 }
593
594 getTicks(range: number[]): number[] {
595 const increment = this.getIncrement(range);
596 const start = Math.ceil(range[0] / increment) * increment;
597 const values = [start];
598 for (let ii = 0; ii < this.drawer.MAX_GRID_LINES - 1; ++ii) {
599 const nextValue = values[ii] + increment;
600 if (nextValue > range[1]) {
601 break;
602 }
603 values.push(nextValue);
604 }
605 return values;
606 }
607
608 plotToCanvasCoordinates(plotPos: number[]): number[] {
609 const webglCoord = this.drawer.plotToCanvasCoordinates(plotPos);
610 const webglX = (webglCoord[0] + 1.0) / 2.0 * this.drawer.ctx.canvas.width;
611 const webglY = (1.0 - webglCoord[1]) / 2.0 * this.drawer.ctx.canvas.height;
612 return [webglX + this.graphBuffers.left, webglY + this.graphBuffers.top];
613 }
614
615 drawXTick(x: number) {
616 const text = this.numberToLabel(x);
617 const height = this.textHeight(text);
618 const xpos = this.plotToCanvasCoordinates([x, 0])[0];
619 this.ctx.textAlign = "center";
620 this.ctx.fillText(
621 text, xpos,
622 this.ctx.canvas.height - this.graphBuffers.bottom + height +
623 this.TEXT_BUFFER);
624 }
625
626 drawYTick(y: number) {
627 const text = this.numberToLabel(y);
628 const height = this.textHeight(text);
629 const ypos = this.plotToCanvasCoordinates([0, y])[1];
630 this.ctx.textAlign = "right";
631 this.ctx.fillText(
632 text, this.graphBuffers.left - this.TEXT_BUFFER,
633 ypos + height / 2.0);
634 }
635
636 drawTitle() {
637 if (this.title) {
638 this.ctx.textAlign = 'center';
639 this.ctx.fillText(
640 this.title, this.ctx.canvas.width / 2.0,
641 this.graphBuffers.top - this.TEXT_BUFFER);
642 }
643 }
644
645 drawXLabel() {
646 if (this.xlabel) {
647 this.ctx.textAlign = 'center';
648 this.ctx.fillText(
649 this.xlabel, this.ctx.canvas.width / 2.0,
650 this.ctx.canvas.height - this.TEXT_BUFFER);
651 }
652 }
653
654 drawYLabel() {
655 this.ctx.save();
656 if (this.ylabel) {
657 this.ctx.textAlign = 'center';
658 const height = this.textHeight(this.ylabel);
659 this.ctx.translate(
660 height + this.TEXT_BUFFER, this.ctx.canvas.height / 2.0);
661 this.ctx.rotate(-Math.PI / 2.0);
662 this.ctx.fillText(this.ylabel, 0, 0);
663 }
664 this.ctx.restore();
665 }
666
667 draw() {
668 this.ctx.fillStyle = 'black';
669 const minValues = this.drawer.minVisiblePoint();
670 const maxValues = this.drawer.maxVisiblePoint();
671 let text = this.numberToLabel(maxValues[1]);
672 this.drawYTick(maxValues[1]);
673 this.drawYTick(minValues[1]);
674 this.drawXTick(minValues[0]);
675 this.drawXTick(maxValues[0]);
676 this.ctx.strokeStyle = 'black';
677 this.ctx.strokeRect(
678 this.graphBuffers.left, this.graphBuffers.top,
679 this.drawer.ctx.canvas.width, this.drawer.ctx.canvas.height);
680 this.ctx.strokeRect(
681 0, 0,
682 this.ctx.canvas.width, this.ctx.canvas.height);
683 const xTicks = this.getTicks([minValues[0], maxValues[0]]);
684 this.drawer.setXTicks(xTicks);
685 const yTicks = this.getTicks([minValues[1], maxValues[1]]);
686 this.drawer.setYTicks(yTicks);
687
688 for (let x of xTicks) {
689 this.drawXTick(x);
690 }
691
692 for (let y of yTicks) {
693 this.drawYTick(y);
694 }
695
696 this.drawTitle();
697 this.drawXLabel();
698 this.drawYLabel();
699 }
700
701 // Draws the current mouse position in the bottom-right of the plot.
702 drawMousePosition(mousePos: number[]) {
703 const plotPos = this.drawer.canvasToPlotCoordinates(mousePos);
704
705 const text =
706 `(${plotPos[0].toPrecision(10)}, ${plotPos[1].toPrecision(10)})`;
707 const textDepth = this.textDepth(text);
708 this.ctx.textAlign = 'right';
709 this.ctx.fillText(
710 text, this.ctx.canvas.width - this.graphBuffers.right,
711 this.ctx.canvas.height - this.graphBuffers.bottom - textDepth);
712 }
713}
714
715// This class manages the entirety of a single plot. Most of the logic in
716// this class is around handling mouse/keyboard events for interacting with
717// the plot.
718export class Plot {
719 private canvas = document.createElement('canvas');
720 private textCanvas = document.createElement('canvas');
721 private drawer: LineDrawer;
722 private static keysPressed: object = {'x': false, 'y': false};
723 // In canvas coordinates (the +/-1 square).
724 private lastMousePanPosition: number[] = null;
725 private axisLabelBuffer: WhitespaceBuffers =
726 new WhitespaceBuffers(50, 20, 20, 30);
727 private axisLabels: AxisLabels;
728 private legend: Legend;
729 private lastMousePosition: number[] = [0.0, 0.0];
730 private autoFollow: boolean = true;
731 private linkedXAxes: Plot[] = [];
732
733 constructor(wrapperDiv: HTMLDivElement, width: number, height: number) {
734 wrapperDiv.appendChild(this.canvas);
735 wrapperDiv.appendChild(this.textCanvas);
736
737 this.canvas.width =
738 width - this.axisLabelBuffer.left - this.axisLabelBuffer.right;
739 this.canvas.height =
740 height - this.axisLabelBuffer.top - this.axisLabelBuffer.bottom;
741 this.canvas.style.left = this.axisLabelBuffer.left.toString();
742 this.canvas.style.top = this.axisLabelBuffer.top.toString();
743 this.canvas.style.position = 'absolute';
744 this.drawer = new LineDrawer(this.canvas.getContext('webgl'));
745
746 this.textCanvas.width = width;
747 this.textCanvas.height = height;
748 this.textCanvas.style.left = '0';
749 this.textCanvas.style.top = '0';
750 this.textCanvas.style.position = 'absolute';
751 this.textCanvas.style.pointerEvents = 'none';
752
753 this.canvas.addEventListener('dblclick', (e) => {
754 this.handleDoubleClick(e);
755 });
756 this.canvas.onwheel = (e) => {
757 this.handleWheel(e);
758 e.preventDefault();
759 };
760 this.canvas.onmousedown = (e) => {
761 this.handleMouseDown(e);
762 };
763 this.canvas.onmouseup = (e) => {
764 this.handleMouseUp(e);
765 };
766 this.canvas.onmousemove = (e) => {
767 this.handleMouseMove(e);
768 };
769 // TODO(james): Deconflict the global state....
770 document.onkeydown = (e) => {
771 this.handleKeyDown(e);
772 };
773 document.onkeyup = (e) => {
774 this.handleKeyUp(e);
775 };
776
777 const textCtx = this.textCanvas.getContext("2d");
778 this.axisLabels =
779 new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
780 this.legend = new Legend(textCtx, this.drawer.getLines());
781
782 this.draw();
783 }
784
785 handleDoubleClick(event: MouseEvent) {
786 this.resetZoom();
787 }
788
789 mouseCanvasLocation(event: MouseEvent): number[] {
790 return [
791 event.offsetX * 2.0 / this.canvas.width - 1.0,
792 -event.offsetY * 2.0 / this.canvas.height + 1.0
793 ];
794 }
795
796 handleWheel(event: WheelEvent) {
797 if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
798 return;
799 }
800 const mousePosition = this.mouseCanvasLocation(event);
801 const kWheelTuningScalar = 1.5;
802 const zoom = -kWheelTuningScalar * event.deltaY / this.canvas.height;
803 let zoomScalar = 1.0 + Math.abs(zoom);
804 if (zoom < 0.0) {
805 zoomScalar = 1.0 / zoomScalar;
806 }
807 const scale = scaleVec(this.drawer.getZoom().scale, zoomScalar);
808 const offset = addVec(
809 scaleVec(mousePosition, 1.0 - zoomScalar),
810 scaleVec(this.drawer.getZoom().offset, zoomScalar));
811 this.setZoom(scale, offset);
812 }
813
814 handleMouseDown(event: MouseEvent) {
815 if (transitionButton(event) === PAN_BUTTON) {
816 this.lastMousePanPosition = this.mouseCanvasLocation(event);
817 }
818 }
819
820 handleMouseUp(event: MouseEvent) {
821 if (transitionButton(event) === PAN_BUTTON) {
822 this.lastMousePanPosition = null;
823 }
824 }
825
826 handleMouseMove(event: MouseEvent) {
827 const mouseLocation = this.mouseCanvasLocation(event);
828 if (buttonPressed(event, PAN_BUTTON) &&
829 (this.lastMousePanPosition !== null)) {
830 const mouseDiff =
831 addVec(mouseLocation, scaleVec(this.lastMousePanPosition, -1));
832 this.setZoom(
833 this.drawer.getZoom().scale,
834 addVec(this.drawer.getZoom().offset, mouseDiff));
835 this.lastMousePanPosition = mouseLocation;
836 }
837 this.lastMousePosition = mouseLocation;
838 }
839
840 setZoom(scale: number[], offset: number[]) {
841 const x_pressed = Plot.keysPressed["x"];
842 const y_pressed = Plot.keysPressed["y"];
843 const zoom = this.drawer.getZoom();
844 if (x_pressed && !y_pressed) {
845 zoom.scale[0] = scale[0];
846 zoom.offset[0] = offset[0];
847 } else if (y_pressed && !x_pressed) {
848 zoom.scale[1] = scale[1];
849 zoom.offset[1] = offset[1];
850 } else {
851 zoom.scale = scale;
852 zoom.offset = offset;
853 }
854
855 for (let plot of this.linkedXAxes) {
856 const otherZoom = plot.drawer.getZoom();
857 otherZoom.scale[0] = zoom.scale[0];
858 otherZoom.offset[0] = zoom.offset[0];
859 plot.drawer.setZoom(otherZoom);
860 plot.autoFollow = false;
861 }
862 this.drawer.setZoom(zoom);
863 this.autoFollow = false;
864 }
865
866
867 setZoomCorners(c1: number[], c2: number[]) {
868 const scale = cwiseOp(c1, c2, (a, b) => {
869 return 2.0 / Math.abs(a - b);
870 });
871 const offset = cwiseOp(scale, cwiseOp(c1, c2, Math.max), (a, b) => {
872 return 1.0 - a * b;
873 });
874 this.setZoom(scale, offset);
875 }
876
877 resetZoom() {
878 this.setZoomCorners(this.drawer.minValues(), this.drawer.maxValues());
879 this.autoFollow = true;
880 for (let plot of this.linkedXAxes) {
881 plot.autoFollow = true;
882 }
883 }
884
885 handleKeyUp(event: KeyboardEvent) {
886 Plot.keysPressed[event.key] = false;
887 }
888
889 handleKeyDown(event: KeyboardEvent) {
890 Plot.keysPressed[event.key] = true;
891 }
892
893 draw() {
894 window.requestAnimationFrame(() => this.draw());
895
896 // Clear the overlay.
897 const textCtx = this.textCanvas.getContext("2d");
898 textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
899
900 this.axisLabels.draw();
901 this.axisLabels.drawMousePosition(this.lastMousePosition);
902 this.legend.draw();
903
904 this.drawer.draw();
905
906 if (this.autoFollow) {
907 this.resetZoom();
908 }
909 }
910
911 getDrawer(): LineDrawer {
912 return this.drawer;
913 }
914
915 getLegend(): Legend {
916 return this.legend;
917 }
918
919 getAxisLabels(): AxisLabels {
920 return this.axisLabels;
921 }
922
923 // Links this plot's x-axis with that of another Plot (e.g., to share time
924 // axes).
925 linkXAxis(other: Plot) {
926 this.linkedXAxes.push(other);
927 other.linkedXAxes.push(this);
928 }
929}