blob: 17bffa35882feac4636d5e3180d14df9069527f0 [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.
Austin Schuh772aad62023-02-04 14:26:17 -0800345 let textdiv = this.legend.children[child].lastChild;
346 let canvas = this.legend.children[child].firstChild;
347 if ((textdiv.textContent.length == 0 && line.label().length != 0) ||
348 (textdiv as HTMLDivElement).offsetHeight !=
349 (canvas as HTMLCanvasElement).height) {
Austin Schuhfcd56942022-07-18 17:41:32 -0700350 needsUpdate = true;
351 break;
352 }
353 child += 1;
James Kuszmaul461b0682020-12-22 22:20:21 -0800354 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700355
Austin Schuhfcd56942022-07-18 17:41:32 -0700356 // If we got through everything, we should be pointed past the last child.
357 // If not, more children exists than lines.
358 if (child != this.legend.children.length) {
359 needsUpdate = true;
360 }
361 }
362 if (!needsUpdate) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800363 return;
364 }
365
Austin Schuhfcd56942022-07-18 17:41:32 -0700366 // Nuke the old legend.
367 while (this.legend.firstChild) {
368 this.legend.removeChild(this.legend.firstChild);
369 }
James Kuszmaul461b0682020-12-22 22:20:21 -0800370
Austin Schuhfcd56942022-07-18 17:41:32 -0700371 // Now, build up a new legend.
James Kuszmaula8f2c452020-07-05 21:17:56 -0700372 for (let line of this.lines) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800373 if (line.label() === null) {
374 continue;
375 }
Austin Schuhfcd56942022-07-18 17:41:32 -0700376
377 // The legend is a div containing both a canvas for the style/color, and a
378 // div for the text. Make those, color in the canvas, and add it to the
379 // page.
380 let l = document.createElement('div');
381 l.classList.add('aos_legend_line');
382 let text = document.createElement('div');
383 text.textContent = line.label();
384
385 l.appendChild(text);
386 this.legend.appendChild(l);
387
388 let c = document.createElement('canvas');
389 c.width = text.offsetHeight;
390 c.height = text.offsetHeight;
391
392 const linestyleContext = c.getContext("2d");
393 linestyleContext.clearRect(0, 0, c.width, c.height);
394
James Kuszmaula8f2c452020-07-05 21:17:56 -0700395 const color = line.color();
Austin Schuhfcd56942022-07-18 17:41:32 -0700396 linestyleContext.strokeStyle = `rgb(${255.0 * color[0]}, ${
397 255.0 * color[1]}, ${255.0 * color[2]})`;
398 linestyleContext.fillStyle = linestyleContext.strokeStyle;
399
James Kuszmaula8f2c452020-07-05 21:17:56 -0700400 const pointSize = line.pointSize();
Austin Schuhfcd56942022-07-18 17:41:32 -0700401 const kDistanceIn = pointSize / 2.0;
402
403 if (line.drawLine()) {
404 linestyleContext.beginPath();
405 linestyleContext.moveTo(0, 0);
406 linestyleContext.lineTo(c.height, c.width);
407 linestyleContext.closePath();
408 linestyleContext.stroke();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700409 }
410
Austin Schuhfcd56942022-07-18 17:41:32 -0700411 if (pointSize > 0) {
412 linestyleContext.fillRect(0, 0, pointSize, pointSize);
413 linestyleContext.fillRect(
414 c.height - 1 - pointSize, c.width - 1 - pointSize, pointSize,
415 pointSize);
416 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700417
Austin Schuh83d6c152022-07-18 18:38:57 -0700418 c.addEventListener('click', (e) => {
419 if (!line.hidden()) {
420 l.classList.add('aos_legend_line_hidden');
421 } else {
422 l.classList.remove('aos_legend_line_hidden');
423 }
424
425 line.setHidden(!line.hidden());
426 this.plot.draw();
427 });
428
Austin Schuhfcd56942022-07-18 17:41:32 -0700429 l.prepend(c);
430 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700431 }
432}
433
434// This class manages all the WebGL rendering--namely, drawing the reference
435// grid for the user and then rendering all the actual lines of the plot.
436export class LineDrawer {
437 private program: WebGLProgram|null = null;
438 private scaleLocation: WebGLUniformLocation;
439 private offsetLocation: WebGLUniformLocation;
440 private vertexBuffer: WebGLBuffer;
441 private lines: Line[] = [];
442 private zoom: ZoomParameters = new ZoomParameters();
James Kuszmaul0d7df892021-04-09 22:19:49 -0700443 private baseZoom: ZoomParameters = new ZoomParameters();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700444 private zoomUpdated: boolean = true;
445 // Maximum grid lines to render at once--this is used provide an upper limit
446 // on the number of Line objects we need to create in order to render the
447 // grid.
448 public readonly MAX_GRID_LINES: number = 5;
449 // Arrays of the points at which we will draw grid lines for the x/y axes.
450 private xTicks: number[] = [];
451 private yTicks: number[] = [];
452 private xGridLines: Line[] = [];
453 private yGridLines: Line[] = [];
454
James Kuszmaul933a9742021-03-07 19:59:06 -0800455 public static readonly COLOR_CYCLE = [
456 Colors.RED, Colors.GREEN, Colors.BLUE, Colors.BROWN, Colors.PINK,
Austin Schuhc2e9c502021-11-25 21:23:24 -0800457 Colors.CYAN, Colors.WHITE, Colors.ORANGE, Colors.YELLOW
James Kuszmaul933a9742021-03-07 19:59:06 -0800458 ];
459 private colorCycleIndex = 0;
460
James Kuszmaula8f2c452020-07-05 21:17:56 -0700461 constructor(public readonly ctx: WebGLRenderingContext) {
462 this.program = this.compileShaders();
463 this.scaleLocation = this.ctx.getUniformLocation(this.program, 'scale');
464 this.offsetLocation = this.ctx.getUniformLocation(this.program, 'offset');
465 this.vertexBuffer = this.ctx.createBuffer();
466
467 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700468 this.xGridLines.push(
469 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
470 this.yGridLines.push(
471 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700472 }
473 }
474
James Kuszmaula8f2c452020-07-05 21:17:56 -0700475 getZoom(): ZoomParameters {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700476 return this.zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700477 }
478
479 plotToCanvasCoordinates(plotPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700480 return addVec(multVec(plotPos, this.zoom.scale), this.zoom.offset);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700481 }
482
483
484 canvasToPlotCoordinates(canvasPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700485 return divideVec(subtractVec(canvasPos, this.zoom.offset), this.zoom.scale);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700486 }
487
Austin Schuhfcd56942022-07-18 17:41:32 -0700488 // These return the max/min rendered points, in plot-space (this is helpful
James Kuszmaula8f2c452020-07-05 21:17:56 -0700489 // for drawing axis labels).
490 maxVisiblePoint(): number[] {
491 return this.canvasToPlotCoordinates([1.0, 1.0]);
492 }
493
494 minVisiblePoint(): number[] {
495 return this.canvasToPlotCoordinates([-1.0, -1.0]);
496 }
497
498 getLines(): Line[] {
499 return this.lines;
500 }
501
502 setZoom(zoom: ZoomParameters) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700503 if (this.zoom.scale[0] == zoom.scale[0] &&
504 this.zoom.scale[1] == zoom.scale[1] &&
505 this.zoom.offset[0] == zoom.offset[0] &&
506 this.zoom.offset[1] == zoom.offset[1]) {
507 return;
508 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700509 this.zoomUpdated = true;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700510 this.zoom = zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700511 }
512
513 setXTicks(ticks: number[]): void {
514 this.xTicks = ticks;
515 }
516
517 setYTicks(ticks: number[]): void {
518 this.yTicks = ticks;
519 }
520
521 // Update the grid lines.
522 updateTicks() {
523 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700524 this.xGridLines[ii].setPoints([]);
525 this.yGridLines[ii].setPoints([]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700526 }
527
528 const minValues = this.minVisiblePoint();
529 const maxValues = this.maxVisiblePoint();
530
531 for (let ii = 0; ii < this.xTicks.length; ++ii) {
532 this.xGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700533 const points = [
534 new Point(this.xTicks[ii], minValues[1]),
535 new Point(this.xTicks[ii], maxValues[1])
536 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700537 this.xGridLines[ii].setPointSize(0);
538 this.xGridLines[ii].setPoints(points);
539 this.xGridLines[ii].draw();
540 }
541
542 for (let ii = 0; ii < this.yTicks.length; ++ii) {
543 this.yGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700544 const points = [
545 new Point(minValues[0], this.yTicks[ii]),
546 new Point(maxValues[0], this.yTicks[ii])
547 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700548 this.yGridLines[ii].setPointSize(0);
549 this.yGridLines[ii].setPoints(points);
550 this.yGridLines[ii].draw();
551 }
552 }
553
554 // Handles redrawing any of the WebGL objects, if necessary.
555 draw(): void {
556 let needsUpdate = this.zoomUpdated;
557 this.zoomUpdated = false;
558 for (let line of this.lines) {
559 if (line.hasUpdate()) {
560 needsUpdate = true;
561 break;
562 }
563 }
564 if (!needsUpdate) {
565 return;
566 }
567
568 this.reset();
569
570 this.updateTicks();
571
572 for (let line of this.lines) {
573 line.draw();
574 }
575
576 return;
577 }
578
579 loadShader(shaderType: number, source: string): WebGLShader {
580 const shader = this.ctx.createShader(shaderType);
581 this.ctx.shaderSource(shader, source);
582 this.ctx.compileShader(shader);
583 if (!this.ctx.getShaderParameter(shader, this.ctx.COMPILE_STATUS)) {
584 alert(
585 'Got an error compiling a shader: ' +
586 this.ctx.getShaderInfoLog(shader));
587 this.ctx.deleteShader(shader);
588 return null;
589 }
590
591 return shader;
592 }
593
594 compileShaders(): WebGLProgram {
595 const vertexShader = 'attribute vec2 apos;' +
596 'uniform vec2 scale;' +
597 'uniform vec2 offset;' +
598 'uniform float point_size;' +
599 'void main() {' +
600 ' gl_Position.xy = apos.xy * scale.xy + offset.xy;' +
601 ' gl_Position.z = 0.0;' +
602 ' gl_Position.w = 1.0;' +
603 ' gl_PointSize = point_size;' +
604 '}';
605
606 const fragmentShader = 'precision highp float;' +
607 'uniform vec4 color;' +
608 'void main() {' +
609 ' gl_FragColor = color;' +
610 '}';
611
612 const compiledVertex =
613 this.loadShader(this.ctx.VERTEX_SHADER, vertexShader);
614 const compiledFragment =
615 this.loadShader(this.ctx.FRAGMENT_SHADER, fragmentShader);
616 const program = this.ctx.createProgram();
617 this.ctx.attachShader(program, compiledVertex);
618 this.ctx.attachShader(program, compiledFragment);
619 this.ctx.linkProgram(program);
620 if (!this.ctx.getProgramParameter(program, this.ctx.LINK_STATUS)) {
621 alert(
622 'Unable to link the shaders: ' + this.ctx.getProgramInfoLog(program));
623 return null;
624 }
625 return program;
626 }
627
James Kuszmaul933a9742021-03-07 19:59:06 -0800628 addLine(useColorCycle: boolean = true): Line {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700629 this.lines.push(
630 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaul933a9742021-03-07 19:59:06 -0800631 const line = this.lines[this.lines.length - 1];
632 if (useColorCycle) {
633 line.setColor(LineDrawer.COLOR_CYCLE[this.colorCycleIndex++]);
634 }
635 return line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700636 }
637
638 minValues(): number[] {
639 let minValues = [Infinity, Infinity];
640 for (let line of this.lines) {
641 minValues = cwiseOp(minValues, line.minValues(), Math.min);
642 }
643 return minValues;
644 }
645
646 maxValues(): number[] {
647 let maxValues = [-Infinity, -Infinity];
648 for (let line of this.lines) {
649 maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
650 }
651 return maxValues;
652 }
653
654 reset(): void {
655 // Set the background color
656 this.ctx.clearColor(0.5, 0.5, 0.5, 1.0);
657 this.ctx.clearDepth(1.0);
658 this.ctx.enable(this.ctx.DEPTH_TEST);
659 this.ctx.depthFunc(this.ctx.LEQUAL);
660 this.ctx.clear(this.ctx.COLOR_BUFFER_BIT | this.ctx.DEPTH_BUFFER_BIT);
661
662 this.ctx.useProgram(this.program);
663
James Kuszmaul0d7df892021-04-09 22:19:49 -0700664 // Check for whether the zoom parameters have changed significantly; if so,
665 // update the base zoom.
666 // These thresholds are somewhat arbitrary.
667 const scaleDiff = divideVec(this.zoom.scale, this.baseZoom.scale);
668 const scaleChanged = scaleDiff[0] < 0.9 || scaleDiff[0] > 1.1 ||
669 scaleDiff[1] < 0.9 || scaleDiff[1] > 1.1;
670 const offsetDiff = subtractVec(this.zoom.offset, this.baseZoom.offset);
671 // Note that offset is in the canvas coordinate frame and so just using
672 // hard-coded constants is fine.
673 const offsetChanged =
674 Math.abs(offsetDiff[0]) > 0.1 || Math.abs(offsetDiff[1]) > 0.1;
675 if (scaleChanged || offsetChanged) {
676 this.baseZoom = this.zoom.copy();
677 for (const line of this.lines) {
678 line.updateBaseZoom(this.baseZoom);
679 }
680 for (const line of this.xGridLines) {
681 line.updateBaseZoom(this.baseZoom);
682 }
683 for (const line of this.yGridLines) {
684 line.updateBaseZoom(this.baseZoom);
685 }
686 }
687
688 // all the points in the lines will be pre-scaled by this.baseZoom, so
689 // we need to remove its effects before passing it in.
690 // zoom.scale * pos + zoom.offset = scale * (baseZoom.scale * pos + baseZoom.offset) + offset
691 // zoom.scale = scale * baseZoom.scale
692 // scale = zoom.scale / baseZoom.scale
693 // zoom.offset = scale * baseZoom.offset + offset
694 // offset = zoom.offset - scale * baseZoom.offset
695 const scale = divideVec(this.zoom.scale, this.baseZoom.scale);
696 const offset =
697 subtractVec(this.zoom.offset, multVec(scale, this.baseZoom.offset));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700698 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700699 this.scaleLocation, scale[0], scale[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700700 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700701 this.offsetLocation, offset[0], offset[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700702 }
703}
704
705// Class to store how much whitespace we put between the edges of the WebGL
706// canvas (where we draw all the lines) and the edge of the plot. This gives
707// us space to, e.g., draw axis labels, the plot title, etc.
708class WhitespaceBuffers {
709 constructor(
710 public left: number, public right: number, public top: number,
711 public bottom: number) {}
712}
713
714// Class to manage all the annotations associated with the plot--the axis/tick
715// labels and the plot title.
716class AxisLabels {
717 private readonly INCREMENTS: number[] = [2, 4, 5, 10];
718 // Space to leave to create some visual space around the text.
719 private readonly TEXT_BUFFER: number = 5;
720 private title: string = "";
721 private xlabel: string = "";
722 private ylabel: string = "";
723 constructor(
724 private ctx: CanvasRenderingContext2D, private drawer: LineDrawer,
725 private graphBuffers: WhitespaceBuffers) {}
726
727 numberToLabel(num: number): string {
728 return num.toPrecision(5);
729 }
730
731 textWidth(str: string): number {
732 return this.ctx.measureText(str).actualBoundingBoxRight;
733 }
734
735 textHeight(str: string): number {
736 return this.ctx.measureText(str).actualBoundingBoxAscent;
737 }
738
739 textDepth(str: string): number {
740 return this.ctx.measureText(str).actualBoundingBoxDescent;
741 }
742
743 setTitle(title: string) {
744 this.title = title;
745 }
746
747 setXLabel(xlabel: string) {
748 this.xlabel = xlabel;
749 }
750
751 setYLabel(ylabel: string) {
752 this.ylabel = ylabel;
753 }
754
755 getIncrement(range: number[]): number {
756 const diff = Math.abs(range[1] - range[0]);
757 const minDiff = diff / this.drawer.MAX_GRID_LINES;
758 const incrementsRatio = this.INCREMENTS[this.INCREMENTS.length - 1];
759 const order = Math.pow(
760 incrementsRatio,
761 Math.floor(Math.log(minDiff) / Math.log(incrementsRatio)));
762 const normalizedDiff = minDiff / order;
763 for (let increment of this.INCREMENTS) {
764 if (increment > normalizedDiff) {
765 return increment * order;
766 }
767 }
768 return 1.0;
769 }
770
771 getTicks(range: number[]): number[] {
772 const increment = this.getIncrement(range);
773 const start = Math.ceil(range[0] / increment) * increment;
774 const values = [start];
775 for (let ii = 0; ii < this.drawer.MAX_GRID_LINES - 1; ++ii) {
776 const nextValue = values[ii] + increment;
777 if (nextValue > range[1]) {
778 break;
779 }
780 values.push(nextValue);
781 }
782 return values;
783 }
784
785 plotToCanvasCoordinates(plotPos: number[]): number[] {
786 const webglCoord = this.drawer.plotToCanvasCoordinates(plotPos);
787 const webglX = (webglCoord[0] + 1.0) / 2.0 * this.drawer.ctx.canvas.width;
788 const webglY = (1.0 - webglCoord[1]) / 2.0 * this.drawer.ctx.canvas.height;
789 return [webglX + this.graphBuffers.left, webglY + this.graphBuffers.top];
790 }
791
792 drawXTick(x: number) {
793 const text = this.numberToLabel(x);
794 const height = this.textHeight(text);
795 const xpos = this.plotToCanvasCoordinates([x, 0])[0];
796 this.ctx.textAlign = "center";
797 this.ctx.fillText(
798 text, xpos,
799 this.ctx.canvas.height - this.graphBuffers.bottom + height +
800 this.TEXT_BUFFER);
801 }
802
803 drawYTick(y: number) {
804 const text = this.numberToLabel(y);
805 const height = this.textHeight(text);
806 const ypos = this.plotToCanvasCoordinates([0, y])[1];
807 this.ctx.textAlign = "right";
808 this.ctx.fillText(
809 text, this.graphBuffers.left - this.TEXT_BUFFER,
810 ypos + height / 2.0);
811 }
812
813 drawTitle() {
814 if (this.title) {
815 this.ctx.textAlign = 'center';
816 this.ctx.fillText(
817 this.title, this.ctx.canvas.width / 2.0,
818 this.graphBuffers.top - this.TEXT_BUFFER);
819 }
820 }
821
822 drawXLabel() {
823 if (this.xlabel) {
824 this.ctx.textAlign = 'center';
825 this.ctx.fillText(
826 this.xlabel, this.ctx.canvas.width / 2.0,
827 this.ctx.canvas.height - this.TEXT_BUFFER);
828 }
829 }
830
831 drawYLabel() {
832 this.ctx.save();
833 if (this.ylabel) {
834 this.ctx.textAlign = 'center';
835 const height = this.textHeight(this.ylabel);
836 this.ctx.translate(
837 height + this.TEXT_BUFFER, this.ctx.canvas.height / 2.0);
838 this.ctx.rotate(-Math.PI / 2.0);
839 this.ctx.fillText(this.ylabel, 0, 0);
840 }
841 this.ctx.restore();
842 }
843
844 draw() {
845 this.ctx.fillStyle = 'black';
846 const minValues = this.drawer.minVisiblePoint();
847 const maxValues = this.drawer.maxVisiblePoint();
848 let text = this.numberToLabel(maxValues[1]);
849 this.drawYTick(maxValues[1]);
850 this.drawYTick(minValues[1]);
851 this.drawXTick(minValues[0]);
852 this.drawXTick(maxValues[0]);
853 this.ctx.strokeStyle = 'black';
854 this.ctx.strokeRect(
855 this.graphBuffers.left, this.graphBuffers.top,
856 this.drawer.ctx.canvas.width, this.drawer.ctx.canvas.height);
857 this.ctx.strokeRect(
858 0, 0,
859 this.ctx.canvas.width, this.ctx.canvas.height);
860 const xTicks = this.getTicks([minValues[0], maxValues[0]]);
861 this.drawer.setXTicks(xTicks);
862 const yTicks = this.getTicks([minValues[1], maxValues[1]]);
863 this.drawer.setYTicks(yTicks);
864
865 for (let x of xTicks) {
866 this.drawXTick(x);
867 }
868
869 for (let y of yTicks) {
870 this.drawYTick(y);
871 }
872
873 this.drawTitle();
874 this.drawXLabel();
875 this.drawYLabel();
876 }
877
878 // Draws the current mouse position in the bottom-right of the plot.
879 drawMousePosition(mousePos: number[]) {
880 const plotPos = this.drawer.canvasToPlotCoordinates(mousePos);
881
882 const text =
883 `(${plotPos[0].toPrecision(10)}, ${plotPos[1].toPrecision(10)})`;
884 const textDepth = this.textDepth(text);
885 this.ctx.textAlign = 'right';
886 this.ctx.fillText(
887 text, this.ctx.canvas.width - this.graphBuffers.right,
888 this.ctx.canvas.height - this.graphBuffers.bottom - textDepth);
889 }
890}
891
892// This class manages the entirety of a single plot. Most of the logic in
893// this class is around handling mouse/keyboard events for interacting with
894// the plot.
895export class Plot {
896 private canvas = document.createElement('canvas');
897 private textCanvas = document.createElement('canvas');
Austin Schuhfcd56942022-07-18 17:41:32 -0700898 private legendDiv = document.createElement('div');
Austin Schuhc2e9c502021-11-25 21:23:24 -0800899 private lineDrawerContext: WebGLRenderingContext;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700900 private drawer: LineDrawer;
James Kuszmaul461b0682020-12-22 22:20:21 -0800901 private static keysPressed:
902 object = {'x': false, 'y': false, 'Escape': false};
903 // List of all plots to use for propagating key-press events to.
904 private static allPlots: Plot[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700905 // In canvas coordinates (the +/-1 square).
James Kuszmaul461b0682020-12-22 22:20:21 -0800906 private lastMousePanPosition: number[]|null = null;
907 private rectangleStartPosition: number[]|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700908 private axisLabelBuffer: WhitespaceBuffers =
909 new WhitespaceBuffers(50, 20, 20, 30);
910 private axisLabels: AxisLabels;
911 private legend: Legend;
912 private lastMousePosition: number[] = [0.0, 0.0];
913 private autoFollow: boolean = true;
914 private linkedXAxes: Plot[] = [];
James Kuszmaul71a81932020-12-15 21:08:01 -0800915 private lastTimeMs: number = 0;
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800916 private defaultYRange: number[]|null = null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800917 private zoomRectangle: Line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700918
Austin Schuhc2e9c502021-11-25 21:23:24 -0800919 constructor(wrapperDiv: HTMLDivElement) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700920 wrapperDiv.appendChild(this.canvas);
921 wrapperDiv.appendChild(this.textCanvas);
Austin Schuhfcd56942022-07-18 17:41:32 -0700922 this.legendDiv.classList.add('aos_legend');
923 wrapperDiv.appendChild(this.legendDiv);
James Kuszmaul71a81932020-12-15 21:08:01 -0800924 this.lastTimeMs = (new Date()).getTime();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700925
Austin Schuhc2e9c502021-11-25 21:23:24 -0800926 this.canvas.style.paddingLeft = this.axisLabelBuffer.left.toString() + "px";
927 this.canvas.style.paddingRight = this.axisLabelBuffer.right.toString() + "px";
928 this.canvas.style.paddingTop = this.axisLabelBuffer.top.toString() + "px";
929 this.canvas.style.paddingBottom = this.axisLabelBuffer.bottom.toString() + "px";
Austin Schuhfcd56942022-07-18 17:41:32 -0700930 this.canvas.classList.add('aos_plot');
James Kuszmaula8f2c452020-07-05 21:17:56 -0700931
Austin Schuhc2e9c502021-11-25 21:23:24 -0800932 this.lineDrawerContext = this.canvas.getContext('webgl');
933 this.drawer = new LineDrawer(this.lineDrawerContext);
934
Austin Schuhfcd56942022-07-18 17:41:32 -0700935 this.textCanvas.classList.add('aos_plot_text');
James Kuszmaula8f2c452020-07-05 21:17:56 -0700936
937 this.canvas.addEventListener('dblclick', (e) => {
938 this.handleDoubleClick(e);
939 });
940 this.canvas.onwheel = (e) => {
941 this.handleWheel(e);
942 e.preventDefault();
943 };
944 this.canvas.onmousedown = (e) => {
945 this.handleMouseDown(e);
946 };
947 this.canvas.onmouseup = (e) => {
948 this.handleMouseUp(e);
949 };
950 this.canvas.onmousemove = (e) => {
951 this.handleMouseMove(e);
952 };
James Kuszmaul461b0682020-12-22 22:20:21 -0800953 this.canvas.addEventListener('contextmenu', event => event.preventDefault());
954 // Note: To handle the fact that only one keypress handle can be registered
955 // per browser tab, we share key-press handlers across all plot instances.
956 Plot.allPlots.push(this);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700957 document.onkeydown = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800958 Plot.handleKeyDown(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700959 };
960 document.onkeyup = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800961 Plot.handleKeyUp(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700962 };
963
964 const textCtx = this.textCanvas.getContext("2d");
965 this.axisLabels =
966 new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
Austin Schuh83d6c152022-07-18 18:38:57 -0700967 this.legend = new Legend(this, this.drawer.getLines(), this.legendDiv);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700968
James Kuszmaul933a9742021-03-07 19:59:06 -0800969 this.zoomRectangle = this.getDrawer().addLine(false);
970 this.zoomRectangle.setColor(Colors.WHITE);
James Kuszmaul461b0682020-12-22 22:20:21 -0800971 this.zoomRectangle.setPointSize(0);
972
James Kuszmaula8f2c452020-07-05 21:17:56 -0700973 this.draw();
974 }
975
976 handleDoubleClick(event: MouseEvent) {
977 this.resetZoom();
978 }
979
980 mouseCanvasLocation(event: MouseEvent): number[] {
Austin Schuhc2e9c502021-11-25 21:23:24 -0800981 const computedStyle = window.getComputedStyle(this.canvas);
982 const paddingLeftStr = computedStyle.getPropertyValue('padding-left');
983 const paddingTopStr = computedStyle.getPropertyValue('padding-top');
984 if (paddingLeftStr.substring(paddingLeftStr.length - 2) != "px") {
985 throw new Error("Left padding should be specified in pixels.");
986 }
987 if (paddingTopStr.substring(paddingTopStr.length - 2) != "px") {
988 throw new Error("Left padding should be specified in pixels.");
989 }
990 // Javascript will just ignore the extra "px".
991 const paddingLeft = Number.parseInt(paddingLeftStr);
992 const paddingTop = Number.parseInt(paddingTopStr);
993
James Kuszmaula8f2c452020-07-05 21:17:56 -0700994 return [
Austin Schuhc2e9c502021-11-25 21:23:24 -0800995 (event.offsetX - paddingLeft) * 2.0 / this.canvas.width - 1.0,
996 -(event.offsetY - paddingTop) * 2.0 / this.canvas.height + 1.0
James Kuszmaula8f2c452020-07-05 21:17:56 -0700997 ];
998 }
999
James Kuszmaul461b0682020-12-22 22:20:21 -08001000 mousePlotLocation(event: MouseEvent): number[] {
1001 return this.drawer.canvasToPlotCoordinates(this.mouseCanvasLocation(event));
1002 }
1003
James Kuszmaula8f2c452020-07-05 21:17:56 -07001004 handleWheel(event: WheelEvent) {
1005 if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
1006 return;
1007 }
1008 const mousePosition = this.mouseCanvasLocation(event);
1009 const kWheelTuningScalar = 1.5;
1010 const zoom = -kWheelTuningScalar * event.deltaY / this.canvas.height;
1011 let zoomScalar = 1.0 + Math.abs(zoom);
1012 if (zoom < 0.0) {
1013 zoomScalar = 1.0 / zoomScalar;
1014 }
1015 const scale = scaleVec(this.drawer.getZoom().scale, zoomScalar);
1016 const offset = addVec(
1017 scaleVec(mousePosition, 1.0 - zoomScalar),
1018 scaleVec(this.drawer.getZoom().offset, zoomScalar));
1019 this.setZoom(scale, offset);
1020 }
1021
1022 handleMouseDown(event: MouseEvent) {
Austin Schuhd31a1612022-07-15 14:31:46 -07001023 for (let plot of this.linkedXAxes) {
1024 plot.autoFollow = false;
1025 }
1026 this.autoFollow = false;
1027
James Kuszmaul461b0682020-12-22 22:20:21 -08001028 const button = transitionButton(event);
1029 switch (button) {
1030 case PAN_BUTTON:
1031 this.lastMousePanPosition = this.mouseCanvasLocation(event);
1032 break;
1033 case RECTANGLE_BUTTON:
1034 this.rectangleStartPosition = this.mousePlotLocation(event);
1035 break;
1036 default:
1037 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001038 }
1039 }
1040
1041 handleMouseUp(event: MouseEvent) {
James Kuszmaul461b0682020-12-22 22:20:21 -08001042 const button = transitionButton(event);
1043 switch (button) {
1044 case PAN_BUTTON:
1045 this.lastMousePanPosition = null;
1046 break;
1047 case RECTANGLE_BUTTON:
1048 if (this.rectangleStartPosition === null) {
1049 // We got a right-button release without ever seeing the mouse-down;
1050 // just return.
1051 return;
1052 }
1053 this.finishRectangleZoom(event);
1054 break;
1055 default:
1056 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001057 }
1058 }
1059
James Kuszmaul461b0682020-12-22 22:20:21 -08001060 private finishRectangleZoom(event: MouseEvent) {
1061 const currentPosition = this.mousePlotLocation(event);
1062 this.setZoomCorners(this.rectangleStartPosition, currentPosition);
1063 this.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001064 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001065 }
1066
James Kuszmaula8f2c452020-07-05 21:17:56 -07001067 handleMouseMove(event: MouseEvent) {
1068 const mouseLocation = this.mouseCanvasLocation(event);
1069 if (buttonPressed(event, PAN_BUTTON) &&
1070 (this.lastMousePanPosition !== null)) {
1071 const mouseDiff =
1072 addVec(mouseLocation, scaleVec(this.lastMousePanPosition, -1));
1073 this.setZoom(
1074 this.drawer.getZoom().scale,
1075 addVec(this.drawer.getZoom().offset, mouseDiff));
1076 this.lastMousePanPosition = mouseLocation;
1077 }
James Kuszmaul461b0682020-12-22 22:20:21 -08001078 if (this.rectangleStartPosition !== null) {
1079 if (buttonPressed(event, RECTANGLE_BUTTON)) {
1080 // p0 and p1 are the two corners of the rectangle to draw.
1081 const p0 = [...this.rectangleStartPosition];
1082 const p1 = [...this.mousePlotLocation(event)];
1083 const minVisible = this.drawer.minVisiblePoint();
1084 const maxVisible = this.drawer.maxVisiblePoint();
1085 // Modify the rectangle corners to display correctly if we are limiting
1086 // the zoom to the x/y axis.
1087 const x_pressed = Plot.keysPressed['x'];
1088 const y_pressed = Plot.keysPressed["y"];
1089 if (x_pressed && !y_pressed) {
1090 p0[1] = minVisible[1];
1091 p1[1] = maxVisible[1];
1092 } else if (!x_pressed && y_pressed) {
1093 p0[0] = minVisible[0];
1094 p1[0] = maxVisible[0];
1095 }
James Kuszmaul0d7df892021-04-09 22:19:49 -07001096 this.zoomRectangle.setPoints([
1097 new Point(p0[0], p0[1]), new Point(p0[0], p1[1]),
1098 new Point(p1[0], p1[1]), new Point(p1[0], p0[1]),
1099 new Point(p0[0], p0[1])
1100 ]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001101 } else {
1102 this.finishRectangleZoom(event);
1103 }
1104 } else {
James Kuszmaul0d7df892021-04-09 22:19:49 -07001105 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001106 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001107 this.lastMousePosition = mouseLocation;
1108 }
1109
1110 setZoom(scale: number[], offset: number[]) {
James Kuszmaul71a81932020-12-15 21:08:01 -08001111 if (!isFinite(scale[0]) || !isFinite(scale[1])) {
1112 throw new Error("Doesn't support non-finite scales due to singularities.");
1113 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001114 const x_pressed = Plot.keysPressed["x"];
1115 const y_pressed = Plot.keysPressed["y"];
1116 const zoom = this.drawer.getZoom();
1117 if (x_pressed && !y_pressed) {
1118 zoom.scale[0] = scale[0];
1119 zoom.offset[0] = offset[0];
1120 } else if (y_pressed && !x_pressed) {
1121 zoom.scale[1] = scale[1];
1122 zoom.offset[1] = offset[1];
1123 } else {
1124 zoom.scale = scale;
1125 zoom.offset = offset;
1126 }
1127
1128 for (let plot of this.linkedXAxes) {
1129 const otherZoom = plot.drawer.getZoom();
1130 otherZoom.scale[0] = zoom.scale[0];
1131 otherZoom.offset[0] = zoom.offset[0];
1132 plot.drawer.setZoom(otherZoom);
1133 plot.autoFollow = false;
1134 }
1135 this.drawer.setZoom(zoom);
1136 this.autoFollow = false;
1137 }
1138
1139
1140 setZoomCorners(c1: number[], c2: number[]) {
1141 const scale = cwiseOp(c1, c2, (a, b) => {
1142 return 2.0 / Math.abs(a - b);
1143 });
1144 const offset = cwiseOp(scale, cwiseOp(c1, c2, Math.max), (a, b) => {
1145 return 1.0 - a * b;
1146 });
1147 this.setZoom(scale, offset);
1148 }
1149
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001150 setDefaultYRange(range: number[]|null) {
1151 if (range == null) {
1152 this.defaultYRange = null;
1153 return;
1154 }
1155 if (range.length != 2) {
1156 throw new Error('Range should contain exactly two values.');
1157 }
1158 this.defaultYRange = range;
1159 }
1160
James Kuszmaula8f2c452020-07-05 21:17:56 -07001161 resetZoom() {
James Kuszmaul71a81932020-12-15 21:08:01 -08001162 const minValues = this.drawer.minValues();
1163 const maxValues = this.drawer.maxValues();
Austin Schuh8b69cc22022-07-15 14:33:34 -07001164 const kScalar = 0.05;
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001165 for (const plot of this.linkedXAxes) {
1166 const otherMin = plot.drawer.minValues();
1167 const otherMax = plot.drawer.maxValues();
1168 // For linked x-axes, only adjust the x limits.
1169 minValues[0] = Math.min(minValues[0], otherMin[0]);
1170 maxValues[0] = Math.max(maxValues[0], otherMax[0]);
1171 }
1172 if (!isFinite(minValues[0]) || !isFinite(maxValues[0])) {
1173 minValues[0] = 0;
1174 maxValues[0] = 0;
1175 }
1176 if (!isFinite(minValues[1]) || !isFinite(maxValues[1])) {
1177 minValues[1] = 0;
1178 maxValues[1] = 0;
1179 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001180 if (minValues[0] == maxValues[0]) {
1181 minValues[0] -= 1;
1182 maxValues[0] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001183 } else {
1184 const width = maxValues[0] - minValues[0];
1185 maxValues[0] += width * kScalar;
1186 minValues[0] -= width * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001187 }
1188 if (minValues[1] == maxValues[1]) {
1189 minValues[1] -= 1;
1190 maxValues[1] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001191 } else {
1192 const height = maxValues[1] - minValues[1];
1193 maxValues[1] += height * kScalar;
1194 minValues[1] -= height * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001195 }
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001196 if (this.defaultYRange != null) {
1197 minValues[1] = this.defaultYRange[0];
1198 maxValues[1] = this.defaultYRange[1];
1199 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001200 this.setZoomCorners(minValues, maxValues);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001201 this.autoFollow = true;
1202 for (let plot of this.linkedXAxes) {
1203 plot.autoFollow = true;
1204 }
1205 }
1206
James Kuszmaul461b0682020-12-22 22:20:21 -08001207 static handleKeyUp(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001208 Plot.keysPressed[event.key] = false;
1209 }
1210
James Kuszmaul461b0682020-12-22 22:20:21 -08001211 static handleKeyDown(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001212 Plot.keysPressed[event.key] = true;
James Kuszmaul461b0682020-12-22 22:20:21 -08001213 for (const plot of this.allPlots) {
1214 if (Plot.keysPressed['Escape']) {
1215 // Cancel zoom/pan operations on escape.
1216 plot.lastMousePanPosition = null;
1217 plot.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001218 plot.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001219 }
1220 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001221 }
1222
1223 draw() {
1224 window.requestAnimationFrame(() => this.draw());
James Kuszmaul71a81932020-12-15 21:08:01 -08001225 const curTime = (new Date()).getTime();
1226 const frameRate = 1000.0 / (curTime - this.lastTimeMs);
1227 this.lastTimeMs = curTime;
Austin Schuhc2e9c502021-11-25 21:23:24 -08001228 const parentWidth = this.textCanvas.parentElement.offsetWidth;
1229 const parentHeight = this.textCanvas.parentElement.offsetHeight;
1230 this.textCanvas.width = parentWidth;
1231 this.textCanvas.height = parentHeight;
1232 this.canvas.width =
1233 parentWidth - this.axisLabelBuffer.left - this.axisLabelBuffer.right;
1234 this.canvas.height =
1235 parentHeight - this.axisLabelBuffer.top - this.axisLabelBuffer.bottom;
1236 this.lineDrawerContext.viewport(
1237 0, 0, this.lineDrawerContext.drawingBufferWidth,
1238 this.lineDrawerContext.drawingBufferHeight);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001239
1240 // Clear the overlay.
1241 const textCtx = this.textCanvas.getContext("2d");
1242 textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
1243
1244 this.axisLabels.draw();
1245 this.axisLabels.drawMousePosition(this.lastMousePosition);
1246 this.legend.draw();
1247
1248 this.drawer.draw();
1249
1250 if (this.autoFollow) {
1251 this.resetZoom();
1252 }
1253 }
1254
1255 getDrawer(): LineDrawer {
1256 return this.drawer;
1257 }
1258
1259 getLegend(): Legend {
1260 return this.legend;
1261 }
1262
1263 getAxisLabels(): AxisLabels {
1264 return this.axisLabels;
1265 }
1266
1267 // Links this plot's x-axis with that of another Plot (e.g., to share time
1268 // axes).
1269 linkXAxis(other: Plot) {
1270 this.linkedXAxes.push(other);
1271 other.linkedXAxes.push(this);
1272 }
1273}