| // Copyright (C) 2021 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import {assertExists} from '../base/logging'; |
| import {Actions} from '../common/actions'; |
| import {cropText, drawIncompleteSlice} from '../common/canvas_utils'; |
| import {colorCompare, colorToStr, GRAY_COLOR} from '../common/colorizer'; |
| import {NUM, QueryResult} from '../common/query_result'; |
| import {SelectionKind} from '../common/state'; |
| import {fromNs, toNs} from '../common/time'; |
| |
| import {checkerboardExcept} from './checkerboard'; |
| import {globals} from './globals'; |
| import {Slice} from './slice'; |
| import {DEFAULT_SLICE_LAYOUT, SliceLayout} from './slice_layout'; |
| import {NewTrackArgs, SliceRect, Track} from './track'; |
| |
| // The common class that underpins all tracks drawing slices. |
| |
| export const SLICE_FLAGS_INCOMPLETE = 1; |
| export const SLICE_FLAGS_INSTANT = 2; |
| |
| // Slices smaller than this don't get any text: |
| const SLICE_MIN_WIDTH_FOR_TEXT_PX = 5; |
| // Slices smaller than this aren't rendered at all. |
| const SLICE_MIN_WIDTH_PX = 0.1; |
| const CHEVRON_WIDTH_PX = 10; |
| const DEFAULT_SLICE_COLOR = GRAY_COLOR; |
| |
| // TODO(hjd): Implement caching. |
| |
| // The minimal set of columns that any table/view must expose to render tracks. |
| // Note: this class assumes that, at the SQL level, slices are: |
| // - Not temporally overlapping (unless they are nested at inner depth). |
| // - Strictly stacked (i.e. a slice at depth N+1 cannot be larger than any |
| // slices at depth 0..N. |
| // If you need temporally overlapping slices, look at AsyncSliceTrack, which |
| // merges several tracks into one visual track. |
| export const BASE_SLICE_ROW = { |
| id: NUM, // The slice ID, for selection / lookups. |
| tsq: NUM, // Quantized |ts|. This class owns the quantization logic. |
| ts: NUM, // Start time in nanoseconds. |
| dur: NUM, // Duration in nanoseconds. -1 = incomplete, 0 = instant. |
| depth: NUM, // Vertical depth. |
| }; |
| |
| export type BaseSliceRow = typeof BASE_SLICE_ROW; |
| |
| // These properties change @ 60FPS and shouldn't be touched by the subclass. |
| // since the Impl doesn't see every frame attempting to reason on them in a |
| // subclass will run in to issues. |
| interface SliceInternal { |
| x: number; |
| w: number; |
| } |
| |
| // We use this to avoid exposing subclasses to the properties that live on |
| // SliceInternal. Within BaseSliceTrack the underlying storage and private |
| // methods use CastInternal<T['slice']> (i.e. whatever the subclass requests |
| // plus our implementation fields) but when we call 'virtual' methods that |
| // the subclass should implement we use just T['slice'] hiding x & w. |
| type CastInternal<S extends Slice> = S&SliceInternal; |
| |
| // The meta-type which describes the types used to extend the BaseSliceTrack. |
| // Derived classes can extend this interface to override these types if needed. |
| export interface BaseSliceTrackTypes { |
| slice: Slice; |
| row: BaseSliceRow; |
| config: {}; |
| } |
| |
| export abstract class BaseSliceTrack<T extends BaseSliceTrackTypes = |
| BaseSliceTrackTypes> extends |
| Track<T['config']> { |
| // This is the slice cache. |
| private slices = new Array<CastInternal<T['slice']>>(); |
| protected sliceLayout: SliceLayout = {...DEFAULT_SLICE_LAYOUT}; |
| |
| // These are the over-skirted cached bounds. |
| private slicesStartNs = -1; |
| private slicesEndNs = -1; |
| private slicesBucketNs = -1; |
| |
| private readonly tableName: string; |
| private maxDurNs = 0; |
| private sqlState: 'UNINITIALIZED'|'INITIALIZING'|'QUERY_PENDING'| |
| 'QUERY_DONE' = 'UNINITIALIZED'; |
| private extraSqlColumns: string[]; |
| |
| private charWidth = -1; |
| private hoverPos?: {x: number, y: number}; |
| protected hoveredSlice?: T['slice']; |
| private hoverTooltip: string[] = []; |
| private maxDataDepth = 0; |
| |
| // Computed layout. |
| private computedTrackHeight = 0; |
| private computedSliceHeight = 0; |
| private computedRowSpacing = 0; |
| |
| // True if this track (and any views tables it might have created) has been |
| // destroyed. This is unfortunately error prone (since we must manually check |
| // this between each query). |
| // TODO(hjd): Replace once we have cancellable query sequences. |
| private isDestroyed = false; |
| |
| // TODO(hjd): Remove when updating selection. |
| // We shouldn't know here about CHROME_SLICE. Maybe should be set by |
| // whatever deals with that. Dunno the namespace of selection is weird. For |
| // most cases in non-ambiguous (because most things are a 'slice'). But some |
| // others (e.g. THREAD_SLICE) have their own ID namespace so we need this. |
| protected selectionKinds: SelectionKind[] = ['SLICE', 'CHROME_SLICE']; |
| |
| // Extension points. |
| // Each extension point should take a dedicated argument type (e.g., |
| // OnSliceOverArgs {slice?: T['slice']}) so it makes future extensions |
| // non-API-breaking (e.g. if we want to add the X position). |
| abstract initSqlTable(_tableName: string): Promise<void>; |
| getRowSpec(): T['row'] { |
| return BASE_SLICE_ROW; |
| } |
| onSliceOver(_args: OnSliceOverArgs<T['slice']>): void {} |
| onSliceOut(_args: OnSliceOutArgs<T['slice']>): void {} |
| onSliceClick(_args: OnSliceClickArgs<T['slice']>): void {} |
| prepareSlices(slices: Array<T['slice']>): void { |
| this.highlightHovererdAndSameTitle(slices); |
| } |
| |
| // TODO(hjd): Remove. |
| drawSchedLatencyArrow( |
| _: CanvasRenderingContext2D, _selectedSlice?: T['slice']): void {} |
| |
| constructor(args: NewTrackArgs) { |
| super(args); |
| this.frontendOnly = true; // Disable auto checkerboarding. |
| this.tableName = `track_${this.trackId}`.replace(/[^a-zA-Z0-9_]+/g, '_'); |
| |
| // Work out the extra columns. |
| // This is the union of the embedder-defined columns and the base columns |
| // we know about (ts, dur, ...). |
| const allCols = Object.keys(this.getRowSpec()); |
| const baseCols = Object.keys(BASE_SLICE_ROW); |
| this.extraSqlColumns = allCols.filter(key => !baseCols.includes(key)); |
| } |
| |
| setSliceLayout(sliceLayout: SliceLayout) { |
| if (sliceLayout.minDepth > sliceLayout.maxDepth) { |
| const {maxDepth, minDepth} = sliceLayout; |
| throw new Error(`minDepth ${minDepth} must be <= maxDepth ${maxDepth}`); |
| } |
| this.sliceLayout = sliceLayout; |
| } |
| |
| onFullRedraw(): void { |
| // TODO(hjd): Call this only when cache changes. See discussion: |
| // What we want to do here is give the Impl a chance to colour the slice, |
| // e.g. depending on the currently selected thread or process. |
| // Here's an interesting thought. We have two options here: |
| // A) We could pass only the vizSlices, but then we'd have to call this |
| // @ 60FPS (because vizSlices changes as we pan). |
| // B) We could call this only on full redraws (when the state changes), |
| // but then the track needs to process *all* cached slices, not just |
| // the visible ones. It's okay now (it's a 2x factor) but might get |
| // worse if we cache several layers of slices at various resolutions. |
| // But there's an escape, I think. I think the right thing to do is: |
| // - For now call it on the full slices, but only on full redraws. |
| // - When we get caching, call it every time we switch "cached quantization |
| // level", which is a way in the middle between 60FPS and full redraws.. |
| // Overall the API contract of this prepareSlices() call is: |
| // - I am going to draw these slices in the near future. |
| // - I am not going to draw any slice that I haven't passed here first. |
| // - This is guaranteed to be called at least on every state change. |
| // - This is NOT guaranteed to be called on every frame. For instance you |
| // cannot use this to do some colour-based animation. |
| |
| // Give a chance to the embedder to change colors and other stuff. |
| this.prepareSlices(this.slices); |
| } |
| |
| renderCanvas(ctx: CanvasRenderingContext2D): void { |
| // TODO(hjd): fonts and colors should come from the CSS and not hardcoded |
| // here. |
| const {timeScale} = globals.frontendLocalState; |
| const vizTime = globals.frontendLocalState.visibleWindowTime; |
| |
| // If the visible time range is outside the cached area, requests |
| // asynchronously new data from the SQL engine. |
| this.maybeRequestData(); |
| |
| // In any case, draw whatever we have (which might be stale/incomplete). |
| |
| // If the cached trace slices don't fully cover the visible time range, |
| // show a gray rectangle with a "Loading..." label. |
| checkerboardExcept( |
| ctx, |
| this.getHeight(), |
| timeScale.timeToPx(vizTime.start), |
| timeScale.timeToPx(vizTime.end), |
| timeScale.timeToPx(fromNs(this.slicesStartNs)), |
| timeScale.timeToPx(fromNs(this.slicesEndNs))); |
| |
| let charWidth = this.charWidth; |
| if (charWidth < 0) { |
| // TODO(hjd): Centralize font measurement/invalidation. |
| ctx.font = '12px Roboto Condensed'; |
| charWidth = this.charWidth = ctx.measureText('dbpqaouk').width / 8; |
| } |
| |
| // Filter only the visible slices. |this.slices| will have more slices than |
| // needed because maybeRequestData() over-fetches to handle small pan/zooms. |
| // We don't want to waste time drawing slices that are off screen. |
| const vizSlices = this.getVisibleSlicesInternal(vizTime.start, vizTime.end); |
| |
| let selection = globals.state.currentSelection; |
| |
| if (!selection || !this.selectionKinds.includes(selection.kind)) { |
| selection = null; |
| } |
| |
| // Believe it or not, doing 4xO(N) passes is ~2x faster than trying to draw |
| // everything in one go. The key is that state changes operations on the |
| // canvas (e.g., color, fonts) dominate any number crunching we do in JS. |
| |
| this.updateSliceAndTrackHeight(); |
| const sliceHeight = this.computedSliceHeight; |
| const padding = this.sliceLayout.padding; |
| const rowSpacing = this.computedRowSpacing; |
| |
| // First pass: compute geometry of slices. |
| let selSlice: CastInternal<T['slice']>|undefined; |
| |
| // pxEnd is the last visible pixel in the visible viewport. Drawing |
| // anything < 0 or > pxEnd doesn't produce any visible effect as it goes |
| // beyond the visible portion of the canvas. |
| const pxEnd = Math.floor(timeScale.timeToPx(vizTime.end)); |
| |
| for (const slice of vizSlices) { |
| // Compute the basic geometry for any visible slice, even if only |
| // partially visible. This might end up with a negative x if the |
| // slice starts before the visible time or with a width that overflows |
| // pxEnd. |
| slice.x = timeScale.timeToPx(slice.startS); |
| slice.w = timeScale.deltaTimeToPx(slice.durationS); |
| if (slice.flags & SLICE_FLAGS_INSTANT) { |
| // In the case of an instant slice, set the slice geometry on the |
| // bounding box that will contain the chevron. |
| slice.x -= CHEVRON_WIDTH_PX / 2; |
| slice.w = CHEVRON_WIDTH_PX; |
| } else { |
| // If the slice is an actual slice, intersect the slice geometry with |
| // the visible viewport (this affects only the first and last slice). |
| // This is so that text is always centered even if we are zoomed in. |
| // Visually if we have |
| // [ visible viewport ] |
| // [ slice ] |
| // The resulting geometry will be: |
| // [slice] |
| // So that the slice title stays within the visible region. |
| const sliceVizLimit = Math.min(slice.x + slice.w, pxEnd); |
| slice.x = Math.max(slice.x, 0); |
| slice.w = sliceVizLimit - slice.x; |
| } |
| |
| if (selection && (selection as {id: number}).id === slice.id) { |
| selSlice = slice; |
| } |
| } |
| |
| // Second pass: fill slices by color. |
| // The .slice() turned out to be an unintended pun. |
| const vizSlicesByColor = vizSlices.slice(); |
| vizSlicesByColor.sort((a, b) => colorCompare(a.color, b.color)); |
| let lastColor = undefined; |
| for (const slice of vizSlicesByColor) { |
| if (slice.color !== lastColor) { |
| lastColor = slice.color; |
| ctx.fillStyle = colorToStr(slice.color); |
| } |
| const y = padding + slice.depth * (sliceHeight + rowSpacing); |
| if (slice.flags & SLICE_FLAGS_INSTANT) { |
| this.drawChevron(ctx, slice.x, y, sliceHeight); |
| } else if (slice.flags & SLICE_FLAGS_INCOMPLETE) { |
| const w = Math.max(slice.w - 2, 2); |
| drawIncompleteSlice(ctx, slice.x, y, w, sliceHeight); |
| } else if (slice.w > SLICE_MIN_WIDTH_PX) { |
| ctx.fillRect(slice.x, y, slice.w, sliceHeight); |
| } |
| } |
| |
| // Third pass, draw the titles (e.g., process name for sched slices). |
| ctx.fillStyle = '#fff'; |
| ctx.textAlign = 'center'; |
| ctx.font = '12px Roboto Condensed'; |
| ctx.textBaseline = 'middle'; |
| for (const slice of vizSlices) { |
| if ((slice.flags & SLICE_FLAGS_INSTANT) || !slice.title || |
| slice.w < SLICE_MIN_WIDTH_FOR_TEXT_PX) { |
| continue; |
| } |
| |
| const title = cropText(slice.title, charWidth, slice.w); |
| const rectXCenter = slice.x + slice.w / 2; |
| const y = padding + slice.depth * (sliceHeight + rowSpacing); |
| const yDiv = slice.subTitle ? 3 : 2; |
| const yMidPoint = Math.floor(y + sliceHeight / yDiv) - 0.5; |
| ctx.fillText(title, rectXCenter, yMidPoint); |
| } |
| |
| // Fourth pass, draw the subtitles (e.g., thread name for sched slices). |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; |
| ctx.font = '10px Roboto Condensed'; |
| for (const slice of vizSlices) { |
| if (slice.w < SLICE_MIN_WIDTH_FOR_TEXT_PX || !slice.subTitle || |
| (slice.flags & SLICE_FLAGS_INSTANT)) { |
| continue; |
| } |
| const rectXCenter = slice.x + slice.w / 2; |
| const subTitle = cropText(slice.subTitle, charWidth, slice.w); |
| const y = padding + slice.depth * (sliceHeight + rowSpacing); |
| const yMidPoint = Math.ceil(y + sliceHeight * 2 / 3) + 1.5; |
| ctx.fillText(subTitle, rectXCenter, yMidPoint); |
| } |
| |
| // Draw a thicker border around the selected slice (or chevron). |
| if (selSlice !== undefined) { |
| const color = selSlice.color; |
| const y = padding + selSlice.depth * (sliceHeight + rowSpacing); |
| ctx.strokeStyle = `hsl(${color.h}, ${color.s}%, 30%)`; |
| ctx.beginPath(); |
| const THICKNESS = 3; |
| ctx.lineWidth = THICKNESS; |
| ctx.strokeRect( |
| selSlice.x, y - THICKNESS / 2, selSlice.w, sliceHeight + THICKNESS); |
| ctx.closePath(); |
| } |
| |
| // TODO(hjd): Remove this. |
| // The only thing this does is drawing the sched latency arrow. We should |
| // have some abstraction for that arrow (ideally the same we'd use for |
| // flows). |
| this.drawSchedLatencyArrow(ctx, selSlice); |
| |
| // If a slice is hovered, draw the tooltip. |
| const tooltip = this.hoverTooltip; |
| if (this.hoveredSlice !== undefined && tooltip.length > 0 && |
| this.hoverPos !== undefined) { |
| if (tooltip.length === 1) { |
| this.drawTrackHoverTooltip(ctx, this.hoverPos, tooltip[0]); |
| } else { |
| this.drawTrackHoverTooltip(ctx, this.hoverPos, tooltip[0], tooltip[1]); |
| } |
| } // if (howSlice) |
| } |
| |
| onDestroy() { |
| super.onDestroy(); |
| this.isDestroyed = true; |
| this.engine.query(`DROP VIEW IF EXISTS ${this.tableName}`); |
| } |
| |
| // This method figures out if the visible window is outside the bounds of |
| // the cached data and if so issues new queries (i.e. sorta subsumes the |
| // onBoundsChange). |
| async maybeRequestData() { |
| // Important: this method is async and is invoked on every frame. Care |
| // must be taken to avoid piling up queries on every frame, hence the FSM. |
| if (this.sqlState === 'UNINITIALIZED') { |
| this.sqlState = 'INITIALIZING'; |
| |
| if (this.isDestroyed) { |
| return; |
| } |
| await this.initSqlTable(this.tableName); |
| |
| if (this.isDestroyed) { |
| return; |
| } |
| const queryRes = await this.engine.query(`select |
| ifnull(max(dur), 0) as maxDur, count(1) as rowCount |
| from ${this.tableName}`); |
| const row = queryRes.firstRow({maxDur: NUM, rowCount: NUM}); |
| this.maxDurNs = row.maxDur; |
| this.sqlState = 'QUERY_DONE'; |
| } else if ( |
| this.sqlState === 'INITIALIZING' || this.sqlState === 'QUERY_PENDING') { |
| return; |
| } |
| |
| const resolutionNs = toNs(globals.getCurResolution()); |
| const vizTime = globals.frontendLocalState.visibleWindowTime; |
| |
| const startNs = toNs(vizTime.start); |
| const endNs = toNs(vizTime.end); |
| |
| // TODO(hjd): figure out / centralize the resolution steps. |
| // Will handle this at the same time as cacheing. |
| const bucketNs = resolutionNs; |
| |
| if (startNs >= this.slicesStartNs && endNs <= this.slicesEndNs && |
| bucketNs === this.slicesBucketNs) { |
| return; // We have the data already, no need to re-query |
| } |
| |
| this.sqlState = 'QUERY_PENDING'; |
| const queryTsq = `(ts + ${bucketNs / 2}) / ${bucketNs} * ${bucketNs}`; |
| |
| const extraCols = this.extraSqlColumns.join(','); |
| let depthCol = 'depth'; |
| let maybeGroupByDepth = 'depth, '; |
| const layout = this.sliceLayout; |
| const isFlat = (layout.maxDepth - layout.minDepth) <= 1; |
| // maxDepth === minDepth only makes sense if track is empty which on the |
| // one hand isn't very useful (and so maybe should be an error) on the |
| // other hand I can see it happening if someone does: |
| // minDepth = min(slices.depth); maxDepth = max(slices.depth); |
| // and slices is empty, so we treat that as flat. |
| if (isFlat) { |
| depthCol = `${this.sliceLayout.minDepth} as depth`; |
| maybeGroupByDepth = ''; |
| } |
| |
| // TODO(hjd): Re-reason and improve this query: |
| // - Materialize the unfinished slices one off. |
| // - Avoid the union if we know we don't have any -1 slices. |
| // - Maybe we don't need the union at all and can deal in TS? |
| if (this.isDestroyed) { |
| return; |
| } |
| const queryRes = await this.engine.query(` |
| with q1 as ( |
| select |
| ${queryTsq} as tsq, |
| ts, |
| max(dur) as dur, |
| id, |
| ${depthCol} |
| ${extraCols ? ',' + extraCols : ''} |
| from ${this.tableName} |
| where |
| ts >= ${startNs - this.maxDurNs /* - durNs */} and |
| ts <= ${endNs /* + durNs */} |
| group by ${maybeGroupByDepth} tsq |
| order by tsq), |
| q2 as ( |
| select |
| ${queryTsq} as tsq, |
| ts, |
| -1 as dur, |
| id, |
| ${depthCol} |
| ${extraCols ? ',' + extraCols : ''} |
| from ${this.tableName} |
| where dur = -1 |
| group by ${maybeGroupByDepth} tsq |
| ) |
| select min(dur) as _unused, * from |
| (select * from q1 union all select * from q2) |
| group by ${maybeGroupByDepth} tsq |
| order by tsq |
| `); |
| this.convertQueryResultToSlices(queryRes, startNs, endNs, bucketNs); |
| this.sqlState = 'QUERY_DONE'; |
| globals.rafScheduler.scheduleRedraw(); |
| } |
| |
| // Here convert each row to a Slice. We do what we can do generically |
| // in the base class, and delegate the rest to the impl via that rowToSlice() |
| // abstract call. |
| convertQueryResultToSlices( |
| queryRes: QueryResult, startNs: number, endNs: number, bucketNs: number) { |
| const slices = new Array<CastInternal<T['slice']>>(queryRes.numRows()); |
| const it = queryRes.iter(this.getRowSpec()); |
| |
| let maxDataDepth = this.maxDataDepth; |
| this.slicesStartNs = startNs; |
| this.slicesEndNs = endNs; |
| this.slicesBucketNs = bucketNs; |
| for (let i = 0; it.valid(); it.next(), ++i) { |
| maxDataDepth = Math.max(maxDataDepth, it.depth); |
| |
| // Construct the base slice. The Impl will construct and return the full |
| // derived T["slice"] (e.g. CpuSlice) in the rowToSlice() method. |
| slices[i] = this.rowToSliceInternal(it); |
| } |
| this.maxDataDepth = maxDataDepth; |
| this.slices = slices; |
| } |
| |
| private rowToSliceInternal(row: T['row']): CastInternal<T['slice']> { |
| const slice = this.rowToSlice(row) as CastInternal<T['slice']>; |
| slice.x = -1; |
| slice.w = -1; |
| return slice; |
| } |
| |
| rowToSlice(row: T['row']): T['slice'] { |
| const startNsQ = row.tsq; |
| const startNs = row.ts; |
| let flags = 0; |
| let durNs: number; |
| if (row.dur === -1) { |
| durNs = toNs(globals.state.traceTime.endSec) - startNs; |
| flags |= SLICE_FLAGS_INCOMPLETE; |
| } else { |
| flags |= (row.dur === 0) ? SLICE_FLAGS_INSTANT : 0; |
| durNs = row.dur; |
| } |
| const endNs = startNs + durNs; |
| const bucketNs = this.slicesBucketNs; |
| let endNsQ = Math.floor((endNs + bucketNs / 2 - 1) / bucketNs) * bucketNs; |
| endNsQ = Math.max(endNsQ, startNsQ + bucketNs); |
| |
| return { |
| id: row.id, |
| startS: fromNs(startNsQ), |
| durationS: fromNs(endNsQ - startNsQ), |
| flags, |
| depth: row.depth, |
| title: '', |
| subTitle: '', |
| |
| // The derived class doesn't need to initialize these. They are |
| // rewritten on every renderCanvas() call. We just need to initialize |
| // them to something. |
| baseColor: DEFAULT_SLICE_COLOR, |
| color: DEFAULT_SLICE_COLOR, |
| }; |
| } |
| |
| private findSlice({x, y}: {x: number, y: number}): undefined|Slice { |
| const trackHeight = this.computedTrackHeight; |
| const sliceHeight = this.computedSliceHeight; |
| const padding = this.sliceLayout.padding; |
| const rowSpacing = this.computedRowSpacing; |
| |
| // Need at least a draw pass to resolve the slice layout. |
| if (sliceHeight === 0) { |
| return undefined; |
| } |
| |
| if (y >= padding && y <= trackHeight - padding) { |
| const depth = Math.floor((y - padding) / (sliceHeight + rowSpacing)); |
| for (const slice of this.slices) { |
| if (slice.depth === depth && slice.x <= x && x <= slice.x + slice.w) { |
| return slice; |
| } |
| } |
| } |
| |
| return undefined; |
| } |
| |
| onMouseMove(position: {x: number, y: number}): void { |
| this.hoverPos = position; |
| this.updateHoveredSlice(this.findSlice(position)); |
| } |
| |
| onMouseOut(): void { |
| this.updateHoveredSlice(undefined); |
| } |
| |
| private updateHoveredSlice(slice?: T['slice']): void { |
| const lastHoveredSlice = this.hoveredSlice; |
| this.hoveredSlice = slice; |
| |
| // Only notify the Impl if the hovered slice changes: |
| if (slice === lastHoveredSlice) return; |
| |
| if (this.hoveredSlice === undefined) { |
| globals.dispatch(Actions.setHighlightedSliceId({sliceId: -1})); |
| this.onSliceOut({slice: assertExists(lastHoveredSlice)}); |
| this.hoverTooltip = []; |
| this.hoverPos = undefined; |
| } else { |
| const args: OnSliceOverArgs<T['slice']> = {slice: this.hoveredSlice}; |
| globals.dispatch( |
| Actions.setHighlightedSliceId({sliceId: this.hoveredSlice.id})); |
| this.onSliceOver(args); |
| this.hoverTooltip = args.tooltip || []; |
| } |
| } |
| |
| onMouseClick(position: {x: number, y: number}): boolean { |
| const slice = this.findSlice(position); |
| if (slice === undefined) { |
| return false; |
| } |
| const args: OnSliceClickArgs<T['slice']> = {slice}; |
| this.onSliceClick(args); |
| return true; |
| } |
| |
| private getVisibleSlicesInternal(startS: number, endS: number): |
| Array<CastInternal<T['slice']>> { |
| return this.getVisibleSlices(startS, endS); |
| } |
| |
| getVisibleSlices(startS: number, endS: number): |
| Array<CastInternal<T['slice']>> { |
| let startIdx = -1; |
| let endIdx = -1; |
| let i = 0; |
| |
| // TODO(hjd): binary search. |
| for (const slice of this.slices) { |
| if (startIdx < 0 && slice.startS + slice.durationS >= startS) { |
| startIdx = i; |
| } |
| if (slice.startS <= endS) { |
| endIdx = i + 1; |
| } else if (slice.startS > endS) { |
| endIdx = i; |
| break; |
| } |
| i++; |
| } |
| return this.slices.slice(startIdx, endIdx); |
| } |
| |
| private updateSliceAndTrackHeight() { |
| const lay = this.sliceLayout; |
| |
| const rows = |
| Math.min(Math.max(this.maxDataDepth + 1, lay.minDepth), lay.maxDepth); |
| |
| // Compute the track height. |
| let trackHeight; |
| if (lay.heightMode === 'FIXED') { |
| trackHeight = lay.fixedHeight; |
| } else { |
| trackHeight = 2 * lay.padding + rows * (lay.sliceHeight + lay.rowSpacing); |
| } |
| |
| // Compute the slice height. |
| let sliceHeight: number; |
| let rowSpacing: number = lay.rowSpacing; |
| if (lay.heightMode === 'FIXED') { |
| const rowHeight = (trackHeight - 2 * lay.padding) / rows; |
| sliceHeight = Math.floor(Math.max(rowHeight - lay.rowSpacing, 0.5)); |
| rowSpacing = Math.max(lay.rowSpacing, rowHeight - sliceHeight); |
| rowSpacing = Math.floor(rowSpacing * 2) / 2; |
| } else { |
| sliceHeight = lay.sliceHeight; |
| } |
| this.computedSliceHeight = sliceHeight; |
| this.computedTrackHeight = trackHeight; |
| this.computedRowSpacing = rowSpacing; |
| } |
| |
| private drawChevron( |
| ctx: CanvasRenderingContext2D, x: number, y: number, h: number) { |
| // Draw an upward facing chevrons, in order: A, B, C, D, and back to A. |
| // . (x, y) |
| // A |
| // ### |
| // ##C## |
| // ## ## |
| // D B |
| // . (x + CHEVRON_WIDTH_PX, y + h) |
| const HALF_CHEVRON_WIDTH_PX = CHEVRON_WIDTH_PX / 2; |
| const midX = x + HALF_CHEVRON_WIDTH_PX; |
| ctx.beginPath(); |
| ctx.moveTo(midX, y); // A. |
| ctx.lineTo(x + CHEVRON_WIDTH_PX, y + h); // B. |
| ctx.lineTo(midX, y + h - HALF_CHEVRON_WIDTH_PX); // C. |
| ctx.lineTo(x, y + h); // D. |
| ctx.lineTo(midX, y); // Back to A. |
| ctx.closePath(); |
| ctx.fill(); |
| } |
| |
| // This is a good default implemenation for highlighting slices. By default |
| // prepareSlices() calls this. However, if the XxxSliceTrack impl overrides |
| // prepareSlices() this gives them a chance to call the highlighting witout |
| // having to reimplement it. |
| protected highlightHovererdAndSameTitle(slices: Slice[]) { |
| for (const slice of slices) { |
| const isHovering = globals.state.highlightedSliceId === slice.id || |
| (this.hoveredSlice && this.hoveredSlice.title === slice.title); |
| if (isHovering) { |
| slice.color = { |
| c: slice.baseColor.c, |
| h: slice.baseColor.h, |
| s: slice.baseColor.s, |
| l: 30 |
| }; |
| } else { |
| slice.color = slice.baseColor; |
| } |
| } |
| } |
| |
| getHeight(): number { |
| this.updateSliceAndTrackHeight(); |
| return this.computedTrackHeight; |
| } |
| |
| getSliceRect(_tStart: number, _tEnd: number, _depth: number): SliceRect |
| |undefined { |
| // TODO(hjd): Implement this as part of updating flow events. |
| return undefined; |
| } |
| } |
| |
| // This is the argument passed to onSliceOver(args). |
| // This is really a workaround for the fact that TypeScript doesn't allow |
| // inner types within a class (whether the class is templated or not). |
| export interface OnSliceOverArgs<S extends Slice> { |
| // Input args (BaseSliceTrack -> Impl): |
| slice: S; // The slice being hovered. |
| |
| // Output args (Impl -> BaseSliceTrack): |
| tooltip?: string[]; // One entry per row, up to a max of 2. |
| } |
| |
| export interface OnSliceOutArgs<S extends Slice> { |
| // Input args (BaseSliceTrack -> Impl): |
| slice: S; // The slice which is not hovered anymore. |
| } |
| |
| export interface OnSliceClickArgs<S extends Slice> { |
| // Input args (BaseSliceTrack -> Impl): |
| slice: S; // The slice which is clicked. |
| } |