| // 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, |
| UNEXPECTED_PINK_COLOR, |
| } from '../common/colorizer'; |
| import {NUM} from '../common/query_result'; |
| import {Selection, 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'; |
| import {BUCKETS_PER_PIXEL, CacheKey, TrackCache} from './track_cache'; |
| |
| // 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; |
| const SLICE_MIN_WIDTH_PX = 1 / BUCKETS_PER_PIXEL; |
| const CHEVRON_WIDTH_PX = 10; |
| const DEFAULT_SLICE_COLOR = UNEXPECTED_PINK_COLOR; |
| |
| // Exposed and standalone to allow for testing without making this |
| // visible to subclasses. |
| function filterVisibleSlices<S extends Slice>( |
| slices: S[], startS: number, endS: number): S[] { |
| // Here we aim to reduce the number of slices we have to draw |
| // by ignoring those that are not visible. A slice is visible iff: |
| // slice.start + slice.duration >= start && slice.start <= end |
| // It's allowable to include slices which aren't visible but we |
| // must not exclude visible slices. |
| // We could filter this.slices using this condition but since most |
| // often we should have the case where there are: |
| // - First a bunch of non-visible slices to the left of the viewport |
| // - Then a bunch of visible slices within the viewport |
| // - Finally a second bunch of non-visible slices to the right of the |
| // viewport. |
| // It seems more sensible to identify the left-most and right-most |
| // visible slices then 'slice' to select these slices and everything |
| // between. |
| |
| // We do not need to handle non-ending slices (where dur = -1 |
| // but the slice is drawn as 'infinite' length) as this is handled |
| // by a special code path. |
| // TODO(hjd): Implement special code path. |
| |
| // While the slices are guaranteed to be ordered by timestamp we must |
| // consider async slices (which are not perfectly nested). This is to |
| // say if we see slice A then B it is guaranteed the A.start <= B.start |
| // but there is no guarantee that (A.end < B.start XOR A.end >= B.end). |
| // Due to this is not possible to use binary search to find the first |
| // visible slice. Consider the following situation: |
| // start V V end |
| // AAA CCC DDD EEEEEEE |
| // BBBBBBBBBBBB GGG |
| // FFFFFFF |
| // B is visible but A and C are not. In general there could be |
| // arbitrarily many slices between B and D which are not visible. |
| |
| // You could binary search to find D (i.e. the first slice which |
| // starts after |start|) then work backwards to find B. |
| // The last visible slice is simpler, since the slices are sorted |
| // by timestamp you can binary search for the last slice such |
| // that slice.start <= end. |
| |
| // One specific edge case that will come up often is when: |
| // For all slice in slices: slice.startS > endS (e.g. all slices are to the |
| // right). Since the slices are sorted by startS we can check this easily: |
| const maybeFirstSlice: S|undefined = slices[0]; |
| if (maybeFirstSlice && maybeFirstSlice.startS > endS) { |
| return []; |
| } |
| // It's not possible to easily check the analogous edge case where all slices |
| // are to the left: |
| // For all slice in slices: slice.startS + slice.durationS < startS |
| // as the slices are not ordered by 'endS'. |
| |
| // As described above you could do some clever binary search combined with |
| // iteration however that seems quite complicated and error prone so instead |
| // the idea of the code below is that we iterate forward though the |
| // array incrementing startIdx until we find the first visible slice |
| // then backwards through the array decrementing endIdx until we find the |
| // last visible slice. In the worst case we end up doing one full pass on |
| // the array. This code is robust to slices not being sorted. |
| let startIdx = 0; |
| let endIdx = slices.length; |
| for (; startIdx < endIdx; ++startIdx) { |
| const slice = slices[startIdx]; |
| const sliceEndS = slice.startS + slice.durationS; |
| if (sliceEndS >= startS && slice.startS <= endS) { |
| break; |
| } |
| } |
| for (; startIdx < endIdx; --endIdx) { |
| const slice = slices[endIdx - 1]; |
| const sliceEndS = slice.startS + slice.durationS; |
| if (sliceEndS >= startS && slice.startS <= endS) { |
| break; |
| } |
| } |
| return slices.slice(startIdx, endIdx); |
| } |
| |
| export const filterVisibleSlicesForTesting = filterVisibleSlices; |
| |
| // 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. |
| tsqEnd: NUM, // Quantized |ts+dur|. The end bucket. |
| 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']> { |
| protected sliceLayout: SliceLayout = {...DEFAULT_SLICE_LAYOUT}; |
| |
| // This is the over-skirted cached bounds: |
| private slicesKey: CacheKey = CacheKey.zero(); |
| |
| // This is the currently 'cached' slices: |
| private slices = new Array<CastInternal<T['slice']>>(); |
| |
| // This is the slices cache: |
| private cache: TrackCache<Array<CastInternal<T['slice']>>> = |
| new TrackCache(5); |
| |
| 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; |
| |
| // 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 {} |
| |
| // The API contract of onUpdatedSlices() 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 once on every global |
| // state update. |
| // - This is NOT guaranteed to be called on every frame. For instance you |
| // cannot use this to do some colour-based animation. |
| onUpdatedSlices(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. |
| // TODO(hjd): Handle pinned tracks, which current cause a crash |
| // since the tableName we generate is the same for both. |
| 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 { |
| // Give a chance to the embedder to change colors and other stuff. |
| this.onUpdatedSlices(this.slices); |
| } |
| |
| protected isSelectionHandled(selection: Selection): boolean { |
| // 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. |
| const supportedSelectionKinds: SelectionKind[] = ['SLICE', 'CHROME_SLICE']; |
| return supportedSelectionKinds.includes(selection.kind); |
| } |
| |
| renderCanvas(ctx: CanvasRenderingContext2D): void { |
| // TODO(hjd): fonts and colors should come from the CSS and not hardcoded |
| // here. |
| const timeScale = globals.frontendLocalState.timeScale; |
| const vizTime = globals.frontendLocalState.visibleWindowTime; |
| |
| { |
| const windowSizePx = Math.max(1, timeScale.endPx - timeScale.startPx); |
| const rawStartNs = toNs(vizTime.start); |
| const rawEndNs = toNs(vizTime.end); |
| const rawSlicesKey = CacheKey.create(rawStartNs, rawEndNs, windowSizePx); |
| |
| // If the visible time range is outside the cached area, requests |
| // asynchronously new data from the SQL engine. |
| this.maybeRequestData(rawSlicesKey); |
| } |
| |
| // In any case, draw whatever we have (which might be stale/incomplete). |
| |
| 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.isSelectionHandled(selection)) { |
| 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 { |
| const w = Math.max(slice.w, SLICE_MIN_WIDTH_PX); |
| ctx.fillRect(slice.x, y, 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(); |
| } |
| |
| // 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.slicesKey.startNs)), |
| timeScale.timeToPx(fromNs(this.slicesKey.endNs))); |
| |
| // 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 (hoveredSlice) |
| } |
| |
| 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). |
| private async maybeRequestData(rawSlicesKey: CacheKey) { |
| // 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; |
| } |
| |
| if (rawSlicesKey.isCoveredBy(this.slicesKey)) { |
| return; // We have the data already, no need to re-query |
| } |
| |
| // Determine the cache key: |
| const slicesKey = rawSlicesKey.normalize(); |
| if (!rawSlicesKey.isCoveredBy(slicesKey)) { |
| throw new Error(`Normalization error ${slicesKey.toString()} ${ |
| rawSlicesKey.toString()}`); |
| } |
| |
| const maybeCachedSlices = this.cache.lookup(slicesKey); |
| if (maybeCachedSlices) { |
| this.slicesKey = slicesKey; |
| this.onUpdatedSlices(maybeCachedSlices); |
| this.slices = maybeCachedSlices; |
| return; |
| } |
| |
| this.sqlState = 'QUERY_PENDING'; |
| const bucketNs = slicesKey.bucketNs; |
| let queryTsq; |
| let queryTsqEnd; |
| // When we're zoomed into the level of single ns there is no point |
| // doing quantization (indeed it causes bad artifacts) so instead |
| // we use ts / ts+dur directly. |
| if (bucketNs === 1) { |
| queryTsq = 'ts'; |
| queryTsqEnd = 'ts + dur'; |
| } else { |
| queryTsq = `(ts + ${bucketNs / 2}) / ${bucketNs} * ${bucketNs}`; |
| queryTsqEnd = `(ts + dur + ${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) { |
| this.sqlState = 'QUERY_DONE'; |
| return; |
| } |
| // TODO(hjd): Count and expose the number of slices summarized in |
| // each bucket? |
| const queryRes = await this.engine.query(` |
| with q1 as ( |
| select |
| ${queryTsq} as tsq, |
| ${queryTsqEnd} as tsqEnd, |
| ts, |
| max(dur) as dur, |
| id, |
| ${depthCol} |
| ${extraCols ? ',' + extraCols : ''} |
| from ${this.tableName} |
| where |
| ts >= ${slicesKey.startNs - this.maxDurNs /* - durNs */} and |
| ts <= ${slicesKey.endNs /* + durNs */} |
| group by ${maybeGroupByDepth} tsq |
| order by tsq), |
| q2 as ( |
| select |
| ${queryTsq} as tsq, |
| ${queryTsqEnd} as tsqEnd, |
| 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 |
| `); |
| |
| // 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. |
| const slices = new Array<CastInternal<T['slice']>>(queryRes.numRows()); |
| const it = queryRes.iter(this.getRowSpec()); |
| |
| let maxDataDepth = this.maxDataDepth; |
| this.slicesKey = slicesKey; |
| 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.onUpdatedSlices(slices); |
| this.cache.insert(slicesKey, slices); |
| this.slices = slices; |
| |
| this.sqlState = 'QUERY_DONE'; |
| globals.rafScheduler.scheduleRedraw(); |
| } |
| |
| 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 endNsQ = row.tsqEnd; |
| let flags = 0; |
| if (row.dur === -1) { |
| flags |= SLICE_FLAGS_INCOMPLETE; |
| } else if (row.dur === 0) { |
| flags |= SLICE_FLAGS_INSTANT; |
| } |
| |
| 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 filterVisibleSlices<CastInternal<T['slice']>>( |
| this.slices, startS, endS); |
| } |
| |
| 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 implementation for highlighting slices. By default |
| // onUpdatedSlices() calls this. However, if the XxxSliceTrack impl overrides |
| // onUpdatedSlices() this gives them a chance to call the highlighting without |
| // 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. |
| } |