blob: 8d462f39a2629f84ed65c74b7363a86ca6dab882 [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,
Austin Schuhc2e9c502021-11-25 21:23:24 -0800412 Colors.CYAN, Colors.WHITE, Colors.ORANGE, Colors.YELLOW
James Kuszmaul933a9742021-03-07 19:59:06 -0800413 ];
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');
Austin Schuhc2e9c502021-11-25 21:23:24 -0800853 private lineDrawerContext: WebGLRenderingContext;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700854 private drawer: LineDrawer;
James Kuszmaul461b0682020-12-22 22:20:21 -0800855 private static keysPressed:
856 object = {'x': false, 'y': false, 'Escape': false};
857 // List of all plots to use for propagating key-press events to.
858 private static allPlots: Plot[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700859 // In canvas coordinates (the +/-1 square).
James Kuszmaul461b0682020-12-22 22:20:21 -0800860 private lastMousePanPosition: number[]|null = null;
861 private rectangleStartPosition: number[]|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700862 private axisLabelBuffer: WhitespaceBuffers =
863 new WhitespaceBuffers(50, 20, 20, 30);
864 private axisLabels: AxisLabels;
865 private legend: Legend;
866 private lastMousePosition: number[] = [0.0, 0.0];
867 private autoFollow: boolean = true;
868 private linkedXAxes: Plot[] = [];
James Kuszmaul71a81932020-12-15 21:08:01 -0800869 private lastTimeMs: number = 0;
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800870 private defaultYRange: number[]|null = null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800871 private zoomRectangle: Line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700872
Austin Schuhc2e9c502021-11-25 21:23:24 -0800873 constructor(wrapperDiv: HTMLDivElement) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700874 wrapperDiv.appendChild(this.canvas);
875 wrapperDiv.appendChild(this.textCanvas);
James Kuszmaul71a81932020-12-15 21:08:01 -0800876 this.lastTimeMs = (new Date()).getTime();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700877
Austin Schuhc2e9c502021-11-25 21:23:24 -0800878 this.canvas.style.paddingLeft = this.axisLabelBuffer.left.toString() + "px";
879 this.canvas.style.paddingRight = this.axisLabelBuffer.right.toString() + "px";
880 this.canvas.style.paddingTop = this.axisLabelBuffer.top.toString() + "px";
881 this.canvas.style.paddingBottom = this.axisLabelBuffer.bottom.toString() + "px";
882 this.canvas.style.width = "100%";
883 this.canvas.style.height = "100%";
884 this.canvas.style.boxSizing = "border-box";
James Kuszmaula8f2c452020-07-05 21:17:56 -0700885
Austin Schuhc2e9c502021-11-25 21:23:24 -0800886 this.canvas.style.position = 'absolute';
887 this.lineDrawerContext = this.canvas.getContext('webgl');
888 this.drawer = new LineDrawer(this.lineDrawerContext);
889
James Kuszmaula8f2c452020-07-05 21:17:56 -0700890 this.textCanvas.style.position = 'absolute';
Austin Schuhc2e9c502021-11-25 21:23:24 -0800891 this.textCanvas.style.width = "100%";
892 this.textCanvas.style.height = "100%";
James Kuszmaula8f2c452020-07-05 21:17:56 -0700893 this.textCanvas.style.pointerEvents = 'none';
894
895 this.canvas.addEventListener('dblclick', (e) => {
896 this.handleDoubleClick(e);
897 });
898 this.canvas.onwheel = (e) => {
899 this.handleWheel(e);
900 e.preventDefault();
901 };
902 this.canvas.onmousedown = (e) => {
903 this.handleMouseDown(e);
904 };
905 this.canvas.onmouseup = (e) => {
906 this.handleMouseUp(e);
907 };
908 this.canvas.onmousemove = (e) => {
909 this.handleMouseMove(e);
910 };
James Kuszmaul461b0682020-12-22 22:20:21 -0800911 this.canvas.addEventListener('contextmenu', event => event.preventDefault());
912 // Note: To handle the fact that only one keypress handle can be registered
913 // per browser tab, we share key-press handlers across all plot instances.
914 Plot.allPlots.push(this);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700915 document.onkeydown = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800916 Plot.handleKeyDown(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700917 };
918 document.onkeyup = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800919 Plot.handleKeyUp(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700920 };
921
922 const textCtx = this.textCanvas.getContext("2d");
923 this.axisLabels =
924 new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
925 this.legend = new Legend(textCtx, this.drawer.getLines());
926
James Kuszmaul933a9742021-03-07 19:59:06 -0800927 this.zoomRectangle = this.getDrawer().addLine(false);
928 this.zoomRectangle.setColor(Colors.WHITE);
James Kuszmaul461b0682020-12-22 22:20:21 -0800929 this.zoomRectangle.setPointSize(0);
930
James Kuszmaula8f2c452020-07-05 21:17:56 -0700931 this.draw();
932 }
933
934 handleDoubleClick(event: MouseEvent) {
935 this.resetZoom();
936 }
937
938 mouseCanvasLocation(event: MouseEvent): number[] {
Austin Schuhc2e9c502021-11-25 21:23:24 -0800939 const computedStyle = window.getComputedStyle(this.canvas);
940 const paddingLeftStr = computedStyle.getPropertyValue('padding-left');
941 const paddingTopStr = computedStyle.getPropertyValue('padding-top');
942 if (paddingLeftStr.substring(paddingLeftStr.length - 2) != "px") {
943 throw new Error("Left padding should be specified in pixels.");
944 }
945 if (paddingTopStr.substring(paddingTopStr.length - 2) != "px") {
946 throw new Error("Left padding should be specified in pixels.");
947 }
948 // Javascript will just ignore the extra "px".
949 const paddingLeft = Number.parseInt(paddingLeftStr);
950 const paddingTop = Number.parseInt(paddingTopStr);
951
James Kuszmaula8f2c452020-07-05 21:17:56 -0700952 return [
Austin Schuhc2e9c502021-11-25 21:23:24 -0800953 (event.offsetX - paddingLeft) * 2.0 / this.canvas.width - 1.0,
954 -(event.offsetY - paddingTop) * 2.0 / this.canvas.height + 1.0
James Kuszmaula8f2c452020-07-05 21:17:56 -0700955 ];
956 }
957
James Kuszmaul461b0682020-12-22 22:20:21 -0800958 mousePlotLocation(event: MouseEvent): number[] {
959 return this.drawer.canvasToPlotCoordinates(this.mouseCanvasLocation(event));
960 }
961
James Kuszmaula8f2c452020-07-05 21:17:56 -0700962 handleWheel(event: WheelEvent) {
963 if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
964 return;
965 }
966 const mousePosition = this.mouseCanvasLocation(event);
967 const kWheelTuningScalar = 1.5;
968 const zoom = -kWheelTuningScalar * event.deltaY / this.canvas.height;
969 let zoomScalar = 1.0 + Math.abs(zoom);
970 if (zoom < 0.0) {
971 zoomScalar = 1.0 / zoomScalar;
972 }
973 const scale = scaleVec(this.drawer.getZoom().scale, zoomScalar);
974 const offset = addVec(
975 scaleVec(mousePosition, 1.0 - zoomScalar),
976 scaleVec(this.drawer.getZoom().offset, zoomScalar));
977 this.setZoom(scale, offset);
978 }
979
980 handleMouseDown(event: MouseEvent) {
Austin Schuhd31a1612022-07-15 14:31:46 -0700981 for (let plot of this.linkedXAxes) {
982 plot.autoFollow = false;
983 }
984 this.autoFollow = false;
985
James Kuszmaul461b0682020-12-22 22:20:21 -0800986 const button = transitionButton(event);
987 switch (button) {
988 case PAN_BUTTON:
989 this.lastMousePanPosition = this.mouseCanvasLocation(event);
990 break;
991 case RECTANGLE_BUTTON:
992 this.rectangleStartPosition = this.mousePlotLocation(event);
993 break;
994 default:
995 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700996 }
997 }
998
999 handleMouseUp(event: MouseEvent) {
James Kuszmaul461b0682020-12-22 22:20:21 -08001000 const button = transitionButton(event);
1001 switch (button) {
1002 case PAN_BUTTON:
1003 this.lastMousePanPosition = null;
1004 break;
1005 case RECTANGLE_BUTTON:
1006 if (this.rectangleStartPosition === null) {
1007 // We got a right-button release without ever seeing the mouse-down;
1008 // just return.
1009 return;
1010 }
1011 this.finishRectangleZoom(event);
1012 break;
1013 default:
1014 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001015 }
1016 }
1017
James Kuszmaul461b0682020-12-22 22:20:21 -08001018 private finishRectangleZoom(event: MouseEvent) {
1019 const currentPosition = this.mousePlotLocation(event);
1020 this.setZoomCorners(this.rectangleStartPosition, currentPosition);
1021 this.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001022 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001023 }
1024
James Kuszmaula8f2c452020-07-05 21:17:56 -07001025 handleMouseMove(event: MouseEvent) {
1026 const mouseLocation = this.mouseCanvasLocation(event);
1027 if (buttonPressed(event, PAN_BUTTON) &&
1028 (this.lastMousePanPosition !== null)) {
1029 const mouseDiff =
1030 addVec(mouseLocation, scaleVec(this.lastMousePanPosition, -1));
1031 this.setZoom(
1032 this.drawer.getZoom().scale,
1033 addVec(this.drawer.getZoom().offset, mouseDiff));
1034 this.lastMousePanPosition = mouseLocation;
1035 }
James Kuszmaul461b0682020-12-22 22:20:21 -08001036 if (this.rectangleStartPosition !== null) {
1037 if (buttonPressed(event, RECTANGLE_BUTTON)) {
1038 // p0 and p1 are the two corners of the rectangle to draw.
1039 const p0 = [...this.rectangleStartPosition];
1040 const p1 = [...this.mousePlotLocation(event)];
1041 const minVisible = this.drawer.minVisiblePoint();
1042 const maxVisible = this.drawer.maxVisiblePoint();
1043 // Modify the rectangle corners to display correctly if we are limiting
1044 // the zoom to the x/y axis.
1045 const x_pressed = Plot.keysPressed['x'];
1046 const y_pressed = Plot.keysPressed["y"];
1047 if (x_pressed && !y_pressed) {
1048 p0[1] = minVisible[1];
1049 p1[1] = maxVisible[1];
1050 } else if (!x_pressed && y_pressed) {
1051 p0[0] = minVisible[0];
1052 p1[0] = maxVisible[0];
1053 }
James Kuszmaul0d7df892021-04-09 22:19:49 -07001054 this.zoomRectangle.setPoints([
1055 new Point(p0[0], p0[1]), new Point(p0[0], p1[1]),
1056 new Point(p1[0], p1[1]), new Point(p1[0], p0[1]),
1057 new Point(p0[0], p0[1])
1058 ]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001059 } else {
1060 this.finishRectangleZoom(event);
1061 }
1062 } else {
James Kuszmaul0d7df892021-04-09 22:19:49 -07001063 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001064 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001065 this.lastMousePosition = mouseLocation;
1066 }
1067
1068 setZoom(scale: number[], offset: number[]) {
James Kuszmaul71a81932020-12-15 21:08:01 -08001069 if (!isFinite(scale[0]) || !isFinite(scale[1])) {
1070 throw new Error("Doesn't support non-finite scales due to singularities.");
1071 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001072 const x_pressed = Plot.keysPressed["x"];
1073 const y_pressed = Plot.keysPressed["y"];
1074 const zoom = this.drawer.getZoom();
1075 if (x_pressed && !y_pressed) {
1076 zoom.scale[0] = scale[0];
1077 zoom.offset[0] = offset[0];
1078 } else if (y_pressed && !x_pressed) {
1079 zoom.scale[1] = scale[1];
1080 zoom.offset[1] = offset[1];
1081 } else {
1082 zoom.scale = scale;
1083 zoom.offset = offset;
1084 }
1085
1086 for (let plot of this.linkedXAxes) {
1087 const otherZoom = plot.drawer.getZoom();
1088 otherZoom.scale[0] = zoom.scale[0];
1089 otherZoom.offset[0] = zoom.offset[0];
1090 plot.drawer.setZoom(otherZoom);
1091 plot.autoFollow = false;
1092 }
1093 this.drawer.setZoom(zoom);
1094 this.autoFollow = false;
1095 }
1096
1097
1098 setZoomCorners(c1: number[], c2: number[]) {
1099 const scale = cwiseOp(c1, c2, (a, b) => {
1100 return 2.0 / Math.abs(a - b);
1101 });
1102 const offset = cwiseOp(scale, cwiseOp(c1, c2, Math.max), (a, b) => {
1103 return 1.0 - a * b;
1104 });
1105 this.setZoom(scale, offset);
1106 }
1107
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001108 setDefaultYRange(range: number[]|null) {
1109 if (range == null) {
1110 this.defaultYRange = null;
1111 return;
1112 }
1113 if (range.length != 2) {
1114 throw new Error('Range should contain exactly two values.');
1115 }
1116 this.defaultYRange = range;
1117 }
1118
James Kuszmaula8f2c452020-07-05 21:17:56 -07001119 resetZoom() {
James Kuszmaul71a81932020-12-15 21:08:01 -08001120 const minValues = this.drawer.minValues();
1121 const maxValues = this.drawer.maxValues();
Austin Schuh8b69cc22022-07-15 14:33:34 -07001122 const kScalar = 0.05;
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001123 for (const plot of this.linkedXAxes) {
1124 const otherMin = plot.drawer.minValues();
1125 const otherMax = plot.drawer.maxValues();
1126 // For linked x-axes, only adjust the x limits.
1127 minValues[0] = Math.min(minValues[0], otherMin[0]);
1128 maxValues[0] = Math.max(maxValues[0], otherMax[0]);
1129 }
1130 if (!isFinite(minValues[0]) || !isFinite(maxValues[0])) {
1131 minValues[0] = 0;
1132 maxValues[0] = 0;
1133 }
1134 if (!isFinite(minValues[1]) || !isFinite(maxValues[1])) {
1135 minValues[1] = 0;
1136 maxValues[1] = 0;
1137 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001138 if (minValues[0] == maxValues[0]) {
1139 minValues[0] -= 1;
1140 maxValues[0] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001141 } else {
1142 const width = maxValues[0] - minValues[0];
1143 maxValues[0] += width * kScalar;
1144 minValues[0] -= width * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001145 }
1146 if (minValues[1] == maxValues[1]) {
1147 minValues[1] -= 1;
1148 maxValues[1] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001149 } else {
1150 const height = maxValues[1] - minValues[1];
1151 maxValues[1] += height * kScalar;
1152 minValues[1] -= height * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001153 }
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001154 if (this.defaultYRange != null) {
1155 minValues[1] = this.defaultYRange[0];
1156 maxValues[1] = this.defaultYRange[1];
1157 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001158 this.setZoomCorners(minValues, maxValues);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001159 this.autoFollow = true;
1160 for (let plot of this.linkedXAxes) {
1161 plot.autoFollow = true;
1162 }
1163 }
1164
James Kuszmaul461b0682020-12-22 22:20:21 -08001165 static handleKeyUp(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001166 Plot.keysPressed[event.key] = false;
1167 }
1168
James Kuszmaul461b0682020-12-22 22:20:21 -08001169 static handleKeyDown(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001170 Plot.keysPressed[event.key] = true;
James Kuszmaul461b0682020-12-22 22:20:21 -08001171 for (const plot of this.allPlots) {
1172 if (Plot.keysPressed['Escape']) {
1173 // Cancel zoom/pan operations on escape.
1174 plot.lastMousePanPosition = null;
1175 plot.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001176 plot.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001177 }
1178 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001179 }
1180
1181 draw() {
1182 window.requestAnimationFrame(() => this.draw());
James Kuszmaul71a81932020-12-15 21:08:01 -08001183 const curTime = (new Date()).getTime();
1184 const frameRate = 1000.0 / (curTime - this.lastTimeMs);
1185 this.lastTimeMs = curTime;
Austin Schuhc2e9c502021-11-25 21:23:24 -08001186 const parentWidth = this.textCanvas.parentElement.offsetWidth;
1187 const parentHeight = this.textCanvas.parentElement.offsetHeight;
1188 this.textCanvas.width = parentWidth;
1189 this.textCanvas.height = parentHeight;
1190 this.canvas.width =
1191 parentWidth - this.axisLabelBuffer.left - this.axisLabelBuffer.right;
1192 this.canvas.height =
1193 parentHeight - this.axisLabelBuffer.top - this.axisLabelBuffer.bottom;
1194 this.lineDrawerContext.viewport(
1195 0, 0, this.lineDrawerContext.drawingBufferWidth,
1196 this.lineDrawerContext.drawingBufferHeight);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001197
1198 // Clear the overlay.
1199 const textCtx = this.textCanvas.getContext("2d");
1200 textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
1201
1202 this.axisLabels.draw();
1203 this.axisLabels.drawMousePosition(this.lastMousePosition);
1204 this.legend.draw();
1205
1206 this.drawer.draw();
1207
1208 if (this.autoFollow) {
1209 this.resetZoom();
1210 }
1211 }
1212
1213 getDrawer(): LineDrawer {
1214 return this.drawer;
1215 }
1216
1217 getLegend(): Legend {
1218 return this.legend;
1219 }
1220
1221 getAxisLabels(): AxisLabels {
1222 return this.axisLabels;
1223 }
1224
1225 // Links this plot's x-axis with that of another Plot (e.g., to share time
1226 // axes).
1227 linkXAxis(other: Plot) {
1228 this.linkedXAxes.push(other);
1229 other.linkedXAxes.push(this);
1230 }
1231}