blob: 599cfb9d0c42f413912d12e2644a8b1b95d28f3b [file]
// Copyright (C) 2026 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 type {PerfettoSqlType} from '../../../trace_processor/perfetto_sql_type';
import type {SqlValue} from '../../../trace_processor/query_result';
import type {ChartConfig} from '../query_builder/nodes/visualisation_node';
/**
* A data source exported by a DashboardNode in the graph builder.
* Global — not tied to any specific dashboard.
*/
export interface DashboardDataSource {
readonly name: string;
readonly nodeId: string;
readonly columns: ReadonlyArray<{name: string; type?: PerfettoSqlType}>;
// Stable ID of the graph tab that owns this source.
readonly graphId: string;
// Materialized SQL table name. Undefined until the source node has been
// executed. DashboardNode resolves this eagerly on publish/update.
tableName?: string;
// Trigger execution of this source node (sync + materialize).
requestExecution?: () => Promise<void>;
}
// The canvas is divided into a 24-column grid of square cells.
// Items snap to this grid for positioning and sizing.
export const GRID_COLUMNS = 24;
export const DEFAULT_COL_SPAN = 8;
export const DEFAULT_ROW_SPAN = 6;
export const MIN_COL_SPAN = 4;
export const MIN_ROW_SPAN = 4;
// Row span for divider items (must match inline style in dashboard.ts).
export const DIVIDER_ROW_SPAN = 0;
// Visual margin (in grid cells) from the canvas edges.
export const GRID_MARGIN = 0.5;
/** A single chart on a dashboard, linked to its data source. */
export interface DashboardChart {
readonly sourceNodeId: string;
config: ChartConfig;
col?: number;
row?: number;
colSpan?: number;
rowSpan?: number;
}
/** An editable text label on the dashboard canvas. */
export interface DashboardLabel {
readonly id: string;
readonly text: string;
col?: number;
row?: number;
colSpan?: number;
rowSpan?: number;
}
/**
* A horizontal divider that splits the dashboard into segments.
* Charts above a divider are "drivers" — they show brush selections visually
* but do NOT filter their own data. Charts below a divider are "consumers" —
* they get filtered by brush selections from driver charts above.
*/
export interface DashboardDivider {
readonly id: string;
/** Grid row of the divider (spans all 24 columns). */
row: number;
/** Optional label displayed on the divider. */
label?: string;
}
/** A dashboard canvas item — a chart, label, or segment divider. */
export type DashboardItem =
| ({readonly kind: 'chart'} & DashboardChart)
| ({readonly kind: 'label'} & DashboardLabel)
| ({readonly kind: 'divider'} & DashboardDivider);
/** Get the unique ID for a dashboard item. */
export function getItemId(item: DashboardItem): string {
if (item.kind === 'chart') return item.config.id;
return item.id;
}
/**
* Return all chart items that drive `target` — i.e. charts whose brush
* selections filter `target`'s SQL query.
*
* Chart X drives chart Y when there exists a divider D with X.row < D.row
* and D.row <= Y.row.
*/
export function getDriversOf(
target: DashboardItem,
allItems: ReadonlyArray<DashboardItem>,
): DashboardItem[] {
if (target.kind !== 'chart') return [];
const targetRow = target.row ?? 0;
const dividerRows: number[] = [];
for (const i of allItems) {
if (i.kind === 'divider' && i.row <= targetRow) {
dividerRows.push(i.row);
}
}
if (dividerRows.length === 0) return [];
return allItems.filter((candidate) => {
if (candidate.kind !== 'chart') return false;
const cRow = candidate.row ?? 0;
return dividerRows.some((dr) => cRow < dr);
});
}
/**
* Return all chart items that `source` drives — i.e. charts whose SQL
* queries are filtered by `source`'s brush selections.
*/
export function getConsumersOf(
source: DashboardItem,
allItems: ReadonlyArray<DashboardItem>,
): DashboardItem[] {
if (source.kind !== 'chart') return [];
const sourceRow = source.row ?? 0;
const dividerRows: number[] = [];
for (const i of allItems) {
if (i.kind === 'divider' && i.row > sourceRow) {
dividerRows.push(i.row);
}
}
if (dividerRows.length === 0) return [];
return allItems.filter((candidate) => {
if (candidate.kind !== 'chart') return false;
const cRow = candidate.row ?? 0;
return dividerRows.some((dr) => dr <= cRow);
});
}
/**
* A chart is a "driver" if it has consumers — i.e. there is a divider below
* it. Driver charts show brush selection overlays but do NOT apply brush
* filters to their own SQL queries.
*/
export function isDriverChart(
item: DashboardItem,
allItems: ReadonlyArray<DashboardItem>,
): boolean {
return getConsumersOf(item, allItems).length > 0;
}
/** A brush filter applied by interacting with a dashboard chart. */
export interface DashboardBrushFilter {
readonly column: string;
readonly op: '=' | '>=' | '<' | 'is null';
readonly value?: SqlValue;
}
/**
* Global pool of exported data sources.
*
* DashboardNode publishes sources here during query execution (outside the
* Mithril render cycle), so this must be a global singleton. Dashboard tabs
* read from this pool via DataExplorer, which passes sources as props.
*
* All other dashboard state (items, brush filters) lives on DataExplorerTab.
*/
class ExportedSourcesPool {
private sources = new Map<string, DashboardDataSource>();
/** Publish a data source from a DashboardNode. */
setExportedSource(source: DashboardDataSource): void {
this.sources.set(source.nodeId, source);
}
/** Remove an exported source (e.g. node deleted). */
removeExportedSource(nodeId: string): void {
this.sources.delete(nodeId);
}
/** Get a single exported source by node ID. */
getExportedSource(nodeId: string): DashboardDataSource | undefined {
return this.sources.get(nodeId);
}
/** Get all exported sources. */
getAllExportedSources(): ReadonlyArray<DashboardDataSource> {
return [...this.sources.values()];
}
/** Get exported sources belonging to a specific graph tab. */
getExportedSourcesForGraph(
graphId: string,
): ReadonlyArray<DashboardDataSource> {
return [...this.sources.values()].filter((s) => s.graphId === graphId);
}
clear(): void {
this.sources.clear();
}
}
export const dashboardRegistry = new ExportedSourcesPool();
/**
* Returns the nodeIds of all sources that have a column named `column`,
* including `sourceNodeId` itself.
*/
export function getLinkedSourceNodeIds(
sources: ReadonlyArray<Pick<DashboardDataSource, 'nodeId' | 'columns'>>,
sourceNodeId: string,
column: string,
): string[] {
return sources
.filter(
(s) =>
s.nodeId === sourceNodeId || s.columns.some((c) => c.name === column),
)
.map((s) => s.nodeId);
}
/** Get the bounding box of a dashboard item in grid coordinates. */
export function getItemBounds(item: DashboardItem): {
col: number;
row: number;
colSpan: number;
rowSpan: number;
} {
if (item.kind === 'divider') {
return {
col: 0,
row: item.row,
colSpan: GRID_COLUMNS,
rowSpan: DIVIDER_ROW_SPAN,
};
}
return {
col: item.col ?? 0,
row: item.row ?? 0,
colSpan: item.colSpan ?? DEFAULT_COL_SPAN,
rowSpan: item.rowSpan ?? DEFAULT_ROW_SPAN,
};
}
/**
* Check if a rectangle overlaps any item except `skipId` (grid coords),
* or would be directly adjacent (enforcing a 1-cell gap between items).
*/
export function checkOverlap(
col: number,
row: number,
colSpan: number,
rowSpan: number,
items: ReadonlyArray<DashboardItem>,
skipId: string,
): boolean {
for (const item of items) {
if (getItemId(item) === skipId) continue;
const b = getItemBounds(item);
// Items must have at least 1 grid cell between them.
const overlaps = !(
col + colSpan < b.col ||
col > b.col + b.colSpan ||
row + rowSpan < b.row ||
row > b.row + b.rowSpan
);
if (overlaps) return true;
}
return false;
}
/**
* Find the nearest non-overlapping position by scanning grid cells
* row by row, left to right.
*/
export function findNonOverlappingPosition(
startCol: number,
startRow: number,
colSpan: number,
rowSpan: number,
items: ReadonlyArray<DashboardItem>,
skipId: string,
): {col: number; row: number} {
// Clamp to grid bounds.
startCol = Math.max(0, Math.min(GRID_COLUMNS - colSpan, startCol));
startRow = Math.max(0, startRow);
if (!checkOverlap(startCol, startRow, colSpan, rowSpan, items, skipId)) {
return {col: startCol, row: startRow};
}
// Scan row by row, left to right, starting near the requested position.
// The grid has a finite number of columns so each row is bounded; we scan
// downward until a free slot is found (dashboards have a small number of
// items so this terminates quickly).
for (let row = startRow; ; row++) {
for (let col = 0; col <= GRID_COLUMNS - colSpan; col++) {
if (!checkOverlap(col, row, colSpan, rowSpan, items, skipId)) {
return {col, row};
}
}
}
}
/** Return a grid position for the next item, cascading left-to-right. */
export function getNextItemPosition(items: ReadonlyArray<DashboardItem>): {
col: number;
row: number;
} {
const itemsPerRow = Math.floor(GRID_COLUMNS / DEFAULT_COL_SPAN);
const idx = items.length;
const col = (idx % itemsPerRow) * DEFAULT_COL_SPAN;
const row = Math.floor(idx / itemsPerRow) * DEFAULT_ROW_SPAN;
return {col, row};
}
/**
* Convert dashboard items to a serializable array, or undefined if empty.
*/
export function serializeDashboardItems(
items?: DashboardItem[],
): unknown[] | undefined {
return items !== undefined && items.length > 0
? (items as unknown[])
: undefined;
}
/**
* Validate that an unknown value looks like a DashboardItem[].
* Returns undefined if validation fails.
*/
export function validateDashboardItems(
items: unknown[] | undefined,
): DashboardItem[] | undefined {
if (items === undefined || items.length === 0) return undefined;
const validated: DashboardItem[] = [];
for (const item of items) {
if (typeof item !== 'object' || item === null) continue;
const obj = item as Record<string, unknown>;
if (obj.kind === 'chart') {
if (
typeof obj.sourceNodeId !== 'string' ||
typeof obj.config !== 'object' ||
obj.config === null
) {
continue;
}
const cfg = obj.config as Record<string, unknown>;
if (
typeof cfg.id !== 'string' ||
typeof cfg.column !== 'string' ||
typeof cfg.chartType !== 'string'
) {
continue;
}
validated.push(item as DashboardItem);
} else if (obj.kind === 'label') {
if (typeof obj.id !== 'string' || typeof obj.text !== 'string') continue;
validated.push(item as DashboardItem);
} else if (obj.kind === 'divider') {
if (typeof obj.id !== 'string' || typeof obj.row !== 'number') continue;
validated.push(item as DashboardItem);
}
}
return validated.length > 0 ? validated : undefined;
}
/**
* Parse serialized brush filters into a Map.
* Validates each entry and drops invalid ones.
*/
export function parseBrushFilters(
raw: Record<string, unknown[]>,
): Map<string, DashboardBrushFilter[]> {
const validOps = new Set(['=', '>=', '<', 'is null']);
const result = new Map<string, DashboardBrushFilter[]>();
for (const [sourceNodeId, entries] of Object.entries(raw)) {
const validated: DashboardBrushFilter[] = [];
for (const entry of entries) {
if (typeof entry !== 'object' || entry === null) continue;
const obj = entry as Record<string, unknown>;
if (typeof obj.column !== 'string') continue;
if (typeof obj.op !== 'string' || !validOps.has(obj.op)) continue;
validated.push(entry as DashboardBrushFilter);
}
if (validated.length > 0) {
result.set(sourceNodeId, validated);
}
}
return result;
}