From 23b7928e4e2eab854197d676b60c7321620a297f Mon Sep 17 00:00:00 2001 From: Eli Zibin Date: Thu, 12 Dec 2024 10:24:36 -0800 Subject: [PATCH 1/5] add animations to pie charts --- example/app/donut-chart.tsx | 7 +++--- lib/src/hooks/useAnimatedPath.ts | 37 ++++++++++++++++++++++------ lib/src/pie/PieSlice.tsx | 23 +++++++++++++---- lib/src/pie/PieSliceAngularInset.tsx | 13 +++++++--- 4 files changed, 61 insertions(+), 19 deletions(-) diff --git a/example/app/donut-chart.tsx b/example/app/donut-chart.tsx index 688df759..3f1a452c 100644 --- a/example/app/donut-chart.tsx +++ b/example/app/donut-chart.tsx @@ -12,7 +12,7 @@ function calculateGradientPoints( startAngle: number, endAngle: number, centerX: number, - centerY: number, + centerY: number ) { // Calculate the midpoint angle of the slice for a central gradient effect const midAngle = (startAngle + endAngle) / 2; @@ -68,12 +68,12 @@ export default function DonutChart(props: { segment: string }) { slice.startAngle, slice.endAngle, slice.center.x, - slice.center.y, + slice.center.y ); return ( <> - + { const t = useSharedValue(0); const [prevPath, setPrevPath] = React.useState(path); @@ -54,9 +54,32 @@ export const useAnimatedPath = ( }, [path, t]); const currentPath = useDerivedValue(() => { - if (t.value !== 1 && path.isInterpolatable(prevPath)) { - return path.interpolate(prevPath, t.value) || path; + if (t.value !== 1) { + if (!path.isInterpolatable(prevPath)) { + // 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 + 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 + ); + } + } else if (path.isInterpolatable(prevPath)) { + return path.interpolate(prevPath, t.value) || path; + } } + return path; }); diff --git a/lib/src/pie/PieSlice.tsx b/lib/src/pie/PieSlice.tsx index 05d3fc3d..52749f0c 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 }); @@ -34,18 +39,26 @@ export const PieSlice = ({ children, ...rest }: PieSliceProps) => { const childrenArray = React.Children.toArray(children); const labelIndex = childrenArray.findIndex( - (child) => (child as ReactElement).type === PieLabel, + (child) => (child as ReactElement).type === PieLabel ); if (labelIndex > -1) { 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..7ba38529 100644 --- a/lib/src/pie/PieSliceAngularInset.tsx +++ b/lib/src/pie/PieSliceAngularInset.tsx @@ -2,20 +2,24 @@ 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 }); @@ -23,9 +27,10 @@ export const PieSliceAngularInset = (props: PieSliceAngularInsetProps) => { return null; } + const Component = animate ? AnimatedPath : Path; return ( - + {children} - + ); }; From 2a10cdbb4a337fd918a0d97286a9f1bf1553a612 Mon Sep 17 00:00:00 2001 From: Eli Zibin Date: Thu, 12 Dec 2024 10:27:07 -0800 Subject: [PATCH 2/5] docs --- 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 + 5 files changed, 269 insertions(+), 6 deletions(-) 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/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", ], From c745df65059f4ec8e8ec59fb706847cca4359e1e Mon Sep 17 00:00:00 2001 From: Eli Zibin Date: Thu, 12 Dec 2024 11:53:59 -0800 Subject: [PATCH 3/5] don't render inset at 0 it messes with the initial animation --- example/app/donut-chart.tsx | 4 ++-- lib/src/hooks/useAnimatedPath.ts | 12 ++++++------ lib/src/pie/PieSlice.tsx | 2 +- lib/src/pie/PieSliceAngularInset.tsx | 5 +++++ 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/example/app/donut-chart.tsx b/example/app/donut-chart.tsx index 3f1a452c..2a1e9a50 100644 --- a/example/app/donut-chart.tsx +++ b/example/app/donut-chart.tsx @@ -12,7 +12,7 @@ function calculateGradientPoints( startAngle: number, endAngle: number, centerX: number, - centerY: number + centerY: number, ) { // Calculate the midpoint angle of the slice for a central gradient effect const midAngle = (startAngle + endAngle) / 2; @@ -68,7 +68,7 @@ export default function DonutChart(props: { segment: string }) { slice.startAngle, slice.endAngle, slice.center.x, - slice.center.y + slice.center.y, ); return ( diff --git a/lib/src/hooks/useAnimatedPath.ts b/lib/src/hooks/useAnimatedPath.ts index 11b961be..6494b134 100644 --- a/lib/src/hooks/useAnimatedPath.ts +++ b/lib/src/hooks/useAnimatedPath.ts @@ -17,26 +17,26 @@ export type PathAnimationConfig = | ({ type: "decay" } & WithDecayConfig); function isWithDecayConfig( - config: PathAnimationConfig + config: PathAnimationConfig, ): config is WithDecayConfig & { type: "decay" } { return config.type === "decay"; } function isWithTimingConfig( - config: PathAnimationConfig + config: PathAnimationConfig, ): config is WithTimingConfig & { type: "timing" } { return config.type === "timing"; } function isWithSpringConfig( - config: PathAnimationConfig + config: PathAnimationConfig, ): config is WithSpringConfig & { type: "spring" } { return config.type === "spring"; } export const useAnimatedPath = ( path: SkPath, - animConfig: PathAnimationConfig = { type: "timing", duration: 300 } + animConfig: PathAnimationConfig = { type: "timing", duration: 300 }, ) => { const t = useSharedValue(0); const [prevPath, setPrevPath] = React.useState(path); @@ -61,10 +61,10 @@ export const useAnimatedPath = ( const normalizePrecision = (path: string): string => path.replace(/(\d+\.\d+)/g, (match) => parseFloat(match).toFixed(3)); const pathNormalized = Skia.Path.MakeFromSVGString( - normalizePrecision(path.toSVGString()) + normalizePrecision(path.toSVGString()), ); const prevPathNormalized = Skia.Path.MakeFromSVGString( - normalizePrecision(prevPath.toSVGString()) + normalizePrecision(prevPath.toSVGString()), ); if ( pathNormalized && diff --git a/lib/src/pie/PieSlice.tsx b/lib/src/pie/PieSlice.tsx index 52749f0c..47807236 100644 --- a/lib/src/pie/PieSlice.tsx +++ b/lib/src/pie/PieSlice.tsx @@ -39,7 +39,7 @@ export const PieSlice = ({ children, animate, ...rest }: PieSliceProps) => { const childrenArray = React.Children.toArray(children); const labelIndex = childrenArray.findIndex( - (child) => (child as ReactElement).type === PieLabel + (child) => (child as ReactElement).type === PieLabel, ); if (labelIndex > -1) { diff --git a/lib/src/pie/PieSliceAngularInset.tsx b/lib/src/pie/PieSliceAngularInset.tsx index 7ba38529..bede0905 100644 --- a/lib/src/pie/PieSliceAngularInset.tsx +++ b/lib/src/pie/PieSliceAngularInset.tsx @@ -23,6 +23,11 @@ export const PieSliceAngularInset = (props: PieSliceAngularInsetProps) => { 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; } From 47cbb17fdd3bc31e64d5d08dd493ff04b7e1a193 Mon Sep 17 00:00:00 2001 From: Eli Zibin Date: Thu, 12 Dec 2024 13:36:48 -0800 Subject: [PATCH 4/5] Create plenty-olives-train.md --- .changeset/plenty-olives-train.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/plenty-olives-train.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 From 4a58e84d8198565a9e6b41232f39d8246760a228 Mon Sep 17 00:00:00 2001 From: Eli Zibin Date: Fri, 13 Dec 2024 09:31:58 -0800 Subject: [PATCH 5/5] Update useAnimatedPath.ts --- lib/src/hooks/useAnimatedPath.ts | 39 ++++++++++++++------------------ 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/lib/src/hooks/useAnimatedPath.ts b/lib/src/hooks/useAnimatedPath.ts index 6494b134..1be27055 100644 --- a/lib/src/hooks/useAnimatedPath.ts +++ b/lib/src/hooks/useAnimatedPath.ts @@ -55,28 +55,23 @@ export const useAnimatedPath = ( const currentPath = useDerivedValue(() => { if (t.value !== 1) { - if (!path.isInterpolatable(prevPath)) { - // 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 - 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 - ); - } - } else if (path.isInterpolatable(prevPath)) { - return path.interpolate(prevPath, t.value) || path; + // 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; } }