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 (
+
+ );
+ },
+);
+
+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