ui: Add data checks to charts
diff --git a/ui/src/assets/widgets/charts.scss b/ui/src/assets/widgets/charts.scss index 2340b4a..9f11232 100644 --- a/ui/src/assets/widgets/charts.scss +++ b/ui/src/assets/widgets/charts.scss
@@ -36,6 +36,24 @@ } } +// Wrapper used when a chart is showing truncated data. +.pf-chart-truncation-wrapper { + position: relative; + width: 100%; + height: 100%; +} + +.pf-chart-truncation-warning { + position: absolute; + top: 4px; + right: 4px; + font-size: 16px; + color: var(--pf-color-warning); + cursor: help; + pointer-events: all; + z-index: 10; +} + // EChartView component styles .pf-echart-view { position: relative;
diff --git a/ui/src/components/widgets/charts/bar_chart.ts b/ui/src/components/widgets/charts/bar_chart.ts index ecbc290..93c1f2f 100644 --- a/ui/src/components/widgets/charts/bar_chart.ts +++ b/ui/src/components/widgets/charts/bar_chart.ts
@@ -22,6 +22,7 @@ } from './chart_utils'; import {EChartView, EChartEventHandler} from './echart_view'; import type {LegendPosition} from './common'; +import {maybeWrapWithTruncation} from './chart_truncation_warning'; import { buildAxisOption, buildGridOption, @@ -161,11 +162,27 @@ * Defaults to 'top'. */ readonly legendPosition?: LegendPosition; + /** Total row count before LIMIT; when set and shown < total, an overlay warns the user. */ + readonly totalCount?: number; + /** Number of items actually shown; should come from the loader result. */ + readonly shownCount?: number; + /** Show the truncation warning overlay. Defaults to false. */ + readonly showTruncationWarning?: boolean; } export class BarChart implements m.ClassComponent<BarChartAttrs> { view({attrs}: m.Vnode<BarChartAttrs>) { - const {data, height, fillParent, className, onBrush, orientation} = attrs; + const { + data, + height, + fillParent, + className, + onBrush, + orientation, + totalCount, + shownCount, + showTruncationWarning, + } = attrs; const horizontal = orientation === 'horizontal'; const isStacked = data?.series !== undefined && data.series.length > 0; @@ -173,7 +190,7 @@ const option = data !== undefined && !isEmpty ? buildBarOption(attrs, data) : undefined; - return m(EChartView, { + const content = m(EChartView, { option, height, fillParent, @@ -183,6 +200,12 @@ activeBrushType: onBrush !== undefined ? (horizontal ? 'lineY' : 'lineX') : undefined, }); + return maybeWrapWithTruncation( + content, + shownCount, + totalCount, + showTruncationWarning, + ); } }
diff --git a/ui/src/components/widgets/charts/bar_chart_loader.ts b/ui/src/components/widgets/charts/bar_chart_loader.ts index c15586d..6290003 100644 --- a/ui/src/components/widgets/charts/bar_chart_loader.ts +++ b/ui/src/components/widgets/charts/bar_chart_loader.ts
@@ -169,4 +169,11 @@ } return {items: [], series}; } + + protected override countShown(data: BarChartData): number { + const series = data.series; + return series !== undefined && series.length > 0 + ? series.reduce((sum, s) => sum + s.items.length, 0) + : data.items.length; + } }
diff --git a/ui/src/components/widgets/charts/boxplot.ts b/ui/src/components/widgets/charts/boxplot.ts index a25d37b..d41b3b5 100644 --- a/ui/src/components/widgets/charts/boxplot.ts +++ b/ui/src/components/widgets/charts/boxplot.ts
@@ -16,6 +16,7 @@ import type {EChartsCoreOption} from 'echarts/core'; import {formatNumber} from './chart_utils'; import {EChartView} from './echart_view'; +import {maybeWrapWithTruncation} from './chart_truncation_warning'; import { buildAxisOption, buildGridOption, @@ -96,11 +97,25 @@ * Defaults to no grid lines. */ readonly gridLines?: 'horizontal' | 'vertical' | 'both'; + /** Total row count before LIMIT; when set and shown < total, an overlay warns the user. */ + readonly totalCount?: number; + /** Number of items actually shown; should come from the loader result. */ + readonly shownCount?: number; + /** Show the truncation warning overlay. Defaults to false. */ + readonly showTruncationWarning?: boolean; } export class BoxplotChart implements m.ClassComponent<BoxplotAttrs> { view({attrs}: m.CVnode<BoxplotAttrs>) { - const {data, height, fillParent, className} = attrs; + const { + data, + height, + fillParent, + className, + totalCount, + shownCount, + showTruncationWarning, + } = attrs; const isEmpty = data !== undefined && data.items.length === 0; const option = @@ -108,13 +123,19 @@ ? buildBoxplotOption(attrs, data) : undefined; - return m(EChartView, { + const content = m(EChartView, { option, height, fillParent, className, empty: isEmpty, }); + return maybeWrapWithTruncation( + content, + shownCount, + totalCount, + showTruncationWarning, + ); } }
diff --git a/ui/src/components/widgets/charts/boxplot_loader.ts b/ui/src/components/widgets/charts/boxplot_loader.ts index b682432..1cc4231 100644 --- a/ui/src/components/widgets/charts/boxplot_loader.ts +++ b/ui/src/components/widgets/charts/boxplot_loader.ts
@@ -12,12 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {QuerySlot, type QueryResult} from '../../../base/query_slot'; +import {QuerySlot} from '../../../base/query_slot'; import {Engine} from '../../../trace_processor/engine'; import {NUM, STR} from '../../../trace_processor/query_result'; import {BoxplotData} from './boxplot'; +import {type ChartLoaderResult} from './chart_sql_source'; import {validateColumnName} from './chart_utils'; +interface BoxplotCacheEntry { + readonly data: BoxplotData; + readonly totalCount: number | undefined; +} + /** * Configuration for boxplot loaders. */ @@ -58,7 +64,7 @@ private readonly query: string; private readonly categoryColumn: string; private readonly valueColumn: string; - private readonly querySlot = new QuerySlot<BoxplotData>(); + private readonly querySlot = new QuerySlot<BoxplotCacheEntry>(); constructor(opts: SQLBoxplotLoaderOpts) { validateColumnName(opts.categoryColumn); @@ -69,7 +75,7 @@ this.valueColumn = opts.valueColumn; } - use(config: BoxplotLoaderConfig): QueryResult<BoxplotData> { + use(config: BoxplotLoaderConfig): ChartLoaderResult<BoxplotData> { const limit = config.limit ?? 20; const cat = this.categoryColumn; const val = this.valueColumn; @@ -115,18 +121,20 @@ q._q1, q._median, q._q3, - s._max + s._max, + (SELECT COUNT(*) FROM _stats) AS _total_count FROM _stats s JOIN _quartiles q ON s._cat = q._cat ORDER BY q._median DESC LIMIT ${limit}`.trim(); - return this.querySlot.use({ + const result = this.querySlot.use({ key: {sql}, retainOn: ['sql'], queryFn: async () => { const queryResult = await this.engine.query(sql); const items = []; + let totalCount: number | undefined; const iter = queryResult.iter({ _cat: STR, @@ -135,9 +143,14 @@ _median: NUM, _q3: NUM, _max: NUM, + _total_count: NUM, }); for (; iter.valid(); iter.next()) { + // _total_count is the same on every row — read it once from the first. + if (totalCount === undefined) { + totalCount = iter._total_count; + } items.push({ label: iter._cat, min: iter._min, @@ -148,9 +161,17 @@ }); } - return {items}; + return {data: {items}, totalCount}; }, }); + const cached = result.data; + return { + data: cached?.data, + isPending: result.isPending, + totalCount: cached?.totalCount, + shownCount: + cached?.totalCount !== undefined ? cached.data.items.length : undefined, + }; } dispose(): void {
diff --git a/ui/src/components/widgets/charts/cdf_loader.ts b/ui/src/components/widgets/charts/cdf_loader.ts index 9ee7a90..fd37333 100644 --- a/ui/src/components/widgets/charts/cdf_loader.ts +++ b/ui/src/components/widgets/charts/cdf_loader.ts
@@ -14,7 +14,7 @@ import {Engine} from '../../../trace_processor/engine'; import {NUM, STR, QueryResult} from '../../../trace_processor/query_result'; -import {LineChartData} from './line_chart'; +import {LineChartData, countLineChartShown} from './line_chart'; import { ChartSource, SQLChartLoader, @@ -112,6 +112,10 @@ ): LineChartData { return parseCdfResult(queryResult, this.seriesCol !== undefined); } + + protected override countShown(data: LineChartData): number { + return countLineChartShown(data); + } } function parseCdfResult(
diff --git a/ui/src/components/widgets/charts/chart_sql_source.ts b/ui/src/components/widgets/charts/chart_sql_source.ts index a5e03cf..8bf7639 100644 --- a/ui/src/components/widgets/charts/chart_sql_source.ts +++ b/ui/src/components/widgets/charts/chart_sql_source.ts
@@ -15,11 +15,18 @@ import {assertUnreachable} from '../../../base/assert'; import {QuerySlot} from '../../../base/query_slot'; import type {Engine} from '../../../trace_processor/engine'; -import type {QueryResult as TPQueryResult} from '../../../trace_processor/query_result'; +import { + NUM, + type QueryResult as TPQueryResult, +} from '../../../trace_processor/query_result'; import {Filter} from '../datagrid/model'; import {filterToSql, sqlAggregateExpr} from '../datagrid/sql_utils'; import {ChartAggregation, validateColumnName} from './chart_utils'; +// Changing the value of this constant invalidates cached query keys; renaming +// the TypeScript identifier is safe. +export const OTHER_LABEL = '(Other)'; + /** Default column alias for the first measure in aggregated queries. */ export const DEFAULT_MEASURE_ALIAS = '_value'; @@ -294,8 +301,29 @@ const groupByExprs = config.dimensions.map((d) => d.column); const direction = config.orderDirection ?? 'desc'; const orderAlias = this.measureAlias(config.measures, 0); - const limitClause = - config.limit !== undefined ? `LIMIT ${config.limit}` : ''; + + // When a limit is set, wrap in a CTE so we can report the total number + // of groups via _total_count alongside the limited result set. + if (config.limit !== undefined) { + const dimAliases = config.dimensions.map((d, i) => this.dimAlias(d, i)); + const measAliases = config.measures.map((_, i) => + this.measureAlias(config.measures, i), + ); + const aliasList = [...dimAliases, ...measAliases].join(', '); + return ` +WITH _agg AS ( + SELECT + ${selectParts.join(',\n ')} + FROM (${this.query}) + ${whereClause} + GROUP BY ${groupByExprs.join(', ')} + ${havingClause} +) +SELECT ${aliasList}, (SELECT COUNT(*) FROM _agg) AS _total_count +FROM _agg +ORDER BY ${orderAlias} ${direction.toUpperCase()} +LIMIT ${config.limit}`.trim(); + } return ` SELECT @@ -304,8 +332,7 @@ ${whereClause} GROUP BY ${groupByExprs.join(', ')} ${havingClause} -ORDER BY ${orderAlias} ${direction.toUpperCase()} -${limitClause}`.trim(); +ORDER BY ${orderAlias} ${direction.toUpperCase()}`.trim(); } private buildTopNWithOther(config: AggregatedQueryConfig): string { @@ -334,7 +361,7 @@ // Build "(Other)" select: first dim = '(Other)', rest of dims = NULL, // each measure = SUM(measure_alias) const otherDimExprs = dimAliases.map((alias, i) => - i === 0 ? `'(Other)' AS ${alias}` : `NULL AS ${alias}`, + i === 0 ? `'${OTHER_LABEL}' AS ${alias}` : `NULL AS ${alias}`, ); const otherMeasExprs = measAliases.map( (alias) => `SUM(${alias}) AS ${alias}`, @@ -360,9 +387,11 @@ FROM _agg WHERE ${firstDimAlias} NOT IN (SELECT ${firstDimAlias} FROM _top) ) -SELECT ${aliasList} FROM _top -UNION ALL -SELECT ${aliasList} FROM _other WHERE ${measAliases[0]} > 0`.trim(); +SELECT *, (SELECT COUNT(*) FROM _agg) AS _total_count FROM ( + SELECT ${aliasList} FROM _top + UNION ALL + SELECT ${aliasList} FROM _other WHERE ${measAliases[0]} > 0 +)`.trim(); } private buildHierarchical(config: AggregatedQueryConfig): string { @@ -401,7 +430,7 @@ ROW_NUMBER() OVER (PARTITION BY ${firstDimAlias} ORDER BY ${orderAlias} ${direction.toUpperCase()}) AS _rank FROM _agg ) -SELECT ${aliasList} +SELECT ${aliasList}, (SELECT COUNT(*) FROM _agg) AS _total_count FROM _ranked WHERE _rank <= ${limitPerGroup} ORDER BY ${firstDimAlias}, ${orderAlias} ${direction.toUpperCase()}`.trim(); @@ -486,12 +515,13 @@ : 'PARTITION BY 1'; return ` -SELECT ${aliasList} +SELECT ${aliasList}, _total_count FROM ( SELECT ${selectParts.join(',\n ')}, ROW_NUMBER() OVER (${partitionExpr}) AS _rn, - COUNT(*) OVER (${partitionExpr}) AS _cnt + COUNT(*) OVER (${partitionExpr}) AS _cnt, + COUNT(*) OVER () AS _total_count FROM (${this.query}) ${whereClause} ) @@ -680,6 +710,16 @@ export interface ChartLoaderResult<TData> { readonly data: TData | undefined; readonly isPending: boolean; + /** Row count before LIMIT; only present when the query applied a limit. */ + readonly totalCount?: number; + /** Number of items actually shown; only present when totalCount is set. */ + readonly shownCount?: number; +} + +/** Internal wrapper to carry totalCount alongside chart data. */ +interface ChartDataWithTotal<TData> { + readonly data: TData; + readonly totalCount?: number; } /** @@ -693,7 +733,7 @@ export abstract class SQLChartLoader<TConfig, TData> { private readonly engine: Engine; protected readonly source: ChartSource; - private readonly querySlot = new QuerySlot<TData>(); + private readonly querySlot = new QuerySlot<ChartDataWithTotal<TData>>(); constructor(engine: Engine, source: ChartSource) { this.engine = engine; @@ -709,13 +749,24 @@ key, queryFn: async () => { const queryResult = await this.engine.query(sql); - return this.parseResult(queryResult, config); + const data = this.parseResult(queryResult, config); + const totalCount = extractTotalCount(queryResult); + return {data, totalCount}; }, // Retain stale chart data while new queries are in flight (e.g., during // brush/filter changes) to avoid flashing a loading spinner. retainOn: Object.keys(key) as (keyof typeof key)[], }); - return {data: result.data, isPending: result.isPending}; + const cached = result.data; + return { + data: cached?.data, + isPending: result.isPending, + totalCount: cached?.totalCount, + shownCount: + cached?.totalCount !== undefined + ? this.countShown(cached.data) + : undefined, + }; } dispose(): void { @@ -731,6 +782,9 @@ config: TConfig, ): TData; + /** Return the number of items actually shown in the chart from `data`. */ + protected abstract countShown(data: TData): number; + /** * Override to add extra fields to the cache key for post-processing * params that don't affect the SQL but do affect the output. @@ -742,6 +796,22 @@ } } +// Returns undefined (not an error) when _total_count is absent — that's the +// normal path for unlimited queries (histogram, heatmap, etc.). +// Called after parseResult() has already consumed the query result. +// This is safe because QueryResult.iter() creates an independent cursor each +// call — it does not share position with the iterator used by parseResult. +function extractTotalCount(queryResult: TPQueryResult): number | undefined { + if (!queryResult.columns().includes('_total_count')) { + return undefined; + } + const iter = queryResult.iter({_total_count: NUM}); + if (iter.valid()) { + return iter._total_count; + } + return undefined; +} + // --------------------------------------------------------------------------- // Filter construction helpers // ---------------------------------------------------------------------------
diff --git a/ui/src/components/widgets/charts/chart_sql_source_unittest.ts b/ui/src/components/widgets/charts/chart_sql_source_unittest.ts index 25e5089..758efce 100644 --- a/ui/src/components/widgets/charts/chart_sql_source_unittest.ts +++ b/ui/src/components/widgets/charts/chart_sql_source_unittest.ts
@@ -12,7 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {ChartSource, ColumnSchema, QueryConfig} from './chart_sql_source'; +import { + ChartSource, + ColumnSchema, + OTHER_LABEL, + QueryConfig, +} from './chart_sql_source'; +import {countDistinctPieSlices} from './pie_chart_loader'; const QUERY = 'SELECT name, dur, ts, cpu, size, category FROM slice'; const SCHEMA: ColumnSchema = { @@ -382,7 +388,7 @@ expect(norm).toContain('COUNT(*) OVER (PARTITION BY CAST(cpu AS TEXT))'); expect(norm).toContain('(_cnt + 500 - 1) / 500'); expect(norm).toContain('WHERE (_rn - 1) %'); - expect(norm).toContain('SELECT _x, _y, _series FROM'); + expect(norm).toContain('SELECT _x, _y, _series, _total_count FROM'); }); test('points: maxPointsPerSeries without breakdown', () => { @@ -397,7 +403,7 @@ const norm = normalizeWhitespace(sql); expect(norm).toContain('PARTITION BY 1'); expect(norm).toContain('(_cnt + 1000 - 1) / 1000'); - expect(norm).toContain('SELECT _x, _y FROM'); + expect(norm).toContain('SELECT _x, _y, _total_count FROM'); }); test('points: maxPointsPerSeries with filters', () => { @@ -505,3 +511,86 @@ }); expect(normalizeWhitespace(sql)).not.toContain('WHERE'); }); + +// --------------------------------------------------------------------------- +// Total count (_total_count) — used to drive the truncation warning +// --------------------------------------------------------------------------- + +test('aggregated with limit emits _total_count', () => { + const sql = build({ + type: 'aggregated', + dimensions: [{column: 'name'}], + measures: [{column: 'dur', aggregation: 'SUM'}], + limit: 10, + }); + const norm = normalizeWhitespace(sql); + expect(norm).toContain('WITH _agg AS'); + expect(norm).toContain('(SELECT COUNT(*) FROM _agg) AS _total_count'); + expect(norm).toContain('LIMIT 10'); +}); + +test('aggregated without limit has no _total_count', () => { + const sql = build({ + type: 'aggregated', + dimensions: [{column: 'name'}], + measures: [{column: 'dur', aggregation: 'SUM'}], + }); + expect(normalizeWhitespace(sql)).not.toContain('_total_count'); +}); + +test('aggregated top-N with Other emits _total_count once', () => { + const sql = build({ + type: 'aggregated', + dimensions: [{column: 'name'}], + measures: [{column: 'dur', aggregation: 'SUM'}], + limit: 5, + includeOther: true, + }); + const norm = normalizeWhitespace(sql); + // _total_count should be projected once in the outer SELECT wrapping the UNION. + expect(norm).toContain('(SELECT COUNT(*) FROM _agg) AS _total_count'); + // The UNION arms themselves must not project _total_count. + expect(norm).not.toContain('_total_count FROM _top'); + expect(norm).not.toContain('_total_count FROM _other'); +}); + +test('aggregated hierarchical emits _total_count', () => { + const sql = build({ + type: 'aggregated', + dimensions: [{column: 'category'}, {column: 'name'}], + measures: [{column: 'size', aggregation: 'SUM'}], + limitPerGroup: 10, + }); + expect(normalizeWhitespace(sql)).toContain( + '(SELECT COUNT(*) FROM _agg) AS _total_count', + ); +}); + +test('histogram has no _total_count', () => { + const sql = build({ + type: 'histogram', + valueColumn: 'dur', + bucketCount: 20, + }); + expect(normalizeWhitespace(sql)).not.toContain('_total_count'); +}); + +// --------------------------------------------------------------------------- +// countDistinctPieSlices +// --------------------------------------------------------------------------- + +test('countDistinctPieSlices: handles undefined', () => { + expect(countDistinctPieSlices(undefined)).toBe(0); +}); + +test('countDistinctPieSlices: counts all slices when no (Other) present', () => { + expect( + countDistinctPieSlices([{label: 'a'}, {label: 'b'}, {label: 'c'}]), + ).toBe(3); +}); + +test('countDistinctPieSlices: excludes the (Other) slice', () => { + expect( + countDistinctPieSlices([{label: 'a'}, {label: 'b'}, {label: OTHER_LABEL}]), + ).toBe(2); +});
diff --git a/ui/src/components/widgets/charts/chart_truncation_warning.ts b/ui/src/components/widgets/charts/chart_truncation_warning.ts new file mode 100644 index 0000000..cef1f55 --- /dev/null +++ b/ui/src/components/widgets/charts/chart_truncation_warning.ts
@@ -0,0 +1,58 @@ +// 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 {classNames} from '../../../base/classnames'; +import {Icon} from '../../../widgets/icon'; +import {Tooltip} from '../../../widgets/tooltip'; +import {PopupPosition} from '../../../widgets/popup'; + +/** + * Wraps `content` with a truncation warning overlay when the chart is + * displaying fewer items than the total available. + * + * Only runs the comparison (and wraps) when `showWarning` is true and + * `total` is present — callers should gate the `shown` computation similarly. + */ +export function maybeWrapWithTruncation( + content: m.Child, + shownCount: number | undefined, + totalCount: number | undefined, + showWarning?: boolean, +): m.Child { + if ( + !showWarning || + totalCount === undefined || + shownCount === undefined || + totalCount <= shownCount + ) { + return content; + } + return m('.pf-chart-truncation-wrapper', [ + content, + m( + Tooltip, + { + trigger: m(Icon, { + className: classNames('pf-chart-truncation-warning'), + icon: 'warning', + filled: true, + }), + position: PopupPosition.BottomEnd, + showArrow: false, + }, + `Showing ${shownCount} of ${totalCount} items`, + ), + ]); +}
diff --git a/ui/src/components/widgets/charts/histogram_loader.ts b/ui/src/components/widgets/charts/histogram_loader.ts index 361b71f..0ad5450 100644 --- a/ui/src/components/widgets/charts/histogram_loader.ts +++ b/ui/src/components/widgets/charts/histogram_loader.ts
@@ -440,6 +440,11 @@ }; } + protected override countShown(_data: HistogramData): number { + // Histogram queries never apply a LIMIT, so this is never called. + return 0; + } + protected override extraCacheKey( config: HistogramLoaderConfig, ): Record<string, string | number | boolean | undefined> {
diff --git a/ui/src/components/widgets/charts/line_chart.ts b/ui/src/components/widgets/charts/line_chart.ts index 6535ba1..2e9cfbf 100644 --- a/ui/src/components/widgets/charts/line_chart.ts +++ b/ui/src/components/widgets/charts/line_chart.ts
@@ -17,6 +17,7 @@ import {extractBrushRange, formatNumber} from './chart_utils'; import {EChartView, EChartEventHandler} from './echart_view'; import type {LegendPosition} from './common'; +import {maybeWrapWithTruncation} from './chart_truncation_warning'; import { buildChartOption, buildLegendOption, @@ -53,6 +54,10 @@ readonly series: readonly LineChartSeries[]; } +export function countLineChartShown(data: LineChartData): number { + return data.series.reduce((sum, s) => sum + s.points.length, 0); +} + export interface LineChartAttrs { /** * Line chart data to display, or undefined if loading. @@ -172,11 +177,26 @@ * Note: When stacked, all series must be aligned to the same X values. */ readonly stacked?: boolean; + /** Total row count before LIMIT; when set and shown < total, an overlay warns the user. */ + readonly totalCount?: number; + /** Number of items actually shown; should come from the loader result. */ + readonly shownCount?: number; + /** Show the truncation warning overlay. Defaults to false. */ + readonly showTruncationWarning?: boolean; } export class LineChart implements m.ClassComponent<LineChartAttrs> { view({attrs}: m.Vnode<LineChartAttrs>) { - const {data, height, fillParent, className, onBrush} = attrs; + const { + data, + height, + fillParent, + className, + onBrush, + totalCount, + shownCount, + showTruncationWarning, + } = attrs; const isEmpty = data !== undefined && @@ -185,7 +205,7 @@ const option = data !== undefined && !isEmpty ? buildLineOption(attrs, data) : undefined; - return m(EChartView, { + const content = m(EChartView, { option, height, fillParent, @@ -194,6 +214,12 @@ eventHandlers: buildLineEventHandlers(attrs, data), activeBrushType: onBrush !== undefined ? 'lineX' : undefined, }); + return maybeWrapWithTruncation( + content, + shownCount, + totalCount, + showTruncationWarning, + ); } }
diff --git a/ui/src/components/widgets/charts/line_chart_loader.ts b/ui/src/components/widgets/charts/line_chart_loader.ts index 825bd07..6ccc006 100644 --- a/ui/src/components/widgets/charts/line_chart_loader.ts +++ b/ui/src/components/widgets/charts/line_chart_loader.ts
@@ -26,7 +26,12 @@ PointColumnSpec, rangeFilters, } from './chart_sql_source'; -import {LineChartData, LineChartPoint, LineChartSeries} from './line_chart'; +import { + LineChartData, + LineChartPoint, + LineChartSeries, + countLineChartShown, +} from './line_chart'; /** * Configuration for SQLLineChartLoader. @@ -159,6 +164,10 @@ return {series}; } + protected override countShown(data: LineChartData): number { + return countLineChartShown(data); + } + protected override extraCacheKey( config: LineChartLoaderConfig, ): Record<string, string | number | boolean | undefined> {
diff --git a/ui/src/components/widgets/charts/pie_chart.ts b/ui/src/components/widgets/charts/pie_chart.ts index 5c69a10..b4e46f5 100644 --- a/ui/src/components/widgets/charts/pie_chart.ts +++ b/ui/src/components/widgets/charts/pie_chart.ts
@@ -16,6 +16,7 @@ import type {EChartsCoreOption} from 'echarts/core'; import {formatNumber} from './chart_utils'; import {EChartView, EChartEventHandler, EChartClickParams} from './echart_view'; +import {maybeWrapWithTruncation} from './chart_truncation_warning'; import {buildLegendOption, buildTooltipOption} from './chart_option_builder'; import type {LegendPosition} from './common'; @@ -91,18 +92,32 @@ * Callback when a slice is clicked. */ readonly onSliceClick?: (slice: PieChartSlice) => void; + /** Total row count before LIMIT; when set and shown < total, an overlay warns the user. */ + readonly totalCount?: number; + /** Number of items actually shown; should come from the loader result. */ + readonly shownCount?: number; + /** Show the truncation warning overlay. Defaults to false. */ + readonly showTruncationWarning?: boolean; } export class PieChart implements m.ClassComponent<PieChartAttrs> { view({attrs}: m.Vnode<PieChartAttrs>) { - const {data, height, fillParent, className} = attrs; + const { + data, + height, + fillParent, + className, + totalCount, + shownCount, + showTruncationWarning, + } = attrs; const validSlices = data?.slices.filter((s) => s.value > 0) ?? []; const isEmpty = data !== undefined && validSlices.length === 0; const option = validSlices.length > 0 ? buildPieOption(attrs, validSlices) : undefined; - return m(EChartView, { + const content = m(EChartView, { option, height, fillParent, @@ -110,6 +125,12 @@ empty: isEmpty, eventHandlers: buildPieEventHandlers(attrs, validSlices), }); + return maybeWrapWithTruncation( + content, + shownCount, + totalCount, + showTruncationWarning, + ); } }
diff --git a/ui/src/components/widgets/charts/pie_chart_loader.ts b/ui/src/components/widgets/charts/pie_chart_loader.ts index 3691308..e20472b 100644 --- a/ui/src/components/widgets/charts/pie_chart_loader.ts +++ b/ui/src/components/widgets/charts/pie_chart_loader.ts
@@ -23,8 +23,21 @@ SQLChartLoader, QueryConfig, ChartLoaderResult, + OTHER_LABEL, inFilter, } from './chart_sql_source'; + +// Excludes the synthetic "(Other)" slice so it doesn't inflate the shown count. +export function countDistinctPieSlices( + slices: ReadonlyArray<{readonly label: string}> | undefined, +): number { + if (slices === undefined) return 0; + let count = 0; + for (const s of slices) { + if (s.label !== OTHER_LABEL) count += 1; + } + return count; +} import {ChartAggregation} from './chart_utils'; import {PieChartData, PieChartSlice} from './pie_chart'; @@ -117,4 +130,8 @@ } return {slices}; } + + protected override countShown(data: PieChartData): number { + return countDistinctPieSlices(data.slices); + } }
diff --git a/ui/src/components/widgets/charts/sankey_loader.ts b/ui/src/components/widgets/charts/sankey_loader.ts index 80e7822..d60d38f 100644 --- a/ui/src/components/widgets/charts/sankey_loader.ts +++ b/ui/src/components/widgets/charts/sankey_loader.ts
@@ -145,4 +145,9 @@ const nodes: SankeyNode[] = Array.from(nodeSet, (name) => ({name})); return {nodes, links}; } + + protected override countShown(_data: SankeyData): number { + // Sankey queries never apply a LIMIT, so this is never called. + return 0; + } }
diff --git a/ui/src/components/widgets/charts/scatterplot.ts b/ui/src/components/widgets/charts/scatterplot.ts index b6b29dc..e675349 100644 --- a/ui/src/components/widgets/charts/scatterplot.ts +++ b/ui/src/components/widgets/charts/scatterplot.ts
@@ -17,6 +17,7 @@ import {extractBrushRect, formatNumber} from './chart_utils'; import {EChartView, EChartEventHandler} from './echart_view'; import type {LegendPosition} from './common'; +import {maybeWrapWithTruncation} from './chart_truncation_warning'; import { buildChartOption, buildLegendOption, @@ -168,11 +169,26 @@ * Defaults to no grid lines. */ readonly gridLines?: 'horizontal' | 'vertical' | 'both'; + /** Total row count before LIMIT; when set and shown < total, an overlay warns the user. */ + readonly totalCount?: number; + /** Number of items actually shown; should come from the loader result. */ + readonly shownCount?: number; + /** Show the truncation warning overlay. Defaults to false. */ + readonly showTruncationWarning?: boolean; } export class Scatterplot implements m.ClassComponent<ScatterChartAttrs> { view({attrs}: m.Vnode<ScatterChartAttrs>) { - const {data, height, fillParent, className, onBrush} = attrs; + const { + data, + height, + fillParent, + className, + onBrush, + totalCount, + shownCount, + showTruncationWarning, + } = attrs; const isEmpty = data !== undefined && @@ -183,7 +199,7 @@ ? buildScatterOption(attrs, data) : undefined; - return m(EChartView, { + const content = m(EChartView, { option, height, fillParent, @@ -192,6 +208,12 @@ eventHandlers: buildScatterEventHandlers(attrs), activeBrushType: onBrush !== undefined ? 'rect' : undefined, }); + return maybeWrapWithTruncation( + content, + shownCount, + totalCount, + showTruncationWarning, + ); } }
diff --git a/ui/src/components/widgets/charts/scatterplot_loader.ts b/ui/src/components/widgets/charts/scatterplot_loader.ts index 838cb47..91fd3bc 100644 --- a/ui/src/components/widgets/charts/scatterplot_loader.ts +++ b/ui/src/components/widgets/charts/scatterplot_loader.ts
@@ -187,4 +187,8 @@ } return {series}; } + + protected override countShown(data: ScatterChartData): number { + return data.series.reduce((sum, s) => sum + s.points.length, 0); + } }
diff --git a/ui/src/components/widgets/charts/single_value_loader.ts b/ui/src/components/widgets/charts/single_value_loader.ts index 3d15396..a65dd4a 100644 --- a/ui/src/components/widgets/charts/single_value_loader.ts +++ b/ui/src/components/widgets/charts/single_value_loader.ts
@@ -76,4 +76,9 @@ } return {value: 0}; } + + protected override countShown(_data: SingleValueData): number { + // Scorecard queries never apply a LIMIT, so this is never called. + return 0; + } }
diff --git a/ui/src/components/widgets/charts/treemap.ts b/ui/src/components/widgets/charts/treemap.ts index cd461e2..6f4daa9 100644 --- a/ui/src/components/widgets/charts/treemap.ts +++ b/ui/src/components/widgets/charts/treemap.ts
@@ -16,6 +16,7 @@ import type {EChartsCoreOption} from 'echarts/core'; import {formatNumber} from './chart_utils'; import {EChartView, EChartEventHandler, EChartClickParams} from './echart_view'; +import {maybeWrapWithTruncation} from './chart_truncation_warning'; import {buildTooltipOption} from './chart_option_builder'; /** @@ -30,6 +31,21 @@ readonly children?: readonly TreemapNode[]; } +export function countTreemapLeaves( + nodes: readonly TreemapNode[] | undefined, +): number { + if (nodes === undefined) return 0; + let count = 0; + for (const node of nodes) { + if (node.children !== undefined && node.children.length > 0) { + count += countTreemapLeaves(node.children); + } else { + count += 1; + } + } + return count; +} + /** * Data provided to a TreemapChart. */ @@ -86,11 +102,25 @@ * When true, clicking a parent node zooms into it. */ readonly enableDrillDown?: boolean; + /** Total row count before LIMIT; when set and shown < total, an overlay warns the user. */ + readonly totalCount?: number; + /** Number of items actually shown; should come from the loader result. */ + readonly shownCount?: number; + /** Show the truncation warning overlay. Defaults to false. */ + readonly showTruncationWarning?: boolean; } export class Treemap implements m.ClassComponent<TreemapChartAttrs> { view({attrs}: m.Vnode<TreemapChartAttrs>) { - const {data, height, fillParent, className} = attrs; + const { + data, + height, + fillParent, + className, + totalCount, + shownCount, + showTruncationWarning, + } = attrs; const isEmpty = data !== undefined && data.nodes.length === 0; const option = @@ -98,7 +128,7 @@ ? buildTreemapOption(attrs, data) : undefined; - return m(EChartView, { + const content = m(EChartView, { option, height, fillParent, @@ -106,6 +136,12 @@ empty: isEmpty, eventHandlers: buildTreemapEventHandlers(attrs, data), }); + return maybeWrapWithTruncation( + content, + shownCount, + totalCount, + showTruncationWarning, + ); } }
diff --git a/ui/src/components/widgets/charts/treemap_loader.ts b/ui/src/components/widgets/charts/treemap_loader.ts index 4795400..cb0e563 100644 --- a/ui/src/components/widgets/charts/treemap_loader.ts +++ b/ui/src/components/widgets/charts/treemap_loader.ts
@@ -26,7 +26,7 @@ inFilter, } from './chart_sql_source'; import {ChartAggregation} from './chart_utils'; -import {TreemapData, TreemapNode} from './treemap'; +import {TreemapData, TreemapNode, countTreemapLeaves} from './treemap'; /** * Configuration for SQLTreemapLoader. @@ -181,4 +181,8 @@ } return {nodes}; } + + protected override countShown(data: TreemapData): number { + return countTreemapLeaves(data.nodes); + } }
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts index 68d68a6..bb548a1 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts
@@ -124,11 +124,13 @@ interface DashboardSettings { showGridDots: boolean; chartGridLines?: 'horizontal' | 'vertical' | 'both'; + showTruncationWarning: boolean; } const DEFAULT_SETTINGS: DashboardSettings = { showGridDots: true, chartGridLines: undefined, + showTruncationWarning: true, }; interface EditingChartContext { @@ -476,6 +478,18 @@ }), ), m('.pf-dashboard__panel-section-subtitle', 'Charts'), + m( + '.pf-dashboard__settings-row', + m(Switch, { + label: 'Show truncation warnings', + checked: this.settings.showTruncationWarning, + onchange: (e: Event) => { + this.settings.showTruncationWarning = ( + e.target as HTMLInputElement + ).checked; + }, + }), + ), m('.pf-dashboard__settings-row', [ m('label.pf-dashboard__settings-label', 'Grid lines'), m( @@ -681,6 +695,7 @@ onBrushFiltersChange: attrs.onBrushFiltersChange, isDriverChart: chartIsDriver, gridLines: this.settings.chartGridLines, + showTruncationWarning: this.settings.showTruncationWarning, }), ), ]);
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 66895e1..2fa12e8 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
@@ -58,6 +58,12 @@ isDriverChart?: boolean; /** Grid lines setting passed through to chart widgets. */ gridLines?: 'horizontal' | 'vertical' | 'both'; + /** + * Global dashboard-level toggle for truncation warnings. + * When false, all charts suppress the warning regardless of per-chart config. + * Defaults to true. + */ + showTruncationWarning?: boolean; } /** Subset of DashboardChartViewAttrs needed by the adapter. */ @@ -322,7 +328,7 @@ onFilterChange: () => m.redraw(), gridLines: attrs.gridLines, }; - return renderChartByType(ctx, config, entry); + return renderChartByType(ctx, config, entry, attrs.showTruncationWarning); } private ensureLoader(
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_config_popup.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_config_popup.ts index 46e27c4..2025497 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_config_popup.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_config_popup.ts
@@ -22,6 +22,7 @@ import {ChartAggregation} from '../../../../components/widgets/charts/chart_utils'; import {Select} from '../../../../widgets/select'; import {Form, FormLabel} from '../../../../widgets/form'; +import {Checkbox} from '../../../../widgets/checkbox'; import {ChartColumnProvider} from './chart_renderers'; interface ColumnInfo { @@ -352,6 +353,18 @@ }, }), ]), + m(FormLabel, [ + m(Checkbox, { + label: 'Show truncation warning', + checked: config.showTruncationWarning ?? true, + onchange: (e: Event) => { + const target = e.target as HTMLInputElement; + ctx.node.updateChart(config.id, { + showTruncationWarning: target.checked, + }); + }, + }), + ]), ], ); }
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_renderers.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_renderers.ts index f4c70bc..6ee5cb5 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_renderers.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_renderers.ts
@@ -13,6 +13,7 @@ // limitations under the License. import m from 'mithril'; +import {assertUnreachable} from '../../../../base/assert'; import { ChartConfig, ChartType, @@ -249,47 +250,62 @@ } } -/** Render the appropriate chart widget for a config + loader entry. */ +/** + * Render the appropriate chart widget for a config + loader entry. + * + * `globalShowTruncationWarning` is a dashboard-level override: when false it + * suppresses the warning for all charts regardless of their per-chart config. + */ export function renderChartByType( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, + globalShowTruncationWarning?: boolean, ): m.Child { + const showWarning = + (globalShowTruncationWarning ?? true) && + config.showTruncationWarning !== false; switch (config.chartType) { case 'bar': - return renderBarChart(ctx, config, entry); + return renderBarChart(ctx, config, entry, showWarning); case 'histogram': return renderHistogram(ctx, config, entry); case 'line': - return renderLineChart(ctx, config, entry); + return renderLineChart(ctx, config, entry, showWarning); case 'scatter': - return renderScatterChart(ctx, config, entry); + return renderScatterChart(ctx, config, entry, showWarning); case 'pie': - return renderPieChart(ctx, config, entry); + return renderPieChart(ctx, config, entry, showWarning); case 'treemap': - return renderTreemap(ctx, config, entry); + return renderTreemap(ctx, config, entry, showWarning); case 'boxplot': - return renderBoxplot(ctx, config, entry); + return renderBoxplot(ctx, config, entry, showWarning); case 'heatmap': return renderHeatmap(ctx, config, entry); case 'cdf': - return renderCdf(ctx, config, entry); + return renderCdf(ctx, config, entry, showWarning); case 'scorecard': return renderScorecard(ctx, config, entry); + default: + assertUnreachable(config.chartType); } } -export function renderBarChart( +function renderBarChart( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, + showTruncationWarning: boolean, ): m.Child { if (!entry.barLoader) { return m(EmptyState, {icon: 'bar_chart', title: 'No data to display'}); } const agg = config.aggregation ?? 'COUNT'; - const {data} = entry.barLoader.use({aggregation: agg, limit: 100}); + const {data, totalCount, shownCount} = entry.barLoader.use({ + aggregation: agg, + limit: 100, + }); const measureLabel = formatMeasureLabel(agg, config); @@ -315,10 +331,13 @@ formatMeasure, gridLines: ctx.gridLines, onBrush: (labels) => handleBarBrush(ctx, config, labels), + totalCount, + shownCount, + showTruncationWarning, }); } -export function renderHistogram( +function renderHistogram( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, @@ -347,16 +366,17 @@ }); } -export function renderLineChart( +function renderLineChart( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, + showTruncationWarning: boolean, ): m.Child { if (!entry.lineLoader) { return m(EmptyState, {icon: 'show_chart', title: 'No data to display'}); } - const {data} = entry.lineLoader.use({}); + const {data, totalCount, shownCount} = entry.lineLoader.use({}); const formatXValue = getNumericFormatter(ctx.node, config.column); const formatYValue = config.yColumn @@ -375,19 +395,25 @@ gridLines: ctx.gridLines, onBrush: (range) => handleHistogramBrush(ctx, config, range.start, range.end), + totalCount, + shownCount, + showTruncationWarning, }); } -export function renderScatterChart( +function renderScatterChart( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, + showTruncationWarning: boolean, ): m.Child { if (!entry.scatterLoader) { return m(EmptyState, {icon: 'scatter_plot', title: 'No data to display'}); } - const {data} = entry.scatterLoader.use({maxPoints: 2000}); + const {data, totalCount, shownCount} = entry.scatterLoader.use({ + maxPoints: 2000, + }); const formatXValue = getNumericFormatter(ctx.node, config.column); const formatYValue = config.yColumn @@ -404,20 +430,27 @@ formatXValue, formatYValue, gridLines: ctx.gridLines, + totalCount, + shownCount, + showTruncationWarning, }); } -export function renderPieChart( +function renderPieChart( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, + showTruncationWarning: boolean, ): m.Child { if (!entry.pieLoader) { return m(EmptyState, {icon: 'pie_chart', title: 'No data to display'}); } const agg = config.aggregation ?? 'COUNT'; - const {data} = entry.pieLoader.use({aggregation: agg, limit: 20}); + const {data, totalCount, shownCount} = entry.pieLoader.use({ + aggregation: agg, + limit: 20, + }); const formatValue = agg !== 'COUNT' && agg !== 'COUNT_DISTINCT' @@ -434,20 +467,27 @@ ctx.node.setBrushSelection(config.column, [slice.label]); ctx.onFilterChange?.(); }, + totalCount, + shownCount, + showTruncationWarning, }); } -export function renderTreemap( +function renderTreemap( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, + showTruncationWarning: boolean, ): m.Child { if (!entry.treemapLoader) { return m(EmptyState, {icon: 'grid_view', title: 'No data to display'}); } const agg = config.aggregation ?? 'SUM'; - const {data} = entry.treemapLoader.use({aggregation: agg, limit: 50}); + const {data, totalCount, shownCount} = entry.treemapLoader.use({ + aggregation: agg, + limit: 50, + }); const formatValue = getNumericFormatter( ctx.node, @@ -464,13 +504,17 @@ ctx.node.setBrushSelection(config.column, [node.name]); ctx.onFilterChange?.(); }, + totalCount, + shownCount, + showTruncationWarning, }); } -export function renderBoxplot( +function renderBoxplot( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, + showTruncationWarning: boolean, ): m.Child { if (!entry.boxplotLoader) { return m(EmptyState, { @@ -479,7 +523,7 @@ }); } - const {data} = entry.boxplotLoader.use({limit: 30}); + const {data, totalCount, shownCount} = entry.boxplotLoader.use({limit: 30}); const formatValue = config.yColumn ? getNumericFormatter(ctx.node, config.yColumn) @@ -493,10 +537,13 @@ fillParent: true, formatValue, gridLines: ctx.gridLines, + totalCount, + shownCount, + showTruncationWarning, }); } -export function renderHeatmap( +function renderHeatmap( _ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, @@ -523,16 +570,17 @@ }); } -export function renderCdf( +function renderCdf( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry, + showTruncationWarning: boolean, ): m.Child { if (!entry.cdfLoader) { return m(EmptyState, {icon: 'trending_up', title: 'No data to display'}); } - const {data} = entry.cdfLoader.use({maxPoints: 500}); + const {data, totalCount, shownCount} = entry.cdfLoader.use({maxPoints: 500}); const formatXValue = getNumericFormatter(ctx.node, config.column); @@ -546,10 +594,13 @@ formatXValue, onBrush: (range) => handleHistogramBrush(ctx, config, range.start, range.end), + totalCount, + shownCount, + showTruncationWarning, }); } -export function renderScorecard( +function renderScorecard( ctx: ChartRenderContext, config: ChartConfig, entry: ChartLoaderEntry,
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 84f4e75..855febc 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
@@ -132,6 +132,11 @@ * Optional bubble-size column for scatter charts (numeric). */ sizeColumn?: string; + /** + * Whether to show a warning icon when the chart is displaying limited + * data (i.e. totalCount > shownCount). Defaults to true. + */ + showTruncationWarning?: boolean; } /** @@ -843,4 +848,64 @@ this.context.onchange?.(); } + + serializeState(): object { + return { + chartConfigs: this.attrs.chartConfigs.map((c) => ({ + id: c.id, + name: c.name, + column: c.column, + chartType: c.chartType, + binCount: c.binCount, + orientation: c.orientation, + widthPx: c.widthPx, + aggregation: c.aggregation, + measureColumn: c.measureColumn, + yColumn: c.yColumn, + groupColumn: c.groupColumn, + sizeColumn: c.sizeColumn, + showTruncationWarning: c.showTruncationWarning, + })), + chartFilters: this.attrs.chartFilters?.map((f) => { + if ('value' in f) { + return { + column: f.column, + op: f.op, + value: f.value, + enabled: f.enabled, + }; + } else { + return { + column: f.column, + op: f.op, + enabled: f.enabled, + }; + } + }), + }; + } + + static deserializeState( + state: Partial<VisualisationNodeAttrs>, + ): Partial<VisualisationNodeAttrs> { + // Convert filter values from strings back to numbers when appropriate. + // JSON serialization converts BigInt to string, and we need numeric + // values for proper filtering (same logic as FilterNode). + const chartFilters = state.chartFilters?.map((f): Partial<UIFilter> => { + if ('value' in f && typeof f.value === 'string') { + if (!Array.isArray(f.value)) { + const parsed = parseFilterValue(f.value); + if (parsed !== undefined && parsed !== f.value) { + return {...f, value: parsed} as Partial<UIFilter>; + } + } + } + return {...f}; + }); + return { + ...state, + chartConfigs: state.chartConfigs?.map((c) => ({...c})) ?? [], + chartFilters, + }; + } }
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/charts_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/charts_demo.ts index 96fca50..01aaf14 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/charts_demo.ts +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/charts_demo.ts
@@ -97,6 +97,14 @@ import {EnumOption, renderWidgetShowcase} from '../widgets_page_utils'; import {Trace} from '../../../public/trace'; +function formatTruncationInfo( + shownCount: number | undefined, + totalCount: number | undefined, +): string { + if (shownCount === undefined || totalCount === undefined) return ''; + return `\nShowing ${shownCount} of ${totalCount} (${totalCount - shownCount} not shown)`; +} + // Generate sample data with normal distribution function generateNormalData( count: number, @@ -460,6 +468,7 @@ horizontal: opts.horizontal, aggregation: opts.aggregation, gridLines: opts.gridLines, + showTruncationWarning: opts.showTruncationWarning, }); }, initialOpts: { @@ -484,6 +493,7 @@ 'vertical', 'both', ] as const), + showTruncationWarning: true, }, }), m('h3', {style: {marginTop: '32px'}}, 'SQLLineChartLoader'), @@ -527,6 +537,7 @@ donut: opts.donut, aggregation: opts.aggregation, limit: opts.limit, + showTruncationWarning: opts.showTruncationWarning, }); }, initialOpts: { @@ -541,6 +552,7 @@ 'COUNT_DISTINCT', ] as const), limit: 8, + showTruncationWarning: true, }, }), m('h3', {style: {marginTop: '32px'}}, 'SQLHistogramLoader'), @@ -604,12 +616,14 @@ height: opts.height, showLabels: opts.showLabels, limit: opts.limit, + showTruncationWarning: opts.showTruncationWarning, }); }, initialOpts: { height: 300, showLabels: true, limit: 10, + showTruncationWarning: true, }, }), m('h3', {style: {marginTop: '32px'}}, 'SQLSankeyLoader'), @@ -661,6 +675,7 @@ height: opts.height, limit: opts.limit, gridLines: opts.gridLines, + showTruncationWarning: opts.showTruncationWarning, }); }, initialOpts: { @@ -672,6 +687,7 @@ 'vertical', 'both', ] as const), + showTruncationWarning: true, }, }), m('h3', {style: {marginTop: '32px'}}, 'SQLHeatmapLoader'), @@ -950,6 +966,7 @@ horizontal: boolean; aggregation: ChartAggregation; gridLines: string; + showTruncationWarning: boolean; }> { let loader: SQLBarChartLoader | undefined; let brushedLabels: Array<string | number> | undefined; @@ -972,7 +989,7 @@ limit: 10, filter: isFilter ? brushedLabels : undefined, }; - const {data, isPending} = loader.use(config); + const {data, isPending, totalCount, shownCount} = loader.use(config); const measureLabels: Record<ChartAggregation, string> = { ANY: 'Any Duration', @@ -1007,6 +1024,9 @@ } : undefined, selection: attrs.brushMode === 'select' ? brushedLabels : undefined, + totalCount, + shownCount, + showTruncationWarning: attrs.showTruncationWarning, }), m( 'pre', @@ -1024,6 +1044,7 @@ `dimensionColumn: 'name', measureColumn: 'dur'\n`, `loader.use(${JSON.stringify(config, null, 2)})`, isPending ? '\n(loading...)' : '', + formatTruncationInfo(shownCount, totalCount), brushedLabels ? `\nBrushed: [${brushedLabels.join(', ')}]` : '', !isFilter && brushedLabels ? '\n(select mode — data unchanged)' @@ -1237,6 +1258,7 @@ donut: boolean; aggregation: ChartAggregation; limit: number; + showTruncationWarning: boolean; }> { let loader: SQLPieChartLoader | undefined; @@ -1255,7 +1277,7 @@ aggregation: attrs.aggregation, limit: attrs.limit, }; - const {data, isPending} = loader.use(config); + const {data, isPending, totalCount, shownCount} = loader.use(config); return m('div', [ m(PieChart, { @@ -1263,6 +1285,9 @@ height: attrs.height, showLegend: attrs.showLegend, innerRadiusRatio: attrs.donut ? 0.5 : 0, + totalCount, + shownCount, + showTruncationWarning: attrs.showTruncationWarning, }), m( 'pre', @@ -1280,6 +1305,7 @@ `dimensionColumn: 'name', measureColumn: 'dur'\n`, `loader.use(${JSON.stringify(config, null, 2)})`, isPending ? '\n(loading...)' : '', + formatTruncationInfo(shownCount, totalCount), ], ), ]); @@ -2027,6 +2053,7 @@ height: number; showLabels: boolean; limit: number; + showTruncationWarning: boolean; }> { let loader: SQLTreemapLoader | undefined; let clickedNode: string | undefined; @@ -2047,7 +2074,7 @@ aggregation: 'SUM', limit: attrs.limit, }; - const {data, isPending} = loader.use(config); + const {data, isPending, totalCount, shownCount} = loader.use(config); return m('div', [ m(Treemap, { @@ -2057,6 +2084,9 @@ onNodeClick: (node) => { clickedNode = node.name; }, + totalCount, + shownCount, + showTruncationWarning: attrs.showTruncationWarning, }), m( 'pre', @@ -2074,6 +2104,7 @@ `labelColumn: 'name', sizeColumn: 'dur', groupColumn: 'category'\n`, `loader.use(${JSON.stringify(config, null, 2)})`, isPending ? '\n(loading...)' : '', + formatTruncationInfo(shownCount, totalCount), clickedNode ? `\nClicked: ${clickedNode}` : '', ], ), @@ -2382,6 +2413,7 @@ height: number; limit: number; gridLines: string; + showTruncationWarning: boolean; }> { let loader: SQLBoxplotLoader | undefined; @@ -2399,7 +2431,7 @@ const config: BoxplotLoaderConfig = { limit: attrs.limit, }; - const {data, isPending} = loader.use(config); + const {data, isPending, totalCount, shownCount} = loader.use(config); return m('div', [ m(BoxplotChart, { @@ -2408,6 +2440,9 @@ categoryLabel: 'Slice Name', valueLabel: 'Duration (ns)', gridLines: toGridLines(attrs.gridLines), + totalCount, + shownCount, + showTruncationWarning: attrs.showTruncationWarning, }), m( 'pre', @@ -2425,6 +2460,7 @@ `categoryColumn: 'name', valueColumn: 'dur'\n`, `loader.use(${JSON.stringify(config, null, 2)})`, isPending ? '\n(loading...)' : '', + formatTruncationInfo(shownCount, totalCount), ], ), ]);