diff --git a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx
index 73f69c1053..0ffb9315d7 100644
--- a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx
+++ b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx
@@ -13,6 +13,7 @@ import {
LineChart,
List,
Modal,
+ PieChart,
RadioGroup,
Row,
Slider,
@@ -41,6 +42,7 @@ import imageManifest from '@lynx-js/a2ui-reactlynx/catalog/Image/catalog.json';
import lineChartManifest from '@lynx-js/a2ui-reactlynx/catalog/LineChart/catalog.json';
import listManifest from '@lynx-js/a2ui-reactlynx/catalog/List/catalog.json';
import modalManifest from '@lynx-js/a2ui-reactlynx/catalog/Modal/catalog.json';
+import pieChartManifest from '@lynx-js/a2ui-reactlynx/catalog/PieChart/catalog.json';
import radioGroupManifest from '@lynx-js/a2ui-reactlynx/catalog/RadioGroup/catalog.json';
import rowManifest from '@lynx-js/a2ui-reactlynx/catalog/Row/catalog.json';
import sliderManifest from '@lynx-js/a2ui-reactlynx/catalog/Slider/catalog.json';
@@ -93,6 +95,7 @@ const ALL_BUILTINS: readonly CatalogInput[] = [
manifestEntry(Icon, iconManifest),
manifestEntry(CheckBox, checkBoxManifest),
manifestEntry(LineChart, lineChartManifest),
+ manifestEntry(PieChart, pieChartManifest),
manifestEntry(RadioGroup, radioGroupManifest),
manifestEntry(Slider, sliderManifest),
manifestEntry(TextField, textFieldManifest),
diff --git a/packages/genui/a2ui-playground/src/catalog/a2ui.ts b/packages/genui/a2ui-playground/src/catalog/a2ui.ts
index 52b816463b..f1c245530f 100644
--- a/packages/genui/a2ui-playground/src/catalog/a2ui.ts
+++ b/packages/genui/a2ui-playground/src/catalog/a2ui.ts
@@ -11,6 +11,7 @@ import imageManifest from '@lynx-js/a2ui-reactlynx/catalog/Image/catalog.json';
import lineChartManifest from '@lynx-js/a2ui-reactlynx/catalog/LineChart/catalog.json';
import listManifest from '@lynx-js/a2ui-reactlynx/catalog/List/catalog.json';
import modalManifest from '@lynx-js/a2ui-reactlynx/catalog/Modal/catalog.json';
+import pieChartManifest from '@lynx-js/a2ui-reactlynx/catalog/PieChart/catalog.json';
import radioGroupManifest from '@lynx-js/a2ui-reactlynx/catalog/RadioGroup/catalog.json';
import rowManifest from '@lynx-js/a2ui-reactlynx/catalog/Row/catalog.json';
import sliderManifest from '@lynx-js/a2ui-reactlynx/catalog/Slider/catalog.json';
@@ -619,6 +620,69 @@ export const COMPONENT_CATALOG: ComponentDoc[] = [
openui: [],
},
},
+ {
+ name: 'PieChart',
+ category: 'Chart',
+ description:
+ 'Renders pie and donut slices with native SVG arcs and a responsive legend.',
+ props: schemaToProps(pieChartManifest),
+ usage: {
+ a2ui: {
+ id: 'revenue-share',
+ component: 'PieChart',
+ variant: 'donut',
+ title: 'Revenue mix',
+ subtitle: 'This month',
+ data: [
+ { name: 'Subscriptions', value: 48, color: '#0057d9' },
+ { name: 'Services', value: 26, color: '#0a8f8f' },
+ { name: 'Licensing', value: 16, color: '#8a5cf6' },
+ { name: 'Other', value: 10, color: '#d92d20' },
+ ],
+ paddingAngle: 2,
+ },
+ openui: {},
+ },
+ usageExamples: {
+ a2ui: [
+ {
+ label: 'Donut',
+ value: {
+ id: 'donut-revenue-share',
+ component: 'PieChart',
+ variant: 'donut',
+ title: 'Revenue mix',
+ subtitle: 'This month',
+ data: [
+ { name: 'Subscriptions', value: 48, color: '#0057d9' },
+ { name: 'Services', value: 26, color: '#0a8f8f' },
+ { name: 'Licensing', value: 16, color: '#8a5cf6' },
+ { name: 'Other', value: 10, color: '#d92d20' },
+ ],
+ paddingAngle: 2,
+ },
+ },
+ {
+ label: 'Flat pie',
+ value: {
+ id: 'flat-audience-share',
+ component: 'PieChart',
+ variant: 'pie',
+ title: 'Audience split',
+ subtitle: 'Active users by region',
+ data: [
+ { name: 'Asia', value: 42, color: '#0057d9' },
+ { name: 'Europe', value: 28, color: '#0a8f8f' },
+ { name: 'North America', value: 18, color: '#8a5cf6' },
+ { name: 'Other', value: 12, color: '#b26a00' },
+ ],
+ paddingAngle: 3,
+ },
+ },
+ ],
+ openui: [],
+ },
+ },
{
name: 'Card',
category: 'Layout',
diff --git a/packages/genui/a2ui-playground/src/demos.ts b/packages/genui/a2ui-playground/src/demos.ts
index d5c2af546b..7f23938284 100644
--- a/packages/genui/a2ui-playground/src/demos.ts
+++ b/packages/genui/a2ui-playground/src/demos.ts
@@ -6,6 +6,7 @@ import castGrid from './mock/basic/cast-grid.json';
import citywalkList from './mock/basic/citywalk-list.json';
import fridgeSearch from './mock/basic/fridge-search.json';
import hangzhouWeatherTrend from './mock/basic/hangzhou-weather-trend.json';
+import pieChartChinaEvShare from './mock/basic/pie-chart-china-ev-share.json';
import recs from './mock/basic/recs.json';
import tripPlanner from './mock/basic/trip-planner.json';
import workoutPlan from './mock/basic/workout-plan.json';
@@ -132,6 +133,14 @@ export const EXTENDED_STATIC_DEMOS: StaticDemo[] = [
tags: tagsFromMessages(hangzhouWeatherTrend),
messages: hangzhouWeatherTrend,
},
+ {
+ id: 'pie-chart-china-ev-share',
+ title: '2025 China EV Brand Share',
+ description:
+ 'A CPCA-based donut chart shows the 2025 passenger NEV share for major Chinese EV brands.',
+ tags: tagsFromMessages(pieChartChinaEvShare),
+ messages: pieChartChinaEvShare,
+ },
{
id: 'fridge-search',
title: 'Fridge Search Results',
diff --git a/packages/genui/a2ui-playground/src/mock/basic/pie-chart-china-ev-share.json b/packages/genui/a2ui-playground/src/mock/basic/pie-chart-china-ev-share.json
new file mode 100644
index 0000000000..bfcb34d6ff
--- /dev/null
+++ b/packages/genui/a2ui-playground/src/mock/basic/pie-chart-china-ev-share.json
@@ -0,0 +1,195 @@
+[
+ {
+ "createSurface": {
+ "surfaceId": "default",
+ "catalogId": "demo-pie-chart-china-ev-share"
+ }
+ },
+ {
+ "updateComponents": {
+ "surfaceId": "default",
+ "components": [
+ {
+ "id": "root",
+ "component": "Column",
+ "align": "start",
+ "justify": "start",
+ "children": ["title", "subtitle", "stageList"]
+ },
+ {
+ "id": "title",
+ "component": "Text",
+ "variant": "h2",
+ "text": "2025 China EV Brand Share"
+ },
+ {
+ "id": "subtitle",
+ "component": "Text",
+ "variant": "body",
+ "text": "Building the chart in stages without overwriting earlier components."
+ },
+ {
+ "id": "stageList",
+ "component": "Column",
+ "align": "stretch",
+ "justify": "start",
+ "children": []
+ }
+ ]
+ }
+ },
+
+ {
+ "updateComponents": {
+ "surfaceId": "default",
+ "components": [
+ {
+ "id": "stageList",
+ "component": "Column",
+ "align": "stretch",
+ "justify": "start",
+ "children": ["stage-1"]
+ },
+ {
+ "id": "stage-1",
+ "component": "Card",
+ "child": "stage-1-body"
+ },
+ {
+ "id": "stage-1-body",
+ "component": "Column",
+ "align": "stretch",
+ "justify": "start",
+ "children": ["stage-1-label", "stage-1-chart"]
+ },
+ {
+ "id": "stage-1-label",
+ "component": "Text",
+ "variant": "h4",
+ "text": "Snapshot 1 · Top 3 brands"
+ },
+ {
+ "id": "stage-1-chart",
+ "component": "PieChart",
+ "variant": "donut",
+ "title": "China EV brand share",
+ "subtitle": "Top 3 brands plus the rest",
+ "paddingAngle": 2,
+ "showPercentages": true,
+ "data": [
+ { "name": "BYD", "value": 27.2, "color": "#0057d9" },
+ { "name": "Geely", "value": 12.2, "color": "#0a8f8f" },
+ { "name": "Changan", "value": 6.2, "color": "#8a5cf6" },
+ { "name": "Other", "value": 54.4, "color": "#64748b" }
+ ]
+ }
+ ]
+ }
+ },
+
+ {
+ "updateComponents": {
+ "surfaceId": "default",
+ "components": [
+ {
+ "id": "stageList",
+ "component": "Column",
+ "align": "stretch",
+ "justify": "start",
+ "children": ["stage-1", "stage-2"]
+ },
+ {
+ "id": "stage-2",
+ "component": "Card",
+ "child": "stage-2-body"
+ },
+ {
+ "id": "stage-2-body",
+ "component": "Column",
+ "align": "stretch",
+ "justify": "start",
+ "children": ["stage-2-label", "stage-2-chart"]
+ },
+ {
+ "id": "stage-2-label",
+ "component": "Text",
+ "variant": "h4",
+ "text": "Snapshot 2 · Mid-tier brands added"
+ },
+ {
+ "id": "stage-2-chart",
+ "component": "PieChart",
+ "variant": "donut",
+ "title": "China EV brand share",
+ "subtitle": "Top 6 brands plus the rest",
+ "paddingAngle": 2,
+ "showPercentages": true,
+ "data": [
+ { "name": "BYD", "value": 27.2, "color": "#0057d9" },
+ { "name": "Geely", "value": 12.2, "color": "#0a8f8f" },
+ { "name": "Changan", "value": 6.2, "color": "#8a5cf6" },
+ { "name": "SAIC-GM-Wuling", "value": 6.0, "color": "#d92d20" },
+ { "name": "Tesla China", "value": 4.9, "color": "#2d6a4f" },
+ { "name": "HIMA", "value": 4.6, "color": "#b26a00" },
+ { "name": "Other", "value": 38.9, "color": "#64748b" }
+ ]
+ }
+ ]
+ }
+ },
+
+ {
+ "updateComponents": {
+ "surfaceId": "default",
+ "components": [
+ {
+ "id": "stageList",
+ "component": "Column",
+ "align": "stretch",
+ "justify": "start",
+ "children": ["stage-1", "stage-2", "stage-3"]
+ },
+ {
+ "id": "stage-3",
+ "component": "Card",
+ "child": "stage-3-body"
+ },
+ {
+ "id": "stage-3-body",
+ "component": "Column",
+ "align": "stretch",
+ "justify": "start",
+ "children": ["stage-3-label", "stage-3-chart"]
+ },
+ {
+ "id": "stage-3-label",
+ "component": "Text",
+ "variant": "h4",
+ "text": "Snapshot 3 · Final full breakdown"
+ },
+ {
+ "id": "stage-3-chart",
+ "component": "PieChart",
+ "variant": "donut",
+ "title": "China EV brand share",
+ "subtitle": "2025 passenger NEV retail share",
+ "paddingAngle": 2,
+ "showPercentages": true,
+ "data": [
+ { "name": "BYD", "value": 27.2, "color": "#0057d9" },
+ { "name": "Geely", "value": 12.2, "color": "#0a8f8f" },
+ { "name": "Changan", "value": 6.2, "color": "#8a5cf6" },
+ { "name": "SAIC-GM-Wuling", "value": 6.0, "color": "#d92d20" },
+ { "name": "Tesla China", "value": 4.9, "color": "#2d6a4f" },
+ { "name": "HIMA", "value": 4.6, "color": "#b26a00" },
+ { "name": "Chery", "value": 4.1, "color": "#a855f7" },
+ { "name": "Leapmotor", "value": 4.1, "color": "#ea580c" },
+ { "name": "Seres", "value": 3.3, "color": "#0891b2" },
+ { "name": "Xiaomi EV", "value": 3.2, "color": "#111827" },
+ { "name": "Other", "value": 24.2, "color": "#64748b" }
+ ]
+ }
+ ]
+ }
+ }
+]
diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json
index 1fe51807be..c9a3e6a442 100644
--- a/packages/genui/a2ui/package.json
+++ b/packages/genui/a2ui/package.json
@@ -21,6 +21,10 @@
"types": "./dist/catalog/index.d.ts",
"default": "./dist/catalog/index.js"
},
+ "./catalog/utils/chart": {
+ "types": "./dist/catalog/utils/chart.d.ts",
+ "default": "./dist/catalog/utils/chart.js"
+ },
"./catalog/Text": {
"types": "./dist/catalog/Text/index.d.ts",
"default": "./dist/catalog/Text/index.js"
@@ -61,6 +65,11 @@
"default": "./dist/catalog/LineChart/index.js"
},
"./catalog/LineChart/catalog.json": "./dist/catalog/LineChart/catalog.json",
+ "./catalog/PieChart": {
+ "types": "./dist/catalog/PieChart/index.d.ts",
+ "default": "./dist/catalog/PieChart/index.js"
+ },
+ "./catalog/PieChart/catalog.json": "./dist/catalog/PieChart/catalog.json",
"./catalog/Modal": {
"types": "./dist/catalog/Modal/index.d.ts",
"default": "./dist/catalog/Modal/index.js"
diff --git a/packages/genui/a2ui/src/catalog/LineChart/index.tsx b/packages/genui/a2ui/src/catalog/LineChart/index.tsx
index af0c909eb0..c8169f1643 100644
--- a/packages/genui/a2ui/src/catalog/LineChart/index.tsx
+++ b/packages/genui/a2ui/src/catalog/LineChart/index.tsx
@@ -3,6 +3,11 @@
// LICENSE file in the root directory of this source tree.
import { useResolvedProps } from '../../react/useDataBinding.js';
import type { GenericComponentProps } from '../../store/types.js';
+import {
+ DEFAULT_CHART_COLORS,
+ escapeXml,
+ formatValue,
+} from '../utils/chart.js';
import '../../../styles/catalog/LineChart.css';
@@ -39,31 +44,6 @@ export interface LineChartProps extends GenericComponentProps {
const SVG_WIDTH = 360;
const DEFAULT_HEIGHT = 240;
const MARGIN = { top: 16, right: 16, bottom: 24, left: 16 };
-const DEFAULT_COLORS = [
- '#0057d9',
- '#0a8f8f',
- '#8a5cf6',
- '#d92d20',
- '#2d6a4f',
- '#b26a00',
-];
-
-function escapeXml(value: string): string {
- return value
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
-function formatValue(value: number): string {
- if (!Number.isFinite(value)) return '0';
- const rounded = Math.abs(value) >= 1000
- ? Math.round(value)
- : Number(value.toFixed(1));
- return String(rounded);
-}
function normalizeSeries(
series: LineChartSeries[],
@@ -207,7 +187,8 @@ function buildSvgMarkup(
});
const color = escapeXml(
- item.color ?? DEFAULT_COLORS[seriesIndex % DEFAULT_COLORS.length]
+ item.color
+ ?? DEFAULT_CHART_COLORS[seriesIndex % DEFAULT_CHART_COLORS.length]
?? '#0057d9',
);
const d = buildPath(points, variant);
@@ -268,7 +249,7 @@ export function LineChart(
: [];
const seriesValue = resolvedProps['series'];
const series = Array.isArray(seriesValue)
- ? normalizeSeries(seriesValue as LineChartSeries[], DEFAULT_COLORS)
+ ? normalizeSeries(seriesValue as LineChartSeries[], DEFAULT_CHART_COLORS)
: [];
const variant = (resolvedProps['variant'] as ChartVariant | undefined)
?? 'natural';
@@ -344,7 +325,9 @@ export function LineChart(
className='line-chart-legend-swatch'
style={{
backgroundColor: item.color
- ?? DEFAULT_COLORS[index % DEFAULT_COLORS.length]
+ ?? DEFAULT_CHART_COLORS[
+ index % DEFAULT_CHART_COLORS.length
+ ]
?? '#0057d9',
}}
/>
diff --git a/packages/genui/a2ui/src/catalog/PieChart/index.tsx b/packages/genui/a2ui/src/catalog/PieChart/index.tsx
new file mode 100644
index 0000000000..0a6943e626
--- /dev/null
+++ b/packages/genui/a2ui/src/catalog/PieChart/index.tsx
@@ -0,0 +1,301 @@
+// Copyright 2026 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { useResolvedProps } from '../../react/useDataBinding.js';
+import type { GenericComponentProps } from '../../store/types.js';
+import {
+ DEFAULT_CHART_COLORS,
+ escapeXml,
+ formatValue,
+} from '../utils/chart.js';
+
+import '../../../styles/catalog/PieChart.css';
+
+export interface PieChartSlice {
+ name: string;
+ value: number;
+ color?: string;
+}
+
+type PieChartVariant = 'pie' | 'donut';
+
+const SVG_WIDTH = 360;
+const SVG_HEIGHT = 220;
+const CENTER_X = SVG_WIDTH / 2;
+const CENTER_Y = SVG_HEIGHT / 2;
+const DEFAULT_OUTER_RADIUS = 84;
+const DEFAULT_DONUT_INNER_RADIUS = 52;
+
+/**
+ * @a2uiCatalog PieChart
+ */
+export interface PieChartProps extends GenericComponentProps {
+ /** Pie slices to render. */
+ data:
+ | Array<{
+ name: string;
+ value: number;
+ color?: string;
+ }>
+ | { path: string };
+ /** Render the chart as a flat pie or a donut. */
+ variant?: 'pie' | 'donut';
+ /** Optional title shown above the chart. */
+ title?: string;
+ /** Optional subtitle shown under the title. */
+ subtitle?: string;
+ /** Show the legend below the chart. */
+ showLegend?: boolean;
+ /** Show percentage values in the legend. */
+ showPercentages?: boolean;
+ /** Chart height in pixels. */
+ height?: number;
+ /** Padding angle between slices, in degrees. */
+ paddingAngle?: number;
+ /** Custom color palette for the slices. */
+ colors?: string[];
+}
+
+function formatPercent(value: number): string {
+ if (!Number.isFinite(value)) return '0%';
+ return `${Number(value.toFixed(1))}%`;
+}
+
+function polarToCartesian(
+ centerX: number,
+ centerY: number,
+ radius: number,
+ angleInDegrees: number,
+): { x: number; y: number } {
+ const angleInRadians = (angleInDegrees - 90) * (Math.PI / 180);
+ return {
+ x: centerX + radius * Math.cos(angleInRadians),
+ y: centerY + radius * Math.sin(angleInRadians),
+ };
+}
+
+function describeSlicePath(
+ centerX: number,
+ centerY: number,
+ outerRadius: number,
+ innerRadius: number,
+ startAngle: number,
+ endAngle: number,
+): string {
+ const outerStart = polarToCartesian(
+ centerX,
+ centerY,
+ outerRadius,
+ startAngle,
+ );
+ const outerEnd = polarToCartesian(centerX, centerY, outerRadius, endAngle);
+ const largeArcFlag = endAngle - startAngle > 180 ? '1' : '0';
+
+ if (innerRadius <= 0) {
+ return [
+ `M ${centerX.toFixed(1)} ${centerY.toFixed(1)}`,
+ `L ${outerStart.x.toFixed(1)} ${outerStart.y.toFixed(1)}`,
+ `A ${outerRadius.toFixed(1)} ${
+ outerRadius.toFixed(1)
+ } 0 ${largeArcFlag} 1 ${outerEnd.x.toFixed(1)} ${outerEnd.y.toFixed(1)}`,
+ 'Z',
+ ].join(' ');
+ }
+
+ const innerEnd = polarToCartesian(centerX, centerY, innerRadius, endAngle);
+ const innerStart = polarToCartesian(
+ centerX,
+ centerY,
+ innerRadius,
+ startAngle,
+ );
+
+ return [
+ `M ${outerStart.x.toFixed(1)} ${outerStart.y.toFixed(1)}`,
+ `A ${outerRadius.toFixed(1)} ${
+ outerRadius.toFixed(1)
+ } 0 ${largeArcFlag} 1 ${outerEnd.x.toFixed(1)} ${outerEnd.y.toFixed(1)}`,
+ `L ${innerEnd.x.toFixed(1)} ${innerEnd.y.toFixed(1)}`,
+ `A ${innerRadius.toFixed(1)} ${
+ innerRadius.toFixed(1)
+ } 0 ${largeArcFlag} 0 ${innerStart.x.toFixed(1)} ${
+ innerStart.y.toFixed(1)
+ }`,
+ 'Z',
+ ].join(' ');
+}
+
+function buildSvgMarkup(
+ slices: PieChartSlice[],
+ variant: PieChartVariant,
+ paddingAngle: number,
+ palette: readonly string[],
+): string {
+ const total = slices.reduce(
+ (sum, slice) => sum + Math.max(0, Number(slice.value) || 0),
+ 0,
+ );
+
+ if (total <= 0) {
+ return ``;
+ }
+
+ const outerRadius = DEFAULT_OUTER_RADIUS;
+ const innerRadius = variant === 'donut' ? DEFAULT_DONUT_INNER_RADIUS : 0;
+ const gap = Math.max(0, paddingAngle);
+
+ let currentAngle = -90;
+ const segments = slices
+ .map((slice, index) => {
+ const value = Math.max(0, Number(slice.value) || 0);
+ if (value <= 0) {
+ return null;
+ }
+
+ const sliceAngle = (value / total) * 360;
+ const gapForSlice = Math.min(gap, sliceAngle * 0.45);
+ const startAngle = currentAngle + gapForSlice / 2;
+ const endAngle = currentAngle + sliceAngle - gapForSlice / 2;
+ currentAngle += sliceAngle;
+
+ if (endAngle <= startAngle) {
+ return null;
+ }
+
+ const color = escapeXml(
+ slice.color ?? palette[index % palette.length]
+ ?? DEFAULT_CHART_COLORS[0],
+ );
+ const path = describeSlicePath(
+ CENTER_X,
+ CENTER_Y,
+ outerRadius,
+ innerRadius,
+ startAngle,
+ endAngle,
+ );
+ return ``;
+ })
+ .filter((segment): segment is string => segment !== null)
+ .join('');
+
+ const backdrop = ``;
+ const hole = innerRadius > 0
+ ? ``
+ : '';
+
+ return `
+
+ `;
+}
+
+export function PieChart(
+ props: PieChartProps,
+): import('@lynx-js/react').ReactNode {
+ const [resolvedProps] = useResolvedProps(
+ props,
+ props.surface,
+ props.dataContextPath,
+ );
+ const id = props.id;
+ const dataValue = resolvedProps['data'];
+ const slices = Array.isArray(dataValue)
+ ? (dataValue as PieChartSlice[])
+ : [];
+ const variant = (resolvedProps['variant'] as PieChartVariant | undefined)
+ ?? 'pie';
+ const title = (resolvedProps['title'] as string | undefined) ?? 'Pie chart';
+ const subtitle = (resolvedProps['subtitle'] as string | undefined)
+ ?? (slices.length > 0
+ ? `${slices.length} slices • ${
+ formatValue(
+ slices.reduce(
+ (sum, slice) => sum + Math.max(0, Number(slice.value) || 0),
+ 0,
+ ),
+ )
+ } total`
+ : 'No data');
+ const showLegend = resolvedProps['showLegend'] !== false;
+ const showPercentages = resolvedProps['showPercentages'] !== false;
+ const heightValue = resolvedProps['height'];
+ const height = typeof heightValue === 'number' ? heightValue : 220;
+ const paddingAngleValue = resolvedProps['paddingAngle'];
+ const paddingAngle = typeof paddingAngleValue === 'number'
+ ? paddingAngleValue
+ : 2;
+ const palette = Array.isArray(resolvedProps['colors'])
+ ? (resolvedProps['colors'] as string[])
+ : DEFAULT_CHART_COLORS;
+ const total = slices.reduce(
+ (sum, slice) => sum + Math.max(0, Number(slice.value) || 0),
+ 0,
+ );
+ const svgMarkup = buildSvgMarkup(slices, variant, paddingAngle, palette);
+
+ return (
+
+
+
+ {title}
+ {subtitle}
+
+
+ Total
+ {formatValue(total)}
+
+
+
+
+
+
+
+ {showLegend
+ ? (
+
+ {slices.map((slice, index) => {
+ const value = Math.max(0, Number(slice.value) || 0);
+ const percent = total > 0 ? (value / total) * 100 : 0;
+ const color = slice.color
+ ?? palette[index % palette.length]
+ ?? DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length]
+ ?? DEFAULT_CHART_COLORS[0];
+
+ return (
+
+
+
+ {slice.name}
+
+ {showPercentages
+ ? `${formatValue(value)} (${formatPercent(percent)})`
+ : formatValue(value)}
+
+
+
+ );
+ })}
+
+ )
+ : null}
+
+ );
+}
diff --git a/packages/genui/a2ui/src/catalog/README.md b/packages/genui/a2ui/src/catalog/README.md
index 70cb54811f..fc375bf5c9 100644
--- a/packages/genui/a2ui/src/catalog/README.md
+++ b/packages/genui/a2ui/src/catalog/README.md
@@ -75,6 +75,7 @@ import {
Icon,
Image,
LineChart,
+ PieChart,
List,
Modal,
RadioGroup,
@@ -108,6 +109,9 @@ import imageManifest from '@lynx-js/a2ui-reactlynx/catalog/Image/catalog.json' w
import lineChartManifest from '@lynx-js/a2ui-reactlynx/catalog/LineChart/catalog.json' with {
type: 'json',
};
+import pieChartManifest from '@lynx-js/a2ui-reactlynx/catalog/PieChart/catalog.json' with {
+ type: 'json',
+};
import listManifest from '@lynx-js/a2ui-reactlynx/catalog/List/catalog.json' with {
type: 'json',
};
@@ -144,6 +148,7 @@ export const allBuiltins = defineCatalog([
[Button, buttonManifest],
[Divider, dividerManifest],
[LineChart, lineChartManifest],
+ [PieChart, pieChartManifest],
[TextField, textFieldManifest],
[CheckBox, checkBoxManifest],
[Icon, iconManifest],
diff --git a/packages/genui/a2ui/src/catalog/index.ts b/packages/genui/a2ui/src/catalog/index.ts
index 1fd98aa229..6d6f6960e3 100755
--- a/packages/genui/a2ui/src/catalog/index.ts
+++ b/packages/genui/a2ui/src/catalog/index.ts
@@ -28,6 +28,7 @@ export { Button } from './Button/index.jsx';
export { Card } from './Card/index.jsx';
export { CheckBox } from './CheckBox/index.jsx';
export { LineChart } from './LineChart/index.jsx';
+export { PieChart } from './PieChart/index.jsx';
export { Column } from './Column/index.jsx';
export { Divider } from './Divider/index.jsx';
export { Icon } from './Icon/index.jsx';
@@ -40,3 +41,4 @@ export { Slider } from './Slider/index.jsx';
export { Tabs } from './Tabs/index.jsx';
export { Text } from './Text/index.jsx';
export { TextField } from './TextField/index.jsx';
+export { DEFAULT_CHART_COLORS, escapeXml, formatValue } from './utils/chart.js';
diff --git a/packages/genui/a2ui/src/catalog/utils/chart.ts b/packages/genui/a2ui/src/catalog/utils/chart.ts
new file mode 100644
index 0000000000..dc5e0b6984
--- /dev/null
+++ b/packages/genui/a2ui/src/catalog/utils/chart.ts
@@ -0,0 +1,29 @@
+// Copyright 2026 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+export const DEFAULT_CHART_COLORS = [
+ '#0057d9',
+ '#0a8f8f',
+ '#8a5cf6',
+ '#d92d20',
+ '#2d6a4f',
+ '#b26a00',
+] as const;
+
+export function escapeXml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+export function formatValue(value: number): string {
+ if (!Number.isFinite(value)) return '0';
+ const rounded = Math.abs(value) >= 1000
+ ? Math.round(value)
+ : Number(value.toFixed(1));
+ return String(rounded);
+}
diff --git a/packages/genui/a2ui/src/index.ts b/packages/genui/a2ui/src/index.ts
index c92f51ca83..49eae5de80 100644
--- a/packages/genui/a2ui/src/index.ts
+++ b/packages/genui/a2ui/src/index.ts
@@ -93,6 +93,7 @@ export {
Divider,
Image,
LineChart,
+ PieChart,
List,
Modal,
RadioGroup,
diff --git a/packages/genui/a2ui/styles/catalog/PieChart.css b/packages/genui/a2ui/styles/catalog/PieChart.css
new file mode 100644
index 0000000000..11b8975fec
--- /dev/null
+++ b/packages/genui/a2ui/styles/catalog/PieChart.css
@@ -0,0 +1,109 @@
+@import "../theme.css";
+
+.pie-chart {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ gap: 12px;
+ box-sizing: border-box;
+ padding: 16px;
+ border: 1px solid var(--a2ui-color-border);
+ border-radius: 20px;
+ background:
+ radial-gradient(circle at top, rgba(255, 255, 255, 0.04), transparent 40%),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%),
+ var(--a2ui-color-surface);
+}
+
+.pie-chart-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.pie-chart-header-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+.pie-chart-title {
+ color: var(--a2ui-color-on-surface);
+ font-size: 16px;
+ font-weight: 700;
+ line-height: 1.25;
+}
+
+.pie-chart-caption,
+.pie-chart-kpi-label,
+.pie-chart-legend-label,
+.pie-chart-legend-value {
+ color: var(--a2ui-color-text-muted);
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.pie-chart-kpi {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.pie-chart-kpi-value {
+ color: var(--a2ui-color-on-surface);
+ font-size: 18px;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.pie-chart-svg-wrap {
+ display: flex;
+ justify-content: center;
+}
+
+.pie-chart-svg {
+ display: block;
+ width: 100%;
+ min-height: 0;
+ border-radius: 16px;
+ overflow: hidden;
+}
+
+.pie-chart-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px 14px;
+ align-items: flex-start;
+}
+
+.pie-chart-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.pie-chart-legend-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ flex-shrink: 0;
+}
+
+.pie-chart-legend-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.pie-chart-legend-label,
+.pie-chart-legend-value {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}