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