From dc085bc709f7e38a69314e3ded8c47f3da10b6dd Mon Sep 17 00:00:00 2001 From: Eli Zibin <1131641+zibs@users.noreply.github.com> Date: Fri, 13 Dec 2024 10:23:19 -0800 Subject: [PATCH] feat: pie animations (#451) --- .changeset/plenty-olives-train.md | 5 + example/app/donut-chart.tsx | 3 +- lib/src/hooks/useAnimatedPath.ts | 24 ++- lib/src/pie/PieSlice.tsx | 21 +- lib/src/pie/PieSliceAngularInset.tsx | 18 +- website/docs/polar/pie/pie-charts.md | 4 +- .../docs/polar/pie/pie-slice-angular-inset.md | 82 ++++++++ website/docs/polar/pie/pie-slice.md | 184 ++++++++++++++++++ website/docs/polar/polar-chart.md | 3 - website/sidebars.ts | 2 + 10 files changed, 328 insertions(+), 18 deletions(-) create mode 100644 .changeset/plenty-olives-train.md create mode 100644 website/docs/polar/pie/pie-slice-angular-inset.md create mode 100644 website/docs/polar/pie/pie-slice.md diff --git a/.changeset/plenty-olives-train.md b/.changeset/plenty-olives-train.md new file mode 100644 index 00000000..df8e7108 --- /dev/null +++ b/.changeset/plenty-olives-train.md @@ -0,0 +1,5 @@ +--- +"victory-native": minor +--- + +Add animations for pie chart diff --git a/example/app/donut-chart.tsx b/example/app/donut-chart.tsx index 688df759..2a1e9a50 100644 --- a/example/app/donut-chart.tsx +++ b/example/app/donut-chart.tsx @@ -73,7 +73,7 @@ export default function DonutChart(props: { segment: string }) { return ( <> - + (() => { - if (t.value !== 1 && path.isInterpolatable(prevPath)) { - return path.interpolate(prevPath, t.value) || path; + if (t.value !== 1) { + // Match floating-point numbers in a string and normalize their precision as this is essential for Skia to interpolate paths + // Without normalization, Skia won't be able to interpolate paths in Pie slice shapes + // This normalization is really only needed for pie charts at the moment + const normalizePrecision = (path: string): string => + path.replace(/(\d+\.\d+)/g, (match) => parseFloat(match).toFixed(3)); + const pathNormalized = Skia.Path.MakeFromSVGString( + normalizePrecision(path.toSVGString()), + ); + const prevPathNormalized = Skia.Path.MakeFromSVGString( + normalizePrecision(prevPath.toSVGString()), + ); + if ( + pathNormalized && + prevPathNormalized && + pathNormalized.isInterpolatable(prevPathNormalized) + ) { + return pathNormalized.interpolate(prevPathNormalized, t.value) || path; + } } + return path; }); diff --git a/lib/src/pie/PieSlice.tsx b/lib/src/pie/PieSlice.tsx index 05d3fc3d..47807236 100644 --- a/lib/src/pie/PieSlice.tsx +++ b/lib/src/pie/PieSlice.tsx @@ -9,6 +9,8 @@ import { useSlicePath } from "./hooks/useSlicePath"; import { usePieSliceContext } from "./contexts/PieSliceContext"; import type { PieLabelProps } from "./PieLabel"; import PieLabel from "./PieLabel"; +import { AnimatedPath } from "../cartesian/components/AnimatedPath"; +import type { PathAnimationConfig } from "../hooks/useAnimatedPath"; export type PieSliceData = { center: SkPoint; @@ -24,9 +26,12 @@ export type PieSliceData = { }; type AdditionalPathProps = Partial>; -type PieSliceProps = AdditionalPathProps & { label?: PieLabelProps }; +type PieSliceProps = AdditionalPathProps & { + label?: PieLabelProps; + animate?: PathAnimationConfig; +}; -export const PieSlice = ({ children, ...rest }: PieSliceProps) => { +export const PieSlice = ({ children, animate, ...rest }: PieSliceProps) => { const { slice } = usePieSliceContext(); const path = useSlicePath({ slice }); @@ -41,11 +46,19 @@ export const PieSlice = ({ children, ...rest }: PieSliceProps) => { label = childrenArray.splice(labelIndex, 1); } + const Component = animate ? AnimatedPath : Path; + return ( <> - + {childrenArray} - + {label} ); diff --git a/lib/src/pie/PieSliceAngularInset.tsx b/lib/src/pie/PieSliceAngularInset.tsx index badf6a8b..bede0905 100644 --- a/lib/src/pie/PieSliceAngularInset.tsx +++ b/lib/src/pie/PieSliceAngularInset.tsx @@ -2,30 +2,40 @@ 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"; +import type { PathAnimationConfig } from "../hooks/useAnimatedPath"; +import { AnimatedPath } from "../cartesian/components/AnimatedPath"; export type PieSliceAngularInsetData = { angularStrokeWidth: number; angularStrokeColor: Color; }; -type AdditionalPathProps = Partial>; +type AdditionalPathProps = Partial> & { + animate?: PathAnimationConfig; +}; type PieSliceAngularInsetProps = { angularInset: PieSliceAngularInsetData; } & AdditionalPathProps; export const PieSliceAngularInset = (props: PieSliceAngularInsetProps) => { - const { angularInset, children, ...rest } = props; + const { angularInset, children, animate, ...rest } = props; const { slice } = usePieSliceContext(); const [path, insetPaint] = useSliceAngularInsetPath({ slice, angularInset }); + // If the path is empty, don't render anything + if (path.toSVGString() === "M0 0L0 0M0 0L0 0") { + return null; + } + if (angularInset.angularStrokeWidth === 0) { return null; } + const Component = animate ? AnimatedPath : Path; return ( - + {children} - + ); }; diff --git a/website/docs/polar/pie/pie-charts.md b/website/docs/polar/pie/pie-charts.md index d6624452..338f0896 100644 --- a/website/docs/polar/pie/pie-charts.md +++ b/website/docs/polar/pie/pie-charts.md @@ -2,9 +2,6 @@ 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 @@ -208,6 +205,7 @@ type PieSliceData = { :::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. +::: ### Pie Slice Labels diff --git a/website/docs/polar/pie/pie-slice-angular-inset.md b/website/docs/polar/pie/pie-slice-angular-inset.md new file mode 100644 index 00000000..f3f91f7b --- /dev/null +++ b/website/docs/polar/pie/pie-slice-angular-inset.md @@ -0,0 +1,82 @@ +# Pie.SliceAngularInset (Component) + +The `Pie.SliceAngularInset` component is a child component of the `Pie.Chart` component and is responsible for rendering the individual slice of a `Pie` or `Donut` chart. By default the `Pie.SliceAngularInset` component will render a simple slice of the pie, but you can customize the rendering of each slice by providing children to the `Pie.Chart` component. + +:::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 how to use `Pie.SliceAngularInset` to render `LinearGradient` slices. + +```tsx +import { View } from "react-native"; +import { Pie, PolarChart } from "victory-native"; + +function MyChart() { + return ( + + + + {({ slice }) => { + // ☝️ render function of each slice object for each pie slice + 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 + +### angularStrokeWidth + +The `angularStrokeWidth` prop is used to set the width of the angular inset stroke. + +### angularStrokeColor + +The `angularStrokeColor` prop is used to set the color of the angular inset stroke. + +### `animate` + +The `animate` prop takes [a `PathAnimationConfig` object](../../animated-paths.md#animconfig) and will animate the path when the points changes. + +### `children` + +This component is just a `Path` under the hood, so accepts most props that a `Path` would accept. diff --git a/website/docs/polar/pie/pie-slice.md b/website/docs/polar/pie/pie-slice.md new file mode 100644 index 00000000..b22b2125 --- /dev/null +++ b/website/docs/polar/pie/pie-slice.md @@ -0,0 +1,184 @@ +# Pie.Slice (Component) + +The `Pie.Slice` component is a child component of the `Pie.Chart` component and is responsible for rendering the individual spaces between slices of a `Pie` or `Donut` chart. + +:::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 how to use `Pie.SliceAngularInset` to render `LinearGradient` slices. + +```tsx +import { View } from "react-native"; +import { Pie, PolarChart } from "victory-native"; + +function MyChart() { + return ( + + + + {({ slice }) => { + // ☝️ render function of each slice object for each pie slice with props described below + const { startX, startY, endX, endY } = calculateGradientPoints( + slice.radius, + slice.startAngle, + slice.endAngle, + slice.center.x, + slice.center.y + ); + + 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")}`; +} + +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 DATA = (numberPoints = 5) => + Array.from({ length: numberPoints }, (_, index) => ({ + value: randomNumber(), + color: generateRandomColor(), + label: `Label ${index + 1}`, + })); +``` + +## Props + +### `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. +::: + +### `animate` + +The `animate` prop takes [a `PathAnimationConfig` object](../../animated-paths.md#animconfig) and will animate the path when the points changes. + +### `children` + +You can optionally provide children in order to add things like `Pie.Label`, and `LinearGradients` amongst other things to each slice, or to wholly customize your own rendering. + +### Pie Slice Labels + +The `` accepts a `` child element that allows for slice label customization. + +The `` accepts render props, and a custom render function. + +`font?: SkFont | null` - Used for calculating the labels position and to be used with the Skia `` element. + +`radiusOffset?: number` - Used to move the slice label closer or further away from the pie chart center. + +`color?: Color` - Set the labels color. + +`text?: String` - Specify the text to use for the label. Defaults to `slice.label`. + +`children?: (position: LabelPosition) => ReactNode` - Render function to allow custom slice labels. The `` will do some calculations for you and pass the `position` based on `radiusOffset` you provide. + +```tsx +... +<> + + {/* 👇 configure slice label with render props */} + + + + +... +``` + +```tsx +... +<> + + {/* 👇 configure custom slice label with render function */} + + {(position) => } + + + + +... +``` + +::: diff --git a/website/docs/polar/polar-chart.md b/website/docs/polar/polar-chart.md index a51bc980..89b93c94 100644 --- a/website/docs/polar/polar-chart.md +++ b/website/docs/polar/polar-chart.md @@ -4,9 +4,6 @@ The `PolarChart` component provides another chart container component in `victor - 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 diff --git a/website/sidebars.ts b/website/sidebars.ts index d418cda2..c20fbde0 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -68,6 +68,8 @@ const sidebars = { label: "Pie / Donut Paths", items: [ "polar/pie/pie-charts", + "polar/pie/pie-slice", + "polar/pie/pie-slice-angular-inset", "polar/pie/use-slice-path", "polar/pie/use-slice-angular-inset-path", ],