| // 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; |
| } |