blob: e56a8087b605df05a108d74deef0a652f77a9514 [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
James Kuszmaul0d7df892021-04-09 22:19:49 -070032function subtractVec(a: number[], b: number[]): number[] {
33 return cwiseOp(a, b, (p, q) => {
34 return p - q;
35 });
36}
37
38function multVec(a: number[], b: number[]): number[] {
39 return cwiseOp(a, b, (p, q) => {
40 return p * q;
41 });
42}
43
44function divideVec(a: number[], b: number[]): number[] {
45 return cwiseOp(a, b, (p, q) => {
46 return p / q;
47 });
48}
49
50// Parameters used when scaling the lines to the canvas.
51// If a point in a line is at pos then its position in the canvas space will be
52// scale * pos + offset.
53class ZoomParameters {
54 public scale: number[] = [1.0, 1.0];
55 public offset: number[] = [0.0, 0.0];
56 copy():ZoomParameters {
57 const copy = new ZoomParameters();
58 copy.scale = [this.scale[0], this.scale[1]];
59 copy.offset = [this.offset[0], this.offset[1]];
60 return copy;
61 }
62}
63
64export class Point {
65 constructor(
66 public x: number = 0.0,
67 public y: number = 0.0) {}
68}
69
James Kuszmaula8f2c452020-07-05 21:17:56 -070070// Represents a single line within a plot. Handles rendering the line with
71// all of its points and the appropriate color/markers/lines.
72export class Line {
James Kuszmaul0d7df892021-04-09 22:19:49 -070073 // Notes on zoom/precision management:
74 // The adjustedPoints field is the buffert of points (formatted [x0, y0, x1,
75 // y1, ..., xn, yn]) that will be read directly by WebGL and operated on in
76 // the vertex shader. However, WebGL provides relatively minimal guarantess
77 // about the floating point precision available in the shaders (to the point
78 // where even Float32 precision is not guaranteed). As such, we
79 // separately maintain the points vector using javascript number's
80 // (arbitrary-precision ints or double-precision floats). We then periodically
81 // set the baseZoom to be equal to the current desired zoom, calculate the
82 // scaled values directly in typescript, store them in adjustedPoints, and
83 // then just pass an identity transformation to WebGL for the zoom parameters.
84 // When actively zooming, we then just use WebGL to compensate for the offset
85 // between the baseZoom and the desired zoom, taking advantage of WebGL's
86 // performance to handle the high-rate updates but then falling back to
87 // typescript periodically to reset the offsets to avoid precision issues.
88 //
89 // As a practical matter, I've found that even if we were to recalculate
90 // the zoom in typescript on every iteration, the penalty is relatively
91 // minor--we still perform far better than using a non-WebGL canvas. This
92 // suggests that the bulk of the performance advantage from using WebGL for
93 // this use-case lies not in doing the zoom updates in the shaders, but rather
94 // in relying on WebGL to figure out how to drawin the lines/points that we
95 // specify.
96 private adjustedPoints: Float32Array = new Float32Array([]);
97 private points: Point[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -070098 private _drawLine: boolean = true;
99 private _pointSize: number = 3.0;
100 private _hasUpdate: boolean = false;
James Kuszmaul71a81932020-12-15 21:08:01 -0800101 private _minValues: number[] = [Infinity, Infinity];
102 private _maxValues: number[] = [-Infinity, -Infinity];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700103 private _color: number[] = [1.0, 0.0, 0.0];
104 private pointAttribLocation: number;
Philipp Schradere625ba22020-11-16 20:11:37 -0800105 private colorLocation: WebGLUniformLocation | null;
106 private pointSizeLocation: WebGLUniformLocation | null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800107 private _label: string|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700108 constructor(
109 private readonly ctx: WebGLRenderingContext,
110 private readonly program: WebGLProgram,
James Kuszmaul0d7df892021-04-09 22:19:49 -0700111 private readonly buffer: WebGLBuffer, private baseZoom: ZoomParameters) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700112 this.pointAttribLocation = this.ctx.getAttribLocation(this.program, 'apos');
113 this.colorLocation = this.ctx.getUniformLocation(this.program, 'color');
114 this.pointSizeLocation =
115 this.ctx.getUniformLocation(this.program, 'point_size');
116 }
117
118 // Return the largest x and y values present in the list of points.
119 maxValues(): number[] {
120 return this._maxValues;
121 }
122
123 // Return the smallest x and y values present in the list of points.
124 minValues(): number[] {
125 return this._minValues;
126 }
127
128 // Whether any parameters have changed that would require re-rending the line.
129 hasUpdate(): boolean {
130 return this._hasUpdate;
131 }
132
133 // Get/set the color of the line, returned as an RGB tuple.
134 color(): number[] {
135 return this._color;
136 }
137
Austin Schuh7d63eab2021-03-06 20:15:02 -0800138 setColor(newColor: number[]): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700139 this._color = newColor;
140 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -0800141 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700142 }
143
144 // Get/set the size of the markers to draw, in pixels (zero means no markers).
145 pointSize(): number {
146 return this._pointSize;
147 }
148
Austin Schuh7d63eab2021-03-06 20:15:02 -0800149 setPointSize(size: number): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700150 this._pointSize = size;
151 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -0800152 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700153 }
154
155 // Get/set whether we draw a line between the points (i.e., setting this to
156 // false would effectively create a scatter-plot). If drawLine is false and
157 // pointSize is zero, then no data is rendered.
158 drawLine(): boolean {
159 return this._drawLine;
160 }
161
James Kuszmauld7d98e82021-03-07 20:17:54 -0800162 setDrawLine(newDrawLine: boolean): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700163 this._drawLine = newDrawLine;
164 this._hasUpdate = true;
James Kuszmauld7d98e82021-03-07 20:17:54 -0800165 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700166 }
167
168 // Set the points to render. The points in the line are ordered and should
169 // be of the format:
170 // [x1, y1, x2, y2, x3, y3, ...., xN, yN]
James Kuszmaul0d7df892021-04-09 22:19:49 -0700171 setPoints(points: Point[]) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700172 this.points = points;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700173 this.adjustedPoints = new Float32Array(points.length * 2);
174 this.updateBaseZoom(this.baseZoom);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700175 this._hasUpdate = true;
176 this._minValues[0] = Infinity;
177 this._minValues[1] = Infinity;
178 this._maxValues[0] = -Infinity;
179 this._maxValues[1] = -Infinity;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700180 for (let ii = 0; ii < this.points.length; ++ii) {
181 const x = this.points[ii].x;
182 const y = this.points[ii].y;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700183
James Kuszmaul71a81932020-12-15 21:08:01 -0800184 if (isNaN(x) || isNaN(y)) {
185 continue;
186 }
187
James Kuszmaula8f2c452020-07-05 21:17:56 -0700188 this._minValues = cwiseOp(this._minValues, [x, y], Math.min);
189 this._maxValues = cwiseOp(this._maxValues, [x, y], Math.max);
190 }
191 }
192
James Kuszmaul0d7df892021-04-09 22:19:49 -0700193 getPoints(): Point[] {
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800194 return this.points;
195 }
196
James Kuszmaula8f2c452020-07-05 21:17:56 -0700197 // Get/set the label to use for the line when drawing the legend.
James Kuszmauld7d98e82021-03-07 20:17:54 -0800198 setLabel(label: string): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700199 this._label = label;
James Kuszmauld7d98e82021-03-07 20:17:54 -0800200 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700201 }
202
James Kuszmaul461b0682020-12-22 22:20:21 -0800203 label(): string|null {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700204 return this._label;
205 }
206
James Kuszmaul0d7df892021-04-09 22:19:49 -0700207 updateBaseZoom(zoom: ZoomParameters) {
208 this.baseZoom = zoom;
209 for (let ii = 0; ii < this.points.length; ++ii) {
210 const point = this.points[ii];
211 this.adjustedPoints[ii * 2] = point.x * zoom.scale[0] + zoom.offset[0];
212 this.adjustedPoints[ii * 2 + 1] = point.y * zoom.scale[1] + zoom.offset[1];
213 }
214 }
215
James Kuszmaula8f2c452020-07-05 21:17:56 -0700216 // Render the line on the canvas.
217 draw() {
218 this._hasUpdate = false;
219 if (this.points.length === 0) {
220 return;
221 }
222
223 this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.buffer);
224 // Note: if this is generating errors associated with the buffer size,
225 // confirm that this.points really is a Float32Array.
226 this.ctx.bufferData(
227 this.ctx.ARRAY_BUFFER,
James Kuszmaul0d7df892021-04-09 22:19:49 -0700228 this.adjustedPoints,
James Kuszmaula8f2c452020-07-05 21:17:56 -0700229 this.ctx.STATIC_DRAW);
230 {
231 const numComponents = 2; // pull out 2 values per iteration
232 const numType = this.ctx.FLOAT; // the data in the buffer is 32bit floats
233 const normalize = false; // don't normalize
234 const stride = 0; // how many bytes to get from one set of values to the
235 // next 0 = use type and numComponents above
236 const offset = 0; // how many bytes inside the buffer to start from
237 this.ctx.vertexAttribPointer(
238 this.pointAttribLocation, numComponents, numType,
239 normalize, stride, offset);
240 this.ctx.enableVertexAttribArray(this.pointAttribLocation);
241 }
242
243 this.ctx.uniform1f(this.pointSizeLocation, this._pointSize);
244 this.ctx.uniform4f(
245 this.colorLocation, this._color[0], this._color[1], this._color[2],
246 1.0);
247
248 if (this._drawLine) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700249 this.ctx.drawArrays(this.ctx.LINE_STRIP, 0, this.points.length);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700250 }
251 if (this._pointSize > 0.0) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700252 this.ctx.drawArrays(this.ctx.POINTS, 0, this.points.length);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700253 }
254 }
255}
256
James Kuszmaula8f2c452020-07-05 21:17:56 -0700257enum MouseButton {
258 Right,
259 Middle,
260 Left
261}
262
263// The button to use for panning the plot.
264const PAN_BUTTON = MouseButton.Left;
James Kuszmaul461b0682020-12-22 22:20:21 -0800265const RECTANGLE_BUTTON = MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700266
267// Returns the mouse button that generated a given event.
268function transitionButton(event: MouseEvent): MouseButton {
269 switch (event.button) {
270 case 0:
271 return MouseButton.Left;
272 case 1:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700273 return MouseButton.Middle;
James Kuszmaulbce45332020-12-15 19:50:01 -0800274 case 2:
275 return MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700276 }
277}
278
279// Returns whether the given button is pressed on the mouse.
280function buttonPressed(event: MouseEvent, button: MouseButton): boolean {
281 switch (button) {
282 // For some reason, the middle/right buttons are swapped relative to where
283 // we would expect them to be given the .button field.
284 case MouseButton.Left:
285 return 0 !== (event.buttons & 0x1);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700286 case MouseButton.Right:
James Kuszmaulbce45332020-12-15 19:50:01 -0800287 return 0 !== (event.buttons & 0x2);
288 case MouseButton.Middle:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700289 return 0 !== (event.buttons & 0x4);
290 }
291}
292
293// Handles rendering a Legend for a list of lines.
294// This takes a 2d canvas, which is what we use for rendering all the text of
295// the plot and is separate, but overlayed on top of, the WebGL canvas that the
296// lines are drawn on.
297export class Legend {
298 // Location, in pixels, of the legend in the text canvas.
299 private location: number[] = [0, 0];
300 constructor(private ctx: CanvasRenderingContext2D, private lines: Line[]) {
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800301 this.location = [80, 30];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700302 }
303
304 setPosition(location: number[]): void {
305 this.location = location;
306 }
307
308 draw(): void {
309 this.ctx.save();
310
311 this.ctx.translate(this.location[0], this.location[1]);
312
313 // Space between rows of the legend.
314 const step = 20;
315
James Kuszmaula8f2c452020-07-05 21:17:56 -0700316 let maxWidth = 0;
317
318 // In the legend, we render both a small line of the appropriate color as
319 // well as the text label--start/endPoint are the relative locations of the
320 // endpoints of the miniature line within the row, and textStart is where
321 // we begin rendering the text within the row.
322 const startPoint = [0, 0];
323 const endPoint = [10, -10];
324 const textStart = endPoint[0] + 5;
325
326 // Calculate how wide the legend needs to be to fit all the text.
327 this.ctx.textAlign = 'left';
James Kuszmaul461b0682020-12-22 22:20:21 -0800328 let numLabels = 0;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700329 for (let line of this.lines) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800330 if (line.label() === null) {
331 continue;
332 }
333 ++numLabels;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700334 const width =
335 textStart + this.ctx.measureText(line.label()).actualBoundingBoxRight;
336 maxWidth = Math.max(width, maxWidth);
337 }
338
James Kuszmaul461b0682020-12-22 22:20:21 -0800339 if (numLabels === 0) {
340 this.ctx.restore();
341 return;
342 }
343
344 // Total height of the body of the legend.
345 const height = step * numLabels;
346
James Kuszmaula8f2c452020-07-05 21:17:56 -0700347 // Set the legend background to be white and opaque.
348 this.ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
349 const backgroundBuffer = 5;
350 this.ctx.fillRect(
351 -backgroundBuffer, 0, maxWidth + 2.0 * backgroundBuffer,
352 height + backgroundBuffer);
353
354 // Go through each line and render the little lines and text for each Line.
355 for (let line of this.lines) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800356 if (line.label() === null) {
357 continue;
358 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700359 this.ctx.translate(0, step);
360 const color = line.color();
361 this.ctx.strokeStyle = `rgb(${255.0 * color[0]}, ${255.0 * color[1]}, ${255.0 * color[2]})`;
362 this.ctx.fillStyle = this.ctx.strokeStyle;
363 if (line.drawLine()) {
364 this.ctx.beginPath();
365 this.ctx.moveTo(startPoint[0], startPoint[1]);
366 this.ctx.lineTo(endPoint[0], endPoint[1]);
367 this.ctx.closePath();
368 this.ctx.stroke();
369 }
370 const pointSize = line.pointSize();
371 if (pointSize > 0) {
372 this.ctx.fillRect(
373 startPoint[0] - pointSize / 2.0, startPoint[1] - pointSize / 2.0,
374 pointSize, pointSize);
375 this.ctx.fillRect(
376 endPoint[0] - pointSize / 2.0, endPoint[1] - pointSize / 2.0,
377 pointSize, pointSize);
378 }
379
380 this.ctx.fillStyle = 'black';
381 this.ctx.textAlign = 'left';
382 this.ctx.fillText(line.label(), textStart, 0);
383 }
384
385 this.ctx.restore();
386 }
387}
388
389// This class manages all the WebGL rendering--namely, drawing the reference
390// grid for the user and then rendering all the actual lines of the plot.
391export class LineDrawer {
392 private program: WebGLProgram|null = null;
393 private scaleLocation: WebGLUniformLocation;
394 private offsetLocation: WebGLUniformLocation;
395 private vertexBuffer: WebGLBuffer;
396 private lines: Line[] = [];
397 private zoom: ZoomParameters = new ZoomParameters();
James Kuszmaul0d7df892021-04-09 22:19:49 -0700398 private baseZoom: ZoomParameters = new ZoomParameters();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700399 private zoomUpdated: boolean = true;
400 // Maximum grid lines to render at once--this is used provide an upper limit
401 // on the number of Line objects we need to create in order to render the
402 // grid.
403 public readonly MAX_GRID_LINES: number = 5;
404 // Arrays of the points at which we will draw grid lines for the x/y axes.
405 private xTicks: number[] = [];
406 private yTicks: number[] = [];
407 private xGridLines: Line[] = [];
408 private yGridLines: Line[] = [];
409
James Kuszmaul933a9742021-03-07 19:59:06 -0800410 public static readonly COLOR_CYCLE = [
411 Colors.RED, Colors.GREEN, Colors.BLUE, Colors.BROWN, Colors.PINK,
412 Colors.CYAN, Colors.WHITE
413 ];
414 private colorCycleIndex = 0;
415
James Kuszmaula8f2c452020-07-05 21:17:56 -0700416 constructor(public readonly ctx: WebGLRenderingContext) {
417 this.program = this.compileShaders();
418 this.scaleLocation = this.ctx.getUniformLocation(this.program, 'scale');
419 this.offsetLocation = this.ctx.getUniformLocation(this.program, 'offset');
420 this.vertexBuffer = this.ctx.createBuffer();
421
422 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700423 this.xGridLines.push(
424 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
425 this.yGridLines.push(
426 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700427 }
428 }
429
James Kuszmaula8f2c452020-07-05 21:17:56 -0700430 getZoom(): ZoomParameters {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700431 return this.zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700432 }
433
434 plotToCanvasCoordinates(plotPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700435 return addVec(multVec(plotPos, this.zoom.scale), this.zoom.offset);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700436 }
437
438
439 canvasToPlotCoordinates(canvasPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700440 return divideVec(subtractVec(canvasPos, this.zoom.offset), this.zoom.scale);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700441 }
442
443 // Tehse return the max/min rendered points, in plot-space (this is helpful
444 // for drawing axis labels).
445 maxVisiblePoint(): number[] {
446 return this.canvasToPlotCoordinates([1.0, 1.0]);
447 }
448
449 minVisiblePoint(): number[] {
450 return this.canvasToPlotCoordinates([-1.0, -1.0]);
451 }
452
453 getLines(): Line[] {
454 return this.lines;
455 }
456
457 setZoom(zoom: ZoomParameters) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700458 if (this.zoom.scale[0] == zoom.scale[0] &&
459 this.zoom.scale[1] == zoom.scale[1] &&
460 this.zoom.offset[0] == zoom.offset[0] &&
461 this.zoom.offset[1] == zoom.offset[1]) {
462 return;
463 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700464 this.zoomUpdated = true;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700465 this.zoom = zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700466 }
467
468 setXTicks(ticks: number[]): void {
469 this.xTicks = ticks;
470 }
471
472 setYTicks(ticks: number[]): void {
473 this.yTicks = ticks;
474 }
475
476 // Update the grid lines.
477 updateTicks() {
478 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700479 this.xGridLines[ii].setPoints([]);
480 this.yGridLines[ii].setPoints([]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700481 }
482
483 const minValues = this.minVisiblePoint();
484 const maxValues = this.maxVisiblePoint();
485
486 for (let ii = 0; ii < this.xTicks.length; ++ii) {
487 this.xGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700488 const points = [
489 new Point(this.xTicks[ii], minValues[1]),
490 new Point(this.xTicks[ii], maxValues[1])
491 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700492 this.xGridLines[ii].setPointSize(0);
493 this.xGridLines[ii].setPoints(points);
494 this.xGridLines[ii].draw();
495 }
496
497 for (let ii = 0; ii < this.yTicks.length; ++ii) {
498 this.yGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700499 const points = [
500 new Point(minValues[0], this.yTicks[ii]),
501 new Point(maxValues[0], this.yTicks[ii])
502 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700503 this.yGridLines[ii].setPointSize(0);
504 this.yGridLines[ii].setPoints(points);
505 this.yGridLines[ii].draw();
506 }
507 }
508
509 // Handles redrawing any of the WebGL objects, if necessary.
510 draw(): void {
511 let needsUpdate = this.zoomUpdated;
512 this.zoomUpdated = false;
513 for (let line of this.lines) {
514 if (line.hasUpdate()) {
515 needsUpdate = true;
516 break;
517 }
518 }
519 if (!needsUpdate) {
520 return;
521 }
522
523 this.reset();
524
525 this.updateTicks();
526
527 for (let line of this.lines) {
528 line.draw();
529 }
530
531 return;
532 }
533
534 loadShader(shaderType: number, source: string): WebGLShader {
535 const shader = this.ctx.createShader(shaderType);
536 this.ctx.shaderSource(shader, source);
537 this.ctx.compileShader(shader);
538 if (!this.ctx.getShaderParameter(shader, this.ctx.COMPILE_STATUS)) {
539 alert(
540 'Got an error compiling a shader: ' +
541 this.ctx.getShaderInfoLog(shader));
542 this.ctx.deleteShader(shader);
543 return null;
544 }
545
546 return shader;
547 }
548
549 compileShaders(): WebGLProgram {
550 const vertexShader = 'attribute vec2 apos;' +
551 'uniform vec2 scale;' +
552 'uniform vec2 offset;' +
553 'uniform float point_size;' +
554 'void main() {' +
555 ' gl_Position.xy = apos.xy * scale.xy + offset.xy;' +
556 ' gl_Position.z = 0.0;' +
557 ' gl_Position.w = 1.0;' +
558 ' gl_PointSize = point_size;' +
559 '}';
560
561 const fragmentShader = 'precision highp float;' +
562 'uniform vec4 color;' +
563 'void main() {' +
564 ' gl_FragColor = color;' +
565 '}';
566
567 const compiledVertex =
568 this.loadShader(this.ctx.VERTEX_SHADER, vertexShader);
569 const compiledFragment =
570 this.loadShader(this.ctx.FRAGMENT_SHADER, fragmentShader);
571 const program = this.ctx.createProgram();
572 this.ctx.attachShader(program, compiledVertex);
573 this.ctx.attachShader(program, compiledFragment);
574 this.ctx.linkProgram(program);
575 if (!this.ctx.getProgramParameter(program, this.ctx.LINK_STATUS)) {
576 alert(
577 'Unable to link the shaders: ' + this.ctx.getProgramInfoLog(program));
578 return null;
579 }
580 return program;
581 }
582
James Kuszmaul933a9742021-03-07 19:59:06 -0800583 addLine(useColorCycle: boolean = true): Line {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700584 this.lines.push(
585 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaul933a9742021-03-07 19:59:06 -0800586 const line = this.lines[this.lines.length - 1];
587 if (useColorCycle) {
588 line.setColor(LineDrawer.COLOR_CYCLE[this.colorCycleIndex++]);
589 }
590 return line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700591 }
592
593 minValues(): number[] {
594 let minValues = [Infinity, Infinity];
595 for (let line of this.lines) {
596 minValues = cwiseOp(minValues, line.minValues(), Math.min);
597 }
598 return minValues;
599 }
600
601 maxValues(): number[] {
602 let maxValues = [-Infinity, -Infinity];
603 for (let line of this.lines) {
604 maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
605 }
606 return maxValues;
607 }
608
609 reset(): void {
610 // Set the background color
611 this.ctx.clearColor(0.5, 0.5, 0.5, 1.0);
612 this.ctx.clearDepth(1.0);
613 this.ctx.enable(this.ctx.DEPTH_TEST);
614 this.ctx.depthFunc(this.ctx.LEQUAL);
615 this.ctx.clear(this.ctx.COLOR_BUFFER_BIT | this.ctx.DEPTH_BUFFER_BIT);
616
617 this.ctx.useProgram(this.program);
618
James Kuszmaul0d7df892021-04-09 22:19:49 -0700619 // Check for whether the zoom parameters have changed significantly; if so,
620 // update the base zoom.
621 // These thresholds are somewhat arbitrary.
622 const scaleDiff = divideVec(this.zoom.scale, this.baseZoom.scale);
623 const scaleChanged = scaleDiff[0] < 0.9 || scaleDiff[0] > 1.1 ||
624 scaleDiff[1] < 0.9 || scaleDiff[1] > 1.1;
625 const offsetDiff = subtractVec(this.zoom.offset, this.baseZoom.offset);
626 // Note that offset is in the canvas coordinate frame and so just using
627 // hard-coded constants is fine.
628 const offsetChanged =
629 Math.abs(offsetDiff[0]) > 0.1 || Math.abs(offsetDiff[1]) > 0.1;
630 if (scaleChanged || offsetChanged) {
631 this.baseZoom = this.zoom.copy();
632 for (const line of this.lines) {
633 line.updateBaseZoom(this.baseZoom);
634 }
635 for (const line of this.xGridLines) {
636 line.updateBaseZoom(this.baseZoom);
637 }
638 for (const line of this.yGridLines) {
639 line.updateBaseZoom(this.baseZoom);
640 }
641 }
642
643 // all the points in the lines will be pre-scaled by this.baseZoom, so
644 // we need to remove its effects before passing it in.
645 // zoom.scale * pos + zoom.offset = scale * (baseZoom.scale * pos + baseZoom.offset) + offset
646 // zoom.scale = scale * baseZoom.scale
647 // scale = zoom.scale / baseZoom.scale
648 // zoom.offset = scale * baseZoom.offset + offset
649 // offset = zoom.offset - scale * baseZoom.offset
650 const scale = divideVec(this.zoom.scale, this.baseZoom.scale);
651 const offset =
652 subtractVec(this.zoom.offset, multVec(scale, this.baseZoom.offset));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700653 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700654 this.scaleLocation, scale[0], scale[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700655 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700656 this.offsetLocation, offset[0], offset[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700657 }
658}
659
660// Class to store how much whitespace we put between the edges of the WebGL
661// canvas (where we draw all the lines) and the edge of the plot. This gives
662// us space to, e.g., draw axis labels, the plot title, etc.
663class WhitespaceBuffers {
664 constructor(
665 public left: number, public right: number, public top: number,
666 public bottom: number) {}
667}
668
669// Class to manage all the annotations associated with the plot--the axis/tick
670// labels and the plot title.
671class AxisLabels {
672 private readonly INCREMENTS: number[] = [2, 4, 5, 10];
673 // Space to leave to create some visual space around the text.
674 private readonly TEXT_BUFFER: number = 5;
675 private title: string = "";
676 private xlabel: string = "";
677 private ylabel: string = "";
678 constructor(
679 private ctx: CanvasRenderingContext2D, private drawer: LineDrawer,
680 private graphBuffers: WhitespaceBuffers) {}
681
682 numberToLabel(num: number): string {
683 return num.toPrecision(5);
684 }
685
686 textWidth(str: string): number {
687 return this.ctx.measureText(str).actualBoundingBoxRight;
688 }
689
690 textHeight(str: string): number {
691 return this.ctx.measureText(str).actualBoundingBoxAscent;
692 }
693
694 textDepth(str: string): number {
695 return this.ctx.measureText(str).actualBoundingBoxDescent;
696 }
697
698 setTitle(title: string) {
699 this.title = title;
700 }
701
702 setXLabel(xlabel: string) {
703 this.xlabel = xlabel;
704 }
705
706 setYLabel(ylabel: string) {
707 this.ylabel = ylabel;
708 }
709
710 getIncrement(range: number[]): number {
711 const diff = Math.abs(range[1] - range[0]);
712 const minDiff = diff / this.drawer.MAX_GRID_LINES;
713 const incrementsRatio = this.INCREMENTS[this.INCREMENTS.length - 1];
714 const order = Math.pow(
715 incrementsRatio,
716 Math.floor(Math.log(minDiff) / Math.log(incrementsRatio)));
717 const normalizedDiff = minDiff / order;
718 for (let increment of this.INCREMENTS) {
719 if (increment > normalizedDiff) {
720 return increment * order;
721 }
722 }
723 return 1.0;
724 }
725
726 getTicks(range: number[]): number[] {
727 const increment = this.getIncrement(range);
728 const start = Math.ceil(range[0] / increment) * increment;
729 const values = [start];
730 for (let ii = 0; ii < this.drawer.MAX_GRID_LINES - 1; ++ii) {
731 const nextValue = values[ii] + increment;
732 if (nextValue > range[1]) {
733 break;
734 }
735 values.push(nextValue);
736 }
737 return values;
738 }
739
740 plotToCanvasCoordinates(plotPos: number[]): number[] {
741 const webglCoord = this.drawer.plotToCanvasCoordinates(plotPos);
742 const webglX = (webglCoord[0] + 1.0) / 2.0 * this.drawer.ctx.canvas.width;
743 const webglY = (1.0 - webglCoord[1]) / 2.0 * this.drawer.ctx.canvas.height;
744 return [webglX + this.graphBuffers.left, webglY + this.graphBuffers.top];
745 }
746
747 drawXTick(x: number) {
748 const text = this.numberToLabel(x);
749 const height = this.textHeight(text);
750 const xpos = this.plotToCanvasCoordinates([x, 0])[0];
751 this.ctx.textAlign = "center";
752 this.ctx.fillText(
753 text, xpos,
754 this.ctx.canvas.height - this.graphBuffers.bottom + height +
755 this.TEXT_BUFFER);
756 }
757
758 drawYTick(y: number) {
759 const text = this.numberToLabel(y);
760 const height = this.textHeight(text);
761 const ypos = this.plotToCanvasCoordinates([0, y])[1];
762 this.ctx.textAlign = "right";
763 this.ctx.fillText(
764 text, this.graphBuffers.left - this.TEXT_BUFFER,
765 ypos + height / 2.0);
766 }
767
768 drawTitle() {
769 if (this.title) {
770 this.ctx.textAlign = 'center';
771 this.ctx.fillText(
772 this.title, this.ctx.canvas.width / 2.0,
773 this.graphBuffers.top - this.TEXT_BUFFER);
774 }
775 }
776
777 drawXLabel() {
778 if (this.xlabel) {
779 this.ctx.textAlign = 'center';
780 this.ctx.fillText(
781 this.xlabel, this.ctx.canvas.width / 2.0,
782 this.ctx.canvas.height - this.TEXT_BUFFER);
783 }
784 }
785
786 drawYLabel() {
787 this.ctx.save();
788 if (this.ylabel) {
789 this.ctx.textAlign = 'center';
790 const height = this.textHeight(this.ylabel);
791 this.ctx.translate(
792 height + this.TEXT_BUFFER, this.ctx.canvas.height / 2.0);
793 this.ctx.rotate(-Math.PI / 2.0);
794 this.ctx.fillText(this.ylabel, 0, 0);
795 }
796 this.ctx.restore();
797 }
798
799 draw() {
800 this.ctx.fillStyle = 'black';
801 const minValues = this.drawer.minVisiblePoint();
802 const maxValues = this.drawer.maxVisiblePoint();
803 let text = this.numberToLabel(maxValues[1]);
804 this.drawYTick(maxValues[1]);
805 this.drawYTick(minValues[1]);
806 this.drawXTick(minValues[0]);
807 this.drawXTick(maxValues[0]);
808 this.ctx.strokeStyle = 'black';
809 this.ctx.strokeRect(
810 this.graphBuffers.left, this.graphBuffers.top,
811 this.drawer.ctx.canvas.width, this.drawer.ctx.canvas.height);
812 this.ctx.strokeRect(
813 0, 0,
814 this.ctx.canvas.width, this.ctx.canvas.height);
815 const xTicks = this.getTicks([minValues[0], maxValues[0]]);
816 this.drawer.setXTicks(xTicks);
817 const yTicks = this.getTicks([minValues[1], maxValues[1]]);
818 this.drawer.setYTicks(yTicks);
819
820 for (let x of xTicks) {
821 this.drawXTick(x);
822 }
823
824 for (let y of yTicks) {
825 this.drawYTick(y);
826 }
827
828 this.drawTitle();
829 this.drawXLabel();
830 this.drawYLabel();
831 }
832
833 // Draws the current mouse position in the bottom-right of the plot.
834 drawMousePosition(mousePos: number[]) {
835 const plotPos = this.drawer.canvasToPlotCoordinates(mousePos);
836
837 const text =
838 `(${plotPos[0].toPrecision(10)}, ${plotPos[1].toPrecision(10)})`;
839 const textDepth = this.textDepth(text);
840 this.ctx.textAlign = 'right';
841 this.ctx.fillText(
842 text, this.ctx.canvas.width - this.graphBuffers.right,
843 this.ctx.canvas.height - this.graphBuffers.bottom - textDepth);
844 }
845}
846
847// This class manages the entirety of a single plot. Most of the logic in
848// this class is around handling mouse/keyboard events for interacting with
849// the plot.
850export class Plot {
851 private canvas = document.createElement('canvas');
852 private textCanvas = document.createElement('canvas');
853 private drawer: LineDrawer;
James Kuszmaul461b0682020-12-22 22:20:21 -0800854 private static keysPressed:
855 object = {'x': false, 'y': false, 'Escape': false};
856 // List of all plots to use for propagating key-press events to.
857 private static allPlots: Plot[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700858 // In canvas coordinates (the +/-1 square).
James Kuszmaul461b0682020-12-22 22:20:21 -0800859 private lastMousePanPosition: number[]|null = null;
860 private rectangleStartPosition: number[]|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700861 private axisLabelBuffer: WhitespaceBuffers =
862 new WhitespaceBuffers(50, 20, 20, 30);
863 private axisLabels: AxisLabels;
864 private legend: Legend;
865 private lastMousePosition: number[] = [0.0, 0.0];
866 private autoFollow: boolean = true;
867 private linkedXAxes: Plot[] = [];
James Kuszmaul71a81932020-12-15 21:08:01 -0800868 private lastTimeMs: number = 0;
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800869 private defaultYRange: number[]|null = null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800870 private zoomRectangle: Line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700871
872 constructor(wrapperDiv: HTMLDivElement, width: number, height: number) {
873 wrapperDiv.appendChild(this.canvas);
874 wrapperDiv.appendChild(this.textCanvas);
James Kuszmaul71a81932020-12-15 21:08:01 -0800875 this.lastTimeMs = (new Date()).getTime();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700876
877 this.canvas.width =
878 width - this.axisLabelBuffer.left - this.axisLabelBuffer.right;
879 this.canvas.height =
880 height - this.axisLabelBuffer.top - this.axisLabelBuffer.bottom;
881 this.canvas.style.left = this.axisLabelBuffer.left.toString();
882 this.canvas.style.top = this.axisLabelBuffer.top.toString();
883 this.canvas.style.position = 'absolute';
884 this.drawer = new LineDrawer(this.canvas.getContext('webgl'));
885
886 this.textCanvas.width = width;
887 this.textCanvas.height = height;
888 this.textCanvas.style.left = '0';
889 this.textCanvas.style.top = '0';
890 this.textCanvas.style.position = 'absolute';
891 this.textCanvas.style.pointerEvents = 'none';
892
893 this.canvas.addEventListener('dblclick', (e) => {
894 this.handleDoubleClick(e);
895 });
896 this.canvas.onwheel = (e) => {
897 this.handleWheel(e);
898 e.preventDefault();
899 };
900 this.canvas.onmousedown = (e) => {
901 this.handleMouseDown(e);
902 };
903 this.canvas.onmouseup = (e) => {
904 this.handleMouseUp(e);
905 };
906 this.canvas.onmousemove = (e) => {
907 this.handleMouseMove(e);
908 };
James Kuszmaul461b0682020-12-22 22:20:21 -0800909 this.canvas.addEventListener('contextmenu', event => event.preventDefault());
910 // Note: To handle the fact that only one keypress handle can be registered
911 // per browser tab, we share key-press handlers across all plot instances.
912 Plot.allPlots.push(this);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700913 document.onkeydown = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800914 Plot.handleKeyDown(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700915 };
916 document.onkeyup = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800917 Plot.handleKeyUp(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700918 };
919
920 const textCtx = this.textCanvas.getContext("2d");
921 this.axisLabels =
922 new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
923 this.legend = new Legend(textCtx, this.drawer.getLines());
924
James Kuszmaul933a9742021-03-07 19:59:06 -0800925 this.zoomRectangle = this.getDrawer().addLine(false);
926 this.zoomRectangle.setColor(Colors.WHITE);
James Kuszmaul461b0682020-12-22 22:20:21 -0800927 this.zoomRectangle.setPointSize(0);
928
James Kuszmaula8f2c452020-07-05 21:17:56 -0700929 this.draw();
930 }
931
932 handleDoubleClick(event: MouseEvent) {
933 this.resetZoom();
934 }
935
936 mouseCanvasLocation(event: MouseEvent): number[] {
937 return [
938 event.offsetX * 2.0 / this.canvas.width - 1.0,
939 -event.offsetY * 2.0 / this.canvas.height + 1.0
940 ];
941 }
942
James Kuszmaul461b0682020-12-22 22:20:21 -0800943 mousePlotLocation(event: MouseEvent): number[] {
944 return this.drawer.canvasToPlotCoordinates(this.mouseCanvasLocation(event));
945 }
946
James Kuszmaula8f2c452020-07-05 21:17:56 -0700947 handleWheel(event: WheelEvent) {
948 if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
949 return;
950 }
951 const mousePosition = this.mouseCanvasLocation(event);
952 const kWheelTuningScalar = 1.5;
953 const zoom = -kWheelTuningScalar * event.deltaY / this.canvas.height;
954 let zoomScalar = 1.0 + Math.abs(zoom);
955 if (zoom < 0.0) {
956 zoomScalar = 1.0 / zoomScalar;
957 }
958 const scale = scaleVec(this.drawer.getZoom().scale, zoomScalar);
959 const offset = addVec(
960 scaleVec(mousePosition, 1.0 - zoomScalar),
961 scaleVec(this.drawer.getZoom().offset, zoomScalar));
962 this.setZoom(scale, offset);
963 }
964
965 handleMouseDown(event: MouseEvent) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800966 const button = transitionButton(event);
967 switch (button) {
968 case PAN_BUTTON:
969 this.lastMousePanPosition = this.mouseCanvasLocation(event);
970 break;
971 case RECTANGLE_BUTTON:
972 this.rectangleStartPosition = this.mousePlotLocation(event);
973 break;
974 default:
975 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700976 }
977 }
978
979 handleMouseUp(event: MouseEvent) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800980 const button = transitionButton(event);
981 switch (button) {
982 case PAN_BUTTON:
983 this.lastMousePanPosition = null;
984 break;
985 case RECTANGLE_BUTTON:
986 if (this.rectangleStartPosition === null) {
987 // We got a right-button release without ever seeing the mouse-down;
988 // just return.
989 return;
990 }
991 this.finishRectangleZoom(event);
992 break;
993 default:
994 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700995 }
996 }
997
James Kuszmaul461b0682020-12-22 22:20:21 -0800998 private finishRectangleZoom(event: MouseEvent) {
999 const currentPosition = this.mousePlotLocation(event);
1000 this.setZoomCorners(this.rectangleStartPosition, currentPosition);
1001 this.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001002 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001003 }
1004
James Kuszmaula8f2c452020-07-05 21:17:56 -07001005 handleMouseMove(event: MouseEvent) {
1006 const mouseLocation = this.mouseCanvasLocation(event);
1007 if (buttonPressed(event, PAN_BUTTON) &&
1008 (this.lastMousePanPosition !== null)) {
1009 const mouseDiff =
1010 addVec(mouseLocation, scaleVec(this.lastMousePanPosition, -1));
1011 this.setZoom(
1012 this.drawer.getZoom().scale,
1013 addVec(this.drawer.getZoom().offset, mouseDiff));
1014 this.lastMousePanPosition = mouseLocation;
1015 }
James Kuszmaul461b0682020-12-22 22:20:21 -08001016 if (this.rectangleStartPosition !== null) {
1017 if (buttonPressed(event, RECTANGLE_BUTTON)) {
1018 // p0 and p1 are the two corners of the rectangle to draw.
1019 const p0 = [...this.rectangleStartPosition];
1020 const p1 = [...this.mousePlotLocation(event)];
1021 const minVisible = this.drawer.minVisiblePoint();
1022 const maxVisible = this.drawer.maxVisiblePoint();
1023 // Modify the rectangle corners to display correctly if we are limiting
1024 // the zoom to the x/y axis.
1025 const x_pressed = Plot.keysPressed['x'];
1026 const y_pressed = Plot.keysPressed["y"];
1027 if (x_pressed && !y_pressed) {
1028 p0[1] = minVisible[1];
1029 p1[1] = maxVisible[1];
1030 } else if (!x_pressed && y_pressed) {
1031 p0[0] = minVisible[0];
1032 p1[0] = maxVisible[0];
1033 }
James Kuszmaul0d7df892021-04-09 22:19:49 -07001034 this.zoomRectangle.setPoints([
1035 new Point(p0[0], p0[1]), new Point(p0[0], p1[1]),
1036 new Point(p1[0], p1[1]), new Point(p1[0], p0[1]),
1037 new Point(p0[0], p0[1])
1038 ]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001039 } else {
1040 this.finishRectangleZoom(event);
1041 }
1042 } else {
James Kuszmaul0d7df892021-04-09 22:19:49 -07001043 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001044 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001045 this.lastMousePosition = mouseLocation;
1046 }
1047
1048 setZoom(scale: number[], offset: number[]) {
James Kuszmaul71a81932020-12-15 21:08:01 -08001049 if (!isFinite(scale[0]) || !isFinite(scale[1])) {
1050 throw new Error("Doesn't support non-finite scales due to singularities.");
1051 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001052 const x_pressed = Plot.keysPressed["x"];
1053 const y_pressed = Plot.keysPressed["y"];
1054 const zoom = this.drawer.getZoom();
1055 if (x_pressed && !y_pressed) {
1056 zoom.scale[0] = scale[0];
1057 zoom.offset[0] = offset[0];
1058 } else if (y_pressed && !x_pressed) {
1059 zoom.scale[1] = scale[1];
1060 zoom.offset[1] = offset[1];
1061 } else {
1062 zoom.scale = scale;
1063 zoom.offset = offset;
1064 }
1065
1066 for (let plot of this.linkedXAxes) {
1067 const otherZoom = plot.drawer.getZoom();
1068 otherZoom.scale[0] = zoom.scale[0];
1069 otherZoom.offset[0] = zoom.offset[0];
1070 plot.drawer.setZoom(otherZoom);
1071 plot.autoFollow = false;
1072 }
1073 this.drawer.setZoom(zoom);
1074 this.autoFollow = false;
1075 }
1076
1077
1078 setZoomCorners(c1: number[], c2: number[]) {
1079 const scale = cwiseOp(c1, c2, (a, b) => {
1080 return 2.0 / Math.abs(a - b);
1081 });
1082 const offset = cwiseOp(scale, cwiseOp(c1, c2, Math.max), (a, b) => {
1083 return 1.0 - a * b;
1084 });
1085 this.setZoom(scale, offset);
1086 }
1087
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001088 setDefaultYRange(range: number[]|null) {
1089 if (range == null) {
1090 this.defaultYRange = null;
1091 return;
1092 }
1093 if (range.length != 2) {
1094 throw new Error('Range should contain exactly two values.');
1095 }
1096 this.defaultYRange = range;
1097 }
1098
James Kuszmaula8f2c452020-07-05 21:17:56 -07001099 resetZoom() {
James Kuszmaul71a81932020-12-15 21:08:01 -08001100 const minValues = this.drawer.minValues();
1101 const maxValues = this.drawer.maxValues();
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001102 for (const plot of this.linkedXAxes) {
1103 const otherMin = plot.drawer.minValues();
1104 const otherMax = plot.drawer.maxValues();
1105 // For linked x-axes, only adjust the x limits.
1106 minValues[0] = Math.min(minValues[0], otherMin[0]);
1107 maxValues[0] = Math.max(maxValues[0], otherMax[0]);
1108 }
1109 if (!isFinite(minValues[0]) || !isFinite(maxValues[0])) {
1110 minValues[0] = 0;
1111 maxValues[0] = 0;
1112 }
1113 if (!isFinite(minValues[1]) || !isFinite(maxValues[1])) {
1114 minValues[1] = 0;
1115 maxValues[1] = 0;
1116 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001117 if (minValues[0] == maxValues[0]) {
1118 minValues[0] -= 1;
1119 maxValues[0] += 1;
1120 }
1121 if (minValues[1] == maxValues[1]) {
1122 minValues[1] -= 1;
1123 maxValues[1] += 1;
1124 }
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001125 if (this.defaultYRange != null) {
1126 minValues[1] = this.defaultYRange[0];
1127 maxValues[1] = this.defaultYRange[1];
1128 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001129 this.setZoomCorners(minValues, maxValues);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001130 this.autoFollow = true;
1131 for (let plot of this.linkedXAxes) {
1132 plot.autoFollow = true;
1133 }
1134 }
1135
James Kuszmaul461b0682020-12-22 22:20:21 -08001136 static handleKeyUp(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001137 Plot.keysPressed[event.key] = false;
1138 }
1139
James Kuszmaul461b0682020-12-22 22:20:21 -08001140 static handleKeyDown(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001141 Plot.keysPressed[event.key] = true;
James Kuszmaul461b0682020-12-22 22:20:21 -08001142 for (const plot of this.allPlots) {
1143 if (Plot.keysPressed['Escape']) {
1144 // Cancel zoom/pan operations on escape.
1145 plot.lastMousePanPosition = null;
1146 plot.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001147 plot.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001148 }
1149 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001150 }
1151
1152 draw() {
1153 window.requestAnimationFrame(() => this.draw());
James Kuszmaul71a81932020-12-15 21:08:01 -08001154 const curTime = (new Date()).getTime();
1155 const frameRate = 1000.0 / (curTime - this.lastTimeMs);
1156 this.lastTimeMs = curTime;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001157
1158 // Clear the overlay.
1159 const textCtx = this.textCanvas.getContext("2d");
1160 textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
1161
1162 this.axisLabels.draw();
1163 this.axisLabels.drawMousePosition(this.lastMousePosition);
1164 this.legend.draw();
1165
1166 this.drawer.draw();
1167
1168 if (this.autoFollow) {
1169 this.resetZoom();
1170 }
1171 }
1172
1173 getDrawer(): LineDrawer {
1174 return this.drawer;
1175 }
1176
1177 getLegend(): Legend {
1178 return this.legend;
1179 }
1180
1181 getAxisLabels(): AxisLabels {
1182 return this.axisLabels;
1183 }
1184
1185 // Links this plot's x-axis with that of another Plot (e.g., to share time
1186 // axes).
1187 linkXAxis(other: Plot) {
1188 this.linkedXAxes.push(other);
1189 other.linkedXAxes.push(this);
1190 }
1191}