blob: 536322f9245320ffa1a166566284b72e94112eb3 [file] [log] [blame]
Philipp Schrader548aedf2023-02-17 20:09:13 -08001import * as Colors from './colors';
2
James Kuszmaula8f2c452020-07-05 21:17:56 -07003// Multiplies all the values in the provided array by scale.
4function scaleVec(vec: number[], scale: number): number[] {
5 const scaled: number[] = [];
6 for (let num of vec) {
7 scaled.push(num * scale);
8 }
9 return scaled;
10}
11
12// Runs the operation op() over every pair of numbers in a, b and returns
13// the result.
14function cwiseOp(
15 a: number[], b: number[], op: (a: number, b: number) => number): number[] {
16 if (a.length !== b.length) {
17 throw new Error("a and b must be of equal length.");
18 }
19 const min: number[] = [];
20 for (let ii = 0; ii < a.length; ++ii) {
21 min.push(op(a[ii], b[ii]));
22 }
23 return min;
24}
25
26// Adds vectors a and b.
27function addVec(a: number[], b: number[]): number[] {
28 return cwiseOp(a, b, (p, q) => {
29 return p + q;
30 });
31}
32
James Kuszmaul0d7df892021-04-09 22:19:49 -070033function subtractVec(a: number[], b: number[]): number[] {
34 return cwiseOp(a, b, (p, q) => {
35 return p - q;
36 });
37}
38
39function multVec(a: number[], b: number[]): number[] {
40 return cwiseOp(a, b, (p, q) => {
41 return p * q;
42 });
43}
44
45function divideVec(a: number[], b: number[]): number[] {
46 return cwiseOp(a, b, (p, q) => {
47 return p / q;
48 });
49}
50
51// Parameters used when scaling the lines to the canvas.
52// If a point in a line is at pos then its position in the canvas space will be
53// scale * pos + offset.
54class ZoomParameters {
55 public scale: number[] = [1.0, 1.0];
56 public offset: number[] = [0.0, 0.0];
57 copy():ZoomParameters {
58 const copy = new ZoomParameters();
59 copy.scale = [this.scale[0], this.scale[1]];
60 copy.offset = [this.offset[0], this.offset[1]];
61 return copy;
62 }
63}
64
65export class Point {
66 constructor(
67 public x: number = 0.0,
68 public y: number = 0.0) {}
69}
70
James Kuszmaula8f2c452020-07-05 21:17:56 -070071// Represents a single line within a plot. Handles rendering the line with
72// all of its points and the appropriate color/markers/lines.
73export class Line {
James Kuszmaul0d7df892021-04-09 22:19:49 -070074 // Notes on zoom/precision management:
75 // The adjustedPoints field is the buffert of points (formatted [x0, y0, x1,
76 // y1, ..., xn, yn]) that will be read directly by WebGL and operated on in
77 // the vertex shader. However, WebGL provides relatively minimal guarantess
78 // about the floating point precision available in the shaders (to the point
79 // where even Float32 precision is not guaranteed). As such, we
80 // separately maintain the points vector using javascript number's
81 // (arbitrary-precision ints or double-precision floats). We then periodically
82 // set the baseZoom to be equal to the current desired zoom, calculate the
83 // scaled values directly in typescript, store them in adjustedPoints, and
84 // then just pass an identity transformation to WebGL for the zoom parameters.
85 // When actively zooming, we then just use WebGL to compensate for the offset
86 // between the baseZoom and the desired zoom, taking advantage of WebGL's
87 // performance to handle the high-rate updates but then falling back to
88 // typescript periodically to reset the offsets to avoid precision issues.
89 //
90 // As a practical matter, I've found that even if we were to recalculate
91 // the zoom in typescript on every iteration, the penalty is relatively
92 // minor--we still perform far better than using a non-WebGL canvas. This
93 // suggests that the bulk of the performance advantage from using WebGL for
94 // this use-case lies not in doing the zoom updates in the shaders, but rather
95 // in relying on WebGL to figure out how to drawin the lines/points that we
96 // specify.
97 private adjustedPoints: Float32Array = new Float32Array([]);
98 private points: Point[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -070099 private _drawLine: boolean = true;
100 private _pointSize: number = 3.0;
101 private _hasUpdate: boolean = false;
James Kuszmaul71a81932020-12-15 21:08:01 -0800102 private _minValues: number[] = [Infinity, Infinity];
103 private _maxValues: number[] = [-Infinity, -Infinity];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700104 private _color: number[] = [1.0, 0.0, 0.0];
105 private pointAttribLocation: number;
Philipp Schradere625ba22020-11-16 20:11:37 -0800106 private colorLocation: WebGLUniformLocation | null;
107 private pointSizeLocation: WebGLUniformLocation | null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800108 private _label: string|null = null;
Austin Schuh83d6c152022-07-18 18:38:57 -0700109 private _hidden: boolean = false;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700110 constructor(
111 private readonly ctx: WebGLRenderingContext,
112 private readonly program: WebGLProgram,
James Kuszmaul0d7df892021-04-09 22:19:49 -0700113 private readonly buffer: WebGLBuffer, private baseZoom: ZoomParameters) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700114 this.pointAttribLocation = this.ctx.getAttribLocation(this.program, 'apos');
115 this.colorLocation = this.ctx.getUniformLocation(this.program, 'color');
116 this.pointSizeLocation =
117 this.ctx.getUniformLocation(this.program, 'point_size');
118 }
119
120 // Return the largest x and y values present in the list of points.
121 maxValues(): number[] {
122 return this._maxValues;
123 }
124
125 // Return the smallest x and y values present in the list of points.
126 minValues(): number[] {
127 return this._minValues;
128 }
129
130 // Whether any parameters have changed that would require re-rending the line.
131 hasUpdate(): boolean {
132 return this._hasUpdate;
133 }
134
135 // Get/set the color of the line, returned as an RGB tuple.
136 color(): number[] {
137 return this._color;
138 }
139
Austin Schuh7d63eab2021-03-06 20:15:02 -0800140 setColor(newColor: number[]): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700141 this._color = newColor;
142 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -0800143 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700144 }
145
146 // Get/set the size of the markers to draw, in pixels (zero means no markers).
147 pointSize(): number {
148 return this._pointSize;
149 }
150
Austin Schuh7d63eab2021-03-06 20:15:02 -0800151 setPointSize(size: number): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700152 this._pointSize = size;
153 this._hasUpdate = true;
Austin Schuh7d63eab2021-03-06 20:15:02 -0800154 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700155 }
156
157 // Get/set whether we draw a line between the points (i.e., setting this to
158 // false would effectively create a scatter-plot). If drawLine is false and
159 // pointSize is zero, then no data is rendered.
160 drawLine(): boolean {
161 return this._drawLine;
162 }
163
James Kuszmauld7d98e82021-03-07 20:17:54 -0800164 setDrawLine(newDrawLine: boolean): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700165 this._drawLine = newDrawLine;
166 this._hasUpdate = true;
James Kuszmauld7d98e82021-03-07 20:17:54 -0800167 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700168 }
169
170 // Set the points to render. The points in the line are ordered and should
171 // be of the format:
172 // [x1, y1, x2, y2, x3, y3, ...., xN, yN]
James Kuszmaul0d7df892021-04-09 22:19:49 -0700173 setPoints(points: Point[]) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700174 this.points = points;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700175 this.adjustedPoints = new Float32Array(points.length * 2);
176 this.updateBaseZoom(this.baseZoom);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700177 this._hasUpdate = true;
178 this._minValues[0] = Infinity;
179 this._minValues[1] = Infinity;
180 this._maxValues[0] = -Infinity;
181 this._maxValues[1] = -Infinity;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700182 for (let ii = 0; ii < this.points.length; ++ii) {
183 const x = this.points[ii].x;
184 const y = this.points[ii].y;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700185
James Kuszmaul71a81932020-12-15 21:08:01 -0800186 if (isNaN(x) || isNaN(y)) {
187 continue;
188 }
189
James Kuszmaula8f2c452020-07-05 21:17:56 -0700190 this._minValues = cwiseOp(this._minValues, [x, y], Math.min);
191 this._maxValues = cwiseOp(this._maxValues, [x, y], Math.max);
192 }
193 }
194
Austin Schuh83d6c152022-07-18 18:38:57 -0700195 hidden(): boolean {
196 return this._hidden;
197 }
198
199 setHidden(hidden: boolean) {
200 this._hasUpdate = true;
201 this._hidden = hidden;
202 }
203
James Kuszmaul0d7df892021-04-09 22:19:49 -0700204 getPoints(): Point[] {
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800205 return this.points;
206 }
207
James Kuszmaula8f2c452020-07-05 21:17:56 -0700208 // Get/set the label to use for the line when drawing the legend.
James Kuszmauld7d98e82021-03-07 20:17:54 -0800209 setLabel(label: string): Line {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700210 this._label = label;
James Kuszmauld7d98e82021-03-07 20:17:54 -0800211 return this;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700212 }
213
James Kuszmaul461b0682020-12-22 22:20:21 -0800214 label(): string|null {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700215 return this._label;
216 }
217
James Kuszmaul0d7df892021-04-09 22:19:49 -0700218 updateBaseZoom(zoom: ZoomParameters) {
219 this.baseZoom = zoom;
220 for (let ii = 0; ii < this.points.length; ++ii) {
221 const point = this.points[ii];
222 this.adjustedPoints[ii * 2] = point.x * zoom.scale[0] + zoom.offset[0];
223 this.adjustedPoints[ii * 2 + 1] = point.y * zoom.scale[1] + zoom.offset[1];
224 }
225 }
226
James Kuszmaula8f2c452020-07-05 21:17:56 -0700227 // Render the line on the canvas.
228 draw() {
229 this._hasUpdate = false;
230 if (this.points.length === 0) {
231 return;
232 }
233
Austin Schuh83d6c152022-07-18 18:38:57 -0700234 if (this._hidden) {
235 return;
236 }
237
James Kuszmaula8f2c452020-07-05 21:17:56 -0700238 this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.buffer);
239 // Note: if this is generating errors associated with the buffer size,
240 // confirm that this.points really is a Float32Array.
241 this.ctx.bufferData(
242 this.ctx.ARRAY_BUFFER,
James Kuszmaul0d7df892021-04-09 22:19:49 -0700243 this.adjustedPoints,
James Kuszmaula8f2c452020-07-05 21:17:56 -0700244 this.ctx.STATIC_DRAW);
245 {
246 const numComponents = 2; // pull out 2 values per iteration
247 const numType = this.ctx.FLOAT; // the data in the buffer is 32bit floats
248 const normalize = false; // don't normalize
249 const stride = 0; // how many bytes to get from one set of values to the
250 // next 0 = use type and numComponents above
251 const offset = 0; // how many bytes inside the buffer to start from
252 this.ctx.vertexAttribPointer(
253 this.pointAttribLocation, numComponents, numType,
254 normalize, stride, offset);
255 this.ctx.enableVertexAttribArray(this.pointAttribLocation);
256 }
257
258 this.ctx.uniform1f(this.pointSizeLocation, this._pointSize);
259 this.ctx.uniform4f(
260 this.colorLocation, this._color[0], this._color[1], this._color[2],
261 1.0);
262
263 if (this._drawLine) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700264 this.ctx.drawArrays(this.ctx.LINE_STRIP, 0, this.points.length);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700265 }
266 if (this._pointSize > 0.0) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700267 this.ctx.drawArrays(this.ctx.POINTS, 0, this.points.length);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700268 }
269 }
270}
271
James Kuszmaula8f2c452020-07-05 21:17:56 -0700272enum MouseButton {
273 Right,
274 Middle,
275 Left
276}
277
278// The button to use for panning the plot.
279const PAN_BUTTON = MouseButton.Left;
James Kuszmaul461b0682020-12-22 22:20:21 -0800280const RECTANGLE_BUTTON = MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700281
282// Returns the mouse button that generated a given event.
283function transitionButton(event: MouseEvent): MouseButton {
284 switch (event.button) {
285 case 0:
286 return MouseButton.Left;
287 case 1:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700288 return MouseButton.Middle;
James Kuszmaulbce45332020-12-15 19:50:01 -0800289 case 2:
290 return MouseButton.Right;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700291 }
292}
293
294// Returns whether the given button is pressed on the mouse.
295function buttonPressed(event: MouseEvent, button: MouseButton): boolean {
296 switch (button) {
297 // For some reason, the middle/right buttons are swapped relative to where
298 // we would expect them to be given the .button field.
299 case MouseButton.Left:
300 return 0 !== (event.buttons & 0x1);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700301 case MouseButton.Right:
James Kuszmaulbce45332020-12-15 19:50:01 -0800302 return 0 !== (event.buttons & 0x2);
303 case MouseButton.Middle:
James Kuszmaula8f2c452020-07-05 21:17:56 -0700304 return 0 !== (event.buttons & 0x4);
305 }
306}
307
308// Handles rendering a Legend for a list of lines.
309// This takes a 2d canvas, which is what we use for rendering all the text of
310// the plot and is separate, but overlayed on top of, the WebGL canvas that the
311// lines are drawn on.
312export class Legend {
313 // Location, in pixels, of the legend in the text canvas.
314 private location: number[] = [0, 0];
Austin Schuh83d6c152022-07-18 18:38:57 -0700315 constructor(
316 private plot: Plot, private lines: Line[],
317 private legend: HTMLDivElement) {
Austin Schuhfcd56942022-07-18 17:41:32 -0700318 this.setPosition([80, 30]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700319 }
320
321 setPosition(location: number[]): void {
322 this.location = location;
Austin Schuhfcd56942022-07-18 17:41:32 -0700323 this.legend.style.left = location[0] + 'px';
324 this.legend.style.top = location[1] + 'px';
James Kuszmaula8f2c452020-07-05 21:17:56 -0700325 }
326
327 draw(): void {
Austin Schuhfcd56942022-07-18 17:41:32 -0700328 // First, figure out if anything has changed. The legend is created and
329 // then titles are changed afterwords, so we have to do this lazily.
330 let needsUpdate = false;
331 {
332 let child = 0;
333 for (let line of this.lines) {
334 if (line.label() === null) {
335 continue;
336 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700337
Austin Schuhfcd56942022-07-18 17:41:32 -0700338 if (child >= this.legend.children.length) {
339 needsUpdate = true;
340 break;
341 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700342
Austin Schuhfcd56942022-07-18 17:41:32 -0700343 // Make sure both have text in the right spot. Don't be too picky since
344 // nothing should really be changing here, and it's handy to let the
345 // user edit the HTML for testing.
Austin Schuh772aad62023-02-04 14:26:17 -0800346 let textdiv = this.legend.children[child].lastChild;
347 let canvas = this.legend.children[child].firstChild;
348 if ((textdiv.textContent.length == 0 && line.label().length != 0) ||
349 (textdiv as HTMLDivElement).offsetHeight !=
350 (canvas as HTMLCanvasElement).height) {
Austin Schuhfcd56942022-07-18 17:41:32 -0700351 needsUpdate = true;
352 break;
353 }
354 child += 1;
James Kuszmaul461b0682020-12-22 22:20:21 -0800355 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700356
Austin Schuhfcd56942022-07-18 17:41:32 -0700357 // If we got through everything, we should be pointed past the last child.
358 // If not, more children exists than lines.
359 if (child != this.legend.children.length) {
360 needsUpdate = true;
361 }
362 }
363 if (!needsUpdate) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800364 return;
365 }
366
Austin Schuhfcd56942022-07-18 17:41:32 -0700367 // Nuke the old legend.
368 while (this.legend.firstChild) {
369 this.legend.removeChild(this.legend.firstChild);
370 }
James Kuszmaul461b0682020-12-22 22:20:21 -0800371
Austin Schuhfcd56942022-07-18 17:41:32 -0700372 // Now, build up a new legend.
James Kuszmaula8f2c452020-07-05 21:17:56 -0700373 for (let line of this.lines) {
James Kuszmaul461b0682020-12-22 22:20:21 -0800374 if (line.label() === null) {
375 continue;
376 }
Austin Schuhfcd56942022-07-18 17:41:32 -0700377
378 // The legend is a div containing both a canvas for the style/color, and a
379 // div for the text. Make those, color in the canvas, and add it to the
380 // page.
381 let l = document.createElement('div');
382 l.classList.add('aos_legend_line');
383 let text = document.createElement('div');
384 text.textContent = line.label();
385
386 l.appendChild(text);
387 this.legend.appendChild(l);
388
389 let c = document.createElement('canvas');
390 c.width = text.offsetHeight;
391 c.height = text.offsetHeight;
392
393 const linestyleContext = c.getContext("2d");
394 linestyleContext.clearRect(0, 0, c.width, c.height);
395
James Kuszmaula8f2c452020-07-05 21:17:56 -0700396 const color = line.color();
Austin Schuhfcd56942022-07-18 17:41:32 -0700397 linestyleContext.strokeStyle = `rgb(${255.0 * color[0]}, ${
398 255.0 * color[1]}, ${255.0 * color[2]})`;
399 linestyleContext.fillStyle = linestyleContext.strokeStyle;
400
James Kuszmaula8f2c452020-07-05 21:17:56 -0700401 const pointSize = line.pointSize();
Austin Schuhfcd56942022-07-18 17:41:32 -0700402 const kDistanceIn = pointSize / 2.0;
403
404 if (line.drawLine()) {
405 linestyleContext.beginPath();
406 linestyleContext.moveTo(0, 0);
407 linestyleContext.lineTo(c.height, c.width);
408 linestyleContext.closePath();
409 linestyleContext.stroke();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700410 }
411
Austin Schuhfcd56942022-07-18 17:41:32 -0700412 if (pointSize > 0) {
413 linestyleContext.fillRect(0, 0, pointSize, pointSize);
414 linestyleContext.fillRect(
415 c.height - 1 - pointSize, c.width - 1 - pointSize, pointSize,
416 pointSize);
417 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700418
Austin Schuh83d6c152022-07-18 18:38:57 -0700419 c.addEventListener('click', (e) => {
420 if (!line.hidden()) {
421 l.classList.add('aos_legend_line_hidden');
422 } else {
423 l.classList.remove('aos_legend_line_hidden');
424 }
425
426 line.setHidden(!line.hidden());
427 this.plot.draw();
428 });
429
Austin Schuhfcd56942022-07-18 17:41:32 -0700430 l.prepend(c);
431 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700432 }
433}
434
435// This class manages all the WebGL rendering--namely, drawing the reference
436// grid for the user and then rendering all the actual lines of the plot.
437export class LineDrawer {
438 private program: WebGLProgram|null = null;
439 private scaleLocation: WebGLUniformLocation;
440 private offsetLocation: WebGLUniformLocation;
441 private vertexBuffer: WebGLBuffer;
442 private lines: Line[] = [];
443 private zoom: ZoomParameters = new ZoomParameters();
James Kuszmaul0d7df892021-04-09 22:19:49 -0700444 private baseZoom: ZoomParameters = new ZoomParameters();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700445 private zoomUpdated: boolean = true;
446 // Maximum grid lines to render at once--this is used provide an upper limit
447 // on the number of Line objects we need to create in order to render the
448 // grid.
449 public readonly MAX_GRID_LINES: number = 5;
450 // Arrays of the points at which we will draw grid lines for the x/y axes.
451 private xTicks: number[] = [];
452 private yTicks: number[] = [];
453 private xGridLines: Line[] = [];
454 private yGridLines: Line[] = [];
455
James Kuszmaul933a9742021-03-07 19:59:06 -0800456 public static readonly COLOR_CYCLE = [
457 Colors.RED, Colors.GREEN, Colors.BLUE, Colors.BROWN, Colors.PINK,
Austin Schuhc2e9c502021-11-25 21:23:24 -0800458 Colors.CYAN, Colors.WHITE, Colors.ORANGE, Colors.YELLOW
James Kuszmaul933a9742021-03-07 19:59:06 -0800459 ];
460 private colorCycleIndex = 0;
461
James Kuszmaula8f2c452020-07-05 21:17:56 -0700462 constructor(public readonly ctx: WebGLRenderingContext) {
463 this.program = this.compileShaders();
464 this.scaleLocation = this.ctx.getUniformLocation(this.program, 'scale');
465 this.offsetLocation = this.ctx.getUniformLocation(this.program, 'offset');
466 this.vertexBuffer = this.ctx.createBuffer();
467
468 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700469 this.xGridLines.push(
470 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
471 this.yGridLines.push(
472 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700473 }
474 }
475
James Kuszmaula8f2c452020-07-05 21:17:56 -0700476 getZoom(): ZoomParameters {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700477 return this.zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700478 }
479
480 plotToCanvasCoordinates(plotPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700481 return addVec(multVec(plotPos, this.zoom.scale), this.zoom.offset);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700482 }
483
484
485 canvasToPlotCoordinates(canvasPos: number[]): number[] {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700486 return divideVec(subtractVec(canvasPos, this.zoom.offset), this.zoom.scale);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700487 }
488
Austin Schuhfcd56942022-07-18 17:41:32 -0700489 // These return the max/min rendered points, in plot-space (this is helpful
James Kuszmaula8f2c452020-07-05 21:17:56 -0700490 // for drawing axis labels).
491 maxVisiblePoint(): number[] {
492 return this.canvasToPlotCoordinates([1.0, 1.0]);
493 }
494
495 minVisiblePoint(): number[] {
496 return this.canvasToPlotCoordinates([-1.0, -1.0]);
497 }
498
499 getLines(): Line[] {
500 return this.lines;
501 }
502
503 setZoom(zoom: ZoomParameters) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700504 if (this.zoom.scale[0] == zoom.scale[0] &&
505 this.zoom.scale[1] == zoom.scale[1] &&
506 this.zoom.offset[0] == zoom.offset[0] &&
507 this.zoom.offset[1] == zoom.offset[1]) {
508 return;
509 }
James Kuszmaula8f2c452020-07-05 21:17:56 -0700510 this.zoomUpdated = true;
James Kuszmaul0d7df892021-04-09 22:19:49 -0700511 this.zoom = zoom.copy();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700512 }
513
514 setXTicks(ticks: number[]): void {
515 this.xTicks = ticks;
516 }
517
518 setYTicks(ticks: number[]): void {
519 this.yTicks = ticks;
520 }
521
522 // Update the grid lines.
523 updateTicks() {
524 for (let ii = 0; ii < this.MAX_GRID_LINES; ++ii) {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700525 this.xGridLines[ii].setPoints([]);
526 this.yGridLines[ii].setPoints([]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700527 }
528
529 const minValues = this.minVisiblePoint();
530 const maxValues = this.maxVisiblePoint();
531
532 for (let ii = 0; ii < this.xTicks.length; ++ii) {
533 this.xGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700534 const points = [
535 new Point(this.xTicks[ii], minValues[1]),
536 new Point(this.xTicks[ii], maxValues[1])
537 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700538 this.xGridLines[ii].setPointSize(0);
539 this.xGridLines[ii].setPoints(points);
540 this.xGridLines[ii].draw();
541 }
542
543 for (let ii = 0; ii < this.yTicks.length; ++ii) {
544 this.yGridLines[ii].setColor([0.0, 0.0, 0.0]);
James Kuszmaul0d7df892021-04-09 22:19:49 -0700545 const points = [
546 new Point(minValues[0], this.yTicks[ii]),
547 new Point(maxValues[0], this.yTicks[ii])
548 ];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700549 this.yGridLines[ii].setPointSize(0);
550 this.yGridLines[ii].setPoints(points);
551 this.yGridLines[ii].draw();
552 }
553 }
554
555 // Handles redrawing any of the WebGL objects, if necessary.
556 draw(): void {
557 let needsUpdate = this.zoomUpdated;
558 this.zoomUpdated = false;
559 for (let line of this.lines) {
560 if (line.hasUpdate()) {
561 needsUpdate = true;
562 break;
563 }
564 }
565 if (!needsUpdate) {
566 return;
567 }
568
569 this.reset();
570
571 this.updateTicks();
572
573 for (let line of this.lines) {
574 line.draw();
575 }
576
577 return;
578 }
579
580 loadShader(shaderType: number, source: string): WebGLShader {
581 const shader = this.ctx.createShader(shaderType);
582 this.ctx.shaderSource(shader, source);
583 this.ctx.compileShader(shader);
584 if (!this.ctx.getShaderParameter(shader, this.ctx.COMPILE_STATUS)) {
585 alert(
586 'Got an error compiling a shader: ' +
587 this.ctx.getShaderInfoLog(shader));
588 this.ctx.deleteShader(shader);
589 return null;
590 }
591
592 return shader;
593 }
594
595 compileShaders(): WebGLProgram {
596 const vertexShader = 'attribute vec2 apos;' +
597 'uniform vec2 scale;' +
598 'uniform vec2 offset;' +
599 'uniform float point_size;' +
600 'void main() {' +
601 ' gl_Position.xy = apos.xy * scale.xy + offset.xy;' +
602 ' gl_Position.z = 0.0;' +
603 ' gl_Position.w = 1.0;' +
604 ' gl_PointSize = point_size;' +
605 '}';
606
607 const fragmentShader = 'precision highp float;' +
608 'uniform vec4 color;' +
609 'void main() {' +
610 ' gl_FragColor = color;' +
611 '}';
612
613 const compiledVertex =
614 this.loadShader(this.ctx.VERTEX_SHADER, vertexShader);
615 const compiledFragment =
616 this.loadShader(this.ctx.FRAGMENT_SHADER, fragmentShader);
617 const program = this.ctx.createProgram();
618 this.ctx.attachShader(program, compiledVertex);
619 this.ctx.attachShader(program, compiledFragment);
620 this.ctx.linkProgram(program);
621 if (!this.ctx.getProgramParameter(program, this.ctx.LINK_STATUS)) {
622 alert(
623 'Unable to link the shaders: ' + this.ctx.getProgramInfoLog(program));
624 return null;
625 }
626 return program;
627 }
628
James Kuszmaul933a9742021-03-07 19:59:06 -0800629 addLine(useColorCycle: boolean = true): Line {
James Kuszmaul0d7df892021-04-09 22:19:49 -0700630 this.lines.push(
631 new Line(this.ctx, this.program, this.vertexBuffer, this.baseZoom));
James Kuszmaul933a9742021-03-07 19:59:06 -0800632 const line = this.lines[this.lines.length - 1];
633 if (useColorCycle) {
634 line.setColor(LineDrawer.COLOR_CYCLE[this.colorCycleIndex++]);
635 }
636 return line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700637 }
638
639 minValues(): number[] {
640 let minValues = [Infinity, Infinity];
641 for (let line of this.lines) {
642 minValues = cwiseOp(minValues, line.minValues(), Math.min);
643 }
644 return minValues;
645 }
646
647 maxValues(): number[] {
648 let maxValues = [-Infinity, -Infinity];
649 for (let line of this.lines) {
650 maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
651 }
652 return maxValues;
653 }
654
655 reset(): void {
656 // Set the background color
657 this.ctx.clearColor(0.5, 0.5, 0.5, 1.0);
658 this.ctx.clearDepth(1.0);
659 this.ctx.enable(this.ctx.DEPTH_TEST);
660 this.ctx.depthFunc(this.ctx.LEQUAL);
661 this.ctx.clear(this.ctx.COLOR_BUFFER_BIT | this.ctx.DEPTH_BUFFER_BIT);
662
663 this.ctx.useProgram(this.program);
664
James Kuszmaul0d7df892021-04-09 22:19:49 -0700665 // Check for whether the zoom parameters have changed significantly; if so,
666 // update the base zoom.
667 // These thresholds are somewhat arbitrary.
668 const scaleDiff = divideVec(this.zoom.scale, this.baseZoom.scale);
669 const scaleChanged = scaleDiff[0] < 0.9 || scaleDiff[0] > 1.1 ||
670 scaleDiff[1] < 0.9 || scaleDiff[1] > 1.1;
671 const offsetDiff = subtractVec(this.zoom.offset, this.baseZoom.offset);
672 // Note that offset is in the canvas coordinate frame and so just using
673 // hard-coded constants is fine.
674 const offsetChanged =
675 Math.abs(offsetDiff[0]) > 0.1 || Math.abs(offsetDiff[1]) > 0.1;
676 if (scaleChanged || offsetChanged) {
677 this.baseZoom = this.zoom.copy();
678 for (const line of this.lines) {
679 line.updateBaseZoom(this.baseZoom);
680 }
681 for (const line of this.xGridLines) {
682 line.updateBaseZoom(this.baseZoom);
683 }
684 for (const line of this.yGridLines) {
685 line.updateBaseZoom(this.baseZoom);
686 }
687 }
688
689 // all the points in the lines will be pre-scaled by this.baseZoom, so
690 // we need to remove its effects before passing it in.
691 // zoom.scale * pos + zoom.offset = scale * (baseZoom.scale * pos + baseZoom.offset) + offset
692 // zoom.scale = scale * baseZoom.scale
693 // scale = zoom.scale / baseZoom.scale
694 // zoom.offset = scale * baseZoom.offset + offset
695 // offset = zoom.offset - scale * baseZoom.offset
696 const scale = divideVec(this.zoom.scale, this.baseZoom.scale);
697 const offset =
698 subtractVec(this.zoom.offset, multVec(scale, this.baseZoom.offset));
James Kuszmaula8f2c452020-07-05 21:17:56 -0700699 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700700 this.scaleLocation, scale[0], scale[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700701 this.ctx.uniform2f(
James Kuszmaul0d7df892021-04-09 22:19:49 -0700702 this.offsetLocation, offset[0], offset[1]);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700703 }
704}
705
706// Class to store how much whitespace we put between the edges of the WebGL
707// canvas (where we draw all the lines) and the edge of the plot. This gives
708// us space to, e.g., draw axis labels, the plot title, etc.
709class WhitespaceBuffers {
710 constructor(
711 public left: number, public right: number, public top: number,
712 public bottom: number) {}
713}
714
715// Class to manage all the annotations associated with the plot--the axis/tick
716// labels and the plot title.
717class AxisLabels {
718 private readonly INCREMENTS: number[] = [2, 4, 5, 10];
719 // Space to leave to create some visual space around the text.
720 private readonly TEXT_BUFFER: number = 5;
721 private title: string = "";
722 private xlabel: string = "";
723 private ylabel: string = "";
724 constructor(
725 private ctx: CanvasRenderingContext2D, private drawer: LineDrawer,
726 private graphBuffers: WhitespaceBuffers) {}
727
728 numberToLabel(num: number): string {
729 return num.toPrecision(5);
730 }
731
732 textWidth(str: string): number {
733 return this.ctx.measureText(str).actualBoundingBoxRight;
734 }
735
736 textHeight(str: string): number {
737 return this.ctx.measureText(str).actualBoundingBoxAscent;
738 }
739
740 textDepth(str: string): number {
741 return this.ctx.measureText(str).actualBoundingBoxDescent;
742 }
743
744 setTitle(title: string) {
745 this.title = title;
746 }
747
748 setXLabel(xlabel: string) {
749 this.xlabel = xlabel;
750 }
751
752 setYLabel(ylabel: string) {
753 this.ylabel = ylabel;
754 }
755
756 getIncrement(range: number[]): number {
757 const diff = Math.abs(range[1] - range[0]);
758 const minDiff = diff / this.drawer.MAX_GRID_LINES;
759 const incrementsRatio = this.INCREMENTS[this.INCREMENTS.length - 1];
760 const order = Math.pow(
761 incrementsRatio,
762 Math.floor(Math.log(minDiff) / Math.log(incrementsRatio)));
763 const normalizedDiff = minDiff / order;
764 for (let increment of this.INCREMENTS) {
765 if (increment > normalizedDiff) {
766 return increment * order;
767 }
768 }
769 return 1.0;
770 }
771
772 getTicks(range: number[]): number[] {
773 const increment = this.getIncrement(range);
774 const start = Math.ceil(range[0] / increment) * increment;
775 const values = [start];
776 for (let ii = 0; ii < this.drawer.MAX_GRID_LINES - 1; ++ii) {
777 const nextValue = values[ii] + increment;
778 if (nextValue > range[1]) {
779 break;
780 }
781 values.push(nextValue);
782 }
783 return values;
784 }
785
786 plotToCanvasCoordinates(plotPos: number[]): number[] {
787 const webglCoord = this.drawer.plotToCanvasCoordinates(plotPos);
788 const webglX = (webglCoord[0] + 1.0) / 2.0 * this.drawer.ctx.canvas.width;
789 const webglY = (1.0 - webglCoord[1]) / 2.0 * this.drawer.ctx.canvas.height;
790 return [webglX + this.graphBuffers.left, webglY + this.graphBuffers.top];
791 }
792
793 drawXTick(x: number) {
794 const text = this.numberToLabel(x);
795 const height = this.textHeight(text);
796 const xpos = this.plotToCanvasCoordinates([x, 0])[0];
797 this.ctx.textAlign = "center";
798 this.ctx.fillText(
799 text, xpos,
800 this.ctx.canvas.height - this.graphBuffers.bottom + height +
801 this.TEXT_BUFFER);
802 }
803
804 drawYTick(y: number) {
805 const text = this.numberToLabel(y);
806 const height = this.textHeight(text);
807 const ypos = this.plotToCanvasCoordinates([0, y])[1];
808 this.ctx.textAlign = "right";
809 this.ctx.fillText(
810 text, this.graphBuffers.left - this.TEXT_BUFFER,
811 ypos + height / 2.0);
812 }
813
814 drawTitle() {
815 if (this.title) {
816 this.ctx.textAlign = 'center';
817 this.ctx.fillText(
818 this.title, this.ctx.canvas.width / 2.0,
819 this.graphBuffers.top - this.TEXT_BUFFER);
820 }
821 }
822
823 drawXLabel() {
824 if (this.xlabel) {
825 this.ctx.textAlign = 'center';
826 this.ctx.fillText(
827 this.xlabel, this.ctx.canvas.width / 2.0,
828 this.ctx.canvas.height - this.TEXT_BUFFER);
829 }
830 }
831
832 drawYLabel() {
833 this.ctx.save();
834 if (this.ylabel) {
835 this.ctx.textAlign = 'center';
836 const height = this.textHeight(this.ylabel);
837 this.ctx.translate(
838 height + this.TEXT_BUFFER, this.ctx.canvas.height / 2.0);
839 this.ctx.rotate(-Math.PI / 2.0);
840 this.ctx.fillText(this.ylabel, 0, 0);
841 }
842 this.ctx.restore();
843 }
844
845 draw() {
846 this.ctx.fillStyle = 'black';
847 const minValues = this.drawer.minVisiblePoint();
848 const maxValues = this.drawer.maxVisiblePoint();
849 let text = this.numberToLabel(maxValues[1]);
850 this.drawYTick(maxValues[1]);
851 this.drawYTick(minValues[1]);
852 this.drawXTick(minValues[0]);
853 this.drawXTick(maxValues[0]);
854 this.ctx.strokeStyle = 'black';
855 this.ctx.strokeRect(
856 this.graphBuffers.left, this.graphBuffers.top,
857 this.drawer.ctx.canvas.width, this.drawer.ctx.canvas.height);
858 this.ctx.strokeRect(
859 0, 0,
860 this.ctx.canvas.width, this.ctx.canvas.height);
861 const xTicks = this.getTicks([minValues[0], maxValues[0]]);
862 this.drawer.setXTicks(xTicks);
863 const yTicks = this.getTicks([minValues[1], maxValues[1]]);
864 this.drawer.setYTicks(yTicks);
865
866 for (let x of xTicks) {
867 this.drawXTick(x);
868 }
869
870 for (let y of yTicks) {
871 this.drawYTick(y);
872 }
873
874 this.drawTitle();
875 this.drawXLabel();
876 this.drawYLabel();
877 }
878
879 // Draws the current mouse position in the bottom-right of the plot.
880 drawMousePosition(mousePos: number[]) {
881 const plotPos = this.drawer.canvasToPlotCoordinates(mousePos);
882
883 const text =
884 `(${plotPos[0].toPrecision(10)}, ${plotPos[1].toPrecision(10)})`;
885 const textDepth = this.textDepth(text);
886 this.ctx.textAlign = 'right';
887 this.ctx.fillText(
888 text, this.ctx.canvas.width - this.graphBuffers.right,
889 this.ctx.canvas.height - this.graphBuffers.bottom - textDepth);
890 }
891}
892
893// This class manages the entirety of a single plot. Most of the logic in
894// this class is around handling mouse/keyboard events for interacting with
895// the plot.
896export class Plot {
897 private canvas = document.createElement('canvas');
898 private textCanvas = document.createElement('canvas');
Austin Schuhfcd56942022-07-18 17:41:32 -0700899 private legendDiv = document.createElement('div');
Austin Schuhc2e9c502021-11-25 21:23:24 -0800900 private lineDrawerContext: WebGLRenderingContext;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700901 private drawer: LineDrawer;
James Kuszmaul461b0682020-12-22 22:20:21 -0800902 private static keysPressed:
903 object = {'x': false, 'y': false, 'Escape': false};
904 // List of all plots to use for propagating key-press events to.
905 private static allPlots: Plot[] = [];
James Kuszmaula8f2c452020-07-05 21:17:56 -0700906 // In canvas coordinates (the +/-1 square).
James Kuszmaul461b0682020-12-22 22:20:21 -0800907 private lastMousePanPosition: number[]|null = null;
908 private rectangleStartPosition: number[]|null = null;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700909 private axisLabelBuffer: WhitespaceBuffers =
910 new WhitespaceBuffers(50, 20, 20, 30);
911 private axisLabels: AxisLabels;
912 private legend: Legend;
913 private lastMousePosition: number[] = [0.0, 0.0];
914 private autoFollow: boolean = true;
915 private linkedXAxes: Plot[] = [];
James Kuszmaul71a81932020-12-15 21:08:01 -0800916 private lastTimeMs: number = 0;
James Kuszmaul5f5e1232020-12-22 20:58:00 -0800917 private defaultYRange: number[]|null = null;
James Kuszmaul461b0682020-12-22 22:20:21 -0800918 private zoomRectangle: Line;
James Kuszmaula8f2c452020-07-05 21:17:56 -0700919
Austin Schuhc2e9c502021-11-25 21:23:24 -0800920 constructor(wrapperDiv: HTMLDivElement) {
James Kuszmaula8f2c452020-07-05 21:17:56 -0700921 wrapperDiv.appendChild(this.canvas);
922 wrapperDiv.appendChild(this.textCanvas);
Austin Schuhfcd56942022-07-18 17:41:32 -0700923 this.legendDiv.classList.add('aos_legend');
924 wrapperDiv.appendChild(this.legendDiv);
James Kuszmaul71a81932020-12-15 21:08:01 -0800925 this.lastTimeMs = (new Date()).getTime();
James Kuszmaula8f2c452020-07-05 21:17:56 -0700926
Austin Schuhc2e9c502021-11-25 21:23:24 -0800927 this.canvas.style.paddingLeft = this.axisLabelBuffer.left.toString() + "px";
928 this.canvas.style.paddingRight = this.axisLabelBuffer.right.toString() + "px";
929 this.canvas.style.paddingTop = this.axisLabelBuffer.top.toString() + "px";
930 this.canvas.style.paddingBottom = this.axisLabelBuffer.bottom.toString() + "px";
Austin Schuhfcd56942022-07-18 17:41:32 -0700931 this.canvas.classList.add('aos_plot');
James Kuszmaula8f2c452020-07-05 21:17:56 -0700932
Austin Schuhc2e9c502021-11-25 21:23:24 -0800933 this.lineDrawerContext = this.canvas.getContext('webgl');
934 this.drawer = new LineDrawer(this.lineDrawerContext);
935
Austin Schuhfcd56942022-07-18 17:41:32 -0700936 this.textCanvas.classList.add('aos_plot_text');
James Kuszmaula8f2c452020-07-05 21:17:56 -0700937
938 this.canvas.addEventListener('dblclick', (e) => {
939 this.handleDoubleClick(e);
940 });
941 this.canvas.onwheel = (e) => {
942 this.handleWheel(e);
943 e.preventDefault();
944 };
945 this.canvas.onmousedown = (e) => {
946 this.handleMouseDown(e);
947 };
948 this.canvas.onmouseup = (e) => {
949 this.handleMouseUp(e);
950 };
951 this.canvas.onmousemove = (e) => {
952 this.handleMouseMove(e);
953 };
James Kuszmaul461b0682020-12-22 22:20:21 -0800954 this.canvas.addEventListener('contextmenu', event => event.preventDefault());
955 // Note: To handle the fact that only one keypress handle can be registered
956 // per browser tab, we share key-press handlers across all plot instances.
957 Plot.allPlots.push(this);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700958 document.onkeydown = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800959 Plot.handleKeyDown(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700960 };
961 document.onkeyup = (e) => {
James Kuszmaul461b0682020-12-22 22:20:21 -0800962 Plot.handleKeyUp(e);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700963 };
964
965 const textCtx = this.textCanvas.getContext("2d");
966 this.axisLabels =
967 new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
Austin Schuh83d6c152022-07-18 18:38:57 -0700968 this.legend = new Legend(this, this.drawer.getLines(), this.legendDiv);
James Kuszmaula8f2c452020-07-05 21:17:56 -0700969
James Kuszmaul933a9742021-03-07 19:59:06 -0800970 this.zoomRectangle = this.getDrawer().addLine(false);
971 this.zoomRectangle.setColor(Colors.WHITE);
James Kuszmaul461b0682020-12-22 22:20:21 -0800972 this.zoomRectangle.setPointSize(0);
973
James Kuszmaula8f2c452020-07-05 21:17:56 -0700974 this.draw();
975 }
976
977 handleDoubleClick(event: MouseEvent) {
978 this.resetZoom();
979 }
980
981 mouseCanvasLocation(event: MouseEvent): number[] {
Austin Schuhc2e9c502021-11-25 21:23:24 -0800982 const computedStyle = window.getComputedStyle(this.canvas);
983 const paddingLeftStr = computedStyle.getPropertyValue('padding-left');
984 const paddingTopStr = computedStyle.getPropertyValue('padding-top');
985 if (paddingLeftStr.substring(paddingLeftStr.length - 2) != "px") {
986 throw new Error("Left padding should be specified in pixels.");
987 }
988 if (paddingTopStr.substring(paddingTopStr.length - 2) != "px") {
989 throw new Error("Left padding should be specified in pixels.");
990 }
991 // Javascript will just ignore the extra "px".
992 const paddingLeft = Number.parseInt(paddingLeftStr);
993 const paddingTop = Number.parseInt(paddingTopStr);
994
James Kuszmaula8f2c452020-07-05 21:17:56 -0700995 return [
Austin Schuhc2e9c502021-11-25 21:23:24 -0800996 (event.offsetX - paddingLeft) * 2.0 / this.canvas.width - 1.0,
997 -(event.offsetY - paddingTop) * 2.0 / this.canvas.height + 1.0
James Kuszmaula8f2c452020-07-05 21:17:56 -0700998 ];
999 }
1000
James Kuszmaul461b0682020-12-22 22:20:21 -08001001 mousePlotLocation(event: MouseEvent): number[] {
1002 return this.drawer.canvasToPlotCoordinates(this.mouseCanvasLocation(event));
1003 }
1004
James Kuszmaula8f2c452020-07-05 21:17:56 -07001005 handleWheel(event: WheelEvent) {
1006 if (event.deltaMode !== event.DOM_DELTA_PIXEL) {
1007 return;
1008 }
1009 const mousePosition = this.mouseCanvasLocation(event);
1010 const kWheelTuningScalar = 1.5;
1011 const zoom = -kWheelTuningScalar * event.deltaY / this.canvas.height;
1012 let zoomScalar = 1.0 + Math.abs(zoom);
1013 if (zoom < 0.0) {
1014 zoomScalar = 1.0 / zoomScalar;
1015 }
1016 const scale = scaleVec(this.drawer.getZoom().scale, zoomScalar);
1017 const offset = addVec(
1018 scaleVec(mousePosition, 1.0 - zoomScalar),
1019 scaleVec(this.drawer.getZoom().offset, zoomScalar));
1020 this.setZoom(scale, offset);
1021 }
1022
1023 handleMouseDown(event: MouseEvent) {
Austin Schuhd31a1612022-07-15 14:31:46 -07001024 for (let plot of this.linkedXAxes) {
1025 plot.autoFollow = false;
1026 }
1027 this.autoFollow = false;
1028
James Kuszmaul461b0682020-12-22 22:20:21 -08001029 const button = transitionButton(event);
1030 switch (button) {
1031 case PAN_BUTTON:
1032 this.lastMousePanPosition = this.mouseCanvasLocation(event);
1033 break;
1034 case RECTANGLE_BUTTON:
1035 this.rectangleStartPosition = this.mousePlotLocation(event);
1036 break;
1037 default:
1038 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001039 }
1040 }
1041
1042 handleMouseUp(event: MouseEvent) {
James Kuszmaul461b0682020-12-22 22:20:21 -08001043 const button = transitionButton(event);
1044 switch (button) {
1045 case PAN_BUTTON:
1046 this.lastMousePanPosition = null;
1047 break;
1048 case RECTANGLE_BUTTON:
1049 if (this.rectangleStartPosition === null) {
1050 // We got a right-button release without ever seeing the mouse-down;
1051 // just return.
1052 return;
1053 }
1054 this.finishRectangleZoom(event);
1055 break;
1056 default:
1057 break;
James Kuszmaula8f2c452020-07-05 21:17:56 -07001058 }
1059 }
1060
James Kuszmaul461b0682020-12-22 22:20:21 -08001061 private finishRectangleZoom(event: MouseEvent) {
1062 const currentPosition = this.mousePlotLocation(event);
1063 this.setZoomCorners(this.rectangleStartPosition, currentPosition);
1064 this.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001065 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001066 }
1067
James Kuszmaula8f2c452020-07-05 21:17:56 -07001068 handleMouseMove(event: MouseEvent) {
1069 const mouseLocation = this.mouseCanvasLocation(event);
1070 if (buttonPressed(event, PAN_BUTTON) &&
1071 (this.lastMousePanPosition !== null)) {
1072 const mouseDiff =
1073 addVec(mouseLocation, scaleVec(this.lastMousePanPosition, -1));
1074 this.setZoom(
1075 this.drawer.getZoom().scale,
1076 addVec(this.drawer.getZoom().offset, mouseDiff));
1077 this.lastMousePanPosition = mouseLocation;
1078 }
James Kuszmaul461b0682020-12-22 22:20:21 -08001079 if (this.rectangleStartPosition !== null) {
1080 if (buttonPressed(event, RECTANGLE_BUTTON)) {
1081 // p0 and p1 are the two corners of the rectangle to draw.
1082 const p0 = [...this.rectangleStartPosition];
1083 const p1 = [...this.mousePlotLocation(event)];
1084 const minVisible = this.drawer.minVisiblePoint();
1085 const maxVisible = this.drawer.maxVisiblePoint();
1086 // Modify the rectangle corners to display correctly if we are limiting
1087 // the zoom to the x/y axis.
1088 const x_pressed = Plot.keysPressed['x'];
1089 const y_pressed = Plot.keysPressed["y"];
1090 if (x_pressed && !y_pressed) {
1091 p0[1] = minVisible[1];
1092 p1[1] = maxVisible[1];
1093 } else if (!x_pressed && y_pressed) {
1094 p0[0] = minVisible[0];
1095 p1[0] = maxVisible[0];
1096 }
James Kuszmaul0d7df892021-04-09 22:19:49 -07001097 this.zoomRectangle.setPoints([
1098 new Point(p0[0], p0[1]), new Point(p0[0], p1[1]),
1099 new Point(p1[0], p1[1]), new Point(p1[0], p0[1]),
1100 new Point(p0[0], p0[1])
1101 ]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001102 } else {
1103 this.finishRectangleZoom(event);
1104 }
1105 } else {
James Kuszmaul0d7df892021-04-09 22:19:49 -07001106 this.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001107 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001108 this.lastMousePosition = mouseLocation;
1109 }
1110
1111 setZoom(scale: number[], offset: number[]) {
James Kuszmaul71a81932020-12-15 21:08:01 -08001112 if (!isFinite(scale[0]) || !isFinite(scale[1])) {
1113 throw new Error("Doesn't support non-finite scales due to singularities.");
1114 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001115 const x_pressed = Plot.keysPressed["x"];
1116 const y_pressed = Plot.keysPressed["y"];
1117 const zoom = this.drawer.getZoom();
1118 if (x_pressed && !y_pressed) {
1119 zoom.scale[0] = scale[0];
1120 zoom.offset[0] = offset[0];
1121 } else if (y_pressed && !x_pressed) {
1122 zoom.scale[1] = scale[1];
1123 zoom.offset[1] = offset[1];
1124 } else {
1125 zoom.scale = scale;
1126 zoom.offset = offset;
1127 }
1128
1129 for (let plot of this.linkedXAxes) {
1130 const otherZoom = plot.drawer.getZoom();
1131 otherZoom.scale[0] = zoom.scale[0];
1132 otherZoom.offset[0] = zoom.offset[0];
1133 plot.drawer.setZoom(otherZoom);
1134 plot.autoFollow = false;
1135 }
1136 this.drawer.setZoom(zoom);
1137 this.autoFollow = false;
1138 }
1139
1140
1141 setZoomCorners(c1: number[], c2: number[]) {
1142 const scale = cwiseOp(c1, c2, (a, b) => {
1143 return 2.0 / Math.abs(a - b);
1144 });
1145 const offset = cwiseOp(scale, cwiseOp(c1, c2, Math.max), (a, b) => {
1146 return 1.0 - a * b;
1147 });
1148 this.setZoom(scale, offset);
1149 }
1150
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001151 setDefaultYRange(range: number[]|null) {
1152 if (range == null) {
1153 this.defaultYRange = null;
1154 return;
1155 }
1156 if (range.length != 2) {
1157 throw new Error('Range should contain exactly two values.');
1158 }
1159 this.defaultYRange = range;
1160 }
1161
James Kuszmaula8f2c452020-07-05 21:17:56 -07001162 resetZoom() {
James Kuszmaul71a81932020-12-15 21:08:01 -08001163 const minValues = this.drawer.minValues();
1164 const maxValues = this.drawer.maxValues();
Austin Schuh8b69cc22022-07-15 14:33:34 -07001165 const kScalar = 0.05;
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001166 for (const plot of this.linkedXAxes) {
1167 const otherMin = plot.drawer.minValues();
1168 const otherMax = plot.drawer.maxValues();
1169 // For linked x-axes, only adjust the x limits.
1170 minValues[0] = Math.min(minValues[0], otherMin[0]);
1171 maxValues[0] = Math.max(maxValues[0], otherMax[0]);
1172 }
1173 if (!isFinite(minValues[0]) || !isFinite(maxValues[0])) {
1174 minValues[0] = 0;
1175 maxValues[0] = 0;
1176 }
1177 if (!isFinite(minValues[1]) || !isFinite(maxValues[1])) {
1178 minValues[1] = 0;
1179 maxValues[1] = 0;
1180 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001181 if (minValues[0] == maxValues[0]) {
1182 minValues[0] -= 1;
1183 maxValues[0] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001184 } else {
1185 const width = maxValues[0] - minValues[0];
1186 maxValues[0] += width * kScalar;
1187 minValues[0] -= width * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001188 }
1189 if (minValues[1] == maxValues[1]) {
1190 minValues[1] -= 1;
1191 maxValues[1] += 1;
Austin Schuh8b69cc22022-07-15 14:33:34 -07001192 } else {
1193 const height = maxValues[1] - minValues[1];
1194 maxValues[1] += height * kScalar;
1195 minValues[1] -= height * kScalar;
James Kuszmaul71a81932020-12-15 21:08:01 -08001196 }
James Kuszmaul5f5e1232020-12-22 20:58:00 -08001197 if (this.defaultYRange != null) {
1198 minValues[1] = this.defaultYRange[0];
1199 maxValues[1] = this.defaultYRange[1];
1200 }
James Kuszmaul71a81932020-12-15 21:08:01 -08001201 this.setZoomCorners(minValues, maxValues);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001202 this.autoFollow = true;
1203 for (let plot of this.linkedXAxes) {
1204 plot.autoFollow = true;
1205 }
1206 }
1207
James Kuszmaul461b0682020-12-22 22:20:21 -08001208 static handleKeyUp(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001209 Plot.keysPressed[event.key] = false;
1210 }
1211
James Kuszmaul461b0682020-12-22 22:20:21 -08001212 static handleKeyDown(event: KeyboardEvent) {
James Kuszmaula8f2c452020-07-05 21:17:56 -07001213 Plot.keysPressed[event.key] = true;
James Kuszmaul461b0682020-12-22 22:20:21 -08001214 for (const plot of this.allPlots) {
1215 if (Plot.keysPressed['Escape']) {
1216 // Cancel zoom/pan operations on escape.
1217 plot.lastMousePanPosition = null;
1218 plot.rectangleStartPosition = null;
James Kuszmaul0d7df892021-04-09 22:19:49 -07001219 plot.zoomRectangle.setPoints([]);
James Kuszmaul461b0682020-12-22 22:20:21 -08001220 }
1221 }
James Kuszmaula8f2c452020-07-05 21:17:56 -07001222 }
1223
1224 draw() {
1225 window.requestAnimationFrame(() => this.draw());
James Kuszmaul71a81932020-12-15 21:08:01 -08001226 const curTime = (new Date()).getTime();
1227 const frameRate = 1000.0 / (curTime - this.lastTimeMs);
1228 this.lastTimeMs = curTime;
Austin Schuhc2e9c502021-11-25 21:23:24 -08001229 const parentWidth = this.textCanvas.parentElement.offsetWidth;
1230 const parentHeight = this.textCanvas.parentElement.offsetHeight;
1231 this.textCanvas.width = parentWidth;
1232 this.textCanvas.height = parentHeight;
1233 this.canvas.width =
1234 parentWidth - this.axisLabelBuffer.left - this.axisLabelBuffer.right;
1235 this.canvas.height =
1236 parentHeight - this.axisLabelBuffer.top - this.axisLabelBuffer.bottom;
1237 this.lineDrawerContext.viewport(
1238 0, 0, this.lineDrawerContext.drawingBufferWidth,
1239 this.lineDrawerContext.drawingBufferHeight);
James Kuszmaula8f2c452020-07-05 21:17:56 -07001240
1241 // Clear the overlay.
1242 const textCtx = this.textCanvas.getContext("2d");
1243 textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
1244
1245 this.axisLabels.draw();
1246 this.axisLabels.drawMousePosition(this.lastMousePosition);
1247 this.legend.draw();
1248
1249 this.drawer.draw();
1250
1251 if (this.autoFollow) {
1252 this.resetZoom();
1253 }
1254 }
1255
1256 getDrawer(): LineDrawer {
1257 return this.drawer;
1258 }
1259
1260 getLegend(): Legend {
1261 return this.legend;
1262 }
1263
1264 getAxisLabels(): AxisLabels {
1265 return this.axisLabels;
1266 }
1267
1268 // Links this plot's x-axis with that of another Plot (e.g., to share time
1269 // axes).
1270 linkXAxis(other: Plot) {
1271 this.linkedXAxes.push(other);
1272 other.linkedXAxes.push(this);
1273 }
1274}