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