• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15/**
16 * This module provides the TrackNodeTree mithril component, which is
17 * responsible for rendering out a tree of tracks and drawing their content
18 * onto the canvas.
19 * - Rendering track panels and handling nested and sticky headers.
20 * - Managing the virtual canvas & drawing the grid-lines, tracks and overlays
21 *   onto the canvas.
22 * - Handling track interaction events such as dragging, panning and scrolling.
23 */
24
25import {hex} from 'color-convert';
26import m from 'mithril';
27import {canvasClip, canvasSave} from '../../base/canvas_utils';
28import {classNames} from '../../base/classnames';
29import {DisposableStack} from '../../base/disposable_stack';
30import {findRef, toHTMLElement} from '../../base/dom_utils';
31import {
32  HorizontalBounds,
33  Rect2D,
34  Size2D,
35  VerticalBounds,
36} from '../../base/geom';
37import {HighPrecisionTime} from '../../base/high_precision_time';
38import {HighPrecisionTimeSpan} from '../../base/high_precision_time_span';
39import {assertExists} from '../../base/logging';
40import {Time} from '../../base/time';
41import {TimeScale} from '../../base/time_scale';
42import {
43  DragEvent,
44  ZonedInteractionHandler,
45} from '../../base/zoned_interaction_handler';
46import {PerfStats, runningStatStr} from '../../core/perf_stats';
47import {TraceImpl} from '../../core/trace_impl';
48import {TrackNode} from '../../public/workspace';
49import {VirtualOverlayCanvas} from '../../widgets/virtual_overlay_canvas';
50import {
51  SELECTION_STROKE_COLOR,
52  TRACK_BORDER_COLOR,
53  TRACK_SHELL_WIDTH,
54} from '../css_constants';
55import {renderFlows} from './flow_events_renderer';
56import {generateTicks, getMaxMajorTicks, TickType} from './gridline_helper';
57import {
58  shiftDragPanInteraction,
59  wheelNavigationInteraction,
60} from './timeline_interactions';
61import {TrackView} from './track_view';
62import {drawVerticalLineAtTime} from './vertical_line_helper';
63import {featureFlags} from '../../core/feature_flags';
64import {EmptyState} from '../../widgets/empty_state';
65import {Button} from '../../widgets/button';
66import {Intent} from '../../widgets/common';
67
68const VIRTUAL_TRACK_SCROLLING = featureFlags.register({
69  id: 'virtualTrackScrolling',
70  name: 'Virtual track scrolling',
71  description: `[Experimental] Use virtual scrolling in the timeline view to
72    improve performance on large traces.`,
73  defaultValue: false,
74});
75
76export interface TrackTreeViewAttrs {
77  // Access to the trace, for accessing the track registry / selection manager.
78  readonly trace: TraceImpl;
79
80  // The root track node for tracks to display in this stack. This node is not
81  // actually displayed, only its children are, but it's used for reordering
82  // purposes if `reorderable` is set to true.
83  readonly rootNode: TrackNode;
84
85  // Additional class names to add to the root level element.
86  readonly className?: string;
87
88  // Allow nodes to be reordered by dragging and dropping.
89  // Default: false
90  readonly canReorderNodes?: boolean;
91
92  // Adds a little remove button to each node.
93  // Default: false
94  readonly canRemoveNodes?: boolean;
95
96  // Scroll to scroll to new tracks as they are added.
97  // Default: false
98  readonly scrollToNewTracks?: boolean;
99
100  // If supplied, each track will be run though this filter to work out whether
101  // to show it or not.
102  readonly trackFilter?: (track: TrackNode) => boolean;
103
104  readonly filtersApplied?: boolean;
105}
106
107const TRACK_CONTAINER_REF = 'track-container';
108
109export class TrackTreeView implements m.ClassComponent<TrackTreeViewAttrs> {
110  private readonly trace: TraceImpl;
111  private readonly trash = new DisposableStack();
112  private interactions?: ZonedInteractionHandler;
113  private perfStatsEnabled = false;
114  private trackPerfStats = new WeakMap<TrackNode, PerfStats>();
115  private perfStats = {
116    totalTracks: 0,
117    tracksOnCanvas: 0,
118    renderStats: new PerfStats(10),
119  };
120  private areaDrag?: InProgressAreaSelection;
121  private handleDrag?: InProgressHandleDrag;
122  private canvasRect?: Rect2D;
123
124  constructor({attrs}: m.Vnode<TrackTreeViewAttrs>) {
125    this.trace = attrs.trace;
126  }
127
128  view({attrs}: m.Vnode<TrackTreeViewAttrs>): m.Children {
129    const {
130      trace,
131      scrollToNewTracks,
132      canReorderNodes,
133      canRemoveNodes,
134      className,
135      rootNode,
136      trackFilter,
137      filtersApplied,
138    } = attrs;
139    const renderedTracks = new Array<TrackView>();
140    let top = 0;
141
142    function filterMatches(node: TrackNode): boolean {
143      if (!trackFilter) return true; // Filter ignored, show all tracks.
144
145      // If this track name matches filter, show it.
146      if (trackFilter(node)) return true;
147
148      // Also show if any of our children match.
149      if (node.children?.some(filterMatches)) return true;
150
151      return false;
152    }
153
154    const renderTrack = (
155      node: TrackNode,
156      depth = 0,
157      stickyTop = 0,
158    ): m.Children => {
159      // Skip nodes that don't match the filter and have no matching children.
160      if (!filterMatches(node)) return undefined;
161
162      const trackView = new TrackView(trace, node, top);
163      renderedTracks.push(trackView);
164
165      let childDepth = depth;
166      let childStickyTop = stickyTop;
167      if (!node.headless) {
168        top += trackView.height;
169        ++childDepth;
170        childStickyTop += trackView.height;
171      }
172
173      const children =
174        (node.headless || node.expanded || filtersApplied) &&
175        node.hasChildren &&
176        node.children.map((track) =>
177          renderTrack(track, childDepth, childStickyTop),
178        );
179
180      if (node.headless) {
181        return children;
182      } else {
183        const isTrackOnScreen = () => {
184          if (VIRTUAL_TRACK_SCROLLING.get()) {
185            return this.canvasRect?.overlaps({
186              left: 0,
187              right: 1,
188              ...trackView.verticalBounds,
189            });
190          } else {
191            return true;
192          }
193        };
194
195        return trackView.renderDOM(
196          {
197            lite: !Boolean(isTrackOnScreen()),
198            scrollToOnCreate: scrollToNewTracks,
199            reorderable: canReorderNodes,
200            removable: canRemoveNodes,
201            stickyTop,
202            depth,
203            collapsible: !filtersApplied,
204          },
205          children,
206        );
207      }
208    };
209
210    const trackVnodes = rootNode.children.map((track) => renderTrack(track));
211
212    // If there are no truthy vnode values, show "empty state" placeholder.
213    if (trackVnodes.every((x) => !Boolean(x))) {
214      if (filtersApplied) {
215        // If we are filtering, show 'no matching tracks' empty state widget.
216        return m(
217          EmptyState,
218          {
219            className,
220            icon: 'filter_alt_off',
221            title: `No tracks match track filter`,
222          },
223          m(Button, {
224            intent: Intent.Primary,
225            label: 'Clear track filter',
226            onclick: () => trace.tracks.filters.clearAll(),
227          }),
228        );
229      } else {
230        // Not filtering, the workspace must be empty.
231        return m(EmptyState, {
232          className,
233          icon: 'inbox',
234          title: 'Empty workspace',
235        });
236      }
237    }
238
239    return m(
240      VirtualOverlayCanvas,
241      {
242        onMount: (redrawCanvas) =>
243          attrs.trace.raf.addCanvasRedrawCallback(redrawCanvas),
244        disableCanvasRedrawOnMithrilUpdates: true,
245        className: classNames(className, 'pf-track-tree'),
246        overflowY: 'auto',
247        overflowX: 'hidden',
248        onCanvasRedraw: ({ctx, virtualCanvasSize, canvasRect}) => {
249          this.drawCanvas(
250            ctx,
251            virtualCanvasSize,
252            renderedTracks,
253            canvasRect,
254            rootNode,
255          );
256
257          if (VIRTUAL_TRACK_SCROLLING.get()) {
258            // The VOC can ask us to redraw the canvas for any number of
259            // reasons, we're interested in the case where the canvas rect has
260            // moved (which indicates that the user has scrolled enough to
261            // warrant drawing more content). If so, we should redraw the DOM in
262            // order to keep the track nodes inside the viewport rendering in
263            // full-fat mode.
264            if (
265              this.canvasRect === undefined ||
266              !this.canvasRect.equals(canvasRect)
267            ) {
268              this.canvasRect = canvasRect;
269              m.redraw();
270            }
271          }
272        },
273      },
274      m('', {ref: TRACK_CONTAINER_REF}, trackVnodes),
275    );
276  }
277
278  oncreate(vnode: m.VnodeDOM<TrackTreeViewAttrs>) {
279    this.trash.use(
280      vnode.attrs.trace.perfDebugging.addContainer({
281        setPerfStatsEnabled: (enable: boolean) => {
282          this.perfStatsEnabled = enable;
283        },
284        renderPerfStats: () => {
285          return [
286            m(
287              '',
288              `${this.perfStats.totalTracks} tracks, ` +
289                `${this.perfStats.tracksOnCanvas} on canvas.`,
290            ),
291            m('', runningStatStr(this.perfStats.renderStats)),
292          ];
293        },
294      }),
295    );
296
297    this.onupdate(vnode);
298  }
299
300  onupdate({dom}: m.VnodeDOM<TrackTreeViewAttrs>) {
301    // Depending on the state of the filter/workspace, we sometimes have a
302    // TRACK_CONTAINER_REF element and sometimes we don't (see the view
303    // function). This means the DOM element could potentially appear/disappear
304    // or change every update cycle. This chunk of code hooks the
305    // ZonedInteractionHandler back up again if the DOM element is present,
306    // otherwise it just removes it.
307    const interactionTarget = findRef(dom, TRACK_CONTAINER_REF) ?? undefined;
308    if (interactionTarget !== this.interactions?.target) {
309      this.interactions?.[Symbol.dispose]();
310      if (!interactionTarget) {
311        this.interactions = undefined;
312      } else {
313        this.interactions = new ZonedInteractionHandler(
314          toHTMLElement(interactionTarget),
315        );
316      }
317    }
318  }
319
320  onremove() {
321    this.interactions?.[Symbol.dispose]();
322  }
323
324  private drawCanvas(
325    ctx: CanvasRenderingContext2D,
326    size: Size2D,
327    renderedTracks: ReadonlyArray<TrackView>,
328    floatingCanvasRect: Rect2D,
329    rootNode: TrackNode,
330  ) {
331    const timelineRect = new Rect2D({
332      left: TRACK_SHELL_WIDTH,
333      top: 0,
334      right: size.width,
335      bottom: size.height,
336    });
337
338    // Always grab the latest visible window and create a timescale out of
339    // it.
340    const visibleWindow = this.trace.timeline.visibleWindow;
341    const timescale = new TimeScale(visibleWindow, timelineRect);
342
343    const start = performance.now();
344
345    // Save, translate & clip the canvas to the area of the timeline.
346    using _ = canvasSave(ctx);
347    canvasClip(ctx, timelineRect);
348
349    this.drawGridLines(ctx, timescale, timelineRect);
350
351    const tracksOnCanvas = this.drawTracks(
352      renderedTracks,
353      floatingCanvasRect,
354      size,
355      ctx,
356      timelineRect,
357      visibleWindow,
358    );
359
360    renderFlows(this.trace, ctx, size, renderedTracks, rootNode, timescale);
361    this.drawHoveredNoteVertical(ctx, timescale, size);
362    this.drawHoveredCursorVertical(ctx, timescale, size);
363    this.drawWakeupVertical(ctx, timescale, size);
364    this.drawNoteVerticals(ctx, timescale, size);
365    this.drawAreaSelection(ctx, timescale, size);
366    this.updateInteractions(timelineRect, timescale, size, renderedTracks);
367
368    const renderTime = performance.now() - start;
369    this.updatePerfStats(renderTime, renderedTracks.length, tracksOnCanvas);
370  }
371
372  private drawGridLines(
373    ctx: CanvasRenderingContext2D,
374    timescale: TimeScale,
375    size: Size2D,
376  ): void {
377    ctx.strokeStyle = TRACK_BORDER_COLOR;
378    ctx.lineWidth = 1;
379
380    if (size.width > 0 && timescale.timeSpan.duration > 0n) {
381      const maxMajorTicks = getMaxMajorTicks(size.width);
382      const offset = this.trace.timeline.timestampOffset();
383      for (const {type, time} of generateTicks(
384        timescale.timeSpan.toTimeSpan(),
385        maxMajorTicks,
386        offset,
387      )) {
388        const px = Math.floor(timescale.timeToPx(time));
389        if (type === TickType.MAJOR) {
390          ctx.beginPath();
391          ctx.moveTo(px + 0.5, 0);
392          ctx.lineTo(px + 0.5, size.height);
393          ctx.stroke();
394        }
395      }
396    }
397  }
398
399  private drawTracks(
400    renderedTracks: ReadonlyArray<TrackView>,
401    floatingCanvasRect: Rect2D,
402    size: Size2D,
403    ctx: CanvasRenderingContext2D,
404    timelineRect: Rect2D,
405    visibleWindow: HighPrecisionTimeSpan,
406  ) {
407    let tracksOnCanvas = 0;
408    for (const trackView of renderedTracks) {
409      const {verticalBounds} = trackView;
410      if (
411        floatingCanvasRect.overlaps({
412          ...verticalBounds,
413          left: 0,
414          right: size.width,
415        })
416      ) {
417        trackView.drawCanvas(
418          ctx,
419          timelineRect,
420          visibleWindow,
421          this.perfStatsEnabled,
422          this.trackPerfStats,
423        );
424        ++tracksOnCanvas;
425      }
426    }
427    return tracksOnCanvas;
428  }
429
430  private updateInteractions(
431    timelineRect: Rect2D,
432    timescale: TimeScale,
433    size: Size2D,
434    renderedTracks: ReadonlyArray<TrackView>,
435  ) {
436    const trace = this.trace;
437    const areaSelection =
438      trace.selection.selection.kind === 'area' && trace.selection.selection;
439
440    assertExists(this.interactions).update([
441      shiftDragPanInteraction(trace, timelineRect, timescale),
442      areaSelection !== false && {
443        id: 'start-edit',
444        area: new Rect2D({
445          left: timescale.timeToPx(areaSelection.start) - 5,
446          right: timescale.timeToPx(areaSelection.start) + 5,
447          top: 0,
448          bottom: size.height,
449        }),
450        cursor: 'col-resize',
451        drag: {
452          cursorWhileDragging: 'col-resize',
453          onDrag: (e) => {
454            if (!this.handleDrag) {
455              this.handleDrag = new InProgressHandleDrag(
456                new HighPrecisionTime(areaSelection.end),
457              );
458            }
459            this.handleDrag.currentTime = timescale.pxToHpTime(e.dragCurrent.x);
460            trace.timeline.selectedSpan = this.handleDrag
461              .timeSpan()
462              .toTimeSpan();
463          },
464          onDragEnd: (e) => {
465            const newStartTime = timescale
466              .pxToHpTime(e.dragCurrent.x)
467              .toTime('ceil');
468            trace.selection.selectArea({
469              ...areaSelection,
470              end: Time.max(newStartTime, areaSelection.end),
471              start: Time.min(newStartTime, areaSelection.end),
472            });
473            trace.timeline.selectedSpan = undefined;
474            this.handleDrag = undefined;
475          },
476        },
477      },
478      areaSelection !== false && {
479        id: 'end-edit',
480        area: new Rect2D({
481          left: timescale.timeToPx(areaSelection.end) - 5,
482          right: timescale.timeToPx(areaSelection.end) + 5,
483          top: 0,
484          bottom: size.height,
485        }),
486        cursor: 'col-resize',
487        drag: {
488          cursorWhileDragging: 'col-resize',
489          onDrag: (e) => {
490            if (!this.handleDrag) {
491              this.handleDrag = new InProgressHandleDrag(
492                new HighPrecisionTime(areaSelection.start),
493              );
494            }
495            this.handleDrag.currentTime = timescale.pxToHpTime(e.dragCurrent.x);
496            trace.timeline.selectedSpan = this.handleDrag
497              .timeSpan()
498              .toTimeSpan();
499          },
500          onDragEnd: (e) => {
501            const newEndTime = timescale
502              .pxToHpTime(e.dragCurrent.x)
503              .toTime('ceil');
504            trace.selection.selectArea({
505              ...areaSelection,
506              end: Time.max(newEndTime, areaSelection.start),
507              start: Time.min(newEndTime, areaSelection.start),
508            });
509            trace.timeline.selectedSpan = undefined;
510            this.handleDrag = undefined;
511          },
512        },
513      },
514      {
515        id: 'area-selection',
516        area: timelineRect,
517        onClick: () => {
518          // If a track hasn't intercepted the click, treat this as a
519          // deselection event.
520          trace.selection.clear();
521        },
522        drag: {
523          minDistance: 1,
524          cursorWhileDragging: 'crosshair',
525          onDrag: (e) => {
526            if (!this.areaDrag) {
527              this.areaDrag = new InProgressAreaSelection(
528                timescale.pxToHpTime(e.dragStart.x),
529                e.dragStart.y,
530              );
531            }
532            this.areaDrag.update(e, timescale);
533            trace.timeline.selectedSpan = this.areaDrag.timeSpan().toTimeSpan();
534          },
535          onDragEnd: (e) => {
536            if (!this.areaDrag) {
537              this.areaDrag = new InProgressAreaSelection(
538                timescale.pxToHpTime(e.dragStart.x),
539                e.dragStart.y,
540              );
541            }
542            this.areaDrag?.update(e, timescale);
543
544            // Find the list of tracks that intersect this selection
545            const trackUris = findTracksInRect(
546              renderedTracks,
547              this.areaDrag.rect(timescale),
548              true,
549            )
550              .map((t) => t.uri)
551              .filter((uri) => uri !== undefined);
552
553            const timeSpan = this.areaDrag.timeSpan().toTimeSpan();
554            trace.selection.selectArea({
555              start: timeSpan.start,
556              end: timeSpan.end,
557              trackUris,
558            });
559
560            trace.timeline.selectedSpan = undefined;
561            this.areaDrag = undefined;
562          },
563        },
564      },
565      wheelNavigationInteraction(trace, timelineRect, timescale),
566    ]);
567  }
568
569  private updatePerfStats(
570    renderTime: number,
571    totalTracks: number,
572    tracksOnCanvas: number,
573  ) {
574    if (!this.perfStatsEnabled) return;
575    this.perfStats.renderStats.addValue(renderTime);
576    this.perfStats.totalTracks = totalTracks;
577    this.perfStats.tracksOnCanvas = tracksOnCanvas;
578  }
579
580  private drawAreaSelection(
581    ctx: CanvasRenderingContext2D,
582    timescale: TimeScale,
583    size: Size2D,
584  ) {
585    if (this.areaDrag) {
586      ctx.strokeStyle = SELECTION_STROKE_COLOR;
587      ctx.lineWidth = 1;
588      const rect = this.areaDrag.rect(timescale);
589      ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
590    }
591
592    if (this.handleDrag) {
593      const rect = this.handleDrag.hBounds(timescale);
594
595      ctx.strokeStyle = SELECTION_STROKE_COLOR;
596      ctx.lineWidth = 1;
597
598      ctx.beginPath();
599      ctx.moveTo(rect.left, 0);
600      ctx.lineTo(rect.left, size.height);
601      ctx.stroke();
602      ctx.closePath();
603
604      ctx.beginPath();
605      ctx.moveTo(rect.right, 0);
606      ctx.lineTo(rect.right, size.height);
607      ctx.stroke();
608      ctx.closePath();
609    }
610
611    const selection = this.trace.selection.selection;
612    if (selection.kind === 'area') {
613      const startPx = timescale.timeToPx(selection.start);
614      const endPx = timescale.timeToPx(selection.end);
615
616      ctx.strokeStyle = '#8398e6';
617      ctx.lineWidth = 2;
618
619      ctx.beginPath();
620      ctx.moveTo(startPx, 0);
621      ctx.lineTo(startPx, size.height);
622      ctx.stroke();
623      ctx.closePath();
624
625      ctx.beginPath();
626      ctx.moveTo(endPx, 0);
627      ctx.lineTo(endPx, size.height);
628      ctx.stroke();
629      ctx.closePath();
630    }
631  }
632
633  private drawHoveredCursorVertical(
634    ctx: CanvasRenderingContext2D,
635    timescale: TimeScale,
636    size: Size2D,
637  ) {
638    if (this.trace.timeline.hoverCursorTimestamp !== undefined) {
639      drawVerticalLineAtTime(
640        ctx,
641        timescale,
642        this.trace.timeline.hoverCursorTimestamp,
643        size.height,
644        `#344596`,
645      );
646    }
647  }
648
649  private drawHoveredNoteVertical(
650    ctx: CanvasRenderingContext2D,
651    timescale: TimeScale,
652    size: Size2D,
653  ) {
654    if (this.trace.timeline.hoveredNoteTimestamp !== undefined) {
655      drawVerticalLineAtTime(
656        ctx,
657        timescale,
658        this.trace.timeline.hoveredNoteTimestamp,
659        size.height,
660        `#aaa`,
661      );
662    }
663  }
664
665  private drawWakeupVertical(
666    ctx: CanvasRenderingContext2D,
667    timescale: TimeScale,
668    size: Size2D,
669  ) {
670    const selection = this.trace.selection.selection;
671    if (selection.kind === 'track_event' && selection.wakeupTs) {
672      drawVerticalLineAtTime(
673        ctx,
674        timescale,
675        selection.wakeupTs,
676        size.height,
677        `black`,
678      );
679    }
680  }
681
682  private drawNoteVerticals(
683    ctx: CanvasRenderingContext2D,
684    timescale: TimeScale,
685    size: Size2D,
686  ) {
687    // All marked areas should have semi-transparent vertical lines
688    // marking the start and end.
689    for (const note of this.trace.notes.notes.values()) {
690      if (note.noteType === 'SPAN') {
691        const transparentNoteColor =
692          'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)';
693        drawVerticalLineAtTime(
694          ctx,
695          timescale,
696          note.start,
697          size.height,
698          transparentNoteColor,
699          1,
700        );
701        drawVerticalLineAtTime(
702          ctx,
703          timescale,
704          note.end,
705          size.height,
706          transparentNoteColor,
707          1,
708        );
709      } else if (note.noteType === 'DEFAULT') {
710        drawVerticalLineAtTime(
711          ctx,
712          timescale,
713          note.timestamp,
714          size.height,
715          note.color,
716        );
717      }
718    }
719  }
720}
721
722/**
723 * Returns a list of track nodes that are contained within a given set of
724 * vertical bounds.
725 *
726 * @param renderedTracks - The list of tracks and their positions.
727 * @param bounds - The bounds in which to check.
728 * @returns - A list of tracks.
729 */
730function findTracksInRect(
731  renderedTracks: ReadonlyArray<TrackView>,
732  bounds: VerticalBounds,
733  recurseCollapsedSummaryTracks = false,
734): TrackNode[] {
735  const tracks: TrackNode[] = [];
736  for (const {node, verticalBounds} of renderedTracks) {
737    const trackRect = new Rect2D({...verticalBounds, left: 0, right: 1});
738    if (trackRect.overlaps({...bounds, left: 0, right: 1})) {
739      // Recurse all child tracks if group node is collapsed and is a summary
740      if (recurseCollapsedSummaryTracks && node.isSummary && node.collapsed) {
741        for (const childTrack of node.flatTracks) {
742          tracks.push(childTrack);
743        }
744      } else {
745        tracks.push(node);
746      }
747    }
748  }
749  return tracks;
750}
751
752// Stores an in-progress area selection.
753class InProgressAreaSelection {
754  currentTime: HighPrecisionTime;
755  currentY: number;
756
757  constructor(
758    readonly startTime: HighPrecisionTime,
759    readonly startY: number,
760  ) {
761    this.currentTime = startTime;
762    this.currentY = startY;
763  }
764
765  update(e: DragEvent, timescale: TimeScale) {
766    this.currentTime = timescale.pxToHpTime(e.dragCurrent.x);
767    this.currentY = e.dragCurrent.y;
768  }
769
770  timeSpan() {
771    return HighPrecisionTimeSpan.fromHpTimes(this.startTime, this.currentTime);
772  }
773
774  rect(timescale: TimeScale) {
775    const horizontal = timescale.hpTimeSpanToPxSpan(this.timeSpan());
776    return Rect2D.fromPoints(
777      {
778        x: horizontal.left,
779        y: this.startY,
780      },
781      {
782        x: horizontal.right,
783        y: this.currentY,
784      },
785    );
786  }
787}
788
789// Stores an in-progress handle drag.
790class InProgressHandleDrag {
791  currentTime: HighPrecisionTime;
792
793  constructor(readonly startTime: HighPrecisionTime) {
794    this.currentTime = startTime;
795  }
796
797  timeSpan() {
798    return HighPrecisionTimeSpan.fromHpTimes(this.startTime, this.currentTime);
799  }
800
801  hBounds(timescale: TimeScale): HorizontalBounds {
802    const horizontal = timescale.hpTimeSpanToPxSpan(this.timeSpan());
803    return new Rect2D({
804      ...horizontal,
805      top: 0,
806      bottom: 0,
807    });
808  }
809}
810