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 ` + + ${backdrop} + ${segments} + ${hole} + + `; +} + +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; +}