blob: 89b360c7d3622d3d1601cccbefc5abcbb63a0f15 [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;
Austin Schuh83d6c152022-07-18 18:38:57 -0700108 private _hidden: boolean = false;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700109 constructor(
110 private readonly ctx: WebGLRenderingContext,
111 private readonly program: WebGLProgram,
James Kuszmaul0d7df892021-04-09 22:19:49 -0700112 private readonly buffer: WebGLBuffer, private baseZoom: ZoomParameters) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700113 this.pointAttribLocation = this.ctx.getAttribLocation(this.program, 'apos');
114 this.colorLocation = this.ctx.getUniformLocation(this.program, 'color');
115 this.pointSizeLocation =
116 this.ctx.getUniformLocation(this.program, 'point_size');
117 }
118
119 // Return the largest x and y values present in the list of points.
120 maxValues(): number[] {
121 return this._maxValues;
122 }
123
124 // Return the smallest x and y values present in the list of points.
125 minValues(): number[] {
126 return this._minValues;
127 }
128
129 // Whether any parameters have changed that would require re-rending the line.
130 hasUpdate(): boolean {
131 return this._hasUpdate;
132 }
133
134 // Get/set the color of the line, returned as an RGB tuple.
135 color(): number[] {
136 return this._color;
137 }
138
Austin Schuh7d63eab2021-03-06 20:15:02 -0800139 setColor(newColor: number[]): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700140 this._color = newColor;
141 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -0800142 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700143 }
144
145 // Get/set the size of the markers to draw, in pixels (zero means no markers).
146 pointSize(): number {
147 return this._pointSize;
148 }
149
Austin Schuh7d63eab2021-03-06 20:15:02 -0800150 setPointSize(size: number): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700151 this._pointSize = size;
152 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -0800153 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700154 }
155
156 // Get/set whether we draw a line between the points (i.e., setting this to
157 // false would effectively create a scatter-plot). If drawLine is false and
158 // pointSize is zero, then no data is rendered.
159 drawLine(): boolean {
160 return this._drawLine;
161 }
162
James Kuszmauld7d98e82021-03-07 20:17:54 -0800163 setDrawLine(newDrawLine: boolean): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700164 this._drawLine = newDrawLine;
165 this._hasUpdate = true;
James Kuszmauld7d98e82021-03-07 20:17:54 -0800166 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700167 }
168
169 // Set the points to render. The points in the line are ordered and should
170 // be of the format:
171 // [x1, y1, x2, y2, x3, y3, ...., xN, yN]
James Kuszmaul0d7df892021-04-09 22:19:49 -0700172 setPoints(points: Point[]) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700173 this.points = points;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700174 this.adjustedPoints = new Float32Array(points.length * 2);
175 this.updateBaseZoom(this.baseZoom);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700176 this._hasUpdate = true;
177 this._minValues[0] = Infinity;
178 this._minValues[1] = Infinity;
179 this._maxValues[0] = -Infinity;
180 this._maxValues[1] = -Infinity;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700181 for (let ii = 0; ii < this.points.length; ++ii) {
182 const x = this.points[ii].x;
183 const y = this.points[ii].y;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700184
James Kuszmaul71a81932020-12-15 21:08:01 -0800185 if (isNaN(x) || isNaN(y)) {
186 continue;
187 }
188
James Kuszmaula8f2c452020-07-05 21:17:56 -0700189 this._minValues = cwiseOp(this._minValues, [x, y], Math.min);
190 this._maxValues = cwiseOp(this._maxValues, [x, y], Math.max);
191 }
192 }
193
Austin Schuh83d6c152022-07-18 18:38:57 -0700194 hidden(): boolean {
195 return this._hidden;
196 }
197
198 setHidden(hidden: boolean) {
199 this._hasUpdate = true;
200 this._hidden = hidden;
201 }
202
James Kuszmaul0d7df892021-04-09 22:19:49 -0700203 getPoints(): Point[] {
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800204 return this.points;
205 }
206
James Kuszmaula8f2c452020-07-05 21:17:56 -0700207 // Get/set the label to use for the line when drawing the legend.
James Kuszmauld7d98e82021-03-07 20:17:54 -0800208 setLabel(label: string): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700209 this._label = label;
James Kuszmauld7d98e82021-03-07 20:17:54 -0800210 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700211 }
212
James Kuszmaul461b0682020-12-22 22:20:21 -0800213 label(): string|null {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700214 return this._label;
215 }
216
James Kuszmaul0d7df892021-04-09 22:19:49 -0700217 updateBaseZoom(zoom: ZoomParameters) {
218 this.baseZoom = zoom;
219 for (let ii = 0; ii < this.points.length; ++ii) {
220 const point = this.points[ii];
221 this.adjustedPoints[ii * 2] = point.x * zoom.scale[0] + zoom.offset[0];
222 this.adjustedPoints[ii * 2 + 1] = point.y * zoom.scale[1] + zoom.offset[1];
223 }
224 }
225
James Kuszmaula8f2c452020-07-05 21:17:56 -0700226 // Render the line on the canvas.
227 draw() {
228 this._hasUpdate = false;
229 if (this.points.length === 0) {
230 return;
231 }
232
Austin Schuh83d6c152022-07-18 18:38:57 -0700233 if (this._hidden) {
234 return;
235 }
236
James Kuszmaula8f2c452020-07-05 21:17:56 -0700237 this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.buffer);
238 // Note: if this is generating errors associated with the buffer size,
239 // confirm that this.points really is a Float32Array.
240 this.ctx.bufferData(
241 this.ctx.ARRAY_BUFFER,
James Kuszmaul0d7df892021-04-09 22:19:49 -0700242 this.adjustedPoints,
James Kuszmaula8f2c452020-07-05 21:17:56 -0700243 this.ctx.STATIC_DRAW);
244 {
245 const numComponents = 2; // pull out 2 values per iteration
246 const numType = this.ctx.FLOAT; // the data in the buffer is 32bit floats
247 const normalize = false; // don't normalize
248 const stride = 0; // how many bytes to get from one set of values to the
249 // next 0 = use type and numComponents above
250 const offset = 0; // how many bytes inside the buffer to start from
251 this.ctx.vertexAttribPointer(
252 this.pointAttribLocation, numComponents, numType,
253 normalize, stride, offset);
254 this.ctx.enableVertexAttribArray(this.pointAttribLocation);
255 }
256
257 this.ctx.uniform1f(this.pointSizeLocation, this._pointSize);
258 this.ctx.uniform4f(
259 this.colorLocation, this._color[0], this._color[1], this._color[2],
260 1.0);
261
262 if (this._drawLine) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700263 this.ctx.drawArrays(this.ctx.LINE_STRIP, 0, this.points.length);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700264 }
265 if (this._pointSize > 0.0) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700266 this.ctx.drawArrays(this.ctx.POINTS, 0, this.points.length);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700267 }
268 }
269}
270
James Kuszmaula8f2c452020-07-05 21:17:56 -0700271enum MouseButton {
272 Right,
273 Middle,
274 Left
275}
276
277// The button to use for panning the plot.
278const PAN_BUTTON = MouseButton.Left;
James Kuszmaul461b0682020-12-22 22:20:21 -0800279const RECTANGLE_BUTTON = MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700280
281// Returns the mouse button that generated a given event.
282function transitionButton(event: MouseEvent): MouseButton {
283 switch (event.button) {
284 case 0:
285 return MouseButton.Left;
286 case 1:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700287 return MouseButton.Middle;
James Kuszmaulbce45332020-12-15 19:50:01 -0800288 case 2:
289 return MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700290 }
291}
292
293// Returns whether the given button is pressed on the mouse.
294function buttonPressed(event: MouseEvent, button: MouseButton): boolean {
295 switch (button) {
296 // For some reason, the middle/right buttons are swapped relative to where
297 // we would expect them to be given the .button field.
298 case MouseButton.Left:
299 return 0 !== (event.buttons & 0x1);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700300 case MouseButton.Right:
James Kuszmaulbce45332020-12-15 19:50:01 -0800301 return 0 !== (event.buttons & 0x2);
302 case MouseButton.Middle:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700303 return 0 !== (event.buttons & 0x4);
304 }
305}
306
307// Handles rendering a Legend for a list of lines.
308// This takes a 2d canvas, which is what we use for rendering all the text of
309// the plot and is separate, but overlayed on top of, the WebGL canvas that the
310// lines are drawn on.
311export class Legend {
312 // Location, in pixels, of the legend in the text canvas.
313 private location: number[] = [0, 0];
Austin Schuh83d6c152022-07-18 18:38:57 -0700314 constructor(
315 private plot: Plot, private lines: Line[],
316 private legend: HTMLDivElement) {
Austin Schuhfcd56942022-07-18 17:41:32 -0700317 this.setPosition([80, 30]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700318 }
319
320 setPosition(location: number[]): void {
321 this.location = location;
Austin Schuhfcd56942022-07-18 17:41:32 -0700322 this.legend.style.left = location[0] + 'px';
323 this.legend.style.top = location[1] + 'px';
James Kuszmaula8f2c452020-07-05 21:17:56 -0700324 }
325
326 draw(): void {
Austin Schuhfcd56942022-07-18 17:41:32 -0700327 // First, figure out if anything has changed. The legend is created and
328 // then titles are changed afterwords, so we have to do this lazily.
329 let needsUpdate = false;
330 {
331 let child = 0;
332 for (let line of this.lines) {
333 if (line.label() === null) {
334 continue;
335 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700336
Austin Schuhfcd56942022-07-18 17:41:32 -0700337 if (child >= this.legend.children.length) {
338 needsUpdate = true;
339 break;
340 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700341
Austin Schuhfcd56942022-07-18 17:41:32 -0700342 // Make sure both have text in the right spot. Don't be too picky since
343 // nothing should really be changing here, and it's handy to let the
344 // user edit the HTML for testing.
345 if (this.legend.children[child].lastChild.textContent.length == 0 &&
346 line.label().length != 0) {
347 needsUpdate = true;
348 break;
349 }
350 child += 1;
James Kuszmaul461b0682020-12-22 22:20:21 -0800351 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700352
Austin Schuhfcd56942022-07-18 17:41:32 -0700353 // If we got through everything, we should be pointed past the last child.
354 // If not, more children exists than lines.
355 if (child != this.legend.children.length) {
356 needsUpdate = true;
357 }
358 }
359 if (!needsUpdate) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800360 return;
361 }
362
Austin Schuhfcd56942022-07-18 17:41:32 -0700363 // Nuke the old legend.
364 while (this.legend.firstChild) {
365 this.legend.removeChild(this.legend.firstChild);
366 }
James Kuszmaul461b0682020-12-22 22:20:21 -0800367
Austin Schuhfcd56942022-07-18 17:41:32 -0700368 // Now, build up a new legend.
James Kuszmaula8f2c452020-07-05 21:17:56 -0700369 for (let line of this.lines) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800370 if (line.label() === null) {
371 continue;
372 }
Austin Schuhfcd56942022-07-18 17:41:32 -0700373
374 // The legend is a div containing both a canvas for the style/color, and a
375 // div for the text. Make those, color in the canvas, and add it to the
376 // page.
377 let l = document.createElement('div');
378 l.classList.add('aos_legend_line');
379 let text = document.createElement('div');
380 text.textContent = line.label();
381
382 l.appendChild(text);
383 this.legend.appendChild(l);
384
385 let c = document.createElement('canvas');
386 c.width = text.offsetHeight;
387 c.height = text.offsetHeight;
388
389 const linestyleContext = c.getContext("2d");
390 linestyleContext.clearRect(0, 0, c.width, c.height);
391
James Kuszmaula8f2c452020-07-05 21:17:56 -0700392 const color = line.color();
Austin Schuhfcd56942022-07-18 17:41:32 -0700393 linestyleContext.strokeStyle = `rgb(${255.0 * color[0]}, ${
394 255.0 * color[1]}, ${255.0 * color[2]})`;
395 linestyleContext.fillStyle = linestyleContext.strokeStyle;
396
James Kuszmaula8f2c452020-07-05 21:17:56 -0700397 const pointSize = line.pointSize();
Austin Schuhfcd56942022-07-18 17:41:32 -0700398 const kDistanceIn = pointSize / 2.0;
399
400 if (line.drawLine()) {
401 linestyleContext.beginPath();
402 linestyleContext.moveTo(0, 0);
403 linestyleContext.lineTo(c.height, c.width);
404 linestyleContext.closePath();
405 linestyleContext.stroke();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700406 }
407
Austin Schuhfcd56942022-07-18 17:41:32 -0700408 if (pointSize > 0) {
409 linestyleContext.fillRect(0, 0, pointSize, pointSize);
410 linestyleContext.fillRect(
411 c.height - 1 - pointSize, c.width - 1 - pointSize, pointSize,
412 pointSize);
413 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700414
Austin Schuh83d6c152022-07-18 18:38:57 -0700415 c.addEventListener('click', (e) => {
416 if (!line.hidden()) {
417 l.classList.add('aos_legend_line_hidden');
418 } else {
419 l.classList.remove('aos_legend_line_hidden');
420 }
421
422 line.setHidden(!line.hidden());
423 this.plot.draw();
424 });
425
Austin Schuhfcd56942022-07-18 17:41:32 -0700426 l.prepend(c);
427 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700428 }
429}
430
431// This class manages all the WebGL rendering--namely, drawing the reference
432// grid for the user and then rendering all the actual lines of the plot.
433export class LineDrawer {
434 private program: WebGLProgram|null = null;
435 private scaleLocation: WebGLUniformLocation;
436 private offsetLocation: WebGLUniformLocation;
437 private vertexBuffer: WebGLBuffer;
438 private lines: Line[] = [];
439 private zoom: ZoomParameters = new ZoomParameters();
James Kuszmaul0d7df892021-04-09 22:19:49 -0700440 private baseZoom: ZoomParameters = new ZoomParameters();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700441 private zoomUpdated: boolean = true;
442 // Maximum grid lines to render at once--this is used provide an upper limit
443 // on the number of Line objects we need to create in order to render the
444 // grid.
445 public readonly MAX_GRID_LINES: number = 5;
446 // Arrays of the points at which we will draw grid lines for the x/y axes.
447 private xTicks: number[] = [];
448 private yTicks: number[] = [];
449 private xGridLines: Line[] = [];
450 private yGridLines: Line[] = [];
451
James Kuszmaul933a9742021-03-07 19:59:06 -0800452 public static readonly COLOR_CYCLE = [
453 Colors.RED, Colors.GREEN, Colors.BLUE, Colors.BROWN, Colors.PINK,
Austin Schuhc2e9c502021-11-25 21:23:24 -0800454 Colors.CYAN, Colors.WHITE, Colors.ORANGE, Colors.YELLOW
James Kuszmaul933a9742021-03-07 19:59:06 -0800455 ];
456 private colorCycleIndex = 0;
457
James Kuszmaula8f2c452020-07-05 21:17:56 -0700458 constructor(public readonly ctx: WebGLRenderingContext) {
459 this.program = this.compileShaders();
460 this.scaleLocation = this.ctx.getUniformLocation(this.program, 'scale');
461 this.offsetLocation = this.ctx.getUniformLocation(this.program, 'offset');
462 this.vertexBuffer = this.ctx.createBuffer();
463
464 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700465 this.xGridLines.push(
466 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
467 this.yGridLines.push(
468 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700469 }
470 }
471
James Kuszmaula8f2c452020-07-05 21:17:56 -0700472 getZoom(): ZoomParameters {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700473 return this.zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700474 }
475
476 plotToCanvasCoordinates(plotPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700477 return addVec(multVec(plotPos, this.zoom.scale), this.zoom.offset);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700478 }
479
480
481 canvasToPlotCoordinates(canvasPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700482 return divideVec(subtractVec(canvasPos, this.zoom.offset), this.zoom.scale);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700483 }
484
Austin Schuhfcd56942022-07-18 17:41:32 -0700485 // These return the max/min rendered points, in plot-space (this is helpful
James Kuszmaula8f2c452020-07-05 21:17:56 -0700486 // for drawing axis labels).
487 maxVisiblePoint(): number[] {
488 return this.canvasToPlotCoordinates([1.0, 1.0]);
489 }
490
491 minVisiblePoint(): number[] {
492 return this.canvasToPlotCoordinates([-1.0, -1.0]);
493 }
494
495 getLines(): Line[] {
496 return this.lines;
497 }
498
499 setZoom(zoom: ZoomParameters) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700500 if (this.zoom.scale[0] == zoom.scale[0] &&
501 this.zoom.scale[1] == zoom.scale[1] &&
502 this.zoom.offset[0] == zoom.offset[0] &&
503 this.zoom.offset[1] == zoom.offset[1]) {
504 return;
505 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700506 this.zoomUpdated = true;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700507 this.zoom = zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700508 }
509
510 setXTicks(ticks: number[]): void {
511 this.xTicks = ticks;
512 }
513
514 setYTicks(ticks: number[]): void {
515 this.yTicks = ticks;
516 }
517
518 // Update the grid lines.
519 updateTicks() {
520 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700521 this.xGridLines[ii].setPoints([]);
522 this.yGridLines[ii].setPoints([]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700523 }
524
525 const minValues = this.minVisiblePoint();
526 const maxValues = this.maxVisiblePoint();
527
528 for (let ii = 0; ii < this.xTicks.length; ++ii) {
529 this.xGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700530 const points = [
531 new Point(this.xTicks[ii], minValues[1]),
532 new Point(this.xTicks[ii], maxValues[1])
533 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700534 this.xGridLines[ii].setPointSize(0);
535 this.xGridLines[ii].setPoints(points);
536 this.xGridLines[ii].draw();
537 }
538
539 for (let ii = 0; ii < this.yTicks.length; ++ii) {
540 this.yGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700541 const points = [
542 new Point(minValues[0], this.yTicks[ii]),
543 new Point(maxValues[0], this.yTicks[ii])
544 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700545 this.yGridLines[ii].setPointSize(0);
546 this.yGridLines[ii].setPoints(points);
547 this.yGridLines[ii].draw();
548 }
549 }
550
551 // Handles redrawing any of the WebGL objects, if necessary.
552 draw(): void {
553 let needsUpdate = this.zoomUpdated;
554 this.zoomUpdated = false;
555 for (let line of this.lines) {
556 if (line.hasUpdate()) {
557 needsUpdate = true;
558 break;
559 }
560 }
561 if (!needsUpdate) {
562 return;
563 }
564
565 this.reset();
566
567 this.updateTicks();
568
569 for (let line of this.lines) {
570 line.draw();
571 }
572
573 return;
574 }
575
576 loadShader(shaderType: number, source: string): WebGLShader {
577 const shader = this.ctx.createShader(shaderType);
578 this.ctx.shaderSource(shader, source);
579 this.ctx.compileShader(shader);
580 if (!this.ctx.getShaderParameter(shader, this.ctx.COMPILE_STATUS)) {
581 alert(
582 'Got an error compiling a shader: ' +
583 this.ctx.getShaderInfoLog(shader));
584 this.ctx.deleteShader(shader);
585 return null;
586 }
587
588 return shader;
589 }
590
591 compileShaders(): WebGLProgram {
592 const vertexShader = 'attribute vec2 apos;' +
593 'uniform vec2 scale;' +
594 'uniform vec2 offset;' +
595 'uniform float point_size;' +
596 'void main() {' +
597 ' gl_Position.xy = apos.xy * scale.xy + offset.xy;' +
598 ' gl_Position.z = 0.0;' +
599 ' gl_Position.w = 1.0;' +
600 ' gl_PointSize = point_size;' +
601 '}';
602
603 const fragmentShader = 'precision highp float;' +
604 'uniform vec4 color;' +
605 'void main() {' +
606 ' gl_FragColor = color;' +
607 '}';
608
609 const compiledVertex =
610 this.loadShader(this.ctx.VERTEX_SHADER, vertexShader);
611 const compiledFragment =
612 this.loadShader(this.ctx.FRAGMENT_SHADER, fragmentShader);
613 const program = this.ctx.createProgram();
614 this.ctx.attachShader(program, compiledVertex);
615 this.ctx.attachShader(program, compiledFragment);
616 this.ctx.linkProgram(program);
617 if (!this.ctx.getProgramParameter(program, this.ctx.LINK_STATUS)) {
618 alert(
619 'Unable to link the shaders: ' + this.ctx.getProgramInfoLog(program));
620 return null;
621 }
622 return program;
623 }
624
James Kuszmaul933a9742021-03-07 19:59:06 -0800625 addLine(useColorCycle: boolean = true): Line {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700626 this.lines.push(
627 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaul933a9742021-03-07 19:59:06 -0800628 const line = this.lines[this.lines.length - 1];
629 if (useColorCycle) {
630 line.setColor(LineDrawer.COLOR_CYCLE[this.colorCycleIndex++]);
631 }
632 return line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700633 }
634
635 minValues(): number[] {
636 let minValues = [Infinity, Infinity];
637 for (let line of this.lines) {
638 minValues = cwiseOp(minValues, line.minValues(), Math.min);
639 }
640 return minValues;
641 }
642
643 maxValues(): number[] {
644 let maxValues = [-Infinity, -Infinity];
645 for (let line of this.lines) {
646 maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
647 }
648 return maxValues;
649 }
650
651 reset(): void {
652 // Set the background color
653 this.ctx.clearColor(0.5, 0.5, 0.5, 1.0);
654 this.ctx.clearDepth(1.0);
655 this.ctx.enable(this.ctx.DEPTH_TEST);
656 this.ctx.depthFunc(this.ctx.LEQUAL);
657 this.ctx.clear(this.ctx.COLOR_BUFFER_BIT | this.ctx.DEPTH_BUFFER_BIT);
658
659 this.ctx.useProgram(this.program);
660
James Kuszmaul0d7df892021-04-09 22:19:49 -0700661 // Check for whether the zoom parameters have changed significantly; if so,
662 // update the base zoom.
663 // These thresholds are somewhat arbitrary.
664 const scaleDiff = divideVec(this.zoom.scale, this.baseZoom.scale);
665 const scaleChanged = scaleDiff[0] < 0.9 || scaleDiff[0] > 1.1 ||
666 scaleDiff[1] < 0.9 || scaleDiff[1] > 1.1;
667 const offsetDiff = subtractVec(this.zoom.offset, this.baseZoom.offset);
668 // Note that offset is in the canvas coordinate frame and so just using
669 // hard-coded constants is fine.
670 const offsetChanged =
671 Math.abs(offsetDiff[0]) > 0.1 || Math.abs(offsetDiff[1]) > 0.1;
672 if (scaleChanged || offsetChanged) {
673 this.baseZoom = this.zoom.copy();
674 for (const line of this.lines) {
675 line.updateBaseZoom(this.baseZoom);
676 }
677 for (const line of this.xGridLines) {
678 line.updateBaseZoom(this.baseZoom);
679 }
680 for (const line of this.yGridLines) {
681 line.updateBaseZoom(this.baseZoom);
682 }
683 }
684
685 // all the points in the lines will be pre-scaled by this.baseZoom, so
686 // we need to remove its effects before passing it in.
687 // zoom.scale * pos + zoom.offset = scale * (baseZoom.scale * pos + baseZoom.offset) + offset
688 // zoom.scale = scale * baseZoom.scale
689 // scale = zoom.scale / baseZoom.scale
690 // zoom.offset = scale * baseZoom.offset + offset
691 // offset = zoom.offset - scale * baseZoom.offset
692 const scale = divideVec(this.zoom.scale, this.baseZoom.scale);
693 const offset =
694 subtractVec(this.zoom.offset, multVec(scale, this.baseZoom.offset));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700695 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700696 this.scaleLocation, scale[0], scale[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700697 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700698 this.offsetLocation, offset[0], offset[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700699 }
700}
701
702// Class to store how much whitespace we put between the edges of the WebGL
703// canvas (where we draw all the lines) and the edge of the plot. This gives
704// us space to, e.g., draw axis labels, the plot title, etc.
705class WhitespaceBuffers {
706 constructor(
707 public left: number, public right: number, public top: number,
708 public bottom: number) {}
709}
710
711// Class to manage all the annotations associated with the plot--the axis/tick
712// labels and the plot title.
713class AxisLabels {
714 private readonly INCREMENTS: number[] = [2, 4, 5, 10];
715 // Space to leave to create some visual space around the text.
716 private readonly TEXT_BUFFER: number = 5;
717 private title: string = "";
718 private xlabel: string = "";
719 private ylabel: string = "";
720 constructor(
721 private ctx: CanvasRenderingContext2D, private drawer: LineDrawer,
722 private graphBuffers: WhitespaceBuffers) {}
723
724 numberToLabel(num: number): string {
725 return num.toPrecision(5);
726 }
727
728 textWidth(str: string): number {
729 return this.ctx.measureText(str).actualBoundingBoxRight;
730 }
731
732 textHeight(str: string): number {
733 return this.ctx.measureText(str).actualBoundingBoxAscent;
734 }
735
736 textDepth(str: string): number {
737 return this.ctx.measureText(str).actualBoundingBoxDescent;
738 }
739
740 setTitle(title: string) {
741 this.title = title;
742 }
743
744 setXLabel(xlabel: string) {
745 this.xlabel = xlabel;
746 }
747
748 setYLabel(ylabel: string) {
749 this.ylabel = ylabel;
750 }
751
752 getIncrement(range: number[]): number {
753 const diff = Math.abs(range[1] - range[0]);
754 const minDiff = diff / this.drawer.MAX_GRID_LINES;
755 const incrementsRatio = this.INCREMENTS[this.INCREMENTS.length - 1];
756 const order = Math.pow(
757 incrementsRatio,
758 Math.floor(Math.log(minDiff) / Math.log(incrementsRatio)));
759 const normalizedDiff = minDiff / order;
760 for (let increment of this.INCREMENTS) {
761 if (increment > normalizedDiff) {
762 return increment * order;
763 }
764 }
765 return 1.0;
766 }
767
768 getTicks(range: number[]): number[] {
769 const increment = this.getIncrement(range);
770 const start = Math.ceil(range[0] / increment) * increment;
771 const values = [start];
772 for (let ii = 0; ii < this.drawer.MAX_GRID_LINES - 1; ++ii) {
773 const nextValue = values[ii] + increment;
774 if (nextValue > range[1]) {
775 break;
776 }
777 values.push(nextValue);
778 }
779 return values;
780 }
781
782 plotToCanvasCoordinates(plotPos: number[]): number[] {
783 const webglCoord = this.drawer.plotToCanvasCoordinates(plotPos);
784 const webglX = (webglCoord[0] + 1.0) / 2.0 * this.drawer.ctx.canvas.width;
785 const webglY = (1.0 - webglCoord[1]) / 2.0 * this.drawer.ctx.canvas.height;
786 return [webglX + this.graphBuffers.left, webglY + this.graphBuffers.top];
787 }
788
789 drawXTick(x: number) {
790 const text = this.numberToLabel(x);
791 const height = this.textHeight(text);
792 const xpos = this.plotToCanvasCoordinates([x, 0])[0];
793 this.ctx.textAlign = "center";
794 this.ctx.fillText(
795 text, xpos,
796 this.ctx.canvas.height - this.graphBuffers.bottom + height +
797 this.TEXT_BUFFER);
798 }
799
800 drawYTick(y: number) {
801 const text = this.numberToLabel(y);
802 const height = this.textHeight(text);
803 const ypos = this.plotToCanvasCoordinates([0, y])[1];
804 this.ctx.textAlign = "right";
805 this.ctx.fillText(
806 text, this.graphBuffers.left - this.TEXT_BUFFER,
807 ypos + height / 2.0);
808 }
809
810 drawTitle() {
811 if (this.title) {
812 this.ctx.textAlign = 'center';
813 this.ctx.fillText(
814 this.title, this.ctx.canvas.width / 2.0,
815 this.graphBuffers.top - this.TEXT_BUFFER);
816 }
817 }
818
819 drawXLabel() {
820 if (this.xlabel) {
821 this.ctx.textAlign = 'center';
822 this.ctx.fillText(
823 this.xlabel, this.ctx.canvas.width / 2.0,
824 this.ctx.canvas.height - this.TEXT_BUFFER);
825 }
826 }
827
828 drawYLabel() {
829 this.ctx.save();
830 if (this.ylabel) {
831 this.ctx.textAlign = 'center';
832 const height = this.textHeight(this.ylabel);
833 this.ctx.translate(
834 height + this.TEXT_BUFFER, this.ctx.canvas.height / 2.0);
835 this.ctx.rotate(-Math.PI / 2.0);
836 this.ctx.fillText(this.ylabel, 0, 0);
837 }
838 this.ctx.restore();
839 }
840
841 draw() {
842 this.ctx.fillStyle = 'black';
843 const minValues = this.drawer.minVisiblePoint();
844 const maxValues = this.drawer.maxVisiblePoint();
845 let text = this.numberToLabel(maxValues[1]);
846 this.drawYTick(maxValues[1]);
847 this.drawYTick(minValues[1]);
848 this.drawXTick(minValues[0]);
849 this.drawXTick(maxValues[0]);
850 this.ctx.strokeStyle = 'black';
851 this.ctx.strokeRect(
852 this.graphBuffers.left, this.graphBuffers.top,
853 this.drawer.ctx.canvas.width, this.drawer.ctx.canvas.height);
854 this.ctx.strokeRect(
855 0, 0,
856 this.ctx.canvas.width, this.ctx.canvas.height);
857 const xTicks = this.getTicks([minValues[0], maxValues[0]]);
858 this.drawer.setXTicks(xTicks);
859 const yTicks = this.getTicks([minValues[1], maxValues[1]]);
860 this.drawer.setYTicks(yTicks);
861
862 for (let x of xTicks) {
863 this.drawXTick(x);
864 }
865
866 for (let y of yTicks) {
867 this.drawYTick(y);
868 }
869
870 this.drawTitle();
871 this.drawXLabel();
872 this.drawYLabel();
873 }
874
875 // Draws the current mouse position in the bottom-right of the plot.
876 drawMousePosition(mousePos: number[]) {
877 const plotPos = this.drawer.canvasToPlotCoordinates(mousePos);
878
879 const text =
880 `(${plotPos[0].toPrecision(10)}, ${plotPos[1].toPrecision(10)})`;
881 const textDepth = this.textDepth(text);
882 this.ctx.textAlign = 'right';
883 this.ctx.fillText(
884 text, this.ctx.canvas.width - this.graphBuffers.right,
885 this.ctx.canvas.height - this.graphBuffers.bottom - textDepth);
886 }
887}
888
889// This class manages the entirety of a single plot. Most of the logic in
890// this class is around handling mouse/keyboard events for interacting with
891// the plot.
892export class Plot {
893 private canvas = document.createElement('canvas');
894 private textCanvas = document.createElement('canvas');
Austin Schuhfcd56942022-07-18 17:41:32 -0700895 private legendDiv = document.createElement('div');
Austin Schuhc2e9c502021-11-25 21:23:24 -0800896 private lineDrawerContext: WebGLRenderingContext;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700897 private drawer: LineDrawer;
James Kuszmaul461b0682020-12-22 22:20:21 -0800898 private static keysPressed:
899 object = {'x': false, 'y': false, 'Escape': false};
900 // List of all plots to use for propagating key-press events to.
901 private static allPlots: Plot[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700902 // In canvas coordinates (the +/-1 square).
James Kuszmaul461b0682020-12-22 22:20:21 -0800903 private lastMousePanPosition: number[]|null = null;
904 private rectangleStartPosition: number[]|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700905 private axisLabelBuffer: WhitespaceBuffers =
906 new WhitespaceBuffers(50, 20, 20, 30);
907 private axisLabels: AxisLabels;
908 private legend: Legend;
909 private lastMousePosition: number[] = [0.0, 0.0];
910 private autoFollow: boolean = true;
911 private linkedXAxes: Plot[] = [];
James Kuszmaul71a81932020-12-15 21:08:01 -0800912 private lastTimeMs: number = 0;
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800913 private defaultYRange: number[]|null = null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800914 private zoomRectangle: Line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700915
Austin Schuhc2e9c502021-11-25 21:23:24 -0800916 constructor(wrapperDiv: HTMLDivElement) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700917 wrapperDiv.appendChild(this.canvas);
918 wrapperDiv.appendChild(this.textCanvas);
Austin Schuhfcd56942022-07-18 17:41:32 -0700919 this.legendDiv.classList.add('aos_legend');
920 wrapperDiv.appendChild(this.legendDiv);
James Kuszmaul71a81932020-12-15 21:08:01 -0800921 this.lastTimeMs = (new Date()).getTime();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700922
Austin Schuhc2e9c502021-11-25 21:23:24 -0800923 this.canvas.style.paddingLeft = this.axisLabelBuffer.left.toString() + "px";
924 this.canvas.style.paddingRight = this.axisLabelBuffer.right.toString() + "px";
925 this.canvas.style.paddingTop = this.axisLabelBuffer.top.toString() + "px";
926 this.canvas.style.paddingBottom = this.axisLabelBuffer.bottom.toString() + "px";
Austin Schuhfcd56942022-07-18 17:41:32 -0700927 this.canvas.classList.add('aos_plot');
James Kuszmaula8f2c452020-07-05 21:17:56 -0700928
Austin Schuhc2e9c502021-11-25 21:23:24 -0800929 this.lineDrawerContext = this.canvas.getContext('webgl');
930 this.drawer = new LineDrawer(this.lineDrawerContext);
931
Austin Schuhfcd56942022-07-18 17:41:32 -0700932 this.textCanvas.classList.add('aos_plot_text');
James Kuszmaula8f2c452020-07-05 21:17:56 -0700933
934 this.canvas.addEventListener('dblclick', (e) => {
935 this.handleDoubleClick(e);
936 });
937 this.canvas.onwheel = (e) => {
938 this.handleWheel(e);
939 e.preventDefault();
940 };
941 this.canvas.onmousedown = (e) => {
942 this.handleMouseDown(e);
943 };
944 this.canvas.onmouseup = (e) => {
945 this.handleMouseUp(e);
946 };
947 this.canvas.onmousemove = (e) => {
948 this.handleMouseMove(e);
949 };
James Kuszmaul461b0682020-12-22 22:20:21 -0800950 this.canvas.addEventListener('contextmenu', event => event.preventDefault());
951 // Note: To handle the fact that only one keypress handle can be registered
952 // per browser tab, we share key-press handlers across all plot instances.
953 Plot.allPlots.push(this);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700954 document.onkeydown = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800955 Plot.handleKeyDown(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700956 };
957 document.onkeyup = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800958 Plot.handleKeyUp(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700959 };
960
961 const textCtx = this.textCanvas.getContext("2d");
962 this.axisLabels =
963 new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
Austin Schuh83d6c152022-07-18 18:38:57 -0700964 this.legend = new Legend(this, this.drawer.getLines(), this.legendDiv);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700965
James Kuszmaul933a9742021-03-07 19:59:06 -0800966 this.zoomRectangle = this.getDrawer().addLine(false);
967 this.zoomRectangle.setColor(Colors.WHITE);
James Kuszmaul461b0682020-12-22 22:20:21 -0800968 this.zoomRectangle.setPointSize(0);
969
James Kuszmaula8f2c452020-07-05 21:17:56 -0700970 this.draw();
971 }
972
973 handleDoubleClick(event: MouseEvent) {
974 this.resetZoom();
975 }
976
977 mouseCanvasLocation(event: MouseEvent): number[] {
Austin Schuhc2e9c502021-11-25 21:23:24 -0800978 const computedStyle = window.getComputedStyle(this.canvas);
979 const paddingLeftStr = computedStyle.getPropertyValue('padding-left');
980 const paddingTopStr = computedStyle.getPropertyValue('padding-top');
981 if (paddingLeftStr.substring(paddingLeftStr.length - 2) != "px") {
982 throw new Error("Left padding should be specified in pixels.");
983 }
984 if (paddingTopStr.substring(paddingTopStr.length - 2) != "px") {
985 throw new Error("Left padding should be specified in pixels.");
986 }
987 // Javascript will just ignore the extra "px".
988 const paddingLeft = Number.parseInt(paddingLeftStr);
989 const paddingTop = Number.parseInt(paddingTopStr);
990
James Kuszmaula8f2c452020-07-05 21:17:56 -0700991 return [
Austin Schuhc2e9c502021-11-25 21:23:24 -0800992 (event.offsetX - paddingLeft) * 2.0 / this.canvas.width - 1.0,
993 -(event.offsetY - paddingTop) * 2.0 / this.canvas.height + 1.0
James Kuszmaula8f2c452020-07-05 21:17:56 -0700994 ];
995 }
996
James Kuszmaul461b0682020-12-22 22:20:21 -0800997 mousePlotLocation(event: MouseEvent): number[] {
998 return this.drawer.canvasToPlotCoordinates(this.mouseCanvasLocation(event));
999 }
1000
James Kuszmaula8f2c452020-07-05 21:17:56 -07001001 handleWheel(event: WheelEvent) {
1002 if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
1003 return;
1004 }
1005 const mousePosition = this.mouseCanvasLocation(event);
1006 const kWheelTuningScalar = 1.5;
1007 const zoom = -kWheelTuningScalar * event.deltaY / this.canvas.height;
1008 let zoomScalar = 1.0 + Math.abs(zoom);
1009 if (zoom < 0.0) {
1010 zoomScalar = 1.0 / zoomScalar;
1011 }
1012 const scale = scaleVec(this.drawer.getZoom().scale, zoomScalar);
1013 const offset = addVec(
1014 scaleVec(mousePosition, 1.0 - zoomScalar),
1015 scaleVec(this.drawer.getZoom().offset, zoomScalar));
1016 this.setZoom(scale, offset);
1017 }
1018
1019 handleMouseDown(event: MouseEvent) {
Austin Schuhd31a1612022-07-15 14:31:46 -07001020 for (let plot of this.linkedXAxes) {
1021 plot.autoFollow = false;
1022 }
1023 this.autoFollow = false;
1024
James Kuszmaul461b0682020-12-22 22:20:21 -08001025 const button = transitionButton(event);
1026 switch (button) {
1027 case PAN_BUTTON:
1028 this.lastMousePanPosition = this.mouseCanvasLocation(event);
1029 break;
1030 case RECTANGLE_BUTTON:
1031 this.rectangleStartPosition = this.mousePlotLocation(event);
1032 break;
1033 default:
1034 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001035 }
1036 }
1037
1038 handleMouseUp(event: MouseEvent) {
James Kuszmaul461b0682020-12-22 22:20:21 -08001039 const button = transitionButton(event);
1040 switch (button) {
1041 case PAN_BUTTON:
1042 this.lastMousePanPosition = null;
1043 break;
1044 case RECTANGLE_BUTTON:
1045 if (this.rectangleStartPosition === null) {
1046 // We got a right-button release without ever seeing the mouse-down;
1047 // just return.
1048 return;
1049 }
1050 this.finishRectangleZoom(event);
1051 break;
1052 default:
1053 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001054 }
1055 }
1056
James Kuszmaul461b0682020-12-22 22:20:21 -08001057 private finishRectangleZoom(event: MouseEvent) {
1058 const currentPosition = this.mousePlotLocation(event);
1059 this.setZoomCorners(this.rectangleStartPosition, currentPosition);
1060 this.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001061 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001062 }
1063
James Kuszmaula8f2c452020-07-05 21:17:56 -07001064 handleMouseMove(event: MouseEvent) {
1065 const mouseLocation = this.mouseCanvasLocation(event);
1066 if (buttonPressed(event, PAN_BUTTON) &&
1067 (this.lastMousePanPosition !== null)) {
1068 const mouseDiff =
1069 addVec(mouseLocation, scaleVec(this.lastMousePanPosition, -1));
1070 this.setZoom(
1071 this.drawer.getZoom().scale,
1072 addVec(this.drawer.getZoom().offset, mouseDiff));
1073 this.lastMousePanPosition = mouseLocation;
1074 }
James Kuszmaul461b0682020-12-22 22:20:21 -08001075 if (this.rectangleStartPosition !== null) {
1076 if (buttonPressed(event, RECTANGLE_BUTTON)) {
1077 // p0 and p1 are the two corners of the rectangle to draw.
1078 const p0 = [...this.rectangleStartPosition];
1079 const p1 = [...this.mousePlotLocation(event)];
1080 const minVisible = this.drawer.minVisiblePoint();
1081 const maxVisible = this.drawer.maxVisiblePoint();
1082 // Modify the rectangle corners to display correctly if we are limiting
1083 // the zoom to the x/y axis.
1084 const x_pressed = Plot.keysPressed['x'];
1085 const y_pressed = Plot.keysPressed["y"];
1086 if (x_pressed && !y_pressed) {
1087 p0[1] = minVisible[1];
1088 p1[1] = maxVisible[1];
1089 } else if (!x_pressed && y_pressed) {
1090 p0[0] = minVisible[0];
1091 p1[0] = maxVisible[0];
1092 }
James Kuszmaul0d7df892021-04-09 22:19:49 -07001093 this.zoomRectangle.setPoints([
1094 new Point(p0[0], p0[1]), new Point(p0[0], p1[1]),
1095 new Point(p1[0], p1[1]), new Point(p1[0], p0[1]),
1096 new Point(p0[0], p0[1])
1097 ]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001098 } else {
1099 this.finishRectangleZoom(event);
1100 }
1101 } else {
James Kuszmaul0d7df892021-04-09 22:19:49 -07001102 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001103 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001104 this.lastMousePosition = mouseLocation;
1105 }
1106
1107 setZoom(scale: number[], offset: number[]) {
James Kuszmaul71a81932020-12-15 21:08:01 -08001108 if (!isFinite(scale[0]) || !isFinite(scale[1])) {
1109 throw new Error("Doesn't support non-finite scales due to singularities.");
1110 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001111 const x_pressed = Plot.keysPressed["x"];
1112 const y_pressed = Plot.keysPressed["y"];
1113 const zoom = this.drawer.getZoom();
1114 if (x_pressed && !y_pressed) {
1115 zoom.scale[0] = scale[0];
1116 zoom.offset[0] = offset[0];
1117 } else if (y_pressed && !x_pressed) {
1118 zoom.scale[1] = scale[1];
1119 zoom.offset[1] = offset[1];
1120 } else {
1121 zoom.scale = scale;
1122 zoom.offset = offset;
1123 }
1124
1125 for (let plot of this.linkedXAxes) {
1126 const otherZoom = plot.drawer.getZoom();
1127 otherZoom.scale[0] = zoom.scale[0];
1128 otherZoom.offset[0] = zoom.offset[0];
1129 plot.drawer.setZoom(otherZoom);
1130 plot.autoFollow = false;
1131 }
1132 this.drawer.setZoom(zoom);
1133 this.autoFollow = false;
1134 }
1135
1136
1137 setZoomCorners(c1: number[], c2: number[]) {
1138 const scale = cwiseOp(c1, c2, (a, b) => {
1139 return 2.0 / Math.abs(a - b);
1140 });
1141 const offset = cwiseOp(scale, cwiseOp(c1, c2, Math.max), (a, b) => {
1142 return 1.0 - a * b;
1143 });
1144 this.setZoom(scale, offset);
1145 }
1146
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001147 setDefaultYRange(range: number[]|null) {
1148 if (range == null) {
1149 this.defaultYRange = null;
1150 return;
1151 }
1152 if (range.length != 2) {
1153 throw new Error('Range should contain exactly two values.');
1154 }
1155 this.defaultYRange = range;
1156 }
1157
James Kuszmaula8f2c452020-07-05 21:17:56 -07001158 resetZoom() {
James Kuszmaul71a81932020-12-15 21:08:01 -08001159 const minValues = this.drawer.minValues();
1160 const maxValues = this.drawer.maxValues();
Austin Schuh8b69cc22022-07-15 14:33:34 -07001161 const kScalar = 0.05;
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001162 for (const plot of this.linkedXAxes) {
1163 const otherMin = plot.drawer.minValues();
1164 const otherMax = plot.drawer.maxValues();
1165 // For linked x-axes, only adjust the x limits.
1166 minValues[0] = Math.min(minValues[0], otherMin[0]);
1167 maxValues[0] = Math.max(maxValues[0], otherMax[0]);
1168 }
1169 if (!isFinite(minValues[0]) || !isFinite(maxValues[0])) {
1170 minValues[0] = 0;
1171 maxValues[0] = 0;
1172 }
1173 if (!isFinite(minValues[1]) || !isFinite(maxValues[1])) {
1174 minValues[1] = 0;
1175 maxValues[1] = 0;
1176 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001177 if (minValues[0] == maxValues[0]) {
1178 minValues[0] -= 1;
1179 maxValues[0] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001180 } else {
1181 const width = maxValues[0] - minValues[0];
1182 maxValues[0] += width * kScalar;
1183 minValues[0] -= width * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001184 }
1185 if (minValues[1] == maxValues[1]) {
1186 minValues[1] -= 1;
1187 maxValues[1] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001188 } else {
1189 const height = maxValues[1] - minValues[1];
1190 maxValues[1] += height * kScalar;
1191 minValues[1] -= height * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001192 }
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001193 if (this.defaultYRange != null) {
1194 minValues[1] = this.defaultYRange[0];
1195 maxValues[1] = this.defaultYRange[1];
1196 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001197 this.setZoomCorners(minValues, maxValues);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001198 this.autoFollow = true;
1199 for (let plot of this.linkedXAxes) {
1200 plot.autoFollow = true;
1201 }
1202 }
1203
James Kuszmaul461b0682020-12-22 22:20:21 -08001204 static handleKeyUp(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001205 Plot.keysPressed[event.key] = false;
1206 }
1207
James Kuszmaul461b0682020-12-22 22:20:21 -08001208 static handleKeyDown(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001209 Plot.keysPressed[event.key] = true;
James Kuszmaul461b0682020-12-22 22:20:21 -08001210 for (const plot of this.allPlots) {
1211 if (Plot.keysPressed['Escape']) {
1212 // Cancel zoom/pan operations on escape.
1213 plot.lastMousePanPosition = null;
1214 plot.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001215 plot.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001216 }
1217 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001218 }
1219
1220 draw() {
1221 window.requestAnimationFrame(() => this.draw());
James Kuszmaul71a81932020-12-15 21:08:01 -08001222 const curTime = (new Date()).getTime();
1223 const frameRate = 1000.0 / (curTime - this.lastTimeMs);
1224 this.lastTimeMs = curTime;
Austin Schuhc2e9c502021-11-25 21:23:24 -08001225 const parentWidth = this.textCanvas.parentElement.offsetWidth;
1226 const parentHeight = this.textCanvas.parentElement.offsetHeight;
1227 this.textCanvas.width = parentWidth;
1228 this.textCanvas.height = parentHeight;
1229 this.canvas.width =
1230 parentWidth - this.axisLabelBuffer.left - this.axisLabelBuffer.right;
1231 this.canvas.height =
1232 parentHeight - this.axisLabelBuffer.top - this.axisLabelBuffer.bottom;
1233 this.lineDrawerContext.viewport(
1234 0, 0, this.lineDrawerContext.drawingBufferWidth,
1235 this.lineDrawerContext.drawingBufferHeight);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001236
1237 // Clear the overlay.
1238 const textCtx = this.textCanvas.getContext("2d");
1239 textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
1240
1241 this.axisLabels.draw();
1242 this.axisLabels.drawMousePosition(this.lastMousePosition);
1243 this.legend.draw();
1244
1245 this.drawer.draw();
1246
1247 if (this.autoFollow) {
1248 this.resetZoom();
1249 }
1250 }
1251
1252 getDrawer(): LineDrawer {
1253 return this.drawer;
1254 }
1255
1256 getLegend(): Legend {
1257 return this.legend;
1258 }
1259
1260 getAxisLabels(): AxisLabels {
1261 return this.axisLabels;
1262 }
1263
1264 // Links this plot's x-axis with that of another Plot (e.g., to share time
1265 // axes).
1266 linkXAxis(other: Plot) {
1267 this.linkedXAxes.push(other);
1268 other.linkedXAxes.push(this);
1269 }
1270}