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