1// Copyright (C) 2021 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 {assertExists} from '../../base/logging'; 16import {clamp, floatEqual} from '../../base/math_utils'; 17import {Time, time} from '../../base/time'; 18import {exists} from '../../base/utils'; 19import { 20 drawIncompleteSlice, 21 drawTrackHoverTooltip, 22} from '../../base/canvas_utils'; 23import {cropText} from '../../base/string_utils'; 24import {colorCompare} from '../../base/color'; 25import {UNEXPECTED_PINK} from '../colorizer'; 26import {featureFlags} from '../../core/feature_flags'; 27import {raf} from '../../core/raf_scheduler'; 28import {TrackRenderer} from '../../public/track'; 29import {Slice} from '../../public/track'; 30import {LONG, NUM} from '../../trace_processor/query_result'; 31import {checkerboardExcept} from '../checkerboard'; 32import {BUCKETS_PER_PIXEL, CacheKey} from './timeline_cache'; 33import {uuidv4Sql} from '../../base/uuid'; 34import {AsyncDisposableStack} from '../../base/disposable_stack'; 35import {TrackMouseEvent, TrackRenderContext} from '../../public/track'; 36import {Point2D, VerticalBounds} from '../../base/geom'; 37import {Trace} from '../../public/trace'; 38 39// The common class that underpins all tracks drawing slices. 40 41export const SLICE_FLAGS_INCOMPLETE = 1; 42export const SLICE_FLAGS_INSTANT = 2; 43 44// Slices smaller than this don't get any text: 45const SLICE_MIN_WIDTH_FOR_TEXT_PX = 5; 46const SLICE_MIN_WIDTH_PX = 1 / BUCKETS_PER_PIXEL; 47const SLICE_MIN_WIDTH_FADED_PX = 0.1; 48 49const CHEVRON_WIDTH_PX = 10; 50const DEFAULT_SLICE_COLOR = UNEXPECTED_PINK; 51const INCOMPLETE_SLICE_WIDTH_PX = 20; 52 53export const CROP_INCOMPLETE_SLICE_FLAG = featureFlags.register({ 54 id: 'cropIncompleteSlice', 55 name: 'Crop incomplete slices', 56 description: 'Display incomplete slices in short form', 57 defaultValue: false, 58}); 59 60export const FADE_THIN_SLICES_FLAG = featureFlags.register({ 61 id: 'fadeThinSlices', 62 name: 'Fade thin slices', 63 description: 'Display sub-pixel slices in a faded way', 64 defaultValue: false, 65}); 66 67// Exposed and standalone to allow for testing without making this 68// visible to subclasses. 69function filterVisibleSlices<S extends Slice>( 70 slices: S[], 71 start: time, 72 end: time, 73): S[] { 74 // Here we aim to reduce the number of slices we have to draw 75 // by ignoring those that are not visible. A slice is visible iff: 76 // slice.endNsQ >= start && slice.startNsQ <= end 77 // It's allowable to include slices which aren't visible but we 78 // must not exclude visible slices. 79 // We could filter this.slices using this condition but since most 80 // often we should have the case where there are: 81 // - First a bunch of non-visible slices to the left of the viewport 82 // - Then a bunch of visible slices within the viewport 83 // - Finally a second bunch of non-visible slices to the right of the 84 // viewport. 85 // It seems more sensible to identify the left-most and right-most 86 // visible slices then 'slice' to select these slices and everything 87 // between. 88 89 // We do not need to handle non-ending slices (where dur = -1 90 // but the slice is drawn as 'infinite' length) as this is handled 91 // by a special code path. See 'incomplete' in maybeRequestData. 92 93 // While the slices are guaranteed to be ordered by timestamp we must 94 // consider async slices (which are not perfectly nested). This is to 95 // say if we see slice A then B it is guaranteed the A.start <= B.start 96 // but there is no guarantee that (A.end < B.start XOR A.end >= B.end). 97 // Due to this is not possible to use binary search to find the first 98 // visible slice. Consider the following situation: 99 // start V V end 100 // AAA CCC DDD EEEEEEE 101 // BBBBBBBBBBBB GGG 102 // FFFFFFF 103 // B is visible but A and C are not. In general there could be 104 // arbitrarily many slices between B and D which are not visible. 105 106 // You could binary search to find D (i.e. the first slice which 107 // starts after |start|) then work backwards to find B. 108 // The last visible slice is simpler, since the slices are sorted 109 // by timestamp you can binary search for the last slice such 110 // that slice.start <= end. 111 112 // One specific edge case that will come up often is when: 113 // For all slice in slices: slice.startNsQ > end (e.g. all slices are 114 // to the right). 115 // Since the slices are sorted by startS we can check this easily: 116 const maybeFirstSlice: S | undefined = slices[0]; 117 if (exists(maybeFirstSlice) && maybeFirstSlice.startNs > end) { 118 return []; 119 } 120 121 return slices.filter((slice) => slice.startNs <= end && slice.endNs >= start); 122} 123 124export const filterVisibleSlicesForTesting = filterVisibleSlices; 125 126// The minimal set of columns that any table/view must expose to render tracks. 127// Note: this class assumes that, at the SQL level, slices are: 128// - Not temporally overlapping (unless they are nested at inner depth). 129// - Strictly stacked (i.e. a slice at depth N+1 cannot be larger than any 130// slices at depth 0..N. 131// If you need temporally overlapping slices, look at AsyncSliceTrack, which 132// merges several tracks into one visual track. 133export const BASE_ROW = { 134 id: NUM, // The slice ID, for selection / lookups. 135 ts: LONG, // True ts in nanoseconds. 136 dur: LONG, // True duration in nanoseconds. -1 = incomplete, 0 = instant. 137 tsQ: LONG, // Quantized start time in nanoseconds. 138 durQ: LONG, // Quantized duration in nanoseconds. 139 depth: NUM, // Vertical depth. 140}; 141 142export type BaseRow = typeof BASE_ROW; 143 144// These properties change @ 60FPS and shouldn't be touched by the subclass. 145// since the Impl doesn't see every frame attempting to reason on them in a 146// subclass will run in to issues. 147interface SliceInternal { 148 x: number; 149 w: number; 150} 151 152// We use this to avoid exposing subclasses to the properties that live on 153// SliceInternal. Within BaseSliceTrack the underlying storage and private 154// methods use CastInternal<S> (i.e. whatever the subclass requests 155// plus our implementation fields) but when we call 'virtual' methods that 156// the subclass should implement we use just S hiding x & w. 157type CastInternal<S extends Slice> = S & SliceInternal; 158 159export interface SliceLayout { 160 // Vertical spacing between slices and track. 161 readonly padding: number; 162 163 // Spacing between rows. 164 readonly rowGap: number; 165 166 // Height of each slice (i.e. height of each row). 167 readonly sliceHeight: number; 168 169 // Title font size. 170 readonly titleSizePx: number; 171 172 // Subtitle font size. 173 readonly subtitleSizePx: number; 174} 175 176export abstract class BaseSliceTrack< 177 SliceT extends Slice = Slice, 178 RowT extends BaseRow = BaseRow, 179> implements TrackRenderer 180{ 181 protected readonly sliceLayout: SliceLayout; 182 protected trackUuid = uuidv4Sql(); 183 184 // This is the over-skirted cached bounds: 185 private slicesKey: CacheKey = CacheKey.zero(); 186 187 // This is the currently 'cached' slices: 188 private slices = new Array<CastInternal<SliceT>>(); 189 190 // Incomplete slices (dur = -1). Rather than adding a lot of logic to 191 // the SQL queries to handle this case we materialise them one off 192 // then unconditionally render them. This should be efficient since 193 // there are at most |depth| slices. 194 private incomplete = new Array<CastInternal<SliceT>>(); 195 196 // The currently selected slice. 197 // TODO(hjd): We should fetch this from the underlying data rather 198 // than just remembering it when we see it. 199 private selectedSlice?: CastInternal<SliceT>; 200 201 private extraSqlColumns: string[]; 202 203 private charWidth = -1; 204 private hoverPos?: Point2D; 205 protected hoveredSlice?: SliceT; 206 private hoverTooltip: string[] = []; 207 private maxDataDepth = 0; 208 209 // Computed layout. 210 private computedTrackHeight = 0; 211 212 private readonly trash: AsyncDisposableStack; 213 214 // Extension points. 215 // Each extension point should take a dedicated argument type (e.g., 216 // OnSliceOverArgs {slice?: S}) so it makes future extensions 217 // non-API-breaking (e.g. if we want to add the X position). 218 219 // onInit hook lets you do asynchronous set up e.g. creating a table 220 // etc. We guarantee that this will be resolved before doing any 221 // queries using the result of getSqlSource(). All persistent 222 // state in trace_processor should be cleaned up when dispose is 223 // called on the returned hook. In the common case of where 224 // the data for this track is a SQL fragment this does nothing. 225 async onInit(): Promise<AsyncDisposable | void> {} 226 227 // This should be an SQL expression returning all the columns listed 228 // mentioned by getRowSpec() excluding tsq and tsqEnd. 229 // For example you might return an SQL expression of the form: 230 // `select id, ts, dur, 0 as depth from foo where bar = 'baz'` 231 abstract getSqlSource(): string; 232 233 // This should return a fast sql select statement or table name with slightly 234 // relaxed constraints compared to getSqlSource(). It's the query used to join 235 // the results of the mipmap table in order to fetch the real `ts` and `dur` 236 // from the quantized slices. 237 // 238 // This query only needs to provide `ts`, `dur` and `id` columns (no depth 239 // required), and it doesn't even need to be filtered (because it's just used 240 // in the join), it just needs to be fast! This means that most of the time 241 // this can just be the root table of wherever this track comes from (e.g. the 242 // slice table). 243 // 244 // If in doubt, this can just return the same query as getSqlSource(), which 245 // is perfectly valid, however you may be leaving some performance on the 246 // table. 247 // 248 // TODO(stevegolton): If we merge BST with DST, this abstraction can be 249 // avoided. 250 abstract getJoinSqlSource(): string; 251 252 onSliceOver(_args: OnSliceOverArgs<SliceT>): void {} 253 onSliceOut(_args: OnSliceOutArgs<SliceT>): void {} 254 255 // By default, clicked slices create track selections 256 onSliceClick({slice}: OnSliceClickArgs<Slice>): void { 257 this.trace.selection.selectTrackEvent(this.uri, slice.id); 258 } 259 260 // The API contract of onUpdatedSlices() is: 261 // - I am going to draw these slices in the near future. 262 // - I am not going to draw any slice that I haven't passed here first. 263 // - This is guaranteed to be called at least once on every global 264 // state update. 265 // - This is NOT guaranteed to be called on every frame. For instance you 266 // cannot use this to do some colour-based animation. 267 onUpdatedSlices(slices: Array<SliceT>): void { 268 this.highlightHoveredAndSameTitle(slices); 269 } 270 271 // TODO(hjd): Remove. 272 drawSchedLatencyArrow( 273 _: CanvasRenderingContext2D, 274 _selectedSlice?: SliceT, 275 ): void {} 276 277 constructor( 278 protected readonly trace: Trace, 279 protected readonly uri: string, 280 protected readonly rowSpec: RowT, 281 sliceLayout: Partial<SliceLayout> = {}, 282 protected readonly depthGuess: number = 0, 283 protected readonly instantWidthPx: number = CHEVRON_WIDTH_PX, 284 ) { 285 // Work out the extra columns. 286 // This is the union of the embedder-defined columns and the base columns 287 // we know about (ts, dur, ...). 288 const allCols = Object.keys(rowSpec); 289 const baseCols = Object.keys(BASE_ROW); 290 this.extraSqlColumns = allCols.filter((key) => !baseCols.includes(key)); 291 292 this.trash = new AsyncDisposableStack(); 293 294 this.sliceLayout = { 295 padding: sliceLayout.padding ?? 3, 296 rowGap: sliceLayout.rowGap ?? 0, 297 sliceHeight: sliceLayout.sliceHeight ?? 18, 298 titleSizePx: sliceLayout.titleSizePx ?? 12, 299 subtitleSizePx: sliceLayout.subtitleSizePx ?? 8, 300 }; 301 } 302 303 onFullRedraw(): void { 304 // Give a chance to the embedder to change colors and other stuff. 305 this.onUpdatedSlices(this.slices); 306 this.onUpdatedSlices(this.incomplete); 307 if (this.selectedSlice !== undefined) { 308 this.onUpdatedSlices([this.selectedSlice]); 309 } 310 } 311 312 private getTitleFont(): string { 313 const size = this.sliceLayout.titleSizePx; 314 return `${size}px Roboto Condensed`; 315 } 316 317 private getSubtitleFont(): string { 318 const size = this.sliceLayout.subtitleSizePx; 319 return `${size}px Roboto Condensed`; 320 } 321 322 private getTableName(): string { 323 return `slice_${this.trackUuid}`; 324 } 325 326 async onCreate(): Promise<void> { 327 const result = await this.onInit(); 328 result && this.trash.use(result); 329 330 // TODO(hjd): Consider case below: 331 // raw: 332 // 0123456789 333 // [A did not end) 334 // [B ] 335 // 336 // 337 // quantised: 338 // 0123456789 339 // [A did not end) 340 // [ B ] 341 // Does it lead to odd results? 342 const extraCols = this.extraSqlColumns.join(','); 343 let queryRes; 344 if (CROP_INCOMPLETE_SLICE_FLAG.get()) { 345 queryRes = await this.engine.query(` 346 select 347 depth, 348 ts as tsQ, 349 ts, 350 -1 as durQ, 351 -1 as dur, 352 id 353 ${extraCols ? ',' + extraCols : ''} 354 from (${this.getSqlSource()}) 355 where dur = -1; 356 `); 357 } else { 358 queryRes = await this.engine.query(` 359 select 360 depth, 361 max(ts) as tsQ, 362 ts, 363 -1 as durQ, 364 -1 as dur, 365 id 366 ${extraCols ? ',' + extraCols : ''} 367 from (${this.getSqlSource()}) 368 group by 1 369 having dur = -1 370 `); 371 } 372 const incomplete = new Array<CastInternal<SliceT>>(queryRes.numRows()); 373 const it = queryRes.iter(this.rowSpec); 374 for (let i = 0; it.valid(); it.next(), ++i) { 375 incomplete[i] = this.rowToSliceInternal(it); 376 } 377 this.onUpdatedSlices(incomplete); 378 this.incomplete = incomplete; 379 380 await this.engine.query(` 381 create virtual table ${this.getTableName()} 382 using __intrinsic_slice_mipmap(( 383 select id, ts, dur, depth 384 from (${this.getSqlSource()}) 385 where dur != -1 386 )); 387 `); 388 389 this.trash.defer(async () => { 390 await this.engine.tryQuery(`drop table ${this.getTableName()}`); 391 }); 392 } 393 394 async onUpdate({visibleWindow, size}: TrackRenderContext): Promise<void> { 395 const windowSizePx = Math.max(1, size.width); 396 const timespan = visibleWindow.toTimeSpan(); 397 const rawSlicesKey = CacheKey.create( 398 timespan.start, 399 timespan.end, 400 windowSizePx, 401 ); 402 403 // If the visible time range is outside the cached area, requests 404 // asynchronously new data from the SQL engine. 405 await this.maybeRequestData(rawSlicesKey); 406 } 407 408 render({ctx, size, visibleWindow, timescale}: TrackRenderContext): void { 409 // TODO(hjd): fonts and colors should come from the CSS and not hardcoded 410 // here. 411 412 // In any case, draw whatever we have (which might be stale/incomplete). 413 let charWidth = this.charWidth; 414 if (charWidth < 0) { 415 // TODO(hjd): Centralize font measurement/invalidation. 416 ctx.font = this.getTitleFont(); 417 charWidth = this.charWidth = ctx.measureText('dbpqaouk').width / 8; 418 } 419 420 // Filter only the visible slices. |this.slices| will have more slices than 421 // needed because maybeRequestData() over-fetches to handle small pan/zooms. 422 // We don't want to waste time drawing slices that are off screen. 423 const vizSlices = this.getVisibleSlicesInternal( 424 visibleWindow.start.toTime('floor'), 425 visibleWindow.end.toTime('ceil'), 426 ); 427 428 const selection = this.trace.selection.selection; 429 const selectedId = 430 selection.kind === 'track_event' && selection.trackUri === this.uri 431 ? selection.eventId 432 : undefined; 433 434 if (selectedId === undefined) { 435 this.selectedSlice = undefined; 436 } 437 let discoveredSelection: CastInternal<SliceT> | undefined; 438 439 // Believe it or not, doing 4xO(N) passes is ~2x faster than trying to draw 440 // everything in one go. The key is that state changes operations on the 441 // canvas (e.g., color, fonts) dominate any number crunching we do in JS. 442 443 const sliceHeight = this.sliceLayout.sliceHeight; 444 const padding = this.sliceLayout.padding; 445 const rowSpacing = this.sliceLayout.rowGap; 446 447 // First pass: compute geometry of slices. 448 449 // pxEnd is the last visible pixel in the visible viewport. Drawing 450 // anything < 0 or > pxEnd doesn't produce any visible effect as it goes 451 // beyond the visible portion of the canvas. 452 const pxEnd = size.width; 453 454 for (const slice of vizSlices) { 455 // Compute the basic geometry for any visible slice, even if only 456 // partially visible. This might end up with a negative x if the 457 // slice starts before the visible time or with a width that overflows 458 // pxEnd. 459 slice.x = timescale.timeToPx(slice.startNs); 460 slice.w = timescale.durationToPx(slice.durNs); 461 462 if (slice.flags & SLICE_FLAGS_INSTANT) { 463 // In the case of an instant slice, set the slice geometry on the 464 // bounding box that will contain the chevron. 465 slice.x -= this.instantWidthPx / 2; 466 slice.w = this.instantWidthPx; 467 } else if (slice.flags & SLICE_FLAGS_INCOMPLETE) { 468 let widthPx; 469 if (CROP_INCOMPLETE_SLICE_FLAG.get()) { 470 widthPx = 471 slice.x > 0 472 ? Math.min(pxEnd, INCOMPLETE_SLICE_WIDTH_PX) 473 : Math.max(0, INCOMPLETE_SLICE_WIDTH_PX + slice.x); 474 slice.x = Math.max(slice.x, 0); 475 } else { 476 slice.x = Math.max(slice.x, 0); 477 widthPx = pxEnd - slice.x; 478 } 479 slice.w = widthPx; 480 } else { 481 // If the slice is an actual slice, intersect the slice geometry with 482 // the visible viewport (this affects only the first and last slice). 483 // This is so that text is always centered even if we are zoomed in. 484 // Visually if we have 485 // [ visible viewport ] 486 // [ slice ] 487 // The resulting geometry will be: 488 // [slice] 489 // So that the slice title stays within the visible region. 490 const sliceVizLimit = Math.min(slice.x + slice.w, pxEnd); 491 slice.x = Math.max(slice.x, 0); 492 slice.w = sliceVizLimit - slice.x; 493 } 494 495 if (selectedId === slice.id) { 496 discoveredSelection = slice; 497 } 498 } 499 500 // Second pass: fill slices by color. 501 const vizSlicesByColor = vizSlices.slice(); 502 vizSlicesByColor.sort((a, b) => 503 colorCompare(a.colorScheme.base, b.colorScheme.base), 504 ); 505 let lastColor = undefined; 506 for (const slice of vizSlicesByColor) { 507 const color = slice.isHighlighted 508 ? slice.colorScheme.variant.cssString 509 : slice.colorScheme.base.cssString; 510 if (color !== lastColor) { 511 lastColor = color; 512 ctx.fillStyle = color; 513 } 514 const y = padding + slice.depth * (sliceHeight + rowSpacing); 515 if (slice.flags & SLICE_FLAGS_INSTANT) { 516 this.drawChevron(ctx, slice.x, y, sliceHeight); 517 } else if (slice.flags & SLICE_FLAGS_INCOMPLETE) { 518 const w = CROP_INCOMPLETE_SLICE_FLAG.get() 519 ? slice.w 520 : Math.max(slice.w - 2, 2); 521 drawIncompleteSlice( 522 ctx, 523 slice.x, 524 y, 525 w, 526 sliceHeight, 527 !CROP_INCOMPLETE_SLICE_FLAG.get(), 528 ); 529 } else { 530 const w = Math.max( 531 slice.w, 532 FADE_THIN_SLICES_FLAG.get() 533 ? SLICE_MIN_WIDTH_FADED_PX 534 : SLICE_MIN_WIDTH_PX, 535 ); 536 ctx.fillRect(slice.x, y, w, sliceHeight); 537 } 538 } 539 540 // Pass 2.5: Draw fillRatio light section. 541 ctx.fillStyle = `#FFFFFF50`; 542 for (const slice of vizSlicesByColor) { 543 // Can't draw fill ratio on incomplete or instant slices. 544 if (slice.flags & (SLICE_FLAGS_INCOMPLETE | SLICE_FLAGS_INSTANT)) { 545 continue; 546 } 547 548 // Clamp fillRatio between 0.0 -> 1.0 549 const fillRatio = clamp(slice.fillRatio, 0, 1); 550 551 // Don't draw anything if the fill ratio is 1.0ish 552 if (floatEqual(fillRatio, 1)) { 553 continue; 554 } 555 556 // Work out the width of the light section 557 const sliceDrawWidth = Math.max(slice.w, SLICE_MIN_WIDTH_PX); 558 const lightSectionDrawWidth = sliceDrawWidth * (1 - fillRatio); 559 560 // Don't draw anything if the light section is smaller than 1 px 561 if (lightSectionDrawWidth < 1) { 562 continue; 563 } 564 565 const y = padding + slice.depth * (sliceHeight + rowSpacing); 566 const x = slice.x + (sliceDrawWidth - lightSectionDrawWidth); 567 ctx.fillRect(x, y, lightSectionDrawWidth, sliceHeight); 568 } 569 570 // Third pass, draw the titles (e.g., process name for sched slices). 571 ctx.textAlign = 'center'; 572 ctx.font = this.getTitleFont(); 573 ctx.textBaseline = 'middle'; 574 for (const slice of vizSlices) { 575 if ( 576 slice.flags & SLICE_FLAGS_INSTANT || 577 !slice.title || 578 slice.w < SLICE_MIN_WIDTH_FOR_TEXT_PX 579 ) { 580 continue; 581 } 582 583 // Change the title color dynamically depending on contrast. 584 const textColor = slice.isHighlighted 585 ? slice.colorScheme.textVariant 586 : slice.colorScheme.textBase; 587 ctx.fillStyle = textColor.cssString; 588 const title = cropText(slice.title, charWidth, slice.w); 589 const rectXCenter = slice.x + slice.w / 2; 590 const y = padding + slice.depth * (sliceHeight + rowSpacing); 591 const yDiv = slice.subTitle ? 3 : 2; 592 const yMidPoint = Math.floor(y + sliceHeight / yDiv) + 0.5; 593 ctx.fillText(title, rectXCenter, yMidPoint); 594 } 595 596 // Fourth pass, draw the subtitles (e.g., thread name for sched slices). 597 ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; 598 ctx.font = this.getSubtitleFont(); 599 for (const slice of vizSlices) { 600 if ( 601 slice.w < SLICE_MIN_WIDTH_FOR_TEXT_PX || 602 !slice.subTitle || 603 slice.flags & SLICE_FLAGS_INSTANT 604 ) { 605 continue; 606 } 607 const rectXCenter = slice.x + slice.w / 2; 608 const subTitle = cropText(slice.subTitle, charWidth, slice.w); 609 const y = padding + slice.depth * (sliceHeight + rowSpacing); 610 const yMidPoint = Math.ceil(y + (sliceHeight * 2) / 3) + 1.5; 611 ctx.fillText(subTitle, rectXCenter, yMidPoint); 612 } 613 614 // Here we need to ensure we never draw a slice that hasn't been 615 // updated via the math above so we don't use this.selectedSlice 616 // directly. 617 if (discoveredSelection !== undefined) { 618 this.selectedSlice = discoveredSelection; 619 620 // Draw a thicker border around the selected slice (or chevron). 621 const slice = discoveredSelection; 622 const color = slice.colorScheme; 623 const y = padding + slice.depth * (sliceHeight + rowSpacing); 624 ctx.strokeStyle = color.base.setHSL({s: 100, l: 10}).cssString; 625 ctx.beginPath(); 626 const THICKNESS = 3; 627 ctx.lineWidth = THICKNESS; 628 ctx.strokeRect( 629 slice.x, 630 y - THICKNESS / 2, 631 slice.w, 632 sliceHeight + THICKNESS, 633 ); 634 ctx.closePath(); 635 } 636 637 // If the cached trace slices don't fully cover the visible time range, 638 // show a gray rectangle with a "Loading..." label. 639 checkerboardExcept( 640 ctx, 641 this.getHeight(), 642 0, 643 size.width, 644 timescale.timeToPx(this.slicesKey.start), 645 timescale.timeToPx(this.slicesKey.end), 646 ); 647 648 // TODO(hjd): Remove this. 649 // The only thing this does is drawing the sched latency arrow. We should 650 // have some abstraction for that arrow (ideally the same we'd use for 651 // flows). 652 this.drawSchedLatencyArrow(ctx, this.selectedSlice); 653 654 // If a slice is hovered, draw the tooltip. 655 const tooltip = this.hoverTooltip; 656 if ( 657 this.hoveredSlice !== undefined && 658 tooltip.length > 0 && 659 this.hoverPos !== undefined 660 ) { 661 if (tooltip.length === 1) { 662 drawTrackHoverTooltip(ctx, this.hoverPos, size, tooltip[0]); 663 } else { 664 drawTrackHoverTooltip(ctx, this.hoverPos, size, tooltip[0], tooltip[1]); 665 } 666 } // if (hoveredSlice) 667 } 668 669 async onDestroy(): Promise<void> { 670 await this.trash.asyncDispose(); 671 } 672 673 // This method figures out if the visible window is outside the bounds of 674 // the cached data and if so issues new queries (i.e. sorta subsumes the 675 // onBoundsChange). 676 private async maybeRequestData(rawSlicesKey: CacheKey) { 677 if (rawSlicesKey.isCoveredBy(this.slicesKey)) { 678 return; // We have the data already, no need to re-query 679 } 680 681 // Determine the cache key: 682 const slicesKey = rawSlicesKey.normalize(); 683 if (!rawSlicesKey.isCoveredBy(slicesKey)) { 684 throw new Error( 685 `Normalization error ${slicesKey.toString()} ${rawSlicesKey.toString()}`, 686 ); 687 } 688 689 // Here convert each row to a Slice. We do what we can do 690 // generically in the base class, and delegate the rest to the impl 691 // via that rowToSlice() abstract call. 692 const slices = new Array<CastInternal<SliceT>>(); 693 694 // The mipmap virtual table will error out when passed a 0 length time span. 695 const resolution = slicesKey.bucketSize; 696 const extraCols = this.extraSqlColumns.join(','); 697 const queryRes = await this.engine.query(` 698 SELECT 699 (z.ts / ${resolution}) * ${resolution} as tsQ, 700 ((z.dur + ${resolution - 1n}) / ${resolution}) * ${resolution} as durQ, 701 s.ts as ts, 702 s.dur as dur, 703 s.id, 704 z.depth 705 ${extraCols ? ',' + extraCols : ''} 706 FROM ${this.getTableName()}( 707 ${slicesKey.start}, 708 ${slicesKey.end}, 709 ${resolution} 710 ) z 711 CROSS JOIN (${this.getJoinSqlSource()}) s using (id) 712 `); 713 714 const it = queryRes.iter(this.rowSpec); 715 716 let maxDataDepth = this.maxDataDepth; 717 for (let i = 0; it.valid(); it.next(), ++i) { 718 if (it.dur === -1n) { 719 continue; 720 } 721 722 maxDataDepth = Math.max(maxDataDepth, it.depth); 723 // Construct the base slice. The Impl will construct and return 724 // the full derived T["slice"] (e.g. CpuSlice) in the 725 // rowToSlice() method. 726 slices.push(this.rowToSliceInternal(it)); 727 } 728 for (const incomplete of this.incomplete) { 729 maxDataDepth = Math.max(maxDataDepth, incomplete.depth); 730 } 731 this.maxDataDepth = maxDataDepth; 732 733 this.slicesKey = slicesKey; 734 this.onUpdatedSlices(slices); 735 this.slices = slices; 736 737 raf.scheduleCanvasRedraw(); 738 } 739 740 private rowToSliceInternal(row: RowT): CastInternal<SliceT> { 741 const slice = this.rowToSlice(row); 742 743 // If this is a more updated version of the selected slice throw 744 // away the old one. 745 if (this.selectedSlice?.id === slice.id) { 746 this.selectedSlice = undefined; 747 } 748 749 return { 750 ...slice, 751 x: -1, 752 w: -1, 753 }; 754 } 755 756 protected abstract rowToSlice(row: RowT): SliceT; 757 758 protected rowToSliceBase(row: RowT): Slice { 759 let flags = 0; 760 if (row.dur === -1n) { 761 flags |= SLICE_FLAGS_INCOMPLETE; 762 } else if (row.dur === 0n) { 763 flags |= SLICE_FLAGS_INSTANT; 764 } 765 766 return { 767 id: row.id, 768 startNs: Time.fromRaw(row.tsQ), 769 endNs: Time.fromRaw(row.tsQ + row.durQ), 770 durNs: row.durQ, 771 ts: Time.fromRaw(row.ts), 772 dur: row.dur, 773 flags, 774 depth: row.depth, 775 title: '', 776 subTitle: '', 777 fillRatio: 1, 778 779 // The derived class doesn't need to initialize these. They are 780 // rewritten on every renderCanvas() call. We just need to initialize 781 // them to something. 782 colorScheme: DEFAULT_SLICE_COLOR, 783 isHighlighted: false, 784 }; 785 } 786 787 private findSlice({x, y, timescale}: TrackMouseEvent): undefined | SliceT { 788 const trackHeight = this.computedTrackHeight; 789 const sliceHeight = this.sliceLayout.sliceHeight; 790 const padding = this.sliceLayout.padding; 791 const rowGap = this.sliceLayout.rowGap; 792 793 // Need at least a draw pass to resolve the slice layout. 794 if (sliceHeight === 0) { 795 return undefined; 796 } 797 798 const depth = Math.floor((y - padding) / (sliceHeight + rowGap)); 799 800 if (y >= padding && y <= trackHeight - padding) { 801 for (const slice of this.slices) { 802 if (slice.depth === depth && slice.x <= x && x <= slice.x + slice.w) { 803 return slice; 804 } 805 } 806 } 807 808 for (const slice of this.incomplete) { 809 const startPx = CROP_INCOMPLETE_SLICE_FLAG.get() 810 ? timescale.timeToPx(slice.startNs) 811 : slice.x; 812 const cropUnfinishedSlicesCondition = CROP_INCOMPLETE_SLICE_FLAG.get() 813 ? startPx + INCOMPLETE_SLICE_WIDTH_PX >= x 814 : true; 815 816 if ( 817 slice.depth === depth && 818 startPx <= x && 819 cropUnfinishedSlicesCondition 820 ) { 821 return slice; 822 } 823 } 824 825 return undefined; 826 } 827 828 onMouseMove(event: TrackMouseEvent): void { 829 const {x, y} = event; 830 this.hoverPos = {x, y}; 831 this.updateHoveredSlice(this.findSlice(event)); 832 833 // We need to do a full redraw in order to update the hovered slice properly 834 // due to the way this system is plumbed together right now, despite the 835 // fact that changing the hovered slice SHOULD only require a canvas redraw. 836 // 837 // TODO(stevegolton): Fix this. 838 this.trace.raf.scheduleFullRedraw(); 839 } 840 841 onMouseOut(): void { 842 this.updateHoveredSlice(undefined); 843 } 844 845 private updateHoveredSlice(slice?: SliceT): void { 846 const lastHoveredSlice = this.hoveredSlice; 847 this.hoveredSlice = slice; 848 849 // Only notify the Impl if the hovered slice changes: 850 if (slice === lastHoveredSlice) return; 851 852 if (this.hoveredSlice === undefined) { 853 this.trace.timeline.highlightedSliceId = undefined; 854 this.onSliceOut({slice: assertExists(lastHoveredSlice)}); 855 this.hoverTooltip = []; 856 this.hoverPos = undefined; 857 } else { 858 const args: OnSliceOverArgs<SliceT> = {slice: this.hoveredSlice}; 859 this.trace.timeline.highlightedSliceId = this.hoveredSlice.id; 860 this.onSliceOver(args); 861 this.hoverTooltip = args.tooltip || []; 862 } 863 } 864 865 onMouseClick(event: TrackMouseEvent): boolean { 866 const slice = this.findSlice(event); 867 if (slice === undefined) { 868 return false; 869 } 870 const args: OnSliceClickArgs<SliceT> = {slice}; 871 this.onSliceClick(args); 872 return true; 873 } 874 875 private getVisibleSlicesInternal( 876 start: time, 877 end: time, 878 ): Array<CastInternal<SliceT>> { 879 // Slice visibility is computed using tsq / endTsq. The means an 880 // event at ts=100n can end up with tsq=90n depending on the bucket 881 // calculation. start and end here are the direct unquantised 882 // boundaries so when start=100n we should see the event at tsq=90n 883 // Ideally we would quantize start and end via the same calculation 884 // we used for slices but since that calculation happens in SQL 885 // this is hard. Instead we increase the range by +1 bucket in each 886 // direction. It's fine to overestimate since false positives 887 // (incorrectly marking a slice as visible) are not a problem it's 888 // only false negatives we have to avoid. 889 start = Time.sub(start, this.slicesKey.bucketSize); 890 end = Time.add(end, this.slicesKey.bucketSize); 891 892 let slices = filterVisibleSlices<CastInternal<SliceT>>( 893 this.slices, 894 start, 895 end, 896 ); 897 slices = slices.concat(this.incomplete); 898 // The selected slice is always visible: 899 if (this.selectedSlice && !this.slices.includes(this.selectedSlice)) { 900 slices.push(this.selectedSlice); 901 } 902 return slices; 903 } 904 905 private updateSliceAndTrackHeight() { 906 const rows = Math.max(this.maxDataDepth, this.depthGuess) + 1; 907 const {padding = 2, sliceHeight = 12, rowGap = 0} = this.sliceLayout; 908 909 // Compute the track height. 910 const trackHeight = 2 * padding + rows * (sliceHeight + rowGap); 911 912 // Compute the slice height. 913 this.computedTrackHeight = trackHeight; 914 } 915 916 protected drawChevron( 917 ctx: CanvasRenderingContext2D, 918 x: number, 919 y: number, 920 h: number, 921 ) { 922 // Draw an upward facing chevrons, in order: A, B, C, D, and back to A. 923 // . (x, y) 924 // A 925 // ### 926 // ##C## 927 // ## ## 928 // D B 929 // . (x + CHEVRON_WIDTH_PX, y + h) 930 const HALF_CHEVRON_WIDTH_PX = CHEVRON_WIDTH_PX / 2; 931 const midX = x + HALF_CHEVRON_WIDTH_PX; 932 ctx.beginPath(); 933 ctx.moveTo(midX, y); // A. 934 ctx.lineTo(x + CHEVRON_WIDTH_PX, y + h); // B. 935 ctx.lineTo(midX, y + h - HALF_CHEVRON_WIDTH_PX); // C. 936 ctx.lineTo(x, y + h); // D. 937 ctx.lineTo(midX, y); // Back to A. 938 ctx.closePath(); 939 ctx.fill(); 940 } 941 942 // This is a good default implementation for highlighting slices. By default 943 // onUpdatedSlices() calls this. However, if the XxxSliceTrack impl overrides 944 // onUpdatedSlices() this gives them a chance to call the highlighting without 945 // having to reimplement it. 946 protected highlightHoveredAndSameTitle(slices: Slice[]) { 947 for (const slice of slices) { 948 const isHovering = 949 this.trace.timeline.highlightedSliceId === slice.id || 950 (this.hoveredSlice && this.hoveredSlice.title === slice.title); 951 slice.isHighlighted = !!isHovering; 952 } 953 } 954 955 getHeight(): number { 956 this.updateSliceAndTrackHeight(); 957 return this.computedTrackHeight; 958 } 959 960 getSliceVerticalBounds(depth: number): VerticalBounds | undefined { 961 this.updateSliceAndTrackHeight(); 962 963 const totalSliceHeight = 964 this.sliceLayout.rowGap + this.sliceLayout.sliceHeight; 965 const top = this.sliceLayout.padding + depth * totalSliceHeight; 966 967 return { 968 top, 969 bottom: top + this.sliceLayout.sliceHeight, 970 }; 971 } 972 973 protected get engine() { 974 return this.trace.engine; 975 } 976} 977 978// This is the argument passed to onSliceOver(args). 979// This is really a workaround for the fact that TypeScript doesn't allow 980// inner types within a class (whether the class is templated or not). 981export interface OnSliceOverArgs<S extends Slice> { 982 // Input args (BaseSliceTrack -> Impl): 983 slice: S; // The slice being hovered. 984 985 // Output args (Impl -> BaseSliceTrack): 986 tooltip?: string[]; // One entry per row, up to a max of 2. 987} 988 989export interface OnSliceOutArgs<S extends Slice> { 990 // Input args (BaseSliceTrack -> Impl): 991 slice: S; // The slice which is not hovered anymore. 992} 993 994export interface OnSliceClickArgs<S extends Slice> { 995 // Input args (BaseSliceTrack -> Impl): 996 slice: S; // The slice which is clicked. 997} 998