blob: 4017b8c5ecd10ee031db6cd1afb891442bb663cd [file] [edit]
// Copyright (C) 2023 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 m from 'mithril';
import {Button} from '../../widgets/button';
import {Icons} from '../../base/semantic_icons';
import type {ColorScheme} from '../../base/color_scheme';
import type {
Point2D,
Size2D,
Transform1D,
VerticalBounds,
} from '../../base/geom';
import {ensureExists} from '../../base/assert';
import {Monitor} from '../../base/monitor';
import {
type CancellationSignal,
QuerySlot,
QUERY_CANCELLED,
SerialTaskQueue,
} from '../../base/query_slot';
import {type duration, Time, type time} from '../../base/time';
import type {TimeScale} from '../../base/time_scale';
import {clamp, floatEqual} from '../../base/math_utils';
import {exists} from '../../base/utils';
import {deferChunkedTask} from '../../base/chunked_task';
import type {TrackEventDetailsPanel} from '../../public/details_panel';
import type {
TrackEventDetails,
TrackEventSelection,
} from '../../public/selection';
import type {Trace} from '../../public/trace';
import type {
SnapPoint,
TrackMouseEvent,
TrackRenderContext,
TrackRenderer,
} from '../../public/track';
import {type DatasetSchema, SourceDataset} from '../../trace_processor/dataset';
import {
type SqlValue,
LONG,
NUM,
LONG_NULL,
NUM_NULL,
} from '../../trace_processor/query_result';
import {
createPerfettoTable,
createVirtualTable,
type DisposableSqlEntity,
} from '../../trace_processor/sql_utils';
import {checkerboardExcept} from '../checkerboard';
import {getColorForSlice} from '../colorizer';
import {formatDuration} from '../time_utils';
import {BufferedBounds} from './buffered_bounds';
import {CHUNKED_TASK_BACKGROUND_PRIORITY} from './feature_flags';
import {SliceTrackDetailsPanel} from './slice_track_details_panel';
import {
RECT_PATTERN_FADE_RIGHT,
type RowLayout,
rowHeightFromLayout,
rowTopFromLayout,
} from '../../base/renderer';
import {cropText} from '../../base/string_utils';
const SLICE_MIN_WIDTH_FOR_TEXT_PX = 5;
const CHEVRON_WIDTH_PX = 10;
export const enum ColorVariant {
BASE = 0,
VARIANT = 1,
DISABLED = 2,
}
interface Slice<T> {
readonly id: number;
readonly title: string;
readonly subtitle: string;
readonly count: number; // Number of slices in this bucket
readonly colorScheme: ColorScheme;
readonly fillRatio: number;
readonly row: T; // The raw dataset row
}
interface SliceBuffers<T> {
readonly starts: Float32Array;
readonly ends: Float32Array;
readonly depths: Uint16Array;
readonly patterns: Uint8Array;
readonly slices: readonly Slice<T>[];
readonly count: number;
}
interface Instant<T> {
readonly id: number;
readonly title: string;
readonly subtitle: string;
readonly count: number; // Number of slices in this bucket
readonly colorScheme: ColorScheme;
readonly row: T; // The raw dataset row
}
interface InstantBuffers<T> {
readonly xs: Float32Array;
readonly depths: Uint16Array;
readonly instants: readonly Instant<T>[];
readonly count: number;
}
interface DataFrame<T> {
readonly start: time;
readonly end: time;
readonly slices: SliceBuffers<T>;
readonly instants: InstantBuffers<T>;
}
type SliceOrInstant<T> = Slice<T> | Instant<T>;
// Height of collapsed (non-top) rows in pixels.
const COLLAPSED_ROW_HEIGHT = 3;
export interface SliceLayout {
// Vertical spacing between slices and track.
readonly padding: number;
// Spacing between rows.
readonly rowGap: number;
// Height of each slice (i.e. height of each row).
readonly sliceHeight: number;
// Title font size.
readonly titleSizePx: number;
// Subtitle font size.
readonly subtitleSizePx: number;
// When true, depth 0 uses sliceHeight but all deeper rows use a compact
// height (COLLAPSED_ROW_HEIGHT), giving a summary view that still shows
// nesting activity.
readonly collapsed: boolean;
}
// Callback argument types - use SliceBase to support both complete and incomplete slices
export interface OnSliceOverArgs<T> {
slice: SliceOrInstant<T>;
tooltip?: string[];
}
export interface OnSliceOutArgs<T> {
slice: SliceOrInstant<T>;
}
export interface OnSliceClickArgs<T> {
slice: SliceOrInstant<T>;
}
export interface InstantStyle {
/**
* Defines the width of an instant event. This, combined with the row height,
* defines the event's hitbox. This width is forwarded to the render function.
*/
readonly width: number;
/**
* Customize how instant events are rendered.
*
* @param ctx - CanvasRenderingContext to draw to.
* @param rect - Position of the TL corner & size of the instant event's
* bounding box.
*/
render(ctx: CanvasRenderingContext2D, rect: Size2D & Point2D): void;
}
export interface SliceTrackAttrs<T extends DatasetSchema> {
/**
* The trace object used by the track for accessing the query engine and other
* trace-related resources.
*/
readonly trace: Trace;
/**
* The URI of this track, which must match the URI specified in the track
* descriptor.
*/
readonly uri: string;
/**
* The source dataset defining the content of this track.
*
* A source dataset consists of a SQL select statement or table name with a
* column schema and optional filtering information. It represents a set of
* instructions to extract slice-like rows from trace processor that
* represents the content of this track, which avoids the need to materialize
* all slices into JavaScript beforehand.
*
* Required columns:
* - `ts` (LONG): Timestamp of each event (in nanoseconds).
*
* Auto-generated columns (if not provided):
* - `id` (NUM): Unique identifier for slices in the track.
*
* Optional columns:
* - `dur` (LONG): Duration of each event (in nanoseconds).
* - `depth` (NUM): Depth of each event, used for vertical arrangement.
* - `layer` (NUM): Layer value for mipmap function.
*/
readonly dataset: SourceDataset<T> | (() => SourceDataset<T>);
/**
* An optional initial estimate for the maximum depth value.
*/
readonly initialMaxDepth?: number;
/**
* An optional root table name for the track's data source.
*/
readonly rootTableName?: string;
/**
* Override the default geometry and layout of the slices.
*/
readonly sliceLayout?: Partial<SliceLayout>;
/**
* Override the appearance of instant events.
*/
readonly instantStyle?: InstantStyle;
/**
* Override the color scheme for each event.
*/
colorizer?(row: T): ColorScheme;
/**
* Override the text displayed on each event (title).
*/
sliceName?(row: T): string;
/**
* Override the subtitle displayed on each event.
*/
sliceSubtitle?(row: T): string;
/**
* Override the tooltip content for each event.
*/
tooltip?(slice: SliceOrInstant<T>): m.Children;
/**
* Customize the details panel for events on this track.
*/
detailsPanel?(row: T): TrackEventDetailsPanel;
/**
* Define the fill ratio for slices (0.0 to 1.0).
*/
fillRatio?(row: T): number;
/**
* Override the pattern for each slice (e.g., RECT_PATTERN_HATCHED for RT threads).
*/
slicePattern?(row: T): number;
/**
* Define buttons displayed on the track shell.
*/
shellButtons?(): m.Children;
/**
* Called once per render cycle before drawing. Return an array of
* ColorVariant values (one per slice) to control each slice's color.
*/
onUpdatedSlices?(slices: readonly SliceOrInstant<T>[]): ColorVariant[];
/**
* Called when a slice is hovered.
*/
onSliceOver?(args: OnSliceOverArgs<T>): void;
/**
* Called when hover leaves a slice.
*/
onSliceOut?(args: OnSliceOutArgs<T>): void;
/**
* Called when a slice is clicked. Return false to prevent default selection.
*/
onSliceClick?(args: OnSliceClickArgs<T>): void;
}
interface Tables extends AsyncDisposable {
readonly slicesMipmapTable: DisposableSqlEntity;
readonly instantsMipmapTable: DisposableSqlEntity;
readonly incompleteSlicesTable: DisposableSqlEntity;
}
export type RowSchema = {
readonly id?: number;
readonly ts: bigint;
readonly dur?: bigint | null;
readonly depth?: number;
readonly layer?: number;
} & DatasetSchema;
function getDataset<T extends DatasetSchema>(
attrs: SliceTrackAttrs<T>,
): SourceDataset<T> {
const dataset = attrs.dataset;
return typeof dataset === 'function' ? dataset() : dataset;
}
export class SliceTrack<T extends RowSchema> implements TrackRenderer {
readonly rootTableName?: string;
private readonly trace: Trace;
private readonly uri: string;
private sliceLayout: SliceLayout;
private readonly attrs: SliceTrackAttrs<T>;
private readonly instantWidthPx: number;
private readonly queue = new SerialTaskQueue();
private readonly tablesSlot = new QuerySlot<Tables>(this.queue);
private readonly dataFrameSlot = new QuerySlot<DataFrame<T>>(this.queue);
private readonly bufferedBounds = new BufferedBounds();
private readonly hoverMonitor = new Monitor([() => this.hoveredSlice?.id]);
private hoveredSlice?: SliceOrInstant<T>;
private charWidth = {title: -1, subtitle: -1};
private computedTrackHeight = 0;
private currentDataFrame?: DataFrame<T>;
private rowCount: number;
/**
* Factory function to create a SliceTrack.
*/
static create<T extends RowSchema>(attrs: SliceTrackAttrs<T>): SliceTrack<T> {
return new SliceTrack(attrs);
}
/**
* Async factory function to create a SliceTrack with a materialized dataset.
*/
static async createMaterialized<T extends RowSchema>(
attrs: SliceTrackAttrs<T>,
): Promise<SliceTrack<T>> {
const originalDataset = getDataset(attrs);
const materializedTable = await createPerfettoTable({
engine: attrs.trace.engine,
as: generateRenderQuery(originalDataset),
});
const materializedDataset = new SourceDataset({
src: materializedTable.name,
schema: {
...originalDataset.schema,
id: NUM,
layer: NUM,
depth: NUM,
dur: LONG,
},
});
return new SliceTrack({
...attrs,
dataset: materializedDataset,
});
}
private constructor(attrs: SliceTrackAttrs<T>) {
this.attrs = attrs;
this.trace = attrs.trace;
this.uri = attrs.uri;
this.rootTableName = attrs.rootTableName;
if (attrs.initialMaxDepth !== undefined) {
// Row count is max depth + 1
this.rowCount = attrs.initialMaxDepth + 1;
} else {
// Assume at least one row
this.rowCount = 1;
}
this.instantWidthPx = attrs.instantStyle?.width ?? CHEVRON_WIDTH_PX;
const sliceLayout = attrs.sliceLayout ?? {};
this.sliceLayout = {
padding: sliceLayout.padding ?? 3,
rowGap: sliceLayout.rowGap ?? 0,
sliceHeight: sliceLayout.sliceHeight ?? 18,
titleSizePx: sliceLayout.titleSizePx ?? 12,
subtitleSizePx: sliceLayout.subtitleSizePx ?? 10,
collapsed: sliceLayout.collapsed ?? false,
};
}
render(trackCtx: TrackRenderContext): void {
const {ctx, size, timescale} = trackCtx;
// Query for new data given the current state or reuse cache
const dataFrame = this.useData(trackCtx);
// Cache the current data frame for use in event handlers
this.currentDataFrame = dataFrame;
// If we have no data, we can't render anything
if (!dataFrame) return;
const pxEnd = size.width;
const pxPerNs = timescale.durationToPx(1n);
const baseOffsetPx = timescale.timeToPx(dataFrame.start);
const charWidth = this.measureCharWidth(ctx);
const selection = this.trace.selection.selection;
const selectedId =
selection.kind === 'track_event' && selection.trackUri === this.uri
? selection.eventId
: undefined;
const xTransform: Transform1D = {
scale: pxPerNs,
offset: baseOffsetPx,
};
this.renderSlices(
trackCtx,
dataFrame.slices,
xTransform,
pxEnd,
pxPerNs,
baseOffsetPx,
charWidth,
selectedId,
);
// Render instants after slices so they appear on top
this.renderInstants(
trackCtx,
dataFrame.instants,
xTransform,
pxPerNs,
baseOffsetPx,
selectedId,
);
// Checkerboard for loading areas
const frameStartPx = timescale.timeToPx(dataFrame.start);
const frameEndPx = timescale.timeToPx(dataFrame.end);
checkerboardExcept(
ctx,
this.getHeight(),
0,
size.width,
frameStartPx,
frameEndPx,
);
}
private renderSlices(
trackCtx: TrackRenderContext,
sliceBuffers: SliceBuffers<T>,
xTransform: Transform1D,
pxEnd: number,
pxPerNs: number,
baseOffsetPx: number,
charWidth: {title: number; subtitle: number},
selectedId: number | undefined,
): void {
const {ctx, renderer} = trackCtx;
const {starts, ends, depths, patterns, slices, count} = sliceBuffers;
const rowLayout = this.buildRowLayout();
// Helper: get Y position for a slice at index j
const sliceTop = (j: number) => rowTopFromLayout(rowLayout, depths[j]);
// Collect text labels to render in a second pass
const textLabels: Array<{
title: string;
subTitle: string;
textColor: string;
rectXCenter: number;
titleY: number;
subTitleY: number;
}> = [];
// Recreate the colors array every time as this could have changed
// TODO(stevegolton): Find a way to avoid having to do this every frame.
const colorVariants = this.onUpdatedSlices(slices);
const colors = new Uint32Array(count);
let selectedIdx = -1;
for (let j = 0; j < count; j++) {
const slice = slices[j];
const colorVariant = colorVariants[j];
const cs = slice.colorScheme;
const color =
colorVariant === ColorVariant.BASE
? cs.base
: colorVariant === ColorVariant.VARIANT
? cs.variant
: cs.disabled;
colors[j] = color.rgba;
// Track selected slice index
if (selectedId !== undefined && slice.id === selectedId) {
selectedIdx = j;
}
// Collect text labels
const w = ends[j] - starts[j];
const wPx = w * pxPerNs;
// Skip text on collapsed rows (too small to read)
if (this.sliceLayout.collapsed && depths[j] > 0) continue;
// Skip slices that are too narrow to show text
if (wPx < SLICE_MIN_WIDTH_FOR_TEXT_PX) continue;
const x = starts[j];
const xPx = x * pxPerNs + baseOffsetPx;
// Skip slices that are completely offscreen
if (xPx + wPx <= 0 || xPx >= pxEnd) continue;
// Collect text label if wide enough (using screen-space width)
const y = sliceTop(j);
const title = slice.title;
const subTitle = slice.subtitle;
if (title || subTitle) {
const textColor =
colorVariant === ColorVariant.BASE
? cs.textBase
: colorVariant === ColorVariant.VARIANT
? cs.textVariant
: cs.textDisabled;
// Clamp slice bounds to visible window for text positioning
const clampedLeft = Math.max(xPx, 0);
const clampedRight = Math.min(xPx + wPx, pxEnd);
const clampedW = clampedRight - clampedLeft;
const rectXCenter = clampedLeft + clampedW / 2;
const yCenter = rowHeightFromLayout(rowLayout, depths[j]) / 2;
const titleOffset = subTitle ? -4 : 1; // Move title up if there's a subtitle
const titleY = Math.floor(y + yCenter) + titleOffset;
const subTitleY = Math.floor(y + yCenter) + 6;
textLabels.push({
title: cropText(title, charWidth.title, clampedW),
subTitle: cropText(subTitle, charWidth.subtitle, clampedW),
textColor: textColor.cssString,
rectXCenter,
titleY,
subTitleY,
});
}
}
renderer.drawSlices(
{
starts,
ends,
depths,
colors,
count,
patterns,
},
rowLayout,
xTransform,
);
// Draw fill ratio light overlay on the unfilled portion of each slice
ctx.fillStyle = `#FFFFFF50`;
for (let j = 0; j < count; j++) {
const slice = slices[j];
const fillRatio = clamp(slice.fillRatio, 0, 1);
if (floatEqual(fillRatio, 1)) continue;
const left = Math.max(starts[j] * pxPerNs + baseOffsetPx, 0);
const right = Math.min(ends[j] * pxPerNs + baseOffsetPx, pxEnd);
const width = right - left;
const lightSectionDrawWidth = width * (1 - fillRatio);
if (lightSectionDrawWidth < 1) continue;
if (left + width <= 0 || left >= pxEnd) continue;
const y = sliceTop(j);
ctx.fillRect(
left + (width - lightSectionDrawWidth),
y,
lightSectionDrawWidth,
rowHeightFromLayout(rowLayout, depths[j]),
);
}
// Draw text labels
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const label of textLabels) {
ctx.fillStyle = label.textColor;
if (label.title) {
ctx.font = this.getTitleFont();
ctx.fillText(label.title, label.rectXCenter, label.titleY);
}
if (label.subTitle) {
ctx.globalAlpha = 0.6; // Slightly fade subtitles for visual hierarchy
ctx.font = this.getSubtitleFont();
ctx.fillText(label.subTitle, label.rectXCenter, label.subTitleY);
ctx.globalAlpha = 1;
}
}
// Draw selection highlight
if (selectedIdx !== -1) {
// Huge rects can be subject to flickering due to floating point precision
// issues, so we clamp the selection rect to a reasonable size offscreen.
const SEL_OFFSCREEN_MAX_PX = 20;
const selLeftRaw = starts[selectedIdx] * pxPerNs + baseOffsetPx;
const selLeft = Math.max(selLeftRaw, -SEL_OFFSCREEN_MAX_PX);
const selRightRaw = ends[selectedIdx] * pxPerNs + baseOffsetPx;
const selRight = Math.min(selRightRaw, pxEnd + SEL_OFFSCREEN_MAX_PX);
const selW = selRight - selLeft;
const selY = sliceTop(selectedIdx);
const THICKNESS = 3;
ctx.strokeStyle = trackCtx.colors.COLOR_TIMELINE_OVERLAY;
ctx.lineWidth = THICKNESS;
ctx.strokeRect(
selLeft,
selY - THICKNESS / 2,
selW,
rowHeightFromLayout(rowLayout, depths[selectedIdx]) + THICKNESS,
);
}
}
private renderInstants(
trackCtx: TrackRenderContext,
instantBuffers: InstantBuffers<T>,
xTransform: Transform1D,
pxPerNs: number,
baseOffsetPx: number,
selectedId: number | undefined,
): void {
const {ctx, renderer} = trackCtx;
const {xs, depths: instantDepths, instants, count} = instantBuffers;
// Recreate the colors array every time as this could have changed
// TODO(stevegolton): Find a way to avoid having to do this every frame.
const colorVariants = this.onUpdatedSlices(instants);
const colors = new Uint32Array(count);
let selectedIdx = -1;
for (let j = 0; j < count; j++) {
const instant = instants[j];
const colorVariant = colorVariants[j];
const cs = instant.colorScheme;
const color =
colorVariant === ColorVariant.BASE
? cs.base
: colorVariant === ColorVariant.VARIANT
? cs.variant
: cs.disabled;
colors[j] = color.rgba;
// Track selected instant index
if (selectedId !== undefined && instant.id === selectedId) {
selectedIdx = j;
}
}
const rowLayout = this.buildRowLayout();
renderer.drawMarkers(
{
xs,
depths: instantDepths,
colors,
count,
},
rowLayout,
this.instantWidthPx,
xTransform,
(ctx, x, y, _w, h) => this.drawChevron(ctx, x, y, h),
);
// Draw selection highlight for instants
if (selectedIdx !== -1) {
const selX =
xs[selectedIdx] * pxPerNs + baseOffsetPx - this.instantWidthPx / 2;
const selY = rowTopFromLayout(rowLayout, instantDepths[selectedIdx]);
const selH = rowHeightFromLayout(rowLayout, instantDepths[selectedIdx]);
const THICKNESS = 3;
ctx.strokeStyle = trackCtx.colors.COLOR_TIMELINE_OVERLAY;
ctx.lineWidth = THICKNESS;
ctx.strokeRect(
selX,
selY - THICKNESS / 2,
this.instantWidthPx,
selH + THICKNESS,
);
}
}
getDataset() {
return getDataset(this.attrs);
}
private measureCharWidth(ctx: CanvasRenderingContext2D) {
const charWidth = this.charWidth;
if (charWidth.title < 0) {
ctx.font = this.getTitleFont();
charWidth.title = ctx.measureText('dbpqaouk').width / 8;
ctx.font = this.getSubtitleFont();
charWidth.subtitle = ctx.measureText('dbpqaouk').width / 8;
}
return charWidth;
}
private get engine() {
return this.trace.engine;
}
private getTitleFont(): string {
return `${this.sliceLayout.titleSizePx}px Roboto Condensed`;
}
private getSubtitleFont(): string {
return `${this.sliceLayout.subtitleSizePx}px Roboto Condensed`;
}
// Creates the mipmap table for efficient slice queries
// Also pre-computes incomplete slices with their next_ts
private async createTables(sqlSource: string): Promise<Tables> {
const engine = this.trace.engine;
const rowCount = await this.getRowCount(sqlSource);
this.rowCount = rowCount;
const slicesMipmapTable = await createVirtualTable({
engine,
using: `__intrinsic_slice_mipmap((
select id, ts, dur, ((layer * ${rowCount ?? 1}) + depth) as depth
from (${sqlSource})
where dur > 0
))`,
});
const instantsMipmapTable = await createVirtualTable({
engine,
using: `__intrinsic_slice_mipmap((
select id, ts, dur, ((layer * ${rowCount ?? 1}) + depth) as depth
from (${sqlSource})
where dur = 0
))`,
});
const incompleteSlicesTable = await createPerfettoTable({
engine,
as: `
SELECT id, ts, depth, (
SELECT i.ts
FROM (${sqlSource}) i
WHERE i.ts > o.ts AND o.depth = i.depth
ORDER BY i.ts
LIMIT 1
) AS next_ts
FROM (${sqlSource}) o
WHERE dur = -1
`,
});
return {
slicesMipmapTable,
instantsMipmapTable,
incompleteSlicesTable,
[Symbol.asyncDispose]: async () => {
await slicesMipmapTable[Symbol.asyncDispose]();
await instantsMipmapTable[Symbol.asyncDispose]();
await incompleteSlicesTable[Symbol.asyncDispose]();
},
};
}
private async getRowCount(sqlSource: string): Promise<number> {
const engine = this.trace.engine;
const result = await engine.query(`
SELECT
IFNULL(depth, 0) + 1 AS rowCount
FROM (${sqlSource})
ORDER BY depth DESC
LIMIT 1
`);
return result.maybeFirstRow({rowCount: NUM})?.rowCount ?? 0;
}
private useData(trackCtx: TrackRenderContext): DataFrame<T> | undefined {
const {resolution, visibleWindow} = trackCtx;
const dataset = this.getDataset();
const sqlSource = generateRenderQuery(dataset);
// 1. Create the mipmap tables which only depend on the sql query source
const {data: tables} = this.tablesSlot.use({
key: {sqlSource},
queryFn: () => this.createTables(sqlSource),
});
// Can't do anything until we have the tables.
if (!tables) return undefined;
// 2. Load the slices into a data frame based on the visible window and
// resolution, which could change every frame.
const visibleSpan = visibleWindow.toTimeSpan();
const bounds = this.bufferedBounds.update(visibleSpan, resolution);
const {data: dataFrame} = this.dataFrameSlot.use({
key: {
start: bounds.start,
end: bounds.end,
resolution: bounds.resolution,
},
queryFn: async (signal) => {
const promise = (async () => {
// Load complete and incomplete slices in a single query
const instants = await this.getInstantBuffers(
tables.instantsMipmapTable.name,
bounds.start,
bounds.end,
bounds.resolution,
signal,
dataset,
);
const slices = await this.getSliceBuffers(
tables.slicesMipmapTable.name,
tables.incompleteSlicesTable.name,
bounds.start,
bounds.end,
bounds.resolution,
signal,
dataset,
);
return {
start: bounds.start,
end: bounds.end,
slices,
instants,
};
})();
const result = await this.trace.taskTracker.track(
promise,
'Loading slices',
);
this.trace.raf.scheduleFullRedraw();
return result;
},
retainOn: ['start', 'end', 'resolution'],
});
return dataFrame;
}
private async getInstantBuffers(
mipmapTableName: string,
start: time,
end: time,
resolution: duration,
signal: CancellationSignal,
dataset: SourceDataset<T>,
): Promise<InstantBuffers<T>> {
const sqlSource = generateRenderQuery(dataset);
const extraCols = Object.keys(dataset.schema)
.map((c) => `s.${c} as ${c}`)
.join(',');
const queryResult = await this.engine.query(`
SELECT
s.id as __id,
((z.ts / ${resolution}) * ${resolution}) - ${start} as __ts,
z.count as __count,
s.depth as __depth,
${extraCols}
FROM ${mipmapTableName}(
${start},
${end},
${resolution}
) z
CROSS JOIN (${sqlSource}) s using (id)
`);
if (signal.isCancelled) throw QUERY_CANCELLED;
const task = await this.deferChunkedTask();
// Initialize buffers
const count = queryResult.numRows();
const xs = new Float32Array(count);
const depths = new Uint16Array(count);
const instants = new Array<Instant<T>>(count);
const it = queryResult.iter({
__id: NUM,
__ts: NUM,
__count: NUM,
__depth: NUM,
...dataset.schema,
});
for (let i = 0; it.valid(); it.next(), ++i) {
if (i % 64 === 0) {
if (signal.isCancelled) throw QUERY_CANCELLED;
if (task.shouldYield()) await task.yield();
}
const id = it.__id;
const ts = it.__ts;
const count = it.__count;
const depth = it.__depth;
const title = this.getTitle(it);
const subtitle = this.getSubtitle(it);
const colorScheme = this.getColor(it, title);
const row = this.extractKeys(it, dataset.schema);
xs[i] = ts;
depths[i] = depth;
instants[i] = {
id,
title,
subtitle,
colorScheme,
count,
row,
};
}
return {
xs,
depths,
instants,
count,
};
}
private async getSliceBuffers(
mipmapTableName: string,
incompleteTableName: string,
start: time,
end: time,
resolution: duration,
signal: CancellationSignal,
dataset: SourceDataset<T>,
): Promise<SliceBuffers<T>> {
const engine = this.trace.engine;
const sqlSource = generateRenderQuery(dataset);
const extraCols = Object.keys(dataset.schema)
.map((c) => `s.${c} as ${c}`)
.join(',');
// Query complete slices from mipmap + incomplete slices in one query
// Incomplete slices use pre-computed next_ts from incompleteTableName
const sliceQueryRes = await engine.query(`
-- Complete slices
SELECT
((z.ts / ${resolution}) * ${resolution}) - ${start} as __start,
(((z.ts + z.dur + ${resolution - 1n}) / ${resolution}) * ${resolution}) - ${start} as __end,
s.id as __id,
z.count as __count,
s.depth as __depth,
0 as __incomplete,
${extraCols}
FROM ${mipmapTableName}(
${start},
${end},
${resolution}
) z
CROSS JOIN (${sqlSource}) s using (id)
UNION ALL
-- Incomplete slices
SELECT
i.ts - ${start} as __start,
i.next_ts - ${start} as __end,
s.id as __id,
1 as __count,
i.depth as __depth,
1 as __incomplete,
${extraCols}
FROM ${incompleteTableName} i
JOIN (${sqlSource}) s ON i.id = s.id
WHERE i.ts < ${end} AND IFNULL(i.next_ts, ${end}) > ${start}
`);
if (signal.isCancelled) throw QUERY_CANCELLED;
const task = await this.deferChunkedTask();
const count = sliceQueryRes.numRows();
const starts = new Float32Array(count);
const ends = new Float32Array(count);
const depths = new Uint16Array(count);
const patterns = new Uint8Array(count);
const slices = new Array<Slice<T>>(count);
const it = sliceQueryRes.iter({
__id: NUM,
__start: NUM,
__end: NUM_NULL,
__count: NUM,
__depth: NUM,
__incomplete: NUM,
...dataset.schema,
});
for (let i = 0; it.valid(); it.next(), ++i) {
if (i % 64 === 0) {
if (signal.isCancelled) throw QUERY_CANCELLED;
if (task.shouldYield()) await task.yield();
}
const count = it.__count;
const id = it.__id;
const start = it.__start;
const end = it.__end;
const depth = it.__depth;
const title = this.getTitle(it);
const subtitle = this.getSubtitle(it);
const colorScheme = this.getColor(it, title);
const isIncomplete = it.__incomplete === 1;
const row = this.extractKeys(it, dataset.schema);
starts[i] = start;
// Incomplete slices are assigned a +Infinity end
ends[i] = end === null ? Number.POSITIVE_INFINITY : end;
depths[i] = depth;
patterns[i] = isIncomplete
? RECT_PATTERN_FADE_RIGHT
: this.attrs.slicePattern?.(it) ?? 0;
slices[i] = {
id,
title,
subtitle,
colorScheme,
count,
fillRatio: this.attrs.fillRatio?.(it) ?? 1,
row,
};
}
return {
starts,
ends,
depths,
patterns,
slices,
count,
};
}
// Efficiently copy a sebset of keys from a raw value based on some template.
// Note: Only the template's keys are used, the values are ignored (hence the
// unknown value types).
private extractKeys(from: T, template: Record<keyof T, unknown>): T {
const result = {} as T;
// eslint-disable-next-line guard-for-in
for (const k in template) {
result[k] = from[k];
}
return result;
}
private async deferChunkedTask() {
const priority = CHUNKED_TASK_BACKGROUND_PRIORITY.get()
? 'background'
: undefined;
return await deferChunkedTask({priority});
}
private getTitle(row: T): string {
if (this.attrs.sliceName) return this.attrs.sliceName(row);
if ('name' in row && typeof row.name === 'string') return row.name;
return '';
}
private getSubtitle(row: T): string {
if (this.attrs.sliceSubtitle) return this.attrs.sliceSubtitle(row);
return '';
}
private getColor(row: T, title: string | undefined): ColorScheme {
if (this.attrs.colorizer) return this.attrs.colorizer(row);
if (title) return getColorForSlice(title);
return getColorForSlice(`${row.id}`);
}
private onUpdatedSlices(
slices: readonly SliceOrInstant<T>[],
): readonly ColorVariant[] {
if (this.attrs.onUpdatedSlices) {
return this.attrs.onUpdatedSlices(slices);
} else {
return this.highlightHoveredAndSameTitle(slices);
}
}
private highlightHoveredAndSameTitle(
slices: readonly SliceOrInstant<T>[],
): readonly ColorVariant[] {
const hoveredSlice = this.hoveredSlice;
const highlightedSliceName = this.attrs.trace.timeline.highlightedSliceName;
const variants = new Array<ColorVariant>(slices.length);
if (hoveredSlice || highlightedSliceName !== undefined) {
const hoveredSliceId = hoveredSlice?.id;
const hoveredTitle = highlightedSliceName;
// Index based iteration is more efficient than .map
for (let i = 0; i < slices.length; i++) {
const {id, title} = slices[i];
variants[i] =
id === hoveredSliceId || title === hoveredTitle
? ColorVariant.VARIANT
: ColorVariant.BASE;
}
return variants;
} else {
// No hovered slice, all variants are the same. .fill is more efficient
// than iteration.
return variants.fill(ColorVariant.BASE);
}
}
renderTooltip(): m.Children {
if (!this.hoveredSlice) {
return undefined;
}
return (
this.attrs.tooltip?.(this.hoveredSlice) ??
renderTooltip(this.trace, this.hoveredSlice)
);
}
protected drawChevron(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
h: number,
) {
if (this.attrs.instantStyle?.render) {
this.attrs.instantStyle.render(ctx, {
x,
y,
height: h,
width: this.attrs.instantStyle.width,
});
} else {
const HALF_CHEVRON_WIDTH_PX = CHEVRON_WIDTH_PX / 2;
const midX = x + HALF_CHEVRON_WIDTH_PX;
ctx.beginPath();
ctx.moveTo(midX, y);
ctx.lineTo(x + CHEVRON_WIDTH_PX, y + h);
ctx.lineTo(midX, y + h - HALF_CHEVRON_WIDTH_PX);
ctx.lineTo(x, y + h);
ctx.lineTo(midX, y);
ctx.closePath();
ctx.fill();
}
}
// Build the row layout formula for the shader and CPU-side lookups.
// When collapsed, row 0 keeps full sliceHeight; deeper rows use
// COLLAPSED_ROW_HEIGHT. When expanded, all rows use sliceHeight.
private buildRowLayout(): RowLayout {
const {padding, rowGap, sliceHeight, collapsed} = this.sliceLayout;
const rowHeight = collapsed ? COLLAPSED_ROW_HEIGHT : sliceHeight;
return {
paddingTop: padding,
firstRowHeight: sliceHeight,
rowHeight,
rowGap,
};
}
private updateSliceAndTrackHeight() {
const layout = this.buildRowLayout();
const padding = layout.paddingTop ?? 0;
if (this.rowCount <= 0) {
this.computedTrackHeight = 2 * padding;
return;
}
// Row 0 height + remaining rows + gaps + padding on both sides
const lastRowBottom =
rowTopFromLayout(layout, this.rowCount - 1) +
rowHeightFromLayout(layout, this.rowCount - 1);
this.computedTrackHeight = lastRowBottom + padding;
}
getHeight(): number {
this.updateSliceAndTrackHeight();
return this.computedTrackHeight;
}
getSliceVerticalBounds(depth: number): VerticalBounds | undefined {
if (depth >= this.rowCount) return undefined;
const layout = this.buildRowLayout();
const top = rowTopFromLayout(layout, depth);
return {top, bottom: top + rowHeightFromLayout(layout, depth)};
}
private findSlice({
x,
y,
timescale,
}: TrackMouseEvent): undefined | SliceOrInstant<T> {
if (!this.currentDataFrame) return undefined;
const trackHeight = this.computedTrackHeight;
// Find which depth row the Y coordinate falls into using the two-tier
// layout: row 0 has firstRowHeight, deeper rows have rowHeight/rowStride.
const layout = this.buildRowLayout();
let depth = -1;
for (let d = 0; d < this.rowCount; d++) {
const top = rowTopFromLayout(layout, d);
const h = rowHeightFromLayout(layout, d);
if (y >= top && y <= top + h) {
depth = d;
break;
}
}
if (depth < 0) return undefined;
const pxPerNs = timescale.durationToPx(1n);
const baseOffsetPx = timescale.timeToPx(this.currentDataFrame.start);
if (
y >= this.sliceLayout.padding &&
y <= trackHeight - this.sliceLayout.padding
) {
// Check regular and incomplete slices
const sliceBufs = this.currentDataFrame.slices;
for (let i = 0; i < sliceBufs.count; i++) {
if (sliceBufs.depths[i] !== depth) continue;
const startPx = sliceBufs.starts[i] * pxPerNs + baseOffsetPx;
const endPx = sliceBufs.ends[i] * pxPerNs + baseOffsetPx;
if (startPx <= x && x <= endPx) {
return sliceBufs.slices[i];
}
}
// Check instants
const instantBufs = this.currentDataFrame.instants;
const halfWidth = this.instantWidthPx / 2;
for (let i = 0; i < instantBufs.count; i++) {
if (instantBufs.depths[i] !== depth) continue;
const instantX = instantBufs.xs[i] * pxPerNs + baseOffsetPx;
if (x >= instantX - halfWidth && x <= instantX + halfWidth) {
return instantBufs.instants[i];
}
}
}
return undefined;
}
onMouseMove(e: TrackMouseEvent): void {
const prevHoveredSlice = this.hoveredSlice;
this.hoveredSlice = this.findSlice(e);
if (this.hoverMonitor.ifStateChanged()) {
this.trace.timeline.highlightedSliceId = this.hoveredSlice?.id;
this.trace.timeline.highlightedSliceName = this.hoveredSlice?.title;
if (this.hoveredSlice === undefined) {
if (this.attrs.onSliceOut) {
this.attrs.onSliceOut({slice: ensureExists(prevHoveredSlice)});
}
} else {
if (this.attrs.onSliceOver) {
this.attrs.onSliceOver({slice: this.hoveredSlice});
}
}
this.trace.raf.scheduleFullRedraw();
}
}
onMouseOut(): void {
const prevHoveredSlice = this.hoveredSlice;
this.hoveredSlice = undefined;
if (this.hoverMonitor.ifStateChanged()) {
this.trace.timeline.highlightedSliceId = undefined;
this.trace.timeline.highlightedSliceName = undefined;
if (this.attrs.onSliceOut && prevHoveredSlice) {
this.attrs.onSliceOut({slice: prevHoveredSlice});
}
this.trace.raf.scheduleFullRedraw();
}
}
onMouseClick(event: TrackMouseEvent): boolean {
const slice = this.findSlice(event);
if (slice === undefined) {
return false;
}
if (this.attrs.onSliceClick) {
this.attrs.onSliceClick({slice});
} else {
this.trace.selection.selectTrackEvent(this.uri, slice.id);
}
return true;
}
getSnapPoint(
targetTime: time,
thresholdPx: number,
timescale: TimeScale,
): SnapPoint | undefined {
if (!this.currentDataFrame) return undefined;
const thresholdNs = timescale.pxToDuration(thresholdPx);
const targetNs = Number(targetTime);
const searchStartNs = targetNs - thresholdNs;
const searchEndNs = targetNs + thresholdNs;
let closestSnap: SnapPoint | undefined = undefined;
let closestDistNs = thresholdNs;
const checkBoundary = (boundaryNs: number) => {
if (boundaryNs < searchStartNs || boundaryNs > searchEndNs) {
return;
}
const distNs = Math.abs(targetNs - boundaryNs);
if (distNs < closestDistNs) {
closestSnap = {time: Time.fromRaw(BigInt(Math.round(boundaryNs)))};
closestDistNs = distNs;
}
};
const frameStartNs = Number(this.currentDataFrame.start);
// Check slices
const {starts, ends, count} = this.currentDataFrame.slices;
for (let i = 0; i < count; i++) {
// Convert relative start to absolute time
const sliceStartNs = frameStartNs + starts[i];
checkBoundary(sliceStartNs);
const sliceEndNs = frameStartNs + ends[i];
checkBoundary(sliceEndNs);
}
// Check instants
const instants = this.currentDataFrame.instants;
for (let i = 0; i < instants.count; i++) {
const instantNs = frameStartNs + instants.xs[i];
checkBoundary(instantNs);
}
return closestSnap;
}
detailsPanel(sel: TrackEventSelection): TrackEventDetailsPanel | undefined {
if (this.attrs.detailsPanel) {
return this.attrs.detailsPanel(sel as unknown as T);
} else {
const dataset = getDataset(this.attrs);
return new SliceTrackDetailsPanel(
this.trace,
dataset,
sel as unknown as T,
);
}
}
async getSelectionDetails(
id: number,
): Promise<TrackEventDetails | undefined> {
const dataset = getDataset(this.attrs);
const query = (function () {
if (dataset.implements({id: NUM})) {
return dataset.query();
} else {
return `
SELECT
ROW_NUMBER() OVER (ORDER BY ts) AS id,
*
FROM (${dataset.query()})
`;
}
})();
const result = await this.trace.engine.query(`
SELECT *
FROM (${query})
WHERE id = ${id}
`);
const row = result.iter(dataset.schema);
if (!row.valid()) return undefined;
const data: {[key: string]: SqlValue} = {};
for (const col of result.columns()) {
data[col] = row.get(col);
}
return {
...data,
ts: Time.fromRaw(row.ts),
};
}
getTrackShellButtons(): m.Children {
const collapseButton =
this.rowCount > 1
? m(Button, {
className: 'pf-visible-on-hover',
onclick: () => {
this.sliceLayout = {
...this.sliceLayout,
collapsed: !this.sliceLayout.collapsed,
};
},
icon: this.sliceLayout.collapsed
? Icons.UnfoldMore
: Icons.UnfoldLess,
tooltip: this.sliceLayout.collapsed
? 'Expand track'
: 'Collapse track',
compact: true,
})
: undefined;
return [collapseButton, this.attrs.shellButtons?.()];
}
}
// Helper functions
export function renderTooltip(
trace: Trace,
slice: SliceOrInstant<RowSchema>,
opts: {readonly title?: string; readonly extras?: m.Children} = {},
): m.Children {
const durationFormatted = formatDurationForTooltip(trace, slice.row.dur);
const {title = slice.title, extras} = opts;
return [
m('', exists(durationFormatted) && m('b', durationFormatted), ' ', title),
extras,
slice.count > 1 && m('div', `and ${slice.count - 1} other events`),
];
}
function formatDurationForTooltip(
trace: Trace,
dur: bigint | null | undefined,
): string | undefined {
if (dur === -1n) {
return '[Incomplete]';
}
if (dur === null || dur === undefined || dur === 0n) {
return undefined; // Instant event
}
return formatDuration(trace, BigInt(dur));
}
export function generateRenderQuery<T extends DatasetSchema>(
dataset: SourceDataset<T>,
): string {
const hasId = dataset.implements({id: NUM});
const hasLayer = dataset.implements({layer: NUM});
const extraCols = Object.fromEntries(
Object.keys(dataset.schema).map((key) => [key, key]),
);
const cols = {
...extraCols,
id: hasId ? 'id' : 'ROW_NUMBER() OVER (ORDER BY ts)',
ts: 'ts',
layer: hasLayer ? 'layer' : 0,
depth: getDepthExpression(dataset),
dur: getDurExpression(dataset),
} as const;
return `SELECT ${Object.entries(cols)
.map(([key, value]) => `${value} AS ${key}`)
.join(', ')} FROM (${dataset.query()})`;
}
function getDepthExpression<T extends DatasetSchema>(
dataset: SourceDataset<T>,
): string {
const hasDepth = dataset.implements({depth: NUM});
const hasDur = dataset.implements({dur: LONG});
const hasNullableDur = dataset.implements({dur: LONG_NULL});
if (hasDepth) {
return 'depth';
} else if (hasDur) {
return `internal_layout(ts, dur) OVER (ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)`;
} else if (hasNullableDur) {
return `internal_layout(ts, COALESCE(dur, -1)) OVER (ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)`;
} else {
return '0';
}
}
function getDurExpression<T extends DatasetSchema>(
dataset: SourceDataset<T>,
): string {
const hasDur = dataset.implements({dur: LONG});
const hasNullableDur = dataset.implements({dur: LONG_NULL});
if (hasDur) {
return 'dur';
} else if (hasNullableDur) {
return 'COALESCE(dur, -1)';
} else {
return '0';
}
}