xref: /aosp_15_r20/external/perfetto/ui/src/components/tracks/base_slice_track.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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