1// Copyright (C) 2018 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 15import {hex} from 'color-convert'; 16import m from 'mithril'; 17import {removeFalsyValues} from '../base/array_utils'; 18import {canvasClip, canvasSave} from '../base/canvas_utils'; 19import {findRef, toHTMLElement} from '../base/dom_utils'; 20import {Size2D, VerticalBounds} from '../base/geom'; 21import {assertExists} from '../base/logging'; 22import {clamp} from '../base/math_utils'; 23import {Time, TimeSpan} from '../base/time'; 24import {TimeScale} from '../base/time_scale'; 25import {featureFlags} from '../core/feature_flags'; 26import {raf} from '../core/raf_scheduler'; 27import {TrackNode} from '../public/workspace'; 28import {TRACK_BORDER_COLOR, TRACK_SHELL_WIDTH} from './css_constants'; 29import {renderFlows} from './flow_events_renderer'; 30import {generateTicks, getMaxMajorTicks, TickType} from './gridline_helper'; 31import {NotesPanel} from './notes_panel'; 32import {OverviewTimelinePanel} from './overview_timeline_panel'; 33import {PanAndZoomHandler} from './pan_and_zoom_handler'; 34import { 35 PanelContainer, 36 PanelOrGroup, 37 RenderedPanelInfo, 38} from './panel_container'; 39import {TabPanel} from './tab_panel'; 40import {TickmarkPanel} from './tickmark_panel'; 41import {TimeAxisPanel} from './time_axis_panel'; 42import {TimeSelectionPanel} from './time_selection_panel'; 43import {TrackPanel} from './track_panel'; 44import {drawVerticalLineAtTime} from './vertical_line_helper'; 45import {TraceImpl} from '../core/trace_impl'; 46import {PageWithTraceImplAttrs} from '../core/page_manager'; 47import {AppImpl} from '../core/app_impl'; 48 49const OVERVIEW_PANEL_FLAG = featureFlags.register({ 50 id: 'overviewVisible', 51 name: 'Overview Panel', 52 description: 'Show the panel providing an overview of the trace', 53 defaultValue: true, 54}); 55 56// Checks if the mousePos is within 3px of the start or end of the 57// current selected time range. 58function onTimeRangeBoundary( 59 trace: TraceImpl, 60 timescale: TimeScale, 61 mousePos: number, 62): 'START' | 'END' | null { 63 const selection = trace.selection.selection; 64 if (selection.kind === 'area') { 65 // If frontend selectedArea exists then we are in the process of editing the 66 // time range and need to use that value instead. 67 const area = trace.timeline.selectedArea 68 ? trace.timeline.selectedArea 69 : selection; 70 const start = timescale.timeToPx(area.start); 71 const end = timescale.timeToPx(area.end); 72 const startDrag = mousePos - TRACK_SHELL_WIDTH; 73 const startDistance = Math.abs(start - startDrag); 74 const endDistance = Math.abs(end - startDrag); 75 const range = 3 * window.devicePixelRatio; 76 // We might be within 3px of both boundaries but we should choose 77 // the closest one. 78 if (startDistance < range && startDistance <= endDistance) return 'START'; 79 if (endDistance < range && endDistance <= startDistance) return 'END'; 80 } 81 return null; 82} 83 84interface SelectedContainer { 85 readonly containerClass: string; 86 readonly dragStartAbsY: number; 87 readonly dragEndAbsY: number; 88} 89 90/** 91 * Top-most level component for the viewer page. Holds tracks, brush timeline, 92 * panels, and everything else that's part of the main trace viewer page. 93 */ 94export class ViewerPage implements m.ClassComponent<PageWithTraceImplAttrs> { 95 private zoomContent?: PanAndZoomHandler; 96 // Used to prevent global deselection if a pan/drag select occurred. 97 private keepCurrentSelection = false; 98 99 private overviewTimelinePanel: OverviewTimelinePanel; 100 private timeAxisPanel: TimeAxisPanel; 101 private timeSelectionPanel: TimeSelectionPanel; 102 private notesPanel: NotesPanel; 103 private tickmarkPanel: TickmarkPanel; 104 private timelineWidthPx?: number; 105 private selectedContainer?: SelectedContainer; 106 private showPanningHint = false; 107 108 private readonly PAN_ZOOM_CONTENT_REF = 'pan-and-zoom-content'; 109 110 constructor(vnode: m.CVnode<PageWithTraceImplAttrs>) { 111 this.notesPanel = new NotesPanel(vnode.attrs.trace); 112 this.timeAxisPanel = new TimeAxisPanel(vnode.attrs.trace); 113 this.timeSelectionPanel = new TimeSelectionPanel(vnode.attrs.trace); 114 this.tickmarkPanel = new TickmarkPanel(vnode.attrs.trace); 115 this.overviewTimelinePanel = new OverviewTimelinePanel(vnode.attrs.trace); 116 this.notesPanel = new NotesPanel(vnode.attrs.trace); 117 this.timeSelectionPanel = new TimeSelectionPanel(vnode.attrs.trace); 118 } 119 120 oncreate({dom, attrs}: m.CVnodeDOM<PageWithTraceImplAttrs>) { 121 const panZoomElRaw = findRef(dom, this.PAN_ZOOM_CONTENT_REF); 122 const panZoomEl = toHTMLElement(assertExists(panZoomElRaw)); 123 124 const {top: panTop} = panZoomEl.getBoundingClientRect(); 125 this.zoomContent = new PanAndZoomHandler({ 126 element: panZoomEl, 127 onPanned: (pannedPx: number) => { 128 const timeline = attrs.trace.timeline; 129 130 if (this.timelineWidthPx === undefined) return; 131 132 this.keepCurrentSelection = true; 133 const timescale = new TimeScale(timeline.visibleWindow, { 134 left: 0, 135 right: this.timelineWidthPx, 136 }); 137 const tDelta = timescale.pxToDuration(pannedPx); 138 timeline.panVisibleWindow(tDelta); 139 }, 140 onZoomed: (zoomedPositionPx: number, zoomRatio: number) => { 141 const timeline = attrs.trace.timeline; 142 // TODO(hjd): Avoid hardcoding TRACK_SHELL_WIDTH. 143 // TODO(hjd): Improve support for zooming in overview timeline. 144 const zoomPx = zoomedPositionPx - TRACK_SHELL_WIDTH; 145 const rect = dom.getBoundingClientRect(); 146 const centerPoint = zoomPx / (rect.width - TRACK_SHELL_WIDTH); 147 timeline.zoomVisibleWindow(1 - zoomRatio, centerPoint); 148 raf.scheduleCanvasRedraw(); 149 }, 150 editSelection: (currentPx: number) => { 151 if (this.timelineWidthPx === undefined) return false; 152 const timescale = new TimeScale(attrs.trace.timeline.visibleWindow, { 153 left: 0, 154 right: this.timelineWidthPx, 155 }); 156 return onTimeRangeBoundary(attrs.trace, timescale, currentPx) !== null; 157 }, 158 onSelection: ( 159 dragStartX: number, 160 dragStartY: number, 161 prevX: number, 162 currentX: number, 163 currentY: number, 164 editing: boolean, 165 ) => { 166 const traceTime = attrs.trace.traceInfo; 167 const timeline = attrs.trace.timeline; 168 169 if (this.timelineWidthPx === undefined) return; 170 171 // TODO(stevegolton): Don't get the windowSpan from globals, get it from 172 // here! 173 const {visibleWindow} = timeline; 174 const timespan = visibleWindow.toTimeSpan(); 175 this.keepCurrentSelection = true; 176 177 const timescale = new TimeScale(timeline.visibleWindow, { 178 left: 0, 179 right: this.timelineWidthPx, 180 }); 181 182 if (editing) { 183 const selection = attrs.trace.selection.selection; 184 if (selection.kind === 'area') { 185 const area = attrs.trace.timeline.selectedArea 186 ? attrs.trace.timeline.selectedArea 187 : selection; 188 let newTime = timescale 189 .pxToHpTime(currentX - TRACK_SHELL_WIDTH) 190 .toTime(); 191 // Have to check again for when one boundary crosses over the other. 192 const curBoundary = onTimeRangeBoundary( 193 attrs.trace, 194 timescale, 195 prevX, 196 ); 197 if (curBoundary == null) return; 198 const keepTime = curBoundary === 'START' ? area.end : area.start; 199 // Don't drag selection outside of current screen. 200 if (newTime < keepTime) { 201 newTime = Time.max(newTime, timespan.start); 202 } else { 203 newTime = Time.min(newTime, timespan.end); 204 } 205 // When editing the time range we always use the saved tracks, 206 // since these will not change. 207 timeline.selectArea( 208 Time.max(Time.min(keepTime, newTime), traceTime.start), 209 Time.min(Time.max(keepTime, newTime), traceTime.end), 210 selection.trackUris, 211 ); 212 } 213 } else { 214 let startPx = Math.min(dragStartX, currentX) - TRACK_SHELL_WIDTH; 215 let endPx = Math.max(dragStartX, currentX) - TRACK_SHELL_WIDTH; 216 if (startPx < 0 && endPx < 0) return; 217 startPx = clamp(startPx, 0, this.timelineWidthPx); 218 endPx = clamp(endPx, 0, this.timelineWidthPx); 219 timeline.selectArea( 220 timescale.pxToHpTime(startPx).toTime('floor'), 221 timescale.pxToHpTime(endPx).toTime('ceil'), 222 ); 223 224 const absStartY = dragStartY + panTop; 225 const absCurrentY = currentY + panTop; 226 if (this.selectedContainer === undefined) { 227 for (const c of dom.querySelectorAll('.pf-panel-container')) { 228 const {top, bottom} = c.getBoundingClientRect(); 229 if (top <= absStartY && absCurrentY <= bottom) { 230 const stack = assertExists(c.querySelector('.pf-panel-stack')); 231 const stackTop = stack.getBoundingClientRect().top; 232 this.selectedContainer = { 233 containerClass: Array.from(c.classList).filter( 234 (x) => x !== 'pf-panel-container', 235 )[0], 236 dragStartAbsY: -stackTop + absStartY, 237 dragEndAbsY: -stackTop + absCurrentY, 238 }; 239 break; 240 } 241 } 242 } else { 243 const c = assertExists( 244 dom.querySelector(`.${this.selectedContainer.containerClass}`), 245 ); 246 const {top, bottom} = c.getBoundingClientRect(); 247 const boundedCurrentY = Math.min( 248 Math.max(top, absCurrentY), 249 bottom, 250 ); 251 const stack = assertExists(c.querySelector('.pf-panel-stack')); 252 const stackTop = stack.getBoundingClientRect().top; 253 this.selectedContainer = { 254 ...this.selectedContainer, 255 dragEndAbsY: -stackTop + boundedCurrentY, 256 }; 257 } 258 this.showPanningHint = true; 259 } 260 raf.scheduleCanvasRedraw(); 261 }, 262 endSelection: (edit: boolean) => { 263 this.selectedContainer = undefined; 264 const area = attrs.trace.timeline.selectedArea; 265 // If we are editing we need to pass the current id through to ensure 266 // the marked area with that id is also updated. 267 if (edit) { 268 const selection = attrs.trace.selection.selection; 269 if (selection.kind === 'area' && area) { 270 attrs.trace.selection.selectArea({...area}); 271 } 272 } else if (area) { 273 attrs.trace.selection.selectArea({...area}); 274 } 275 // Now the selection has ended we stored the final selected area in the 276 // global state and can remove the in progress selection from the 277 // timeline. 278 attrs.trace.timeline.deselectArea(); 279 // Full redraw to color track shell. 280 raf.scheduleFullRedraw(); 281 }, 282 }); 283 } 284 285 onremove() { 286 if (this.zoomContent) this.zoomContent[Symbol.dispose](); 287 } 288 289 view({attrs}: m.CVnode<PageWithTraceImplAttrs>) { 290 const scrollingPanels = renderToplevelPanels(attrs.trace); 291 292 const result = m( 293 '.page.viewer-page', 294 m( 295 '.pan-and-zoom-content', 296 { 297 ref: this.PAN_ZOOM_CONTENT_REF, 298 onclick: () => { 299 // We don't want to deselect when panning/drag selecting. 300 if (this.keepCurrentSelection) { 301 this.keepCurrentSelection = false; 302 return; 303 } 304 attrs.trace.selection.clear(); 305 }, 306 }, 307 m( 308 '.pf-timeline-header', 309 m(PanelContainer, { 310 trace: attrs.trace, 311 className: 'header-panel-container', 312 panels: removeFalsyValues([ 313 OVERVIEW_PANEL_FLAG.get() && this.overviewTimelinePanel, 314 this.timeAxisPanel, 315 this.timeSelectionPanel, 316 this.notesPanel, 317 this.tickmarkPanel, 318 ]), 319 selectedYRange: this.getYRange('header-panel-container'), 320 }), 321 m('.scrollbar-spacer-vertical'), 322 ), 323 m(PanelContainer, { 324 trace: attrs.trace, 325 className: 'pinned-panel-container', 326 panels: AppImpl.instance.isLoadingTrace 327 ? [] 328 : attrs.trace.workspace.pinnedTracks.map((trackNode) => { 329 if (trackNode.uri) { 330 const tr = attrs.trace.tracks.getTrackRenderer(trackNode.uri); 331 return new TrackPanel({ 332 trace: attrs.trace, 333 reorderable: true, 334 node: trackNode, 335 trackRenderer: tr, 336 revealOnCreate: true, 337 indentationLevel: 0, 338 topOffsetPx: 0, 339 }); 340 } else { 341 return new TrackPanel({ 342 trace: attrs.trace, 343 node: trackNode, 344 revealOnCreate: true, 345 indentationLevel: 0, 346 topOffsetPx: 0, 347 }); 348 } 349 }), 350 renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size), 351 renderOverlay: (ctx, size, panels) => 352 renderOverlay( 353 attrs.trace, 354 ctx, 355 size, 356 panels, 357 attrs.trace.workspace.pinnedTracksNode, 358 ), 359 selectedYRange: this.getYRange('pinned-panel-container'), 360 }), 361 m(PanelContainer, { 362 trace: attrs.trace, 363 className: 'scrolling-panel-container', 364 panels: AppImpl.instance.isLoadingTrace ? [] : scrollingPanels, 365 onPanelStackResize: (width) => { 366 const timelineWidth = width - TRACK_SHELL_WIDTH; 367 this.timelineWidthPx = timelineWidth; 368 }, 369 renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size), 370 renderOverlay: (ctx, size, panels) => 371 renderOverlay( 372 attrs.trace, 373 ctx, 374 size, 375 panels, 376 attrs.trace.workspace.tracks, 377 ), 378 selectedYRange: this.getYRange('scrolling-panel-container'), 379 }), 380 ), 381 m(TabPanel, { 382 trace: attrs.trace, 383 }), 384 this.showPanningHint && m(HelpPanningNotification), 385 ); 386 387 attrs.trace.tracks.flushOldTracks(); 388 return result; 389 } 390 391 private getYRange(cls: string): VerticalBounds | undefined { 392 if (this.selectedContainer?.containerClass !== cls) { 393 return undefined; 394 } 395 const {dragStartAbsY, dragEndAbsY} = this.selectedContainer; 396 return { 397 top: Math.min(dragStartAbsY, dragEndAbsY), 398 bottom: Math.max(dragStartAbsY, dragEndAbsY), 399 }; 400 } 401} 402 403function renderUnderlay( 404 trace: TraceImpl, 405 ctx: CanvasRenderingContext2D, 406 canvasSize: Size2D, 407): void { 408 const size = { 409 width: canvasSize.width - TRACK_SHELL_WIDTH, 410 height: canvasSize.height, 411 }; 412 413 using _ = canvasSave(ctx); 414 ctx.translate(TRACK_SHELL_WIDTH, 0); 415 416 const timewindow = trace.timeline.visibleWindow; 417 const timescale = new TimeScale(timewindow, {left: 0, right: size.width}); 418 419 // Just render the gridlines - these should appear underneath all tracks 420 drawGridLines(trace, ctx, timewindow.toTimeSpan(), timescale, size); 421} 422 423function renderOverlay( 424 trace: TraceImpl, 425 ctx: CanvasRenderingContext2D, 426 canvasSize: Size2D, 427 panels: ReadonlyArray<RenderedPanelInfo>, 428 trackContainer: TrackNode, 429): void { 430 const size = { 431 width: canvasSize.width - TRACK_SHELL_WIDTH, 432 height: canvasSize.height, 433 }; 434 435 using _ = canvasSave(ctx); 436 ctx.translate(TRACK_SHELL_WIDTH, 0); 437 canvasClip(ctx, 0, 0, size.width, size.height); 438 439 // TODO(primiano): plumb the TraceImpl obj throughout the viwer page. 440 renderFlows(trace, ctx, size, panels, trackContainer); 441 442 const timewindow = trace.timeline.visibleWindow; 443 const timescale = new TimeScale(timewindow, {left: 0, right: size.width}); 444 445 renderHoveredNoteVertical(trace, ctx, timescale, size); 446 renderHoveredCursorVertical(trace, ctx, timescale, size); 447 renderWakeupVertical(trace, ctx, timescale, size); 448 renderNoteVerticals(trace, ctx, timescale, size); 449} 450 451// Render the toplevel "scrolling" tracks and track groups 452function renderToplevelPanels(trace: TraceImpl): PanelOrGroup[] { 453 return renderNodes(trace, trace.workspace.children, 0, 0); 454} 455 456// Given a list of tracks and a filter term, return a list pf panels filtered by 457// the filter term 458function renderNodes( 459 trace: TraceImpl, 460 nodes: ReadonlyArray<TrackNode>, 461 indent: number, 462 topOffsetPx: number, 463): PanelOrGroup[] { 464 return nodes.flatMap((node) => { 465 if (node.headless) { 466 // Render children as if this node doesn't exist 467 return renderNodes(trace, node.children, indent, topOffsetPx); 468 } else if (node.children.length === 0) { 469 return renderTrackPanel(trace, node, indent, topOffsetPx); 470 } else { 471 const headerPanel = renderTrackPanel(trace, node, indent, topOffsetPx); 472 const isSticky = node.isSummary; 473 const nextTopOffsetPx = isSticky 474 ? topOffsetPx + headerPanel.heightPx 475 : topOffsetPx; 476 return { 477 kind: 'group', 478 collapsed: node.collapsed, 479 header: headerPanel, 480 sticky: isSticky, // && node.collapsed?? 481 topOffsetPx, 482 childPanels: node.collapsed 483 ? [] 484 : renderNodes(trace, node.children, indent + 1, nextTopOffsetPx), 485 }; 486 } 487 }); 488} 489 490function renderTrackPanel( 491 trace: TraceImpl, 492 trackNode: TrackNode, 493 indent: number, 494 topOffsetPx: number, 495) { 496 let tr = undefined; 497 if (trackNode.uri) { 498 tr = trace.tracks.getTrackRenderer(trackNode.uri); 499 } 500 return new TrackPanel({ 501 trace, 502 node: trackNode, 503 trackRenderer: tr, 504 indentationLevel: indent, 505 topOffsetPx, 506 }); 507} 508 509export function drawGridLines( 510 trace: TraceImpl, 511 ctx: CanvasRenderingContext2D, 512 timespan: TimeSpan, 513 timescale: TimeScale, 514 size: Size2D, 515): void { 516 ctx.strokeStyle = TRACK_BORDER_COLOR; 517 ctx.lineWidth = 1; 518 519 if (size.width > 0 && timespan.duration > 0n) { 520 const maxMajorTicks = getMaxMajorTicks(size.width); 521 const offset = trace.timeline.timestampOffset(); 522 for (const {type, time} of generateTicks(timespan, maxMajorTicks, offset)) { 523 const px = Math.floor(timescale.timeToPx(time)); 524 if (type === TickType.MAJOR) { 525 ctx.beginPath(); 526 ctx.moveTo(px + 0.5, 0); 527 ctx.lineTo(px + 0.5, size.height); 528 ctx.stroke(); 529 } 530 } 531 } 532} 533 534export function renderHoveredCursorVertical( 535 trace: TraceImpl, 536 ctx: CanvasRenderingContext2D, 537 timescale: TimeScale, 538 size: Size2D, 539) { 540 if (trace.timeline.hoverCursorTimestamp !== undefined) { 541 drawVerticalLineAtTime( 542 ctx, 543 timescale, 544 trace.timeline.hoverCursorTimestamp, 545 size.height, 546 `#344596`, 547 ); 548 } 549} 550 551export function renderHoveredNoteVertical( 552 trace: TraceImpl, 553 ctx: CanvasRenderingContext2D, 554 timescale: TimeScale, 555 size: Size2D, 556) { 557 if (trace.timeline.hoveredNoteTimestamp !== undefined) { 558 drawVerticalLineAtTime( 559 ctx, 560 timescale, 561 trace.timeline.hoveredNoteTimestamp, 562 size.height, 563 `#aaa`, 564 ); 565 } 566} 567 568export function renderWakeupVertical( 569 trace: TraceImpl, 570 ctx: CanvasRenderingContext2D, 571 timescale: TimeScale, 572 size: Size2D, 573) { 574 const selection = trace.selection.selection; 575 if (selection.kind === 'track_event' && selection.wakeupTs) { 576 drawVerticalLineAtTime( 577 ctx, 578 timescale, 579 selection.wakeupTs, 580 size.height, 581 `black`, 582 ); 583 } 584} 585 586export function renderNoteVerticals( 587 trace: TraceImpl, 588 ctx: CanvasRenderingContext2D, 589 timescale: TimeScale, 590 size: Size2D, 591) { 592 // All marked areas should have semi-transparent vertical lines 593 // marking the start and end. 594 for (const note of trace.notes.notes.values()) { 595 if (note.noteType === 'SPAN') { 596 const transparentNoteColor = 597 'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)'; 598 drawVerticalLineAtTime( 599 ctx, 600 timescale, 601 note.start, 602 size.height, 603 transparentNoteColor, 604 1, 605 ); 606 drawVerticalLineAtTime( 607 ctx, 608 timescale, 609 note.end, 610 size.height, 611 transparentNoteColor, 612 1, 613 ); 614 } else if (note.noteType === 'DEFAULT') { 615 drawVerticalLineAtTime( 616 ctx, 617 timescale, 618 note.timestamp, 619 size.height, 620 note.color, 621 ); 622 } 623 } 624} 625 626class HelpPanningNotification implements m.ClassComponent { 627 private readonly PANNING_HINT_KEY = 'dismissedPanningHint'; 628 private dismissed = localStorage.getItem(this.PANNING_HINT_KEY) === 'true'; 629 630 view() { 631 // Do not show the help notification in embedded mode because local storage 632 // does not persist for iFrames. The host is responsible for communicating 633 // to users that they can press '?' for help. 634 if (AppImpl.instance.embeddedMode || this.dismissed) { 635 return; 636 } 637 return m( 638 '.helpful-hint', 639 m( 640 '.hint-text', 641 'Are you trying to pan? Use the WASD keys or hold shift to click ' + 642 "and drag. Press '?' for more help.", 643 ), 644 m( 645 'button.hint-dismiss-button', 646 { 647 onclick: () => { 648 this.dismissed = true; 649 localStorage.setItem(this.PANNING_HINT_KEY, 'true'); 650 raf.scheduleFullRedraw(); 651 }, 652 }, 653 'Dismiss', 654 ), 655 ); 656 } 657} 658