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