diff --git a/.changeset/friendly-buttons-approve.md b/.changeset/friendly-buttons-approve.md
new file mode 100644
index 00000000..596418b6
--- /dev/null
+++ b/.changeset/friendly-buttons-approve.md
@@ -0,0 +1,6 @@
+---
+"victory-native": minor
+"example": patch
+---
+
+Add Pie/Donut charts
diff --git a/example/app/consts/routes.ts b/example/app/consts/routes.ts
index 6c00c8cd..4ce100ed 100644
--- a/example/app/consts/routes.ts
+++ b/example/app/consts/routes.ts
@@ -58,6 +58,24 @@ export const ChartRoutes: {
"This chart showcases using custom shaders from Skia, leveraging shader uniforms derived from Reanimated shared values.",
path: "/custom-shaders",
},
+ {
+ title: "Pie Chart",
+ description:
+ "This is a Pie chart in Victory. It has support for customizing each slice and adding insets.",
+ path: "/pie-chart",
+ },
+ {
+ title: "Donut Chart",
+ description:
+ "This is how to make a Donut chart in Victory. It is built off of the Pie chart using the `innerRadius` prop.",
+ path: "/donut-chart",
+ },
+ {
+ title: "Pie and Donut Assortment",
+ description:
+ "This is mixture of Pie and Donut charts, showing off the different ways to customize the charts.",
+ path: "/pie-and-donut-charts",
+ },
];
if (__DEV__) {
diff --git a/example/app/donut-chart.tsx b/example/app/donut-chart.tsx
new file mode 100644
index 00000000..d9e1f399
--- /dev/null
+++ b/example/app/donut-chart.tsx
@@ -0,0 +1,134 @@
+import React, { useState } from "react";
+import { SafeAreaView, ScrollView, StyleSheet, View } from "react-native";
+import { LinearGradient, vec } from "@shopify/react-native-skia";
+import { Pie, PolarChart } from "victory-native";
+import { InfoCard } from "example/components/InfoCard";
+import { Button } from "example/components/Button";
+import { appColors } from "./consts/colors";
+import { descriptionForRoute } from "./consts/routes";
+
+function calculateGradientPoints(
+ radius: number,
+ startAngle: number,
+ endAngle: number,
+ centerX: number,
+ centerY: number,
+) {
+ // Calculate the midpoint angle of the slice for a central gradient effect
+ const midAngle = (startAngle + endAngle) / 2;
+
+ // Convert angles from degrees to radians
+ const startRad = (Math.PI / 180) * startAngle;
+ const midRad = (Math.PI / 180) * midAngle;
+
+ // Calculate start point (inner edge near the pie's center)
+ const startX = centerX + radius * 0.5 * Math.cos(startRad);
+ const startY = centerY + radius * 0.5 * Math.sin(startRad);
+
+ // Calculate end point (outer edge of the slice)
+ const endX = centerX + radius * Math.cos(midRad);
+ const endY = centerY + radius * Math.sin(midRad);
+
+ return { startX, startY, endX, endY };
+}
+
+const randomNumber = () => Math.floor(Math.random() * (50 - 25 + 1)) + 125;
+function generateRandomColor(): string {
+ // Generating a random number between 0 and 0xFFFFFF
+ const randomColor = Math.floor(Math.random() * 0xffffff);
+ // Converting the number to a hexadecimal string and padding with zeros
+ return `#${randomColor.toString(16).padStart(6, "0")}`;
+}
+
+const DATA = (numberPoints = 5) =>
+ Array.from({ length: numberPoints }, (_, index) => ({
+ value: randomNumber(),
+ color: generateRandomColor(),
+ label: `Label ${index + 1}`,
+ }));
+
+export default function DonutChart(props: { segment: string }) {
+ const description = descriptionForRoute(props.segment);
+ const [data, setData] = useState(DATA(5));
+
+ return (
+
+
+
+
+
+ {({ slice }) => {
+ const { startX, startY, endX, endY } = calculateGradientPoints(
+ slice.radius,
+ slice.startAngle,
+ slice.endAngle,
+ slice.center.x,
+ slice.center.y,
+ );
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }}
+
+
+
+
+
+
+ {description}
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeView: {
+ flex: 1,
+ backgroundColor: appColors.viewBackground.light,
+ $dark: {
+ backgroundColor: appColors.viewBackground.dark,
+ },
+ },
+ chartContainer: {
+ height: 400,
+ padding: 25,
+ },
+});
diff --git a/example/app/pie-and-donut-charts.tsx b/example/app/pie-and-donut-charts.tsx
new file mode 100644
index 00000000..b2006b0e
--- /dev/null
+++ b/example/app/pie-and-donut-charts.tsx
@@ -0,0 +1,366 @@
+import React, { useState } from "react";
+import { SafeAreaView, ScrollView, StyleSheet, View } from "react-native";
+import {
+ Canvas,
+ Circle,
+ LinearGradient,
+ Rect,
+ vec,
+} from "@shopify/react-native-skia";
+import { Pie, PolarChart } from "victory-native";
+import { InfoCard } from "example/components/InfoCard";
+import { Text } from "example/components/Text";
+import { appColors } from "./consts/colors";
+import { descriptionForRoute } from "./consts/routes";
+
+function calculateGradientPoints(
+ radius: number,
+ startAngle: number,
+ endAngle: number,
+ centerX: number,
+ centerY: number,
+) {
+ // Calculate the midpoint angle of the slice for a central gradient effect
+ const midAngle = (startAngle + endAngle) / 2;
+
+ // Convert angles from degrees to radians
+ const startRad = (Math.PI / 180) * startAngle;
+ const midRad = (Math.PI / 180) * midAngle;
+
+ // Calculate start point (inner edge near the pie's center)
+ const startX = centerX + radius * 0.5 * Math.cos(startRad);
+ const startY = centerY + radius * 0.5 * Math.sin(startRad);
+
+ // Calculate end point (outer edge of the slice)
+ const endX = centerX + radius * Math.cos(midRad);
+ const endY = centerY + radius * Math.sin(midRad);
+
+ return { startX, startY, endX, endY };
+}
+
+const randomNumber = () => Math.floor(Math.random() * (50 - 25 + 1)) + 125;
+function generateRandomColor(): string {
+ // Generating a random number between 0 and 0xFFFFFF
+ const randomColor = Math.floor(Math.random() * 0xffffff);
+ // Converting the number to a hexadecimal string and padding with zeros
+ return `#${randomColor.toString(16).padStart(6, "0")}`;
+}
+
+const DATA = (numberPoints = 5) =>
+ Array.from({ length: numberPoints }, (_, index) => ({
+ value: randomNumber(),
+ color: generateRandomColor(),
+ label: `Label ${index + 1}`,
+ }));
+
+const DonutChartSingleDataPoint = () => {
+ const [data] = useState(DATA(1));
+ return (
+
+
+ {({ slice }) => {
+ const { startX, startY, endX, endY } = calculateGradientPoints(
+ slice.radius,
+ slice.startAngle,
+ slice.endAngle,
+ slice.center.x,
+ slice.center.y,
+ );
+
+ return (
+
+
+
+ );
+ }}
+
+
+ );
+};
+
+const PieChartSingleDataPoint = () => {
+ const [data] = useState(DATA(1));
+ return (
+
+
+
+ );
+};
+const PieChartMultipleDataPoints = () => {
+ const [data] = useState(DATA(10));
+ return (
+
+
+
+ {({ slice }) => {
+ const { startX, startY, endX, endY } = calculateGradientPoints(
+ slice.radius,
+ slice.startAngle,
+ slice.endAngle,
+ slice.center.x,
+ slice.center.y,
+ );
+
+ return (
+
+
+
+ );
+ }}
+
+
+
+ {data.map((d, i) => (
+
+
+ {d.label}
+
+ ))}
+
+
+ );
+};
+const PieChartWithInsets = () => {
+ const [data] = useState(DATA(6));
+ return (
+
+
+ {({ slice }) => {
+ const { startX, startY, endX, endY } = calculateGradientPoints(
+ slice.radius,
+ slice.startAngle,
+ slice.endAngle,
+ slice.center.x,
+ slice.center.y,
+ );
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }}
+
+
+ );
+};
+const DonutChartWithInsets = () => {
+ const [data] = useState(DATA(5));
+ return (
+
+
+ {({ slice }) => {
+ const { startX, startY, endX, endY } = calculateGradientPoints(
+ slice.radius,
+ slice.startAngle,
+ slice.endAngle,
+ slice.center.x,
+ slice.center.y,
+ );
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }}
+
+
+ );
+};
+
+const PieChartSimpleNoCustomizations = () => {
+ const [data] = useState(DATA(5));
+ return (
+
+
+
+ );
+};
+const PieChartSimpleCustomLegend = () => {
+ const [data] = useState(DATA(5));
+ return (
+
+
+
+
+
+ {data.map((d, index) => {
+ return (
+
+
+ {d.label}
+
+ );
+ })}
+
+
+ );
+};
+
+export default function PieAndDonutCharts(props: { segment: string }) {
+ const description = descriptionForRoute(props.segment);
+
+ return (
+
+
+
+ {description}
+
+
+ Pie Chart with No Customizations
+
+
+
+ Donut Chart with Insets
+
+
+
+ Donut Chart with Single Data Point
+
+
+
+ Pie Chart with Single Data Point
+
+
+
+ Pie Chart with Multiple Data Points
+
+
+
+
+ Pie Chart with Insets
+
+
+
+ Pie Chart with Custom Legend
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeView: {
+ flex: 1,
+ backgroundColor: appColors.viewBackground.light,
+ $dark: {
+ backgroundColor: appColors.viewBackground.dark,
+ },
+ },
+ chartContainer: {
+ height: 400,
+ padding: 25,
+ borderBottomWidth: 1,
+ borderBottomColor: appColors.cardBorder.light,
+ $dark: {
+ borderBottomColor: appColors.cardBorder.dark,
+ },
+ },
+ title: { marginBottom: 10, fontSize: 16, fontWeight: "bold" },
+ legend: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center" },
+ legendItemContainer: {
+ flexDirection: "row",
+ padding: 5,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ legendItemCanvas: {
+ height: 12,
+ width: 12,
+ marginRight: 2,
+ },
+});
diff --git a/example/app/pie-chart.tsx b/example/app/pie-chart.tsx
new file mode 100644
index 00000000..159d618d
--- /dev/null
+++ b/example/app/pie-chart.tsx
@@ -0,0 +1,96 @@
+import React, { useState } from "react";
+import { SafeAreaView, ScrollView, StyleSheet, View } from "react-native";
+import { Pie, PolarChart } from "victory-native";
+import { InfoCard } from "example/components/InfoCard";
+import { Button } from "example/components/Button";
+import { InputSlider } from "example/components/InputSlider";
+import { InputColor } from "example/components/InputColor";
+import { appColors } from "./consts/colors";
+import { descriptionForRoute } from "./consts/routes";
+
+const randomNumber = () => Math.floor(Math.random() * (50 - 25 + 1)) + 125;
+function generateRandomColor(): string {
+ // Generating a random number between 0 and 0xFFFFFF
+ const randomColor = Math.floor(Math.random() * 0xffffff);
+ // Converting the number to a hexadecimal string and padding with zeros
+ return `#${randomColor.toString(16).padStart(6, "0")}`;
+}
+
+const DATA = (numberPoints = 5) =>
+ Array.from({ length: numberPoints }, (_, index) => ({
+ value: randomNumber(),
+ color: generateRandomColor(),
+ label: `Label ${index + 1}`,
+ }));
+
+export default function PieChart(props: { segment: string }) {
+ const description = descriptionForRoute(props.segment);
+ const [data, setData] = useState(DATA(5));
+ const [insetWidth, setInsetWidth] = useState(4);
+ const [insetColor, setInsetColor] = useState("#fafafa");
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {description}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeView: {
+ flex: 1,
+ backgroundColor: appColors.viewBackground.light,
+ $dark: {
+ backgroundColor: appColors.viewBackground.dark,
+ },
+ },
+});
diff --git a/example/components/InfoCard.tsx b/example/components/InfoCard.tsx
index 0cc464c1..df06d6a6 100644
--- a/example/components/InfoCard.tsx
+++ b/example/components/InfoCard.tsx
@@ -45,7 +45,6 @@ const styles = StyleSheet.create({
},
},
content: {
- flex: 1,
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "flex-start",
diff --git a/lib/src/index.ts b/lib/src/index.ts
index 2c961d76..cc3baaf2 100644
--- a/lib/src/index.ts
+++ b/lib/src/index.ts
@@ -45,3 +45,15 @@ export { Scatter, type ScatterShape } from "./cartesian/components/Scatter";
// Grid and Axis
export { CartesianAxis } from "./cartesian/components/CartesianAxis";
+
+/**
+ * Polar chart exports
+ */
+export { PolarChart } from "./polar/PolarChart";
+
+/**
+ * Pie chart exports (including useful types)
+ */
+export { Pie } from "./pie";
+export { useSlicePath } from "./pie/hooks/useSlicePath";
+export { useSliceAngularInsetPath } from "./pie/hooks/useSliceAngularInsetPath";
diff --git a/lib/src/pie/PieChart.tsx b/lib/src/pie/PieChart.tsx
new file mode 100644
index 00000000..a92d9353
--- /dev/null
+++ b/lib/src/pie/PieChart.tsx
@@ -0,0 +1,81 @@
+import * as React from "react";
+import { vec, type Color } from "@shopify/react-native-skia";
+import { PieSlice, type PieSliceData } from "./PieSlice";
+import { handleTranslateInnerRadius } from "./utils/innerRadius";
+import { PieSliceProvider } from "./contexts/PieSliceContext";
+import { usePolarChartContext } from "../polar/contexts/PolarChartContext";
+
+const CIRCLE_SWEEP_DEGREES = 360;
+
+type PieChartProps = {
+ children?: (args: { slice: PieSliceData }) => React.ReactNode;
+ innerRadius?: number | string;
+};
+
+export const PieChart = (props: PieChartProps) => {
+ const { innerRadius = 0, children } = props;
+ const {
+ canvasSize,
+ data: _data,
+ labelKey,
+ valueKey,
+ colorKey,
+ } = usePolarChartContext();
+
+ // The sum all the slices' values
+ const totalCircleValue = _data.reduce(
+ (sum, entry) => sum + Number(entry[valueKey]),
+ 0,
+ );
+
+ const { width, height } = canvasSize; // Get the dynamic canvas size
+ const radius = Math.min(width, height) / 2; // Calculate the radius based on canvas size
+ const center = vec(width / 2, height / 2);
+
+ const data = React.useMemo(() => {
+ let startAngle = 0; // Initialize the start angle for the first slice
+
+ const enhanced = _data.map((datum): PieSliceData => {
+ const sliceValue = datum[valueKey] as number;
+ const sliceLabel = datum[labelKey] as string;
+ const sliceColor = datum[colorKey] as Color;
+
+ const initialStartAngle = startAngle; // grab the initial start angle
+ const sweepAngle = (sliceValue / totalCircleValue) * CIRCLE_SWEEP_DEGREES; // Calculate the sweep angle for the slice as a part of the entire pie
+ const endAngle = initialStartAngle + sweepAngle; // the sum of sweep + start
+
+ startAngle += sweepAngle; // the next startAngle is the accumulation of each sweep
+ return {
+ value: sliceValue,
+ label: sliceLabel,
+ color: sliceColor,
+ innerRadius: handleTranslateInnerRadius(innerRadius, radius),
+ startAngle: initialStartAngle,
+ endAngle: endAngle,
+ sweepAngle,
+ sliceIsEntireCircle: _data.length === 1,
+ radius,
+ center,
+ };
+ });
+
+ return enhanced;
+ }, [
+ valueKey,
+ _data,
+ totalCircleValue,
+ colorKey,
+ labelKey,
+ radius,
+ center,
+ innerRadius,
+ ]);
+
+ return data.map((slice, index) => {
+ return (
+
+ {children ? children({ slice }) : }
+
+ );
+ });
+};
diff --git a/lib/src/pie/PieSlice.tsx b/lib/src/pie/PieSlice.tsx
new file mode 100644
index 00000000..b11c988b
--- /dev/null
+++ b/lib/src/pie/PieSlice.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+import {
+ type Color,
+ Path,
+ type PathProps,
+ type SkPoint,
+} from "@shopify/react-native-skia";
+import { useSlicePath } from "./hooks/useSlicePath";
+import { usePieSliceContext } from "./contexts/PieSliceContext";
+
+export type PieSliceData = {
+ center: SkPoint;
+ color: Color;
+ endAngle: number;
+ innerRadius: number;
+ label: string;
+ radius: number;
+ sliceIsEntireCircle: boolean;
+ startAngle: number;
+ sweepAngle: number;
+ value: number;
+};
+
+type AdditionalPathProps = Partial>;
+type PieSliceProps = AdditionalPathProps;
+
+export const PieSlice = (props: PieSliceProps) => {
+ const { children, ...rest } = props;
+ const { slice } = usePieSliceContext();
+
+ const path = useSlicePath({ slice });
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/lib/src/pie/PieSliceAngularInset.tsx b/lib/src/pie/PieSliceAngularInset.tsx
new file mode 100644
index 00000000..badf6a8b
--- /dev/null
+++ b/lib/src/pie/PieSliceAngularInset.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+import { type Color, Path, type PathProps } from "@shopify/react-native-skia";
+import { useSliceAngularInsetPath } from "./hooks/useSliceAngularInsetPath";
+import { usePieSliceContext } from "./contexts/PieSliceContext";
+
+export type PieSliceAngularInsetData = {
+ angularStrokeWidth: number;
+ angularStrokeColor: Color;
+};
+
+type AdditionalPathProps = Partial>;
+
+type PieSliceAngularInsetProps = {
+ angularInset: PieSliceAngularInsetData;
+} & AdditionalPathProps;
+
+export const PieSliceAngularInset = (props: PieSliceAngularInsetProps) => {
+ const { angularInset, children, ...rest } = props;
+ const { slice } = usePieSliceContext();
+ const [path, insetPaint] = useSliceAngularInsetPath({ slice, angularInset });
+
+ if (angularInset.angularStrokeWidth === 0) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/lib/src/pie/contexts/PieSliceContext.tsx b/lib/src/pie/contexts/PieSliceContext.tsx
new file mode 100644
index 00000000..c6fce9bc
--- /dev/null
+++ b/lib/src/pie/contexts/PieSliceContext.tsx
@@ -0,0 +1,39 @@
+import React, {
+ useContext,
+ createContext,
+ type PropsWithChildren,
+} from "react";
+import type { PieSliceData } from "../PieSlice";
+
+interface PieSliceContext {
+ slice: PieSliceData;
+}
+
+const PieSliceContext = createContext(undefined);
+
+interface PieSliceProviderProps {
+ slice: PieSliceData;
+}
+
+export const PieSliceProvider = ({
+ children,
+ slice: _slice,
+}: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const usePieSliceContext = () => {
+ const context = useContext(PieSliceContext);
+
+ if (context === undefined) {
+ throw new Error(
+ "usePieSliceContext must be used within a PieSliceProvider",
+ );
+ }
+
+ return context;
+};
diff --git a/lib/src/pie/hooks/useSliceAngularInsetPath.ts b/lib/src/pie/hooks/useSliceAngularInsetPath.ts
new file mode 100644
index 00000000..022dc498
--- /dev/null
+++ b/lib/src/pie/hooks/useSliceAngularInsetPath.ts
@@ -0,0 +1,85 @@
+import { Skia, PaintStyle } from "@shopify/react-native-skia";
+import { useMemo } from "react";
+import {
+ calculatePointOnCircumference,
+ degreesToRadians,
+} from "../utils/radians";
+import type { PieSliceData } from "../PieSlice";
+import type { PieSliceAngularInsetData } from "../PieSliceAngularInset";
+
+type SliceAngularInsetPathArgs = {
+ slice: PieSliceData;
+ angularInset: PieSliceAngularInsetData;
+};
+export const useSliceAngularInsetPath = ({
+ angularInset,
+ slice,
+}: SliceAngularInsetPathArgs) => {
+ const [path, paint] = useMemo(() => {
+ const { radius, center, innerRadius } = slice;
+
+ const path = Skia.Path.Make();
+
+ // Convert angles to radians for calculations
+ const startRadians = degreesToRadians(slice.startAngle);
+ const endRadians = degreesToRadians(slice.endAngle);
+
+ if (innerRadius > 0) {
+ // Calculate start and end points on the inner circumference
+ const startPointInnerRadius = calculatePointOnCircumference(
+ center,
+ innerRadius,
+ startRadians,
+ );
+ const endPointInnerRadius = calculatePointOnCircumference(
+ center,
+ innerRadius,
+ endRadians,
+ );
+ // Calculate start and end points on the outer circumference
+ const startPointOuterRadius = calculatePointOnCircumference(
+ center,
+ radius,
+ startRadians,
+ );
+ const endPointOuterRadius = calculatePointOnCircumference(
+ center,
+ radius,
+ endRadians,
+ );
+
+ // Move to center, draw line to start point, move to center, draw line to end point
+ path.moveTo(startPointInnerRadius.x, startPointInnerRadius.y);
+ path.lineTo(startPointOuterRadius.x, startPointOuterRadius.y);
+ path.moveTo(endPointInnerRadius.x, endPointInnerRadius.y);
+ path.lineTo(endPointOuterRadius.x, endPointOuterRadius.y);
+ } else {
+ // Calculate start and end points on the circumference
+ const startPoint = calculatePointOnCircumference(
+ center,
+ radius,
+ startRadians,
+ );
+ const endPoint = calculatePointOnCircumference(
+ center,
+ radius,
+ endRadians,
+ );
+
+ // Move to center, draw line to start point, move to center, draw line to end point
+ path.moveTo(center.x, center.y);
+ path.lineTo(startPoint.x, startPoint.y);
+ path.moveTo(center.x, center.y);
+ path.lineTo(endPoint.x, endPoint.y);
+ }
+
+ // Create Paint for inset
+ const insetPaint = Skia.Paint();
+ insetPaint.setColor(Skia.Color(angularInset.angularStrokeColor));
+ insetPaint.setStyle(PaintStyle.Stroke);
+ insetPaint.setStrokeWidth(angularInset.angularStrokeWidth);
+ return [path, insetPaint] as const;
+ }, [slice, angularInset]);
+
+ return [path, paint] as const;
+};
diff --git a/lib/src/pie/hooks/useSlicePath.ts b/lib/src/pie/hooks/useSlicePath.ts
new file mode 100644
index 00000000..a5c8d533
--- /dev/null
+++ b/lib/src/pie/hooks/useSlicePath.ts
@@ -0,0 +1,72 @@
+import { FillType, Skia, type SkPath } from "@shopify/react-native-skia";
+import { useMemo } from "react";
+import type { PieSliceData } from "../PieSlice";
+
+type SlicePathArgs = {
+ slice: PieSliceData;
+};
+export const useSlicePath = ({ slice }: SlicePathArgs): SkPath => {
+ const path = useMemo(() => {
+ const { radius, center, startAngle, endAngle, innerRadius } = slice;
+
+ const path = Skia.Path.Make();
+
+ // Draw the outer arc
+ path.arcToOval(
+ Skia.XYWHRect(
+ center.x - radius,
+ center.y - radius,
+ radius * 2,
+ radius * 2,
+ ),
+ startAngle,
+ endAngle - startAngle,
+ false,
+ );
+ if (slice.sliceIsEntireCircle) {
+ // If there's only one data point, draw the entire circle
+ path.addOval(
+ Skia.XYWHRect(
+ center.x - radius,
+ center.y - radius,
+ radius * 2,
+ radius * 2,
+ ),
+ );
+ }
+
+ if (innerRadius > 0) {
+ if (slice.sliceIsEntireCircle) {
+ path.addOval(
+ Skia.XYWHRect(
+ center.x - innerRadius,
+ center.y - innerRadius,
+ innerRadius * 2,
+ innerRadius * 2,
+ ),
+ );
+ path.setFillType(FillType.EvenOdd);
+ } else {
+ // Draw the inner arc in reverse
+ path.arcToOval(
+ Skia.XYWHRect(
+ center.x - innerRadius,
+ center.y - innerRadius,
+ innerRadius * 2,
+ innerRadius * 2,
+ ),
+ endAngle,
+ startAngle - endAngle,
+ false,
+ );
+ }
+ } else {
+ // If no inner radius, just draw a line back to the center (traditional pie slice)
+ path.lineTo(center.x, center.y);
+ }
+
+ return path;
+ }, [slice]);
+
+ return path;
+};
diff --git a/lib/src/pie/index.ts b/lib/src/pie/index.ts
new file mode 100644
index 00000000..71880422
--- /dev/null
+++ b/lib/src/pie/index.ts
@@ -0,0 +1,13 @@
+import { PieChart } from "./PieChart";
+import { PieSlice } from "./PieSlice";
+import { PieSliceAngularInset } from "./PieSliceAngularInset";
+import { PieSliceProvider } from "./contexts/PieSliceContext";
+
+const Pie = {
+ Chart: PieChart,
+ Slice: PieSlice,
+ SliceProvider: PieSliceProvider,
+ SliceAngularInset: PieSliceAngularInset,
+};
+
+export { Pie };
diff --git a/lib/src/pie/utils/innerRadius.ts b/lib/src/pie/utils/innerRadius.ts
new file mode 100644
index 00000000..225150ea
--- /dev/null
+++ b/lib/src/pie/utils/innerRadius.ts
@@ -0,0 +1,17 @@
+// Inner radius can be supplied as a number (px) or a percentage string
+export const handleTranslateInnerRadius = (
+ innerRadius: string | number,
+ radius: number,
+) => {
+ if (typeof innerRadius === "string") {
+ try {
+ innerRadius = parseFloat(innerRadius.replace("%", ""));
+ innerRadius = (innerRadius / 100) * radius;
+ } catch (error) {
+ console.warn(`Error parsing innerRadius as a number: ${innerRadius}`);
+ innerRadius = 0;
+ }
+ }
+ // prevent inversion, don't let the inner radius be greater than the actual radius
+ return innerRadius >= radius ? 0 : innerRadius;
+};
diff --git a/lib/src/pie/utils/radians.ts b/lib/src/pie/utils/radians.ts
new file mode 100644
index 00000000..980118c0
--- /dev/null
+++ b/lib/src/pie/utils/radians.ts
@@ -0,0 +1,16 @@
+import type { SkPoint } from "@shopify/react-native-skia";
+
+export function degreesToRadians(degrees: number): number {
+ return (degrees * Math.PI) / 180;
+}
+
+export function calculatePointOnCircumference(
+ center: SkPoint,
+ radius: number,
+ angleInRadians: number,
+): SkPoint {
+ return {
+ x: center.x + radius * Math.cos(angleInRadians),
+ y: center.y + radius * Math.sin(angleInRadians),
+ };
+}
diff --git a/lib/src/polar/PolarChart.tsx b/lib/src/polar/PolarChart.tsx
new file mode 100644
index 00000000..ed7854bd
--- /dev/null
+++ b/lib/src/polar/PolarChart.tsx
@@ -0,0 +1,129 @@
+import * as React from "react";
+import { Canvas } from "@shopify/react-native-skia";
+import {
+ StyleSheet,
+ View,
+ type ViewStyle,
+ type LayoutChangeEvent,
+ type StyleProp,
+} from "react-native";
+import {
+ PolarChartProvider,
+ usePolarChartContext,
+} from "./contexts/PolarChartContext";
+import type {
+ ColorFields,
+ InputFields,
+ NumericalFields,
+ StringKeyOf,
+} from "../types";
+
+type PolarChartBaseProps = {
+ onLayout: ({ nativeEvent: { layout } }: LayoutChangeEvent) => void;
+ hasMeasuredLayoutSize: boolean;
+ canvasSize: { width: number; height: number };
+ containerStyle?: StyleProp;
+ canvasStyle?: StyleProp;
+};
+
+const PolarChartBase = (
+ props: React.PropsWithChildren,
+) => {
+ const {
+ containerStyle,
+ canvasStyle,
+ children,
+ onLayout,
+ hasMeasuredLayoutSize,
+ canvasSize,
+ } = props;
+ const { width, height } = canvasSize;
+
+ const ctx = usePolarChartContext();
+
+ return (
+
+
+
+ );
+};
+
+type PolarChartProps<
+ RawData extends Record,
+ LabelKey extends StringKeyOf>,
+ ValueKey extends StringKeyOf>,
+ ColorKey extends StringKeyOf>,
+> = {
+ data: RawData[];
+ colorKey: ColorKey;
+ labelKey: LabelKey;
+ valueKey: ValueKey;
+} & Omit<
+ PolarChartBaseProps,
+ "canvasSize" | "onLayout" | "hasMeasuredLayoutSize" // omit exposing internal props for calculating canvas layout/size
+>;
+export const PolarChart = <
+ RawData extends Record,
+ LabelKey extends StringKeyOf>,
+ ValueKey extends StringKeyOf>,
+ ColorKey extends StringKeyOf>,
+>(
+ props: React.PropsWithChildren<
+ PolarChartProps
+ >,
+) => {
+ const { data, labelKey, colorKey, valueKey } = props;
+
+ const [canvasSize, setCanvasSize] = React.useState({ width: 0, height: 0 });
+
+ const [hasMeasuredLayoutSize, setHasMeasuredLayoutSize] =
+ React.useState(false);
+
+ const onLayout = React.useCallback(
+ ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
+ setHasMeasuredLayoutSize(true);
+ setCanvasSize(layout);
+ },
+ [],
+ );
+
+ return (
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ baseContainer: {
+ flex: 1,
+ },
+ canvasContainer: {
+ flex: 1,
+ },
+});
diff --git a/lib/src/polar/contexts/PolarChartContext.tsx b/lib/src/polar/contexts/PolarChartContext.tsx
new file mode 100644
index 00000000..34fb806d
--- /dev/null
+++ b/lib/src/polar/contexts/PolarChartContext.tsx
@@ -0,0 +1,57 @@
+import React, {
+ useContext,
+ createContext,
+ type PropsWithChildren,
+} from "react";
+
+interface PolarChartContext {
+ data: Record[];
+ canvasSize: { width: number; height: number };
+ labelKey: string;
+ valueKey: string;
+ colorKey: string;
+}
+
+const PolarChartContext = createContext(
+ undefined,
+);
+
+interface PolarChartProviderProps {
+ data: Record[];
+ canvasSize: { width: number; height: number };
+ labelKey: string;
+ valueKey: string;
+ colorKey: string;
+}
+
+export const PolarChartProvider = (
+ props: PropsWithChildren,
+) => {
+ const { children, data, canvasSize, labelKey, valueKey, colorKey } = props;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const usePolarChartContext = () => {
+ const context = useContext(PolarChartContext);
+
+ if (context === undefined) {
+ throw new Error(
+ "usePolarChartContext must be used within a PolarChartProvider",
+ );
+ }
+
+ return context;
+};
diff --git a/lib/src/types.ts b/lib/src/types.ts
index 640494cc..4f581900 100644
--- a/lib/src/types.ts
+++ b/lib/src/types.ts
@@ -87,6 +87,12 @@ export type NumericalFields = {
[K in keyof T as T[K] extends MaybeNumber ? K : never]: T[K];
};
+export type ColorFields = {
+ [K in keyof T as T[K] extends Color ? K : never]: T[K];
+};
+
+export type StringKeyOf = Extract;
+
export type AxisProps<
RawData extends Record,
XK extends keyof InputFields,
diff --git a/website/docs/polar/pie/pie-charts.md b/website/docs/polar/pie/pie-charts.md
new file mode 100644
index 00000000..ca60226f
--- /dev/null
+++ b/website/docs/polar/pie/pie-charts.md
@@ -0,0 +1,196 @@
+# Pie.Chart (Component)
+
+The `Pie.Chart` component is a child component of the `PolarChart` component and is responsible for rendering the `Pie` or `Donut` chart.
+
+:::info
+This chart does not yet support labels. We are working on adding support for labels in the future. In the meantime, you can easily add your own legend next to the chart. See the [example app](https://github.com/FormidableLabs/victory-native-xl/tree/main/example) for more details.
+:::
+
+:::tip
+
+The [example app](https://github.com/FormidableLabs/victory-native-xl/tree/main/example) inside this repo has a lot of examples of how to use the `Pie.Chart` and its associated components!
+
+:::
+
+## Example
+
+The example below shows the most basic use of the `Pie.Chart`.
+
+```tsx
+import { View } from "react-native";
+import { Pie } from "victory-native";
+
+function MyChart() {
+ return (
+
+
+
+
+
+ );
+}
+
+function randomNumber() {
+ return Math.floor(Math.random() * 26) + 125;
+}
+function generateRandomColor(): string {
+ // Generating a random number between 0 and 0xFFFFFF
+ const randomColor = Math.floor(Math.random() * 0xffffff);
+ // Converting the number to a hexadecimal string and padding with zeros
+ return `#${randomColor.toString(16).padStart(6, "0")}`;
+}
+const DATA = (numberPoints = 5) =>
+ Array.from({ length: numberPoints }, (_, index) => ({
+ value: randomNumber(),
+ color: generateRandomColor(),
+ label: `Label ${index + 1}`,
+ }));
+```
+
+## Props
+
+### `innerRadius`
+
+A `number` or `string` (as a percentage) which turns the `Pie` chart into a `Donut` chart. The `innerRadius` prop is the radius of the inner circle of the donut chart. If not provided, the chart will remain a `Pie` chart.
+
+:::tip
+The `innerRadius` prop must be a `number` or a `string` like `50%`.
+:::
+
+### `children`
+
+The `children` prop is a render function which maps through the data and whose sole argument is each individual `slice` of the pie, allowing you to customize each slice as needed. E.g. this slice will have all the data needed to render a `Pie.Slice />`.
+
+If you do not provide any children, the `Pie.Chart` will just render a simple `Pie.Slice />` for each slice.
+
+However, you can provide children in order to add things like `Pie.SliceAngularInsets` and `LinearGradients` amongst other things to each slice, or to wholly customize your own rendering.
+
+**See the [Render Function Fields](#render-function-fields) section for an outline of all of the available fields on the render function argument.**
+
+The example below shows a more complex use of the `Pie.Chart`:
+
+### Customizing Children Example
+
+```tsx
+import { View } from "react-native";
+import { Pie } from "victory-native";
+
+function MyChart() {
+ return (
+
+
+ {/* 👇 each individual slice */}
+ {({ slice }) => {
+ const { startX, startY, endX, endY } = calculateGradientPoints(
+ slice.radius,
+ slice.startAngle,
+ slice.endAngle,
+ slice.center.x,
+ slice.center.y,
+ ); // 👈 create your own custom fn to calculate the gradient details (see example app)
+
+ return (
+ <>
+ {/* 👇 return customized slice here */}
+
+
+
+
+ >
+ );
+ }}
+
+
+ );
+}
+
+function calculateGradientPoints(
+ radius: number,
+ startAngle: number,
+ endAngle: number,
+ centerX: number,
+ centerY: number,
+) {
+ // Calculate the midpoint angle of the slice for a central gradient effect
+ const midAngle = (startAngle + endAngle) / 2;
+
+ // Convert angles from degrees to radians
+ const startRad = (Math.PI / 180) * startAngle;
+ const midRad = (Math.PI / 180) * midAngle;
+
+ // Calculate start point (inner edge near the pie's center)
+ const startX = centerX + radius * 0.5 * Math.cos(startRad);
+ const startY = centerY + radius * 0.5 * Math.sin(startRad);
+
+ // Calculate end point (outer edge of the slice)
+ const endX = centerX + radius * Math.cos(midRad);
+ const endY = centerY + radius * Math.sin(midRad);
+
+ return { startX, startY, endX, endY };
+}
+
+function randomNumber() {
+ return Math.floor(Math.random() * 26) + 125;
+}
+
+function generateRandomColor(): string {
+ // Generating a random number between 0 and 0xFFFFFF
+ const randomColor = Math.floor(Math.random() * 0xffffff);
+ // Converting the number to a hexadecimal string and padding with zeros
+ return `#${randomColor.toString(16).padStart(6, "0")}`;
+}
+
+const DATA = (numberPoints = 5) =>
+ Array.from({ length: numberPoints }, (_, index) => ({
+ value: randomNumber(),
+ color: generateRandomColor(),
+ label: `Label ${index + 1}`,
+ }));
+```
+
+## Render Function Fields
+
+The `Pie.Chart` `children` render function has a single argument that is an object with the following fields.
+
+### `slice`
+
+An object of the form of `PieSliceData` which is the transformed data for each slice of the pie. The `slice` object has the following fields:
+
+```ts
+type PieSliceData = {
+ center: SkPoint;
+ color: Color;
+ endAngle: number;
+ innerRadius: number;
+ label: string;
+ radius: number;
+ sliceIsEntireCircle: boolean;
+ startAngle: number;
+ sweepAngle: number;
+ value: number;
+};
+```
+
+:::info
+Generally, you would not need to use the `slice` object directly, but it is available to you if you need to do something custom with each slice. Please refer to the example app repo for more information on how to use the `slice` object e.g the `LinearGradient` examples.
+:::
diff --git a/website/docs/polar/pie/use-slice-angular-inset-path.md b/website/docs/polar/pie/use-slice-angular-inset-path.md
new file mode 100644
index 00000000..1e2c8037
--- /dev/null
+++ b/website/docs/polar/pie/use-slice-angular-inset-path.md
@@ -0,0 +1,51 @@
+# `useSliceAngularPath`
+
+The `useSliceAngularPath` hook takes a `PieSliceData` as input, and returns an array of Skia `[SkPath, SkPaint]` objects that represent the path and the paint for that pie slice.
+
+:::info
+
+- You wouldn't normally use this unless you were creating entirely custom angular insets.
+
+:::
+
+## Example
+
+```tsx
+import { Pie, useSliceAngularPath, type PieSliceData } from "victory-native";
+import { Path } from "@shopify/react-native-skia";
+import DATA from "./my-data";
+
+function MyCustomSliceAngularInset({ slice }: { slice: PieSliceData }) {
+ // 👇 use the hook to generate a path and paint object.
+ const [path, insetPaint] = useSliceAngularInsetPath({ slice, angularInset });
+ /* 👇 experiment wtih any other customizations you want */
+ return ;
+}
+
+export function MyChart() {
+ return (
+
+ {/* 👇 pass the PieSliceData to our custom component */}
+ {({ slice }) => }
+
+ );
+}
+```
+
+## Arguments
+
+`useSliceAngularPath` has a function signature as follows:
+
+```ts
+useSliceAngularPath(slice: PieSliceData): [SkPath, SkPaint]
+```
+
+### `slice`
+
+The `slice` argument is a `PieSliceData` object used to generate the slices's path. Generally, this data comes from render function of the `Pie.Chart`, as illustrated in the example above.
+
+## Returns
+
+### [SkPath, SkPaint]
+
+The `SkPath` path object to be used as the `path` argument of a Skia `` element. The `SkPaint` path object to be used as the `paint` argument of a Skia `` element.
diff --git a/website/docs/polar/pie/use-slice-path.md b/website/docs/polar/pie/use-slice-path.md
new file mode 100644
index 00000000..12524e71
--- /dev/null
+++ b/website/docs/polar/pie/use-slice-path.md
@@ -0,0 +1,56 @@
+# `useSlicePath`
+
+The `useSlicePath` hook takes a `PieSliceData` as input, and returns a Skia `SkPath` path object that represents the path for that pie slice.
+
+:::info
+
+- You wouldn't normally use this unless you were creating entirely custom slices.
+
+:::
+
+## Example
+
+```tsx
+import { Pie, useSlicePath, type PieSliceData } from "victory-native";
+import { Path } from "@shopify/react-native-skia";
+import DATA from "./my-data";
+
+function MyCustomSlice({ slice }: { slice: PieSliceData }) {
+ // 👇 use the hook to generate a path object.
+ const path = useSlicePath(slice);
+ /* 👇 experiment wtih any other customizations you want */
+ return
+}
+
+export function MyChart() {
+ return (
+
+ {/* 👇 pass the PieSliceData to our custom component */}
+ {({ slice }) => }
+
+ );
+}
+```
+
+## Arguments
+
+`useSlicePath` has a function signature as follows:
+
+```ts
+useSlicePath(slice: PieSliceData): SkPath
+```
+
+### `slice`
+
+The `slice` argument is a `PieSliceData` object used to generate the slices's path. Generally, this data comes from render function of the `Pie.Chart`, as illustrated in the example above.
+
+## Returns
+
+### `path`
+
+The `SkPath` path object to be used as the `path` argument of a Skia `` element.
diff --git a/website/docs/polar/polar-chart.md b/website/docs/polar/polar-chart.md
new file mode 100644
index 00000000..eed89d53
--- /dev/null
+++ b/website/docs/polar/polar-chart.md
@@ -0,0 +1,94 @@
+# Polar Chart
+
+The `PolarChart` component provides another chart container component in `victory-native`. Its core responsibilities are:
+
+- accepting raw data and metadata in a format that can then be easily transformed and used for charting `Pie` and `Donut` charts.
+
+:::info
+This chart does not yet support gestures or animations.
+:::
+
+:::tip
+
+The [example app](https://github.com/FormidableLabs/victory-native-xl/tree/main/example) inside this repo has a lot of examples of how to use the `PolarChart` and its associated components!
+
+:::
+
+## Example
+
+The example below shows the most basic use of the `PolarChart`.
+
+```tsx
+import { View } from "react-native";
+import { Pie } from "victory-native";
+
+function MyChart() {
+ return (
+
+
+
+
+
+ );
+}
+
+// helper functions for example purposes:
+function randomNumber() {
+ return Math.floor(Math.random() * 26) + 125;
+}
+function generateRandomColor(): string {
+ // Generating a random number between 0 and 0xFFFFFF
+ const randomColor = Math.floor(Math.random() * 0xffffff);
+ // Converting the number to a hexadecimal string and padding with zeros
+ return `#${randomColor.toString(16).padStart(6, "0")}`;
+}
+const DATA = (numberPoints = 5) =>
+ Array.from({ length: numberPoints }, (_, index) => ({
+ value: randomNumber(),
+ color: generateRandomColor(),
+ label: `Label ${index + 1}`,
+ }));
+```
+
+## Props
+
+### `data` (required)
+
+An array of objects to be used as data points for the chart.
+
+### `labelKey` (required)
+
+A `string` value indicating the _key_ of each `data[number]` object to be used. Currently only used on the legend part of the chart. In the future we may add support for a variety of labels within the chart. The value of the label can be a `string` | `number`
+
+### `valueKey` (required)
+
+A `string` value indicating the _key_ of each `data[number]` object to be used to draw a slice of the Pie.
+
+:::info
+The `valueKey` prop must be a key for a field that has a `number` value. That is, only `number`s can be used as dependent values for charting purposes.
+:::
+
+### `colorKey` (required)
+
+A `string` value indicating the _key_ of each `data[number]` object to be used to draw a slice of the Pie.
+
+:::info
+The `valueKey` prop must be a key for a field that has a Skia `Color` value.
+:::
+
+### `children`
+
+The only supported `children` of a `PolarChart` is currently a `Pie.Chart` **See the [Pie Chart](/polar/pie/pie-charts) for more details.**
+
+### `containerStyle`
+
+A `StyleProp` that styles the `View` which wraps the `Canvas` of the `Polar` chart.
+
+### `canvasStyle`
+
+A `StyleProp` that styles the `Canvas` upon which the `Polar` chart is drawn.
diff --git a/website/sidebars.js b/website/sidebars.js
index 2cd86f1f..b51748f7 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -48,6 +48,24 @@ const sidebars = {
},
],
},
+ {
+ type: "category",
+ label: "Polar Charts",
+ collapsed: false,
+ collapsible: false,
+ items: [
+ "polar/polar-chart",
+ {
+ type: "category",
+ label: "Pie / Donut Paths",
+ items: [
+ "polar/pie/pie-charts",
+ "polar/pie/use-slice-path",
+ "polar/pie/use-slice-angular-inset-path",
+ ],
+ },
+ ],
+ },
"animated-paths",
],