de: More modern charts picker (#5195)
Replace the simple "Add Chart" button/menu with a visual chart type
picker grid that lets users choose the chart type upfront when adding a
chart to the Data Explorer dashboard or query builder.
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts
index db15876..f0e91d4 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts
@@ -43,10 +43,14 @@
} from './dashboard_chart_view';
import {ResizeHandle} from '../../../widgets/resize_handle';
import {Card} from '../../../widgets/card';
-import {getDefaultChartLabel} from '../query_builder/nodes/visualisation_node';
+import {
+ ChartType,
+ getDefaultChartLabel,
+} from '../query_builder/nodes/visualisation_node';
import {Popup, PopupPosition} from '../../../widgets/popup';
import {renderChartConfigPopup} from '../query_builder/charts/chart_config_popup';
import {RoundActionButton} from '../query_builder/widgets';
+import {renderChartTypePickerGrid} from '../query_builder/charts/chart_type_picker';
// Default dimensions for dashboard chart cards (in pixels).
const DEFAULT_CHART_WIDTH = 400;
@@ -205,26 +209,26 @@
return m(
'.pf-dashboard__add-button',
m(
- PopupMenu,
+ Popup,
{
trigger: RoundActionButton({
icon: 'add',
title: 'Add item',
}),
+ fitContent: true,
},
- m(MenuItem, {
- label: 'Chart',
- icon: 'bar_chart',
- disabled: lastSource === undefined,
- onclick: () => {
- if (lastSource !== undefined) {
- this.addChartForSource(attrs, lastSource);
- }
- },
- }),
+ lastSource !== undefined
+ ? renderChartTypePickerGrid((chartType: ChartType) => {
+ this.addChartForSource(attrs, lastSource, chartType);
+ })
+ : m(
+ '.pf-chart-type-picker__empty',
+ 'Add a data source first to create charts',
+ ),
m(MenuItem, {
label: 'Label',
icon: 'text_fields',
+ className: 'pf-dismiss-popup-group',
onclick: () => {
this.addLabel(attrs);
},
@@ -232,6 +236,7 @@
m(MenuItem, {
label: 'Segment Divider',
icon: 'horizontal_rule',
+ className: 'pf-dismiss-popup-group',
onclick: () => {
this.addDivider(attrs);
},
@@ -1171,24 +1176,31 @@
),
),
),
- m(Button, {
- label: 'Add Chart',
- icon: 'bar_chart',
- compact: true,
- className: 'pf-dashboard__add-chart-btn',
- onclick: () => {
- this.addChartForSource(attrs, source);
+ m(
+ Popup,
+ {
+ trigger: m(Button, {
+ label: 'Add Chart',
+ icon: 'bar_chart',
+ compact: true,
+ className: 'pf-dashboard__add-chart-btn',
+ }),
+ fitContent: true,
},
- }),
+ renderChartTypePickerGrid((chartType: ChartType) => {
+ this.addChartForSource(attrs, source, chartType);
+ }),
+ ),
];
}
private addChartForSource(
attrs: DashboardAttrs,
source: DashboardDataSource,
+ chartType: ChartType,
): void {
const items = [...attrs.items];
- const newConfig = createDefaultChartConfig(source.columns);
+ const newConfig = createDefaultChartConfig(source.columns, chartType);
const candidate = getNextItemPosition(items);
const pos = findNonOverlappingPosition(
candidate.x,
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts
index 7bf2558..18b363c 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts
@@ -441,11 +441,12 @@
*/
export function createDefaultChartConfig(
columns: ReadonlyArray<{name: string}>,
+ chartType: ChartType = 'bar',
): ChartConfig {
const column = columns.length > 0 ? columns[0].name : '';
return {
id: generateChartId(),
column,
- chartType: 'bar',
+ chartType,
};
}
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_type_picker.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_type_picker.ts
new file mode 100644
index 0000000..b2dfda9
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_type_picker.ts
@@ -0,0 +1,269 @@
+// 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 m from 'mithril';
+import {ChartType} from '../nodes/visualisation_node';
+import {CHART_TYPES, ChartTypeDefinition} from '../nodes/chart_type_registry';
+
+// SVG preview renderers for each chart type. Each returns a small schematic
+// SVG that gives users a visual sense of what the chart looks like.
+const CHART_PREVIEW_RENDERERS: Record<ChartType, () => m.Children> = {
+ bar: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48', fill: 'currentColor'},
+ m('rect', {x: 6, y: 28, width: 10, height: 16, rx: 1, opacity: 0.5}),
+ m('rect', {x: 20, y: 12, width: 10, height: 32, rx: 1, opacity: 0.7}),
+ m('rect', {x: 34, y: 20, width: 10, height: 24, rx: 1, opacity: 0.85}),
+ m('rect', {x: 48, y: 6, width: 10, height: 38, rx: 1}),
+ ),
+
+ histogram: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48', fill: 'currentColor'},
+ m('rect', {x: 4, y: 30, width: 8, height: 14, opacity: 0.4}),
+ m('rect', {x: 12, y: 22, width: 8, height: 22, opacity: 0.55}),
+ m('rect', {x: 20, y: 10, width: 8, height: 34, opacity: 0.75}),
+ m('rect', {x: 28, y: 6, width: 8, height: 38, opacity: 0.9}),
+ m('rect', {x: 36, y: 14, width: 8, height: 30, opacity: 0.7}),
+ m('rect', {x: 44, y: 26, width: 8, height: 18, opacity: 0.5}),
+ m('rect', {x: 52, y: 34, width: 8, height: 10, opacity: 0.35}),
+ ),
+
+ line: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48', fill: 'none', stroke: 'currentColor'},
+ m('polyline', {
+ 'points': '6,38 16,28 26,32 36,16 46,20 58,8',
+ 'stroke-width': '2.5',
+ 'stroke-linecap': 'round',
+ 'stroke-linejoin': 'round',
+ }),
+ m('circle', {
+ cx: 6,
+ cy: 38,
+ r: 2.5,
+ fill: 'currentColor',
+ stroke: 'none',
+ }),
+ m('circle', {
+ cx: 26,
+ cy: 32,
+ r: 2.5,
+ fill: 'currentColor',
+ stroke: 'none',
+ }),
+ m('circle', {
+ cx: 46,
+ cy: 20,
+ r: 2.5,
+ fill: 'currentColor',
+ stroke: 'none',
+ }),
+ m('circle', {
+ cx: 58,
+ cy: 8,
+ r: 2.5,
+ fill: 'currentColor',
+ stroke: 'none',
+ }),
+ ),
+
+ scatter: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48', fill: 'currentColor'},
+ m('circle', {cx: 10, cy: 34, r: 3.5, opacity: 0.7}),
+ m('circle', {cx: 18, cy: 26, r: 2.5, opacity: 0.6}),
+ m('circle', {cx: 28, cy: 18, r: 4, opacity: 0.8}),
+ m('circle', {cx: 36, cy: 30, r: 3, opacity: 0.65}),
+ m('circle', {cx: 44, cy: 12, r: 3.5, opacity: 0.75}),
+ m('circle', {cx: 52, cy: 22, r: 2.5, opacity: 0.85}),
+ m('circle', {cx: 22, cy: 38, r: 2, opacity: 0.5}),
+ m('circle', {cx: 48, cy: 36, r: 2, opacity: 0.55}),
+ ),
+
+ pie: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48'},
+ // Larger slice (~60%)
+ m('path', {
+ d: 'M32,24 L32,8 A16,16 0 1,1 18.1,36.1 Z',
+ fill: 'currentColor',
+ opacity: 0.8,
+ }),
+ // Smaller slice (~40%)
+ m('path', {
+ d: 'M32,24 L18.1,36.1 A16,16 0 0,1 32,8 Z',
+ fill: 'currentColor',
+ opacity: 0.45,
+ }),
+ ),
+
+ treemap: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48', fill: 'currentColor'},
+ m('rect', {x: 4, y: 4, width: 34, height: 24, rx: 2, opacity: 0.8}),
+ m('rect', {x: 40, y: 4, width: 20, height: 24, rx: 2, opacity: 0.55}),
+ m('rect', {x: 4, y: 30, width: 20, height: 14, rx: 2, opacity: 0.65}),
+ m('rect', {x: 26, y: 30, width: 16, height: 14, rx: 2, opacity: 0.4}),
+ m('rect', {x: 44, y: 30, width: 16, height: 14, rx: 2, opacity: 0.5}),
+ ),
+
+ boxplot: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48', stroke: 'currentColor', fill: 'currentColor'},
+ // Whisker lines
+ m('line', {'x1': 20, 'y1': 6, 'x2': 20, 'y2': 14, 'stroke-width': 1.5}),
+ m('line', {'x1': 20, 'y1': 34, 'x2': 20, 'y2': 42, 'stroke-width': 1.5}),
+ // Caps
+ m('line', {'x1': 15, 'y1': 6, 'x2': 25, 'y2': 6, 'stroke-width': 1.5}),
+ m('line', {'x1': 15, 'y1': 42, 'x2': 25, 'y2': 42, 'stroke-width': 1.5}),
+ // Box
+ m('rect', {
+ 'x': 13,
+ 'y': 14,
+ 'width': 14,
+ 'height': 20,
+ 'rx': 1,
+ 'fill': 'none',
+ 'stroke-width': 1.5,
+ }),
+ // Median line
+ m('line', {'x1': 13, 'y1': 22, 'x2': 27, 'y2': 22, 'stroke-width': 2}),
+ // Second boxplot (shorter)
+ m('line', {'x1': 44, 'y1': 12, 'x2': 44, 'y2': 18, 'stroke-width': 1.5}),
+ m('line', {'x1': 44, 'y1': 36, 'x2': 44, 'y2': 42, 'stroke-width': 1.5}),
+ m('line', {'x1': 39, 'y1': 12, 'x2': 49, 'y2': 12, 'stroke-width': 1.5}),
+ m('line', {'x1': 39, 'y1': 42, 'x2': 49, 'y2': 42, 'stroke-width': 1.5}),
+ m('rect', {
+ 'x': 37,
+ 'y': 18,
+ 'width': 14,
+ 'height': 18,
+ 'rx': 1,
+ 'fill': 'none',
+ 'stroke-width': 1.5,
+ }),
+ m('line', {'x1': 37, 'y1': 28, 'x2': 51, 'y2': 28, 'stroke-width': 2}),
+ ),
+
+ heatmap: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48', fill: 'currentColor'},
+ // 4x3 grid of cells with varying opacity
+ m('rect', {x: 4, y: 4, width: 13, height: 12, rx: 1, opacity: 0.9}),
+ m('rect', {x: 19, y: 4, width: 13, height: 12, rx: 1, opacity: 0.4}),
+ m('rect', {x: 34, y: 4, width: 13, height: 12, rx: 1, opacity: 0.7}),
+ m('rect', {x: 49, y: 4, width: 13, height: 12, rx: 1, opacity: 0.3}),
+ m('rect', {x: 4, y: 18, width: 13, height: 12, rx: 1, opacity: 0.5}),
+ m('rect', {x: 19, y: 18, width: 13, height: 12, rx: 1, opacity: 0.8}),
+ m('rect', {x: 34, y: 18, width: 13, height: 12, rx: 1, opacity: 0.35}),
+ m('rect', {x: 49, y: 18, width: 13, height: 12, rx: 1, opacity: 0.65}),
+ m('rect', {x: 4, y: 32, width: 13, height: 12, rx: 1, opacity: 0.25}),
+ m('rect', {x: 19, y: 32, width: 13, height: 12, rx: 1, opacity: 0.6}),
+ m('rect', {x: 34, y: 32, width: 13, height: 12, rx: 1, opacity: 0.85}),
+ m('rect', {x: 49, y: 32, width: 13, height: 12, rx: 1, opacity: 0.45}),
+ ),
+
+ cdf: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48', fill: 'none', stroke: 'currentColor'},
+ m('polyline', {
+ 'points': '6,42 14,40 22,36 28,28 34,18 40,12 48,9 56,8',
+ 'stroke-width': '2.5',
+ 'stroke-linecap': 'round',
+ 'stroke-linejoin': 'round',
+ }),
+ // Dashed 50% line
+ m('line', {
+ 'x1': 4,
+ 'y1': 24,
+ 'x2': 60,
+ 'y2': 24,
+ 'stroke-width': 1,
+ 'stroke-dasharray': '3,3',
+ 'opacity': 0.4,
+ }),
+ ),
+
+ scorecard: () =>
+ m(
+ 'svg',
+ {viewBox: '0 0 64 48', fill: 'currentColor'},
+ m(
+ 'text',
+ {
+ 'x': 32,
+ 'y': 28,
+ 'text-anchor': 'middle',
+ 'dominant-baseline': 'central',
+ 'font-size': '18',
+ 'font-weight': 'bold',
+ },
+ '42',
+ ),
+ m(
+ 'text',
+ {
+ 'x': 32,
+ 'y': 41,
+ 'text-anchor': 'middle',
+ 'font-size': '7',
+ 'opacity': 0.5,
+ },
+ 'value',
+ ),
+ ),
+};
+
+/**
+ * Renders a grid of chart type cards. Each card fires `onSelect` when clicked
+ * and has the `pf-dismiss-popup-group` class so it auto-closes a parent Popup.
+ */
+export function renderChartTypePickerGrid(
+ onSelect: (type: ChartType) => void,
+): m.Children {
+ return m(
+ '.pf-chart-type-picker',
+ CHART_TYPES.map((def) => renderChartTypeCard(def, onSelect)),
+ );
+}
+
+function renderChartTypeCard(
+ def: ChartTypeDefinition,
+ onSelect: (type: ChartType) => void,
+): m.Children {
+ const description = def.description;
+
+ return m(
+ 'button.pf-chart-type-picker__card.pf-dismiss-popup-group',
+ {
+ key: def.type,
+ title: description,
+ onclick: () => onSelect(def.type),
+ },
+ [
+ m('.pf-chart-type-picker__preview', CHART_PREVIEW_RENDERERS[def.type]()),
+ m('.pf-chart-type-picker__label', def.label),
+ ],
+ );
+}
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_view.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_view.ts
index 6f7375b..bfce456 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_view.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_view.ts
@@ -37,6 +37,7 @@
import {EmptyState} from '../../../../widgets/empty_state';
import {Card} from '../../../../widgets/card';
import {AddItemPlaceholder} from '../widgets';
+import {renderChartTypePickerGrid} from './chart_type_picker';
export interface ChartViewAttrs {
trace: Trace;
@@ -175,14 +176,20 @@
title: 'No charts configured',
fillHeight: true,
},
- m(Button, {
- label: 'Add first chart',
- icon: 'add',
- onclick: () => {
- attrs.node.addChart();
- attrs.onFilterChange?.();
+ m(
+ Popup,
+ {
+ trigger: m(Button, {
+ label: 'Add first chart',
+ icon: 'add',
+ }),
+ fitContent: true,
},
- }),
+ renderChartTypePickerGrid((chartType) => {
+ attrs.node.addChart(chartType);
+ attrs.onFilterChange?.();
+ }),
+ ),
),
);
}
@@ -210,15 +217,25 @@
toolbar,
m(`.pf-chart-view__charts.${gridClass}`, [
...configs.map((config) => this.renderSingleChart(attrs, config)),
- m(AddItemPlaceholder, {
- key: 'add-chart',
- label: 'Add Chart',
- icon: 'add',
- onclick: () => {
- attrs.node.addChart();
- attrs.onFilterChange?.();
+ m(
+ Popup,
+ {
+ key: 'add-chart',
+ trigger: m(
+ 'span',
+ m(AddItemPlaceholder, {
+ label: 'Add Chart',
+ icon: 'add',
+ }),
+ ),
+ showArrow: false,
+ fitContent: true,
},
- }),
+ renderChartTypePickerGrid((chartType) => {
+ attrs.node.addChart(chartType);
+ attrs.onFilterChange?.();
+ }),
+ ),
]),
]);
}
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/chart_type_registry.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/chart_type_registry.ts
index b48880d..4cf26b8 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/chart_type_registry.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/chart_type_registry.ts
@@ -78,6 +78,9 @@
* Example: Scatter plot bubble size.
*/
readonly supportsSizeColumn: boolean;
+
+ /** Short description shown on hover in the chart type picker. */
+ readonly description: string;
}
/**
@@ -98,6 +101,7 @@
supportsYColumn: false,
supportsGroupColumn: false,
supportsSizeColumn: false,
+ description: 'Compare categories using vertical or horizontal bars',
},
{
type: 'histogram',
@@ -110,6 +114,7 @@
supportsYColumn: false,
supportsGroupColumn: false,
supportsSizeColumn: false,
+ description: 'Show distribution of numeric values across bins',
},
{
type: 'line',
@@ -122,6 +127,7 @@
supportsYColumn: true,
supportsGroupColumn: true,
supportsSizeColumn: false,
+ description: 'Plot trends with connected data points over a numeric axis',
},
{
type: 'scatter',
@@ -134,6 +140,7 @@
supportsYColumn: true,
supportsGroupColumn: true,
supportsSizeColumn: true,
+ description: 'Reveal correlations between two numeric variables',
},
{
type: 'pie',
@@ -146,6 +153,7 @@
supportsYColumn: false,
supportsGroupColumn: false,
supportsSizeColumn: false,
+ description: 'Show proportions of a whole as slices',
},
{
type: 'treemap',
@@ -158,6 +166,7 @@
supportsYColumn: false,
supportsGroupColumn: true,
supportsSizeColumn: false,
+ description: 'Display hierarchical data as nested rectangles by size',
},
{
type: 'boxplot',
@@ -170,6 +179,7 @@
supportsYColumn: true,
supportsGroupColumn: false,
supportsSizeColumn: false,
+ description: 'Summarize data spread with quartiles and outliers',
},
{
type: 'heatmap',
@@ -182,6 +192,7 @@
supportsYColumn: true,
supportsGroupColumn: false,
supportsSizeColumn: false,
+ description: 'Visualize magnitude across two dimensions using color',
},
{
type: 'cdf',
@@ -194,6 +205,8 @@
supportsYColumn: false,
supportsGroupColumn: true,
supportsSizeColumn: false,
+ description:
+ 'Cumulative distribution — proportion of values below a threshold',
},
{
type: 'scorecard',
@@ -206,6 +219,7 @@
supportsYColumn: false,
supportsGroupColumn: false,
supportsSizeColumn: false,
+ description: 'Display a single aggregated number prominently',
},
] as const;
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/visualisation_node.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/visualisation_node.ts
index e928908..f108cde 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/visualisation_node.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/visualisation_node.ts
@@ -43,6 +43,8 @@
import {Icon} from '../../../../widgets/icon';
import {Card} from '../../../../widgets/card';
import {classNames} from '../../../../base/classnames';
+import {renderChartTypePickerGrid} from '../charts/chart_type_picker';
+import {Popup} from '../../../../widgets/popup';
/**
* Chart type options.
@@ -488,14 +490,23 @@
title: 'Charts',
content: m('.pf-chart-cards-container', [
...chartCards,
- m(AddItemPlaceholder, {
- key: 'add-chart',
- label: 'Add Chart',
- icon: 'add',
- onclick: () => {
- this.addChart();
+ m(
+ Popup,
+ {
+ key: 'add-chart',
+ trigger: m(
+ 'span',
+ m(AddItemPlaceholder, {
+ label: 'Add Chart',
+ icon: 'add',
+ }),
+ ),
+ fitContent: true,
},
- }),
+ renderChartTypePickerGrid((chartType: ChartType) => {
+ this.addChart(chartType);
+ }),
+ ),
]),
});
@@ -526,7 +537,7 @@
* Prefers string/categorical columns for bar charts, and avoids
* columns already used by other charts when possible.
*/
- addChart(): void {
+ addChart(chartType: ChartType = 'bar'): void {
// Get columns already used by existing charts
const usedColumns = new Set(
this.state.chartConfigs.map((c) => c.column).filter(Boolean),
@@ -534,13 +545,13 @@
// Find a good default column:
// 1. Prefer unused columns
- // 2. Prefer non-numeric columns (better for bar charts)
+ // 2. Prefer non-numeric columns (better for aggregation chart types)
// 3. Fall back to first available column
const availableCols = this.sourceCols;
const unusedCols = availableCols.filter((c) => !usedColumns.has(c.name));
const colsToCheck = unusedCols.length > 0 ? unusedCols : availableCols;
- // Prefer string/categorical columns for bar charts
+ // Prefer string/categorical columns for aggregation charts
const stringCol = colsToCheck.find(
(c) => c.column.type === undefined || !isQuantitativeType(c.column.type),
);
@@ -549,7 +560,7 @@
const newChart: ChartConfig = {
id: generateChartId(),
column: defaultColumn,
- chartType: 'bar',
+ chartType,
};
this.state.chartConfigs.push(newChart);
this.state.onchange?.();
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/widgets.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/widgets.ts
index 269afa4..2094724 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/widgets.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/widgets.ts
@@ -754,7 +754,7 @@
export interface AddItemPlaceholderAttrs {
label: string;
icon?: string;
- onclick: () => void;
+ onclick?: () => void;
}
export class AddItemPlaceholder
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/styles.scss b/ui/src/plugins/dev.perfetto.DataExplorer/styles.scss
index d4b10f2..88904f5 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/styles.scss
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/styles.scss
@@ -608,3 +608,74 @@
padding: 16px 8px;
}
}
+
+// Chart type picker — visual grid of chart types shown when adding a chart.
+.pf-chart-type-picker {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 6px;
+ padding: 4px;
+
+ &__card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 6px;
+ border: 1px solid var(--pf-color-border);
+ border-radius: 6px;
+ background: var(--pf-color-surface);
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ box-shadow 0.15s,
+ background 0.15s;
+ // Reset button defaults
+ font: inherit;
+ color: inherit;
+
+ &:hover {
+ border-color: var(--pf-color-primary);
+ background: var(--pf-color-surface-highlight);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--pf-color-primary);
+ outline-offset: 2px;
+ }
+
+ &:active {
+ transform: scale(0.97);
+ }
+ }
+
+ &__preview {
+ width: 48px;
+ height: 36px;
+ color: var(--pf-color-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ &__label {
+ font-size: 11px;
+ font-weight: 500;
+ text-align: center;
+ color: var(--pf-color-text);
+ white-space: nowrap;
+ }
+
+ &__empty {
+ padding: 12px 16px;
+ font-size: 12px;
+ color: var(--pf-color-text-muted);
+ text-align: center;
+ }
+}