xref: /aosp_15_r20/external/perfetto/ui/src/frontend/virtual_canvas.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1*6dbdd20aSAndroid Build Coastguard Worker// Copyright (C) 2024 The Android Open Source Project
2*6dbdd20aSAndroid Build Coastguard Worker//
3*6dbdd20aSAndroid Build Coastguard Worker// Licensed under the Apache License, Version 2.0 (the "License");
4*6dbdd20aSAndroid Build Coastguard Worker// you may not use this file except in compliance with the License.
5*6dbdd20aSAndroid Build Coastguard Worker// You may obtain a copy of the License at
6*6dbdd20aSAndroid Build Coastguard Worker//
7*6dbdd20aSAndroid Build Coastguard Worker//      http://www.apache.org/licenses/LICENSE-2.0
8*6dbdd20aSAndroid Build Coastguard Worker//
9*6dbdd20aSAndroid Build Coastguard Worker// Unless required by applicable law or agreed to in writing, software
10*6dbdd20aSAndroid Build Coastguard Worker// distributed under the License is distributed on an "AS IS" BASIS,
11*6dbdd20aSAndroid Build Coastguard Worker// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12*6dbdd20aSAndroid Build Coastguard Worker// See the License for the specific language governing permissions and
13*6dbdd20aSAndroid Build Coastguard Worker// limitations under the License.
14*6dbdd20aSAndroid Build Coastguard Worker
15*6dbdd20aSAndroid Build Coastguard Worker/**
16*6dbdd20aSAndroid Build Coastguard Worker * Canvases have limits on their maximum size (which is determined by the
17*6dbdd20aSAndroid Build Coastguard Worker * system). Usually, this limit is fairly large, but can be as small as
18*6dbdd20aSAndroid Build Coastguard Worker * 4096x4096px on some machines.
19*6dbdd20aSAndroid Build Coastguard Worker *
20*6dbdd20aSAndroid Build Coastguard Worker * If we need a super large canvas, we need to use a different approach.
21*6dbdd20aSAndroid Build Coastguard Worker *
22*6dbdd20aSAndroid Build Coastguard Worker * Unless the user has a huge monitor, most of the time any sufficiently large
23*6dbdd20aSAndroid Build Coastguard Worker * canvas will overflow it's container, so we assume this container is set to
24*6dbdd20aSAndroid Build Coastguard Worker * scroll so that the user can actually see all of the canvas. We can take
25*6dbdd20aSAndroid Build Coastguard Worker * advantage of the fact that users may only see a small portion of the canvas
26*6dbdd20aSAndroid Build Coastguard Worker * at a time. So, if we position a small floating canvas element over the
27*6dbdd20aSAndroid Build Coastguard Worker * viewport of the scrolling container, we can approximate a huge canvas using a
28*6dbdd20aSAndroid Build Coastguard Worker * much smaller one.
29*6dbdd20aSAndroid Build Coastguard Worker *
30*6dbdd20aSAndroid Build Coastguard Worker * Given a target element and it's scrolling container, VirtualCanvas turns an
31*6dbdd20aSAndroid Build Coastguard Worker * empty HTML element into a "virtual" canvas with virtually unlimited size
32*6dbdd20aSAndroid Build Coastguard Worker * using the "floating" canvas technique described above.
33*6dbdd20aSAndroid Build Coastguard Worker */
34*6dbdd20aSAndroid Build Coastguard Worker
35*6dbdd20aSAndroid Build Coastguard Workerimport {DisposableStack} from '../base/disposable_stack';
36*6dbdd20aSAndroid Build Coastguard Workerimport {Bounds2D, Rect2D, Size2D} from '../base/geom';
37*6dbdd20aSAndroid Build Coastguard Worker
38*6dbdd20aSAndroid Build Coastguard Workerexport type LayoutShiftListener = (
39*6dbdd20aSAndroid Build Coastguard Worker  canvas: HTMLCanvasElement,
40*6dbdd20aSAndroid Build Coastguard Worker  rect: Rect2D,
41*6dbdd20aSAndroid Build Coastguard Worker) => void;
42*6dbdd20aSAndroid Build Coastguard Worker
43*6dbdd20aSAndroid Build Coastguard Workerexport type CanvasResizeListener = (
44*6dbdd20aSAndroid Build Coastguard Worker  canvas: HTMLCanvasElement,
45*6dbdd20aSAndroid Build Coastguard Worker  width: number,
46*6dbdd20aSAndroid Build Coastguard Worker  height: number,
47*6dbdd20aSAndroid Build Coastguard Worker) => void;
48*6dbdd20aSAndroid Build Coastguard Worker
49*6dbdd20aSAndroid Build Coastguard Workerexport interface VirtualCanvasOpts {
50*6dbdd20aSAndroid Build Coastguard Worker  // How much buffer to add above and below the visible window.
51*6dbdd20aSAndroid Build Coastguard Worker  overdrawPx: number;
52*6dbdd20aSAndroid Build Coastguard Worker
53*6dbdd20aSAndroid Build Coastguard Worker  // If true, the canvas will remain within the bounds on the target element at
54*6dbdd20aSAndroid Build Coastguard Worker  // all times.
55*6dbdd20aSAndroid Build Coastguard Worker  //
56*6dbdd20aSAndroid Build Coastguard Worker  // If false, the canvas is allowed to overflow the bounds of the target
57*6dbdd20aSAndroid Build Coastguard Worker  // element to avoid resizing unnecessarily.
58*6dbdd20aSAndroid Build Coastguard Worker  avoidOverflowingContainer: boolean;
59*6dbdd20aSAndroid Build Coastguard Worker}
60*6dbdd20aSAndroid Build Coastguard Worker
61*6dbdd20aSAndroid Build Coastguard Workerexport class VirtualCanvas implements Disposable {
62*6dbdd20aSAndroid Build Coastguard Worker  private readonly _trash = new DisposableStack();
63*6dbdd20aSAndroid Build Coastguard Worker  private readonly _canvasElement: HTMLCanvasElement;
64*6dbdd20aSAndroid Build Coastguard Worker  private readonly _targetElement: HTMLElement;
65*6dbdd20aSAndroid Build Coastguard Worker
66*6dbdd20aSAndroid Build Coastguard Worker  // Describes the offset of the canvas w.r.t. the "target" container
67*6dbdd20aSAndroid Build Coastguard Worker  private _canvasRect: Rect2D;
68*6dbdd20aSAndroid Build Coastguard Worker  private _layoutShiftListener?: LayoutShiftListener;
69*6dbdd20aSAndroid Build Coastguard Worker  private _canvasResizeListener?: CanvasResizeListener;
70*6dbdd20aSAndroid Build Coastguard Worker
71*6dbdd20aSAndroid Build Coastguard Worker  /**
72*6dbdd20aSAndroid Build Coastguard Worker   * @param targetElement The element to turn into a virtual canvas. The
73*6dbdd20aSAndroid Build Coastguard Worker   * dimensions of this element are used to size the canvas, so ensure this
74*6dbdd20aSAndroid Build Coastguard Worker   * element is sized appropriately.
75*6dbdd20aSAndroid Build Coastguard Worker   * @param containerElement The scrolling container to be used for determining
76*6dbdd20aSAndroid Build Coastguard Worker   * the size and position of the canvas. The targetElement should be a child of
77*6dbdd20aSAndroid Build Coastguard Worker   * this element.
78*6dbdd20aSAndroid Build Coastguard Worker   * @param opts Setup options for the VirtualCanvas.
79*6dbdd20aSAndroid Build Coastguard Worker   */
80*6dbdd20aSAndroid Build Coastguard Worker  constructor(
81*6dbdd20aSAndroid Build Coastguard Worker    targetElement: HTMLElement,
82*6dbdd20aSAndroid Build Coastguard Worker    containerElement: Element,
83*6dbdd20aSAndroid Build Coastguard Worker    opts?: Partial<VirtualCanvasOpts>,
84*6dbdd20aSAndroid Build Coastguard Worker  ) {
85*6dbdd20aSAndroid Build Coastguard Worker    const {overdrawPx = 100, avoidOverflowingContainer} = opts ?? {};
86*6dbdd20aSAndroid Build Coastguard Worker
87*6dbdd20aSAndroid Build Coastguard Worker    // Returns what the canvas rect should look like
88*6dbdd20aSAndroid Build Coastguard Worker    const getCanvasRect = () => {
89*6dbdd20aSAndroid Build Coastguard Worker      const containerRect = new Rect2D(
90*6dbdd20aSAndroid Build Coastguard Worker        containerElement.getBoundingClientRect(),
91*6dbdd20aSAndroid Build Coastguard Worker      );
92*6dbdd20aSAndroid Build Coastguard Worker      const targetElementRect = targetElement.getBoundingClientRect();
93*6dbdd20aSAndroid Build Coastguard Worker
94*6dbdd20aSAndroid Build Coastguard Worker      // Calculate the intersection of the container's viewport and the target
95*6dbdd20aSAndroid Build Coastguard Worker      const intersection = containerRect.intersect(targetElementRect);
96*6dbdd20aSAndroid Build Coastguard Worker
97*6dbdd20aSAndroid Build Coastguard Worker      // Pad the intersection by the overdraw amount
98*6dbdd20aSAndroid Build Coastguard Worker      const intersectionExpanded = intersection.expand(overdrawPx);
99*6dbdd20aSAndroid Build Coastguard Worker
100*6dbdd20aSAndroid Build Coastguard Worker      // Intersect with the original target rect unless we want to avoid resizes
101*6dbdd20aSAndroid Build Coastguard Worker      const canvasTargetRect = avoidOverflowingContainer
102*6dbdd20aSAndroid Build Coastguard Worker        ? intersectionExpanded.intersect(targetElementRect)
103*6dbdd20aSAndroid Build Coastguard Worker        : intersectionExpanded;
104*6dbdd20aSAndroid Build Coastguard Worker
105*6dbdd20aSAndroid Build Coastguard Worker      return canvasTargetRect.reframe(targetElementRect);
106*6dbdd20aSAndroid Build Coastguard Worker    };
107*6dbdd20aSAndroid Build Coastguard Worker
108*6dbdd20aSAndroid Build Coastguard Worker    const updateCanvas = () => {
109*6dbdd20aSAndroid Build Coastguard Worker      let repaintRequired = false;
110*6dbdd20aSAndroid Build Coastguard Worker
111*6dbdd20aSAndroid Build Coastguard Worker      const canvasRect = getCanvasRect();
112*6dbdd20aSAndroid Build Coastguard Worker      const canvasRectPrev = this._canvasRect;
113*6dbdd20aSAndroid Build Coastguard Worker      this._canvasRect = canvasRect;
114*6dbdd20aSAndroid Build Coastguard Worker
115*6dbdd20aSAndroid Build Coastguard Worker      if (
116*6dbdd20aSAndroid Build Coastguard Worker        canvasRectPrev.width !== canvasRect.width ||
117*6dbdd20aSAndroid Build Coastguard Worker        canvasRectPrev.height !== canvasRect.height
118*6dbdd20aSAndroid Build Coastguard Worker      ) {
119*6dbdd20aSAndroid Build Coastguard Worker        // Canvas needs to change size, update its size
120*6dbdd20aSAndroid Build Coastguard Worker        canvas.style.width = `${canvasRect.width}px`;
121*6dbdd20aSAndroid Build Coastguard Worker        canvas.style.height = `${canvasRect.height}px`;
122*6dbdd20aSAndroid Build Coastguard Worker        this._canvasResizeListener?.(
123*6dbdd20aSAndroid Build Coastguard Worker          canvas,
124*6dbdd20aSAndroid Build Coastguard Worker          canvasRect.width,
125*6dbdd20aSAndroid Build Coastguard Worker          canvasRect.height,
126*6dbdd20aSAndroid Build Coastguard Worker        );
127*6dbdd20aSAndroid Build Coastguard Worker        repaintRequired = true;
128*6dbdd20aSAndroid Build Coastguard Worker      }
129*6dbdd20aSAndroid Build Coastguard Worker
130*6dbdd20aSAndroid Build Coastguard Worker      if (
131*6dbdd20aSAndroid Build Coastguard Worker        canvasRectPrev.left !== canvasRect.left ||
132*6dbdd20aSAndroid Build Coastguard Worker        canvasRectPrev.top !== canvasRect.top
133*6dbdd20aSAndroid Build Coastguard Worker      ) {
134*6dbdd20aSAndroid Build Coastguard Worker        // Canvas needs to move, update the transform
135*6dbdd20aSAndroid Build Coastguard Worker        canvas.style.transform = `translate(${canvasRect.left}px, ${canvasRect.top}px)`;
136*6dbdd20aSAndroid Build Coastguard Worker        repaintRequired = true;
137*6dbdd20aSAndroid Build Coastguard Worker      }
138*6dbdd20aSAndroid Build Coastguard Worker
139*6dbdd20aSAndroid Build Coastguard Worker      repaintRequired && this._layoutShiftListener?.(canvas, canvasRect);
140*6dbdd20aSAndroid Build Coastguard Worker    };
141*6dbdd20aSAndroid Build Coastguard Worker
142*6dbdd20aSAndroid Build Coastguard Worker    containerElement.addEventListener('scroll', updateCanvas, {
143*6dbdd20aSAndroid Build Coastguard Worker      passive: true,
144*6dbdd20aSAndroid Build Coastguard Worker    });
145*6dbdd20aSAndroid Build Coastguard Worker    this._trash.defer(() =>
146*6dbdd20aSAndroid Build Coastguard Worker      containerElement.removeEventListener('scroll', updateCanvas),
147*6dbdd20aSAndroid Build Coastguard Worker    );
148*6dbdd20aSAndroid Build Coastguard Worker
149*6dbdd20aSAndroid Build Coastguard Worker    // Resize observer callbacks are called once immediately
150*6dbdd20aSAndroid Build Coastguard Worker    const resizeObserver = new ResizeObserver(() => {
151*6dbdd20aSAndroid Build Coastguard Worker      updateCanvas();
152*6dbdd20aSAndroid Build Coastguard Worker    });
153*6dbdd20aSAndroid Build Coastguard Worker
154*6dbdd20aSAndroid Build Coastguard Worker    resizeObserver.observe(containerElement);
155*6dbdd20aSAndroid Build Coastguard Worker    resizeObserver.observe(targetElement);
156*6dbdd20aSAndroid Build Coastguard Worker    this._trash.defer(() => {
157*6dbdd20aSAndroid Build Coastguard Worker      resizeObserver.disconnect();
158*6dbdd20aSAndroid Build Coastguard Worker    });
159*6dbdd20aSAndroid Build Coastguard Worker
160*6dbdd20aSAndroid Build Coastguard Worker    // Ensures the canvas doesn't change the size of the target element
161*6dbdd20aSAndroid Build Coastguard Worker    targetElement.style.overflow = 'hidden';
162*6dbdd20aSAndroid Build Coastguard Worker
163*6dbdd20aSAndroid Build Coastguard Worker    const canvas = document.createElement('canvas');
164*6dbdd20aSAndroid Build Coastguard Worker    canvas.style.position = 'absolute';
165*6dbdd20aSAndroid Build Coastguard Worker    targetElement.appendChild(canvas);
166*6dbdd20aSAndroid Build Coastguard Worker    this._trash.defer(() => {
167*6dbdd20aSAndroid Build Coastguard Worker      targetElement.removeChild(canvas);
168*6dbdd20aSAndroid Build Coastguard Worker    });
169*6dbdd20aSAndroid Build Coastguard Worker
170*6dbdd20aSAndroid Build Coastguard Worker    this._canvasElement = canvas;
171*6dbdd20aSAndroid Build Coastguard Worker    this._targetElement = targetElement;
172*6dbdd20aSAndroid Build Coastguard Worker    this._canvasRect = new Rect2D({
173*6dbdd20aSAndroid Build Coastguard Worker      left: 0,
174*6dbdd20aSAndroid Build Coastguard Worker      top: 0,
175*6dbdd20aSAndroid Build Coastguard Worker      bottom: 0,
176*6dbdd20aSAndroid Build Coastguard Worker      right: 0,
177*6dbdd20aSAndroid Build Coastguard Worker    });
178*6dbdd20aSAndroid Build Coastguard Worker  }
179*6dbdd20aSAndroid Build Coastguard Worker
180*6dbdd20aSAndroid Build Coastguard Worker  /**
181*6dbdd20aSAndroid Build Coastguard Worker   * Set the callback that gets called when the canvas element is moved or
182*6dbdd20aSAndroid Build Coastguard Worker   * resized, thus, invalidating the contents, and should be re-painted.
183*6dbdd20aSAndroid Build Coastguard Worker   *
184*6dbdd20aSAndroid Build Coastguard Worker   * @param cb The new callback.
185*6dbdd20aSAndroid Build Coastguard Worker   */
186*6dbdd20aSAndroid Build Coastguard Worker  setLayoutShiftListener(cb: LayoutShiftListener) {
187*6dbdd20aSAndroid Build Coastguard Worker    this._layoutShiftListener = cb;
188*6dbdd20aSAndroid Build Coastguard Worker  }
189*6dbdd20aSAndroid Build Coastguard Worker
190*6dbdd20aSAndroid Build Coastguard Worker  /**
191*6dbdd20aSAndroid Build Coastguard Worker   * Set the callback that gets called when the canvas element is resized. This
192*6dbdd20aSAndroid Build Coastguard Worker   * might be a good opportunity to update the size of the canvas' draw buffer.
193*6dbdd20aSAndroid Build Coastguard Worker   *
194*6dbdd20aSAndroid Build Coastguard Worker   * @param cb The new callback.
195*6dbdd20aSAndroid Build Coastguard Worker   */
196*6dbdd20aSAndroid Build Coastguard Worker  setCanvasResizeListener(cb: CanvasResizeListener) {
197*6dbdd20aSAndroid Build Coastguard Worker    this._canvasResizeListener = cb;
198*6dbdd20aSAndroid Build Coastguard Worker  }
199*6dbdd20aSAndroid Build Coastguard Worker
200*6dbdd20aSAndroid Build Coastguard Worker  /**
201*6dbdd20aSAndroid Build Coastguard Worker   * The floating canvas element.
202*6dbdd20aSAndroid Build Coastguard Worker   */
203*6dbdd20aSAndroid Build Coastguard Worker  get canvasElement(): HTMLCanvasElement {
204*6dbdd20aSAndroid Build Coastguard Worker    return this._canvasElement;
205*6dbdd20aSAndroid Build Coastguard Worker  }
206*6dbdd20aSAndroid Build Coastguard Worker
207*6dbdd20aSAndroid Build Coastguard Worker  /**
208*6dbdd20aSAndroid Build Coastguard Worker   * The target element, i.e. the one passed to our constructor.
209*6dbdd20aSAndroid Build Coastguard Worker   */
210*6dbdd20aSAndroid Build Coastguard Worker  get targetElement(): HTMLElement {
211*6dbdd20aSAndroid Build Coastguard Worker    return this._targetElement;
212*6dbdd20aSAndroid Build Coastguard Worker  }
213*6dbdd20aSAndroid Build Coastguard Worker
214*6dbdd20aSAndroid Build Coastguard Worker  /**
215*6dbdd20aSAndroid Build Coastguard Worker   * The size of the target element, aka the size of the virtual canvas.
216*6dbdd20aSAndroid Build Coastguard Worker   */
217*6dbdd20aSAndroid Build Coastguard Worker  get size(): Size2D {
218*6dbdd20aSAndroid Build Coastguard Worker    return {
219*6dbdd20aSAndroid Build Coastguard Worker      width: this._targetElement.clientWidth,
220*6dbdd20aSAndroid Build Coastguard Worker      height: this._targetElement.clientHeight,
221*6dbdd20aSAndroid Build Coastguard Worker    };
222*6dbdd20aSAndroid Build Coastguard Worker  }
223*6dbdd20aSAndroid Build Coastguard Worker
224*6dbdd20aSAndroid Build Coastguard Worker  /**
225*6dbdd20aSAndroid Build Coastguard Worker   * Returns the rect of the floating canvas with respect to the target element.
226*6dbdd20aSAndroid Build Coastguard Worker   * This will need to be subtracted from any drawing operations to get the
227*6dbdd20aSAndroid Build Coastguard Worker   * right alignment within the virtual canvas.
228*6dbdd20aSAndroid Build Coastguard Worker   */
229*6dbdd20aSAndroid Build Coastguard Worker  get canvasRect(): Rect2D {
230*6dbdd20aSAndroid Build Coastguard Worker    return this._canvasRect;
231*6dbdd20aSAndroid Build Coastguard Worker  }
232*6dbdd20aSAndroid Build Coastguard Worker
233*6dbdd20aSAndroid Build Coastguard Worker  /**
234*6dbdd20aSAndroid Build Coastguard Worker   * Stop listening to DOM events.
235*6dbdd20aSAndroid Build Coastguard Worker   */
236*6dbdd20aSAndroid Build Coastguard Worker  [Symbol.dispose]() {
237*6dbdd20aSAndroid Build Coastguard Worker    this._trash.dispose();
238*6dbdd20aSAndroid Build Coastguard Worker  }
239*6dbdd20aSAndroid Build Coastguard Worker
240*6dbdd20aSAndroid Build Coastguard Worker  /**
241*6dbdd20aSAndroid Build Coastguard Worker   * Return true if a rect overlaps the floating canvas.
242*6dbdd20aSAndroid Build Coastguard Worker   * @param rect The rect to test.
243*6dbdd20aSAndroid Build Coastguard Worker   * @returns true if rect overlaps, false otherwise.
244*6dbdd20aSAndroid Build Coastguard Worker   */
245*6dbdd20aSAndroid Build Coastguard Worker  overlapsCanvas(rect: Bounds2D): boolean {
246*6dbdd20aSAndroid Build Coastguard Worker    const c = this._canvasRect;
247*6dbdd20aSAndroid Build Coastguard Worker    const y = rect.top < c.bottom && rect.bottom > c.top;
248*6dbdd20aSAndroid Build Coastguard Worker    const x = rect.left < c.right && rect.right > c.left;
249*6dbdd20aSAndroid Build Coastguard Worker    return x && y;
250*6dbdd20aSAndroid Build Coastguard Worker  }
251*6dbdd20aSAndroid Build Coastguard Worker}
252