diff --git a/apps/docs/docgen.config.js b/apps/docs/docgen.config.js index 106e09bf8..6139a942f 100644 --- a/apps/docs/docgen.config.js +++ b/apps/docs/docgen.config.js @@ -74,7 +74,10 @@ module.exports = { 'chart/area/AreaChart', 'chart/bar/BarChart', 'chart/CartesianChart', + 'chart/DonutChart', 'chart/line/LineChart', + 'chart/pie/PieChart', + 'chart/PolarChart', 'chart/line/ReferenceLine', 'chart/axis/XAxis', 'chart/axis/YAxis', diff --git a/apps/docs/docs/components/graphs/AreaChart/mobileMetadata.json b/apps/docs/docs/components/graphs/AreaChart/mobileMetadata.json index 9c76f0451..97f11aaeb 100644 --- a/apps/docs/docs/components/graphs/AreaChart/mobileMetadata.json +++ b/apps/docs/docs/components/graphs/AreaChart/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { AreaChart } from '@coinbase/cds-mobile-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/area/AreaChart.tsx", - "description": "A chart component that displays data as filled areas beneath lines. Ideal for showing cumulative values, stacked data, or emphasizing volume over time.", + "description": "A chart component built on CartesianChart that displays data as filled areas beneath lines. Ideal for showing cumulative values, stacked data, or emphasizing volume over time.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/AreaChart/webMetadata.json b/apps/docs/docs/components/graphs/AreaChart/webMetadata.json index af041ad54..5bd997ed9 100644 --- a/apps/docs/docs/components/graphs/AreaChart/webMetadata.json +++ b/apps/docs/docs/components/graphs/AreaChart/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { AreaChart } from '@coinbase/cds-web-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/area/AreaChart.tsx", - "description": "A chart component that displays data as filled areas beneath lines. Ideal for showing cumulative values, stacked data, or emphasizing volume over time.", + "description": "A chart component built on CartesianChart that displays data as filled areas beneath lines. Ideal for showing cumulative values, stacked data, or emphasizing volume over time.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/BarChart/mobileMetadata.json b/apps/docs/docs/components/graphs/BarChart/mobileMetadata.json index d2037bf2b..0de6c169e 100644 --- a/apps/docs/docs/components/graphs/BarChart/mobileMetadata.json +++ b/apps/docs/docs/components/graphs/BarChart/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { BarChart } from '@coinbase/cds-mobile-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/bar/BarChart.tsx", - "description": "A bar chart component for comparing values across categories. Supports horizontal and vertical orientations, stacked bars, and grouped series.", + "description": "A bar chart component built on CartesianChart for comparing values across categories. Supports horizontal and vertical orientations, stacked bars, and grouped series.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/BarChart/webMetadata.json b/apps/docs/docs/components/graphs/BarChart/webMetadata.json index cecea286a..25de41a1a 100644 --- a/apps/docs/docs/components/graphs/BarChart/webMetadata.json +++ b/apps/docs/docs/components/graphs/BarChart/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { BarChart } from '@coinbase/cds-web-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/bar/BarChart.tsx", - "description": "A bar chart component for comparing values across categories. Supports horizontal and vertical orientations, stacked bars, and grouped series.", + "description": "A bar chart component built on CartesianChart for comparing values across categories. Supports horizontal and vertical orientations, stacked bars, and grouped series.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/CartesianChart/mobileMetadata.json b/apps/docs/docs/components/graphs/CartesianChart/mobileMetadata.json index 109b74d3b..d7446afdb 100644 --- a/apps/docs/docs/components/graphs/CartesianChart/mobileMetadata.json +++ b/apps/docs/docs/components/graphs/CartesianChart/mobileMetadata.json @@ -1,8 +1,24 @@ { "import": "import { CartesianChart } from '@coinbase/cds-mobile-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/CartesianChart.tsx", - "description": "A flexible, low-level chart component for displaying data in an x/y coordinate space. Provides a foundation for building custom chart visualizations with full control over rendering.", + "description": "A flexible, low-level chart component for displaying data in an x/y coordinate space. Provides a foundation for building custom chart visualizations with full control over rendering. ", "relatedComponents": [ + { + "label": "AreaChart", + "url": "/components/graphs/AreaChart/" + }, + { + "label": "BarChart", + "url": "/components/graphs/BarChart/" + }, + { + "label": "LineChart", + "url": "/components/graphs/LineChart/" + }, + { + "label": "PolarChart", + "url": "/components/graphs/PolarChart/" + }, { "label": "Point", "url": "/components/graphs/Point/" diff --git a/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json b/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json index d9af11660..6cba651c3 100644 --- a/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json +++ b/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json @@ -3,6 +3,22 @@ "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/CartesianChart.tsx", "description": "A flexible, low-level chart component for displaying data in an x/y coordinate space. Provides a foundation for building custom chart visualizations with full control over rendering.", "relatedComponents": [ + { + "label": "AreaChart", + "url": "/components/graphs/AreaChart/" + }, + { + "label": "BarChart", + "url": "/components/graphs/BarChart/" + }, + { + "label": "LineChart", + "url": "/components/graphs/LineChart/" + }, + { + "label": "PolarChart", + "url": "/components/graphs/PolarChart/" + }, { "label": "Point", "url": "/components/graphs/Point/" diff --git a/apps/docs/docs/components/graphs/DonutChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/DonutChart/_mobileExamples.mdx new file mode 100644 index 000000000..5eab42085 --- /dev/null +++ b/apps/docs/docs/components/graphs/DonutChart/_mobileExamples.mdx @@ -0,0 +1,425 @@ +DonutChart is built on [PolarChart](/components/graphs/PolarChart) for visualizing proportional data with a hollow center. Charts are built using `@shopify/react-native-skia`. + +## Setup + +Before using DonutChart, you need to wrap your app with `ChartBridgeProvider`. This enables charts to access CDS theming and other React contexts within the Skia renderer. See [CartesianChart](/components/graphs/CartesianChart/#setup) for details. + +## Basics + +The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a `data` number representing the slice value. + +```jsx +function BasicDonutChart() { + const theme = useTheme(); + + return ( + + ); +} +``` + +### Inner Radius + +You can control the size of the center hole using `innerRadiusRatio` (0-1). The default is `0.5`. + +```jsx +function InnerRadiusExamples() { + const theme = useTheme(); + + return ( + + + + + 0.3 + + + + + + 0.5 + + + + + + 0.75 + + + + ); +} +``` + +## Styling + +### Corner Radius + +Use `cornerRadius` to round the corners of each slice. + +```jsx +function CornerRadiusExample() { + const theme = useTheme(); + + return ( + + + + + ); +} +``` + +### Stroke + +Use `stroke` and `strokeWidth` to add borders between slices. + +```jsx +function StrokeExample() { + const theme = useTheme(); + + return ( + + ); +} +``` + +### Padding Between Slices + +Use `angularAxis` with `paddingAngle` to add gaps between slices. + +```jsx +function PaddingExample() { + const theme = useTheme(); + + return ( + + ); +} +``` + +## Center Label + +You can add a label in the center of the donut by using [PolarChart](/components/graphs/PolarChart) with custom `ChartText` children. + +```jsx +function DonutWithCenterLabel() { + const theme = useTheme(); + + const DonutCenterLabel = memo(({ children, ...props }) => { + const { drawingArea } = usePolarChartContext(); + + if (drawingArea.width <= 0 || drawingArea.height <= 0) return null; + + const centerX = drawingArea.x + drawingArea.width / 2; + const centerY = drawingArea.y + drawingArea.height / 2; + + return ( + + {children} + + ); + }); + + return ( + ({ min: max * 0.7, max }) }} + series={[ + { id: 'teal', data: 10, color: `rgb(${theme.spectrum.teal40})` }, + { id: 'blue', data: 25, color: `rgb(${theme.spectrum.blue40})` }, + { id: 'purple', data: 20, color: `rgb(${theme.spectrum.purple40})` }, + { id: 'pink', data: 15, color: `rgb(${theme.spectrum.pink40})` }, + { id: 'orange', data: 30, color: `rgb(${theme.spectrum.orange40})` }, + ]} + > + + + Portfolio + + + $9,999.99 + + + ); +} +``` + +## Animations + +DonutChart animates by default when data changes. You can disable animations by setting `animate` to `false`. + +```jsx +function AnimatedDonutChart() { + const theme = useTheme(); + const [dataSet, setDataSet] = useState(0); + + const dataSets = useMemo( + () => [ + [ + { id: 'a', data: 30, label: 'A', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 40, label: 'B', color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 30, label: 'C', color: `rgb(${theme.spectrum.orange40})` }, + ], + [ + { id: 'a', data: 60, label: 'A', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 20, label: 'B', color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 20, label: 'C', color: `rgb(${theme.spectrum.orange40})` }, + ], + [ + { id: 'a', data: 15, label: 'A', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 55, label: 'B', color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 30, label: 'C', color: `rgb(${theme.spectrum.orange40})` }, + ], + ], + [theme], + ); + + return ( + + + + setDataSet((prev) => (prev - 1 + dataSets.length) % dataSets.length)} + variant="secondary" + /> + Data Set {dataSet + 1} + setDataSet((prev) => (prev + 1) % dataSets.length)} + variant="secondary" + /> + + + ); +} +``` + +## Composed Examples + +### Portfolio Breakdown + +A common use case for donut charts is showing portfolio or wallet breakdowns. + +```jsx +function PortfolioBreakdown() { + const theme = useTheme(); + + const series = useMemo( + () => [ + { id: 'crypto', data: 45000, label: 'Crypto', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'cash', data: 30000, label: 'Cash', color: `rgb(${theme.spectrum.green40})` }, + { id: 'rewards', data: 15000, label: 'Rewards', color: `rgb(${theme.spectrum.purple40})` }, + { id: 'nft', data: 10000, label: 'NFTs', color: `rgb(${theme.spectrum.orange40})` }, + ], + [theme], + ); + + const total = series.reduce((sum, s) => sum + s.data, 0); + + const formatCurrency = useCallback((value) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }, []); + + return ( + + + + {series.map((item) => ( + + + + {item.label} + + {formatCurrency(item.data)} ({Math.round((item.data / total) * 100)}%) + + + + ))} + + + ); +} +``` + +### Compact Donut + +For smaller spaces, you can create a compact donut chart. + +```jsx +function CompactDonut() { + const theme = useTheme(); + + return ( + + + + + 75% + + Complete + + + + + + + 30% + + In Progress + + + + + ); +} +``` + +### Wallet Breakdown with Padding + +A stylized wallet breakdown with rounded slices and padding. + +```jsx +function WalletBreakdown() { + const theme = useTheme(); + + return ( + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/DonutChart/_mobilePropsTable.mdx b/apps/docs/docs/components/graphs/DonutChart/_mobilePropsTable.mdx new file mode 100644 index 000000000..3f9baaf78 --- /dev/null +++ b/apps/docs/docs/components/graphs/DonutChart/_mobilePropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import mobilePropsData from ':docgen/mobile-visualization/chart/DonutChart/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/graphs/DonutChart/_webExamples.mdx b/apps/docs/docs/components/graphs/DonutChart/_webExamples.mdx new file mode 100644 index 000000000..ef6d34bb2 --- /dev/null +++ b/apps/docs/docs/components/graphs/DonutChart/_webExamples.mdx @@ -0,0 +1,447 @@ +DonutChart is built on [PolarChart](/components/graphs/PolarChart) for visualizing proportional data with a hollow center. + +## Basics + +The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a `data` number representing the slice value. + +```jsx live + +``` + +### Inner Radius + +You can control the size of the center hole using `innerRadiusRatio` (0-1). The default is `0.5`. + +```jsx live + + + + + innerRadiusRatio: 0.3 + + + + + + innerRadiusRatio: 0.5 + + + + + + innerRadiusRatio: 0.75 + + + +``` + +## Styling + +### Corner Radius + +Use `cornerRadius` to round the corners of each slice. + +```jsx live + + + + + cornerRadius: 0 + + + + + + cornerRadius: 8 + + + +``` + +### Stroke + +Use `stroke` and `strokeWidth` to add borders between slices. + +```jsx live + +``` + +### Padding Between Slices + +Use `angularAxis` with `paddingAngle` to add gaps between slices. + +```jsx live + +``` + +## Center Label + +You can add a label in the center of the donut using children with absolute positioning. + +```jsx live +function DonutWithCenterLabel() { + const series = [ + { id: 'btc', data: 40, label: 'Bitcoin', color: 'rgb(var(--orange40))' }, + { id: 'eth', data: 30, label: 'Ethereum', color: 'rgb(var(--blue40))' }, + { id: 'sol', data: 20, label: 'Solana', color: 'rgb(var(--purple40))' }, + { id: 'other', data: 10, label: 'Other', color: 'rgb(var(--gray40))' }, + ]; + + const total = series.reduce((sum, s) => sum + s.data, 0); + + return ( + + + + + + Total + + ${(total * 100).toLocaleString()} + + + + ); +} +``` + +## Animations + +DonutChart animates by default when data changes. You can disable animations by setting `animate` to `false`. + +```jsx live +function AnimatedDonutChart() { + const [dataSet, setDataSet] = useState(0); + + const dataSets = useMemo( + () => [ + [ + { id: 'a', data: 30, label: 'A', color: 'rgb(var(--blue40))' }, + { id: 'b', data: 40, label: 'B', color: 'rgb(var(--green40))' }, + { id: 'c', data: 30, label: 'C', color: 'rgb(var(--orange40))' }, + ], + [ + { id: 'a', data: 60, label: 'A', color: 'rgb(var(--blue40))' }, + { id: 'b', data: 20, label: 'B', color: 'rgb(var(--green40))' }, + { id: 'c', data: 20, label: 'C', color: 'rgb(var(--orange40))' }, + ], + [ + { id: 'a', data: 15, label: 'A', color: 'rgb(var(--blue40))' }, + { id: 'b', data: 55, label: 'B', color: 'rgb(var(--green40))' }, + { id: 'c', data: 30, label: 'C', color: 'rgb(var(--orange40))' }, + ], + ], + [], + ); + + return ( + + + + setDataSet((prev) => (prev - 1 + dataSets.length) % dataSets.length)} + variant="secondary" + /> + Data Set {dataSet + 1} + setDataSet((prev) => (prev + 1) % dataSets.length)} + variant="secondary" + /> + + + ); +} +``` + +## Interactive + +You can create interactive donut charts by using [PolarChart](/components/graphs/PolarChart) directly with `PiePlot`. + +```jsx live +function InteractiveDonutChart() { + const [selectedSlice, setSelectedSlice] = useState(null); + + const series = useMemo( + () => [ + { id: 'btc', data: 40, label: 'Bitcoin', color: 'rgb(var(--orange40))' }, + { id: 'eth', data: 30, label: 'Ethereum', color: 'rgb(var(--blue40))' }, + { id: 'sol', data: 15, label: 'Solana', color: 'rgb(var(--purple40))' }, + { id: 'other', data: 15, label: 'Other', color: 'rgb(var(--gray40))' }, + ], + [], + ); + + const total = series.reduce((sum, s) => sum + s.data, 0); + const selectedData = selectedSlice ? series.find((s) => s.id === selectedSlice) : null; + + const handleSliceClick = useCallback((data) => { + setSelectedSlice((prev) => (prev === data.id ? null : data.id)); + }, []); + + return ( + + + ({ min: max * 0.65, max }) }} + series={series.map((s) => ({ + ...s, + color: selectedSlice && selectedSlice !== s.id ? `${s.color}80` : s.color, + }))} + > + + + + + {selectedData ? selectedData.label : 'Total'} + + + {selectedData ? `${Math.round((selectedData.data / total) * 100)}%` : '$12,345'} + + + + + ); +} +``` + +## Composed Examples + +### Portfolio Breakdown + +A common use case for donut charts is showing portfolio or wallet breakdowns. + +```jsx live +function PortfolioBreakdown() { + const series = useMemo( + () => [ + { id: 'crypto', data: 45000, label: 'Crypto', color: 'rgb(var(--blue40))' }, + { id: 'cash', data: 30000, label: 'Cash', color: 'rgb(var(--green40))' }, + { id: 'rewards', data: 15000, label: 'Rewards', color: 'rgb(var(--purple40))' }, + { id: 'nft', data: 10000, label: 'NFTs', color: 'rgb(var(--orange40))' }, + ], + [], + ); + + const total = series.reduce((sum, s) => sum + s.data, 0); + + const formatCurrency = useCallback((value) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }, []); + + return ( + + + + + + + Portfolio Value + + {formatCurrency(total)} + + + + + {series.map((item) => ( + + + + {item.label} + + {formatCurrency(item.data)} ({Math.round((item.data / total) * 100)}%) + + + + ))} + + + ); +} +``` + +### Compact Donut + +For smaller spaces, you can create a compact donut chart. + +```jsx live + + + + + 75% + + Complete + + + + + + + 30% + + In Progress + + + + +``` diff --git a/apps/docs/docs/components/graphs/DonutChart/_webPropsTable.mdx b/apps/docs/docs/components/graphs/DonutChart/_webPropsTable.mdx new file mode 100644 index 000000000..4559404e0 --- /dev/null +++ b/apps/docs/docs/components/graphs/DonutChart/_webPropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import webPropsData from ':docgen/web-visualization/chart/DonutChart/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/graphs/DonutChart/index.mdx b/apps/docs/docs/components/graphs/DonutChart/index.mdx new file mode 100644 index 000000000..8bd492327 --- /dev/null +++ b/apps/docs/docs/components/graphs/DonutChart/index.mdx @@ -0,0 +1,35 @@ +--- +id: donutChart +title: DonutChart +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web-visualization/chart/DonutChart/toc-props'; +import mobilePropsToc from ':docgen/mobile-visualization/chart/DonutChart/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + webExamples={} + mobilePropsTable={} + mobileExamples={} + webExamplesToc={webExamplesToc} + mobileExamplesToc={mobileExamplesToc} + webPropsToc={webPropsToc} + mobilePropsToc={mobilePropsToc} + /> + diff --git a/apps/docs/docs/components/graphs/DonutChart/mobileMetadata.json b/apps/docs/docs/components/graphs/DonutChart/mobileMetadata.json new file mode 100644 index 000000000..3cce38e59 --- /dev/null +++ b/apps/docs/docs/components/graphs/DonutChart/mobileMetadata.json @@ -0,0 +1,29 @@ +{ + "import": "import { DonutChart } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/DonutChart.tsx", + "description": "A donut chart component built on PolarChart for visualizing proportional data with a hollow center.", + "relatedComponents": [ + { + "label": "PolarChart", + "url": "/components/graphs/PolarChart/" + }, + { + "label": "PieChart", + "url": "/components/graphs/PieChart/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/graphs/DonutChart/webMetadata.json b/apps/docs/docs/components/graphs/DonutChart/webMetadata.json new file mode 100644 index 000000000..250038d90 --- /dev/null +++ b/apps/docs/docs/components/graphs/DonutChart/webMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { DonutChart } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/DonutChart.tsx", + "description": "A donut chart component built on PolarChart for visualizing proportional data with a hollow center.", + "relatedComponents": [ + { + "label": "PolarChart", + "url": "/components/graphs/PolarChart/" + }, + { + "label": "PieChart", + "url": "/components/graphs/PieChart/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/graphs/LineChart/mobileMetadata.json b/apps/docs/docs/components/graphs/LineChart/mobileMetadata.json index c551bd955..5fa9aa05c 100644 --- a/apps/docs/docs/components/graphs/LineChart/mobileMetadata.json +++ b/apps/docs/docs/components/graphs/LineChart/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { LineChart } from '@coinbase/cds-mobile-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/line/LineChart.tsx", - "description": "A flexible line chart component for displaying data trends over time. Supports multiple series, custom curves, areas, scrubbing, and interactive data exploration.", + "description": "A flexible line chart component built on CartesianChart for displaying data trends over time. Supports multiple series, custom curves, areas, scrubbing, and interactive data exploration.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/LineChart/webMetadata.json b/apps/docs/docs/components/graphs/LineChart/webMetadata.json index d540edc70..7611e6196 100644 --- a/apps/docs/docs/components/graphs/LineChart/webMetadata.json +++ b/apps/docs/docs/components/graphs/LineChart/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { LineChart } from '@coinbase/cds-web-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/line/LineChart.tsx", - "description": "A flexible line chart component for displaying data trends over time. Supports multiple series, custom curves, areas, scrubbing, and interactive data exploration.", + "description": "A flexible line chart component built on CartesianChart for displaying data trends over time. Supports multiple series, custom curves, areas, scrubbing, and interactive data exploration.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/PieChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/PieChart/_mobileExamples.mdx new file mode 100644 index 000000000..5b97dd2fa --- /dev/null +++ b/apps/docs/docs/components/graphs/PieChart/_mobileExamples.mdx @@ -0,0 +1,350 @@ +PieChart is built on [PolarChart](/components/graphs/PolarChart) for visualizing proportional data. Charts are built using `@shopify/react-native-skia`. + +## Setup + +Before using PieChart, you need to wrap your app with `ChartBridgeProvider`. This enables charts to access CDS theming and other React contexts within the Skia renderer. See [CartesianChart](/components/graphs/CartesianChart/#setup) for details. + +## Basics + +The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a `data` number representing the slice value. + +```jsx +function BasicPieChart() { + const theme = useTheme(); + + return ( + + ); +} +``` + +## Styling + +### Corner Radius + +Use `cornerRadius` to round the corners of each slice. + +```jsx +function CornerRadiusExample() { + const theme = useTheme(); + + return ( + + + + + ); +} +``` + +### Stroke + +Use `stroke` and `strokeWidth` to add borders between slices. + +```jsx +function StrokeExample() { + const theme = useTheme(); + + return ( + + ); +} +``` + +### Padding Between Slices + +Use `angularAxis` with `paddingAngle` to add gaps between slices. + +```jsx +function PaddingExample() { + const theme = useTheme(); + + return ( + + ); +} +``` + +## Partial Charts + +### Semicircle + +Use `angularAxis` with `range` to create partial pie charts like semicircles. + +```jsx +function SemicircleChart() { + const theme = useTheme(); + + return ( + + ); +} +``` + +### Quarter Pie + +```jsx +function QuarterPieChart() { + const theme = useTheme(); + + return ( + + ); +} +``` + +## Animations + +PieChart animates by default when data changes. You can disable animations by setting `animate` to `false`. + +```jsx +function AnimatedPieChart() { + const theme = useTheme(); + const [dataSet, setDataSet] = useState(0); + + const dataSets = useMemo( + () => [ + [ + { id: 'a', data: 30, color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 40, color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 30, color: `rgb(${theme.spectrum.orange40})` }, + ], + [ + { id: 'a', data: 50, color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 30, color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 20, color: `rgb(${theme.spectrum.orange40})` }, + ], + [ + { id: 'a', data: 20, color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 50, color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 30, color: `rgb(${theme.spectrum.orange40})` }, + ], + ], + [theme], + ); + + return ( + + + + setDataSet((prev) => (prev - 1 + dataSets.length) % dataSets.length)} + variant="secondary" + /> + Data Set {dataSet + 1} + setDataSet((prev) => (prev + 1) % dataSets.length)} + variant="secondary" + /> + + + ); +} +``` + +## Composed Examples + +### Category Distribution + +```jsx +function CategoryDistribution() { + const theme = useTheme(); + + const series = useMemo( + () => [ + { id: 'tech', data: 35, label: 'Technology', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'health', data: 25, label: 'Healthcare', color: `rgb(${theme.spectrum.green40})` }, + { id: 'finance', data: 20, label: 'Finance', color: `rgb(${theme.spectrum.purple40})` }, + { id: 'energy', data: 12, label: 'Energy', color: `rgb(${theme.spectrum.orange40})` }, + { id: 'other', data: 8, label: 'Other', color: `rgb(${theme.spectrum.gray40})` }, + ], + [theme], + ); + + const total = series.reduce((sum, s) => sum + s.data, 0); + + return ( + + + + {series.map((item) => ( + + + + {item.label} + + + {Math.round((item.data / total) * 100)}% + + + ))} + + + ); +} +``` + +### Gauge Chart + +A semicircle pie chart can be used as a gauge to show progress or status. + +```jsx +function GaugeChart() { + const theme = useTheme(); + const value = 72; + const max = 100; + + return ( + + + + {value}% + + Goal Progress + + + + ); +} +``` + +### Compact Pie + +For smaller spaces, you can create compact pie charts. + +```jsx +function CompactPie() { + const theme = useTheme(); + + return ( + + + + + 60% + + Allocation + + + + + + + Equal + + Split + + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/PieChart/_mobilePropsTable.mdx b/apps/docs/docs/components/graphs/PieChart/_mobilePropsTable.mdx new file mode 100644 index 000000000..db0a40390 --- /dev/null +++ b/apps/docs/docs/components/graphs/PieChart/_mobilePropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import mobilePropsData from ':docgen/mobile-visualization/chart/pie/PieChart/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/graphs/PieChart/_webExamples.mdx b/apps/docs/docs/components/graphs/PieChart/_webExamples.mdx new file mode 100644 index 000000000..96398225e --- /dev/null +++ b/apps/docs/docs/components/graphs/PieChart/_webExamples.mdx @@ -0,0 +1,324 @@ +PieChart is built on [PolarChart](/components/graphs/PolarChart) for visualizing proportional data. + +## Basics + +The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a `data` number representing the slice value. + +```jsx live + +``` + +## Styling + +### Corner Radius + +Use `cornerRadius` to round the corners of each slice. + +```jsx live + + + + + cornerRadius: 0 + + + + + + cornerRadius: 8 + + + +``` + +### Stroke + +Use `stroke` and `strokeWidth` to add borders between slices. + +```jsx live + +``` + +### Padding Between Slices + +Use `angularAxis` with `paddingAngle` to add gaps between slices. + +```jsx live + +``` + +## Partial Charts + +### Semicircle + +Use `angularAxis` with `range` to create partial pie charts like semicircles. + +```jsx live + + + + Risk Distribution + + +``` + +### Quarter Pie + +```jsx live + +``` + +## Animations + +PieChart animates by default when data changes. You can disable animations by setting `animate` to `false`. + +```jsx live +function AnimatedPieChart() { + const [dataSet, setDataSet] = useState(0); + + const dataSets = useMemo( + () => [ + [ + { id: 'a', data: 30, color: 'rgb(var(--blue40))' }, + { id: 'b', data: 40, color: 'rgb(var(--green40))' }, + { id: 'c', data: 30, color: 'rgb(var(--orange40))' }, + ], + [ + { id: 'a', data: 50, color: 'rgb(var(--blue40))' }, + { id: 'b', data: 30, color: 'rgb(var(--green40))' }, + { id: 'c', data: 20, color: 'rgb(var(--orange40))' }, + ], + [ + { id: 'a', data: 20, color: 'rgb(var(--blue40))' }, + { id: 'b', data: 50, color: 'rgb(var(--green40))' }, + { id: 'c', data: 30, color: 'rgb(var(--orange40))' }, + ], + ], + [], + ); + + return ( + + + + setDataSet((prev) => (prev - 1 + dataSets.length) % dataSets.length)} + variant="secondary" + /> + Data Set {dataSet + 1} + setDataSet((prev) => (prev + 1) % dataSets.length)} + variant="secondary" + /> + + + ); +} +``` + +## Composed Examples + +### Category Distribution + +```jsx live +function CategoryDistribution() { + const series = useMemo( + () => [ + { id: 'tech', data: 35, label: 'Technology', color: 'rgb(var(--blue40))' }, + { id: 'health', data: 25, label: 'Healthcare', color: 'rgb(var(--green40))' }, + { id: 'finance', data: 20, label: 'Finance', color: 'rgb(var(--purple40))' }, + { id: 'energy', data: 12, label: 'Energy', color: 'rgb(var(--orange40))' }, + { id: 'other', data: 8, label: 'Other', color: 'rgb(var(--gray40))' }, + ], + [], + ); + + const total = series.reduce((sum, s) => sum + s.data, 0); + + return ( + + + + {series.map((item) => ( + + + + {item.label} + + + {Math.round((item.data / total) * 100)}% + + + ))} + + + ); +} +``` + +### Gauge Chart + +A semicircle pie chart can be used as a gauge to show progress or status. + +```jsx live +function GaugeChart() { + const value = 72; + const max = 100; + + return ( + + + + + + {value}% + + Goal Progress + + + + + + ); +} +``` + +### Compact Pie + +For smaller spaces, you can create compact pie charts. + +```jsx live + + + + + 60% + + Allocation + + + + + + + Equal + + Split + + + + +``` diff --git a/apps/docs/docs/components/graphs/PieChart/_webPropsTable.mdx b/apps/docs/docs/components/graphs/PieChart/_webPropsTable.mdx new file mode 100644 index 000000000..676da4ef5 --- /dev/null +++ b/apps/docs/docs/components/graphs/PieChart/_webPropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import webPropsData from ':docgen/web-visualization/chart/pie/PieChart/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/graphs/PieChart/index.mdx b/apps/docs/docs/components/graphs/PieChart/index.mdx new file mode 100644 index 000000000..74881fe97 --- /dev/null +++ b/apps/docs/docs/components/graphs/PieChart/index.mdx @@ -0,0 +1,35 @@ +--- +id: pieChart +title: PieChart +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web-visualization/chart/pie/PieChart/toc-props'; +import mobilePropsToc from ':docgen/mobile-visualization/chart/pie/PieChart/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + webExamples={} + mobilePropsTable={} + mobileExamples={} + webExamplesToc={webExamplesToc} + mobileExamplesToc={mobileExamplesToc} + webPropsToc={webPropsToc} + mobilePropsToc={mobilePropsToc} + /> + diff --git a/apps/docs/docs/components/graphs/PieChart/mobileMetadata.json b/apps/docs/docs/components/graphs/PieChart/mobileMetadata.json new file mode 100644 index 000000000..ad1a2b271 --- /dev/null +++ b/apps/docs/docs/components/graphs/PieChart/mobileMetadata.json @@ -0,0 +1,29 @@ +{ + "import": "import { PieChart } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/pie/PieChart.tsx", + "description": "A pie chart component built on PolarChart for visualizing proportional data.", + "relatedComponents": [ + { + "label": "PolarChart", + "url": "/components/graphs/PolarChart/" + }, + { + "label": "DonutChart", + "url": "/components/graphs/DonutChart/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/graphs/PieChart/webMetadata.json b/apps/docs/docs/components/graphs/PieChart/webMetadata.json new file mode 100644 index 000000000..cfacc8781 --- /dev/null +++ b/apps/docs/docs/components/graphs/PieChart/webMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { PieChart } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/pie/PieChart.tsx", + "description": "A pie chart component built on PolarChart for visualizing proportional data.", + "relatedComponents": [ + { + "label": "PolarChart", + "url": "/components/graphs/PolarChart/" + }, + { + "label": "DonutChart", + "url": "/components/graphs/DonutChart/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/graphs/Point/mobileMetadata.json b/apps/docs/docs/components/graphs/Point/mobileMetadata.json index 660a5e89a..8a9558476 100644 --- a/apps/docs/docs/components/graphs/Point/mobileMetadata.json +++ b/apps/docs/docs/components/graphs/Point/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { Point } from '@coinbase/cds-mobile-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/Point.tsx", - "description": "Visual markers that highlight specific data values on a chart. Points can be customized with different colors, sizes, and labels.", + "description": "Visual markers that highlight specific data values on a cartesian based chart. Points can be customized with different colors, sizes, and labels.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/Point/webMetadata.json b/apps/docs/docs/components/graphs/Point/webMetadata.json index 0f3a11077..c8e020d60 100644 --- a/apps/docs/docs/components/graphs/Point/webMetadata.json +++ b/apps/docs/docs/components/graphs/Point/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { Point } from '@coinbase/cds-web-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/Point.tsx", - "description": "Visual markers that highlight specific data values on a chart. Points can be customized with different colors, sizes, and interactivity.", + "description": "Visual markers that highlight specific data values on a cartesian based chart. Points can be customized with different colors, sizes, and interactivity.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/PolarChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/PolarChart/_mobileExamples.mdx new file mode 100644 index 000000000..88e956920 --- /dev/null +++ b/apps/docs/docs/components/graphs/PolarChart/_mobileExamples.mdx @@ -0,0 +1,467 @@ +PolarChart is a customizable component for displaying data in a polar coordinate space. Charts are built using `@shopify/react-native-skia`. + +## Setup + +Before using PolarChart, you need to wrap your app with `ChartBridgeProvider`. This enables charts to access CDS theming and other React contexts within the Skia renderer. + +## Basic Example + +[PieChart](/components/graphs/PieChart/) and [DonutChart](/components/graphs/DonutChart/) are built on top of PolarChart and have default functionality for your chart. + +```jsx +function BasicExample() { + const theme = useTheme(); + + return ( + + + + + PieChart + + + + + + DonutChart + + + + ); +} +``` + +## Series + +Series are the data that will be displayed on the chart. Each series must have a defined `id` and `data` (a single number representing the value). + +```jsx +function SeriesExample() { + const theme = useTheme(); + + return ( + + + + ); +} +``` + +## Angular Axis + +The angular axis controls the start and end angles of the chart. By default, it creates a full circle (0° to 360°). + +### Custom Range + +Use `angularAxis` with `range` to create partial charts. + +```jsx +function CustomRangeExample() { + const theme = useTheme(); + + return ( + + + + + + + Semicircle (top) + + + + + + + + Semicircle (bottom) + + + + ); +} +``` + +### Padding Angle + +Use `paddingAngle` to add gaps between slices. + +```jsx +function PaddingAngleExample() { + const theme = useTheme(); + + return ( + + + + ); +} +``` + +### Multiple Angular Axes + +You can define multiple angular axes for different sections of the chart. + +```jsx +function MultipleAngularAxesExample() { + const theme = useTheme(); + + return ( + + + + + ); +} +``` + +## Radial Axis + +The radial axis controls the inner and outer radii of the chart. By default, it starts from 0 (center) to the full radius. + +### Donut Effect + +Use `radialAxis` with `range` to create a donut chart with a hollow center. + +```jsx +function DonutEffectExample() { + const theme = useTheme(); + + return ( + ({ min: max * 0.6, max }) }} + series={[ + { id: 'a', data: 30, color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 40, color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 30, color: `rgb(${theme.spectrum.purple40})` }, + ]} + > + + + ); +} +``` + +### Nested Rings + +Use multiple radial axes to create nested rings. + +```jsx +function NestedRingsExample() { + const theme = useTheme(); + + return ( + ({ min: 0, max: max * 0.35 }) }, + { id: 'outer', range: ({ max }) => ({ min: max * 0.45, max }) }, + ]} + series={[ + { id: 'inner-a', data: 60, radialAxisId: 'inner', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'inner-b', data: 40, radialAxisId: 'inner', color: `rgb(${theme.spectrum.blue60})` }, + { id: 'outer-a', data: 30, radialAxisId: 'outer', color: `rgb(${theme.spectrum.green40})` }, + { id: 'outer-b', data: 25, radialAxisId: 'outer', color: `rgb(${theme.spectrum.green50})` }, + { id: 'outer-c', data: 20, radialAxisId: 'outer', color: `rgb(${theme.spectrum.green60})` }, + { id: 'outer-d', data: 25, radialAxisId: 'outer', color: `rgb(${theme.spectrum.green70})` }, + ]} + > + + + + ); +} +``` + +## Customization + +### Center Label + +You can add custom content in the center of donut charts using `DonutCenterLabel`. + +```jsx +function CenterLabelExample() { + const theme = useTheme(); + + return ( + ({ min: max * 0.7, max }) }} + series={[ + { id: 'teal', data: 10, color: `rgb(${theme.spectrum.teal40})` }, + { id: 'blue', data: 25, color: `rgb(${theme.spectrum.blue40})` }, + { id: 'purple', data: 20, color: `rgb(${theme.spectrum.purple40})` }, + { id: 'pink', data: 15, color: `rgb(${theme.spectrum.pink40})` }, + { id: 'orange', data: 30, color: `rgb(${theme.spectrum.orange40})` }, + ]} + > + + + Total + + + $1,234 + + + ); +} +``` + +## Composed Examples + +### Quadrant Chart + +A chart divided into four quadrants, each with its own data. + +```jsx +function QuadrantChartExample() { + const theme = useTheme(); + + return ( + + + + + + + ); +} +``` + +### Segmented Progress Ring + +A progress ring with segmented sections and clipped progress indicator. + +```jsx +function SegmentedProgressRing() { + const theme = useTheme(); + + const innerRadiusRatio = 0.75; + const angleEachSideGap = (45 / 4) * 3; + const startAngleDegrees = angleEachSideGap - 180; + const endAngleDegrees = 180 - angleEachSideGap; + const angleGapDegrees = 5; + const totalGapDegrees = angleGapDegrees * 2; + const gapBetweenDegrees = totalGapDegrees / 3; + const sectionLengthDegrees = (endAngleDegrees - startAngleDegrees) / 3 - gapBetweenDegrees; + + const firstSectionEnd = startAngleDegrees + sectionLengthDegrees; + const secondSectionStart = firstSectionEnd + gapBetweenDegrees; + const secondSectionEnd = secondSectionStart + sectionLengthDegrees; + const thirdSectionStart = secondSectionEnd + gapBetweenDegrees; + const thirdSectionEnd = thirdSectionStart + sectionLengthDegrees; + const progressAngle = -45; + + const BackgroundArcs = memo(() => { + const { drawingArea } = usePolarChartContext(); + + const { innerRadius, outerRadius } = useMemo(() => { + const r = Math.min(drawingArea.width, drawingArea.height) / 2; + return { + innerRadius: r * innerRadiusRatio, + outerRadius: r, + }; + }, [drawingArea]); + + const sections = useMemo( + () => [ + { + startAngle: (startAngleDegrees * Math.PI) / 180, + endAngle: (firstSectionEnd * Math.PI) / 180, + }, + { + startAngle: (secondSectionStart * Math.PI) / 180, + endAngle: (secondSectionEnd * Math.PI) / 180, + }, + { + startAngle: (thirdSectionStart * Math.PI) / 180, + endAngle: (thirdSectionEnd * Math.PI) / 180, + }, + ], + [], + ); + + return ( + <> + {sections.map((section, i) => ( + + ))} + + ); + }); + + const ClippedProgress = memo(() => { + const { drawingArea } = usePolarChartContext(); + + const clipPath = useMemo(() => { + const r = Math.min(drawingArea.width, drawingArea.height) / 2; + const innerRadius = r * innerRadiusRatio; + const outerRadius = r; + + const sections = [ + { + startAngle: (startAngleDegrees * Math.PI) / 180, + endAngle: (firstSectionEnd * Math.PI) / 180, + }, + { + startAngle: (secondSectionStart * Math.PI) / 180, + endAngle: (secondSectionEnd * Math.PI) / 180, + }, + { + startAngle: (thirdSectionStart * Math.PI) / 180, + endAngle: (thirdSectionEnd * Math.PI) / 180, + }, + ]; + + return sections + .map((section) => + getArcPath({ + startAngle: section.startAngle, + endAngle: section.endAngle, + innerRadius, + outerRadius, + cornerRadius: 100, + }), + ) + .join(' '); + }, [drawingArea]); + + return ; + }); + + return ( + ({ min: innerRadiusRatio * max, max }) }} + series={[{ id: 'progress', data: 100, label: 'Progress', color: theme.color.fg }]} + width={200} + > + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/PolarChart/_mobilePropsTable.mdx b/apps/docs/docs/components/graphs/PolarChart/_mobilePropsTable.mdx new file mode 100644 index 000000000..63f092c73 --- /dev/null +++ b/apps/docs/docs/components/graphs/PolarChart/_mobilePropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import mobilePropsData from ':docgen/mobile-visualization/chart/PolarChart/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/graphs/PolarChart/_webExamples.mdx b/apps/docs/docs/components/graphs/PolarChart/_webExamples.mdx new file mode 100644 index 000000000..54ddc9a2a --- /dev/null +++ b/apps/docs/docs/components/graphs/PolarChart/_webExamples.mdx @@ -0,0 +1,533 @@ +PolarChart is a customizable, SVG based component that can be used to display a variety of data in a polar coordinate space. The underlying logic is handled by D3. + +## Basic Example + +[PieChart](/components/graphs/PieChart/) and [DonutChart](/components/graphs/DonutChart/) are built on top of PolarChart and have default functionality for your chart. + +```jsx live + + + + + PieChart + + + + + + DonutChart + + + +``` + +## Series + +Series are the data that will be displayed on the chart. Each series must have a defined `id` and `data` (a single number representing the value). + +```jsx live + + + +``` + +## Angular Axis + +The angular axis controls the start and end angles of the chart. By default, it creates a full circle (0° to 360°). + +### Custom Range + +Use `angularAxis` with `range` to create partial charts. + +```jsx live + + + + + + + Semicircle (top) + + + + + + + + Semicircle (bottom) + + + +``` + +### Padding Angle + +Use `paddingAngle` to add gaps between slices. + +```jsx live + + + +``` + +### Multiple Angular Axes + +You can define multiple angular axes for different sections of the chart. + +```jsx live + + + + +``` + +## Radial Axis + +The radial axis controls the inner and outer radii of the chart. By default, it starts from 0 (center) to the full radius. + +### Donut Effect + +Use `radialAxis` with `range` to create a donut chart with a hollow center. + +```jsx live + ({ min: max * 0.6, max }) }} + series={[ + { id: 'a', data: 30, color: 'var(--color-accentBoldBlue)' }, + { id: 'b', data: 40, color: 'var(--color-accentBoldGreen)' }, + { id: 'c', data: 30, color: 'var(--color-accentBoldPurple)' }, + ]} +> + + +``` + +### Nested Rings + +Use multiple radial axes to create nested rings. + +```jsx live + ({ min: 0, max: max * 0.35 }) }, + { id: 'outer', range: ({ max }) => ({ min: max * 0.45, max }) }, + ]} + series={[ + { id: 'inner-a', data: 60, radialAxisId: 'inner', color: 'rgb(var(--blue40))' }, + { id: 'inner-b', data: 40, radialAxisId: 'inner', color: 'rgb(var(--blue60))' }, + { id: 'outer-a', data: 30, radialAxisId: 'outer', color: 'rgb(var(--green40))' }, + { id: 'outer-b', data: 25, radialAxisId: 'outer', color: 'rgb(var(--green50))' }, + { id: 'outer-c', data: 20, radialAxisId: 'outer', color: 'rgb(var(--green60))' }, + { id: 'outer-d', data: 25, radialAxisId: 'outer', color: 'rgb(var(--green70))' }, + ]} +> + + + +``` + +## Customization + +### Center Label + +You can add custom SVG elements as children, such as a center label. + +```jsx live +function DonutWithCenterLabel() { + const CenterLabel = memo(({ children }) => { + const { drawingArea } = usePolarChartContext(); + + if (!drawingArea.width || !drawingArea.height) return null; + + const centerX = drawingArea.x + drawingArea.width / 2; + const centerY = drawingArea.y + drawingArea.height / 2; + + return ( + + {children} + + ); + }); + + return ( + ({ min: max * 0.7, max }) }} + series={[ + { id: 'teal', data: 10, color: 'rgb(var(--teal40))' }, + { id: 'blue', data: 25, color: 'rgb(var(--blue40))' }, + { id: 'purple', data: 20, color: 'rgb(var(--purple40))' }, + { id: 'pink', data: 15, color: 'rgb(var(--pink40))' }, + { id: 'orange', data: 30, color: 'rgb(var(--orange40))' }, + ]} + > + + $1,234 + + ); +} +``` + +### Interactive Slices + +Use `onSliceClick` on `PiePlot` to handle slice interactions. + +```jsx live +function InteractiveChart() { + const [selectedSlice, setSelectedSlice] = useState(null); + + const series = useMemo( + () => [ + { id: 'btc', data: 40, label: 'Bitcoin', color: 'rgb(var(--orange40))' }, + { id: 'eth', data: 30, label: 'Ethereum', color: 'rgb(var(--blue40))' }, + { id: 'sol', data: 15, label: 'Solana', color: 'rgb(var(--purple40))' }, + { id: 'other', data: 15, label: 'Other', color: 'rgb(var(--gray30))' }, + ], + [], + ); + + const total = series.reduce((sum, s) => sum + s.data, 0); + const selectedData = selectedSlice ? series.find((s) => s.id === selectedSlice) : null; + + const handleSliceClick = useCallback((data) => { + setSelectedSlice((prev) => (prev === data.id ? null : data.id)); + }, []); + + return ( + + + ({ min: max * 0.65, max }) }} + series={series.map((s) => ({ + ...s, + color: selectedSlice && selectedSlice !== s.id ? `${s.color}80` : s.color, + }))} + > + + + + + {selectedData ? selectedData.label : 'Total'} + + + {selectedData ? `${Math.round((selectedData.data / total) * 100)}%` : '$12,345'} + + + + + ); +} +``` + +### Custom Arc Component + +You can provide a custom `ArcComponent` to `PiePlot` for complete control over arc rendering. + +```jsx live +function CustomArcExample() { + const CustomArc = memo(({ startAngle, endAngle, innerRadius, outerRadius, fill, ...props }) => { + // Add a glow effect to each arc + return ( + + ); + }); + + return ( + + + + ); +} +``` + +## Composed Examples + +### Quadrant Chart + +A chart divided into four quadrants, each with its own data. + +```jsx live + + + + + + +``` + +### Segmented Progress Ring + +A progress ring with segmented sections and clipped progress indicator. + +```jsx live +function SegmentedProgressRing() { + const innerRadiusRatio = 0.75; + const angleEachSideGap = (45 / 4) * 3; + const startAngleDegrees = angleEachSideGap - 180; + const endAngleDegrees = 180 - angleEachSideGap; + const angleGapDegrees = 5; + const totalGapDegrees = angleGapDegrees * 2; + const gapBetweenDegrees = totalGapDegrees / 3; + const sectionLengthDegrees = (endAngleDegrees - startAngleDegrees) / 3 - gapBetweenDegrees; + + const firstSectionEnd = startAngleDegrees + sectionLengthDegrees; + const secondSectionStart = firstSectionEnd + gapBetweenDegrees; + const secondSectionEnd = secondSectionStart + sectionLengthDegrees; + const thirdSectionStart = secondSectionEnd + gapBetweenDegrees; + const thirdSectionEnd = thirdSectionStart + sectionLengthDegrees; + const progressAngle = -45; + + const BackgroundArcs = memo(() => { + const { drawingArea } = usePolarChartContext(); + + const { innerRadius, outerRadius } = useMemo(() => { + const r = Math.min(drawingArea.width, drawingArea.height) / 2; + return { + innerRadius: r * innerRadiusRatio, + outerRadius: r, + }; + }, [drawingArea]); + + const sections = useMemo( + () => [ + { + startAngle: (startAngleDegrees * Math.PI) / 180, + endAngle: (firstSectionEnd * Math.PI) / 180, + }, + { + startAngle: (secondSectionStart * Math.PI) / 180, + endAngle: (secondSectionEnd * Math.PI) / 180, + }, + { + startAngle: (thirdSectionStart * Math.PI) / 180, + endAngle: (thirdSectionEnd * Math.PI) / 180, + }, + ], + [], + ); + + return ( + <> + {sections.map((section, i) => ( + + ))} + + ); + }); + + const ClippedProgress = memo(() => { + const { drawingArea } = usePolarChartContext(); + + const clipPathId = useMemo(() => { + return `rewards-clip-${Math.random().toString(36).substr(2, 9)}`; + }, []); + + const clipPath = useMemo(() => { + const r = Math.min(drawingArea.width, drawingArea.height) / 2; + const innerRadius = r * innerRadiusRatio; + const outerRadius = r; + + const sections = [ + { + startAngle: (startAngleDegrees * Math.PI) / 180, + endAngle: (firstSectionEnd * Math.PI) / 180, + }, + { + startAngle: (secondSectionStart * Math.PI) / 180, + endAngle: (secondSectionEnd * Math.PI) / 180, + }, + { + startAngle: (thirdSectionStart * Math.PI) / 180, + endAngle: (thirdSectionEnd * Math.PI) / 180, + }, + ]; + + return sections + .map((section) => + getArcPath({ + startAngle: section.startAngle, + endAngle: section.endAngle, + innerRadius, + outerRadius, + cornerRadius: 100, + }), + ) + .join(' '); + }, [drawingArea]); + + const centerX = drawingArea.x + drawingArea.width / 2; + const centerY = drawingArea.y + drawingArea.height / 2; + + return ( + <> + + + + + + + + + + ); + }); + + return ( + ({ min: innerRadiusRatio * max, max }) }} + series={[{ id: 'progress', data: 100, color: 'var(--color-fg)' }]} + width={200} + > + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/PolarChart/_webPropsTable.mdx b/apps/docs/docs/components/graphs/PolarChart/_webPropsTable.mdx new file mode 100644 index 000000000..c12460231 --- /dev/null +++ b/apps/docs/docs/components/graphs/PolarChart/_webPropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import webPropsData from ':docgen/web-visualization/chart/PolarChart/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/graphs/PolarChart/index.mdx b/apps/docs/docs/components/graphs/PolarChart/index.mdx new file mode 100644 index 000000000..67bb068d0 --- /dev/null +++ b/apps/docs/docs/components/graphs/PolarChart/index.mdx @@ -0,0 +1,35 @@ +--- +id: polarChart +title: PolarChart +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web-visualization/chart/PolarChart/toc-props'; +import mobilePropsToc from ':docgen/mobile-visualization/chart/PolarChart/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + webExamples={} + mobilePropsTable={} + mobileExamples={} + webExamplesToc={webExamplesToc} + mobileExamplesToc={mobileExamplesToc} + webPropsToc={webPropsToc} + mobilePropsToc={mobilePropsToc} + /> + diff --git a/apps/docs/docs/components/graphs/PolarChart/mobileMetadata.json b/apps/docs/docs/components/graphs/PolarChart/mobileMetadata.json new file mode 100644 index 000000000..00d6c3980 --- /dev/null +++ b/apps/docs/docs/components/graphs/PolarChart/mobileMetadata.json @@ -0,0 +1,33 @@ +{ + "import": "import { PolarChart } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/PolarChart.tsx", + "description": "A base component for polar coordinate charts (pie, donut, radial). Provides context and layout for polar chart child components.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/graphs/CartesianChart/" + }, + { + "label": "DonutChart", + "url": "/components/graphs/DonutChart/" + }, + { + "label": "PieChart", + "url": "/components/graphs/PieChart/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/graphs/PolarChart/webMetadata.json b/apps/docs/docs/components/graphs/PolarChart/webMetadata.json new file mode 100644 index 000000000..15e7c78e9 --- /dev/null +++ b/apps/docs/docs/components/graphs/PolarChart/webMetadata.json @@ -0,0 +1,25 @@ +{ + "import": "import { PolarChart } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/PolarChart.tsx", + "description": "A base component for polar coordinate charts (pie, donut, radial). Provides context and layout for polar chart child components.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/graphs/CartesianChart/" + }, + { + "label": "DonutChart", + "url": "/components/graphs/DonutChart/" + }, + { + "label": "PieChart", + "url": "/components/graphs/PieChart/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/graphs/ReferenceLine/mobileMetadata.json b/apps/docs/docs/components/graphs/ReferenceLine/mobileMetadata.json index f30371a6e..1f17d5191 100644 --- a/apps/docs/docs/components/graphs/ReferenceLine/mobileMetadata.json +++ b/apps/docs/docs/components/graphs/ReferenceLine/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { ReferenceLine } from '@coinbase/cds-mobile-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/line/ReferenceLine.tsx", - "description": "A horizontal or vertical reference line to mark important values on a chart, such as targets, thresholds, or baseline values.", + "description": "A horizontal or vertical reference line to mark important values on a cartesian based chart, such as targets, thresholds, or baseline values.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/ReferenceLine/webMetadata.json b/apps/docs/docs/components/graphs/ReferenceLine/webMetadata.json index 270290eef..441e24c02 100644 --- a/apps/docs/docs/components/graphs/ReferenceLine/webMetadata.json +++ b/apps/docs/docs/components/graphs/ReferenceLine/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { ReferenceLine } from '@coinbase/cds-web-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/line/ReferenceLine.tsx", - "description": "A horizontal or vertical reference line to mark important values on a chart, such as targets, thresholds, or baseline values.", + "description": "A horizontal or vertical reference line to mark important values on a cartesian based chart, such as targets, thresholds, or baseline values.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/Scrubber/mobileMetadata.json b/apps/docs/docs/components/graphs/Scrubber/mobileMetadata.json index f18cb0336..43dcf3b7a 100644 --- a/apps/docs/docs/components/graphs/Scrubber/mobileMetadata.json +++ b/apps/docs/docs/components/graphs/Scrubber/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { Scrubber } from '@coinbase/cds-mobile-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx", - "description": "An interactive scrubber component for exploring individual data points in charts. Displays values on hover or drag and supports custom labels and formatting.", + "description": "An interactive scrubber component for cartesian based charts. Allows exploring individual data points by displaying values on hover or drag, with support for custom labels and formatting.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/Scrubber/webMetadata.json b/apps/docs/docs/components/graphs/Scrubber/webMetadata.json index 88326f55b..ae6c8a66b 100644 --- a/apps/docs/docs/components/graphs/Scrubber/webMetadata.json +++ b/apps/docs/docs/components/graphs/Scrubber/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { Scrubber } from '@coinbase/cds-web-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/scrubber/Scrubber.tsx", - "description": "An interactive scrubber component for exploring individual data points in charts. Displays values on hover or drag and supports custom labels and formatting.", + "description": "An interactive scrubber component for cartesian based charts. Allows exploring individual data points by displaying values on hover or drag, with support for custom labels and formatting.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/XAxis/mobileMetadata.json b/apps/docs/docs/components/graphs/XAxis/mobileMetadata.json index a3ee7c9bc..be39311a4 100644 --- a/apps/docs/docs/components/graphs/XAxis/mobileMetadata.json +++ b/apps/docs/docs/components/graphs/XAxis/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { XAxis } from '@coinbase/cds-mobile-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/axis/XAxis.tsx", - "description": "A horizontal axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", + "description": "A horizontal axis component for cartesian based charts. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/XAxis/webMetadata.json b/apps/docs/docs/components/graphs/XAxis/webMetadata.json index 8a7e9e83d..76e9dc348 100644 --- a/apps/docs/docs/components/graphs/XAxis/webMetadata.json +++ b/apps/docs/docs/components/graphs/XAxis/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { XAxis } from '@coinbase/cds-web-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/axis/XAxis.tsx", - "description": "A horizontal axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting and data domains.", + "description": "A horizontal axis component for cartesian based charts. Displays tick marks, labels, gridlines, and supports custom formatting and data domains.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/YAxis/mobileMetadata.json b/apps/docs/docs/components/graphs/YAxis/mobileMetadata.json index e9882e3a3..a3ecd5201 100644 --- a/apps/docs/docs/components/graphs/YAxis/mobileMetadata.json +++ b/apps/docs/docs/components/graphs/YAxis/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { YAxis } from '@coinbase/cds-mobile-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/axis/YAxis.tsx", - "description": "A vertical axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", + "description": "A vertical axis component for cartesian based charts. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/graphs/YAxis/webMetadata.json b/apps/docs/docs/components/graphs/YAxis/webMetadata.json index 19bca7ff2..fd7cf07ae 100644 --- a/apps/docs/docs/components/graphs/YAxis/webMetadata.json +++ b/apps/docs/docs/components/graphs/YAxis/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { YAxis } from '@coinbase/cds-web-visualization'", "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/axis/YAxis.tsx", - "description": "A vertical axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", + "description": "A vertical axis component for cartesian based charts. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", "relatedComponents": [ { "label": "CartesianChart", diff --git a/apps/docs/docs/components/inputs/Radio/_webExamples.mdx b/apps/docs/docs/components/inputs/Radio/_webExamples.mdx index 842109167..604a5674a 100644 --- a/apps/docs/docs/components/inputs/Radio/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/Radio/_webExamples.mdx @@ -173,10 +173,10 @@ function CustomStyledRadio() { value="styled2" checked={selectedStyle === 'styled2'} onChange={(e) => setSelectedStyle(e.target.value)} - controlColor="accentBoldOrange" - borderColor="accentBoldOrange" + controlColor="accentBoldPurple" + borderColor="accentBoldPurple" > - Orange Theme + Purple Theme ); diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 0b28b2258..ce48fdc1a 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -603,11 +603,26 @@ const sidebars: SidebarsConfig = { id: 'components/graphs/CartesianChart/cartesianChart', label: 'CartesianChart', }, + { + type: 'doc', + id: 'components/graphs/DonutChart/donutChart', + label: 'DonutChart', + }, { type: 'doc', id: 'components/graphs/LineChart/lineChart', label: 'LineChart', }, + { + type: 'doc', + id: 'components/graphs/PieChart/pieChart', + label: 'PieChart', + }, + { + type: 'doc', + id: 'components/graphs/PolarChart/polarChart', + label: 'PolarChart', + }, { type: 'doc', id: 'components/graphs/ReferenceLine/referenceLine', diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs index a5684309d..293096742 100644 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ b/apps/mobile-app/scripts/utils/routes.mjs @@ -43,6 +43,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, }, + { + key: 'AlphaSelectChip', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, + }, { key: 'AlphaTabbedChips', getComponent: () => @@ -466,6 +471,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/illustrations/__stories__/Pictogram.stories').default, }, + { + key: 'PolarChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/PolarChart.stories').default, + }, { key: 'Pressable', getComponent: () => diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index 342a579a0..293096742 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -43,6 +43,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, }, + { + key: 'AlphaSelectChip', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, + }, { key: 'AlphaTabbedChips', getComponent: () => @@ -466,6 +471,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/illustrations/__stories__/Pictogram.stories').default, }, + { + key: 'PolarChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/PolarChart.stories').default, + }, { key: 'Pressable', getComponent: () => @@ -539,7 +549,7 @@ export const routes = [ { key: 'SelectChip', getComponent: () => - require('@coinbase/cds-mobile/alpha/select-chip/__stories__/SelectChip.stories').default, + require('@coinbase/cds-mobile/chips/__stories__/SelectChip.stories').default, }, { key: 'SelectOption', diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index 1cce17197..1d3e957d2 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -11,20 +11,20 @@ import { convertToSerializableScale, type SerializableScale } from './utils/scal import { useChartContextBridge } from './ChartContextBridge'; import { CartesianChartProvider } from './ChartProvider'; import { - type AxisConfig, - type AxisConfigProps, + type CartesianAxisConfig, + type CartesianAxisConfigProps, type CartesianChartContextValue, + type CartesianSeries, type ChartInset, type ChartScaleFunction, defaultAxisId, defaultChartInset, - getAxisConfig, - getAxisDomain, getAxisRange, - getAxisScale, + getCartesianAxisConfig, + getCartesianAxisDomain, + getCartesianAxisScale, + getCartesianStackedSeriesData as calculateStackedSeriesData, getChartInset, - getStackedSeriesData as calculateStackedSeriesData, - type Series, useTotalAxisPadding, } from './utils'; @@ -46,7 +46,7 @@ export type CartesianChartBaseProps = Omit & * Configuration objects that define how to visualize the data. * Each series contains its own data array. */ - series?: Array; + series?: Array; /** * Whether to animate the chart. * @default true @@ -55,11 +55,11 @@ export type CartesianChartBaseProps = Omit & /** * Configuration for x-axis. */ - xAxis?: Partial>; + xAxis?: Partial>; /** * Configuration for y-axis(es). Can be a single config or array of configs. */ - yAxis?: Partial | Partial[]; + yAxis?: Partial | Partial[]; /** * Inset around the entire chart (outside the axes). */ @@ -135,8 +135,14 @@ export const CartesianChart = memo( const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); // there can only be one x axis but the helper function always returns an array - const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp)[0], [xAxisConfigProp]); - const yAxisConfig = useMemo(() => getAxisConfig('y', yAxisConfigProp), [yAxisConfigProp]); + const xAxisConfig = useMemo( + () => getCartesianAxisConfig('x', xAxisConfigProp)[0], + [xAxisConfigProp], + ); + const yAxisConfig = useMemo( + () => getCartesianAxisConfig('y', yAxisConfigProp), + [yAxisConfigProp], + ); const { renderedAxes, registerAxis, unregisterAxis, axisPadding } = useTotalAxisPadding(); @@ -168,10 +174,10 @@ export const CartesianChart = memo( if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) return { xAxis: undefined, xScale: undefined }; - const domain = getAxisDomain(xAxisConfig, series ?? [], 'x'); + const domain = getCartesianAxisDomain(xAxisConfig, series ?? [], 'x'); const range = getAxisRange(xAxisConfig, chartRect, 'x'); - const axisConfig: AxisConfig = { + const axisConfig: CartesianAxisConfig = { scaleType: xAxisConfig.scaleType, domain, range, @@ -181,7 +187,7 @@ export const CartesianChart = memo( }; // Create the scale - const scale = getAxisScale({ + const scale = getCartesianAxisScale({ config: axisConfig, type: 'x', range: axisConfig.range, @@ -211,7 +217,7 @@ export const CartesianChart = memo( }, [xScale]); const { yAxes, yScales } = useMemo(() => { - const axes = new Map(); + const axes = new Map(); const scales = new Map(); if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) return { yAxes: axes, yScales: scales }; @@ -224,20 +230,20 @@ export const CartesianChart = memo( series?.filter((s) => (s.yAxisId ?? defaultAxisId) === axisId) ?? []; // Calculate domain and range - const dataDomain = getAxisDomain(axisParam, relevantSeries, 'y'); + const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'y'); const range = getAxisRange(axisParam, chartRect, 'y'); - const axisConfig: AxisConfig = { + const axisConfig: CartesianAxisConfig = { scaleType: axisParam.scaleType, domain: dataDomain, range, data: axisParam.data, categoryPadding: axisParam.categoryPadding, - domainLimit: axisParam.domainLimit ?? 'nice', + domainLimit: axisParam.domainLimit, }; // Create the scale - const scale = getAxisScale({ + const scale = getCartesianAxisScale({ config: axisConfig, type: 'y', range: axisConfig.range, diff --git a/packages/mobile-visualization/src/chart/ChartProvider.tsx b/packages/mobile-visualization/src/chart/ChartProvider.tsx index 4d255c885..5a5ab1910 100644 --- a/packages/mobile-visualization/src/chart/ChartProvider.tsx +++ b/packages/mobile-visualization/src/chart/ChartProvider.tsx @@ -1,9 +1,18 @@ import { createContext, useContext } from 'react'; -import type { CartesianChartContextValue } from './utils'; +import type { + CartesianChartContextValue, + ChartContextValue, + PolarChartContextValue, +} from './utils'; const CartesianChartContext = createContext(undefined); +const PolarChartContext = createContext(undefined); +/** + * Hook to access the CartesianChart context. + * Must be used within a PolarChart component. + */ export const useCartesianChartContext = (): CartesianChartContextValue => { const context = useContext(CartesianChartContext); if (!context) { @@ -15,3 +24,40 @@ export const useCartesianChartContext = (): CartesianChartContextValue => { }; export const CartesianChartProvider = CartesianChartContext.Provider; + +/** + * Hook to access the PolarChart context. + * Must be used within a PolarChart component. + */ +export const usePolarChartContext = (): PolarChartContextValue => { + const context = useContext(PolarChartContext); + if (!context) { + throw new Error( + 'usePolarChartContext must be used within a PolarChart component. See http://cds.coinbase.com/components/graphs/PolarChart.', + ); + } + return context; +}; + +export const PolarChartProvider = PolarChartContext.Provider; + +/** + * Hook to access chart context. + * Use this for components that need to work in any chart type (e.g., ChartText). + * + * @example + * const { width, height, fontProvider } = useChartContext(); + */ +export const useChartContext = (): ChartContextValue => { + const cartesian = useContext(CartesianChartContext); + const polar = useContext(PolarChartContext); + + const context = cartesian ?? polar; + if (!context) { + throw new Error( + 'useChartContext must be used within a Chart component (CartesianChart or PolarChart).', + ); + } + + return context; +}; diff --git a/packages/mobile-visualization/src/chart/DonutChart.tsx b/packages/mobile-visualization/src/chart/DonutChart.tsx new file mode 100644 index 000000000..3a0cff462 --- /dev/null +++ b/packages/mobile-visualization/src/chart/DonutChart.tsx @@ -0,0 +1,96 @@ +import { forwardRef, memo, useMemo } from 'react'; +import type { View } from 'react-native'; + +import { PiePlot, type PiePlotProps, type PieSeries } from './pie'; +import { PolarChart, type PolarChartBaseProps, type PolarChartProps } from './PolarChart'; + +/** + * Series type for DonutChart + */ +export type DonutSeries = PieSeries; + +export type DonutChartBaseProps = Omit & + Pick & { + /** + * Array of series, where each series represents one slice. + * Each series must have a single numeric value. + */ + series?: DonutSeries[]; + /** + * Inner radius as a ratio of the outer radius (0-1). + * This sets the default radial axis to: `range: ({ max }) => ({ min: max * innerRadiusRatio, max })` + * + * @note if you provide a custom `radialAxis` prop, this will be ignored. + * @default 0.5 + */ + innerRadiusRatio?: number; + }; + +export type DonutChartProps = DonutChartBaseProps & Omit; + +/** + * A donut chart component for visualizing proportional data with a hollow center. + * Each series represents one slice, with its value as a proportion of the total. + * The hollow center can be used for displaying additional information. + * + * @example + * ```tsx + * + * ``` + */ +export const DonutChart = memo( + forwardRef( + ( + { + series, + children, + innerRadiusRatio = 0.5, + ArcComponent, + fillOpacity, + stroke, + strokeWidth, + cornerRadius, + radialAxis, + ...chartProps + }, + ref, + ) => { + const defaultRadialAxis = useMemo( + () => ({ + range: ({ max }: { min: number; max: number }) => ({ + min: max * innerRadiusRatio, + max, + }), + }), + [innerRadiusRatio], + ); + + return ( + + + {children} + + ); + }, + ), +); diff --git a/packages/mobile-visualization/src/chart/Path.tsx b/packages/mobile-visualization/src/chart/Path.tsx index d00da6255..3b6bcfeaf 100644 --- a/packages/mobile-visualization/src/chart/Path.tsx +++ b/packages/mobile-visualization/src/chart/Path.tsx @@ -12,7 +12,7 @@ import { import type { Transition } from './utils/transition'; import { usePathTransition } from './utils/transition'; -import { useCartesianChartContext } from './ChartProvider'; +import { useChartContext } from './ChartProvider'; import { unwrapAnimatedValue } from './utils'; /** @@ -191,7 +191,7 @@ export const Path = memo((props) => { ...pathProps } = props; - const context = useCartesianChartContext(); + const context = useChartContext(); const rect = clipRect ?? context.drawingArea; const animate = animateProp ?? context.animate; diff --git a/packages/mobile-visualization/src/chart/PolarChart.tsx b/packages/mobile-visualization/src/chart/PolarChart.tsx new file mode 100644 index 000000000..b87aea4a1 --- /dev/null +++ b/packages/mobile-visualization/src/chart/PolarChart.tsx @@ -0,0 +1,437 @@ +import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import { type StyleProp, type View, type ViewStyle } from 'react-native'; +import type { Rect } from '@coinbase/cds-common/types'; +import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout'; +import type { BoxBaseProps, BoxProps } from '@coinbase/cds-mobile/layout'; +import { Box } from '@coinbase/cds-mobile/layout'; +import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; + +import { convertToSerializableScale, type SerializableScale } from './utils/scale'; +import { useChartContextBridge } from './ChartContextBridge'; +import { PolarChartProvider } from './ChartProvider'; +import { + type AngularAxisConfig, + type AngularAxisConfigProps, + type ChartInset, + type ChartScaleFunction, + defaultAxisId, + defaultChartInset, + getAngularAxisConfig, + getChartInset, + getPolarAxisDomain, + getPolarAxisRange, + getPolarAxisScale, + getRadialAxisConfig, + type PolarChartContextValue, + type PolarSeries, + type RadialAxisConfig, + type RadialAxisConfigProps, +} from './utils'; + +const ChartCanvas = memo( + ({ children, style }: { children: React.ReactNode; style?: StyleProp }) => { + const ContextBridge = useChartContextBridge(); + + return ( + + {children} + + ); + }, +); + +export type PolarChartBaseProps = Omit & { + /** + * Configuration object that defines the data to visualize. + */ + series?: PolarSeries[]; + /** + * Whether to animate the chart. + * @default true + */ + animate?: boolean; + /** + * Configuration for angular axis/axes (controls start/end angles). + * Can be a single axis config or an array of axis configs for multiple angular ranges. + * Default range: { min: 0, max: 360 } (full circle) + * + * @example + * Single axis (default): + * ```tsx + * // Semicircle + * + * + * // Add padding between slices + * + * ``` + * + * @example + * Multiple axes: + * ```tsx + * + * ``` + */ + angularAxis?: Partial | Partial[]; + /** + * Configuration for radial axis/axes (controls inner/outer radii). + * Can be a single axis config or an array of axis configs for multiple radial ranges. + * Default range: { min: 0, max: [radius in pixels] } (pie chart using full radius) + * + * @example + * Single axis (default): + * ```tsx + * // Donut chart with 50% inner radius + * ({ min: max * 0.5, max }) }} /> + * ``` + * + * @example + * Multiple axes (nested rings): + * ```tsx + * ({ min: 0, max: max * 0.4 }) }, + * { id: 'outer', range: ({ max }) => ({ min: max * 0.6, max }) }, + * ]} + * series={[ + * { id: 'innerData', data: [...], radialAxisId: 'inner' }, + * { id: 'outerData', data: [...], radialAxisId: 'outer' }, + * ]} + * /> + * ``` + */ + radialAxis?: Partial | Partial[]; + /** + * Inset around the entire chart (outside the drawing area). + */ + inset?: number | Partial; +}; + +export type PolarChartProps = PolarChartBaseProps & + Omit & { + /** + * Default font families to use within ChartText. + * If not provided, will be the default for the system. + * @example + * ['Helvetica', 'sans-serif'] + */ + fontFamilies?: string[]; + /** + * Skia font provider to allow for custom fonts. + * If not provided, the only available fonts will be those defined by the system. + */ + fontProvider?: SkTypefaceFontProvider; + /** + * Custom styles for the root element. + */ + style?: StyleProp; + /** + * Custom styles for the component. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: StyleProp; + /** + * Custom styles for the chart canvas element. + */ + chart?: StyleProp; + }; + }; + +/** + * Base component for polar coordinate charts (pie, donut). + * Provides context and layout for polar chart child components. + */ +export const PolarChart = memo( + forwardRef( + ( + { + series, + children, + animate = true, + angularAxis, + radialAxis, + inset: insetInput, + width = '100%', + height = '100%', + style, + styles, + fontFamilies, + fontProvider: fontProviderProp, + // React Native will collapse views by default when only used + // to group children, which interferes with gesture-handler + // https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/gesture-detector/#:~:text=%7B%0A%20%20return%20%3C-,View,-collapsable%3D%7B + collapsable = false, + ...props + }, + ref, + ) => { + const [containerLayout, onContainerLayout] = useLayout(); + + const chartWidth = containerLayout.width; + const chartHeight = containerLayout.height; + + const inset = useMemo(() => { + return getChartInset(insetInput, defaultChartInset); + }, [insetInput]); + + // Normalize axis configs (same pattern as CartesianChart) + const angularAxisConfig = useMemo(() => getAngularAxisConfig(angularAxis), [angularAxis]); + const radialAxisConfig = useMemo(() => getRadialAxisConfig(radialAxis), [radialAxis]); + + // Calculate drawing area - always square for polar charts + const drawingArea: Rect = useMemo(() => { + if (chartWidth <= 0 || chartHeight <= 0) return { x: 0, y: 0, width: 0, height: 0 }; + + const availableWidth = chartWidth - inset.left - inset.right; + const availableHeight = chartHeight - inset.top - inset.bottom; + + // Use the smaller dimension to create a square drawing area + const size = Math.min( + availableWidth > 0 ? availableWidth : 0, + availableHeight > 0 ? availableHeight : 0, + ); + + // Center the square drawing area within the available space + const offsetX = (availableWidth - size) / 2; + const offsetY = (availableHeight - size) / 2; + + return { + x: inset.left + offsetX, + y: inset.top + offsetY, + width: size, + height: size, + }; + }, [chartWidth, chartHeight, inset]); + + const outerRadius = Math.min(drawingArea.width, drawingArea.height) / 2; + + const getSeries = useCallback( + (seriesId?: string) => series?.find((s) => s.id === seriesId), + [series], + ); + + const getSeriesData = useCallback( + (seriesId?: string) => series?.find((s) => s.id === seriesId)?.data, + [series], + ); + + const { angularAxes, angularScales } = useMemo(() => { + const axes = new Map(); + const scales = new Map(); + + if (drawingArea.width <= 0 || drawingArea.height <= 0) + return { angularAxes: axes, angularScales: scales }; + + angularAxisConfig.forEach((axisParam) => { + const axisId = axisParam.id ?? defaultAxisId; + + const relevantSeries = + series?.filter((s) => (s.angularAxisId ?? defaultAxisId) === axisId) ?? []; + + const domain = getPolarAxisDomain(axisParam, relevantSeries, 'angular'); + const range = getPolarAxisRange(axisParam, 'angular', outerRadius); + + const axisConfig: AngularAxisConfig = { + scaleType: axisParam.scaleType ?? 'linear', + domain, + range, + paddingAngle: axisParam.paddingAngle, + }; + + const scale = getPolarAxisScale({ + config: axisConfig, + range: axisConfig.range, + dataDomain: axisConfig.domain, + }); + + if (scale) { + scales.set(axisId, scale); + axes.set(axisId, axisConfig); + } + }); + + return { angularAxes: axes, angularScales: scales }; + }, [angularAxisConfig, series, drawingArea, outerRadius]); + + const angularSerializableScales = useMemo(() => { + const serializableScales = new Map(); + angularScales.forEach((scale, id) => { + const serializableScale = convertToSerializableScale(scale); + if (serializableScale) { + serializableScales.set(id, serializableScale); + } + }); + return serializableScales; + }, [angularScales]); + + const { radialAxes, radialScales } = useMemo(() => { + const axes = new Map(); + const scales = new Map(); + + if (drawingArea.width <= 0 || drawingArea.height <= 0 || outerRadius <= 0) + return { radialAxes: axes, radialScales: scales }; + + radialAxisConfig.forEach((axisParam) => { + const axisId = axisParam.id ?? defaultAxisId; + + const relevantSeries = + series?.filter((s) => (s.radialAxisId ?? defaultAxisId) === axisId) ?? []; + + const domain = getPolarAxisDomain(axisParam, relevantSeries, 'radial'); + const range = getPolarAxisRange(axisParam, 'radial', outerRadius); + + const axisConfig: RadialAxisConfig = { + scaleType: axisParam.scaleType ?? 'linear', + domain, + range, + }; + + const scale = getPolarAxisScale({ + config: axisConfig, + range: axisConfig.range, + dataDomain: axisConfig.domain, + }); + + if (scale) { + scales.set(axisId, scale); + + const scaleDomain = scale.domain(); + const actualDomain = + Array.isArray(scaleDomain) && scaleDomain.length === 2 + ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } + : axisConfig.domain; + + axes.set(axisId, { ...axisConfig, domain: actualDomain }); + } + }); + + return { radialAxes: axes, radialScales: scales }; + }, [radialAxisConfig, series, drawingArea, outerRadius]); + + const radialSerializableScales = useMemo(() => { + const serializableScales = new Map(); + radialScales.forEach((scale, id) => { + const serializableScale = convertToSerializableScale(scale); + if (serializableScale) { + serializableScales.set(id, serializableScale); + } + }); + return serializableScales; + }, [radialScales]); + + const getAngularAxis = useCallback( + (id?: string) => angularAxes.get(id ?? defaultAxisId), + [angularAxes], + ); + const getRadialAxis = useCallback( + (id?: string) => radialAxes.get(id ?? defaultAxisId), + [radialAxes], + ); + const getAngularScale = useCallback( + (id?: string) => angularScales.get(id ?? defaultAxisId), + [angularScales], + ); + const getRadialScale = useCallback( + (id?: string) => radialScales.get(id ?? defaultAxisId), + [radialScales], + ); + const getAngularSerializableScale = useCallback( + (id?: string) => angularSerializableScales.get(id ?? defaultAxisId), + [angularSerializableScales], + ); + const getRadialSerializableScale = useCallback( + (id?: string) => radialSerializableScales.get(id ?? defaultAxisId), + [radialSerializableScales], + ); + + const dataLength = useMemo(() => { + if (!series || series.length === 0) return 0; + const firstSeriesData = series[0].data; + if (typeof firstSeriesData === 'number') return series.length; + if (Array.isArray(firstSeriesData)) { + return Math.max(...series.map((s) => (Array.isArray(s.data) ? s.data.length : 0))); + } + return 0; + }, [series]); + + const fontProvider = useMemo(() => { + if (fontProviderProp) return fontProviderProp; + return Skia.TypefaceFontProvider.Make(); + }, [fontProviderProp]); + + const contextValue: PolarChartContextValue = useMemo( + () => ({ + series: series ?? [], + getSeries, + getSeriesData, + animate, + width: chartWidth, + height: chartHeight, + fontFamilies, + fontProvider, + drawingArea, + outerRadius, + getAngularAxis, + getRadialAxis, + getAngularScale, + getRadialScale, + getAngularSerializableScale, + getRadialSerializableScale, + dataLength, + }), + [ + series, + getSeries, + getSeriesData, + animate, + chartWidth, + chartHeight, + fontFamilies, + fontProvider, + drawingArea, + outerRadius, + getAngularAxis, + getRadialAxis, + getAngularScale, + getRadialScale, + getAngularSerializableScale, + getRadialSerializableScale, + dataLength, + ], + ); + + const rootStyles = useMemo(() => { + return [style, styles?.root]; + }, [style, styles?.root]); + + return ( + + + {children} + + + ); + }, + ), +); diff --git a/packages/mobile-visualization/src/chart/__stories__/PolarChart.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/PolarChart.stories.tsx new file mode 100644 index 000000000..03e6d036d --- /dev/null +++ b/packages/mobile-visualization/src/chart/__stories__/PolarChart.stories.tsx @@ -0,0 +1,870 @@ +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { useTheme } from '@coinbase/cds-mobile'; +import { IconButton } from '@coinbase/cds-mobile/buttons'; +import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; +import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; +import { TextLabel1 } from '@coinbase/cds-mobile/typography'; +import { Text } from '@coinbase/cds-mobile/typography/Text'; +import { Rect } from '@shopify/react-native-skia'; + +import { usePolarChartContext } from '../ChartProvider'; +import { DonutChart } from '../DonutChart'; +import { Arc, PieChart, PiePlot } from '../pie'; +import { PolarChart } from '../PolarChart'; +import { ChartText, type ChartTextProps } from '../text'; +import { getArcPath } from '../utils'; + +const DonutCenterLabel = memo>(({ children, ...props }) => { + const { drawingArea } = usePolarChartContext(); + + if (drawingArea.width <= 0 || drawingArea.height <= 0) return; + + const centerX = drawingArea.x + drawingArea.width / 2; + const centerY = drawingArea.y + drawingArea.height / 2; + + return ( + + {children} + + ); +}); + +const RewardsBackgroundArcs = memo<{ + innerRadiusRatio: number; + startAngleDegrees: number; + firstSectionEnd: number; + secondSectionStart: number; + secondSectionEnd: number; + thirdSectionStart: number; + thirdSectionEnd: number; +}>( + ({ + innerRadiusRatio, + startAngleDegrees, + firstSectionEnd, + secondSectionStart, + secondSectionEnd, + thirdSectionStart, + thirdSectionEnd, + }) => { + const theme = useTheme(); + const { drawingArea } = usePolarChartContext(); + + const { innerRadius, outerRadius } = useMemo(() => { + const r = Math.min(drawingArea.width, drawingArea.height) / 2; + return { + innerRadius: r * innerRadiusRatio, + outerRadius: r, + }; + }, [drawingArea, innerRadiusRatio]); + + const sections = useMemo( + () => [ + { + startAngle: (startAngleDegrees * Math.PI) / 180, + endAngle: (firstSectionEnd * Math.PI) / 180, + }, + { + startAngle: (secondSectionStart * Math.PI) / 180, + endAngle: (secondSectionEnd * Math.PI) / 180, + }, + { + startAngle: (thirdSectionStart * Math.PI) / 180, + endAngle: (thirdSectionEnd * Math.PI) / 180, + }, + ], + [ + startAngleDegrees, + firstSectionEnd, + secondSectionStart, + secondSectionEnd, + thirdSectionStart, + thirdSectionEnd, + ], + ); + + return ( + <> + {sections.map((section, i) => ( + + ))} + + ); + }, +); + +const RewardsClippedProgress = memo<{ + innerRadiusRatio: number; + startAngleDegrees: number; + firstSectionEnd: number; + secondSectionStart: number; + secondSectionEnd: number; + thirdSectionStart: number; + thirdSectionEnd: number; +}>( + ({ + innerRadiusRatio, + startAngleDegrees, + firstSectionEnd, + secondSectionStart, + secondSectionEnd, + thirdSectionStart, + thirdSectionEnd, + }) => { + const { drawingArea } = usePolarChartContext(); + + const clipPath = useMemo(() => { + const r = Math.min(drawingArea.width, drawingArea.height) / 2; + const innerRadius = r * innerRadiusRatio; + const outerRadius = r; + + const sections = [ + { + startAngle: (startAngleDegrees * Math.PI) / 180, + endAngle: (firstSectionEnd * Math.PI) / 180, + }, + { + startAngle: (secondSectionStart * Math.PI) / 180, + endAngle: (secondSectionEnd * Math.PI) / 180, + }, + { + startAngle: (thirdSectionStart * Math.PI) / 180, + endAngle: (thirdSectionEnd * Math.PI) / 180, + }, + ]; + + return sections + .map((section) => + getArcPath({ + startAngle: section.startAngle, + endAngle: section.endAngle, + innerRadius, + outerRadius, + cornerRadius: 100, + }), + ) + .join(' '); + }, [ + drawingArea, + innerRadiusRatio, + startAngleDegrees, + firstSectionEnd, + secondSectionStart, + secondSectionEnd, + thirdSectionStart, + thirdSectionEnd, + ]); + + return ; + }, +); + +const VariableRadiusPieArcs = memo(() => { + const theme = useTheme(); + const { drawingArea } = usePolarChartContext(); + + const data = useMemo( + () => [ + { id: 'a', value: 2548, color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', value: 1754, color: `rgb(${theme.spectrum.purple40})` }, + { id: 'c', value: 390, color: `rgb(${theme.spectrum.orange40})` }, + { id: 'd', value: 250, color: `rgb(${theme.spectrum.green40})` }, + { id: 'e', value: 280, color: `rgb(${theme.spectrum.teal40})` }, + ], + [theme], + ); + + const maxRadius = Math.min(drawingArea.width, drawingArea.height) / 2; + const minRadius = maxRadius * 0.5; + const maxValue = Math.max(...data.map((d) => d.value)); + const total = data.reduce((sum, d) => sum + d.value, 0); + + const arcs = useMemo(() => { + let currentAngle = 0; // Start at 3 o'clock (0°) + return data.map((d) => { + const angleSpan = (d.value / total) * 2 * Math.PI; + const startAngle = currentAngle; + const endAngle = currentAngle + angleSpan; + currentAngle = endAngle; + + const outerRadius = minRadius + (d.value / maxValue) * (maxRadius - minRadius); + + return { + startAngle, + endAngle, + innerRadius: 0, + outerRadius, + paddingAngle: 0, + id: d.id, + color: d.color, + }; + }); + }, [data, total, maxValue, minRadius, maxRadius]); + + return ( + <> + {arcs.map((arc) => ( + + ))} + + ); +}); + +function MyCustomDrawingArea() { + const theme = useTheme(); + const { drawingArea } = usePolarChartContext(); + + return ( + + ); +} + +const BasicPieChart = () => { + const theme = useTheme(); + + return ( + + ); +}; + +const BasicDonutChart = () => { + const theme = useTheme(); + + return ( + + ); +}; + +const DonutWithCenterLabel = () => { + const theme = useTheme(); + + return ( + ({ min: max * 0.7, max }) }} + series={[ + { id: 'teal', data: 10, label: 'Other', color: `rgb(${theme.spectrum.teal40})` }, + { id: 'blue', data: 25, label: 'Bitcoin', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'purple', data: 20, label: 'Ethereum', color: `rgb(${theme.spectrum.purple40})` }, + { id: 'pink', data: 15, label: 'Solana', color: `rgb(${theme.spectrum.pink40})` }, + { id: 'orange', data: 15, label: 'Solana', color: `rgb(${theme.spectrum.orange40})` }, + { id: 'yellow', data: 12, label: 'USDC', color: `rgb(${theme.spectrum.yellow40})` }, + { id: 'green', data: 8, label: 'Doge', color: `rgb(${theme.spectrum.green40})` }, + ]} + width={200} + > + + + label + + + $9,999.99 + + + ); +}; + +const SemicircleChart = () => { + const theme = useTheme(); + + return ( + + ); +}; + +const VariableRadiusPieChart = () => ( + + + +); + +const PaddingAngleChart = () => { + const theme = useTheme(); + + return ( + + + + ); +}; + +const CornerRadiusChart = () => { + const theme = useTheme(); + + return ( + + + + + + + + + ); +}; + +const NestedRingsChart = () => { + const theme = useTheme(); + + return ( + ({ min: 0, max: max - 20 }) }, + { id: 'outer', range: ({ max }) => ({ min: max - 20, max }) }, + ]} + series={[ + { + id: 'crypto', + data: 35, + label: 'Crypto', + color: `rgb(${theme.spectrum.blue40})`, + radialAxisId: 'inner', + }, + { + id: 'fiat', + data: 45, + label: 'Fiat', + color: `rgb(${theme.spectrum.green40})`, + radialAxisId: 'inner', + }, + { + id: 'rewards', + data: 20, + label: 'Rewards', + color: `rgb(${theme.spectrum.orange40})`, + radialAxisId: 'inner', + }, + { + id: 'btc', + data: 15, + label: 'Bitcoin', + color: `rgb(${theme.spectrum.blue30})`, + radialAxisId: 'outer', + }, + { + id: 'eth', + data: 12, + label: 'Ethereum', + color: `rgb(${theme.spectrum.blue20})`, + radialAxisId: 'outer', + }, + { + id: 'other-crypto', + data: 8, + label: 'Other', + color: `rgb(${theme.spectrum.blue10})`, + radialAxisId: 'outer', + }, + { + id: 'usd', + data: 30, + label: 'USD', + color: `rgb(${theme.spectrum.green30})`, + radialAxisId: 'outer', + }, + { + id: 'eur', + data: 15, + label: 'EUR', + color: `rgb(${theme.spectrum.green20})`, + radialAxisId: 'outer', + }, + { + id: 'cashback', + data: 12, + label: 'Cash Back', + color: `rgb(${theme.spectrum.orange30})`, + radialAxisId: 'outer', + }, + { + id: 'points', + data: 8, + label: 'Points', + color: `rgb(${theme.spectrum.orange20})`, + radialAxisId: 'outer', + }, + ]} + width={200} + > + + + + ); +}; + +const MultiAxisSemicircles = () => { + const theme = useTheme(); + + return ( + + + + + ); +}; + +const QuadrantChart = () => { + const theme = useTheme(); + + return ( + + + + + + + ); +}; + +const CoinbaseOneRewardsChart = () => { + const theme = useTheme(); + + const innerRadiusRatio = 0.75; + const angleEachSideGap = (45 / 4) * 3; + const startAngleDegrees = angleEachSideGap - 180; + const endAngleDegrees = 180 - angleEachSideGap; + const angleGapDegrees = 5; + const totalGapDegrees = angleGapDegrees * 2; + const gapBetweenDegrees = totalGapDegrees / 3; + const sectionLengthDegrees = (endAngleDegrees - startAngleDegrees) / 3 - gapBetweenDegrees; + + const firstSectionEnd = startAngleDegrees + sectionLengthDegrees; + const secondSectionStart = firstSectionEnd + gapBetweenDegrees; + const secondSectionEnd = secondSectionStart + sectionLengthDegrees; + const thirdSectionStart = secondSectionEnd + gapBetweenDegrees; + const thirdSectionEnd = thirdSectionStart + sectionLengthDegrees; + const progressAngle = -45; + + return ( + ({ min: innerRadiusRatio * max, max }) }} + series={[{ id: 'progress', data: 100, label: 'Progress', color: theme.color.fg }]} + width={200} + > + + + + ); +}; + +const AnimatedDataChange = () => { + const theme = useTheme(); + const [dataSet, setDataSet] = useState(0); + + const dataSets = useMemo( + () => [ + [ + { id: 'a', data: 30, label: 'A', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 40, label: 'B', color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 30, label: 'C', color: `rgb(${theme.spectrum.orange40})` }, + ], + [ + { id: 'a', data: 60, label: 'A', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 20, label: 'B', color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 20, label: 'C', color: `rgb(${theme.spectrum.orange40})` }, + ], + [ + { id: 'a', data: 15, label: 'A', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 55, label: 'B', color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 30, label: 'C', color: `rgb(${theme.spectrum.orange40})` }, + ], + ], + [theme], + ); + + return ( + + + + setDataSet((prev) => (prev - 1 + dataSets.length) % dataSets.length)} + variant="secondary" + /> + Data Set {dataSet + 1} + setDataSet((prev) => (prev + 1) % dataSets.length)} + variant="secondary" + /> + + + ); +}; + +const WalletBreakdownDonut = () => { + const theme = useTheme(); + + return ( + + ); +}; + +type ExampleItem = { + title: string; + component: React.ReactNode; +}; + +function ExampleNavigator() { + const [currentIndex, setCurrentIndex] = useState(0); + + const examples = useMemo( + () => [ + { title: 'Basic Pie Chart', component: }, + { title: 'Basic Donut Chart', component: }, + { title: 'Donut with Center Label', component: }, + { title: 'Animated Data Change', component: }, + { title: 'Wallet Breakdown', component: }, + { title: 'Semicircle', component: }, + { title: 'Variable Radius', component: }, + { title: 'Padding Angle', component: }, + { title: 'Corner Radius', component: }, + { title: 'Nested Rings', component: }, + { title: 'Multi-Axis Semicircles', component: }, + { title: 'Quadrants', component: }, + { title: 'Coinbase One Rewards', component: }, + ], + [], + ); + + const currentExample = examples[currentIndex]; + const isFirstExample = currentIndex === 0; + const isLastExample = currentIndex === examples.length - 1; + + const handlePrevious = useCallback(() => { + setCurrentIndex((prev) => Math.max(0, prev - 1)); + }, []); + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => Math.min(examples.length - 1, prev + 1)); + }, [examples.length]); + + return ( + + + + + + {currentExample.title} + + {currentIndex + 1} / {examples.length} + + + + + + {currentExample.component} + + + + ); +} + +export default ExampleNavigator; diff --git a/packages/mobile-visualization/src/chart/area/AreaChart.tsx b/packages/mobile-visualization/src/chart/area/AreaChart.tsx index c79187070..a3ac317bb 100644 --- a/packages/mobile-visualization/src/chart/area/AreaChart.tsx +++ b/packages/mobile-visualization/src/chart/area/AreaChart.tsx @@ -9,16 +9,16 @@ import { } from '../CartesianChart'; import { Line, type LineProps } from '../line/Line'; import { - type AxisConfigProps, + type CartesianAxisConfigProps, + type CartesianSeries, defaultChartInset, defaultStackId, getChartInset, - type Series, } from '../utils'; import { Area, type AreaProps } from './Area'; -export type AreaSeries = Series & +export type AreaSeries = CartesianSeries & Partial< Pick< AreaProps, @@ -77,13 +77,13 @@ export type AreaChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type AreaChartProps = AreaChartBaseProps & @@ -117,10 +117,10 @@ export const AreaChart = memo( ) => { const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); - // Convert AreaSeries to Series for Chart context + // Convert AreaSeries to CartesianSeries for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, @@ -160,7 +160,7 @@ export const AreaChart = memo( ...yAxisVisualProps } = yAxis || {}; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType, data: xData, categoryPadding: xCategoryPadding, @@ -181,7 +181,7 @@ export const AreaChart = memo( }, [series]); // Set default min domain to 0 for area chart, but only if there are no negative values - const yAxisConfig: Partial = { + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, diff --git a/packages/mobile-visualization/src/chart/bar/BarChart.tsx b/packages/mobile-visualization/src/chart/bar/BarChart.tsx index 73da40dd9..a47211d11 100644 --- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx @@ -8,11 +8,11 @@ import { type CartesianChartProps, } from '../CartesianChart'; import { - type AxisConfigProps, + type CartesianAxisConfigProps, + type CartesianSeries, defaultChartInset, defaultStackId, getChartInset, - type Series, } from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; @@ -36,7 +36,7 @@ export type BarChartBaseProps = Omit; + series?: Array; /** * Whether to stack the areas on top of each other. * When true, each series builds cumulative values on top of the previous series. @@ -59,13 +59,13 @@ export type BarChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type BarChartProps = BarChartBaseProps & @@ -132,7 +132,7 @@ export const BarChart = memo( ...yAxisVisualProps } = yAxis || {}; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType ?? 'band', data: xData, categoryPadding: xCategoryPadding, @@ -152,8 +152,8 @@ export const BarChart = memo( ); }, [series]); - // Set default min domain to 0 for area chart, but only if there are no negative values - const yAxisConfig: Partial = { + // Set default min domain to 0 for bar chart, but only if there are no negative values + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile-visualization/src/chart/bar/BarStack.tsx index 53aa12100..e5698e490 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx @@ -3,7 +3,7 @@ import type { Rect } from '@coinbase/cds-common'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; -import type { ChartScaleFunction, Series, Transition } from '../utils'; +import type { CartesianSeries, ChartScaleFunction, Transition } from '../utils'; import { evaluateGradientAtValue, getGradientStops } from '../utils/gradient'; import { convertToSerializableScale } from '../utils/scale'; @@ -19,7 +19,7 @@ export type BarStackBaseProps = Pick< /** * Array of series configurations that belong to this stack. */ - series: Series[]; + series: CartesianSeries[]; /** * The category index for this stack. */ diff --git a/packages/mobile-visualization/src/chart/index.ts b/packages/mobile-visualization/src/chart/index.ts index 7042f6699..f2e887573 100644 --- a/packages/mobile-visualization/src/chart/index.ts +++ b/packages/mobile-visualization/src/chart/index.ts @@ -1,16 +1,19 @@ // codegen:start {preset: barrel, include: [./*.tsx, ./*/index.ts]} -export * from './area'; -export * from './axis'; -export * from './bar'; +export * from './area/index'; +export * from './axis/index'; +export * from './bar/index'; export * from './CartesianChart'; export * from './ChartContextBridge'; export * from './ChartProvider'; -export * from './gradient'; -export * from './line'; +export * from './DonutChart'; +export * from './gradient/index'; +export * from './line/index'; export * from './Path'; export * from './PeriodSelector'; -export * from './point'; -export * from './scrubber'; -export * from './text'; -export * from './utils'; +export * from './pie/index'; +export * from './point/index'; +export * from './PolarChart'; +export * from './scrubber/index'; +export * from './text/index'; +export * from './utils/index'; // codegen:end diff --git a/packages/mobile-visualization/src/chart/line/LineChart.tsx b/packages/mobile-visualization/src/chart/line/LineChart.tsx index cf9855892..16b358773 100644 --- a/packages/mobile-visualization/src/chart/line/LineChart.tsx +++ b/packages/mobile-visualization/src/chart/line/LineChart.tsx @@ -8,11 +8,16 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, getChartInset, type Series } from '../utils'; +import { + type CartesianAxisConfigProps, + type CartesianSeries, + defaultChartInset, + getChartInset, +} from '../utils'; import { Line, type LineProps } from './Line'; -export type LineSeries = Series & +export type LineSeries = CartesianSeries & Partial< Pick< LineProps, @@ -67,13 +72,13 @@ export type LineChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type LineChartProps = LineChartBaseProps & @@ -111,7 +116,7 @@ export const LineChart = memo( // Convert LineSeries to Series for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, @@ -144,7 +149,7 @@ export const LineChart = memo( ...yAxisVisualProps } = yAxis || {}; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType, data: xData, categoryPadding: xCategoryPadding, @@ -153,7 +158,7 @@ export const LineChart = memo( range: xRange, }; - const yAxisConfig: Partial = { + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, diff --git a/packages/mobile-visualization/src/chart/pie/Arc.tsx b/packages/mobile-visualization/src/chart/pie/Arc.tsx new file mode 100644 index 000000000..fd5bbaad3 --- /dev/null +++ b/packages/mobile-visualization/src/chart/pie/Arc.tsx @@ -0,0 +1,249 @@ +import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { runOnJS, useAnimatedReaction, useSharedValue, withTiming } from 'react-native-reanimated'; +import { Group, Path as SkiaPath, Skia } from '@shopify/react-native-skia'; + +import { usePolarChartContext } from '../ChartProvider'; +import { defaultAxisId } from '../utils'; +import { getArcPath } from '../utils/path'; +import { degreesToRadians } from '../utils/polar'; + +export type ArcBaseProps = { + /** + * Start angle in radians. + */ + startAngle: number; + /** + * End angle in radians. + */ + endAngle: number; + /** + * Inner radius in pixels (0 for pie chart). + */ + innerRadius: number; + /** + * Outer radius in pixels. + */ + outerRadius: number; + /** + * Padding angle in radians between adjacent arcs. + */ + paddingAngle?: number; + /** + * Fill color for the arc. + */ + fill?: string; + /** + * Fill opacity. + * @default 1 + */ + fillOpacity?: number; + /** + * Stroke color. + */ + stroke?: string; + /** + * Stroke width in pixels. + */ + strokeWidth?: number; + /** + * Corner radius in pixels. + */ + cornerRadius?: number; + /** + * Clip path ID to apply to this arc. + * Note: On mobile, this creates a Skia clip path. + */ + clipPathId?: string; + /** + * ID of the angular axis to use for determining the animation baseline. + * If not provided, uses the default angular axis. + */ + angularAxisId?: string; + /** + * Whether to animate the arc. Overrides the chart-level animate setting. + * If not provided, uses the chart context's animate value. + */ + animate?: boolean; +}; + +export type ArcProps = ArcBaseProps; + +/** + * Renders an arc (slice) in a polar chart. + * Used by PieChart and DonutChart components. + */ +export const Arc = memo( + ({ + startAngle, + endAngle, + innerRadius, + outerRadius, + paddingAngle, + fill, + fillOpacity = 1, + stroke, + strokeWidth, + cornerRadius, + clipPathId, + angularAxisId, + animate: animateProp, + }) => { + const { animate: contextAnimate, drawingArea, getAngularAxis } = usePolarChartContext(); + const animate = animateProp !== undefined ? animateProp : contextAnimate; + + // Get the angular axis to determine the baseline angle + const angularAxis = getAngularAxis(angularAxisId ?? defaultAxisId); + + const baselineAngle = useMemo(() => { + const startDegrees = angularAxis?.range?.min ?? 0; + return degreesToRadians(startDegrees); + }, [angularAxis?.range?.min]); + + const { centerX, centerY } = useMemo( + () => ({ + centerX: drawingArea.x + drawingArea.width / 2, + centerY: drawingArea.y + drawingArea.height / 2, + }), + [drawingArea.x, drawingArea.y, drawingArea.width, drawingArea.height], + ); + + // Track if this arc has completed its initial animation from baseline + const hasInitialAnimationStartedRef = useRef(false); + + const animatedStartAngle = useSharedValue(baselineAngle); + const animatedEndAngle = useSharedValue(baselineAngle); + + const [animatedSvgPath, setAnimatedSvgPath] = useState(() => + getArcPath({ + startAngle: baselineAngle, + endAngle: baselineAngle, + innerRadius, + outerRadius, + cornerRadius, + paddingAngle, + }), + ); + + // JS thread function to compute SVG path + const updateSvgPath = (animStartAngle: number, animEndAngle: number) => { + const svgPath = getArcPath({ + startAngle: animStartAngle, + endAngle: animEndAngle, + innerRadius, + outerRadius, + cornerRadius, + paddingAngle, + }); + setAnimatedSvgPath(svgPath); + }; + + // Watch angle changes and compute SVG path on JS thread + useAnimatedReaction( + () => ({ + start: animatedStartAngle.value, + end: animatedEndAngle.value, + }), + (angles) => { + // Call the function on JS thread + runOnJS(updateSvgPath)(angles.start, angles.end); + }, + [innerRadius, outerRadius, cornerRadius, paddingAngle], + ); + + // Convert SVG path string to Skia Path (can happen on UI thread) + const animatedPath = useMemo(() => { + return Skia.Path.MakeFromSVGString(animatedSvgPath) ?? Skia.Path.Make(); + }, [animatedSvgPath]); + + // Trigger animation when the component mounts or data changes + useEffect(() => { + // Don't start animation until axis is ready (has valid baseline) + if (!angularAxis) return; + + if (animate) { + // Determine the starting point for animation: + // - Initial mount: start from baseline angle (e.g., -90° for semicircle) + // - Data change: start from current animated position (smooth transition) + const isInitialAnimation = !hasInitialAnimationStartedRef.current; + + if (isInitialAnimation) { + // Initial animation: reset to baseline first, then animate to target + animatedStartAngle.value = baselineAngle; + animatedEndAngle.value = baselineAngle; + } + // For data changes, withTiming automatically starts from current value + + animatedStartAngle.value = withTiming(startAngle, { + duration: isInitialAnimation ? 1000 : 500, // Slower for initial, faster for data updates + }); + animatedEndAngle.value = withTiming(endAngle, { + duration: isInitialAnimation ? 1000 : 500, + }); + + // Mark that initial animation has started + hasInitialAnimationStartedRef.current = true; + } else { + animatedStartAngle.value = startAngle; + animatedEndAngle.value = endAngle; + } + }, [ + startAngle, + endAngle, + animate, + animatedStartAngle, + animatedEndAngle, + baselineAngle, + angularAxis, + ]); + + // Static path for non-animated rendering + const staticPath = useMemo(() => { + const svgPath = getArcPath({ + startAngle, + endAngle, + innerRadius, + outerRadius, + cornerRadius, + paddingAngle, + }); + + return Skia.Path.MakeFromSVGString(svgPath) ?? Skia.Path.Make(); + }, [startAngle, endAngle, innerRadius, outerRadius, cornerRadius, paddingAngle]); + + const clipSkiaPath = useMemo(() => { + if (!clipPathId) return; + return Skia.Path.MakeFromSVGString(clipPathId) ?? null; + }, [clipPathId]); + + // Don't render until axis is ready and we have valid radius + if (!angularAxis || outerRadius <= 0) return; + + const path = animate ? animatedPath : staticPath; + const isFilled = fill !== undefined && fill !== 'none'; + const isStroked = stroke !== undefined && stroke !== 'none'; + + const content = ( + <> + {isFilled && } + {isStroked && ( + + )} + + ); + + return ( + + {content} + + ); + }, +); diff --git a/packages/mobile-visualization/src/chart/pie/PieChart.tsx b/packages/mobile-visualization/src/chart/pie/PieChart.tsx new file mode 100644 index 000000000..f08bf7f3f --- /dev/null +++ b/packages/mobile-visualization/src/chart/pie/PieChart.tsx @@ -0,0 +1,77 @@ +import { forwardRef, memo } from 'react'; +import type { View } from 'react-native'; + +import { PolarChart, type PolarChartBaseProps, type PolarChartProps } from '../PolarChart'; +import type { PolarSeries } from '../utils'; + +import { PiePlot, type PiePlotProps } from './PiePlot'; + +/** + * Series type for PieChart - enforces single number data values. + */ +export type PieSeries = Omit & { + /** + * Single numeric value for this slice. + */ + data: number; +}; + +export type PieChartBaseProps = Omit & + Pick & { + /** + * Array of series, where each series represents one slice. + * Each series must have a single numeric value. + */ + series?: PieSeries[]; + }; + +export type PieChartProps = PieChartBaseProps & Omit; + +/** + * A pie chart component for visualizing proportional data. + * Each series represents one slice, with its value as a proportion of the total. + * + * By default, uses the full radius (radialAxis: { range: { min: 0, max: [radius in pixels] } }). + * + * @example + * ```tsx + * + * ``` + */ +export const PieChart = memo( + forwardRef( + ( + { + series = [], + children, + ArcComponent, + fillOpacity, + stroke, + strokeWidth, + cornerRadius, + ...chartProps + }, + ref, + ) => { + return ( + + + {children} + + ); + }, + ), +); diff --git a/packages/mobile-visualization/src/chart/pie/PiePlot.tsx b/packages/mobile-visualization/src/chart/pie/PiePlot.tsx new file mode 100644 index 000000000..cb4ed75af --- /dev/null +++ b/packages/mobile-visualization/src/chart/pie/PiePlot.tsx @@ -0,0 +1,174 @@ +import React, { memo, useMemo } from 'react'; +import { useTheme } from '@coinbase/cds-mobile'; + +import { usePolarChartContext } from '../ChartProvider'; +import { defaultAxisId } from '../utils'; +import { calculateArcData } from '../utils/polar'; + +import { Arc, type ArcBaseProps, type ArcProps } from './Arc'; + +export type PiePlotBaseProps = Pick< + ArcBaseProps, + 'fillOpacity' | 'stroke' | 'strokeWidth' | 'cornerRadius' | 'clipPathId' | 'animate' +> & { + /** + * Array of series IDs to render. + * If not provided, renders all series for the axes. + */ + seriesIds?: string[]; + /** + * ID of the radial axis to filter series by. + * Defaults to the default radial axis. + */ + radialAxisId?: string; + /** + * ID of the angular axis to filter series by. + * Defaults to the default angular axis. + */ + angularAxisId?: string; + /** + * Custom Arc component to use for rendering slices. + */ + ArcComponent?: React.ComponentType; +}; + +export type PiePlotProps = PiePlotBaseProps; + +/** + * PiePlot component that renders arc slices for polar charts. + * Filters series by radialAxisId and angularAxisId, then renders an Arc for each. + */ +export const PiePlot = memo( + ({ + seriesIds, + radialAxisId: radialAxisIdProp, + angularAxisId: angularAxisIdProp, + ArcComponent = Arc, + animate: animateProp, + fillOpacity, + stroke, + strokeWidth = 1, + cornerRadius, + clipPathId, + }) => { + const theme = useTheme(); + const { + series: allSeries, + animate: contextAnimate, + drawingArea, + getAngularAxis, + getRadialAxis, + } = usePolarChartContext(); + + const animate = animateProp ?? contextAnimate; + + const maxRadius = useMemo(() => { + return Math.max(0, Math.min(drawingArea.width, drawingArea.height) / 2); + }, [drawingArea.width, drawingArea.height]); + + const radialAxisId = radialAxisIdProp ?? defaultAxisId; + const angularAxisId = angularAxisIdProp ?? defaultAxisId; + + const targetSeries = useMemo(() => { + // Filter by axis IDs first + const axisFilteredSeries = allSeries.filter( + (s) => + (s.radialAxisId ?? defaultAxisId) === radialAxisId && + (s.angularAxisId ?? defaultAxisId) === angularAxisId, + ); + + // Then filter by seriesIds if provided + if (seriesIds !== undefined) { + return axisFilteredSeries.filter((s) => seriesIds.includes(s.id)); + } + + return axisFilteredSeries; + }, [allSeries, seriesIds, radialAxisId, angularAxisId]); + + const angularAxisConfig = useMemo( + () => getAngularAxis(angularAxisId), + [angularAxisId, getAngularAxis], + ); + + const radialAxisConfig = useMemo( + () => getRadialAxis(radialAxisId), + [radialAxisId, getRadialAxis], + ); + + const { startAngleDegrees, endAngleDegrees, paddingAngleDegrees } = useMemo(() => { + const range = angularAxisConfig?.range ?? { min: 0, max: 360 }; + return { + startAngleDegrees: range.min ?? 0, + endAngleDegrees: range.max ?? 360, + paddingAngleDegrees: angularAxisConfig?.paddingAngle ?? 0, + }; + }, [angularAxisConfig]); + + const { innerRadius, outerRadius } = useMemo(() => { + const range = radialAxisConfig?.range ?? { min: 0, max: maxRadius }; + return { + innerRadius: range.min ?? 0, + outerRadius: range.max ?? maxRadius, + }; + }, [radialAxisConfig, maxRadius]); + + const seriesData = useMemo(() => { + return targetSeries + .map((s) => { + const value = typeof s.data === 'number' ? s.data : s.data[0]; + if (value === null || value === undefined) return null; + return { value, color: s.color, id: s.id, label: s.label }; + }) + .filter((d): d is NonNullable => d !== null); + }, [targetSeries]); + + const arcs = useMemo(() => { + if (!seriesData.length) { + return []; + } + + const values = seriesData.map((d) => d.value); + return calculateArcData( + values, + innerRadius, + outerRadius, + startAngleDegrees, + endAngleDegrees, + paddingAngleDegrees, + ); + }, [ + seriesData, + innerRadius, + outerRadius, + startAngleDegrees, + endAngleDegrees, + paddingAngleDegrees, + ]); + + if (!arcs.length) return; + + return arcs.map((arc) => { + const data = seriesData[arc.index]; + const fill = data.color ?? theme.color.fgPrimary; + + return ( + + ); + }); + }, +); diff --git a/packages/mobile-visualization/src/chart/pie/index.ts b/packages/mobile-visualization/src/chart/pie/index.ts new file mode 100644 index 000000000..e7a18c37c --- /dev/null +++ b/packages/mobile-visualization/src/chart/pie/index.ts @@ -0,0 +1,3 @@ +export * from './Arc'; +export * from './PieChart'; +export * from './PiePlot'; diff --git a/packages/mobile-visualization/src/chart/text/ChartText.tsx b/packages/mobile-visualization/src/chart/text/ChartText.tsx index 4d556750c..bfa7e1845 100644 --- a/packages/mobile-visualization/src/chart/text/ChartText.tsx +++ b/packages/mobile-visualization/src/chart/text/ChartText.tsx @@ -21,7 +21,7 @@ import { type Transforms3d, } from '@shopify/react-native-skia'; -import { useCartesianChartContext } from '../ChartProvider'; +import { useChartContext } from '../ChartProvider'; import { type ChartInset, getChartInset, getColorWithOpacity, unwrapAnimatedValue } from '../utils'; /** @@ -234,7 +234,7 @@ export const ChartText = memo( height: chartHeight, fontFamilies: contextFontFamilies, fontProvider, - } = useCartesianChartContext(); + } = useChartContext(); const inset = useMemo(() => getChartInset(insetInput), [insetInput]); diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts index 0a2a0b4e0..82f9158a3 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts @@ -1,4 +1,17 @@ -import { formatAxisTick, getAxisTicksData } from '../axis'; +import { + formatAxisTick, + getAngularAxisConfig, + getAxisRange, + getAxisTicksData, + getCartesianAxisConfig, + getCartesianAxisDomain, + getCartesianAxisScale, + getPolarAxisDomain, + getPolarAxisRange, + getPolarAxisScale, + getRadialAxisConfig, +} from '../axis'; +import { type CartesianSeries, type PolarSeries } from '../chart'; import { type CategoricalScale, getCategoricalScale, @@ -6,6 +19,459 @@ import { type NumericScale, } from '../scale'; +describe('getCartesianAxisScale', () => { + it('should create a linear scale for x-axis', () => { + const scale = getCartesianAxisScale({ + type: 'x', + range: { min: 0, max: 400 }, + dataDomain: { min: 0, max: 100 }, + }); + + expect(scale(0)).toBe(0); + expect(scale(50)).toBe(200); + expect(scale(100)).toBe(400); + }); + + it('should create an inverted linear scale for y-axis', () => { + const scale = getCartesianAxisScale({ + type: 'y', + range: { min: 0, max: 400 }, + dataDomain: { min: 0, max: 100 }, + }); + + // Y-axis is inverted for SVG coordinates + expect(scale(0)).toBe(400); + expect(scale(100)).toBe(0); + }); + + it('should use config domain when provided', () => { + const scale = getCartesianAxisScale({ + config: { + domain: { min: 10, max: 90 }, + range: { min: 0, max: 400 }, + scaleType: 'linear', + domainLimit: 'strict', + }, + type: 'x', + range: { min: 0, max: 400 }, + dataDomain: { min: 0, max: 100 }, + }); + + expect(scale(10)).toBe(0); + expect(scale(90)).toBe(400); + }); + + it('should apply nice() when domainLimit is nice', () => { + const scale = getCartesianAxisScale({ + config: { + domain: { min: 0, max: 100 }, + range: { min: 0, max: 400 }, + scaleType: 'linear', + domainLimit: 'nice', + }, + type: 'x', + range: { min: 0, max: 400 }, + dataDomain: { min: 3, max: 97 }, + }); + + // Scale should be created successfully + expect(scale).toBeDefined(); + expect(typeof scale).toBe('function'); + }); + + it('should create band scale when scaleType is band', () => { + const scale = getCartesianAxisScale({ + config: { + domain: { min: 0, max: 4 }, + range: { min: 0, max: 400 }, + scaleType: 'band', + domainLimit: 'strict', + categoryPadding: 0.1, + }, + type: 'x', + range: { min: 0, max: 400 }, + dataDomain: { min: 0, max: 4 }, + }); + + expect(scale).toBeDefined(); + // Band scale should have bandwidth method + expect((scale as any).bandwidth).toBeDefined(); + }); + + it('should throw error for invalid domain bounds', () => { + expect(() => + getCartesianAxisScale({ + type: 'x', + range: { min: 0, max: 400 }, + dataDomain: { min: undefined as any, max: undefined as any }, + }), + ).toThrow('Invalid domain bounds'); + }); +}); + +describe('getPolarAxisScale', () => { + it('should create a linear scale for radial axis', () => { + const scale = getPolarAxisScale({ + range: { min: 0, max: 100 }, + dataDomain: { min: 0, max: 50 }, + }); + + expect(scale(0)).toBe(0); + expect(scale(25)).toBe(50); + expect(scale(50)).toBe(100); + }); + + it('should use config domain when provided', () => { + const scale = getPolarAxisScale({ + config: { + domain: { min: 10, max: 40 }, + range: { min: 0, max: 100 }, + scaleType: 'linear', + }, + range: { min: 0, max: 100 }, + dataDomain: { min: 0, max: 50 }, + }); + + expect(scale(10)).toBe(0); + expect(scale(40)).toBe(100); + }); + + it('should throw error for invalid domain bounds', () => { + expect(() => + getPolarAxisScale({ + range: { min: 0, max: 100 }, + dataDomain: { min: undefined as any, max: undefined as any }, + }), + ).toThrow('Invalid polar axis domain bounds'); + }); +}); + +describe('getCartesianAxisConfig', () => { + it('should return default config when no axes provided', () => { + const result = getCartesianAxisConfig('x', undefined); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('DEFAULT_AXIS_ID'); + expect(result[0].scaleType).toBe('linear'); + expect(result[0].domainLimit).toBe('strict'); // x-axis default + }); + + it('should use nice domainLimit for y-axis by default', () => { + const result = getCartesianAxisConfig('y', undefined); + + expect(result[0].domainLimit).toBe('nice'); + }); + + it('should handle single axis config object', () => { + const result = getCartesianAxisConfig('x', { scaleType: 'log' }); + + expect(result).toHaveLength(1); + expect(result[0].scaleType).toBe('log'); + }); + + it('should handle array of axis configs', () => { + const result = getCartesianAxisConfig('y', [ + { id: 'left', scaleType: 'linear' }, + { id: 'right', scaleType: 'log' }, + ]); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('left'); + expect(result[1].id).toBe('right'); + }); + + it('should throw error when multiple axes lack ids', () => { + expect(() => + getCartesianAxisConfig('y', [{ scaleType: 'linear' }, { scaleType: 'log' }]), + ).toThrow('When defining multiple axes, each must have a unique id'); + }); +}); + +describe('getAngularAxisConfig', () => { + it('should return default config when no axes provided', () => { + const result = getAngularAxisConfig(undefined); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('DEFAULT_AXIS_ID'); + expect(result[0].scaleType).toBe('linear'); + }); + + it('should handle single axis config object', () => { + const result = getAngularAxisConfig({ paddingAngle: 5 }); + + expect(result).toHaveLength(1); + expect(result[0].paddingAngle).toBe(5); + }); + + it('should handle array of axis configs', () => { + const result = getAngularAxisConfig([{ id: 'angular1' }, { id: 'angular2' }]); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('angular1'); + expect(result[1].id).toBe('angular2'); + }); + + it('should throw error when multiple axes lack ids', () => { + expect(() => getAngularAxisConfig([{}, {}])).toThrow( + 'When defining multiple angular axes, each must have a unique id', + ); + }); +}); + +describe('getRadialAxisConfig', () => { + it('should return default config when no axes provided', () => { + const result = getRadialAxisConfig(undefined); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('DEFAULT_AXIS_ID'); + expect(result[0].scaleType).toBe('linear'); + }); + + it('should handle single axis config object', () => { + const result = getRadialAxisConfig({ scaleType: 'log' }); + + expect(result).toHaveLength(1); + expect(result[0].scaleType).toBe('log'); + }); + + it('should handle array of axis configs', () => { + const result = getRadialAxisConfig([{ id: 'radial1' }, { id: 'radial2' }]); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('radial1'); + expect(result[1].id).toBe('radial2'); + }); + + it('should throw error when multiple axes lack ids', () => { + expect(() => getRadialAxisConfig([{}, {}])).toThrow( + 'When defining multiple radial axes, each must have a unique id', + ); + }); +}); + +describe('getCartesianAxisDomain', () => { + it('should calculate domain from series data for x-axis', () => { + const series: CartesianSeries[] = [ + { id: 'series1', data: [1, 2, 3, 4, 5] }, + { id: 'series2', data: [10, 20, 30] }, + ]; + + const result = getCartesianAxisDomain( + { id: 'x', scaleType: 'linear', domainLimit: 'strict' }, + series, + 'x', + ); + + expect(result.min).toBe(0); + expect(result.max).toBe(4); // Longest series has 5 items (indices 0-4) + }); + + it('should calculate domain from series data for y-axis', () => { + const series: CartesianSeries[] = [ + { id: 'series1', data: [1, 5, 3] }, + { id: 'series2', data: [2, 8, 4] }, + ]; + + const result = getCartesianAxisDomain( + { id: 'y', scaleType: 'linear', domainLimit: 'nice' }, + series, + 'y', + ); + + expect(result.min).toBe(1); + expect(result.max).toBe(8); + }); + + it('should use explicit domain bounds when provided', () => { + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; + + const result = getCartesianAxisDomain( + { id: 'y', scaleType: 'linear', domainLimit: 'nice', domain: { min: 0, max: 100 } }, + series, + 'y', + ); + + expect(result.min).toBe(0); + expect(result.max).toBe(100); + }); + + it('should handle domain function', () => { + const series: CartesianSeries[] = [{ id: 'series1', data: [10, 20, 30] }]; + + const result = getCartesianAxisDomain( + { + id: 'y', + scaleType: 'linear', + domainLimit: 'nice', + domain: (bounds) => ({ min: bounds.min - 5, max: bounds.max + 5 }), + }, + series, + 'y', + ); + + expect(result.min).toBe(5); // 10 - 5 + expect(result.max).toBe(35); // 30 + 5 + }); + + it('should use data array for categorical domain', () => { + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; + + const result = getCartesianAxisDomain( + { id: 'x', scaleType: 'band', domainLimit: 'strict', data: ['Jan', 'Feb', 'Mar', 'Apr'] }, + series, + 'x', + ); + + expect(result.min).toBe(0); + expect(result.max).toBe(3); // 4 categories + }); +}); + +describe('getPolarAxisDomain', () => { + it('should calculate angular domain for pie chart data', () => { + const series: PolarSeries[] = [ + { id: 'slice1', data: 10 }, + { id: 'slice2', data: 20 }, + { id: 'slice3', data: 30 }, + ]; + + const result = getPolarAxisDomain({ id: 'angular' }, series, 'angular'); + + expect(result.min).toBe(0); + expect(result.max).toBe(2); // 3 slices + }); + + it('should calculate radial domain for radar chart data', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: [1, 5, 3, 8, 2] }, + { id: 'series2', data: [2, 4, 6] }, + ]; + + const result = getPolarAxisDomain({ id: 'radial' }, series, 'radial'); + + expect(result.min).toBe(0); + expect(result.max).toBe(8); + }); + + it('should use explicit domain bounds when provided', () => { + const series: PolarSeries[] = [{ id: 'series1', data: 50 }]; + + const result = getPolarAxisDomain( + { id: 'radial', domain: { min: 0, max: 100 } }, + series, + 'radial', + ); + + expect(result.min).toBe(0); + expect(result.max).toBe(100); + }); +}); + +describe('getPolarAxisRange', () => { + it('should return default angular range in degrees', () => { + const result = getPolarAxisRange({ id: 'angular' }, 'angular', 100); + + // Default is 0° to 360° (full circle starting at 3 o'clock) + expect(result.min).toBe(0); + expect(result.max).toBe(360); + }); + + it('should return default radial range in pixels', () => { + const result = getPolarAxisRange({ id: 'radial' }, 'radial', 150); + + expect(result.min).toBe(0); + expect(result.max).toBe(150); // outerRadius in pixels + }); + + it('should return angular range in degrees (no conversion)', () => { + // User specifies degrees, output is degrees + const result = getPolarAxisRange( + { id: 'angular', range: { min: 0, max: 180 } }, // semicircle in degrees + 'angular', + 100, + ); + + // Output remains in degrees + expect(result.min).toBe(0); + expect(result.max).toBe(180); + }); + + it('should handle radial range function with pixels', () => { + // User can do percentage-based donut + const result = getPolarAxisRange( + { id: 'radial', range: (bounds) => ({ min: bounds.max * 0.5, max: bounds.max }) }, + 'radial', + 100, + ); + + expect(result.min).toBe(50); // 50% of 100px + expect(result.max).toBe(100); + }); + + it('should handle radial range function with pixel offset', () => { + // User can do pixel-based thickness + const result = getPolarAxisRange( + { id: 'radial', range: (bounds) => ({ min: bounds.max - 6, max: bounds.max }) }, + 'radial', + 100, + ); + + expect(result.min).toBe(94); // 100 - 6 = 94px inner radius + expect(result.max).toBe(100); + }); + + it('should handle angular range function with degrees', () => { + // User specifies function that works with degrees + const result = getPolarAxisRange( + { id: 'angular', range: (bounds) => ({ min: bounds.min + 45, max: bounds.max - 45 }) }, + 'angular', + 100, + ); + + // Default base is 0° to 360°, so result is 45° to 315° (still in degrees) + expect(result.min).toBe(45); + expect(result.max).toBe(315); + }); +}); + +describe('getAxisRange', () => { + it('should calculate range for x-axis', () => { + const chartRect = { x: 50, y: 20, width: 400, height: 300 }; + const result = getAxisRange({ id: 'x' }, chartRect, 'x'); + + expect(result.min).toBe(50); + expect(result.max).toBe(450); // x + width + }); + + it('should calculate range for y-axis', () => { + const chartRect = { x: 50, y: 20, width: 400, height: 300 }; + const result = getAxisRange({ id: 'y' }, chartRect, 'y'); + + expect(result.min).toBe(20); + expect(result.max).toBe(320); // y + height + }); + + it('should use explicit range when provided', () => { + const chartRect = { x: 0, y: 0, width: 400, height: 300 }; + const result = getAxisRange({ id: 'x', range: { min: 100, max: 300 } }, chartRect, 'x'); + + expect(result.min).toBe(100); + expect(result.max).toBe(300); + }); + + it('should handle range function', () => { + const chartRect = { x: 0, y: 0, width: 400, height: 300 }; + const result = getAxisRange( + { id: 'x', range: (bounds) => ({ min: bounds.min + 20, max: bounds.max - 20 }) }, + chartRect, + 'x', + ); + + expect(result.min).toBe(20); + expect(result.max).toBe(380); + }); +}); + describe('getAxisTicksData', () => { let numericScale: NumericScale; let bandScale: CategoricalScale; diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts index 8ff0147cb..394af6e9b 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,93 +1,96 @@ import { type AxisBounds, + type CartesianSeries, type ChartInset, defaultChartInset, defaultStackId, - getChartDomain, + getCartesianDomain, + getCartesianRange, + getCartesianStackedSeriesData, getChartInset, - getChartRange, - getStackedSeriesData, + getPolarAngularDomain, + getPolarRadialRange, isValidBounds, - type Series, + type PolarSeries, } from '../chart'; -describe('getChartDomain', () => { +describe('getCartesianDomain', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, { id: 'series2', data: [10, 20, 30] }, ]; - const result = getChartDomain(series, 5, 15); + const result = getCartesianDomain(series, 5, 15); expect(result).toEqual({ min: 5, max: 15 }); }); it('should calculate domain from series data when min/max not provided', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, // length 5, so max index = 4 { id: 'series2', data: [10, 20, 30] }, // length 3, so max index = 2 ]; - const result = getChartDomain(series); + const result = getCartesianDomain(series); expect(result).toEqual({ min: 0, max: 4 }); // Uses longest series (5 items, indices 0-4) }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartDomain(series, 10); + const result = getCartesianDomain(series, 10); expect(result).toEqual({ min: 10, max: 2 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; - const result = getChartDomain(series, undefined, 10); + const result = getCartesianDomain(series, undefined, 10); expect(result).toEqual({ min: 0, max: 10 }); }); it('should handle empty series array', () => { - const result = getChartDomain([]); + const result = getCartesianDomain([]); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getChartDomain(series); + const result = getCartesianDomain(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with empty data arrays', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [] }, { id: 'series2', data: [] }, ]; - const result = getChartDomain(series); + const result = getCartesianDomain(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle mixed series with and without data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1' }, { id: 'series2', data: [1, 2, 3, 4, 5, 6] }, { id: 'series3', data: [] }, ]; - const result = getChartDomain(series); + const result = getCartesianDomain(series); expect(result).toEqual({ min: 0, max: 5 }); }); }); -describe('getStackedSeriesData', () => { +describe('getCartesianStackedSeriesData', () => { it('should handle individual series without stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3] }, { id: 'series2', data: [4, 5, 6] }, ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(2); expect(result.get('series1')).toEqual([ @@ -103,7 +106,7 @@ describe('getStackedSeriesData', () => { }); it('should handle series with tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -114,7 +117,7 @@ describe('getStackedSeriesData', () => { }, ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(1); expect(result.get('series1')).toEqual([ @@ -125,12 +128,12 @@ describe('getStackedSeriesData', () => { }); it('should stack series with same stackId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(2); // D3 stack will create cumulative values @@ -144,12 +147,12 @@ describe('getStackedSeriesData', () => { }); it('should not stack series with different yAxisId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(2); // Should be treated as individual series since they have different y-axes @@ -166,33 +169,33 @@ describe('getStackedSeriesData', () => { }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 3] }]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.get('series1')).toEqual([[0, 1], null, [0, 3]]); }); it('should handle empty series array', () => { - const result = getStackedSeriesData([]); + const result = getCartesianStackedSeriesData([]); expect(result.size).toBe(0); }); it('should handle series without data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(0); }); it('should handle mixed stacked and individual series', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, { id: 'series3', data: [7, 8, 9] }, // No stackId ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(3); expect(result.get('series3')).toEqual([ @@ -203,26 +206,26 @@ describe('getStackedSeriesData', () => { }); }); -describe('getChartRange', () => { +describe('getCartesianRange', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, -10, 20); + const result = getCartesianRange(series, -10, 20); expect(result).toEqual({ min: -10, max: 20 }); }); it('should calculate range from simple numeric data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 5, 3] }, { id: 'series2', data: [2, 4, 6] }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: 1, max: 6 }); }); it('should calculate range from tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -240,17 +243,17 @@ describe('getChartRange', () => { }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: -1, max: 7 }); }); it('should calculate range from stacked data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); // Stacked values should be cumulative expect(result.min).toBeDefined(); @@ -260,19 +263,19 @@ describe('getChartRange', () => { }); it('should handle negative values', () => { - const series: Series[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: -5, max: 3 }); }); it('should handle mixed positive and negative stacked values', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [2, -1, 3], stackId: 'stack1' }, { id: 'series2', data: [-3, 4, -2], stackId: 'stack1' }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result.min).toBeDefined(); expect(result.max).toBeDefined(); @@ -281,51 +284,178 @@ describe('getChartRange', () => { }); it('should handle empty series array', () => { - const result = getChartRange([]); + const result = getCartesianRange([]); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: 1, max: 5 }); }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, -5); + const result = getCartesianRange(series, -5); expect(result).toEqual({ min: -5, max: 3 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, undefined, 10); + const result = getCartesianRange(series, undefined, 10); expect(result).toEqual({ min: 1, max: 10 }); }); it('should handle series with different yAxisId in stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); // Should treat as individual series, not stacked expect(result).toEqual({ min: 0, max: 6 }); }); }); +describe('getPolarAngularDomain', () => { + it('should return provided min and max when both are specified', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: 10 }, + { id: 'series2', data: 20 }, + ]; + + const result = getPolarAngularDomain(series, 0, 100); + expect(result).toEqual({ min: 0, max: 100 }); + }); + + it('should calculate domain for pie/donut charts (single number data)', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: 10 }, + { id: 'series2', data: 20 }, + { id: 'series3', data: 30 }, + ]; + + const result = getPolarAngularDomain(series); + expect(result).toEqual({ min: 0, max: 2 }); // 3 slices, indices 0-2 + }); + + it('should calculate domain for radar charts (array data)', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: [1, 2, 3, 4, 5] }, + { id: 'series2', data: [5, 4, 3] }, + ]; + + const result = getPolarAngularDomain(series); + expect(result).toEqual({ min: 0, max: 4 }); // Longest array has 5 items + }); + + it('should handle empty series array', () => { + const result = getPolarAngularDomain([]); + expect(result).toEqual({ min: undefined, max: undefined }); + }); + + it('should use provided min with calculated max', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: 10 }, + { id: 'series2', data: 20 }, + ]; + + const result = getPolarAngularDomain(series, 5); + expect(result).toEqual({ min: 5, max: 1 }); + }); + + it('should use calculated min with provided max', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: 10 }, + { id: 'series2', data: 20 }, + ]; + + const result = getPolarAngularDomain(series, undefined, 10); + expect(result).toEqual({ min: 0, max: 10 }); + }); +}); + +describe('getPolarRadialRange', () => { + it('should return provided min and max when both are specified', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: 10 }, + { id: 'series2', data: 20 }, + ]; + + const result = getPolarRadialRange(series, -5, 100); + expect(result).toEqual({ min: -5, max: 100 }); + }); + + it('should calculate range for pie/donut charts (single number data)', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: 10 }, + { id: 'series2', data: 20 }, + { id: 'series3', data: 5 }, + ]; + + const result = getPolarRadialRange(series); + expect(result).toEqual({ min: 0, max: 20 }); + }); + + it('should calculate range for radar charts (array data)', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: [1, 5, 3] }, + { id: 'series2', data: [2, 8, 4] }, + ]; + + const result = getPolarRadialRange(series); + expect(result).toEqual({ min: 0, max: 8 }); + }); + + it('should handle negative values', () => { + const series: PolarSeries[] = [ + { id: 'series1', data: [-5, 10, 3] }, + { id: 'series2', data: [2, -2, 4] }, + ]; + + const result = getPolarRadialRange(series); + expect(result).toEqual({ min: -5, max: 10 }); + }); + + it('should handle null values in array data', () => { + const series: PolarSeries[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; + + const result = getPolarRadialRange(series); + expect(result).toEqual({ min: 0, max: 5 }); + }); + + it('should handle empty series array', () => { + const result = getPolarRadialRange([]); + expect(result).toEqual({ min: undefined, max: undefined }); + }); + + it('should use provided min with calculated max', () => { + const series: PolarSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; + + const result = getPolarRadialRange(series, -10); + expect(result).toEqual({ min: -10, max: 3 }); + }); + + it('should use calculated min with provided max', () => { + const series: PolarSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; + + const result = getPolarRadialRange(series, undefined, 100); + expect(result).toEqual({ min: 0, max: 100 }); + }); +}); + describe('defaultStackId', () => { it('should be defined as a string constant', () => { expect(typeof defaultStackId).toBe('string'); diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts index b34ad2afd..242d7210b 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts @@ -1,9 +1,12 @@ import { type ChartPathCurveType, + getArcPath, getAreaPath, getBarPath, + getDottedAreaPath, getLinePath, getPathCurveFunction, + lineToPath, } from '../path'; import { getCategoricalScale, getNumericScale } from '../scale'; @@ -375,3 +378,239 @@ describe('getBarPath', () => { expect(noRounding).not.toBe(bothRounding); }); }); + +describe('lineToPath', () => { + it('should generate a simple line path', () => { + const result = lineToPath(0, 0, 100, 100); + expect(result).toBe('M 0 0 L 100 100'); + }); + + it('should handle negative coordinates', () => { + const result = lineToPath(-50, -25, 50, 25); + expect(result).toBe('M -50 -25 L 50 25'); + }); + + it('should handle fractional coordinates', () => { + const result = lineToPath(10.5, 20.25, 30.75, 40.125); + expect(result).toBe('M 10.5 20.25 L 30.75 40.125'); + }); + + it('should handle horizontal lines', () => { + const result = lineToPath(0, 50, 100, 50); + expect(result).toBe('M 0 50 L 100 50'); + }); + + it('should handle vertical lines', () => { + const result = lineToPath(50, 0, 50, 100); + expect(result).toBe('M 50 0 L 50 100'); + }); + + it('should handle zero-length lines (same start and end)', () => { + const result = lineToPath(25, 25, 25, 25); + expect(result).toBe('M 25 25 L 25 25'); + }); +}); + +describe('getDottedAreaPath', () => { + it('should generate dotted pattern within bounds', () => { + const result = getDottedAreaPath({ x: 0, y: 0, width: 100, height: 50 }, 10, 2); + + expect(result).toBeTruthy(); + // Should contain multiple circles (arc commands) + expect(result).toContain('a'); + expect(result).toContain('M'); + }); + + it('should return empty string for zero width', () => { + const result = getDottedAreaPath({ x: 0, y: 0, width: 0, height: 50 }, 10, 2); + expect(result).toBe(''); + }); + + it('should return empty string for zero height', () => { + const result = getDottedAreaPath({ x: 0, y: 0, width: 100, height: 0 }, 10, 2); + expect(result).toBe(''); + }); + + it('should return empty string for zero pattern size', () => { + const result = getDottedAreaPath({ x: 0, y: 0, width: 100, height: 50 }, 0, 2); + expect(result).toBe(''); + }); + + it('should return empty string for zero dot size', () => { + const result = getDottedAreaPath({ x: 0, y: 0, width: 100, height: 50 }, 10, 0); + expect(result).toBe(''); + }); + + it('should return empty string for negative dimensions', () => { + const result = getDottedAreaPath({ x: 0, y: 0, width: -100, height: 50 }, 10, 2); + expect(result).toBe(''); + }); + + it('should handle offset bounds', () => { + const result = getDottedAreaPath({ x: 50, y: 25, width: 100, height: 50 }, 10, 2); + + expect(result).toBeTruthy(); + // Should have paths starting at offset positions + expect(result).toContain('M'); + }); + + it('should scale with pattern size', () => { + const smallPattern = getDottedAreaPath({ x: 0, y: 0, width: 100, height: 100 }, 10, 2); + const largePattern = getDottedAreaPath({ x: 0, y: 0, width: 100, height: 100 }, 50, 2); + + // Smaller pattern size should create more dots (longer path) + expect(smallPattern.length).toBeGreaterThan(largePattern.length); + }); +}); + +describe('getArcPath', () => { + it('should generate arc path for pie slice', () => { + const result = getArcPath({ + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 0, + outerRadius: 100, + }); + + expect(result).toBeTruthy(); + expect(result.startsWith('M')).toBe(true); + // Arc should contain arc commands + expect(result).toContain('A'); + }); + + it('should generate arc path for donut slice', () => { + const result = getArcPath({ + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + }); + + expect(result).toBeTruthy(); + expect(result.startsWith('M')).toBe(true); + // Should have different path than pie (has inner radius) + expect(result).toContain('A'); + }); + + it('should return empty string for zero outer radius', () => { + const result = getArcPath({ + startAngle: 0, + endAngle: Math.PI, + innerRadius: 0, + outerRadius: 0, + }); + + expect(result).toBe(''); + }); + + it('should return empty string for negative outer radius', () => { + const result = getArcPath({ + startAngle: 0, + endAngle: Math.PI, + innerRadius: 0, + outerRadius: -100, + }); + + expect(result).toBe(''); + }); + + it('should handle full circle (2π)', () => { + const result = getArcPath({ + startAngle: 0, + endAngle: Math.PI * 2, + innerRadius: 0, + outerRadius: 100, + }); + + expect(result).toBeTruthy(); + expect(result.startsWith('M')).toBe(true); + }); + + it('should handle corner radius', () => { + const withoutCorners = getArcPath({ + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + cornerRadius: 0, + }); + + const withCorners = getArcPath({ + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + cornerRadius: 8, + }); + + expect(withoutCorners).toBeTruthy(); + expect(withCorners).toBeTruthy(); + // Paths should be different + expect(withCorners).not.toBe(withoutCorners); + }); + + it('should handle pad angle', () => { + const withoutPadding = getArcPath({ + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + paddingAngle: 0, + }); + + const withPadding = getArcPath({ + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + paddingAngle: 0.05, + }); + + expect(withoutPadding).toBeTruthy(); + expect(withPadding).toBeTruthy(); + // Paths should be different when padding is applied + expect(withPadding).not.toBe(withoutPadding); + }); + + it('should handle collapsed arc (same start and end angle)', () => { + const result = getArcPath({ + startAngle: Math.PI / 4, + endAngle: Math.PI / 4, // Same as start + innerRadius: 0, + outerRadius: 100, + }); + + // Should return empty string for collapsed arc (no area to draw) + expect(result).toBe(''); + }); + + it('should handle negative inner radius (clamps to 0)', () => { + const result = getArcPath({ + startAngle: 0, + endAngle: Math.PI, + innerRadius: -50, + outerRadius: 100, + }); + + expect(result).toBeTruthy(); + expect(result.startsWith('M')).toBe(true); + }); + + it('should generate different paths for different angles', () => { + const quarterCircle = getArcPath({ + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 0, + outerRadius: 100, + }); + + const halfCircle = getArcPath({ + startAngle: 0, + endAngle: Math.PI, + innerRadius: 0, + outerRadius: 100, + }); + + expect(quarterCircle).not.toBe(halfCircle); + }); +}); diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/polar.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/polar.test.ts new file mode 100644 index 000000000..78dd7a767 --- /dev/null +++ b/packages/mobile-visualization/src/chart/utils/__tests__/polar.test.ts @@ -0,0 +1,347 @@ +import { getArcPath } from '../path'; +import { + calculateArcData, + degreesToRadians, + getAngularAxisRadians, + getRadialAxisPixels, + radiansToDegrees, +} from '../polar'; + +describe('degreesToRadians', () => { + it('should convert 0 degrees to 0 radians', () => { + expect(degreesToRadians(0)).toBe(0); + }); + + it('should convert 90 degrees to π/2 radians', () => { + expect(degreesToRadians(90)).toBeCloseTo(Math.PI / 2); + }); + + it('should convert 180 degrees to π radians', () => { + expect(degreesToRadians(180)).toBeCloseTo(Math.PI); + }); + + it('should convert 360 degrees to 2π radians', () => { + expect(degreesToRadians(360)).toBeCloseTo(2 * Math.PI); + }); + + it('should handle negative degrees', () => { + expect(degreesToRadians(-90)).toBeCloseTo(-Math.PI / 2); + }); +}); + +describe('radiansToDegrees', () => { + it('should convert 0 radians to 0 degrees', () => { + expect(radiansToDegrees(0)).toBe(0); + }); + + it('should convert π/2 radians to 90 degrees', () => { + expect(radiansToDegrees(Math.PI / 2)).toBeCloseTo(90); + }); + + it('should convert π radians to 180 degrees', () => { + expect(radiansToDegrees(Math.PI)).toBeCloseTo(180); + }); + + it('should convert 2π radians to 360 degrees', () => { + expect(radiansToDegrees(2 * Math.PI)).toBeCloseTo(360); + }); + + it('should handle negative radians', () => { + expect(radiansToDegrees(-Math.PI / 2)).toBeCloseTo(-90); + }); +}); + +describe('getAngularAxisRadians', () => { + it('should return full circle defaults when no config provided', () => { + const result = getAngularAxisRadians(); + + expect(result.startAngle).toBe(0); + expect(result.endAngle).toBeCloseTo(2 * Math.PI); + expect(result.paddingAngle).toBe(0); + }); + + it('should use custom range values', () => { + const result = getAngularAxisRadians({ + range: { min: Math.PI / 4, max: Math.PI }, + }); + + expect(result.startAngle).toBeCloseTo(Math.PI / 4); + expect(result.endAngle).toBeCloseTo(Math.PI); + }); + + it('should convert paddingAngle from degrees to radians', () => { + const result = getAngularAxisRadians({ + paddingAngle: 5, // 5 degrees + }); + + expect(result.paddingAngle).toBeCloseTo(degreesToRadians(5)); + }); + + it('should handle partial range config', () => { + const result = getAngularAxisRadians({ + range: { min: Math.PI, max: 2 * Math.PI }, + }); + + expect(result.startAngle).toBeCloseTo(Math.PI); + expect(result.endAngle).toBeCloseTo(2 * Math.PI); + }); +}); + +describe('getRadialAxisPixels', () => { + it('should return full radius when no config provided', () => { + const result = getRadialAxisPixels(100); + + expect(result.innerRadius).toBe(0); + expect(result.outerRadius).toBe(100); + }); + + it('should calculate radii as percentage of maxRadius', () => { + const result = getRadialAxisPixels(100, { + range: { min: 0.3, max: 0.8 }, + }); + + expect(result.innerRadius).toBe(30); + expect(result.outerRadius).toBe(80); + }); + + it('should handle donut configuration', () => { + const result = getRadialAxisPixels(200, { + range: { min: 0.5, max: 1 }, + }); + + expect(result.innerRadius).toBe(100); + expect(result.outerRadius).toBe(200); + }); + + it('should handle zero maxRadius', () => { + const result = getRadialAxisPixels(0, { + range: { min: 0.5, max: 1 }, + }); + + expect(result.innerRadius).toBe(0); + expect(result.outerRadius).toBe(0); + }); +}); + +describe('calculateArcData', () => { + it('should return empty array for empty values', () => { + const result = calculateArcData([], 0, 100, 0, 2 * Math.PI, 0); + expect(result).toEqual([]); + }); + + it('should calculate single slice as full circle', () => { + // Input: degrees (0 to 360), Output: radians + const result = calculateArcData([100], 0, 100, 0, 360, 0); + + expect(result).toHaveLength(1); + expect(result[0].startAngle).toBe(0); + expect(result[0].endAngle).toBeCloseTo(2 * Math.PI); + expect(result[0].innerRadius).toBe(0); + expect(result[0].outerRadius).toBe(100); + expect(result[0].index).toBe(0); + expect(result[0].value).toBe(100); + }); + + it('should divide circle equally for equal values', () => { + // Input: degrees (0 to 360), Output: radians + const result = calculateArcData([25, 25, 25, 25], 0, 100, 0, 360, 0); + + expect(result).toHaveLength(4); + + // Each slice should be approximately π/2 radians (90 degrees) + result.forEach((arc, index) => { + const expectedStart = (index * Math.PI) / 2; + const expectedEnd = ((index + 1) * Math.PI) / 2; + expect(arc.startAngle).toBeCloseTo(expectedStart, 1); + expect(arc.endAngle).toBeCloseTo(expectedEnd, 1); + expect(arc.index).toBe(index); + }); + }); + + it('should handle unequal values proportionally', () => { + // 30% + 50% + 20% = 100% + // Input: degrees (0 to 360), Output: radians + const result = calculateArcData([30, 50, 20], 0, 100, 0, 360, 0); + + expect(result).toHaveLength(3); + + // First slice: 30% of 2π + expect(result[0].startAngle).toBe(0); + expect(result[0].endAngle).toBeCloseTo(0.3 * 2 * Math.PI); + + // Second slice: 50% of 2π + expect(result[1].startAngle).toBeCloseTo(0.3 * 2 * Math.PI); + expect(result[1].endAngle).toBeCloseTo(0.8 * 2 * Math.PI); + + // Third slice: 20% of 2π + expect(result[2].startAngle).toBeCloseTo(0.8 * 2 * Math.PI); + expect(result[2].endAngle).toBeCloseTo(2 * Math.PI); + }); + + it('should account for padding angle', () => { + // Padding angle in degrees, converted internally to radians + const paddingAngleDegrees = 5; + const paddingAngleRadians = (paddingAngleDegrees * Math.PI) / 180; + const result = calculateArcData([50, 50], 0, 100, 0, 360, paddingAngleDegrees); + + expect(result).toHaveLength(2); + // Output paddingAngle is in radians + expect(result[0].paddingAngle).toBeCloseTo(paddingAngleRadians); + expect(result[1].paddingAngle).toBeCloseTo(paddingAngleRadians); + + // D3's pie generator stores the padAngle in the result + // The arc path rendering uses this to create visual gaps between slices + expect(result[0].endAngle).toBeCloseTo(Math.PI); + expect(result[1].startAngle).toBeCloseTo(Math.PI); + }); + + it('should respect custom start and end angles (semicircle)', () => { + // Input: degrees (-90 to 90), Output: radians + const result = calculateArcData([50, 50], 0, 100, -90, 90, 0); + + expect(result).toHaveLength(2); + + // First slice should start at -π/2 and end near 0 + expect(result[0].startAngle).toBeCloseTo(-Math.PI / 2); + expect(result[0].endAngle).toBeCloseTo(0); + + // Second slice should start near 0 and end at π/2 + expect(result[1].startAngle).toBeCloseTo(0); + expect(result[1].endAngle).toBeCloseTo(Math.PI / 2); + }); + + it('should preserve inner and outer radius', () => { + const result = calculateArcData([100], 50, 150, 0, 360, 0); + + expect(result[0].innerRadius).toBe(50); + expect(result[0].outerRadius).toBe(150); + }); + + it('should preserve value in output', () => { + const result = calculateArcData([30, 70], 0, 100, 0, 360, 0); + + expect(result[0].value).toBe(30); + expect(result[1].value).toBe(70); + }); + + it('should handle negative values by using absolute value', () => { + const result = calculateArcData([-30, 70], 0, 100, 0, 360, 0); + + expect(result).toHaveLength(2); + // D3's pie uses Math.abs for the angle calculation + // First slice: 30% of total (30 + 70 = 100) + expect(result[0].endAngle - result[0].startAngle).toBeCloseTo(0.3 * 2 * Math.PI); + }); +}); + +describe('calculateArcData + getArcPath integration', () => { + it('should generate valid SVG paths for calculated arcs', () => { + // Input: degrees (0 to 360) + const arcs = calculateArcData([30, 40, 30], 0, 100, 0, 360, 0); + + arcs.forEach((arc) => { + const path = getArcPath({ + startAngle: arc.startAngle, + endAngle: arc.endAngle, + innerRadius: arc.innerRadius, + outerRadius: arc.outerRadius, + paddingAngle: arc.paddingAngle, + }); + + expect(path).toBeTruthy(); + expect(path.startsWith('M')).toBe(true); + expect(path).toContain('A'); // Should contain arc commands + }); + }); + + it('should generate donut arc paths with inner radius', () => { + // Input: degrees (0 to 360) + const arcs = calculateArcData([50, 50], 50, 100, 0, 360, 0); + + arcs.forEach((arc) => { + const path = getArcPath({ + startAngle: arc.startAngle, + endAngle: arc.endAngle, + innerRadius: arc.innerRadius, + outerRadius: arc.outerRadius, + }); + + expect(path).toBeTruthy(); + expect(path.startsWith('M')).toBe(true); + }); + }); + + it('should generate semicircle arc paths', () => { + // Input: degrees (-90 to 90) + const arcs = calculateArcData( + [25, 50, 25], + 0, + 100, + -90, // -90 degrees + 90, // 90 degrees + 0, + ); + + expect(arcs).toHaveLength(3); + + arcs.forEach((arc) => { + const path = getArcPath({ + startAngle: arc.startAngle, + endAngle: arc.endAngle, + innerRadius: arc.innerRadius, + outerRadius: arc.outerRadius, + }); + + expect(path).toBeTruthy(); + expect(path.startsWith('M')).toBe(true); + }); + }); + + it('should generate padded arc paths', () => { + const paddingAngle = degreesToRadians(3); // 3 degrees + const arcs = calculateArcData([33, 33, 34], 0, 100, 0, 2 * Math.PI, paddingAngle); + + const paths = arcs.map((arc) => + getArcPath({ + startAngle: arc.startAngle, + endAngle: arc.endAngle, + innerRadius: arc.innerRadius, + outerRadius: arc.outerRadius, + paddingAngle: arc.paddingAngle, + }), + ); + + // All paths should be valid + paths.forEach((path) => { + expect(path).toBeTruthy(); + expect(path.startsWith('M')).toBe(true); + }); + }); + + it('should generate corner-radiused arc paths', () => { + const arcs = calculateArcData([50, 50], 50, 100, 0, 2 * Math.PI, 0); + + arcs.forEach((arc) => { + const pathWithCorners = getArcPath({ + startAngle: arc.startAngle, + endAngle: arc.endAngle, + innerRadius: arc.innerRadius, + outerRadius: arc.outerRadius, + cornerRadius: 8, + }); + + const pathWithoutCorners = getArcPath({ + startAngle: arc.startAngle, + endAngle: arc.endAngle, + innerRadius: arc.innerRadius, + outerRadius: arc.outerRadius, + cornerRadius: 0, + }); + + expect(pathWithCorners).toBeTruthy(); + expect(pathWithoutCorners).toBeTruthy(); + // Corner radius should produce different paths + expect(pathWithCorners).not.toBe(pathWithoutCorners); + }); + }); +}); diff --git a/packages/mobile-visualization/src/chart/utils/axis.ts b/packages/mobile-visualization/src/chart/utils/axis.ts index 0731e67ab..e0b5fa88e 100644 --- a/packages/mobile-visualization/src/chart/utils/axis.ts +++ b/packages/mobile-visualization/src/chart/utils/axis.ts @@ -4,10 +4,13 @@ import type { Rect } from '@coinbase/cds-common/types'; import { type AxisBounds, - getChartDomain, - getChartRange, + type CartesianSeries, + getCartesianDomain, + getCartesianRange, + getPolarAngularDomain, + getPolarRadialRange, isValidBounds, - type Series, + type PolarSeries, } from './chart'; import { type ChartAxisScaleType, @@ -26,8 +29,6 @@ export const defaultAxisScaleType = 'linear'; * Axis configuration with computed bounds */ export type AxisConfig = { - /** The type of scale to use */ - scaleType: ChartAxisScaleType; /** * Domain bounds for the axis (data space) */ @@ -36,10 +37,17 @@ export type AxisConfig = { * Range bounds for the axis (visual space in pixels) */ range: AxisBounds; +}; + +export type CartesianAxisConfig = AxisConfig & { + /** The type of scale to use */ + scaleType: ChartAxisScaleType; /** - * Data for the axis + * Domain limit type for numeric scales + * - 'nice' (default for y axes): Rounds the domain to human-friendly values (e.g., 0-100 instead of 1.2-97.8) + * - 'strict' (default for x axes): Uses the exact min/max values from the data */ - data?: string[] | number[]; + domainLimit: 'nice' | 'strict'; /** * Padding between categories for band scales (0-1, where 0.1 = 10% spacing) * Only used when scaleType is 'band' @@ -47,45 +55,116 @@ export type AxisConfig = { */ categoryPadding?: number; /** - * Domain limit type for numeric scales - * - 'nice': Rounds the domain to human-friendly values - * - 'strict': Uses the exact min/max values from the data + * Data for the axis */ - domainLimit: 'nice' | 'strict'; + data?: string[] | number[]; +}; + +export type RadialAxisConfig = AxisConfig & { + /** The type of scale to use */ + scaleType: Exclude; +}; + +export type AngularAxisConfig = AxisConfig & { + /** The type of scale to use */ + scaleType: Exclude; + /** + * Padding angle between slices in degrees. + * @default 0 + */ + paddingAngle?: number; }; /** - * Axis configuration without computed bounds (used for input) + * Base axis configuration props (used for input, without computed bounds) */ -export type AxisConfigProps = Omit & { +type AxisConfigProps = { /** * Unique identifier for this axis. */ id: string; /** * Domain configuration for the axis (data space). - * - * The domainLimit parameter (inherited from AxisConfig) controls how initial domain bounds are calculated: - * - 'nice' (default for y axes): Rounds the domain to human-friendly values (e.g., 0-100 instead of 1.2-97.8) - * - 'strict' (default for x axes): Uses the exact min/max values from the data - * - * The domain can be: - * - A partial bounds object to override specific min/max values - * - A function that receives the limit-processed bounds and allows further customization - * - * This allows you to first apply nice/strict processing, then optionally transform the result. + * Can be a partial bounds object to override specific min/max values, + * or a function that receives calculated bounds and allows customization. */ domain?: Partial | ((bounds: AxisBounds) => AxisBounds); /** * Range configuration for the axis (visual space in pixels). - * Can be a partial bounds object to override specific values, or a function that transforms the calculated range. - * - * When using a function, it receives the initial calculated range bounds and allows you to adjust them. - * This replaces the previous rangeOffset approach and provides more flexibility for range customization. + * Can be a partial bounds object to override specific values, + * or a function that transforms the calculated range. */ range?: Partial | ((bounds: AxisBounds) => AxisBounds); }; +export type CartesianAxisConfigProps = Omit & + AxisConfigProps; + +/** + * Base polar axis configuration props (shared by angular and radial axes) + */ +type PolarAxisConfigProps = AxisConfigProps & { + /** + * The type of scale to use. + * @default 'linear' + */ + scaleType?: Exclude; +}; + +/** + * Angular axis configuration props (for polar charts) + */ +export type AngularAxisConfigProps = PolarAxisConfigProps & { + /** + * Padding angle between slices in degrees. + * @default 0 + */ + paddingAngle?: number; +}; + +/** + * Radial axis configuration props (for polar charts) + */ +export type RadialAxisConfigProps = PolarAxisConfigProps; + +/** + * Gets a D3 scale for a polar axis. + * + * @param params - Scale parameters + * @returns The D3 scale function + */ +export const getPolarAxisScale = ({ + config, + range, + dataDomain, +}: { + config?: AngularAxisConfig | RadialAxisConfig; + range: AxisBounds; + dataDomain: AxisBounds; +}): ChartScaleFunction => { + const scaleType = config?.scaleType ?? 'linear'; + + let adjustedDomain = dataDomain; + + if (config?.domain) { + adjustedDomain = { + min: config.domain.min ?? dataDomain.min, + max: config.domain.max ?? dataDomain.max, + }; + } + + if (!isValidBounds(adjustedDomain)) { + throw new Error('Invalid polar axis domain bounds.'); + } + + // Polar charts only use linear/log scales (no band scales) + return getNumericScale({ + domain: adjustedDomain, + range, + scaleType: scaleType as 'linear' | 'log', + }); +}; + /** * Gets a D3 scale based on the axis configuration. * Handles both numeric (linear/log) and categorical (band) scales. @@ -97,13 +176,13 @@ export type AxisConfigProps = Omit & { * @returns The D3 scale function * @throws An Error if bounds are invalid */ -export const getAxisScale = ({ +export const getCartesianAxisScale = ({ config, type, range, dataDomain, }: { - config?: AxisConfig; + config?: CartesianAxisConfig; type: 'x' | 'y'; range: AxisBounds; dataDomain: AxisBounds; @@ -161,12 +240,12 @@ export const getAxisScale = ({ * @param defaultScaleType - the default scale type to use for the axis * @returns array of axis configs with IDs */ -export const getAxisConfig = ( +export const getCartesianAxisConfig = ( type: 'x' | 'y', - axes: Partial | Partial[] | undefined, + axes: Partial | Partial[] | undefined, defaultId: string = defaultAxisId, defaultScaleType: ChartAxisScaleType = defaultAxisScaleType, -): AxisConfigProps[] => { +): CartesianAxisConfigProps[] => { const defaultDomainLimit = type === 'x' ? 'strict' : 'nice'; if (!axes) { return [{ id: defaultId, scaleType: defaultScaleType, domainLimit: defaultDomainLimit }]; @@ -194,6 +273,66 @@ export const getAxisConfig = ( return [{ id: defaultId, scaleType: defaultScaleType, domainLimit: defaultDomainLimit, ...axes }]; }; +/** + * Formats the array of user-provided angular axis configs with default values. + * @param axes - array of axis configs or single axis config + * @param defaultId - the default id to use for the axis + * @returns array of angular axis configs with IDs + */ +export const getAngularAxisConfig = ( + axes: Partial | Partial[] | undefined, + defaultId: string = defaultAxisId, +): AngularAxisConfigProps[] => { + if (!axes) { + return [{ id: defaultId, scaleType: 'linear' }]; + } + + if (Array.isArray(axes)) { + const axesLength = axes.length; + if (axesLength > 1 && axes.some(({ id }) => id === undefined)) { + throw new Error('When defining multiple angular axes, each must have a unique id.'); + } + + return axes.map(({ id, ...axis }) => ({ + id: axesLength > 1 ? (id ?? defaultAxisId) : (id as string), + scaleType: 'linear' as const, + ...axis, + })); + } + + return [{ id: defaultId, scaleType: 'linear', ...axes }]; +}; + +/** + * Formats the array of user-provided radial axis configs with default values. + * @param axes - array of axis configs or single axis config + * @param defaultId - the default id to use for the axis + * @returns array of radial axis configs with IDs + */ +export const getRadialAxisConfig = ( + axes: Partial | Partial[] | undefined, + defaultId: string = defaultAxisId, +): RadialAxisConfigProps[] => { + if (!axes) { + return [{ id: defaultId, scaleType: 'linear' }]; + } + + if (Array.isArray(axes)) { + const axesLength = axes.length; + if (axesLength > 1 && axes.some(({ id }) => id === undefined)) { + throw new Error('When defining multiple radial axes, each must have a unique id.'); + } + + return axes.map(({ id, ...axis }) => ({ + id: axesLength > 1 ? (id ?? defaultAxisId) : (id as string), + scaleType: 'linear' as const, + ...axis, + })); + } + + return [{ id: defaultId, scaleType: 'linear', ...axes }]; +}; + /** * Calculates the data domain for an axis based on its configuration and series data. * Handles both x and y axes, categorical data, custom domain configurations, and stacking. @@ -203,9 +342,9 @@ export const getAxisConfig = ( * @param axisType - Whether this is an 'x' or 'y' axis * @returns The calculated axis bounds */ -export const getAxisDomain = ( - axisParam: AxisConfigProps, - series: Series[], +export const getCartesianAxisDomain = ( + axisParam: CartesianAxisConfigProps, + series: CartesianSeries[], axisType: 'x' | 'y', ): AxisBounds => { let dataDomain: AxisBounds | null = null; @@ -230,7 +369,7 @@ export const getAxisDomain = ( } // Calculate domain from series data - const seriesDomain = axisType === 'x' ? getChartDomain(series) : getChartRange(series); + const seriesDomain = axisType === 'x' ? getCartesianDomain(series) : getCartesianRange(series); // If data sets the domain, use that instead of the series domain const preferredDataDomain = dataDomain ?? seriesDomain; @@ -263,6 +402,84 @@ export const getAxisDomain = ( }; }; +/** + * Calculates the data domain for a polar axis based on its configuration and series data. + * + * @param axisParam - The axis configuration + * @param series - Array of polar series objects + * @param axisType - Whether this is an 'angular' or 'radial' axis + * @returns The calculated axis bounds + */ +export const getPolarAxisDomain = ( + axisParam: PolarAxisConfigProps, + series: PolarSeries[], + axisType: 'angular' | 'radial', +): AxisBounds => { + // Calculate domain from series data + const seriesDomain = + axisType === 'angular' ? getPolarAngularDomain(series) : getPolarRadialRange(series); + + const bounds = axisParam.domain; + let finalDomain: Partial; + + if (typeof bounds === 'function') { + finalDomain = bounds({ + min: seriesDomain.min ?? 0, + max: seriesDomain.max ?? 0, + }); + } else if (bounds && typeof bounds === 'object') { + finalDomain = { + min: bounds.min ?? seriesDomain.min, + max: bounds.max ?? seriesDomain.max, + }; + } else { + finalDomain = seriesDomain; + } + + return { + min: finalDomain.min ?? 0, + max: finalDomain.max ?? 0, + }; +}; + +/** + * Calculates the visual range for a polar axis. + * + * For angular axes, returns degrees (conversion to radians happens at path generation). + * For radial axes, returns pixels. + * + * @param axisParam - The axis configuration + * @param axisType - Whether this is an 'angular' or 'radial' axis + * @param outerRadius - The outer radius in pixels (used for radial axis) + * @returns The calculated axis range bounds (degrees for angular, pixels for radial) + */ +export const getPolarAxisRange = ( + axisParam: AxisConfigProps, + axisType: 'angular' | 'radial', + outerRadius: number, +): AxisBounds => { + // Default ranges: + // Angular: full circle starting at 3 o'clock (0° to 360° in degrees) + // Radial: 0 to outerRadius (pixels) + const baseRange: AxisBounds = + axisType === 'angular' ? { min: 0, max: 360 } : { min: 0, max: outerRadius }; + + const rangeConfig = axisParam.range; + + if (!rangeConfig) { + return baseRange; + } + + if (typeof rangeConfig === 'function') { + return rangeConfig(baseRange); + } + + return { + min: rangeConfig.min ?? baseRange.min, + max: rangeConfig.max ?? baseRange.max, + }; +}; + /** * Calculates the visual range for an axis based on the chart rectangle and configuration. * Handles custom range configurations including functions and partial bounds. @@ -729,7 +946,7 @@ export type RegisteredAxis = { /** * Calculates the total amount of padding needed to render a set of axes on the main drawing area of the chart. - * Returns the registed axes, an API for adding/removing axes as well as the total calculated padding that must be reserved in the drawing area. + * Returns the registered axes, an API for adding/removing axes as well as the total calculated padding that must be reserved in the drawing area. */ export const useTotalAxisPadding = () => { const [renderedAxes, setRenderedAxes] = useState>(new Map()); diff --git a/packages/mobile-visualization/src/chart/utils/chart.ts b/packages/mobile-visualization/src/chart/utils/chart.ts index 3531aee9c..62a18ca73 100644 --- a/packages/mobile-visualization/src/chart/utils/chart.ts +++ b/packages/mobile-visualization/src/chart/utils/chart.ts @@ -24,28 +24,28 @@ export type Series = { * Id of the series. */ id: string; - /** - * Data array for this series. Use null values to create gaps in the visualization. - * - * Can be either: - * - Array of numbers: `[10, -5, 20]` - * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs - */ - data?: Array | Array<[number, number] | null>; /** * Label of the series. - * Used for scrubber beacon labels. */ label?: string; /** * Color for the series. - * If gradient is provided, that will be used for chart components - * Color will still be used by scrubber beacon labels */ color?: string; +}; + +export type CartesianSeries = Series & { + /** + * Data array for this series. Use null values to create gaps in the visualization. + * + * Can be either: + * - Array of numbers: `[10, -5, 20]` + * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs + */ + data?: Array | Array<[number, number] | null>; /** * Color gradient configuration. - * Takes precedence over color except for scrubber beacon labels. + * Takes precedence over color for drawing area components (such as Line, Area, Bar, and ScrubberBeacon). */ gradient?: GradientDefinition; /** @@ -61,12 +61,111 @@ export type Series = { stackId?: string; }; +export type PolarSeries = Series & { + /** + * Data for the series. + * - Single number for pie/donut charts (each series = one slice) + * - Array of numbers for radar/radial bar charts (each series = multiple points) + */ + data: number | Array; + /** + * ID of the angular axis this series should use. + * If not specified, uses the default angular axis. + */ + angularAxisId?: string; + /** + * ID of the radial axis this series should use. + * If not specified, uses the default radial axis. + */ + radialAxisId?: string; +}; + +/** + * Calculates the angular domain from polar series data. + * For pie/donut: domain is 0 to series.length - 1 + * For radar: domain is 0 to data array length - 1 + */ +export const getPolarAngularDomain = ( + series: PolarSeries[], + min?: number, + max?: number, +): Partial => { + const domain = { min, max }; + + if (domain.min !== undefined && domain.max !== undefined) { + return domain; + } + + if (series.length === 0) { + return domain; + } + + const firstSeriesData = series[0].data; + + if (typeof firstSeriesData === 'number') { + // Pie/donut: each series is a slice + if (domain.min === undefined) domain.min = 0; + if (domain.max === undefined) domain.max = series.length - 1; + } else if (Array.isArray(firstSeriesData)) { + // Radar: domain is based on data array length + const dataLength = Math.max(...series.map((s) => (Array.isArray(s.data) ? s.data.length : 0))); + if (dataLength > 0) { + if (domain.min === undefined) domain.min = 0; + if (domain.max === undefined) domain.max = dataLength - 1; + } + } + + return domain; +}; + +/** + * Calculates the radial range (value extent) from polar series data. + */ +export const getPolarRadialRange = ( + series: PolarSeries[], + min?: number, + max?: number, +): Partial => { + const range = { min, max }; + + if (range.min !== undefined && range.max !== undefined) { + return range; + } + + if (series.length === 0) { + return range; + } + + const allValues: number[] = []; + + series.forEach((s) => { + if (typeof s.data === 'number') { + allValues.push(s.data); + } else if (Array.isArray(s.data)) { + s.data.forEach((value) => { + if (typeof value === 'number') { + allValues.push(value); + } + }); + } + }); + + if (allValues.length > 0) { + const minValue = Math.min(...allValues); + const maxValue = Math.max(...allValues); + if (range.min === undefined) range.min = Math.min(0, minValue); + if (range.max === undefined) range.max = maxValue; + } + + return range; +}; + /** * Calculates the domain of a chart from series data. * Domain represents the range of x-values from the data. */ -export const getChartDomain = ( - series: Series[], +export const getCartesianDomain = ( + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -95,7 +194,7 @@ export const getChartDomain = ( * Creates a composite stack key that includes both stack ID and y-axis ID. * This ensures series with different y-scales don't get stacked together. */ -const createStackKey = (series: Series): string | undefined => { +const createCartesianStackKey = (series: CartesianSeries): string | undefined => { if (series.stackId === undefined) return undefined; // Include y-axis ID to prevent cross-scale stacking @@ -110,8 +209,8 @@ const createStackKey = (series: Series): string | undefined => { * @param series - Array of series with potential stack properties * @returns Map of series ID to stacked data arrays */ -export const getStackedSeriesData = ( - series: Series[], +export const getCartesianStackedSeriesData = ( + series: CartesianSeries[], ): Map> => { const stackedDataMap = new Map>(); @@ -119,7 +218,7 @@ export const getStackedSeriesData = ( const individualSeries: typeof series = []; series.forEach((s) => { - const stackKey = createStackKey(s); + const stackKey = createCartesianStackKey(s); const hasTupleData = s.data?.some((val) => Array.isArray(val)); if (hasTupleData || stackKey === undefined) { @@ -220,8 +319,8 @@ export const getLineData = ( * Range represents the range of y-values from the data. * Handles stacking by transforming data when series have stack properties. */ -export const getChartRange = ( - series: Series[], +export const getCartesianRange = ( + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -241,7 +340,7 @@ export const getChartRange = ( // Group series by composite stack key for proper calculation const stackGroups = new Map(); series.forEach((s) => { - const stackKey = createStackKey(s); + const stackKey = createCartesianStackKey(s); if (!stackGroups.has(stackKey)) { stackGroups.set(stackKey, []); } @@ -253,7 +352,7 @@ export const getChartRange = ( if (hasStacks) { // Get stacked data using the shared function - const stackedDataMap = getStackedSeriesData(series); + const stackedDataMap = getCartesianStackedSeriesData(series); // Find the extreme values from the stacked data let stackedMax = 0; diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index d6372574b..0c49f3fdc 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -3,34 +3,22 @@ import type { SharedValue } from 'react-native-reanimated'; import type { Rect } from '@coinbase/cds-common/types'; import type { SkTypefaceFontProvider } from '@shopify/react-native-skia'; -import type { AxisConfig } from './axis'; -import type { Series } from './chart'; +import type { AngularAxisConfig, CartesianAxisConfig, RadialAxisConfig } from './axis'; +import type { CartesianSeries, PolarSeries, Series } from './chart'; import type { ChartScaleFunction, SerializableScale } from './scale'; /** - * Context value for Cartesian (X/Y) coordinate charts. - * Contains axis-specific methods and properties for rectangular coordinate systems. + * Context value for charts. */ -export type CartesianChartContextValue = { - /** - * The series data for the chart. - */ - series: Series[]; - /** - * Returns the series which matches the seriesId or undefined. - * @param seriesId - A series' id - */ - getSeries: (seriesId?: string) => Series | undefined; - /** - * Returns the data for a series - * @param seriesId - A series' id - * @returns data for series, if series exists - */ - getSeriesData: (seriesId?: string) => Array<[number, number] | null> | undefined; +export type ChartContextValue = { /** * Whether to animate the chart. */ animate: boolean; + /** + * Drawing area of the chart. + */ + drawingArea: Rect; /** * Width of the chart SVG. */ @@ -48,15 +36,41 @@ export type CartesianChartContextValue = { * Skia font provider. */ fontProvider: SkTypefaceFontProvider; + /** + * Length of the data domain. + */ + dataLength: number; +}; + +/** + * Context value for Cartesian (X/Y) coordinate charts. + * Contains axis-specific methods and properties for rectangular coordinate systems. + */ +export type CartesianChartContextValue = ChartContextValue & { + /** + * The series data for the chart. + */ + series: CartesianSeries[]; + /** + * Returns the series which matches the seriesId or undefined. + * @param seriesId - A series' id + */ + getSeries: (seriesId?: string) => CartesianSeries | undefined; + /** + * Returns the data for a series + * @param seriesId - A series' id + * @returns data for series, if series exists + */ + getSeriesData: (seriesId?: string) => Array<[number, number] | null> | undefined; /** * Get x-axis configuration. */ - getXAxis: () => AxisConfig | undefined; + getXAxis: () => CartesianAxisConfig | undefined; /** * Get y-axis configuration by ID. * @param id - The axis ID. Defaults to defaultAxisId. */ - getYAxis: (id?: string) => AxisConfig | undefined; + getYAxis: (id?: string) => CartesianAxisConfig | undefined; /** * Get x-axis scale function. */ @@ -75,16 +89,6 @@ export type CartesianChartContextValue = { * @param id - The axis ID. Defaults to defaultAxisId. */ getYSerializableScale: (id?: string) => SerializableScale | undefined; - /** - * Drawing area of the chart. - */ - drawingArea: Rect; - /** - * Length of the data domain. - * This is equal to the length of xAxis.data or the longest series data length - * This equals the number of possible scrubber positions - */ - dataLength: number; /** * Registers an axis. * Used by axis components to reserve space in the chart, preventing overlap with the drawing area. @@ -104,6 +108,66 @@ export type CartesianChartContextValue = { getAxisBounds: (id: string) => Rect | undefined; }; +/** + * Context value for Polar (Angular/Radial) coordinate charts. + * Contains axis-specific methods and properties for polar coordinate systems. + */ +export type PolarChartContextValue = ChartContextValue & { + /** + * The series data for the chart. + */ + series: PolarSeries[]; + /** + * Returns the series which matches the seriesId or undefined. + * @param seriesId - A series' id + */ + getSeries: (seriesId?: string) => PolarSeries | undefined; + /** + * Returns the data for a series. + * @param seriesId - A series' id + * @returns data for series, if series exists + */ + getSeriesData: (seriesId?: string) => number | Array | undefined; + /** + * Outer radius of the polar chart in pixels. + */ + outerRadius: number; + /** + * Returns the angular axis configuration by ID. + * If no ID is provided, returns the default angular axis. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getAngularAxis: (id?: string) => AngularAxisConfig | undefined; + /** + * Returns the radial axis configuration by ID. + * If no ID is provided, returns the default radial axis. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getRadialAxis: (id?: string) => RadialAxisConfig | undefined; + /** + * Get angular axis scale function by ID. + * Maps data indices/values to angles in radians. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getAngularScale: (id?: string) => ChartScaleFunction | undefined; + /** + * Get radial axis scale function by ID. + * Maps data values to pixel distances from center. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getRadialScale: (id?: string) => ChartScaleFunction | undefined; + /** + * Get angular axis serializable scale function by ID that can be used in worklets. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getAngularSerializableScale: (id?: string) => SerializableScale | undefined; + /** + * Get radial axis serializable scale function by ID that can be used in worklets. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getRadialSerializableScale: (id?: string) => SerializableScale | undefined; +}; + export type ScrubberContextValue = { /** * Enables scrubbing interactions. diff --git a/packages/mobile-visualization/src/chart/utils/index.ts b/packages/mobile-visualization/src/chart/utils/index.ts index 0bf7ad953..1d9468590 100644 --- a/packages/mobile-visualization/src/chart/utils/index.ts +++ b/packages/mobile-visualization/src/chart/utils/index.ts @@ -6,6 +6,7 @@ export * from './context'; export * from './gradient'; export * from './path'; export * from './point'; +export * from './polar'; export * from './scale'; export * from './scrubber'; export * from './transition'; diff --git a/packages/mobile-visualization/src/chart/utils/path.ts b/packages/mobile-visualization/src/chart/utils/path.ts index dd3330e92..05003ed8f 100644 --- a/packages/mobile-visualization/src/chart/utils/path.ts +++ b/packages/mobile-visualization/src/chart/utils/path.ts @@ -1,4 +1,5 @@ import { + arc as d3Arc, area as d3Area, curveBumpX, curveCatmullRom, @@ -351,3 +352,60 @@ export const getDottedAreaPath = ( return path.trim(); }; + +/** + * Generates an SVG arc path string for pie/donut charts. + * Uses D3's arc generator for consistent arc rendering. + * + * @param startAngle - Start angle in radians (0 at top, increasing clockwise) + * @param endAngle - End angle in radians (0 at top, increasing clockwise) + * @param innerRadius - Inner radius in pixels (0 for pie chart) + * @param outerRadius - Outer radius in pixels + * @param cornerRadius - Corner radius in pixels + * @param paddingAngle - Padding angle in radians between adjacent arcs + * @returns SVG path string for the arc + * + * @example + * ```typescript + * const arcPath = getArcPath({ + * startAngle: 0, + * endAngle: Math.PI, + * innerRadius: 0, + * outerRadius: 100, + * cornerRadius: 4, + * paddingAngle: 0.02 + * }); + * ``` + */ +export const getArcPath = ({ + startAngle, + endAngle, + innerRadius, + outerRadius, + cornerRadius = 0, + paddingAngle = 0, +}: { + /** Start angle in radians (0 at top, increasing clockwise) */ + startAngle: number; + /** End angle in radians (0 at top, increasing clockwise) */ + endAngle: number; + /** Inner radius in pixels (0 for pie chart) */ + innerRadius: number; + /** Outer radius in pixels */ + outerRadius: number; + /** Corner radius in pixels */ + cornerRadius?: number; + /** Padding angle in radians between adjacent arcs */ + paddingAngle?: number; +}): string => { + if (outerRadius <= 0 || startAngle === endAngle) return ''; + + const path = d3Arc().cornerRadius(cornerRadius).padAngle(paddingAngle)({ + innerRadius: Math.max(0, innerRadius), + outerRadius: Math.max(0, outerRadius), + startAngle, + endAngle, + }); + + return path ?? ''; +}; diff --git a/packages/mobile-visualization/src/chart/utils/polar.ts b/packages/mobile-visualization/src/chart/utils/polar.ts new file mode 100644 index 000000000..d20d83cdf --- /dev/null +++ b/packages/mobile-visualization/src/chart/utils/polar.ts @@ -0,0 +1,109 @@ +import { pie as d3Pie } from 'd3-shape'; + +import type { AngularAxisConfig, AxisConfig } from './axis'; + +/** + * Converts degrees to radians. + */ +export const degreesToRadians = (degrees: number): number => { + return (degrees * Math.PI) / 180; +}; + +/** + * Converts radians to degrees. + */ +export const radiansToDegrees = (radians: number): number => { + return (radians * 180) / Math.PI; +}; + +/** + * Extracts angular axis values (in radians) from axis config. + * Range values are expected in radians, paddingAngle in degrees (converted to radians). + * @param axisConfig - The angular axis configuration + */ +export const getAngularAxisRadians = ( + axisConfig?: Partial, +): { startAngle: number; endAngle: number; paddingAngle: number } => { + const range = axisConfig?.range ?? { min: 0, max: 2 * Math.PI }; + const startAngle = range.min ?? 0; + const endAngle = range.max ?? 2 * Math.PI; + // Convert paddingAngle from degrees to radians + const paddingAngle = degreesToRadians(axisConfig?.paddingAngle ?? 0); + + return { startAngle, endAngle, paddingAngle }; +}; + +/** + * Extracts radial axis values (in pixels) from axis config. + * @param maxRadius - The maximum available radius + * @param axisConfig - The radial axis configuration + */ +export const getRadialAxisPixels = ( + maxRadius: number, + axisConfig?: Partial, +): { innerRadius: number; outerRadius: number } => { + const range = axisConfig?.range ?? { min: 0, max: 1 }; + // Range values are typically 0-1 representing percentage of maxRadius + const innerRadius = (range.min ?? 0) * maxRadius; + const outerRadius = (range.max ?? 1) * maxRadius; + + return { innerRadius, outerRadius }; +}; + +/** + * Calculates arc geometry for pie/donut chart slices using D3's pie generator. + * + * All angle inputs are in degrees; outputs are in radians for rendering. + * + * @param values - Array of numeric values for each slice + * @param innerRadius - Inner radius in pixels + * @param outerRadius - Outer radius in pixels + * @param startAngleDegrees - Start angle in degrees (default: 0, 3 o'clock) + * @param endAngleDegrees - End angle in degrees (default: 360, full circle) + * @param paddingAngleDegrees - Padding between slices in degrees (default: 0) + * @returns Array of arc data objects with angles in radians for rendering + */ +export const calculateArcData = ( + values: number[], + innerRadius: number, + outerRadius: number, + startAngleDegrees = 0, + endAngleDegrees = 360, + paddingAngleDegrees = 0, +): Array<{ + startAngle: number; + endAngle: number; + paddingAngle: number; + innerRadius: number; + outerRadius: number; + index: number; + value: number; +}> => { + if (values.length === 0) { + return []; + } + + // Convert degrees to radians for D3 + const startAngleRadians = degreesToRadians(startAngleDegrees); + const endAngleRadians = degreesToRadians(endAngleDegrees); + const paddingAngleRadians = degreesToRadians(paddingAngleDegrees); + + const pieGenerator = d3Pie() + .value((d) => Math.abs(d)) + .startAngle(startAngleRadians) + .endAngle(endAngleRadians) + .padAngle(paddingAngleRadians) + .sort(null); // Preserve data order + + const pieData = pieGenerator(values); + + return pieData.map((d, index) => ({ + startAngle: d.startAngle, + endAngle: d.endAngle, + paddingAngle: d.padAngle, + innerRadius, + outerRadius, + index, + value: d.data, + })); +}; diff --git a/packages/mobile/src/alpha/select-chip/__stories__/SelectChip.stories.tsx b/packages/mobile/src/alpha/select-chip/__stories__/AlphaSelectChip.stories.tsx similarity index 100% rename from packages/mobile/src/alpha/select-chip/__stories__/SelectChip.stories.tsx rename to packages/mobile/src/alpha/select-chip/__stories__/AlphaSelectChip.stories.tsx diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts index a5684309d..293096742 100644 --- a/packages/ui-mobile-playground/src/routes.ts +++ b/packages/ui-mobile-playground/src/routes.ts @@ -43,6 +43,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, }, + { + key: 'AlphaSelectChip', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, + }, { key: 'AlphaTabbedChips', getComponent: () => @@ -466,6 +471,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/illustrations/__stories__/Pictogram.stories').default, }, + { + key: 'PolarChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/PolarChart.stories').default, + }, { key: 'Pressable', getComponent: () => diff --git a/packages/ui-mobile-visreg/src/routes.ts b/packages/ui-mobile-visreg/src/routes.ts index a5684309d..293096742 100644 --- a/packages/ui-mobile-visreg/src/routes.ts +++ b/packages/ui-mobile-visreg/src/routes.ts @@ -43,6 +43,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, }, + { + key: 'AlphaSelectChip', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, + }, { key: 'AlphaTabbedChips', getComponent: () => @@ -466,6 +471,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/illustrations/__stories__/Pictogram.stories').default, }, + { + key: 'PolarChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/PolarChart.stories').default, + }, { key: 'Pressable', getComponent: () => diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 2b541d2a3..cfd5a89dc 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -8,20 +8,20 @@ import { css } from '@linaria/core'; import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider'; import { CartesianChartProvider } from './ChartProvider'; import { - type AxisConfig, - type AxisConfigProps, + type CartesianAxisConfig, + type CartesianAxisConfigProps, type CartesianChartContextValue, + type CartesianSeries, type ChartInset, type ChartScaleFunction, defaultAxisId, defaultChartInset, - getAxisConfig, - getAxisDomain, getAxisRange, - getAxisScale, + getCartesianAxisConfig, + getCartesianAxisDomain, + getCartesianAxisScale, + getCartesianStackedSeriesData as calculateStackedSeriesData, getChartInset, - getStackedSeriesData as calculateStackedSeriesData, - type Series, useTotalAxisPadding, } from './utils'; @@ -41,7 +41,7 @@ export type CartesianChartBaseProps = BoxBaseProps & * Configuration objects that define how to visualize the data. * Each series contains its own data array. */ - series?: Array; + series?: Array; /** * Whether to animate the chart. * @default true @@ -50,11 +50,13 @@ export type CartesianChartBaseProps = BoxBaseProps & /** * Configuration for x-axis. */ - xAxis?: Partial>; + xAxis?: Partial>; /** * Configuration for y-axis(es). Can be a single config or array of configs. */ - yAxis?: Partial> | Partial>[]; + yAxis?: + | Partial> + | Partial>[]; /** * Inset around the entire chart (outside the axes). */ @@ -128,8 +130,14 @@ export const CartesianChart = memo( // Axis configs store the properties of each axis, such as id, scale type, domain limit, etc. // We only support 1 x axis but allow for multiple y axes. - const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp)[0], [xAxisConfigProp]); - const yAxisConfig = useMemo(() => getAxisConfig('y', yAxisConfigProp), [yAxisConfigProp]); + const xAxisConfig = useMemo( + () => getCartesianAxisConfig('x', xAxisConfigProp)[0], + [xAxisConfigProp], + ); + const yAxisConfig = useMemo( + () => getCartesianAxisConfig('y', yAxisConfigProp), + [yAxisConfigProp], + ); const { renderedAxes, registerAxis, unregisterAxis, axisPadding } = useTotalAxisPadding(); @@ -158,10 +166,10 @@ export const CartesianChart = memo( if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) return { xAxis: undefined, xScale: undefined }; - const domain = getAxisDomain(xAxisConfig, series ?? [], 'x'); + const domain = getCartesianAxisDomain(xAxisConfig, series ?? [], 'x'); const range = getAxisRange(xAxisConfig, chartRect, 'x'); - const axisConfig: AxisConfig = { + const axisConfig: CartesianAxisConfig = { scaleType: xAxisConfig.scaleType, domain, range, @@ -171,7 +179,7 @@ export const CartesianChart = memo( }; // Create the scale - const scale = getAxisScale({ + const scale = getCartesianAxisScale({ config: axisConfig, type: 'x', range: axisConfig.range, @@ -196,7 +204,7 @@ export const CartesianChart = memo( }, [xAxisConfig, series, chartRect]); const { yAxes, yScales } = useMemo(() => { - const axes = new Map(); + const axes = new Map(); const scales = new Map(); if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) return { yAxes: axes, yScales: scales }; @@ -209,20 +217,20 @@ export const CartesianChart = memo( series?.filter((s) => (s.yAxisId ?? defaultAxisId) === axisId) ?? []; // Calculate domain and range - const dataDomain = getAxisDomain(axisParam, relevantSeries, 'y'); + const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'y'); const range = getAxisRange(axisParam, chartRect, 'y'); - const axisConfig: AxisConfig = { + const axisConfig: CartesianAxisConfig = { scaleType: axisParam.scaleType, domain: dataDomain, range, data: axisParam.data, categoryPadding: axisParam.categoryPadding, - domainLimit: axisParam.domainLimit ?? 'nice', + domainLimit: axisParam.domainLimit, }; // Create the scale - const scale = getAxisScale({ + const scale = getCartesianAxisScale({ config: axisConfig, type: 'y', range: axisConfig.range, diff --git a/packages/web-visualization/src/chart/ChartProvider.tsx b/packages/web-visualization/src/chart/ChartProvider.tsx index 34ac00c48..bc2cb31e1 100644 --- a/packages/web-visualization/src/chart/ChartProvider.tsx +++ b/packages/web-visualization/src/chart/ChartProvider.tsx @@ -1,9 +1,18 @@ import { createContext, useContext } from 'react'; -import type { CartesianChartContextValue } from './utils/context'; +import type { + CartesianChartContextValue, + ChartContextValue, + PolarChartContextValue, +} from './utils/context'; const CartesianChartContext = createContext(undefined); +const PolarChartContext = createContext(undefined); +/** + * Hook to access the CartesianChart context. + * Must be used within a CartesianChart component. + */ export const useCartesianChartContext = (): CartesianChartContextValue => { const context = useContext(CartesianChartContext); if (!context) { @@ -15,3 +24,40 @@ export const useCartesianChartContext = (): CartesianChartContextValue => { }; export const CartesianChartProvider = CartesianChartContext.Provider; + +/** + * Hook to access the PolarChart context. + * Must be used within a PolarChart component. + */ +export const usePolarChartContext = (): PolarChartContextValue => { + const context = useContext(PolarChartContext); + if (!context) { + throw new Error( + 'usePolarChartContext must be used within a PolarChart component. See http://cds.coinbase.com/components/graphs/PolarChart.', + ); + } + return context; +}; + +export const PolarChartProvider = PolarChartContext.Provider; + +/** + * Hook to access chart context. + * Use this for components that need to work in any chart type (e.g., ChartText). + * + * @example + * const { width, height, drawingArea } = useChartContext(); + */ +export const useChartContext = (): ChartContextValue => { + const cartesian = useContext(CartesianChartContext); + const polar = useContext(PolarChartContext); + + const context = cartesian ?? polar; + if (!context) { + throw new Error( + 'useChartContext must be used within a Chart component (CartesianChart or PolarChart).', + ); + } + + return context; +}; diff --git a/packages/web-visualization/src/chart/DonutChart.tsx b/packages/web-visualization/src/chart/DonutChart.tsx new file mode 100644 index 000000000..9fc210801 --- /dev/null +++ b/packages/web-visualization/src/chart/DonutChart.tsx @@ -0,0 +1,95 @@ +import React, { forwardRef, memo, useMemo } from 'react'; + +import { PiePlot, type PiePlotProps, type PieSeries } from './pie'; +import { PolarChart, type PolarChartBaseProps, type PolarChartProps } from './PolarChart'; + +/** + * Series type for DonutChart + */ +export type DonutSeries = PieSeries; + +export type DonutChartBaseProps = Omit & + Pick & { + /** + * Array of series, where each series represents one slice. + * Each series must have a single numeric value. + */ + series?: DonutSeries[]; + /** + * Inner radius as a ratio of the outer radius (0-1). + * This sets the default radial axis to: `range: ({ max }) => ({ min: max * innerRadiusRatio, max })` + * + * @note if you provide a custom `radialAxis` prop, this will be ignored. + * @default 0.5 + */ + innerRadiusRatio?: number; + }; + +export type DonutChartProps = DonutChartBaseProps & Omit; + +/** + * A donut chart component for visualizing proportional data with a hollow center. + * Each series represents one slice, with its value as a proportion of the total. + * The hollow center can be used for displaying additional information. + * + * @example + * ```tsx + * + * ``` + */ +export const DonutChart = memo( + forwardRef( + ( + { + series, + children, + innerRadiusRatio = 0.5, + ArcComponent, + fillOpacity, + stroke, + strokeWidth, + cornerRadius, + radialAxis, + ...chartProps + }, + ref, + ) => { + const defaultRadialAxis = useMemo( + () => ({ + range: ({ max }: { min: number; max: number }) => ({ + min: max * innerRadiusRatio, + max, + }), + }), + [innerRadiusRatio], + ); + + return ( + + + {children} + + ); + }, + ), +); diff --git a/packages/web-visualization/src/chart/Path.tsx b/packages/web-visualization/src/chart/Path.tsx index 720d90c21..1b39d0dfe 100644 --- a/packages/web-visualization/src/chart/Path.tsx +++ b/packages/web-visualization/src/chart/Path.tsx @@ -4,7 +4,7 @@ import type { Rect, SharedProps } from '@coinbase/cds-common/types'; import { m as motion, type Transition } from 'framer-motion'; import { usePathTransition } from './utils/transition'; -import { useCartesianChartContext } from './ChartProvider'; +import { useChartContext } from './ChartProvider'; /** * Duration in seconds for path enter transition. @@ -70,7 +70,7 @@ const AnimatedPath = memo>(({ d = '', transition, ... export const Path = memo( ({ animate: animateProp, clipRect, clipOffset = 0, d = '', transition, ...pathProps }) => { const clipPathId = useId(); - const context = useCartesianChartContext(); + const context = useChartContext(); const rect = clipRect !== undefined ? clipRect : context.drawingArea; const animate = animateProp ?? context.animate; diff --git a/packages/web-visualization/src/chart/PolarChart.tsx b/packages/web-visualization/src/chart/PolarChart.tsx new file mode 100644 index 000000000..2dcce4a55 --- /dev/null +++ b/packages/web-visualization/src/chart/PolarChart.tsx @@ -0,0 +1,387 @@ +import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import type { Rect } from '@coinbase/cds-common/types'; +import { cx } from '@coinbase/cds-web'; +import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions'; +import { Box, type BoxBaseProps, type BoxProps } from '@coinbase/cds-web/layout'; + +import { PolarChartProvider } from './ChartProvider'; +import { + type AngularAxisConfig, + type AngularAxisConfigProps, + type ChartInset, + type ChartScaleFunction, + defaultAxisId, + defaultChartInset, + getAngularAxisConfig, + getChartInset, + getPolarAxisDomain, + getPolarAxisRange, + getPolarAxisScale, + getRadialAxisConfig, + type PolarChartContextValue, + type PolarSeries, + type RadialAxisConfig, + type RadialAxisConfigProps, +} from './utils'; + +export type PolarChartBaseProps = BoxBaseProps & { + /** + * Configuration object that defines the data to visualize. + */ + series?: PolarSeries[]; + /** + * Whether to animate the chart. + * @default true + */ + animate?: boolean; + /** + * Configuration for angular axis/axes (controls start/end angles). + * Can be a single axis config or an array of axis configs for multiple angular ranges. + * Default range: { min: 0, max: 360 } (full circle) + * + * @example + * Single axis (default): + * ```tsx + * // Semicircle + * + * + * // Add padding between slices + * + * ``` + * + * @example + * Multiple axes: + * ```tsx + * + * ``` + */ + angularAxis?: Partial | Partial[]; + /** + * Configuration for radial axis/axes (controls inner/outer radii). + * Can be a single axis config or an array of axis configs for multiple radial ranges. + * Default range: { min: 0, max: [radius in pixels] } (pie chart using full radius) + * + * @example + * Single axis (default): + * ```tsx + * // Donut chart with 50% inner radius + * ({ min: max * 0.5, max }) }} /> + * ``` + * + * @example + * Multiple axes (nested rings): + * ```tsx + * ({ min: 0, max: max * 0.4 }) }, + * { id: 'outer', range: ({ max }) => ({ min: max * 0.6, max }) }, + * ]} + * series={[ + * { id: 'innerData', data: [...], radialAxisId: 'inner' }, + * { id: 'outerData', data: [...], radialAxisId: 'outer' }, + * ]} + * /> + * ``` + */ + radialAxis?: Partial | Partial[]; + /** + * Inset around the entire chart (outside the drawing area). + */ + inset?: number | Partial; +}; + +export type PolarChartProps = Omit, 'title'> & + PolarChartBaseProps & { + /** + * Custom class name for the root element. + */ + className?: string; + /** + * Custom class names for the component. + */ + classNames?: { + /** + * Custom class name for the root element. + */ + root?: string; + /** + * Custom class name for the chart SVG element. + */ + chart?: string; + }; + /** + * Custom styles for the root element. + */ + style?: React.CSSProperties; + /** + * Custom styles for the component. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: React.CSSProperties; + /** + * Custom styles for the chart SVG element. + */ + chart?: React.CSSProperties; + }; + }; + +/** + * Base component for polar coordinate charts (pie, donut). + * Provides context and layout for polar chart child components. + */ +export const PolarChart = memo( + forwardRef( + ( + { + series, + children, + animate = true, + angularAxis, + radialAxis, + inset: insetInput, + width = '100%', + height = '100%', + className, + classNames, + style, + styles, + ...props + }, + ref, + ) => { + const { observe, width: chartWidth, height: chartHeight } = useDimensions(); + + const inset = useMemo(() => { + return getChartInset(insetInput, defaultChartInset); + }, [insetInput]); + + // Normalize axis configs (same pattern as CartesianChart) + const angularAxisConfig = useMemo(() => getAngularAxisConfig(angularAxis), [angularAxis]); + const radialAxisConfig = useMemo(() => getRadialAxisConfig(radialAxis), [radialAxis]); + + // Calculate drawing area - always square for polar charts + const drawingArea: Rect = useMemo(() => { + if (chartWidth <= 0 || chartHeight <= 0) return { x: 0, y: 0, width: 0, height: 0 }; + + const availableWidth = chartWidth - inset.left - inset.right; + const availableHeight = chartHeight - inset.top - inset.bottom; + + // Use the smaller dimension to create a square drawing area + const size = Math.min( + availableWidth > 0 ? availableWidth : 0, + availableHeight > 0 ? availableHeight : 0, + ); + + // Center the square drawing area within the available space + const offsetX = (availableWidth - size) / 2; + const offsetY = (availableHeight - size) / 2; + + return { + x: inset.left + offsetX, + y: inset.top + offsetY, + width: size, + height: size, + }; + }, [chartWidth, chartHeight, inset]); + + const outerRadius = Math.min(drawingArea.width, drawingArea.height) / 2; + + const getSeries = useCallback( + (seriesId?: string) => series?.find((s) => s.id === seriesId), + [series], + ); + + const getSeriesData = useCallback( + (seriesId?: string) => series?.find((s) => s.id === seriesId)?.data, + [series], + ); + + const { angularAxes, angularScales } = useMemo(() => { + const axes = new Map(); + const scales = new Map(); + + if (drawingArea.width <= 0 || drawingArea.height <= 0) + return { angularAxes: axes, angularScales: scales }; + + angularAxisConfig.forEach((axisParam) => { + const axisId = axisParam.id ?? defaultAxisId; + + const relevantSeries = + series?.filter((s) => (s.angularAxisId ?? defaultAxisId) === axisId) ?? []; + + const domain = getPolarAxisDomain(axisParam, relevantSeries, 'angular'); + const range = getPolarAxisRange(axisParam, 'angular', outerRadius); + + const axisConfig: AngularAxisConfig = { + scaleType: axisParam.scaleType ?? 'linear', + domain, + range, + paddingAngle: axisParam.paddingAngle, + }; + + const scale = getPolarAxisScale({ + config: axisConfig, + range: axisConfig.range, + dataDomain: axisConfig.domain, + }); + + if (scale) { + scales.set(axisId, scale); + axes.set(axisId, axisConfig); + } + }); + + return { angularAxes: axes, angularScales: scales }; + }, [angularAxisConfig, series, drawingArea, outerRadius]); + + const { radialAxes, radialScales } = useMemo(() => { + const axes = new Map(); + const scales = new Map(); + + if (drawingArea.width <= 0 || drawingArea.height <= 0 || outerRadius <= 0) + return { radialAxes: axes, radialScales: scales }; + + radialAxisConfig.forEach((axisParam) => { + const axisId = axisParam.id ?? defaultAxisId; + + const relevantSeries = + series?.filter((s) => (s.radialAxisId ?? defaultAxisId) === axisId) ?? []; + + const domain = getPolarAxisDomain(axisParam, relevantSeries, 'radial'); + const range = getPolarAxisRange(axisParam, 'radial', outerRadius); + + const axisConfig: RadialAxisConfig = { + scaleType: axisParam.scaleType ?? 'linear', + domain, + range, + }; + + const scale = getPolarAxisScale({ + config: axisConfig, + range: axisConfig.range, + dataDomain: axisConfig.domain, + }); + + if (scale) { + scales.set(axisId, scale); + + const scaleDomain = scale.domain(); + const actualDomain = + Array.isArray(scaleDomain) && scaleDomain.length === 2 + ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } + : axisConfig.domain; + + axes.set(axisId, { ...axisConfig, domain: actualDomain }); + } + }); + + return { radialAxes: axes, radialScales: scales }; + }, [radialAxisConfig, series, drawingArea, outerRadius]); + + const getAngularAxis = useCallback( + (id?: string) => angularAxes.get(id ?? defaultAxisId), + [angularAxes], + ); + const getRadialAxis = useCallback( + (id?: string) => radialAxes.get(id ?? defaultAxisId), + [radialAxes], + ); + const getAngularScale = useCallback( + (id?: string) => angularScales.get(id ?? defaultAxisId), + [angularScales], + ); + const getRadialScale = useCallback( + (id?: string) => radialScales.get(id ?? defaultAxisId), + [radialScales], + ); + + const dataLength = useMemo(() => { + if (!series || series.length === 0) return 0; + const firstSeriesData = series[0].data; + if (typeof firstSeriesData === 'number') return series.length; + if (Array.isArray(firstSeriesData)) { + return Math.max(...series.map((s) => (Array.isArray(s.data) ? s.data.length : 0))); + } + return 0; + }, [series]); + + const contextValue: PolarChartContextValue = useMemo( + () => ({ + series: series ?? [], + getSeries, + getSeriesData, + animate, + width: chartWidth, + height: chartHeight, + drawingArea, + outerRadius, + getAngularAxis, + getRadialAxis, + getAngularScale, + getRadialScale, + dataLength, + }), + [ + series, + getSeries, + getSeriesData, + animate, + chartWidth, + chartHeight, + drawingArea, + outerRadius, + getAngularAxis, + getRadialAxis, + getAngularScale, + getRadialScale, + dataLength, + ], + ); + + const rootClassNames = useMemo( + () => cx(className, classNames?.root), + [className, classNames], + ); + const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]); + + return ( + + { + observe(node as unknown as HTMLElement); + }} + className={rootClassNames} + height={height} + style={rootStyles} + width={width} + {...props} + > + + {children} + + + + ); + }, + ), +); diff --git a/packages/web-visualization/src/chart/__stories__/PolarChart.stories.tsx b/packages/web-visualization/src/chart/__stories__/PolarChart.stories.tsx new file mode 100644 index 000000000..dc8f1765b --- /dev/null +++ b/packages/web-visualization/src/chart/__stories__/PolarChart.stories.tsx @@ -0,0 +1,633 @@ +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { useTheme } from '@coinbase/cds-web'; +import { IconButton } from '@coinbase/cds-web/buttons'; +import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; + +import { usePolarChartContext } from '../ChartProvider'; +import { DonutChart } from '../DonutChart'; +import { Arc, PieChart, PiePlot, type PieSliceEventData } from '../pie'; +import { PolarChart } from '../PolarChart'; +import { getArcPath } from '../utils'; + +export default { + component: PolarChart, + title: 'Components/Chart/PolarChart', +}; + +const DonutCenterLabel = memo<{ children: React.ReactNode }>(({ children }) => { + const { drawingArea } = usePolarChartContext(); + + if (!drawingArea.width || !drawingArea.height) return; + + const centerX = drawingArea.x + drawingArea.width / 2; + const centerY = drawingArea.y + drawingArea.height / 2; + + return ( + + {children} + + ); +}); + +const BasicPieChart = () => { + const theme = useTheme(); + + return ( + + ); +}; + +const BasicDonutChart = () => { + const theme = useTheme(); + + return ( + + ); +}; + +const DonutWithCenterLabel = () => { + const theme = useTheme(); + + return ( + ({ min: max * 0.7, max }) }} + series={[ + { id: 'teal', data: 10, label: 'Other', color: `rgb(${theme.spectrum.teal40})` }, + { id: 'blue', data: 25, label: 'Bitcoin', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'purple', data: 20, label: 'Ethereum', color: `rgb(${theme.spectrum.purple40})` }, + { id: 'pink', data: 15, label: 'Solana', color: `rgb(${theme.spectrum.pink40})` }, + { id: 'orange', data: 30, label: 'USDC', color: `rgb(${theme.spectrum.orange40})` }, + ]} + width={200} + > + + $1,234 + + ); +}; + +const SemicircleDonut = () => { + const theme = useTheme(); + + return ( + ({ min: max * 0.6, max }) }} + series={[ + { id: 'a', data: 35, label: 'Complete', color: `rgb(${theme.spectrum.green40})` }, + { id: 'b', data: 65, label: 'Remaining', color: `rgb(${theme.spectrum.gray30})` }, + ]} + width={200} + > + + + ); +}; + +const PieWithPadding = () => { + const theme = useTheme(); + + return ( + + ); +}; + +const CustomStyledPie = () => { + const theme = useTheme(); + + return ( + + ); +}; + +const InteractiveDonutChart = () => { + const theme = useTheme(); + const [selectedSlice, setSelectedSlice] = useState(null); + + const series = useMemo( + () => [ + { id: 'btc', data: 40, label: 'Bitcoin', color: `rgb(${theme.spectrum.orange40})` }, + { id: 'eth', data: 30, label: 'Ethereum', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'sol', data: 15, label: 'Solana', color: `rgb(${theme.spectrum.purple40})` }, + { id: 'other', data: 15, label: 'Other', color: `rgb(${theme.spectrum.gray30})` }, + ], + [theme], + ); + + const total = series.reduce((sum, s) => sum + s.data, 0); + const selectedData = selectedSlice ? series.find((s) => s.id === selectedSlice) : null; + + const handleSliceClick = useCallback((data: PieSliceEventData) => { + setSelectedSlice((prev) => (prev === data.id ? null : data.id)); + }, []); + + return ( + + + ({ min: max * 0.65, max }) }} + series={series.map((s) => ({ + ...s, + color: selectedSlice && selectedSlice !== s.id ? `${s.color}80` : s.color, + }))} + width={200} + > + + + + + {selectedData ? selectedData.label : 'Total'} + + + {selectedData ? `${Math.round((selectedData.data / total) * 100)}%` : '$12,345'} + + + + + ); +}; + +const AnimatedDataChange = () => { + const theme = useTheme(); + const [dataSet, setDataSet] = useState(0); + + const dataSets = [ + [ + { id: 'a', data: 30, label: 'A', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 40, label: 'B', color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 30, label: 'C', color: `rgb(${theme.spectrum.orange40})` }, + ], + [ + { id: 'a', data: 60, label: 'A', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 20, label: 'B', color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 20, label: 'C', color: `rgb(${theme.spectrum.orange40})` }, + ], + [ + { id: 'a', data: 15, label: 'A', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'b', data: 55, label: 'B', color: `rgb(${theme.spectrum.green40})` }, + { id: 'c', data: 30, label: 'C', color: `rgb(${theme.spectrum.orange40})` }, + ], + ]; + + return ( + + + + setDataSet((prev) => (prev - 1 + dataSets.length) % dataSets.length)} + variant="secondary" + /> + Data Set {dataSet + 1} + setDataSet((prev) => (prev + 1) % dataSets.length)} + variant="secondary" + /> + + + ); +}; + +const NestedRings = () => { + const theme = useTheme(); + + return ( + ({ min: 0, max: max * 0.35 }) }, + { id: 'outer', range: ({ max }) => ({ min: max * 0.45, max }) }, + ]} + series={[ + // Inner ring + { + id: 'inner-a', + data: 60, + radialAxisId: 'inner', + color: `rgb(${theme.spectrum.blue40})`, + }, + { + id: 'inner-b', + data: 40, + radialAxisId: 'inner', + color: `rgb(${theme.spectrum.blue60})`, + }, + // Outer ring + { + id: 'outer-a', + data: 30, + radialAxisId: 'outer', + color: `rgb(${theme.spectrum.green40})`, + }, + { + id: 'outer-b', + data: 25, + radialAxisId: 'outer', + color: `rgb(${theme.spectrum.green50})`, + }, + { + id: 'outer-c', + data: 20, + radialAxisId: 'outer', + color: `rgb(${theme.spectrum.green60})`, + }, + { + id: 'outer-d', + data: 25, + radialAxisId: 'outer', + color: `rgb(${theme.spectrum.green70})`, + }, + ]} + width={250} + > + + + + ); +}; + +// Helper component for rewards background arcs +const RewardsBackgroundArcs = memo<{ + innerRadiusRatio: number; + startAngleDegrees: number; + firstSectionEnd: number; + secondSectionStart: number; + secondSectionEnd: number; + thirdSectionStart: number; + thirdSectionEnd: number; +}>( + ({ + innerRadiusRatio, + startAngleDegrees, + firstSectionEnd, + secondSectionStart, + secondSectionEnd, + thirdSectionStart, + thirdSectionEnd, + }) => { + const theme = useTheme(); + const { drawingArea } = usePolarChartContext(); + + const { innerRadius, outerRadius } = useMemo(() => { + const r = Math.min(drawingArea.width, drawingArea.height) / 2; + return { + innerRadius: r * innerRadiusRatio, + outerRadius: r, + }; + }, [drawingArea, innerRadiusRatio]); + + const sections = useMemo( + () => [ + { + startAngle: (startAngleDegrees * Math.PI) / 180, + endAngle: (firstSectionEnd * Math.PI) / 180, + }, + { + startAngle: (secondSectionStart * Math.PI) / 180, + endAngle: (secondSectionEnd * Math.PI) / 180, + }, + { + startAngle: (thirdSectionStart * Math.PI) / 180, + endAngle: (thirdSectionEnd * Math.PI) / 180, + }, + ], + [ + startAngleDegrees, + firstSectionEnd, + secondSectionStart, + secondSectionEnd, + thirdSectionStart, + thirdSectionEnd, + ], + ); + + return ( + <> + {sections.map((section, i) => ( + + ))} + + ); + }, +); + +// Helper component for rewards clipped progress +const RewardsClippedProgress = memo<{ + innerRadiusRatio: number; + startAngleDegrees: number; + firstSectionEnd: number; + secondSectionStart: number; + secondSectionEnd: number; + thirdSectionStart: number; + thirdSectionEnd: number; +}>( + ({ + innerRadiusRatio, + startAngleDegrees, + firstSectionEnd, + secondSectionStart, + secondSectionEnd, + thirdSectionStart, + thirdSectionEnd, + }) => { + const { drawingArea } = usePolarChartContext(); + + const clipPathId = useMemo(() => { + return `rewards-clip-${Math.random().toString(36).substr(2, 9)}`; + }, []); + + const clipPath = useMemo(() => { + const r = Math.min(drawingArea.width, drawingArea.height) / 2; + const innerRadius = r * innerRadiusRatio; + const outerRadius = r; + + const sections = [ + { + startAngle: (startAngleDegrees * Math.PI) / 180, + endAngle: (firstSectionEnd * Math.PI) / 180, + }, + { + startAngle: (secondSectionStart * Math.PI) / 180, + endAngle: (secondSectionEnd * Math.PI) / 180, + }, + { + startAngle: (thirdSectionStart * Math.PI) / 180, + endAngle: (thirdSectionEnd * Math.PI) / 180, + }, + ]; + + return sections + .map((section) => + getArcPath({ + startAngle: section.startAngle, + endAngle: section.endAngle, + innerRadius, + outerRadius, + cornerRadius: 100, + }), + ) + .join(' '); + }, [ + drawingArea, + innerRadiusRatio, + startAngleDegrees, + firstSectionEnd, + secondSectionStart, + secondSectionEnd, + thirdSectionStart, + thirdSectionEnd, + ]); + + const centerX = drawingArea.x + drawingArea.width / 2; + const centerY = drawingArea.y + drawingArea.height / 2; + + return ( + <> + + + + + + + + + + ); + }, +); + +const CoinbaseOneRewardsChart = () => { + const theme = useTheme(); + + const innerRadiusRatio = 0.75; + const angleEachSideGap = (45 / 4) * 3; + const startAngleDegrees = angleEachSideGap - 180; + const endAngleDegrees = 180 - angleEachSideGap; + const angleGapDegrees = 5; + const totalGapDegrees = angleGapDegrees * 2; + const gapBetweenDegrees = totalGapDegrees / 3; + const sectionLengthDegrees = (endAngleDegrees - startAngleDegrees) / 3 - gapBetweenDegrees; + + const firstSectionEnd = startAngleDegrees + sectionLengthDegrees; + const secondSectionStart = firstSectionEnd + gapBetweenDegrees; + const secondSectionEnd = secondSectionStart + sectionLengthDegrees; + const thirdSectionStart = secondSectionEnd + gapBetweenDegrees; + const thirdSectionEnd = thirdSectionStart + sectionLengthDegrees; + const progressAngle = -45; + + return ( + ({ min: innerRadiusRatio * max, max }) }} + series={[{ id: 'progress', data: 100, label: 'Progress', color: theme.color.fg }]} + width={200} + > + + + + ); +}; + +export const Default = () => ( + + Pie Charts + + + + + Basic Pie + + + + + + With Padding + + + + + + Custom Styled + + + + + Donut Charts + + + + + Basic Donut + + + + + + Center Label + + + + + + Semicircle + + + + + Advanced Examples + + + + + Interactive + + + + + + Data Animation + + + + + + Nested Rings + + + + + + Coinbase One Rewards + + + + +); + +export const Pie = () => ; +export const Donut = () => ; +export const WithCenterLabel = () => ; +export const Semicircle = () => ; +export const Interactive = () => ; +export const Animated = () => ; +export const Nested = () => ; +export const CoinbaseOneRewards = () => ; diff --git a/packages/web-visualization/src/chart/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx index d53a61a8a..352534ef0 100644 --- a/packages/web-visualization/src/chart/area/AreaChart.tsx +++ b/packages/web-visualization/src/chart/area/AreaChart.tsx @@ -8,16 +8,16 @@ import { } from '../CartesianChart'; import { Line, type LineProps } from '../line/Line'; import { - type AxisConfigProps, + type CartesianAxisConfigProps, + type CartesianSeries, defaultChartInset, defaultStackId, getChartInset, - type Series, } from '../utils'; import { Area, type AreaProps } from './Area'; -export type AreaSeries = Series & +export type AreaSeries = CartesianSeries & Partial< Pick< AreaProps, @@ -76,13 +76,13 @@ export type AreaChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type AreaChartProps = AreaChartBaseProps & @@ -116,10 +116,10 @@ export const AreaChart = memo( ) => { const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); - // Convert AreaSeries to Series for Chart context + // Convert AreaSeries to CartesianSeries for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, @@ -159,7 +159,7 @@ export const AreaChart = memo( ...yAxisVisualProps } = yAxis || {}; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType, data: xData, categoryPadding: xCategoryPadding, @@ -180,7 +180,7 @@ export const AreaChart = memo( }, [series]); // Set default min domain to 0 for area chart, but only if there are no negative values - const yAxisConfig: Partial = { + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, diff --git a/packages/web-visualization/src/chart/bar/BarChart.tsx b/packages/web-visualization/src/chart/bar/BarChart.tsx index 5b819ae37..038a5a263 100644 --- a/packages/web-visualization/src/chart/bar/BarChart.tsx +++ b/packages/web-visualization/src/chart/bar/BarChart.tsx @@ -7,11 +7,11 @@ import { type CartesianChartProps, } from '../CartesianChart'; import { - type AxisConfigProps, + type CartesianAxisConfigProps, + type CartesianSeries, defaultChartInset, defaultStackId, getChartInset, - type Series, } from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; @@ -35,7 +35,7 @@ export type BarChartBaseProps = Omit; + series?: Array; /** * Whether to stack the areas on top of each other. * When true, each series builds cumulative values on top of the previous series. @@ -58,13 +58,13 @@ export type BarChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type BarChartProps = BarChartBaseProps & @@ -131,7 +131,7 @@ export const BarChart = memo( ...yAxisVisualProps } = yAxis || {}; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType ?? 'band', data: xData, categoryPadding: xCategoryPadding, @@ -152,7 +152,7 @@ export const BarChart = memo( }, [series]); // Set default min domain to 0 for area chart, but only if there are no negative values - const yAxisConfig: Partial = { + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, diff --git a/packages/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx index 37fb64c3c..8b768a93f 100644 --- a/packages/web-visualization/src/chart/bar/BarStack.tsx +++ b/packages/web-visualization/src/chart/bar/BarStack.tsx @@ -3,7 +3,7 @@ import type { Rect } from '@coinbase/cds-common'; import type { Transition } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import type { ChartScaleFunction, Series } from '../utils'; +import type { CartesianSeries, ChartScaleFunction } from '../utils'; import { evaluateGradientAtValue, getGradientConfig } from '../utils/gradient'; import { Bar, type BarComponent, type BarProps } from './Bar'; @@ -18,7 +18,7 @@ export type BarStackBaseProps = Pick< /** * Array of series configurations that belong to this stack. */ - series: Series[]; + series: CartesianSeries[]; /** * The category index for this stack. */ diff --git a/packages/web-visualization/src/chart/index.ts b/packages/web-visualization/src/chart/index.ts index 6b18c5af7..2265caa59 100644 --- a/packages/web-visualization/src/chart/index.ts +++ b/packages/web-visualization/src/chart/index.ts @@ -4,11 +4,14 @@ export * from './axis/index'; export * from './bar/index'; export * from './CartesianChart'; export * from './ChartProvider'; +export * from './DonutChart'; export * from './gradient/index'; export * from './line/index'; export * from './Path'; export * from './PeriodSelector'; +export * from './pie/index'; export * from './point/index'; +export * from './PolarChart'; export * from './scrubber/index'; export * from './text/index'; export * from './utils/index'; diff --git a/packages/web-visualization/src/chart/line/LineChart.tsx b/packages/web-visualization/src/chart/line/LineChart.tsx index 301f02eaa..5e3b71d00 100644 --- a/packages/web-visualization/src/chart/line/LineChart.tsx +++ b/packages/web-visualization/src/chart/line/LineChart.tsx @@ -7,11 +7,16 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, getChartInset, type Series } from '../utils'; +import { + type CartesianAxisConfigProps, + type CartesianSeries, + defaultChartInset, + getChartInset, +} from '../utils'; import { Line, type LineProps } from './Line'; -export type LineSeries = Series & +export type LineSeries = CartesianSeries & Partial< Pick< LineProps, @@ -68,13 +73,13 @@ export type LineChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type LineChartProps = LineChartBaseProps & @@ -110,10 +115,10 @@ export const LineChart = memo( ) => { const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); - // Convert LineSeries to Series for Chart context + // Convert LineSeries to CartesianSeries for Chart context const chartSeries = useMemo(() => { return series?.map( - (s): Series => ({ + (s): CartesianSeries => ({ id: s.id, data: s.data, label: s.label, @@ -147,7 +152,7 @@ export const LineChart = memo( ...yAxisVisualProps } = yAxis || {}; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType, data: xData, categoryPadding: xCategoryPadding, @@ -156,7 +161,7 @@ export const LineChart = memo( range: xRange, }; - const yAxisConfig: Partial = { + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, diff --git a/packages/web-visualization/src/chart/pie/Arc.tsx b/packages/web-visualization/src/chart/pie/Arc.tsx new file mode 100644 index 000000000..d207db981 --- /dev/null +++ b/packages/web-visualization/src/chart/pie/Arc.tsx @@ -0,0 +1,232 @@ +import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { animate as framerAnimate } from 'framer-motion'; + +import { usePolarChartContext } from '../ChartProvider'; +import { defaultAxisId, getArcPath } from '../utils'; +import { degreesToRadians } from '../utils/polar'; + +export type ArcBaseProps = { + /** + * Start angle in radians. + */ + startAngle: number; + /** + * End angle in radians. + */ + endAngle: number; + /** + * Inner radius in pixels (0 for pie chart). + */ + innerRadius: number; + /** + * Outer radius in pixels. + */ + outerRadius: number; + /** + * Padding angle in radians between adjacent arcs. + */ + paddingAngle?: number; + /** + * Fill color for the arc. + */ + fill?: string; + /** + * Fill opacity. + * @default 1 + */ + fillOpacity?: number; + /** + * Stroke color. + */ + stroke?: string; + /** + * Stroke width in pixels. + */ + strokeWidth?: number; + /** + * Corner radius in pixels. + */ + cornerRadius?: number; + /** + * Clip path ID to apply to this arc. + * Should reference a clipPath element defined elsewhere in the SVG. + */ + clipPathId?: string; + /** + * ID of the angular axis to use for determining the animation baseline. + * If not provided, uses the default angular axis. + */ + angularAxisId?: string; + /** + * Whether to animate the arc. Overrides the chart-level animate setting. + * If not provided, uses the chart context's animate value. + */ + animate?: boolean; + /** + * Click handler for the arc. + */ + onClick?: React.MouseEventHandler; + /** + * Mouse enter handler for the arc. + */ + onMouseEnter?: React.MouseEventHandler; + /** + * Mouse leave handler for the arc. + */ + onMouseLeave?: React.MouseEventHandler; + /** + * CSS cursor style for the arc. + */ + cursor?: string; +}; + +export type ArcProps = ArcBaseProps; + +/** + * Renders an arc (slice) in a polar chart. + * Used by PieChart and DonutChart components. + */ +export const Arc = memo( + ({ + startAngle, + endAngle, + innerRadius, + outerRadius, + paddingAngle, + fill, + fillOpacity = 1, + stroke, + strokeWidth, + cornerRadius, + clipPathId, + angularAxisId, + animate: animateProp, + onClick, + onMouseEnter, + onMouseLeave, + cursor, + }) => { + const { animate: contextAnimate, drawingArea, getAngularAxis } = usePolarChartContext(); + const animate = animateProp !== undefined ? animateProp : contextAnimate; + + // Get the angular axis to determine the baseline angle + const angularAxis = getAngularAxis(angularAxisId ?? defaultAxisId); + + const baselineAngle = useMemo(() => { + const startDegrees = angularAxis?.range?.min ?? 0; + return degreesToRadians(startDegrees); + }, [angularAxis?.range?.min]); + + const centerX = drawingArea.x + drawingArea.width / 2; + const centerY = drawingArea.y + drawingArea.height / 2; + + // Track if this arc has completed its initial animation from baseline + const hasInitialAnimationStartedRef = useRef(false); + + // Refs to track current animated values - these persist across renders + // and allow us to animate from the current position when data changes + const currentStartAngleRef = useRef(baselineAngle); + const currentEndAngleRef = useRef(baselineAngle); + + // State for animated angles (drives the render) + const [animatedStartAngle, setAnimatedStartAngle] = useState(baselineAngle); + const [animatedEndAngle, setAnimatedEndAngle] = useState(baselineAngle); + + // Trigger animation when the component mounts or data changes + useEffect(() => { + // Don't start animation until axis is ready (has valid baseline) + if (!angularAxis) return; + + if (animate) { + // Determine the starting point for animation: + // - Initial mount: start from baseline angle (e.g., -90° for semicircle) + // - Data change: start from current animated position (smooth transition) + const isInitialAnimation = !hasInitialAnimationStartedRef.current; + + if (isInitialAnimation) { + // For initial animation, start from baseline + currentStartAngleRef.current = baselineAngle; + currentEndAngleRef.current = baselineAngle; + setAnimatedStartAngle(baselineAngle); + setAnimatedEndAngle(baselineAngle); + } + + const fromStartAngle = currentStartAngleRef.current; + const fromEndAngle = currentEndAngleRef.current; + + // Animate from current/baseline to target values + const startControl = framerAnimate(fromStartAngle, startAngle, { + duration: isInitialAnimation ? 1 : 0.5, // Slower for initial, faster for data updates + ease: 'easeOut', + onUpdate: (value) => { + currentStartAngleRef.current = value; + setAnimatedStartAngle(value); + }, + }); + + const endControl = framerAnimate(fromEndAngle, endAngle, { + duration: isInitialAnimation ? 1 : 0.5, + ease: 'easeOut', + onUpdate: (value) => { + currentEndAngleRef.current = value; + setAnimatedEndAngle(value); + }, + }); + + // Mark that initial animation has started + hasInitialAnimationStartedRef.current = true; + + return () => { + startControl.stop(); + endControl.stop(); + }; + } else { + currentStartAngleRef.current = startAngle; + currentEndAngleRef.current = endAngle; + setAnimatedStartAngle(startAngle); + setAnimatedEndAngle(endAngle); + } + }, [startAngle, endAngle, animate, baselineAngle, angularAxis]); + + // Compute the current path + const currentPath = useMemo(() => { + return getArcPath({ + startAngle: animatedStartAngle, + endAngle: animatedEndAngle, + innerRadius, + outerRadius, + cornerRadius, + paddingAngle, + }); + }, [ + animatedStartAngle, + animatedEndAngle, + innerRadius, + outerRadius, + cornerRadius, + paddingAngle, + ]); + + // Don't render until axis is ready and we have valid radius + if (!angularAxis || outerRadius <= 0) return; + + return ( + + + + ); + }, +); diff --git a/packages/web-visualization/src/chart/pie/PieChart.tsx b/packages/web-visualization/src/chart/pie/PieChart.tsx new file mode 100644 index 000000000..7750fc29f --- /dev/null +++ b/packages/web-visualization/src/chart/pie/PieChart.tsx @@ -0,0 +1,76 @@ +import React, { forwardRef, memo } from 'react'; + +import { PolarChart, type PolarChartBaseProps, type PolarChartProps } from '../PolarChart'; +import type { PolarSeries } from '../utils'; + +import { PiePlot, type PiePlotProps } from './PiePlot'; + +/** + * Series type for PieChart - enforces single number data values. + */ +export type PieSeries = Omit & { + /** + * Single numeric value for this slice. + */ + data: number; +}; + +export type PieChartBaseProps = Omit & + Pick & { + /** + * Array of series, where each series represents one slice. + * Each series must have a single numeric value. + */ + series?: PieSeries[]; + }; + +export type PieChartProps = PieChartBaseProps & Omit; + +/** + * A pie chart component for visualizing proportional data. + * Each series represents one slice, with its value as a proportion of the total. + * + * By default, uses the full radius (radialAxis: { range: { min: 0, max: [radius in pixels] } }). + * + * @example + * ```tsx + * + * ``` + */ +export const PieChart = memo( + forwardRef( + ( + { + series = [], + children, + ArcComponent, + fillOpacity, + stroke, + strokeWidth, + cornerRadius, + ...chartProps + }, + ref, + ) => { + return ( + + + {children} + + ); + }, + ), +); diff --git a/packages/web-visualization/src/chart/pie/PiePlot.tsx b/packages/web-visualization/src/chart/pie/PiePlot.tsx new file mode 100644 index 000000000..ced3942a9 --- /dev/null +++ b/packages/web-visualization/src/chart/pie/PiePlot.tsx @@ -0,0 +1,251 @@ +import React, { memo, useCallback, useMemo } from 'react'; +import { useTheme } from '@coinbase/cds-web'; + +import { usePolarChartContext } from '../ChartProvider'; +import { defaultAxisId } from '../utils'; +import { calculateArcData } from '../utils/polar'; + +import { Arc, type ArcBaseProps, type ArcProps } from './Arc'; + +/** + * Data passed to slice event handlers. + */ +export type PieSliceEventData = { + /** + * The series ID of the clicked slice. + */ + id: string; + /** + * The label of the slice. + */ + label?: string; + /** + * The value of the slice. + */ + value: number; + /** + * The index of the slice in the series array. + */ + index: number; +}; + +export type PiePlotBaseProps = Pick< + ArcBaseProps, + 'fillOpacity' | 'stroke' | 'strokeWidth' | 'cornerRadius' | 'clipPathId' | 'animate' +> & { + /** + * Array of series IDs to render. + * If not provided, renders all series for the axes. + */ + seriesIds?: string[]; + /** + * ID of the radial axis to filter series by. + * Defaults to the default radial axis. + */ + radialAxisId?: string; + /** + * ID of the angular axis to filter series by. + * Defaults to the default angular axis. + */ + angularAxisId?: string; + /** + * Custom Arc component to use for rendering slices. + */ + ArcComponent?: React.ComponentType; + /** + * Callback fired when a slice is clicked. + */ + onSliceClick?: (data: PieSliceEventData) => void; + /** + * Callback fired when the mouse enters a slice. + */ + onSliceMouseEnter?: (data: PieSliceEventData) => void; + /** + * Callback fired when the mouse leaves a slice. + */ + onSliceMouseLeave?: (data: PieSliceEventData) => void; + /** + * CSS cursor style for slices. Set to 'pointer' for clickable slices. + */ + cursor?: string; +}; + +export type PiePlotProps = PiePlotBaseProps; + +/** + * PiePlot component that renders arc slices for polar charts. + * Filters series by radialAxisId and angularAxisId, then renders an Arc for each. + */ +export const PiePlot = memo( + ({ + seriesIds, + radialAxisId: radialAxisIdProp, + angularAxisId: angularAxisIdProp, + ArcComponent = Arc, + animate: animateProp, + fillOpacity, + stroke, + strokeWidth = 1, + cornerRadius, + clipPathId, + onSliceClick, + onSliceMouseEnter, + onSliceMouseLeave, + cursor, + }) => { + const theme = useTheme(); + const { + series: allSeries, + animate: contextAnimate, + drawingArea, + getAngularAxis, + getRadialAxis, + } = usePolarChartContext(); + + const animate = animateProp ?? contextAnimate; + + const maxRadius = useMemo(() => { + return Math.max(0, Math.min(drawingArea.width, drawingArea.height) / 2); + }, [drawingArea.width, drawingArea.height]); + + const radialAxisId = radialAxisIdProp ?? defaultAxisId; + const angularAxisId = angularAxisIdProp ?? defaultAxisId; + + const targetSeries = useMemo(() => { + // Filter by axis IDs first + const axisFilteredSeries = allSeries.filter( + (s) => + (s.radialAxisId ?? defaultAxisId) === radialAxisId && + (s.angularAxisId ?? defaultAxisId) === angularAxisId, + ); + + // Then filter by seriesIds if provided + if (seriesIds !== undefined) { + return axisFilteredSeries.filter((s) => seriesIds.includes(s.id)); + } + + return axisFilteredSeries; + }, [allSeries, seriesIds, radialAxisId, angularAxisId]); + + const angularAxisConfig = useMemo( + () => getAngularAxis(angularAxisId), + [angularAxisId, getAngularAxis], + ); + + const radialAxisConfig = useMemo( + () => getRadialAxis(radialAxisId), + [radialAxisId, getRadialAxis], + ); + + const { startAngleDegrees, endAngleDegrees, paddingAngleDegrees } = useMemo(() => { + const range = angularAxisConfig?.range ?? { min: 0, max: 360 }; + return { + startAngleDegrees: range.min ?? 0, + endAngleDegrees: range.max ?? 360, + paddingAngleDegrees: angularAxisConfig?.paddingAngle ?? 0, + }; + }, [angularAxisConfig]); + + const { innerRadius, outerRadius } = useMemo(() => { + const range = radialAxisConfig?.range ?? { min: 0, max: maxRadius }; + return { + innerRadius: range.min ?? 0, + outerRadius: range.max ?? maxRadius, + }; + }, [radialAxisConfig, maxRadius]); + + const seriesData = useMemo(() => { + return targetSeries + .map((s) => { + const value = typeof s.data === 'number' ? s.data : s.data[0]; + if (value === null || value === undefined) return null; + return { value, color: s.color, id: s.id, label: s.label }; + }) + .filter((d): d is NonNullable => d !== null); + }, [targetSeries]); + + const arcs = useMemo(() => { + if (!seriesData.length) { + return []; + } + + const values = seriesData.map((d) => d.value); + return calculateArcData( + values, + innerRadius, + outerRadius, + startAngleDegrees, + endAngleDegrees, + paddingAngleDegrees, + ); + }, [ + seriesData, + innerRadius, + outerRadius, + startAngleDegrees, + endAngleDegrees, + paddingAngleDegrees, + ]); + + const handleSliceClick = useCallback( + (sliceData: { id: string; label?: string; value: number }, index: number) => { + onSliceClick?.({ ...sliceData, index }); + }, + [onSliceClick], + ); + + const handleSliceMouseEnter = useCallback( + (sliceData: { id: string; label?: string; value: number }, index: number) => { + onSliceMouseEnter?.({ ...sliceData, index }); + }, + [onSliceMouseEnter], + ); + + const handleSliceMouseLeave = useCallback( + (sliceData: { id: string; label?: string; value: number }, index: number) => { + onSliceMouseLeave?.({ ...sliceData, index }); + }, + [onSliceMouseLeave], + ); + + if (!arcs.length) { + return null; + } + + return ( + <> + {arcs.map((arc) => { + const data = seriesData[arc.index]; + const fill = data.color ?? theme.color.fgPrimary; + + return ( + handleSliceClick(data, arc.index) : undefined} + onMouseEnter={ + onSliceMouseEnter ? () => handleSliceMouseEnter(data, arc.index) : undefined + } + onMouseLeave={ + onSliceMouseLeave ? () => handleSliceMouseLeave(data, arc.index) : undefined + } + outerRadius={arc.outerRadius} + paddingAngle={arc.paddingAngle} + startAngle={arc.startAngle} + stroke={stroke ?? theme.color.bg} + strokeWidth={strokeWidth} + /> + ); + })} + + ); + }, +); diff --git a/packages/web-visualization/src/chart/pie/index.ts b/packages/web-visualization/src/chart/pie/index.ts new file mode 100644 index 000000000..e7a18c37c --- /dev/null +++ b/packages/web-visualization/src/chart/pie/index.ts @@ -0,0 +1,3 @@ +export * from './Arc'; +export * from './PieChart'; +export * from './PiePlot'; diff --git a/packages/web-visualization/src/chart/text/ChartText.tsx b/packages/web-visualization/src/chart/text/ChartText.tsx index ad29b9df5..8e731cafb 100644 --- a/packages/web-visualization/src/chart/text/ChartText.tsx +++ b/packages/web-visualization/src/chart/text/ChartText.tsx @@ -5,7 +5,7 @@ import { Box, type BoxProps } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; import { m as motion } from 'framer-motion'; -import { useCartesianChartContext } from '../ChartProvider'; +import { useChartContext } from '../ChartProvider'; import { type ChartInset, getChartInset } from '../utils'; type ValidChartTextChildElements = @@ -184,7 +184,7 @@ export const ChartText = memo( className, classNames, }) => { - const { animate, width: chartWidth, height: chartHeight } = useCartesianChartContext(); + const { animate, width: chartWidth, height: chartHeight } = useChartContext(); const fullChartBounds = useMemo( () => ({ x: 0, y: 0, width: chartWidth, height: chartHeight }), [chartWidth, chartHeight], diff --git a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts index 8ff0147cb..541e003f7 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,93 +1,93 @@ import { type AxisBounds, + type CartesianSeries, type ChartInset, defaultChartInset, defaultStackId, - getChartDomain, + getCartesianDomain, + getCartesianRange, + getCartesianStackedSeriesData, getChartInset, - getChartRange, - getStackedSeriesData, isValidBounds, - type Series, } from '../chart'; -describe('getChartDomain', () => { +describe('getCartesianDomain', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, { id: 'series2', data: [10, 20, 30] }, ]; - const result = getChartDomain(series, 5, 15); + const result = getCartesianDomain(series, 5, 15); expect(result).toEqual({ min: 5, max: 15 }); }); it('should calculate domain from series data when min/max not provided', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3, 4, 5] }, // length 5, so max index = 4 { id: 'series2', data: [10, 20, 30] }, // length 3, so max index = 2 ]; - const result = getChartDomain(series); + const result = getCartesianDomain(series); expect(result).toEqual({ min: 0, max: 4 }); // Uses longest series (5 items, indices 0-4) }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartDomain(series, 10); + const result = getCartesianDomain(series, 10); expect(result).toEqual({ min: 10, max: 2 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3, 4] }]; - const result = getChartDomain(series, undefined, 10); + const result = getCartesianDomain(series, undefined, 10); expect(result).toEqual({ min: 0, max: 10 }); }); it('should handle empty series array', () => { - const result = getChartDomain([]); + const result = getCartesianDomain([]); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getChartDomain(series); + const result = getCartesianDomain(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with empty data arrays', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [] }, { id: 'series2', data: [] }, ]; - const result = getChartDomain(series); + const result = getCartesianDomain(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle mixed series with and without data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1' }, { id: 'series2', data: [1, 2, 3, 4, 5, 6] }, { id: 'series3', data: [] }, ]; - const result = getChartDomain(series); + const result = getCartesianDomain(series); expect(result).toEqual({ min: 0, max: 5 }); }); }); -describe('getStackedSeriesData', () => { +describe('getCartesianStackedSeriesData', () => { it('should handle individual series without stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3] }, { id: 'series2', data: [4, 5, 6] }, ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(2); expect(result.get('series1')).toEqual([ @@ -103,7 +103,7 @@ describe('getStackedSeriesData', () => { }); it('should handle series with tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -114,7 +114,7 @@ describe('getStackedSeriesData', () => { }, ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(1); expect(result.get('series1')).toEqual([ @@ -125,12 +125,12 @@ describe('getStackedSeriesData', () => { }); it('should stack series with same stackId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(2); // D3 stack will create cumulative values @@ -144,12 +144,12 @@ describe('getStackedSeriesData', () => { }); it('should not stack series with different yAxisId', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(2); // Should be treated as individual series since they have different y-axes @@ -166,33 +166,33 @@ describe('getStackedSeriesData', () => { }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 3] }]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.get('series1')).toEqual([[0, 1], null, [0, 3]]); }); it('should handle empty series array', () => { - const result = getStackedSeriesData([]); + const result = getCartesianStackedSeriesData([]); expect(result.size).toBe(0); }); it('should handle series without data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(0); }); it('should handle mixed stacked and individual series', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, { id: 'series3', data: [7, 8, 9] }, // No stackId ]; - const result = getStackedSeriesData(series); + const result = getCartesianStackedSeriesData(series); expect(result.size).toBe(3); expect(result.get('series3')).toEqual([ @@ -203,26 +203,26 @@ describe('getStackedSeriesData', () => { }); }); -describe('getChartRange', () => { +describe('getCartesianRange', () => { it('should return provided min and max when both are specified', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, -10, 20); + const result = getCartesianRange(series, -10, 20); expect(result).toEqual({ min: -10, max: 20 }); }); it('should calculate range from simple numeric data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 5, 3] }, { id: 'series2', data: [2, 4, 6] }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: 1, max: 6 }); }); it('should calculate range from tuple data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [ @@ -240,17 +240,17 @@ describe('getChartRange', () => { }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: -1, max: 7 }); }); it('should calculate range from stacked data', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); // Stacked values should be cumulative expect(result.min).toBeDefined(); @@ -260,19 +260,19 @@ describe('getChartRange', () => { }); it('should handle negative values', () => { - const series: Series[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: -5, max: 3 }); }); it('should handle mixed positive and negative stacked values', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [2, -1, 3], stackId: 'stack1' }, { id: 'series2', data: [-3, 4, -2], stackId: 'stack1' }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result.min).toBeDefined(); expect(result.max).toBeDefined(); @@ -281,45 +281,45 @@ describe('getChartRange', () => { }); it('should handle empty series array', () => { - const result = getChartRange([]); + const result = getCartesianRange([]); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with no data', () => { - const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; + const series: CartesianSeries[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle null values in data', () => { - const series: Series[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; - const result = getChartRange(series); + const result = getCartesianRange(series); expect(result).toEqual({ min: 1, max: 5 }); }); it('should use provided min with calculated max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, -5); + const result = getCartesianRange(series, -5); expect(result).toEqual({ min: -5, max: 3 }); }); it('should use calculated min with provided max', () => { - const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; + const series: CartesianSeries[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, undefined, 10); + const result = getCartesianRange(series, undefined, 10); expect(result).toEqual({ min: 1, max: 10 }); }); it('should handle series with different yAxisId in stacking', () => { - const series: Series[] = [ + const series: CartesianSeries[] = [ { id: 'series1', data: [1, 2, 3], stackId: 'stack1', yAxisId: 'left' }, { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; - const result = getChartRange(series); + const result = getCartesianRange(series); // Should treat as individual series, not stacked expect(result).toEqual({ min: 0, max: 6 }); diff --git a/packages/web-visualization/src/chart/utils/axis.ts b/packages/web-visualization/src/chart/utils/axis.ts index 0731e67ab..ba3631ba2 100644 --- a/packages/web-visualization/src/chart/utils/axis.ts +++ b/packages/web-visualization/src/chart/utils/axis.ts @@ -4,10 +4,13 @@ import type { Rect } from '@coinbase/cds-common/types'; import { type AxisBounds, - getChartDomain, - getChartRange, + type CartesianSeries, + getCartesianDomain, + getCartesianRange, + getPolarAngularDomain, + getPolarRadialRange, isValidBounds, - type Series, + type PolarSeries, } from './chart'; import { type ChartAxisScaleType, @@ -26,8 +29,6 @@ export const defaultAxisScaleType = 'linear'; * Axis configuration with computed bounds */ export type AxisConfig = { - /** The type of scale to use */ - scaleType: ChartAxisScaleType; /** * Domain bounds for the axis (data space) */ @@ -36,10 +37,17 @@ export type AxisConfig = { * Range bounds for the axis (visual space in pixels) */ range: AxisBounds; +}; + +export type CartesianAxisConfig = AxisConfig & { + /** The type of scale to use */ + scaleType: ChartAxisScaleType; /** - * Data for the axis + * Domain limit type for numeric scales + * - 'nice' (default for y axes): Rounds the domain to human-friendly values (e.g., 0-100 instead of 1.2-97.8) + * - 'strict' (default for x axes): Uses the exact min/max values from the data */ - data?: string[] | number[]; + domainLimit: 'nice' | 'strict'; /** * Padding between categories for band scales (0-1, where 0.1 = 10% spacing) * Only used when scaleType is 'band' @@ -47,45 +55,121 @@ export type AxisConfig = { */ categoryPadding?: number; /** - * Domain limit type for numeric scales - * - 'nice': Rounds the domain to human-friendly values - * - 'strict': Uses the exact min/max values from the data + * Data for the axis */ - domainLimit: 'nice' | 'strict'; + data?: string[] | number[]; +}; + +export type RadialAxisConfig = AxisConfig & { + /** The type of scale to use */ + scaleType: Exclude; +}; + +export type AngularAxisConfig = AxisConfig & { + /** The type of scale to use */ + scaleType: Exclude; + /** + * Padding angle between slices in degrees. + * @default 0 + */ + paddingAngle?: number; }; /** - * Axis configuration without computed bounds (used for input) + * Base axis configuration props (used for input, without computed bounds) */ -export type AxisConfigProps = Omit & { +type AxisConfigProps = { /** * Unique identifier for this axis. */ id: string; /** * Domain configuration for the axis (data space). - * - * The domainLimit parameter (inherited from AxisConfig) controls how initial domain bounds are calculated: - * - 'nice' (default for y axes): Rounds the domain to human-friendly values (e.g., 0-100 instead of 1.2-97.8) - * - 'strict' (default for x axes): Uses the exact min/max values from the data - * - * The domain can be: - * - A partial bounds object to override specific min/max values - * - A function that receives the limit-processed bounds and allows further customization - * - * This allows you to first apply nice/strict processing, then optionally transform the result. + * Can be a partial bounds object to override specific min/max values, + * or a function that receives calculated bounds and allows customization. */ domain?: Partial | ((bounds: AxisBounds) => AxisBounds); /** * Range configuration for the axis (visual space in pixels). - * Can be a partial bounds object to override specific values, or a function that transforms the calculated range. - * - * When using a function, it receives the initial calculated range bounds and allows you to adjust them. - * This replaces the previous rangeOffset approach and provides more flexibility for range customization. + * Can be a partial bounds object to override specific values, + * or a function that transforms the calculated range. */ range?: Partial | ((bounds: AxisBounds) => AxisBounds); }; +/** + * Cartesian axis configuration props (for x/y axes). + * Inherits required scaleType and domainLimit from CartesianAxisConfig, + * with optional domain/range overrides for user input. + */ +export type CartesianAxisConfigProps = Omit & + AxisConfigProps; + +/** + * Base polar axis configuration props (shared by angular and radial axes) + */ +type PolarAxisConfigProps = AxisConfigProps & { + /** + * The type of scale to use. + * @default 'linear' + */ + scaleType?: Exclude; +}; + +/** + * Angular axis configuration props (for polar charts) + */ +export type AngularAxisConfigProps = PolarAxisConfigProps & { + /** + * Padding angle between slices in degrees. + * @default 0 + */ + paddingAngle?: number; +}; + +/** + * Radial axis configuration props (for polar charts) + */ +export type RadialAxisConfigProps = PolarAxisConfigProps; + +/** + * Gets a D3 scale for a polar axis. + * + * @param params - Scale parameters + * @returns The D3 scale function + */ +export const getPolarAxisScale = ({ + config, + range, + dataDomain, +}: { + config?: AngularAxisConfig | RadialAxisConfig; + range: AxisBounds; + dataDomain: AxisBounds; +}): ChartScaleFunction => { + const scaleType = config?.scaleType ?? 'linear'; + + let adjustedDomain = dataDomain; + + if (config?.domain) { + adjustedDomain = { + min: config.domain.min ?? dataDomain.min, + max: config.domain.max ?? dataDomain.max, + }; + } + + if (!isValidBounds(adjustedDomain)) { + throw new Error('Invalid polar axis domain bounds.'); + } + + // Polar charts only use linear/log scales (no band scales) + return getNumericScale({ + domain: adjustedDomain, + range, + scaleType: scaleType as 'linear' | 'log', + }); +}; + /** * Gets a D3 scale based on the axis configuration. * Handles both numeric (linear/log) and categorical (band) scales. @@ -97,13 +181,13 @@ export type AxisConfigProps = Omit & { * @returns The D3 scale function * @throws An Error if bounds are invalid */ -export const getAxisScale = ({ +export const getCartesianAxisScale = ({ config, type, range, dataDomain, }: { - config?: AxisConfig; + config?: CartesianAxisConfig; type: 'x' | 'y'; range: AxisBounds; dataDomain: AxisBounds; @@ -161,12 +245,12 @@ export const getAxisScale = ({ * @param defaultScaleType - the default scale type to use for the axis * @returns array of axis configs with IDs */ -export const getAxisConfig = ( +export const getCartesianAxisConfig = ( type: 'x' | 'y', - axes: Partial | Partial[] | undefined, + axes: Partial | Partial[] | undefined, defaultId: string = defaultAxisId, defaultScaleType: ChartAxisScaleType = defaultAxisScaleType, -): AxisConfigProps[] => { +): CartesianAxisConfigProps[] => { const defaultDomainLimit = type === 'x' ? 'strict' : 'nice'; if (!axes) { return [{ id: defaultId, scaleType: defaultScaleType, domainLimit: defaultDomainLimit }]; @@ -203,9 +287,9 @@ export const getAxisConfig = ( * @param axisType - Whether this is an 'x' or 'y' axis * @returns The calculated axis bounds */ -export const getAxisDomain = ( - axisParam: AxisConfigProps, - series: Series[], +export const getCartesianAxisDomain = ( + axisParam: CartesianAxisConfigProps, + series: CartesianSeries[], axisType: 'x' | 'y', ): AxisBounds => { let dataDomain: AxisBounds | null = null; @@ -230,7 +314,7 @@ export const getAxisDomain = ( } // Calculate domain from series data - const seriesDomain = axisType === 'x' ? getChartDomain(series) : getChartRange(series); + const seriesDomain = axisType === 'x' ? getCartesianDomain(series) : getCartesianRange(series); // If data sets the domain, use that instead of the series domain const preferredDataDomain = dataDomain ?? seriesDomain; @@ -303,6 +387,144 @@ export const getAxisRange = ( } }; +/** + * Formats the array of user-provided angular axis configs with default values. + * @param axes - array of axis configs or single axis config + * @param defaultId - the default id to use for the axis + * @returns array of angular axis configs with IDs + */ +export const getAngularAxisConfig = ( + axes: Partial | Partial[] | undefined, + defaultId: string = defaultAxisId, +): AngularAxisConfigProps[] => { + if (!axes) { + return [{ id: defaultId, scaleType: 'linear' }]; + } + + if (Array.isArray(axes)) { + const axesLength = axes.length; + if (axesLength > 1 && axes.some(({ id }) => id === undefined)) { + throw new Error('When defining multiple angular axes, each must have a unique id.'); + } + + return axes.map(({ id, ...axis }) => ({ + id: axesLength > 1 ? (id ?? defaultAxisId) : (id as string), + scaleType: 'linear' as const, + ...axis, + })); + } + + return [{ id: defaultId, scaleType: 'linear', ...axes }]; +}; + +/** + * Formats the array of user-provided radial axis configs with default values. + * @param axes - array of axis configs or single axis config + * @param defaultId - the default id to use for the axis + * @returns array of radial axis configs with IDs + */ +export const getRadialAxisConfig = ( + axes: Partial | Partial[] | undefined, + defaultId: string = defaultAxisId, +): RadialAxisConfigProps[] => { + if (!axes) { + return [{ id: defaultId, scaleType: 'linear' }]; + } + + if (Array.isArray(axes)) { + const axesLength = axes.length; + if (axesLength > 1 && axes.some(({ id }) => id === undefined)) { + throw new Error('When defining multiple radial axes, each must have a unique id.'); + } + + return axes.map(({ id, ...axis }) => ({ + id: axesLength > 1 ? (id ?? defaultAxisId) : (id as string), + scaleType: 'linear' as const, + ...axis, + })); + } + + return [{ id: defaultId, scaleType: 'linear', ...axes }]; +}; + +/** + * Calculates the data domain for a polar axis based on its configuration and series data. + * + * @param axisParam - The axis configuration + * @param series - Array of polar series objects + * @param axisType - Whether this is an 'angular' or 'radial' axis + * @returns The calculated axis bounds + */ +export const getPolarAxisDomain = ( + axisParam: PolarAxisConfigProps, + series: PolarSeries[], + axisType: 'angular' | 'radial', +): AxisBounds => { + // Calculate domain from series data + const seriesDomain = + axisType === 'angular' ? getPolarAngularDomain(series) : getPolarRadialRange(series); + + const bounds = axisParam.domain; + let finalDomain: Partial; + + if (typeof bounds === 'function') { + finalDomain = bounds({ + min: seriesDomain.min ?? 0, + max: seriesDomain.max ?? 0, + }); + } else if (bounds && typeof bounds === 'object') { + finalDomain = { + min: bounds.min ?? seriesDomain.min, + max: bounds.max ?? seriesDomain.max, + }; + } else { + finalDomain = seriesDomain; + } + + return { + min: finalDomain.min ?? 0, + max: finalDomain.max ?? 0, + }; +}; + +/** + * Calculates the visual range for a polar axis. + * + * For angular axes, returns degrees (conversion to radians happens at path generation). + * For radial axes, returns pixels. + * + * @param axisParam - The axis configuration + * @param axisType - Whether this is an 'angular' or 'radial' axis + * @param outerRadius - The outer radius in pixels (used for radial axis) + * @returns The calculated axis range bounds (degrees for angular, pixels for radial) + */ +export const getPolarAxisRange = ( + axisParam: AngularAxisConfigProps | RadialAxisConfigProps, + axisType: 'angular' | 'radial', + outerRadius: number, +): AxisBounds => { + // Default ranges: + // Angular: full circle starting at 3 o'clock (0° to 360° in degrees) + // Radial: 0 to outerRadius (pixels) + const baseRange: AxisBounds = + axisType === 'angular' ? { min: 0, max: 360 } : { min: 0, max: outerRadius }; + + const rangeConfig = axisParam.range; + + if (!rangeConfig) { + return baseRange; + } + + if (typeof rangeConfig === 'function') { + return rangeConfig(baseRange); + } + + return { + min: rangeConfig.min ?? baseRange.min, + max: rangeConfig.max ?? baseRange.max, + }; +}; + /** * Options for tick generation behavior */ @@ -729,7 +951,7 @@ export type RegisteredAxis = { /** * Calculates the total amount of padding needed to render a set of axes on the main drawing area of the chart. - * Returns the registed axes, an API for adding/removing axes as well as the total calculated padding that must be reserved in the drawing area. + * Returns the registered axes, an API for adding/removing axes as well as the total calculated padding that must be reserved in the drawing area. */ export const useTotalAxisPadding = () => { const [renderedAxes, setRenderedAxes] = useState>(new Map()); diff --git a/packages/web-visualization/src/chart/utils/chart.ts b/packages/web-visualization/src/chart/utils/chart.ts index b80dc5a62..ca9330e37 100644 --- a/packages/web-visualization/src/chart/utils/chart.ts +++ b/packages/web-visualization/src/chart/utils/chart.ts @@ -22,14 +22,6 @@ export type Series = { * Id of the series. */ id: string; - /** - * Data array for this series. Use null values to create gaps in the visualization. - * - * Can be either: - * - Array of numbers: `[10, -5, 20]` - * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs - */ - data?: Array | Array<[number, number] | null>; /** * Label of the series. * Used for scrubber beacon labels. @@ -41,6 +33,17 @@ export type Series = { * Color will still be used by scrubber beacon labels */ color?: string; +}; + +export type CartesianSeries = Series & { + /** + * Data array for this series. Use null values to create gaps in the visualization. + * + * Can be either: + * - Array of numbers: `[10, -5, 20]` + * - Array of tuples: `[[0, 10], [0, -5], [0, 20]]` [baseline, value] pairs + */ + data?: Array | Array<[number, number] | null>; /** * Color gradient configuration. * Takes precedence over color except for scrubber beacon labels. @@ -59,12 +62,111 @@ export type Series = { stackId?: string; }; +export type PolarSeries = Series & { + /** + * Data for the series. + * - Single number for pie/donut charts (each series = one slice) + * - Array of numbers for radar/radial bar charts (each series = multiple points) + */ + data: number | Array; + /** + * ID of the angular axis this series should use. + * If not specified, uses the default angular axis. + */ + angularAxisId?: string; + /** + * ID of the radial axis this series should use. + * If not specified, uses the default radial axis. + */ + radialAxisId?: string; +}; + +/** + * Calculates the angular domain from polar series data. + * For pie/donut: domain is 0 to series.length - 1 + * For radar: domain is 0 to data array length - 1 + */ +export const getPolarAngularDomain = ( + series: PolarSeries[], + min?: number, + max?: number, +): Partial => { + const domain = { min, max }; + + if (domain.min !== undefined && domain.max !== undefined) { + return domain; + } + + if (series.length === 0) { + return domain; + } + + const firstSeriesData = series[0].data; + + if (typeof firstSeriesData === 'number') { + // Pie/donut: each series is a slice + if (domain.min === undefined) domain.min = 0; + if (domain.max === undefined) domain.max = series.length - 1; + } else if (Array.isArray(firstSeriesData)) { + // Radar: domain is based on data array length + const dataLength = Math.max(...series.map((s) => (Array.isArray(s.data) ? s.data.length : 0))); + if (dataLength > 0) { + if (domain.min === undefined) domain.min = 0; + if (domain.max === undefined) domain.max = dataLength - 1; + } + } + + return domain; +}; + +/** + * Calculates the radial range (value extent) from polar series data. + */ +export const getPolarRadialRange = ( + series: PolarSeries[], + min?: number, + max?: number, +): Partial => { + const range = { min, max }; + + if (range.min !== undefined && range.max !== undefined) { + return range; + } + + if (series.length === 0) { + return range; + } + + const allValues: number[] = []; + + series.forEach((s) => { + if (typeof s.data === 'number') { + allValues.push(s.data); + } else if (Array.isArray(s.data)) { + s.data.forEach((value) => { + if (typeof value === 'number') { + allValues.push(value); + } + }); + } + }); + + if (allValues.length > 0) { + const minValue = Math.min(...allValues); + const maxValue = Math.max(...allValues); + if (range.min === undefined) range.min = Math.min(0, minValue); + if (range.max === undefined) range.max = maxValue; + } + + return range; +}; + /** * Calculates the domain of a chart from series data. * Domain represents the range of x-values from the data. */ -export const getChartDomain = ( - series: Series[], +export const getCartesianDomain = ( + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -93,7 +195,7 @@ export const getChartDomain = ( * Creates a composite stack key that includes both stack ID and y-axis ID. * This ensures series with different y-scales don't get stacked together. */ -const createStackKey = (series: Series): string | undefined => { +const createCartesianStackKey = (series: CartesianSeries): string | undefined => { if (series.stackId === undefined) return undefined; // Include y-axis ID to prevent cross-scale stacking @@ -108,8 +210,8 @@ const createStackKey = (series: Series): string | undefined => { * @param series - Array of series with potential stack properties * @returns Map of series ID to stacked data arrays */ -export const getStackedSeriesData = ( - series: Series[], +export const getCartesianStackedSeriesData = ( + series: CartesianSeries[], ): Map> => { const stackedDataMap = new Map>(); @@ -117,7 +219,7 @@ export const getStackedSeriesData = ( const individualSeries: typeof series = []; series.forEach((s) => { - const stackKey = createStackKey(s); + const stackKey = createCartesianStackKey(s); const hasTupleData = s.data?.some((val) => Array.isArray(val)); if (hasTupleData || stackKey === undefined) { @@ -218,8 +320,8 @@ export const getLineData = ( * Range represents the range of y-values from the data. * Handles stacking by transforming data when series have stack properties. */ -export const getChartRange = ( - series: Series[], +export const getCartesianRange = ( + series: CartesianSeries[], min?: number, max?: number, ): Partial => { @@ -239,7 +341,7 @@ export const getChartRange = ( // Group series by composite stack key for proper calculation const stackGroups = new Map(); series.forEach((s) => { - const stackKey = createStackKey(s); + const stackKey = createCartesianStackKey(s); if (!stackGroups.has(stackKey)) { stackGroups.set(stackKey, []); } @@ -251,7 +353,7 @@ export const getChartRange = ( if (hasStacks) { // Get stacked data using the shared function - const stackedDataMap = getStackedSeriesData(series); + const stackedDataMap = getCartesianStackedSeriesData(series); // Find the extreme values from the stacked data let stackedMax = 0; diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index c976ac02b..95ed097f1 100644 --- a/packages/web-visualization/src/chart/utils/context.ts +++ b/packages/web-visualization/src/chart/utils/context.ts @@ -1,51 +1,65 @@ import { createContext, useContext } from 'react'; import type { Rect } from '@coinbase/cds-common/types'; -import type { AxisConfig } from './axis'; -import type { Series } from './chart'; +import type { AngularAxisConfig, CartesianAxisConfig, RadialAxisConfig } from './axis'; +import type { CartesianSeries, PolarSeries } from './chart'; import type { ChartScaleFunction } from './scale'; +/** + * Base context value for all chart types. + */ +export type ChartContextValue = { + /** + * Whether to animate the chart. + */ + animate: boolean; + /** + * Drawing area of the chart. + */ + drawingArea: Rect; + /** + * Width of the chart SVG. + */ + width: number; + /** + * Height of the chart SVG. + */ + height: number; + /** + * Length of the data domain. + */ + dataLength: number; +}; + /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ -export type CartesianChartContextValue = { +export type CartesianChartContextValue = ChartContextValue & { /** * The series data for the chart. */ - series: Series[]; + series: CartesianSeries[]; /** * Returns the series which matches the seriesId or undefined. * @param seriesId - A series' id */ - getSeries: (seriesId?: string) => Series | undefined; + getSeries: (seriesId?: string) => CartesianSeries | undefined; /** * Returns the data for a series * @param seriesId - A series' id * @returns data for series, if series exists */ getSeriesData: (seriesId?: string) => Array<[number, number] | null> | undefined; - /** - * Whether to animate the chart. - */ - animate: boolean; - /** - * Width of the chart SVG. - */ - width: number; - /** - * Height of the chart SVG. - */ - height: number; /** * Get x-axis configuration. */ - getXAxis: () => AxisConfig | undefined; + getXAxis: () => CartesianAxisConfig | undefined; /** * Get y-axis configuration by ID. * @param id - The axis ID. Defaults to defaultAxisId. */ - getYAxis: (id?: string) => AxisConfig | undefined; + getYAxis: (id?: string) => CartesianAxisConfig | undefined; /** * Get x-axis scale function. */ @@ -55,16 +69,6 @@ export type CartesianChartContextValue = { * @param id - The axis ID. Defaults to defaultAxisId. */ getYScale: (id?: string) => ChartScaleFunction | undefined; - /** - * Drawing area of the chart. - */ - drawingArea: Rect; - /** - * Length of the data domain. - * This is equal to the length of xAxis.data or the longest series data length - * This equals the number of possible scrubber positions - */ - dataLength: number; /** * Registers an axis. * Used by axis components to reserve space in the chart, preventing overlap with the drawing area. @@ -84,6 +88,56 @@ export type CartesianChartContextValue = { getAxisBounds: (id: string) => Rect | undefined; }; +/** + * Context value for Polar (Angular/Radial) coordinate charts. + * Contains axis-specific methods and properties for polar coordinate systems. + */ +export type PolarChartContextValue = ChartContextValue & { + /** + * The series data for the chart. + */ + series: PolarSeries[]; + /** + * Returns the series which matches the seriesId or undefined. + * @param seriesId - A series' id + */ + getSeries: (seriesId?: string) => PolarSeries | undefined; + /** + * Returns the data for a series. + * @param seriesId - A series' id + * @returns data for series, if series exists + */ + getSeriesData: (seriesId?: string) => number | Array | undefined; + /** + * Outer radius of the polar chart in pixels. + */ + outerRadius: number; + /** + * Returns the angular axis configuration by ID. + * If no ID is provided, returns the default angular axis. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getAngularAxis: (id?: string) => AngularAxisConfig | undefined; + /** + * Returns the radial axis configuration by ID. + * If no ID is provided, returns the default radial axis. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getRadialAxis: (id?: string) => RadialAxisConfig | undefined; + /** + * Get angular axis scale function by ID. + * Maps data indices/values to angles in radians. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getAngularScale: (id?: string) => ChartScaleFunction | undefined; + /** + * Get radial axis scale function by ID. + * Maps data values to pixel distances from center. + * @param id - The axis ID. Defaults to defaultAxisId. + */ + getRadialScale: (id?: string) => ChartScaleFunction | undefined; +}; + export type ScrubberContextValue = { /** * Enables scrubbing interactions. diff --git a/packages/web-visualization/src/chart/utils/index.ts b/packages/web-visualization/src/chart/utils/index.ts index a02719070..6ed5e6183 100644 --- a/packages/web-visualization/src/chart/utils/index.ts +++ b/packages/web-visualization/src/chart/utils/index.ts @@ -7,6 +7,7 @@ export * from './gradient'; export * from './interpolate'; export * from './path'; export * from './point'; +export * from './polar'; export * from './scale'; export * from './scrubber'; export * from './transition'; diff --git a/packages/web-visualization/src/chart/utils/path.ts b/packages/web-visualization/src/chart/utils/path.ts index 9932df4d9..31b1b49bd 100644 --- a/packages/web-visualization/src/chart/utils/path.ts +++ b/packages/web-visualization/src/chart/utils/path.ts @@ -1,4 +1,5 @@ import { + arc as d3Arc, area as d3Area, curveBumpX, curveCatmullRom, @@ -293,3 +294,60 @@ export const getBarPath = ( path += ' Z'; return path; }; + +/** + * Generates an SVG arc path string for pie/donut charts. + * Uses D3's arc generator for consistent arc rendering. + * + * @param startAngle - Start angle in radians (0 at top, increasing clockwise) + * @param endAngle - End angle in radians (0 at top, increasing clockwise) + * @param innerRadius - Inner radius in pixels (0 for pie chart) + * @param outerRadius - Outer radius in pixels + * @param cornerRadius - Corner radius in pixels + * @param paddingAngle - Padding angle in radians between adjacent arcs + * @returns SVG path string for the arc + * + * @example + * ```typescript + * const arcPath = getArcPath({ + * startAngle: 0, + * endAngle: Math.PI, + * innerRadius: 0, + * outerRadius: 100, + * cornerRadius: 4, + * paddingAngle: 0.02 + * }); + * ``` + */ +export const getArcPath = ({ + startAngle, + endAngle, + innerRadius, + outerRadius, + cornerRadius = 0, + paddingAngle = 0, +}: { + /** Start angle in radians (0 at top, increasing clockwise) */ + startAngle: number; + /** End angle in radians (0 at top, increasing clockwise) */ + endAngle: number; + /** Inner radius in pixels (0 for pie chart) */ + innerRadius: number; + /** Outer radius in pixels */ + outerRadius: number; + /** Corner radius in pixels */ + cornerRadius?: number; + /** Padding angle in radians between adjacent arcs */ + paddingAngle?: number; +}): string => { + if (outerRadius <= 0 || startAngle === endAngle) return ''; + + const path = d3Arc().cornerRadius(cornerRadius).padAngle(paddingAngle)({ + innerRadius: Math.max(0, innerRadius), + outerRadius: Math.max(0, outerRadius), + startAngle, + endAngle, + }); + + return path ?? ''; +}; diff --git a/packages/web-visualization/src/chart/utils/polar.ts b/packages/web-visualization/src/chart/utils/polar.ts new file mode 100644 index 000000000..d20d83cdf --- /dev/null +++ b/packages/web-visualization/src/chart/utils/polar.ts @@ -0,0 +1,109 @@ +import { pie as d3Pie } from 'd3-shape'; + +import type { AngularAxisConfig, AxisConfig } from './axis'; + +/** + * Converts degrees to radians. + */ +export const degreesToRadians = (degrees: number): number => { + return (degrees * Math.PI) / 180; +}; + +/** + * Converts radians to degrees. + */ +export const radiansToDegrees = (radians: number): number => { + return (radians * 180) / Math.PI; +}; + +/** + * Extracts angular axis values (in radians) from axis config. + * Range values are expected in radians, paddingAngle in degrees (converted to radians). + * @param axisConfig - The angular axis configuration + */ +export const getAngularAxisRadians = ( + axisConfig?: Partial, +): { startAngle: number; endAngle: number; paddingAngle: number } => { + const range = axisConfig?.range ?? { min: 0, max: 2 * Math.PI }; + const startAngle = range.min ?? 0; + const endAngle = range.max ?? 2 * Math.PI; + // Convert paddingAngle from degrees to radians + const paddingAngle = degreesToRadians(axisConfig?.paddingAngle ?? 0); + + return { startAngle, endAngle, paddingAngle }; +}; + +/** + * Extracts radial axis values (in pixels) from axis config. + * @param maxRadius - The maximum available radius + * @param axisConfig - The radial axis configuration + */ +export const getRadialAxisPixels = ( + maxRadius: number, + axisConfig?: Partial, +): { innerRadius: number; outerRadius: number } => { + const range = axisConfig?.range ?? { min: 0, max: 1 }; + // Range values are typically 0-1 representing percentage of maxRadius + const innerRadius = (range.min ?? 0) * maxRadius; + const outerRadius = (range.max ?? 1) * maxRadius; + + return { innerRadius, outerRadius }; +}; + +/** + * Calculates arc geometry for pie/donut chart slices using D3's pie generator. + * + * All angle inputs are in degrees; outputs are in radians for rendering. + * + * @param values - Array of numeric values for each slice + * @param innerRadius - Inner radius in pixels + * @param outerRadius - Outer radius in pixels + * @param startAngleDegrees - Start angle in degrees (default: 0, 3 o'clock) + * @param endAngleDegrees - End angle in degrees (default: 360, full circle) + * @param paddingAngleDegrees - Padding between slices in degrees (default: 0) + * @returns Array of arc data objects with angles in radians for rendering + */ +export const calculateArcData = ( + values: number[], + innerRadius: number, + outerRadius: number, + startAngleDegrees = 0, + endAngleDegrees = 360, + paddingAngleDegrees = 0, +): Array<{ + startAngle: number; + endAngle: number; + paddingAngle: number; + innerRadius: number; + outerRadius: number; + index: number; + value: number; +}> => { + if (values.length === 0) { + return []; + } + + // Convert degrees to radians for D3 + const startAngleRadians = degreesToRadians(startAngleDegrees); + const endAngleRadians = degreesToRadians(endAngleDegrees); + const paddingAngleRadians = degreesToRadians(paddingAngleDegrees); + + const pieGenerator = d3Pie() + .value((d) => Math.abs(d)) + .startAngle(startAngleRadians) + .endAngle(endAngleRadians) + .padAngle(paddingAngleRadians) + .sort(null); // Preserve data order + + const pieData = pieGenerator(values); + + return pieData.map((d, index) => ({ + startAngle: d.startAngle, + endAngle: d.endAngle, + paddingAngle: d.padAngle, + innerRadius, + outerRadius, + index, + value: d.data, + })); +};