• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 {Actions} from '../common/actions';
17import {cropText, drawIncompleteSlice} from '../common/canvas_utils';
18import {colorCompare, colorToStr, GRAY_COLOR} from '../common/colorizer';
19import {NUM, QueryResult} from '../common/query_result';
20import {SelectionKind} from '../common/state';
21import {fromNs, toNs} from '../common/time';
22
23import {checkerboardExcept} from './checkerboard';
24import {globals} from './globals';
25import {Slice} from './slice';
26import {DEFAULT_SLICE_LAYOUT, SliceLayout} from './slice_layout';
27import {NewTrackArgs, SliceRect, Track} from './track';
28
29// The common class that underpins all tracks drawing slices.
30
31export const SLICE_FLAGS_INCOMPLETE = 1;
32export const SLICE_FLAGS_INSTANT = 2;
33
34// Slices smaller than this don't get any text:
35const SLICE_MIN_WIDTH_FOR_TEXT_PX = 5;
36// Slices smaller than this aren't rendered at all.
37const SLICE_MIN_WIDTH_PX = 0.1;
38const CHEVRON_WIDTH_PX = 10;
39const DEFAULT_SLICE_COLOR = GRAY_COLOR;
40
41// TODO(hjd): Implement caching.
42
43// The minimal set of columns that any table/view must expose to render tracks.
44// Note: this class assumes that, at the SQL level, slices are:
45// - Not temporally overlapping (unless they are nested at inner depth).
46// - Strictly stacked (i.e. a slice at depth N+1 cannot be larger than any
47//   slices at depth 0..N.
48// If you need temporally overlapping slices, look at AsyncSliceTrack, which
49// merges several tracks into one visual track.
50export const BASE_SLICE_ROW = {
51  id: NUM,     // The slice ID, for selection / lookups.
52  tsq: NUM,    // Quantized |ts|. This class owns the quantization logic.
53  ts: NUM,     // Start time in nanoseconds.
54  dur: NUM,    // Duration in nanoseconds. -1 = incomplete, 0 = instant.
55  depth: NUM,  // Vertical depth.
56};
57
58export type BaseSliceRow = typeof BASE_SLICE_ROW;
59
60// These properties change @ 60FPS and shouldn't be touched by the subclass.
61// since the Impl doesn't see every frame attempting to reason on them in a
62// subclass will run in to issues.
63interface SliceInternal {
64  x: number;
65  w: number;
66}
67
68// We use this to avoid exposing subclasses to the properties that live on
69// SliceInternal. Within BaseSliceTrack the underlying storage and private
70// methods use CastInternal<T['slice']> (i.e. whatever the subclass requests
71// plus our implementation fields) but when we call 'virtual' methods that
72// the subclass should implement we use just T['slice'] hiding x & w.
73type CastInternal<S extends Slice> = S&SliceInternal;
74
75// The meta-type which describes the types used to extend the BaseSliceTrack.
76// Derived classes can extend this interface to override these types if needed.
77export interface BaseSliceTrackTypes {
78  slice: Slice;
79  row: BaseSliceRow;
80  config: {};
81}
82
83export abstract class BaseSliceTrack<T extends BaseSliceTrackTypes =
84                                                   BaseSliceTrackTypes> extends
85    Track<T['config']> {
86  // This is the slice cache.
87  private slices = new Array<CastInternal<T['slice']>>();
88  protected sliceLayout: SliceLayout = {...DEFAULT_SLICE_LAYOUT};
89
90  // These are the over-skirted cached bounds.
91  private slicesStartNs = -1;
92  private slicesEndNs = -1;
93  private slicesBucketNs = -1;
94
95  private readonly tableName: string;
96  private maxDurNs = 0;
97  private sqlState: 'UNINITIALIZED'|'INITIALIZING'|'QUERY_PENDING'|
98      'QUERY_DONE' = 'UNINITIALIZED';
99  private extraSqlColumns: string[];
100
101  private charWidth = -1;
102  private hoverPos?: {x: number, y: number};
103  protected hoveredSlice?: T['slice'];
104  private hoverTooltip: string[] = [];
105  private maxDataDepth = 0;
106
107  // Computed layout.
108  private computedTrackHeight = 0;
109  private computedSliceHeight = 0;
110  private computedRowSpacing = 0;
111
112  // True if this track (and any views tables it might have created) has been
113  // destroyed. This is unfortunately error prone (since we must manually check
114  // this between each query).
115  // TODO(hjd): Replace once we have cancellable query sequences.
116  private isDestroyed = false;
117
118  // TODO(hjd): Remove when updating selection.
119  // We shouldn't know here about CHROME_SLICE. Maybe should be set by
120  // whatever deals with that. Dunno the namespace of selection is weird. For
121  // most cases in non-ambiguous (because most things are a 'slice'). But some
122  // others (e.g. THREAD_SLICE) have their own ID namespace so we need this.
123  protected selectionKinds: SelectionKind[] = ['SLICE', 'CHROME_SLICE'];
124
125  // Extension points.
126  // Each extension point should take a dedicated argument type (e.g.,
127  // OnSliceOverArgs {slice?: T['slice']}) so it makes future extensions
128  // non-API-breaking (e.g. if we want to add the X position).
129  abstract initSqlTable(_tableName: string): Promise<void>;
130  getRowSpec(): T['row'] {
131    return BASE_SLICE_ROW;
132  }
133  onSliceOver(_args: OnSliceOverArgs<T['slice']>): void {}
134  onSliceOut(_args: OnSliceOutArgs<T['slice']>): void {}
135  onSliceClick(_args: OnSliceClickArgs<T['slice']>): void {}
136  prepareSlices(slices: Array<T['slice']>): void {
137    this.highlightHovererdAndSameTitle(slices);
138  }
139
140  // TODO(hjd): Remove.
141  drawSchedLatencyArrow(
142      _: CanvasRenderingContext2D, _selectedSlice?: T['slice']): void {}
143
144  constructor(args: NewTrackArgs) {
145    super(args);
146    this.frontendOnly = true;  // Disable auto checkerboarding.
147    this.tableName = `track_${this.trackId}`.replace(/[^a-zA-Z0-9_]+/g, '_');
148
149    // Work out the extra columns.
150    // This is the union of the embedder-defined columns and the base columns
151    // we know about (ts, dur, ...).
152    const allCols = Object.keys(this.getRowSpec());
153    const baseCols = Object.keys(BASE_SLICE_ROW);
154    this.extraSqlColumns = allCols.filter(key => !baseCols.includes(key));
155  }
156
157  setSliceLayout(sliceLayout: SliceLayout) {
158    if (sliceLayout.minDepth > sliceLayout.maxDepth) {
159      const {maxDepth, minDepth} = sliceLayout;
160      throw new Error(`minDepth ${minDepth} must be <= maxDepth ${maxDepth}`);
161    }
162    this.sliceLayout = sliceLayout;
163  }
164
165  onFullRedraw(): void {
166    // TODO(hjd): Call this only when cache changes. See discussion:
167    // What we want to do here is give the Impl a chance to colour the slice,
168    // e.g. depending on the currently selected thread or process.
169    // Here's an interesting thought. We have two options here:
170    //   A) We could pass only the vizSlices, but then we'd have to call this
171    //      @ 60FPS (because vizSlices changes as we pan).
172    //   B) We could call this only on full redraws (when the state changes),
173    //      but then the track needs to process *all* cached slices, not just
174    //      the visible ones. It's okay now (it's a 2x factor) but might get
175    //      worse if we cache several layers of slices at various resolutions.
176    // But there's an escape, I think. I think the right thing to do is:
177    // - For now call it on the full slices, but only on full redraws.
178    // - When we get caching, call it every time we switch "cached quantization
179    //  level", which is a way in the middle between 60FPS and full redraws..
180    // Overall the API contract of this prepareSlices() call is:
181    //  - I am going to draw these slices in the near future.
182    //  - I am not going to draw any slice that I haven't passed here first.
183    //  - This is guaranteed to be called at least on every state change.
184    //  - This is NOT guaranteed to be called on every frame. For instance you
185    //    cannot use this to do some colour-based animation.
186
187    // Give a chance to the embedder to change colors and other stuff.
188    this.prepareSlices(this.slices);
189  }
190
191  renderCanvas(ctx: CanvasRenderingContext2D): void {
192    // TODO(hjd): fonts and colors should come from the CSS and not hardcoded
193    // here.
194    const {timeScale} = globals.frontendLocalState;
195    const vizTime = globals.frontendLocalState.visibleWindowTime;
196
197    // If the visible time range is outside the cached area, requests
198    // asynchronously new data from the SQL engine.
199    this.maybeRequestData();
200
201    // In any case, draw whatever we have (which might be stale/incomplete).
202
203    // If the cached trace slices don't fully cover the visible time range,
204    // show a gray rectangle with a "Loading..." label.
205    checkerboardExcept(
206        ctx,
207        this.getHeight(),
208        timeScale.timeToPx(vizTime.start),
209        timeScale.timeToPx(vizTime.end),
210        timeScale.timeToPx(fromNs(this.slicesStartNs)),
211        timeScale.timeToPx(fromNs(this.slicesEndNs)));
212
213    let charWidth = this.charWidth;
214    if (charWidth < 0) {
215      // TODO(hjd): Centralize font measurement/invalidation.
216      ctx.font = '12px Roboto Condensed';
217      charWidth = this.charWidth = ctx.measureText('dbpqaouk').width / 8;
218    }
219
220    // Filter only the visible slices. |this.slices| will have more slices than
221    // needed because maybeRequestData() over-fetches to handle small pan/zooms.
222    // We don't want to waste time drawing slices that are off screen.
223    const vizSlices = this.getVisibleSlicesInternal(vizTime.start, vizTime.end);
224
225    let selection = globals.state.currentSelection;
226
227    if (!selection || !this.selectionKinds.includes(selection.kind)) {
228      selection = null;
229    }
230
231    // Believe it or not, doing 4xO(N) passes is ~2x faster than trying to draw
232    // everything in one go. The key is that state changes operations on the
233    // canvas (e.g., color, fonts) dominate any number crunching we do in JS.
234
235    this.updateSliceAndTrackHeight();
236    const sliceHeight = this.computedSliceHeight;
237    const padding = this.sliceLayout.padding;
238    const rowSpacing = this.computedRowSpacing;
239
240    // First pass: compute geometry of slices.
241    let selSlice: CastInternal<T['slice']>|undefined;
242
243    // pxEnd is the last visible pixel in the visible viewport. Drawing
244    // anything < 0 or > pxEnd doesn't produce any visible effect as it goes
245    // beyond the visible portion of the canvas.
246    const pxEnd = Math.floor(timeScale.timeToPx(vizTime.end));
247
248    for (const slice of vizSlices) {
249      // Compute the basic geometry for any visible slice, even if only
250      // partially visible. This might end up with a negative x if the
251      // slice starts before the visible time or with a width that overflows
252      // pxEnd.
253      slice.x = timeScale.timeToPx(slice.startS);
254      slice.w = timeScale.deltaTimeToPx(slice.durationS);
255      if (slice.flags & SLICE_FLAGS_INSTANT) {
256        // In the case of an instant slice, set the slice geometry on the
257        // bounding box that will contain the chevron.
258        slice.x -= CHEVRON_WIDTH_PX / 2;
259        slice.w = CHEVRON_WIDTH_PX;
260      } else {
261        // If the slice is an actual slice, intersect the slice geometry with
262        // the visible viewport (this affects only the first and last slice).
263        // This is so that text is always centered even if we are zoomed in.
264        // Visually if we have
265        //                   [    visible viewport   ]
266        //  [         slice         ]
267        // The resulting geometry will be:
268        //                   [slice]
269        // So that the slice title stays within the visible region.
270        const sliceVizLimit = Math.min(slice.x + slice.w, pxEnd);
271        slice.x = Math.max(slice.x, 0);
272        slice.w = sliceVizLimit - slice.x;
273      }
274
275      if (selection && (selection as {id: number}).id === slice.id) {
276        selSlice = slice;
277      }
278    }
279
280    // Second pass: fill slices by color.
281    // The .slice() turned out to be an unintended pun.
282    const vizSlicesByColor = vizSlices.slice();
283    vizSlicesByColor.sort((a, b) => colorCompare(a.color, b.color));
284    let lastColor = undefined;
285    for (const slice of vizSlicesByColor) {
286      if (slice.color !== lastColor) {
287        lastColor = slice.color;
288        ctx.fillStyle = colorToStr(slice.color);
289      }
290      const y = padding + slice.depth * (sliceHeight + rowSpacing);
291      if (slice.flags & SLICE_FLAGS_INSTANT) {
292        this.drawChevron(ctx, slice.x, y, sliceHeight);
293      } else if (slice.flags & SLICE_FLAGS_INCOMPLETE) {
294        const w = Math.max(slice.w - 2, 2);
295        drawIncompleteSlice(ctx, slice.x, y, w, sliceHeight);
296      } else if (slice.w > SLICE_MIN_WIDTH_PX) {
297        ctx.fillRect(slice.x, y, slice.w, sliceHeight);
298      }
299    }
300
301    // Third pass, draw the titles (e.g., process name for sched slices).
302    ctx.fillStyle = '#fff';
303    ctx.textAlign = 'center';
304    ctx.font = '12px Roboto Condensed';
305    ctx.textBaseline = 'middle';
306    for (const slice of vizSlices) {
307      if ((slice.flags & SLICE_FLAGS_INSTANT) || !slice.title ||
308          slice.w < SLICE_MIN_WIDTH_FOR_TEXT_PX) {
309        continue;
310      }
311
312      const title = cropText(slice.title, charWidth, slice.w);
313      const rectXCenter = slice.x + slice.w / 2;
314      const y = padding + slice.depth * (sliceHeight + rowSpacing);
315      const yDiv = slice.subTitle ? 3 : 2;
316      const yMidPoint = Math.floor(y + sliceHeight / yDiv) - 0.5;
317      ctx.fillText(title, rectXCenter, yMidPoint);
318    }
319
320    // Fourth pass, draw the subtitles (e.g., thread name for sched slices).
321    ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
322    ctx.font = '10px Roboto Condensed';
323    for (const slice of vizSlices) {
324      if (slice.w < SLICE_MIN_WIDTH_FOR_TEXT_PX || !slice.subTitle ||
325          (slice.flags & SLICE_FLAGS_INSTANT)) {
326        continue;
327      }
328      const rectXCenter = slice.x + slice.w / 2;
329      const subTitle = cropText(slice.subTitle, charWidth, slice.w);
330      const y = padding + slice.depth * (sliceHeight + rowSpacing);
331      const yMidPoint = Math.ceil(y + sliceHeight * 2 / 3) + 1.5;
332      ctx.fillText(subTitle, rectXCenter, yMidPoint);
333    }
334
335    // Draw a thicker border around the selected slice (or chevron).
336    if (selSlice !== undefined) {
337      const color = selSlice.color;
338      const y = padding + selSlice.depth * (sliceHeight + rowSpacing);
339      ctx.strokeStyle = `hsl(${color.h}, ${color.s}%, 30%)`;
340      ctx.beginPath();
341      const THICKNESS = 3;
342      ctx.lineWidth = THICKNESS;
343      ctx.strokeRect(
344          selSlice.x, y - THICKNESS / 2, selSlice.w, sliceHeight + THICKNESS);
345      ctx.closePath();
346    }
347
348    // TODO(hjd): Remove this.
349    // The only thing this does is drawing the sched latency arrow. We should
350    // have some abstraction for that arrow (ideally the same we'd use for
351    // flows).
352    this.drawSchedLatencyArrow(ctx, selSlice);
353
354    // If a slice is hovered, draw the tooltip.
355    const tooltip = this.hoverTooltip;
356    if (this.hoveredSlice !== undefined && tooltip.length > 0 &&
357        this.hoverPos !== undefined) {
358      if (tooltip.length === 1) {
359        this.drawTrackHoverTooltip(ctx, this.hoverPos, tooltip[0]);
360      } else {
361        this.drawTrackHoverTooltip(ctx, this.hoverPos, tooltip[0], tooltip[1]);
362      }
363    }  // if (howSlice)
364  }
365
366  onDestroy() {
367    super.onDestroy();
368    this.isDestroyed = true;
369    this.engine.query(`DROP VIEW IF EXISTS ${this.tableName}`);
370  }
371
372  // This method figures out if the visible window is outside the bounds of
373  // the cached data and if so issues new queries (i.e. sorta subsumes the
374  // onBoundsChange).
375  async maybeRequestData() {
376    // Important: this method is async and is invoked on every frame. Care
377    // must be taken to avoid piling up queries on every frame, hence the FSM.
378    if (this.sqlState === 'UNINITIALIZED') {
379      this.sqlState = 'INITIALIZING';
380
381      if (this.isDestroyed) {
382        return;
383      }
384      await this.initSqlTable(this.tableName);
385
386      if (this.isDestroyed) {
387        return;
388      }
389      const queryRes = await this.engine.query(`select
390          ifnull(max(dur), 0) as maxDur, count(1) as rowCount
391          from ${this.tableName}`);
392      const row = queryRes.firstRow({maxDur: NUM, rowCount: NUM});
393      this.maxDurNs = row.maxDur;
394      this.sqlState = 'QUERY_DONE';
395    } else if (
396        this.sqlState === 'INITIALIZING' || this.sqlState === 'QUERY_PENDING') {
397      return;
398    }
399
400    const resolutionNs = toNs(globals.getCurResolution());
401    const vizTime = globals.frontendLocalState.visibleWindowTime;
402
403    const startNs = toNs(vizTime.start);
404    const endNs = toNs(vizTime.end);
405
406    // TODO(hjd): figure out / centralize the resolution steps.
407    // Will handle this at the same time as cacheing.
408    const bucketNs = resolutionNs;
409
410    if (startNs >= this.slicesStartNs && endNs <= this.slicesEndNs &&
411        bucketNs === this.slicesBucketNs) {
412      return;  // We have the data already, no need to re-query
413    }
414
415    this.sqlState = 'QUERY_PENDING';
416    const queryTsq = `(ts + ${bucketNs / 2}) / ${bucketNs} * ${bucketNs}`;
417
418    const extraCols = this.extraSqlColumns.join(',');
419    let depthCol = 'depth';
420    let maybeGroupByDepth = 'depth, ';
421    const layout = this.sliceLayout;
422    const isFlat = (layout.maxDepth - layout.minDepth) <= 1;
423    // maxDepth === minDepth only makes sense if track is empty which on the
424    // one hand isn't very useful (and so maybe should be an error) on the
425    // other hand I can see it happening if someone does:
426    // minDepth = min(slices.depth); maxDepth = max(slices.depth);
427    // and slices is empty, so we treat that as flat.
428    if (isFlat) {
429      depthCol = `${this.sliceLayout.minDepth} as depth`;
430      maybeGroupByDepth = '';
431    }
432
433    // TODO(hjd): Re-reason and improve this query:
434    // - Materialize the unfinished slices one off.
435    // - Avoid the union if we know we don't have any -1 slices.
436    // - Maybe we don't need the union at all and can deal in TS?
437    if (this.isDestroyed) {
438      return;
439    }
440    const queryRes = await this.engine.query(`
441    with q1 as (
442      select
443        ${queryTsq} as tsq,
444        ts,
445        max(dur) as dur,
446        id,
447        ${depthCol}
448        ${extraCols ? ',' + extraCols : ''}
449      from ${this.tableName}
450      where
451        ts >= ${startNs - this.maxDurNs /* - durNs */} and
452        ts <= ${endNs /* + durNs */}
453      group by ${maybeGroupByDepth} tsq
454      order by tsq),
455    q2 as (
456      select
457        ${queryTsq} as tsq,
458        ts,
459        -1 as dur,
460        id,
461        ${depthCol}
462        ${extraCols ? ',' + extraCols : ''}
463      from ${this.tableName}
464      where dur = -1
465      group by ${maybeGroupByDepth} tsq
466      )
467      select min(dur) as _unused, * from
468      (select * from q1 union all select * from q2)
469      group by ${maybeGroupByDepth} tsq
470      order by tsq
471    `);
472    this.convertQueryResultToSlices(queryRes, startNs, endNs, bucketNs);
473    this.sqlState = 'QUERY_DONE';
474    globals.rafScheduler.scheduleRedraw();
475  }
476
477  // Here convert each row to a Slice. We do what we can do generically
478  // in the base class, and delegate the rest to the impl via that rowToSlice()
479  // abstract call.
480  convertQueryResultToSlices(
481      queryRes: QueryResult, startNs: number, endNs: number, bucketNs: number) {
482    const slices = new Array<CastInternal<T['slice']>>(queryRes.numRows());
483    const it = queryRes.iter(this.getRowSpec());
484
485    let maxDataDepth = this.maxDataDepth;
486    this.slicesStartNs = startNs;
487    this.slicesEndNs = endNs;
488    this.slicesBucketNs = bucketNs;
489    for (let i = 0; it.valid(); it.next(), ++i) {
490      maxDataDepth = Math.max(maxDataDepth, it.depth);
491
492      // Construct the base slice. The Impl will construct and return the full
493      // derived T["slice"] (e.g. CpuSlice) in the rowToSlice() method.
494      slices[i] = this.rowToSliceInternal(it);
495    }
496    this.maxDataDepth = maxDataDepth;
497    this.slices = slices;
498  }
499
500  private rowToSliceInternal(row: T['row']): CastInternal<T['slice']> {
501    const slice = this.rowToSlice(row) as CastInternal<T['slice']>;
502    slice.x = -1;
503    slice.w = -1;
504    return slice;
505  }
506
507  rowToSlice(row: T['row']): T['slice'] {
508    const startNsQ = row.tsq;
509    const startNs = row.ts;
510    let flags = 0;
511    let durNs: number;
512    if (row.dur === -1) {
513      durNs = toNs(globals.state.traceTime.endSec) - startNs;
514      flags |= SLICE_FLAGS_INCOMPLETE;
515    } else {
516      flags |= (row.dur === 0) ? SLICE_FLAGS_INSTANT : 0;
517      durNs = row.dur;
518    }
519    const endNs = startNs + durNs;
520    const bucketNs = this.slicesBucketNs;
521    let endNsQ = Math.floor((endNs + bucketNs / 2 - 1) / bucketNs) * bucketNs;
522    endNsQ = Math.max(endNsQ, startNsQ + bucketNs);
523
524    return {
525      id: row.id,
526      startS: fromNs(startNsQ),
527      durationS: fromNs(endNsQ - startNsQ),
528      flags,
529      depth: row.depth,
530      title: '',
531      subTitle: '',
532
533      // The derived class doesn't need to initialize these. They are
534      // rewritten on every renderCanvas() call. We just need to initialize
535      // them to something.
536      baseColor: DEFAULT_SLICE_COLOR,
537      color: DEFAULT_SLICE_COLOR,
538    };
539  }
540
541  private findSlice({x, y}: {x: number, y: number}): undefined|Slice {
542    const trackHeight = this.computedTrackHeight;
543    const sliceHeight = this.computedSliceHeight;
544    const padding = this.sliceLayout.padding;
545    const rowSpacing = this.computedRowSpacing;
546
547    // Need at least a draw pass to resolve the slice layout.
548    if (sliceHeight === 0) {
549      return undefined;
550    }
551
552    if (y >= padding && y <= trackHeight - padding) {
553      const depth = Math.floor((y - padding) / (sliceHeight + rowSpacing));
554      for (const slice of this.slices) {
555        if (slice.depth === depth && slice.x <= x && x <= slice.x + slice.w) {
556          return slice;
557        }
558      }
559    }
560
561    return undefined;
562  }
563
564  onMouseMove(position: {x: number, y: number}): void {
565    this.hoverPos = position;
566    this.updateHoveredSlice(this.findSlice(position));
567  }
568
569  onMouseOut(): void {
570    this.updateHoveredSlice(undefined);
571  }
572
573  private updateHoveredSlice(slice?: T['slice']): void {
574    const lastHoveredSlice = this.hoveredSlice;
575    this.hoveredSlice = slice;
576
577    // Only notify the Impl if the hovered slice changes:
578    if (slice === lastHoveredSlice) return;
579
580    if (this.hoveredSlice === undefined) {
581      globals.dispatch(Actions.setHighlightedSliceId({sliceId: -1}));
582      this.onSliceOut({slice: assertExists(lastHoveredSlice)});
583      this.hoverTooltip = [];
584      this.hoverPos = undefined;
585    } else {
586      const args: OnSliceOverArgs<T['slice']> = {slice: this.hoveredSlice};
587      globals.dispatch(
588          Actions.setHighlightedSliceId({sliceId: this.hoveredSlice.id}));
589      this.onSliceOver(args);
590      this.hoverTooltip = args.tooltip || [];
591    }
592  }
593
594  onMouseClick(position: {x: number, y: number}): boolean {
595    const slice = this.findSlice(position);
596    if (slice === undefined) {
597      return false;
598    }
599    const args: OnSliceClickArgs<T['slice']> = {slice};
600    this.onSliceClick(args);
601    return true;
602  }
603
604  private getVisibleSlicesInternal(startS: number, endS: number):
605      Array<CastInternal<T['slice']>> {
606    return this.getVisibleSlices(startS, endS);
607  }
608
609  getVisibleSlices(startS: number, endS: number):
610      Array<CastInternal<T['slice']>> {
611    let startIdx = -1;
612    let endIdx = -1;
613    let i = 0;
614
615    // TODO(hjd): binary search.
616    for (const slice of this.slices) {
617      if (startIdx < 0 && slice.startS + slice.durationS >= startS) {
618        startIdx = i;
619      }
620      if (slice.startS <= endS) {
621        endIdx = i + 1;
622      } else if (slice.startS > endS) {
623        endIdx = i;
624        break;
625      }
626      i++;
627    }
628    return this.slices.slice(startIdx, endIdx);
629  }
630
631  private updateSliceAndTrackHeight() {
632    const lay = this.sliceLayout;
633
634    const rows =
635        Math.min(Math.max(this.maxDataDepth + 1, lay.minDepth), lay.maxDepth);
636
637    // Compute the track height.
638    let trackHeight;
639    if (lay.heightMode === 'FIXED') {
640      trackHeight = lay.fixedHeight;
641    } else {
642      trackHeight = 2 * lay.padding + rows * (lay.sliceHeight + lay.rowSpacing);
643    }
644
645    // Compute the slice height.
646    let sliceHeight: number;
647    let rowSpacing: number = lay.rowSpacing;
648    if (lay.heightMode === 'FIXED') {
649      const rowHeight = (trackHeight - 2 * lay.padding) / rows;
650      sliceHeight = Math.floor(Math.max(rowHeight - lay.rowSpacing, 0.5));
651      rowSpacing = Math.max(lay.rowSpacing, rowHeight - sliceHeight);
652      rowSpacing = Math.floor(rowSpacing * 2) / 2;
653    } else {
654      sliceHeight = lay.sliceHeight;
655    }
656    this.computedSliceHeight = sliceHeight;
657    this.computedTrackHeight = trackHeight;
658    this.computedRowSpacing = rowSpacing;
659  }
660
661  private drawChevron(
662      ctx: CanvasRenderingContext2D, x: number, y: number, h: number) {
663    // Draw an upward facing chevrons, in order: A, B, C, D, and back to A.
664    // . (x, y)
665    //      A
666    //     ###
667    //    ##C##
668    //   ##   ##
669    //  D       B
670    //            . (x + CHEVRON_WIDTH_PX, y + h)
671    const HALF_CHEVRON_WIDTH_PX = CHEVRON_WIDTH_PX / 2;
672    const midX = x + HALF_CHEVRON_WIDTH_PX;
673    ctx.beginPath();
674    ctx.moveTo(midX, y);                              // A.
675    ctx.lineTo(x + CHEVRON_WIDTH_PX, y + h);          // B.
676    ctx.lineTo(midX, y + h - HALF_CHEVRON_WIDTH_PX);  // C.
677    ctx.lineTo(x, y + h);                             // D.
678    ctx.lineTo(midX, y);                              // Back to A.
679    ctx.closePath();
680    ctx.fill();
681  }
682
683  // This is a good default implemenation for highlighting slices. By default
684  // prepareSlices() calls this. However, if the XxxSliceTrack impl overrides
685  // prepareSlices() this gives them a chance to call the highlighting witout
686  // having to reimplement it.
687  protected highlightHovererdAndSameTitle(slices: Slice[]) {
688    for (const slice of slices) {
689      const isHovering = globals.state.highlightedSliceId === slice.id ||
690          (this.hoveredSlice && this.hoveredSlice.title === slice.title);
691      if (isHovering) {
692        slice.color = {
693          c: slice.baseColor.c,
694          h: slice.baseColor.h,
695          s: slice.baseColor.s,
696          l: 30
697        };
698      } else {
699        slice.color = slice.baseColor;
700      }
701    }
702  }
703
704  getHeight(): number {
705    this.updateSliceAndTrackHeight();
706    return this.computedTrackHeight;
707  }
708
709  getSliceRect(_tStart: number, _tEnd: number, _depth: number): SliceRect
710      |undefined {
711    // TODO(hjd): Implement this as part of updating flow events.
712    return undefined;
713  }
714}
715
716// This is the argument passed to onSliceOver(args).
717// This is really a workaround for the fact that TypeScript doesn't allow
718// inner types within a class (whether the class is templated or not).
719export interface OnSliceOverArgs<S extends Slice> {
720  // Input args (BaseSliceTrack -> Impl):
721  slice: S;  // The slice being hovered.
722
723  // Output args (Impl -> BaseSliceTrack):
724  tooltip?: string[];  // One entry per row, up to a max of 2.
725}
726
727export interface OnSliceOutArgs<S extends Slice> {
728  // Input args (BaseSliceTrack -> Impl):
729  slice: S;  // The slice which is not hovered anymore.
730}
731
732export interface OnSliceClickArgs<S extends Slice> {
733  // Input args (BaseSliceTrack -> Impl):
734  slice: S;  // The slice which is clicked.
735}
736