diff --git a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx index f8bcf6186c..73f69c1053 100644 --- a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx @@ -10,6 +10,7 @@ import { Divider, Icon, Image, + LineChart, List, Modal, RadioGroup, @@ -37,6 +38,7 @@ import columnManifest from '@lynx-js/a2ui-reactlynx/catalog/Column/catalog.json' import dividerManifest from '@lynx-js/a2ui-reactlynx/catalog/Divider/catalog.json'; import iconManifest from '@lynx-js/a2ui-reactlynx/catalog/Icon/catalog.json'; 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 radioGroupManifest from '@lynx-js/a2ui-reactlynx/catalog/RadioGroup/catalog.json'; @@ -90,6 +92,7 @@ const ALL_BUILTINS: readonly CatalogInput[] = [ manifestEntry(Divider, dividerManifest), manifestEntry(Icon, iconManifest), manifestEntry(CheckBox, checkBoxManifest), + manifestEntry(LineChart, lineChartManifest), 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 8734400cea..52b816463b 100644 --- a/packages/genui/a2ui-playground/src/catalog/a2ui.ts +++ b/packages/genui/a2ui-playground/src/catalog/a2ui.ts @@ -8,6 +8,7 @@ import columnManifest from '@lynx-js/a2ui-reactlynx/catalog/Column/catalog.json' import dividerManifest from '@lynx-js/a2ui-reactlynx/catalog/Divider/catalog.json'; import iconManifest from '@lynx-js/a2ui-reactlynx/catalog/Icon/catalog.json'; 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 radioGroupManifest from '@lynx-js/a2ui-reactlynx/catalog/RadioGroup/catalog.json'; @@ -31,7 +32,12 @@ export interface ComponentUsageExample { value: object | object[]; } -export type ComponentCategory = 'Display' | 'Layout' | 'Input' | 'Data'; +export type ComponentCategory = + | 'Display' + | 'Layout' + | 'Input' + | 'Data' + | 'Chart'; export interface ComponentDoc { name: string; @@ -47,6 +53,7 @@ export const CATEGORIES: { id: ComponentCategory; label: string }[] = [ { id: 'Layout', label: 'Layout' }, { id: 'Input', label: 'Input' }, { id: 'Data', label: 'Data' }, + { id: 'Chart', label: 'Chart' }, ]; type PropSchema = Record; @@ -536,6 +543,82 @@ export const COMPONENT_CATALOG: ComponentDoc[] = [ openui: [], }, }, + { + name: 'LineChart', + category: 'Chart', + description: + 'Plots one or more numeric series over category labels with native SVG rendering.', + props: schemaToProps(lineChartManifest), + usage: { + a2ui: { + id: 'sales-chart', + component: 'LineChart', + labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + series: [ + { + name: 'Revenue', + values: [120, 180, 160, 220, 260, 240], + color: '#0057d9', + }, + { + name: 'Orders', + values: [80, 92, 104, 118, 126, 140], + color: '#0a8f8f', + }, + ], + variant: 'natural', + xLabel: 'Month', + yLabel: 'Performance', + }, + openui: {}, + }, + usageExamples: { + a2ui: [ + { + label: 'Multi-series', + value: { + id: 'multi-series-chart', + component: 'LineChart', + labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + series: [ + { + name: 'Revenue', + values: [120, 180, 160, 220, 260, 240], + color: '#0057d9', + }, + { + name: 'Orders', + values: [80, 92, 104, 118, 126, 140], + color: '#0a8f8f', + }, + ], + variant: 'natural', + xLabel: 'Month', + yLabel: 'Performance', + }, + }, + { + label: 'Stepped', + value: { + id: 'step-chart', + component: 'LineChart', + labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], + series: [ + { + name: 'Traffic', + values: [32, 42, 39, 51, 58], + color: '#8a5cf6', + }, + ], + variant: 'step', + xLabel: 'Day', + yLabel: 'Traffic', + }, + }, + ], + openui: [], + }, + }, { name: 'Card', category: 'Layout', diff --git a/packages/genui/a2ui-playground/src/demos.ts b/packages/genui/a2ui-playground/src/demos.ts index e85908b3c5..d5c2af546b 100644 --- a/packages/genui/a2ui-playground/src/demos.ts +++ b/packages/genui/a2ui-playground/src/demos.ts @@ -5,6 +5,7 @@ import { A2UI_GALLERY_DEMOS } from './mock/a2ui-gallery/index.js'; 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 recs from './mock/basic/recs.json'; import tripPlanner from './mock/basic/trip-planner.json'; import workoutPlan from './mock/basic/workout-plan.json'; @@ -123,6 +124,14 @@ export const EXTENDED_STATIC_DEMOS: StaticDemo[] = [ tags: tagsFromMessages(citywalkList), messages: citywalkList, }, + { + id: 'hangzhou-weather-trend', + title: 'Hangzhou Weather Trend', + description: + 'Use LineChart to show a week-long temperature trend in Hangzhou.', + tags: tagsFromMessages(hangzhouWeatherTrend), + messages: hangzhouWeatherTrend, + }, { id: 'fridge-search', title: 'Fridge Search Results', diff --git a/packages/genui/a2ui-playground/src/mock/basic/hangzhou-weather-trend.json b/packages/genui/a2ui-playground/src/mock/basic/hangzhou-weather-trend.json new file mode 100644 index 0000000000..651749f9d5 --- /dev/null +++ b/packages/genui/a2ui-playground/src/mock/basic/hangzhou-weather-trend.json @@ -0,0 +1,54 @@ +[ + { + "createSurface": { + "surfaceId": "default", + "catalogId": "demo-hangzhou-weather-trend" + } + }, + { + "updateComponents": { + "surfaceId": "default", + "components": [ + { + "id": "root", + "component": "Column", + "align": "start", + "justify": "start", + "children": ["title", "subtitle", "chart"] + }, + { + "id": "title", + "component": "Text", + "variant": "h2", + "text": "Hangzhou Weather Trend for the Next 7 Days" + }, + { + "id": "subtitle", + "component": "Text", + "variant": "body", + "text": "Temperatures trend upward through the week, with a slight dip on the weekend." + }, + { + "id": "chart", + "component": "LineChart", + "labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + "series": [ + { + "name": "High (°C)", + "values": [12, 14, 16, 18, 20, 22, 19], + "color": "#0057d9" + }, + { + "name": "Low (°C)", + "values": [6, 7, 8, 10, 11, 13, 12], + "color": "#0a8f8f" + } + ], + "variant": "natural", + "xLabel": "Day", + "yLabel": "Temperature" + } + ] + } + } +] diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index 5d3e6c547c..1fe51807be 100644 --- a/packages/genui/a2ui/package.json +++ b/packages/genui/a2ui/package.json @@ -56,6 +56,11 @@ "default": "./dist/catalog/Card/index.js" }, "./catalog/Card/catalog.json": "./dist/catalog/Card/catalog.json", + "./catalog/LineChart": { + "types": "./dist/catalog/LineChart/index.d.ts", + "default": "./dist/catalog/LineChart/index.js" + }, + "./catalog/LineChart/catalog.json": "./dist/catalog/LineChart/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 new file mode 100644 index 0000000000..af0c909eb0 --- /dev/null +++ b/packages/genui/a2ui/src/catalog/LineChart/index.tsx @@ -0,0 +1,359 @@ +// 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 '../../../styles/catalog/LineChart.css'; + +type ChartVariant = 'linear' | 'natural' | 'step'; + +export interface LineChartSeries { + name: string; + values: number[]; + color?: string; +} + +/** + * @a2uiCatalog LineChart + */ +export interface LineChartProps extends GenericComponentProps { + /** Category labels shown along the x axis. */ + labels: string[] | { path: string }; + /** One or more line series to render over the shared labels. */ + series: + | Array<{ + name: string; + values: number[]; + color?: string; + }> + | { path: string }; + variant?: 'linear' | 'natural' | 'step'; + xLabel?: string; + yLabel?: string; + showGrid?: boolean; + showLegend?: boolean; + height?: number; +} + +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[], + palette: readonly string[], +): LineChartSeries[] { + return series.map((item, index) => ({ + name: item.name, + values: item.values, + color: item.color ?? palette[index % palette.length] ?? '#0057d9', + })); +} + +function buildLinearPath(points: Array<{ x: number; y: number }>): string { + if (points.length === 0) return ''; + const first = points[0]!; + const rest = points.slice(1); + let path = `M ${first.x.toFixed(1)} ${first.y.toFixed(1)}`; + for (const point of rest) { + path += ` L ${point.x.toFixed(1)} ${point.y.toFixed(1)}`; + } + return path; +} + +function buildStepPath(points: Array<{ x: number; y: number }>): string { + if (points.length === 0) return ''; + const first = points[0]!; + const rest = points.slice(1); + let path = `M ${first.x.toFixed(1)} ${first.y.toFixed(1)}`; + let prev = first; + for (const point of rest) { + path += ` L ${point.x.toFixed(1)} ${prev.y.toFixed(1)}`; + path += ` L ${point.x.toFixed(1)} ${point.y.toFixed(1)}`; + prev = point; + } + return path; +} + +function buildNaturalPath(points: Array<{ x: number; y: number }>): string { + if (points.length === 0) return ''; + if (points.length === 1) { + const point = points[0]!; + return `M ${point.x.toFixed(1)} ${point.y.toFixed(1)}`; + } + + let path = `M ${points[0]!.x.toFixed(1)} ${points[0]!.y.toFixed(1)}`; + for (let index = 0; index < points.length - 1; index += 1) { + const p0 = points[index - 1] ?? points[index]!; + const p1 = points[index]!; + const p2 = points[index + 1]!; + const p3 = points[index + 2] ?? p2; + const cp1x = p1.x + (p2.x - p0.x) / 6; + const cp1y = p1.y + (p2.y - p0.y) / 6; + const cp2x = p2.x - (p3.x - p1.x) / 6; + const cp2y = p2.y - (p3.y - p1.y) / 6; + path += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${ + cp2x.toFixed( + 1, + ) + } ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`; + } + return path; +} + +function buildPath( + points: Array<{ x: number; y: number }>, + variant: ChartVariant, +): string { + switch (variant) { + case 'step': + return buildStepPath(points); + case 'natural': + return buildNaturalPath(points); + default: + return buildLinearPath(points); + } +} + +function sampleIndices(count: number, maxLabels: number): number[] { + if (count <= 0) return []; + if (count <= maxLabels) { + return Array.from({ length: count }, (_, index) => index); + } + const step = Math.ceil(count / maxLabels); + const indices: number[] = []; + for (let index = 0; index < count; index += step) { + indices.push(index); + } + const last = count - 1; + if (indices[indices.length - 1] !== last) { + indices.push(last); + } + return Array.from(new Set(indices)); +} + +function minMax(values: number[]): { min: number; max: number } { + if (values.length === 0) { + return { min: 0, max: 1 }; + } + let min = values[0]!; + let max = values[0]!; + for (const value of values) { + if (value < min) min = value; + if (value > max) max = value; + } + if (min === max) { + min -= 1; + max += 1; + } + return { min, max }; +} + +function buildSvgMarkup( + labels: string[], + series: LineChartSeries[], + variant: ChartVariant, + showGrid: boolean, +): string { + const width = SVG_WIDTH; + const height = DEFAULT_HEIGHT; + const chartWidth = width - MARGIN.left - MARGIN.right; + const chartHeight = height - MARGIN.top - MARGIN.bottom; + const maxPoints = labels.length; + const values = series.flatMap((item) => item.values.slice(0, maxPoints)); + + if (maxPoints === 0 || series.length === 0 || values.length === 0) { + return ``; + } + + const { min: minValue, max: maxValue } = minMax(values); + const yRange = maxValue - minValue; + const xStep = maxPoints > 1 ? chartWidth / (maxPoints - 1) : 0; + const gridLines = 4; + + const lineMarkup = series + .map((item, seriesIndex) => { + const points = item.values.slice(0, maxPoints).map((value, index) => { + const x = MARGIN.left + xStep * index; + const y = MARGIN.top + chartHeight + - ((value - minValue) / yRange) * chartHeight; + return { x, y }; + }); + + const color = escapeXml( + item.color ?? DEFAULT_COLORS[seriesIndex % DEFAULT_COLORS.length] + ?? '#0057d9', + ); + const d = buildPath(points, variant); + const circles = points + .map((point) => + `` + ) + .join(''); + + return ` + + ${circles} + `; + }) + .join(''); + + const gridMarkup = showGrid + ? Array.from({ length: gridLines + 1 }, (_, index) => { + const ratio = index / gridLines; + const y = MARGIN.top + chartHeight - ratio * chartHeight; + const dashed = index === gridLines ? '' : ' stroke-dasharray="4 6"'; + return ``; + }).join('') + : ''; + + return ` + + + ${gridMarkup} + + ${lineMarkup} + + `; +} + +export function LineChart( + props: LineChartProps, +): import('@lynx-js/react').ReactNode { + const [resolvedProps] = useResolvedProps( + props, + props.surface, + props.dataContextPath, + ); + const id = props.id; + const labelsValue = resolvedProps['labels']; + const labels = Array.isArray(labelsValue) + ? (labelsValue as string[]) + : []; + const seriesValue = resolvedProps['series']; + const series = Array.isArray(seriesValue) + ? normalizeSeries(seriesValue as LineChartSeries[], DEFAULT_COLORS) + : []; + const variant = (resolvedProps['variant'] as ChartVariant | undefined) + ?? 'natural'; + const showGrid = resolvedProps['showGrid'] !== false; + const showLegend = resolvedProps['showLegend'] !== false; + const heightValue = resolvedProps['height']; + const height = typeof heightValue === 'number' + ? heightValue + : DEFAULT_HEIGHT; + const svgMarkup = buildSvgMarkup(labels, series, variant, showGrid); + const visibleLabelIndices = sampleIndices(labels.length, 8); + const { min, max } = minMax( + series.flatMap((item) => item.values.slice(0, labels.length)), + ); + + return ( + + + + + {(resolvedProps['yLabel'] as string | undefined) ?? 'Line chart'} + + + {series.length > 0 + ? `${series.length} series • ${labels.length} points` + : 'No data'} + + + {(resolvedProps['xLabel'] as string | undefined) + ? ( + + {resolvedProps['xLabel'] as string} + + ) + : null} + + + + {formatValue(min)} + + {formatValue(max)} + + + + + + + {visibleLabelIndices.map((index) => ( + + + {labels[index] ?? ''} + + + ))} + + + {showLegend + ? ( + + {series.map((item, index) => ( + + + {item.name} + + ))} + + ) + : null} + + ); +} diff --git a/packages/genui/a2ui/src/catalog/README.md b/packages/genui/a2ui/src/catalog/README.md index f5a802feef..70cb54811f 100644 --- a/packages/genui/a2ui/src/catalog/README.md +++ b/packages/genui/a2ui/src/catalog/README.md @@ -74,6 +74,7 @@ import { Divider, Icon, Image, + LineChart, List, Modal, RadioGroup, @@ -104,6 +105,9 @@ import iconManifest from '@lynx-js/a2ui-reactlynx/catalog/Icon/catalog.json' wit import imageManifest from '@lynx-js/a2ui-reactlynx/catalog/Image/catalog.json' with { type: 'json', }; +import lineChartManifest from '@lynx-js/a2ui-reactlynx/catalog/LineChart/catalog.json' with { + type: 'json', +}; import listManifest from '@lynx-js/a2ui-reactlynx/catalog/List/catalog.json' with { type: 'json', }; @@ -139,6 +143,7 @@ export const allBuiltins = defineCatalog([ [Modal, modalManifest], [Button, buttonManifest], [Divider, dividerManifest], + [LineChart, lineChartManifest], [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 ffc8cbfac4..1fd98aa229 100755 --- a/packages/genui/a2ui/src/catalog/index.ts +++ b/packages/genui/a2ui/src/catalog/index.ts @@ -27,6 +27,7 @@ export type { 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 { Column } from './Column/index.jsx'; export { Divider } from './Divider/index.jsx'; export { Icon } from './Icon/index.jsx'; diff --git a/packages/genui/a2ui/src/index.ts b/packages/genui/a2ui/src/index.ts index 0be4c2effb..c92f51ca83 100644 --- a/packages/genui/a2ui/src/index.ts +++ b/packages/genui/a2ui/src/index.ts @@ -92,6 +92,7 @@ export { Column, Divider, Image, + LineChart, List, Modal, RadioGroup, diff --git a/packages/genui/a2ui/styles/catalog/LineChart.css b/packages/genui/a2ui/styles/catalog/LineChart.css new file mode 100644 index 0000000000..ea235ac0fe --- /dev/null +++ b/packages/genui/a2ui/styles/catalog/LineChart.css @@ -0,0 +1,116 @@ +@import "../theme.css"; + +.line-chart { + display: flex; + flex-direction: column; + width: 100%; + gap: 10px; + box-sizing: border-box; + padding: 16px; + border: 1px solid var(--a2ui-color-border); + border-radius: 20px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 28%), + var(--a2ui-color-surface); +} + +.line-chart-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.line-chart-header-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.line-chart-title { + color: var(--a2ui-color-on-surface); + font-size: 16px; + font-weight: 700; + line-height: 1.25; +} + +.line-chart-caption, +.line-chart-axis-label, +.line-chart-scale-value, +.line-chart-legend-label, +.line-chart-axis-tick-label { + color: var(--a2ui-color-text-muted); + font-size: 11px; + line-height: 1.4; +} + +.line-chart-axis-label { + text-align: right; + white-space: nowrap; +} + +.line-chart-scale-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.line-chart-scale-value-right { + text-align: right; +} + +.line-chart-svg { + display: block; + width: 100%; + min-height: 220px; + border-radius: 14px; + overflow: hidden; +} + +.line-chart-axis-row { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.line-chart-axis-tick { + flex: 1; + min-width: 0; + text-align: center; +} + +.line-chart-axis-tick-label { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.line-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + align-items: center; +} + +.line-chart-legend-item { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.line-chart-legend-swatch { + width: 10px; + height: 10px; + border-radius: 999px; + flex-shrink: 0; +} + +.line-chart-legend-label { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/packages/genui/a2ui/test/catalog.test.ts b/packages/genui/a2ui/test/catalog.test.ts index 300a4e837d..9850c58d48 100644 --- a/packages/genui/a2ui/test/catalog.test.ts +++ b/packages/genui/a2ui/test/catalog.test.ts @@ -25,6 +25,7 @@ function namedStub(name: string): CatalogComponent { const Text = namedStub('Text'); const Button = namedStub('Button'); const Icon = namedStub('Icon'); +const LineChart = namedStub('LineChart'); const Tabs = namedStub('Tabs'); const TEXT_MANIFEST: CatalogManifest = { @@ -36,6 +37,24 @@ const BUTTON_MANIFEST: CatalogManifest = { const ICON_MANIFEST: CatalogManifest = { Icon: { type: 'object', properties: { name: { type: 'string' } } }, }; +const LINE_CHART_MANIFEST: CatalogManifest = { + LineChart: { + type: 'object', + properties: { + labels: { type: 'array', items: { type: 'string' } }, + series: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + values: { type: 'array', items: { type: 'number' } }, + }, + }, + }, + }, + }, +}; const TABS_MANIFEST: CatalogManifest = { Tabs: { type: 'object', @@ -152,6 +171,7 @@ describe('user composes their own all-builtins catalog', () => { [Text, TEXT_MANIFEST], [Button, BUTTON_MANIFEST], [Icon, ICON_MANIFEST], + [LineChart, LINE_CHART_MANIFEST], [Tabs, TABS_MANIFEST], ]); const manifest = serializeCatalog(all); @@ -159,6 +179,7 @@ describe('user composes their own all-builtins catalog', () => { 'Text', 'Button', 'Icon', + 'LineChart', 'Tabs', ]); expect(manifest.components.every((c) => c.schema)).toBe(true);