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),
           ],
         ),
       ]);