blob: f9271bb117df81fc27e54324be713d4bc2a27c4b [file] [log] [blame]
James Kuszmaul933a9742021-03-07 19:59:06 -08001import * as Colors from 'org_frc971/aos/network/www/colors';
James Kuszmaula8f2c452020-07-05 21:17:56 -07002// Multiplies all the values in the provided array by scale.
3function scaleVec(vec: number[], scale: number): number[] {
4 const scaled: number[] = [];
5 for (let num of vec) {
6 scaled.push(num * scale);
7 }
8 return scaled;
9}
10
11// Runs the operation op() over every pair of numbers in a, b and returns
12// the result.
13function cwiseOp(
14 a: number[], b: number[], op: (a: number, b: number) => number): number[] {
15 if (a.length !== b.length) {
16 throw new Error("a and b must be of equal length.");
17 }
18 const min: number[] = [];
19 for (let ii = 0; ii < a.length; ++ii) {
20 min.push(op(a[ii], b[ii]));
21 }
22 return min;
23}
24
25// Adds vectors a and b.
26function addVec(a: number[], b: number[]): number[] {
27 return cwiseOp(a, b, (p, q) => {
28 return p + q;
29 });
30}
31
James Kuszmaul0d7df892021-04-09 22:19:49 -070032function subtractVec(a: number[], b: number[]): number[] {
33 return cwiseOp(a, b, (p, q) => {
34 return p - q;
35 });
36}
37
38function multVec(a: number[], b: number[]): number[] {
39 return cwiseOp(a, b, (p, q) => {
40 return p * q;
41 });
42}
43
44function divideVec(a: number[], b: number[]): number[] {
45 return cwiseOp(a, b, (p, q) => {
46 return p / q;
47 });
48}
49
50// Parameters used when scaling the lines to the canvas.
51// If a point in a line is at pos then its position in the canvas space will be
52// scale * pos + offset.
53class ZoomParameters {
54 public scale: number[] = [1.0, 1.0];
55 public offset: number[] = [0.0, 0.0];
56 copy():ZoomParameters {
57 const copy = new ZoomParameters();
58 copy.scale = [this.scale[0], this.scale[1]];
59 copy.offset = [this.offset[0], this.offset[1]];
60 return copy;
61 }
62}
63
64export class Point {
65 constructor(
66 public x: number = 0.0,
67 public y: number = 0.0) {}
68}
69
James Kuszmaula8f2c452020-07-05 21:17:56 -070070// Represents a single line within a plot. Handles rendering the line with
71// all of its points and the appropriate color/markers/lines.
72export class Line {
James Kuszmaul0d7df892021-04-09 22:19:49 -070073 // Notes on zoom/precision management:
74 // The adjustedPoints field is the buffert of points (formatted [x0, y0, x1,
75 // y1, ..., xn, yn]) that will be read directly by WebGL and operated on in
76 // the vertex shader. However, WebGL provides relatively minimal guarantess
77 // about the floating point precision available in the shaders (to the point
78 // where even Float32 precision is not guaranteed). As such, we
79 // separately maintain the points vector using javascript number's
80 // (arbitrary-precision ints or double-precision floats). We then periodically
81 // set the baseZoom to be equal to the current desired zoom, calculate the
82 // scaled values directly in typescript, store them in adjustedPoints, and
83 // then just pass an identity transformation to WebGL for the zoom parameters.
84 // When actively zooming, we then just use WebGL to compensate for the offset
85 // between the baseZoom and the desired zoom, taking advantage of WebGL's
86 // performance to handle the high-rate updates but then falling back to
87 // typescript periodically to reset the offsets to avoid precision issues.
88 //
89 // As a practical matter, I've found that even if we were to recalculate
90 // the zoom in typescript on every iteration, the penalty is relatively
91 // minor--we still perform far better than using a non-WebGL canvas. This
92 // suggests that the bulk of the performance advantage from using WebGL for
93 // this use-case lies not in doing the zoom updates in the shaders, but rather
94 // in relying on WebGL to figure out how to drawin the lines/points that we
95 // specify.
96 private adjustedPoints: Float32Array = new Float32Array([]);
97 private points: Point[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -070098 private _drawLine: boolean = true;
99 private _pointSize: number = 3.0;
100 private _hasUpdate: boolean = false;
James Kuszmaul71a81932020-12-15 21:08:01 -0800101 private _minValues: number[] = [Infinity, Infinity];
102 private _maxValues: number[] = [-Infinity, -Infinity];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700103 private _color: number[] = [1.0, 0.0, 0.0];
104 private pointAttribLocation: number;
Philipp Schradere625ba22020-11-16 20:11:37 -0800105 private colorLocation: WebGLUniformLocation | null;
106 private pointSizeLocation: WebGLUniformLocation | null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800107 private _label: string|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700108 constructor(
109 private readonly ctx: WebGLRenderingContext,
110 private readonly program: WebGLProgram,
James Kuszmaul0d7df892021-04-09 22:19:49 -0700111 private readonly buffer: WebGLBuffer, private baseZoom: ZoomParameters) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700112 this.pointAttribLocation = this.ctx.getAttribLocation(this.program, 'apos');
113 this.colorLocation = this.ctx.getUniformLocation(this.program, 'color');
114 this.pointSizeLocation =
115 this.ctx.getUniformLocation(this.program, 'point_size');
116 }
117
118 // Return the largest x and y values present in the list of points.
119 maxValues(): number[] {
120 return this._maxValues;
121 }
122
123 // Return the smallest x and y values present in the list of points.
124 minValues(): number[] {
125 return this._minValues;
126 }
127
128 // Whether any parameters have changed that would require re-rending the line.
129 hasUpdate(): boolean {
130 return this._hasUpdate;
131 }
132
133 // Get/set the color of the line, returned as an RGB tuple.
134 color(): number[] {
135 return this._color;
136 }
137
Austin Schuh7d63eab2021-03-06 20:15:02 -0800138 setColor(newColor: number[]): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700139 this._color = newColor;
140 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -0800141 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700142 }
143
144 // Get/set the size of the markers to draw, in pixels (zero means no markers).
145 pointSize(): number {
146 return this._pointSize;
147 }
148
Austin Schuh7d63eab2021-03-06 20:15:02 -0800149 setPointSize(size: number): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700150 this._pointSize = size;
151 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -0800152 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700153 }
154
155 // Get/set whether we draw a line between the points (i.e., setting this to
156 // false would effectively create a scatter-plot). If drawLine is false and
157 // pointSize is zero, then no data is rendered.
158 drawLine(): boolean {
159 return this._drawLine;
160 }
161
James Kuszmauld7d98e82021-03-07 20:17:54 -0800162 setDrawLine(newDrawLine: boolean): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700163 this._drawLine = newDrawLine;
164 this._hasUpdate = true;
James Kuszmauld7d98e82021-03-07 20:17:54 -0800165 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700166 }
167
168 // Set the points to render. The points in the line are ordered and should
169 // be of the format:
170 // [x1, y1, x2, y2, x3, y3, ...., xN, yN]
James Kuszmaul0d7df892021-04-09 22:19:49 -0700171 setPoints(points: Point[]) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700172 this.points = points;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700173 this.adjustedPoints = new Float32Array(points.length * 2);
174 this.updateBaseZoom(this.baseZoom);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700175 this._hasUpdate = true;
176 this._minValues[0] = Infinity;
177 this._minValues[1] = Infinity;
178 this._maxValues[0] = -Infinity;
179 this._maxValues[1] = -Infinity;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700180 for (let ii = 0; ii < this.points.length; ++ii) {
181 const x = this.points[ii].x;
182 const y = this.points[ii].y;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700183
James Kuszmaul71a81932020-12-15 21:08:01 -0800184 if (isNaN(x) || isNaN(y)) {
185 continue;
186 }
187
James Kuszmaula8f2c452020-07-05 21:17:56 -0700188 this._minValues = cwiseOp(this._minValues, [x, y], Math.min);
189 this._maxValues = cwiseOp(this._maxValues, [x, y], Math.max);
190 }
191 }
192
James Kuszmaul0d7df892021-04-09 22:19:49 -0700193 getPoints(): Point[] {
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800194 return this.points;
195 }
196
James Kuszmaula8f2c452020-07-05 21:17:56 -0700197 // Get/set the label to use for the line when drawing the legend.
James Kuszmauld7d98e82021-03-07 20:17:54 -0800198 setLabel(label: string): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700199 this._label = label;
James Kuszmauld7d98e82021-03-07 20:17:54 -0800200 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700201 }
202
James Kuszmaul461b0682020-12-22 22:20:21 -0800203 label(): string|null {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700204 return this._label;
205 }
206
James Kuszmaul0d7df892021-04-09 22:19:49 -0700207 updateBaseZoom(zoom: ZoomParameters) {
208 this.baseZoom = zoom;
209 for (let ii = 0; ii < this.points.length; ++ii) {
210 const point = this.points[ii];
211 this.adjustedPoints[ii * 2] = point.x * zoom.scale[0] + zoom.offset[0];
212 this.adjustedPoints[ii * 2 + 1] = point.y * zoom.scale[1] + zoom.offset[1];
213 }
214 }
215
James Kuszmaula8f2c452020-07-05 21:17:56 -0700216 // Render the line on the canvas.
217 draw() {
218 this._hasUpdate = false;
219 if (this.points.length === 0) {
220 return;
221 }
222
223 this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.buffer);
224 // Note: if this is generating errors associated with the buffer size,
225 // confirm that this.points really is a Float32Array.
226 this.ctx.bufferData(
227 this.ctx.ARRAY_BUFFER,
James Kuszmaul0d7df892021-04-09 22:19:49 -0700228 this.adjustedPoints,
James Kuszmaula8f2c452020-07-05 21:17:56 -0700229 this.ctx.STATIC_DRAW);
230 {
231 const numComponents = 2; // pull out 2 values per iteration
232 const numType = this.ctx.FLOAT; // the data in the buffer is 32bit floats
233 const normalize = false; // don't normalize
234 const stride = 0; // how many bytes to get from one set of values to the
235 // next 0 = use type and numComponents above
236 const offset = 0; // how many bytes inside the buffer to start from
237 this.ctx.vertexAttribPointer(
238 this.pointAttribLocation, numComponents, numType,
239 normalize, stride, offset);
240 this.ctx.enableVertexAttribArray(this.pointAttribLocation);
241 }
242
243 this.ctx.uniform1f(this.pointSizeLocation, this._pointSize);
244 this.ctx.uniform4f(
245 this.colorLocation, this._color[0], this._color[1], this._color[2],
246 1.0);
247
248 if (this._drawLine) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700249 this.ctx.drawArrays(this.ctx.LINE_STRIP, 0, this.points.length);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700250 }
251 if (this._pointSize > 0.0) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700252 this.ctx.drawArrays(this.ctx.POINTS, 0, this.points.length);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700253 }
254 }
255}
256
James Kuszmaula8f2c452020-07-05 21:17:56 -0700257enum MouseButton {
258 Right,
259 Middle,
260 Left
261}
262
263// The button to use for panning the plot.
264const PAN_BUTTON = MouseButton.Left;
James Kuszmaul461b0682020-12-22 22:20:21 -0800265const RECTANGLE_BUTTON = MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700266
267// Returns the mouse button that generated a given event.
268function transitionButton(event: MouseEvent): MouseButton {
269 switch (event.button) {
270 case 0:
271 return MouseButton.Left;
272 case 1:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700273 return MouseButton.Middle;
James Kuszmaulbce45332020-12-15 19:50:01 -0800274 case 2:
275 return MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700276 }
277}
278
279// Returns whether the given button is pressed on the mouse.
280function buttonPressed(event: MouseEvent, button: MouseButton): boolean {
281 switch (button) {
282 // For some reason, the middle/right buttons are swapped relative to where
283 // we would expect them to be given the .button field.
284 case MouseButton.Left:
285 return 0 !== (event.buttons & 0x1);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700286 case MouseButton.Right:
James Kuszmaulbce45332020-12-15 19:50:01 -0800287 return 0 !== (event.buttons & 0x2);
288 case MouseButton.Middle:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700289 return 0 !== (event.buttons & 0x4);
290 }
291}
292
293// Handles rendering a Legend for a list of lines.
294// This takes a 2d canvas, which is what we use for rendering all the text of
295// the plot and is separate, but overlayed on top of, the WebGL canvas that the
296// lines are drawn on.
297export class Legend {
298 // Location, in pixels, of the legend in the text canvas.
299 private location: number[] = [0, 0];
Austin Schuhfcd56942022-07-18 17:41:32 -0700300 constructor(private lines: Line[], private legend: HTMLDivElement) {
301 this.setPosition([80, 30]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700302 }
303
304 setPosition(location: number[]): void {
305 this.location = location;
Austin Schuhfcd56942022-07-18 17:41:32 -0700306 this.legend.style.left = location[0] + 'px';
307 this.legend.style.top = location[1] + 'px';
James Kuszmaula8f2c452020-07-05 21:17:56 -0700308 }
309
310 draw(): void {
Austin Schuhfcd56942022-07-18 17:41:32 -0700311 // First, figure out if anything has changed. The legend is created and
312 // then titles are changed afterwords, so we have to do this lazily.
313 let needsUpdate = false;
314 {
315 let child = 0;
316 for (let line of this.lines) {
317 if (line.label() === null) {
318 continue;
319 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700320
Austin Schuhfcd56942022-07-18 17:41:32 -0700321 if (child >= this.legend.children.length) {
322 needsUpdate = true;
323 break;
324 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700325
Austin Schuhfcd56942022-07-18 17:41:32 -0700326 // Make sure both have text in the right spot. Don't be too picky since
327 // nothing should really be changing here, and it's handy to let the
328 // user edit the HTML for testing.
329 if (this.legend.children[child].lastChild.textContent.length == 0 &&
330 line.label().length != 0) {
331 needsUpdate = true;
332 break;
333 }
334 child += 1;
James Kuszmaul461b0682020-12-22 22:20:21 -0800335 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700336
Austin Schuhfcd56942022-07-18 17:41:32 -0700337 // If we got through everything, we should be pointed past the last child.
338 // If not, more children exists than lines.
339 if (child != this.legend.children.length) {
340 needsUpdate = true;
341 }
342 }
343 if (!needsUpdate) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800344 return;
345 }
346
Austin Schuhfcd56942022-07-18 17:41:32 -0700347 // Nuke the old legend.
348 while (this.legend.firstChild) {
349 this.legend.removeChild(this.legend.firstChild);
350 }
James Kuszmaul461b0682020-12-22 22:20:21 -0800351
Austin Schuhfcd56942022-07-18 17:41:32 -0700352 // Now, build up a new legend.
James Kuszmaula8f2c452020-07-05 21:17:56 -0700353 for (let line of this.lines) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800354 if (line.label() === null) {
355 continue;
356 }
Austin Schuhfcd56942022-07-18 17:41:32 -0700357
358 // The legend is a div containing both a canvas for the style/color, and a
359 // div for the text. Make those, color in the canvas, and add it to the
360 // page.
361 let l = document.createElement('div');
362 l.classList.add('aos_legend_line');
363 let text = document.createElement('div');
364 text.textContent = line.label();
365
366 l.appendChild(text);
367 this.legend.appendChild(l);
368
369 let c = document.createElement('canvas');
370 c.width = text.offsetHeight;
371 c.height = text.offsetHeight;
372
373 const linestyleContext = c.getContext("2d");
374 linestyleContext.clearRect(0, 0, c.width, c.height);
375
James Kuszmaula8f2c452020-07-05 21:17:56 -0700376 const color = line.color();
Austin Schuhfcd56942022-07-18 17:41:32 -0700377 linestyleContext.strokeStyle = `rgb(${255.0 * color[0]}, ${
378 255.0 * color[1]}, ${255.0 * color[2]})`;
379 linestyleContext.fillStyle = linestyleContext.strokeStyle;
380
James Kuszmaula8f2c452020-07-05 21:17:56 -0700381 const pointSize = line.pointSize();
Austin Schuhfcd56942022-07-18 17:41:32 -0700382 const kDistanceIn = pointSize / 2.0;
383
384 if (line.drawLine()) {
385 linestyleContext.beginPath();
386 linestyleContext.moveTo(0, 0);
387 linestyleContext.lineTo(c.height, c.width);
388 linestyleContext.closePath();
389 linestyleContext.stroke();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700390 }
391
Austin Schuhfcd56942022-07-18 17:41:32 -0700392 if (pointSize > 0) {
393 linestyleContext.fillRect(0, 0, pointSize, pointSize);
394 linestyleContext.fillRect(
395 c.height - 1 - pointSize, c.width - 1 - pointSize, pointSize,
396 pointSize);
397 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700398
Austin Schuhfcd56942022-07-18 17:41:32 -0700399 l.prepend(c);
400 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700401 }
402}
403
404// This class manages all the WebGL rendering--namely, drawing the reference
405// grid for the user and then rendering all the actual lines of the plot.
406export class LineDrawer {
407 private program: WebGLProgram|null = null;
408 private scaleLocation: WebGLUniformLocation;
409 private offsetLocation: WebGLUniformLocation;
410 private vertexBuffer: WebGLBuffer;
411 private lines: Line[] = [];
412 private zoom: ZoomParameters = new ZoomParameters();
James Kuszmaul0d7df892021-04-09 22:19:49 -0700413 private baseZoom: ZoomParameters = new ZoomParameters();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700414 private zoomUpdated: boolean = true;
415 // Maximum grid lines to render at once--this is used provide an upper limit
416 // on the number of Line objects we need to create in order to render the
417 // grid.
418 public readonly MAX_GRID_LINES: number = 5;
419 // Arrays of the points at which we will draw grid lines for the x/y axes.
420 private xTicks: number[] = [];
421 private yTicks: number[] = [];
422 private xGridLines: Line[] = [];
423 private yGridLines: Line[] = [];
424
James Kuszmaul933a9742021-03-07 19:59:06 -0800425 public static readonly COLOR_CYCLE = [
426 Colors.RED, Colors.GREEN, Colors.BLUE, Colors.BROWN, Colors.PINK,
Austin Schuhc2e9c502021-11-25 21:23:24 -0800427 Colors.CYAN, Colors.WHITE, Colors.ORANGE, Colors.YELLOW
James Kuszmaul933a9742021-03-07 19:59:06 -0800428 ];
429 private colorCycleIndex = 0;
430
James Kuszmaula8f2c452020-07-05 21:17:56 -0700431 constructor(public readonly ctx: WebGLRenderingContext) {
432 this.program = this.compileShaders();
433 this.scaleLocation = this.ctx.getUniformLocation(this.program, 'scale');
434 this.offsetLocation = this.ctx.getUniformLocation(this.program, 'offset');
435 this.vertexBuffer = this.ctx.createBuffer();
436
437 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700438 this.xGridLines.push(
439 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
440 this.yGridLines.push(
441 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700442 }
443 }
444
James Kuszmaula8f2c452020-07-05 21:17:56 -0700445 getZoom(): ZoomParameters {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700446 return this.zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700447 }
448
449 plotToCanvasCoordinates(plotPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700450 return addVec(multVec(plotPos, this.zoom.scale), this.zoom.offset);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700451 }
452
453
454 canvasToPlotCoordinates(canvasPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700455 return divideVec(subtractVec(canvasPos, this.zoom.offset), this.zoom.scale);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700456 }
457
Austin Schuhfcd56942022-07-18 17:41:32 -0700458 // These return the max/min rendered points, in plot-space (this is helpful
James Kuszmaula8f2c452020-07-05 21:17:56 -0700459 // for drawing axis labels).
460 maxVisiblePoint(): number[] {
461 return this.canvasToPlotCoordinates([1.0, 1.0]);
462 }
463
464 minVisiblePoint(): number[] {
465 return this.canvasToPlotCoordinates([-1.0, -1.0]);
466 }
467
468 getLines(): Line[] {
469 return this.lines;
470 }
471
472 setZoom(zoom: ZoomParameters) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700473 if (this.zoom.scale[0] == zoom.scale[0] &&
474 this.zoom.scale[1] == zoom.scale[1] &&
475 this.zoom.offset[0] == zoom.offset[0] &&
476 this.zoom.offset[1] == zoom.offset[1]) {
477 return;
478 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700479 this.zoomUpdated = true;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700480 this.zoom = zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700481 }
482
483 setXTicks(ticks: number[]): void {
484 this.xTicks = ticks;
485 }
486
487 setYTicks(ticks: number[]): void {
488 this.yTicks = ticks;
489 }
490
491 // Update the grid lines.
492 updateTicks() {
493 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700494 this.xGridLines[ii].setPoints([]);
495 this.yGridLines[ii].setPoints([]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700496 }
497
498 const minValues = this.minVisiblePoint();
499 const maxValues = this.maxVisiblePoint();
500
501 for (let ii = 0; ii < this.xTicks.length; ++ii) {
502 this.xGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700503 const points = [
504 new Point(this.xTicks[ii], minValues[1]),
505 new Point(this.xTicks[ii], maxValues[1])
506 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700507 this.xGridLines[ii].setPointSize(0);
508 this.xGridLines[ii].setPoints(points);
509 this.xGridLines[ii].draw();
510 }
511
512 for (let ii = 0; ii < this.yTicks.length; ++ii) {
513 this.yGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700514 const points = [
515 new Point(minValues[0], this.yTicks[ii]),
516 new Point(maxValues[0], this.yTicks[ii])
517 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700518 this.yGridLines[ii].setPointSize(0);
519 this.yGridLines[ii].setPoints(points);
520 this.yGridLines[ii].draw();
521 }
522 }
523
524 // Handles redrawing any of the WebGL objects, if necessary.
525 draw(): void {
526 let needsUpdate = this.zoomUpdated;
527 this.zoomUpdated = false;
528 for (let line of this.lines) {
529 if (line.hasUpdate()) {
530 needsUpdate = true;
531 break;
532 }
533 }
534 if (!needsUpdate) {
535 return;
536 }
537
538 this.reset();
539
540 this.updateTicks();
541
542 for (let line of this.lines) {
543 line.draw();
544 }
545
546 return;
547 }
548
549 loadShader(shaderType: number, source: string): WebGLShader {
550 const shader = this.ctx.createShader(shaderType);
551 this.ctx.shaderSource(shader, source);
552 this.ctx.compileShader(shader);
553 if (!this.ctx.getShaderParameter(shader, this.ctx.COMPILE_STATUS)) {
554 alert(
555 'Got an error compiling a shader: ' +
556 this.ctx.getShaderInfoLog(shader));
557 this.ctx.deleteShader(shader);
558 return null;
559 }
560
561 return shader;
562 }
563
564 compileShaders(): WebGLProgram {
565 const vertexShader = 'attribute vec2 apos;' +
566 'uniform vec2 scale;' +
567 'uniform vec2 offset;' +
568 'uniform float point_size;' +
569 'void main() {' +
570 ' gl_Position.xy = apos.xy * scale.xy + offset.xy;' +
571 ' gl_Position.z = 0.0;' +
572 ' gl_Position.w = 1.0;' +
573 ' gl_PointSize = point_size;' +
574 '}';
575
576 const fragmentShader = 'precision highp float;' +
577 'uniform vec4 color;' +
578 'void main() {' +
579 ' gl_FragColor = color;' +
580 '}';
581
582 const compiledVertex =
583 this.loadShader(this.ctx.VERTEX_SHADER, vertexShader);
584 const compiledFragment =
585 this.loadShader(this.ctx.FRAGMENT_SHADER, fragmentShader);
586 const program = this.ctx.createProgram();
587 this.ctx.attachShader(program, compiledVertex);
588 this.ctx.attachShader(program, compiledFragment);
589 this.ctx.linkProgram(program);
590 if (!this.ctx.getProgramParameter(program, this.ctx.LINK_STATUS)) {
591 alert(
592 'Unable to link the shaders: ' + this.ctx.getProgramInfoLog(program));
593 return null;
594 }
595 return program;
596 }
597
James Kuszmaul933a9742021-03-07 19:59:06 -0800598 addLine(useColorCycle: boolean = true): Line {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700599 this.lines.push(
600 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaul933a9742021-03-07 19:59:06 -0800601 const line = this.lines[this.lines.length - 1];
602 if (useColorCycle) {
603 line.setColor(LineDrawer.COLOR_CYCLE[this.colorCycleIndex++]);
604 }
605 return line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700606 }
607
608 minValues(): number[] {
609 let minValues = [Infinity, Infinity];
610 for (let line of this.lines) {
611 minValues = cwiseOp(minValues, line.minValues(), Math.min);
612 }
613 return minValues;
614 }
615
616 maxValues(): number[] {
617 let maxValues = [-Infinity, -Infinity];
618 for (let line of this.lines) {
619 maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
620 }
621 return maxValues;
622 }
623
624 reset(): void {
625 // Set the background color
626 this.ctx.clearColor(0.5, 0.5, 0.5, 1.0);
627 this.ctx.clearDepth(1.0);
628 this.ctx.enable(this.ctx.DEPTH_TEST);
629 this.ctx.depthFunc(this.ctx.LEQUAL);
630 this.ctx.clear(this.ctx.COLOR_BUFFER_BIT | this.ctx.DEPTH_BUFFER_BIT);
631
632 this.ctx.useProgram(this.program);
633
James Kuszmaul0d7df892021-04-09 22:19:49 -0700634 // Check for whether the zoom parameters have changed significantly; if so,
635 // update the base zoom.
636 // These thresholds are somewhat arbitrary.
637 const scaleDiff = divideVec(this.zoom.scale, this.baseZoom.scale);
638 const scaleChanged = scaleDiff[0] < 0.9 || scaleDiff[0] > 1.1 ||
639 scaleDiff[1] < 0.9 || scaleDiff[1] > 1.1;
640 const offsetDiff = subtractVec(this.zoom.offset, this.baseZoom.offset);
641 // Note that offset is in the canvas coordinate frame and so just using
642 // hard-coded constants is fine.
643 const offsetChanged =
644 Math.abs(offsetDiff[0]) > 0.1 || Math.abs(offsetDiff[1]) > 0.1;
645 if (scaleChanged || offsetChanged) {
646 this.baseZoom = this.zoom.copy();
647 for (const line of this.lines) {
648 line.updateBaseZoom(this.baseZoom);
649 }
650 for (const line of this.xGridLines) {
651 line.updateBaseZoom(this.baseZoom);
652 }
653 for (const line of this.yGridLines) {
654 line.updateBaseZoom(this.baseZoom);
655 }
656 }
657
658 // all the points in the lines will be pre-scaled by this.baseZoom, so
659 // we need to remove its effects before passing it in.
660 // zoom.scale * pos + zoom.offset = scale * (baseZoom.scale * pos + baseZoom.offset) + offset
661 // zoom.scale = scale * baseZoom.scale
662 // scale = zoom.scale / baseZoom.scale
663 // zoom.offset = scale * baseZoom.offset + offset
664 // offset = zoom.offset - scale * baseZoom.offset
665 const scale = divideVec(this.zoom.scale, this.baseZoom.scale);
666 const offset =
667 subtractVec(this.zoom.offset, multVec(scale, this.baseZoom.offset));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700668 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700669 this.scaleLocation, scale[0], scale[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700670 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700671 this.offsetLocation, offset[0], offset[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700672 }
673}
674
675// Class to store how much whitespace we put between the edges of the WebGL
676// canvas (where we draw all the lines) and the edge of the plot. This gives
677// us space to, e.g., draw axis labels, the plot title, etc.
678class WhitespaceBuffers {
679 constructor(
680 public left: number, public right: number, public top: number,
681 public bottom: number) {}
682}
683
684// Class to manage all the annotations associated with the plot--the axis/tick
685// labels and the plot title.
686class AxisLabels {
687 private readonly INCREMENTS: number[] = [2, 4, 5, 10];
688 // Space to leave to create some visual space around the text.
689 private readonly TEXT_BUFFER: number = 5;
690 private title: string = "";
691 private xlabel: string = "";
692 private ylabel: string = "";
693 constructor(
694 private ctx: CanvasRenderingContext2D, private drawer: LineDrawer,
695 private graphBuffers: WhitespaceBuffers) {}
696
697 numberToLabel(num: number): string {
698 return num.toPrecision(5);
699 }
700
701 textWidth(str: string): number {
702 return this.ctx.measureText(str).actualBoundingBoxRight;
703 }
704
705 textHeight(str: string): number {
706 return this.ctx.measureText(str).actualBoundingBoxAscent;
707 }
708
709 textDepth(str: string): number {
710 return this.ctx.measureText(str).actualBoundingBoxDescent;
711 }
712
713 setTitle(title: string) {
714 this.title = title;
715 }
716
717 setXLabel(xlabel: string) {
718 this.xlabel = xlabel;
719 }
720
721 setYLabel(ylabel: string) {
722 this.ylabel = ylabel;
723 }
724
725 getIncrement(range: number[]): number {
726 const diff = Math.abs(range[1] - range[0]);
727 const minDiff = diff / this.drawer.MAX_GRID_LINES;
728 const incrementsRatio = this.INCREMENTS[this.INCREMENTS.length - 1];
729 const order = Math.pow(
730 incrementsRatio,
731 Math.floor(Math.log(minDiff) / Math.log(incrementsRatio)));
732 const normalizedDiff = minDiff / order;
733 for (let increment of this.INCREMENTS) {
734 if (increment > normalizedDiff) {
735 return increment * order;
736 }
737 }
738 return 1.0;
739 }
740
741 getTicks(range: number[]): number[] {
742 const increment = this.getIncrement(range);
743 const start = Math.ceil(range[0] / increment) * increment;
744 const values = [start];
745 for (let ii = 0; ii < this.drawer.MAX_GRID_LINES - 1; ++ii) {
746 const nextValue = values[ii] + increment;
747 if (nextValue > range[1]) {
748 break;
749 }
750 values.push(nextValue);
751 }
752 return values;
753 }
754
755 plotToCanvasCoordinates(plotPos: number[]): number[] {
756 const webglCoord = this.drawer.plotToCanvasCoordinates(plotPos);
757 const webglX = (webglCoord[0] + 1.0) / 2.0 * this.drawer.ctx.canvas.width;
758 const webglY = (1.0 - webglCoord[1]) / 2.0 * this.drawer.ctx.canvas.height;
759 return [webglX + this.graphBuffers.left, webglY + this.graphBuffers.top];
760 }
761
762 drawXTick(x: number) {
763 const text = this.numberToLabel(x);
764 const height = this.textHeight(text);
765 const xpos = this.plotToCanvasCoordinates([x, 0])[0];
766 this.ctx.textAlign = "center";
767 this.ctx.fillText(
768 text, xpos,
769 this.ctx.canvas.height - this.graphBuffers.bottom + height +
770 this.TEXT_BUFFER);
771 }
772
773 drawYTick(y: number) {
774 const text = this.numberToLabel(y);
775 const height = this.textHeight(text);
776 const ypos = this.plotToCanvasCoordinates([0, y])[1];
777 this.ctx.textAlign = "right";
778 this.ctx.fillText(
779 text, this.graphBuffers.left - this.TEXT_BUFFER,
780 ypos + height / 2.0);
781 }
782
783 drawTitle() {
784 if (this.title) {
785 this.ctx.textAlign = 'center';
786 this.ctx.fillText(
787 this.title, this.ctx.canvas.width / 2.0,
788 this.graphBuffers.top - this.TEXT_BUFFER);
789 }
790 }
791
792 drawXLabel() {
793 if (this.xlabel) {
794 this.ctx.textAlign = 'center';
795 this.ctx.fillText(
796 this.xlabel, this.ctx.canvas.width / 2.0,
797 this.ctx.canvas.height - this.TEXT_BUFFER);
798 }
799 }
800
801 drawYLabel() {
802 this.ctx.save();
803 if (this.ylabel) {
804 this.ctx.textAlign = 'center';
805 const height = this.textHeight(this.ylabel);
806 this.ctx.translate(
807 height + this.TEXT_BUFFER, this.ctx.canvas.height / 2.0);
808 this.ctx.rotate(-Math.PI / 2.0);
809 this.ctx.fillText(this.ylabel, 0, 0);
810 }
811 this.ctx.restore();
812 }
813
814 draw() {
815 this.ctx.fillStyle = 'black';
816 const minValues = this.drawer.minVisiblePoint();
817 const maxValues = this.drawer.maxVisiblePoint();
818 let text = this.numberToLabel(maxValues[1]);
819 this.drawYTick(maxValues[1]);
820 this.drawYTick(minValues[1]);
821 this.drawXTick(minValues[0]);
822 this.drawXTick(maxValues[0]);
823 this.ctx.strokeStyle = 'black';
824 this.ctx.strokeRect(
825 this.graphBuffers.left, this.graphBuffers.top,
826 this.drawer.ctx.canvas.width, this.drawer.ctx.canvas.height);
827 this.ctx.strokeRect(
828 0, 0,
829 this.ctx.canvas.width, this.ctx.canvas.height);
830 const xTicks = this.getTicks([minValues[0], maxValues[0]]);
831 this.drawer.setXTicks(xTicks);
832 const yTicks = this.getTicks([minValues[1], maxValues[1]]);
833 this.drawer.setYTicks(yTicks);
834
835 for (let x of xTicks) {
836 this.drawXTick(x);
837 }
838
839 for (let y of yTicks) {
840 this.drawYTick(y);
841 }
842
843 this.drawTitle();
844 this.drawXLabel();
845 this.drawYLabel();
846 }
847
848 // Draws the current mouse position in the bottom-right of the plot.
849 drawMousePosition(mousePos: number[]) {
850 const plotPos = this.drawer.canvasToPlotCoordinates(mousePos);
851
852 const text =
853 `(${plotPos[0].toPrecision(10)}, ${plotPos[1].toPrecision(10)})`;
854 const textDepth = this.textDepth(text);
855 this.ctx.textAlign = 'right';
856 this.ctx.fillText(
857 text, this.ctx.canvas.width - this.graphBuffers.right,
858 this.ctx.canvas.height - this.graphBuffers.bottom - textDepth);
859 }
860}
861
862// This class manages the entirety of a single plot. Most of the logic in
863// this class is around handling mouse/keyboard events for interacting with
864// the plot.
865export class Plot {
866 private canvas = document.createElement('canvas');
867 private textCanvas = document.createElement('canvas');
Austin Schuhfcd56942022-07-18 17:41:32 -0700868 private legendDiv = document.createElement('div');
Austin Schuhc2e9c502021-11-25 21:23:24 -0800869 private lineDrawerContext: WebGLRenderingContext;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700870 private drawer: LineDrawer;
James Kuszmaul461b0682020-12-22 22:20:21 -0800871 private static keysPressed:
872 object = {'x': false, 'y': false, 'Escape': false};
873 // List of all plots to use for propagating key-press events to.
874 private static allPlots: Plot[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700875 // In canvas coordinates (the +/-1 square).
James Kuszmaul461b0682020-12-22 22:20:21 -0800876 private lastMousePanPosition: number[]|null = null;
877 private rectangleStartPosition: number[]|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700878 private axisLabelBuffer: WhitespaceBuffers =
879 new WhitespaceBuffers(50, 20, 20, 30);
880 private axisLabels: AxisLabels;
881 private legend: Legend;
882 private lastMousePosition: number[] = [0.0, 0.0];
883 private autoFollow: boolean = true;
884 private linkedXAxes: Plot[] = [];
James Kuszmaul71a81932020-12-15 21:08:01 -0800885 private lastTimeMs: number = 0;
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800886 private defaultYRange: number[]|null = null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800887 private zoomRectangle: Line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700888
Austin Schuhc2e9c502021-11-25 21:23:24 -0800889 constructor(wrapperDiv: HTMLDivElement) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700890 wrapperDiv.appendChild(this.canvas);
891 wrapperDiv.appendChild(this.textCanvas);
Austin Schuhfcd56942022-07-18 17:41:32 -0700892 this.legendDiv.classList.add('aos_legend');
893 wrapperDiv.appendChild(this.legendDiv);
James Kuszmaul71a81932020-12-15 21:08:01 -0800894 this.lastTimeMs = (new Date()).getTime();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700895
Austin Schuhc2e9c502021-11-25 21:23:24 -0800896 this.canvas.style.paddingLeft = this.axisLabelBuffer.left.toString() + "px";
897 this.canvas.style.paddingRight = this.axisLabelBuffer.right.toString() + "px";
898 this.canvas.style.paddingTop = this.axisLabelBuffer.top.toString() + "px";
899 this.canvas.style.paddingBottom = this.axisLabelBuffer.bottom.toString() + "px";
Austin Schuhfcd56942022-07-18 17:41:32 -0700900 this.canvas.classList.add('aos_plot');
James Kuszmaula8f2c452020-07-05 21:17:56 -0700901
Austin Schuhc2e9c502021-11-25 21:23:24 -0800902 this.lineDrawerContext = this.canvas.getContext('webgl');
903 this.drawer = new LineDrawer(this.lineDrawerContext);
904
Austin Schuhfcd56942022-07-18 17:41:32 -0700905 this.textCanvas.classList.add('aos_plot_text');
James Kuszmaula8f2c452020-07-05 21:17:56 -0700906
907 this.canvas.addEventListener('dblclick', (e) => {
908 this.handleDoubleClick(e);
909 });
910 this.canvas.onwheel = (e) => {
911 this.handleWheel(e);
912 e.preventDefault();
913 };
914 this.canvas.onmousedown = (e) => {
915 this.handleMouseDown(e);
916 };
917 this.canvas.onmouseup = (e) => {
918 this.handleMouseUp(e);
919 };
920 this.canvas.onmousemove = (e) => {
921 this.handleMouseMove(e);
922 };
James Kuszmaul461b0682020-12-22 22:20:21 -0800923 this.canvas.addEventListener('contextmenu', event => event.preventDefault());
924 // Note: To handle the fact that only one keypress handle can be registered
925 // per browser tab, we share key-press handlers across all plot instances.
926 Plot.allPlots.push(this);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700927 document.onkeydown = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800928 Plot.handleKeyDown(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700929 };
930 document.onkeyup = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800931 Plot.handleKeyUp(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700932 };
933
934 const textCtx = this.textCanvas.getContext("2d");
935 this.axisLabels =
936 new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
Austin Schuhfcd56942022-07-18 17:41:32 -0700937 this.legend = new Legend(this.drawer.getLines(), this.legendDiv);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700938
James Kuszmaul933a9742021-03-07 19:59:06 -0800939 this.zoomRectangle = this.getDrawer().addLine(false);
940 this.zoomRectangle.setColor(Colors.WHITE);
James Kuszmaul461b0682020-12-22 22:20:21 -0800941 this.zoomRectangle.setPointSize(0);
942
James Kuszmaula8f2c452020-07-05 21:17:56 -0700943 this.draw();
944 }
945
946 handleDoubleClick(event: MouseEvent) {
947 this.resetZoom();
948 }
949
950 mouseCanvasLocation(event: MouseEvent): number[] {
Austin Schuhc2e9c502021-11-25 21:23:24 -0800951 const computedStyle = window.getComputedStyle(this.canvas);
952 const paddingLeftStr = computedStyle.getPropertyValue('padding-left');
953 const paddingTopStr = computedStyle.getPropertyValue('padding-top');
954 if (paddingLeftStr.substring(paddingLeftStr.length - 2) != "px") {
955 throw new Error("Left padding should be specified in pixels.");
956 }
957 if (paddingTopStr.substring(paddingTopStr.length - 2) != "px") {
958 throw new Error("Left padding should be specified in pixels.");
959 }
960 // Javascript will just ignore the extra "px".
961 const paddingLeft = Number.parseInt(paddingLeftStr);
962 const paddingTop = Number.parseInt(paddingTopStr);
963
James Kuszmaula8f2c452020-07-05 21:17:56 -0700964 return [
Austin Schuhc2e9c502021-11-25 21:23:24 -0800965 (event.offsetX - paddingLeft) * 2.0 / this.canvas.width - 1.0,
966 -(event.offsetY - paddingTop) * 2.0 / this.canvas.height + 1.0
James Kuszmaula8f2c452020-07-05 21:17:56 -0700967 ];
968 }
969
James Kuszmaul461b0682020-12-22 22:20:21 -0800970 mousePlotLocation(event: MouseEvent): number[] {
971 return this.drawer.canvasToPlotCoordinates(this.mouseCanvasLocation(event));
972 }
973
James Kuszmaula8f2c452020-07-05 21:17:56 -0700974 handleWheel(event: WheelEvent) {
975 if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
976 return;
977 }
978 const mousePosition = this.mouseCanvasLocation(event);
979 const kWheelTuningScalar = 1.5;
980 const zoom = -kWheelTuningScalar * event.deltaY / this.canvas.height;
981 let zoomScalar = 1.0 + Math.abs(zoom);
982 if (zoom < 0.0) {
983 zoomScalar = 1.0 / zoomScalar;
984 }
985 const scale = scaleVec(this.drawer.getZoom().scale, zoomScalar);
986 const offset = addVec(
987 scaleVec(mousePosition, 1.0 - zoomScalar),
988 scaleVec(this.drawer.getZoom().offset, zoomScalar));
989 this.setZoom(scale, offset);
990 }
991
992 handleMouseDown(event: MouseEvent) {
Austin Schuhd31a1612022-07-15 14:31:46 -0700993 for (let plot of this.linkedXAxes) {
994 plot.autoFollow = false;
995 }
996 this.autoFollow = false;
997
James Kuszmaul461b0682020-12-22 22:20:21 -0800998 const button = transitionButton(event);
999 switch (button) {
1000 case PAN_BUTTON:
1001 this.lastMousePanPosition = this.mouseCanvasLocation(event);
1002 break;
1003 case RECTANGLE_BUTTON:
1004 this.rectangleStartPosition = this.mousePlotLocation(event);
1005 break;
1006 default:
1007 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001008 }
1009 }
1010
1011 handleMouseUp(event: MouseEvent) {
James Kuszmaul461b0682020-12-22 22:20:21 -08001012 const button = transitionButton(event);
1013 switch (button) {
1014 case PAN_BUTTON:
1015 this.lastMousePanPosition = null;
1016 break;
1017 case RECTANGLE_BUTTON:
1018 if (this.rectangleStartPosition === null) {
1019 // We got a right-button release without ever seeing the mouse-down;
1020 // just return.
1021 return;
1022 }
1023 this.finishRectangleZoom(event);
1024 break;
1025 default:
1026 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001027 }
1028 }
1029
James Kuszmaul461b0682020-12-22 22:20:21 -08001030 private finishRectangleZoom(event: MouseEvent) {
1031 const currentPosition = this.mousePlotLocation(event);
1032 this.setZoomCorners(this.rectangleStartPosition, currentPosition);
1033 this.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001034 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001035 }
1036
James Kuszmaula8f2c452020-07-05 21:17:56 -07001037 handleMouseMove(event: MouseEvent) {
1038 const mouseLocation = this.mouseCanvasLocation(event);
1039 if (buttonPressed(event, PAN_BUTTON) &&
1040 (this.lastMousePanPosition !== null)) {
1041 const mouseDiff =
1042 addVec(mouseLocation, scaleVec(this.lastMousePanPosition, -1));
1043 this.setZoom(
1044 this.drawer.getZoom().scale,
1045 addVec(this.drawer.getZoom().offset, mouseDiff));
1046 this.lastMousePanPosition = mouseLocation;
1047 }
James Kuszmaul461b0682020-12-22 22:20:21 -08001048 if (this.rectangleStartPosition !== null) {
1049 if (buttonPressed(event, RECTANGLE_BUTTON)) {
1050 // p0 and p1 are the two corners of the rectangle to draw.
1051 const p0 = [...this.rectangleStartPosition];
1052 const p1 = [...this.mousePlotLocation(event)];
1053 const minVisible = this.drawer.minVisiblePoint();
1054 const maxVisible = this.drawer.maxVisiblePoint();
1055 // Modify the rectangle corners to display correctly if we are limiting
1056 // the zoom to the x/y axis.
1057 const x_pressed = Plot.keysPressed['x'];
1058 const y_pressed = Plot.keysPressed["y"];
1059 if (x_pressed && !y_pressed) {
1060 p0[1] = minVisible[1];
1061 p1[1] = maxVisible[1];
1062 } else if (!x_pressed && y_pressed) {
1063 p0[0] = minVisible[0];
1064 p1[0] = maxVisible[0];
1065 }
James Kuszmaul0d7df892021-04-09 22:19:49 -07001066 this.zoomRectangle.setPoints([
1067 new Point(p0[0], p0[1]), new Point(p0[0], p1[1]),
1068 new Point(p1[0], p1[1]), new Point(p1[0], p0[1]),
1069 new Point(p0[0], p0[1])
1070 ]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001071 } else {
1072 this.finishRectangleZoom(event);
1073 }
1074 } else {
James Kuszmaul0d7df892021-04-09 22:19:49 -07001075 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001076 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001077 this.lastMousePosition = mouseLocation;
1078 }
1079
1080 setZoom(scale: number[], offset: number[]) {
James Kuszmaul71a81932020-12-15 21:08:01 -08001081 if (!isFinite(scale[0]) || !isFinite(scale[1])) {
1082 throw new Error("Doesn't support non-finite scales due to singularities.");
1083 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001084 const x_pressed = Plot.keysPressed["x"];
1085 const y_pressed = Plot.keysPressed["y"];
1086 const zoom = this.drawer.getZoom();
1087 if (x_pressed && !y_pressed) {
1088 zoom.scale[0] = scale[0];
1089 zoom.offset[0] = offset[0];
1090 } else if (y_pressed && !x_pressed) {
1091 zoom.scale[1] = scale[1];
1092 zoom.offset[1] = offset[1];
1093 } else {
1094 zoom.scale = scale;
1095 zoom.offset = offset;
1096 }
1097
1098 for (let plot of this.linkedXAxes) {
1099 const otherZoom = plot.drawer.getZoom();
1100 otherZoom.scale[0] = zoom.scale[0];
1101 otherZoom.offset[0] = zoom.offset[0];
1102 plot.drawer.setZoom(otherZoom);
1103 plot.autoFollow = false;
1104 }
1105 this.drawer.setZoom(zoom);
1106 this.autoFollow = false;
1107 }
1108
1109
1110 setZoomCorners(c1: number[], c2: number[]) {
1111 const scale = cwiseOp(c1, c2, (a, b) => {
1112 return 2.0 / Math.abs(a - b);
1113 });
1114 const offset = cwiseOp(scale, cwiseOp(c1, c2, Math.max), (a, b) => {
1115 return 1.0 - a * b;
1116 });
1117 this.setZoom(scale, offset);
1118 }
1119
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001120 setDefaultYRange(range: number[]|null) {
1121 if (range == null) {
1122 this.defaultYRange = null;
1123 return;
1124 }
1125 if (range.length != 2) {
1126 throw new Error('Range should contain exactly two values.');
1127 }
1128 this.defaultYRange = range;
1129 }
1130
James Kuszmaula8f2c452020-07-05 21:17:56 -07001131 resetZoom() {
James Kuszmaul71a81932020-12-15 21:08:01 -08001132 const minValues = this.drawer.minValues();
1133 const maxValues = this.drawer.maxValues();
Austin Schuh8b69cc22022-07-15 14:33:34 -07001134 const kScalar = 0.05;
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001135 for (const plot of this.linkedXAxes) {
1136 const otherMin = plot.drawer.minValues();
1137 const otherMax = plot.drawer.maxValues();
1138 // For linked x-axes, only adjust the x limits.
1139 minValues[0] = Math.min(minValues[0], otherMin[0]);
1140 maxValues[0] = Math.max(maxValues[0], otherMax[0]);
1141 }
1142 if (!isFinite(minValues[0]) || !isFinite(maxValues[0])) {
1143 minValues[0] = 0;
1144 maxValues[0] = 0;
1145 }
1146 if (!isFinite(minValues[1]) || !isFinite(maxValues[1])) {
1147 minValues[1] = 0;
1148 maxValues[1] = 0;
1149 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001150 if (minValues[0] == maxValues[0]) {
1151 minValues[0] -= 1;
1152 maxValues[0] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001153 } else {
1154 const width = maxValues[0] - minValues[0];
1155 maxValues[0] += width * kScalar;
1156 minValues[0] -= width * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001157 }
1158 if (minValues[1] == maxValues[1]) {
1159 minValues[1] -= 1;
1160 maxValues[1] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001161 } else {
1162 const height = maxValues[1] - minValues[1];
1163 maxValues[1] += height * kScalar;
1164 minValues[1] -= height * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001165 }
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001166 if (this.defaultYRange != null) {
1167 minValues[1] = this.defaultYRange[0];
1168 maxValues[1] = this.defaultYRange[1];
1169 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001170 this.setZoomCorners(minValues, maxValues);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001171 this.autoFollow = true;
1172 for (let plot of this.linkedXAxes) {
1173 plot.autoFollow = true;
1174 }
1175 }
1176
James Kuszmaul461b0682020-12-22 22:20:21 -08001177 static handleKeyUp(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001178 Plot.keysPressed[event.key] = false;
1179 }
1180
James Kuszmaul461b0682020-12-22 22:20:21 -08001181 static handleKeyDown(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001182 Plot.keysPressed[event.key] = true;
James Kuszmaul461b0682020-12-22 22:20:21 -08001183 for (const plot of this.allPlots) {
1184 if (Plot.keysPressed['Escape']) {
1185 // Cancel zoom/pan operations on escape.
1186 plot.lastMousePanPosition = null;
1187 plot.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001188 plot.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001189 }
1190 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001191 }
1192
1193 draw() {
1194 window.requestAnimationFrame(() => this.draw());
James Kuszmaul71a81932020-12-15 21:08:01 -08001195 const curTime = (new Date()).getTime();
1196 const frameRate = 1000.0 / (curTime - this.lastTimeMs);
1197 this.lastTimeMs = curTime;
Austin Schuhc2e9c502021-11-25 21:23:24 -08001198 const parentWidth = this.textCanvas.parentElement.offsetWidth;
1199 const parentHeight = this.textCanvas.parentElement.offsetHeight;
1200 this.textCanvas.width = parentWidth;
1201 this.textCanvas.height = parentHeight;
1202 this.canvas.width =
1203 parentWidth - this.axisLabelBuffer.left - this.axisLabelBuffer.right;
1204 this.canvas.height =
1205 parentHeight - this.axisLabelBuffer.top - this.axisLabelBuffer.bottom;
1206 this.lineDrawerContext.viewport(
1207 0, 0, this.lineDrawerContext.drawingBufferWidth,
1208 this.lineDrawerContext.drawingBufferHeight);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001209
1210 // Clear the overlay.
1211 const textCtx = this.textCanvas.getContext("2d");
1212 textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
1213
1214 this.axisLabels.draw();
1215 this.axisLabels.drawMousePosition(this.lastMousePosition);
1216 this.legend.draw();
1217
1218 this.drawer.draw();
1219
1220 if (this.autoFollow) {
1221 this.resetZoom();
1222 }
1223 }
1224
1225 getDrawer(): LineDrawer {
1226 return this.drawer;
1227 }
1228
1229 getLegend(): Legend {
1230 return this.legend;
1231 }
1232
1233 getAxisLabels(): AxisLabels {
1234 return this.axisLabels;
1235 }
1236
1237 // Links this plot's x-axis with that of another Plot (e.g., to share time
1238 // axes).
1239 linkXAxis(other: Plot) {
1240 this.linkedXAxes.push(other);
1241 other.linkedXAxes.push(this);
1242 }
1243}