Pie Chart Insights
A light weight component that displays data as a customizable SVG pie chart with a central total value and a detailed two-column legend.
react-nativechartpie-chartdata-visualizationsvgui-componentinsights
Installation
Step 1: Install Dependencies
npx expo install react-native-svg
Step 2: Add the PieChartInsights.tsx file
PieChartInsights.tsx
import React from 'react';
import { View, Text, StyleSheet, StyleProp, ViewStyle, TextStyle } from 'react-native';
import Svg, { Circle, Text as SvgText } from 'react-native-svg';
export type PieChartDataItem = {
id: string | number; // Unique key for mapping
label: string; // Label for the legend
value: number; // Numerical value for the slice
color: string; // Color for the slice and legend marker
};
type svgTextStyle = {
fontSize: number;
color: string;
fontWeight: string;
}
type PieChartInsightsProps = {
data: PieChartDataItem[];
title?: string;
size?: number; // SVG canvas size
strokeWidth?: number; // Thickness of the pie chart arcs
totalLabel?: string; // Label for the total value in the center (e.g., "Total")
valueSuffix?: string; // Suffix for values (e.g., " USD", " Points")
formatValue?: (value: number) => string; // Function to format numerical values for display
// Styling Props
containerStyle?: StyleProp<ViewStyle>;
titleStyle?: StyleProp<TextStyle>;
centerSectionStyle?: StyleProp<ViewStyle>; // Style for the View containing center text and pie
centerValueStyle?: svgTextStyle;
centerTotalLabelStyle?: svgTextStyle;
pieBaseColor?: string; // Background color of the full circle behind segments
emptyPieColor?: string; // Color of the pie when all values are zero or data is empty
legendContainerStyle?: StyleProp<ViewStyle>;
legendColumnStyle?: StyleProp<ViewStyle>; // Style for each column in the legend
legendItemContainerStyle?: StyleProp<ViewStyle>; // Style for an individual legend item's wrapping view
legendItemDetailsStyle?: StyleProp<ViewStyle>; // Style for the view containing text part of legend item
legendItemLabelStyle?: StyleProp<TextStyle>;
legendItemValueStyle?: StyleProp<TextStyle>; // For "formattedValue Suffix"
legendItemPercentageStyle?: StyleProp<TextStyle>;
//additionally you could add your loading state here if you want to make sure data is loaded but that is better done in the parent
};
const DefaultFormatValue = (value: number): string => {
if (value === undefined || value === null) return '0';
// Simple compact number formatter as a default
if (Math.abs(value) >= 1.0e9) {
return (Math.abs(value) / 1.0e9).toFixed(1).replace(/\.0$/, '') + 'B';
}
if (Math.abs(value) >= 1.0e6) {
return (Math.abs(value) / 1.0e6).toFixed(1).replace(/\.0$/, '') + 'M';
}
if (Math.abs(value) >= 1.0e3) {
return (Math.abs(value) / 1.0e3).toFixed(1).replace(/\.0$/, '') + 'K';
}
return value.toLocaleString();
};
const PieChartInsights: React.FC<PieChartInsightsProps> = ({
data,
title,
size = 240,
strokeWidth = 30,
totalLabel = 'Total',
valueSuffix = '',
formatValue = DefaultFormatValue,
containerStyle,
titleStyle,
centerSectionStyle,
centerValueStyle,
centerTotalLabelStyle,
pieBaseColor = '#F0F0F0', // Light gray for base
emptyPieColor = '#E0E0E0', // Slightly different gray for empty state
legendContainerStyle,
legendColumnStyle,
legendItemContainerStyle,
legendItemDetailsStyle,
legendItemLabelStyle,
legendItemValueStyle,
legendItemPercentageStyle,
}) => {
const center = size / 2;
const radius = size / 2 - strokeWidth / 2; // Adjust radius to account for stroke width center
const circumference = 2 * Math.PI * radius;
const totalValue = React.useMemo(
() => data.reduce((sum, item) => sum + item.value, 0),
[data]
);
const allValuesZero = totalValue === 0;
const calculateStrokeDashArray = (
itemValue: number,
currentTotal: number,
circ: number
) => {
if (currentTotal === 0) return `0 ${circ}`; // No stroke if total is zero
const stroke = (itemValue / currentTotal) * circ;
return `${stroke} ${circ - stroke}`;
};
const calculateStrokeDashOffset = (
cumulativeValue: number,
currentTotal: number,
circ: number
) => {
if (currentTotal === 0) return 0;
return -( (cumulativeValue / currentTotal) * circ );
};
let cumulativePercentage = 0;
// For legend layout (2 columns)
const midPoint = Math.ceil(data.length / 2);
const legendColumn1 = data.slice(0, midPoint);
const legendColumn2 = data.slice(midPoint);
return (
<View style={[styles.defaultContainer, containerStyle]}>
{title && <Text style={[styles.defaultTitle, titleStyle]}>{title}</Text>}
<View style={[styles.defaultCenterSection, centerSectionStyle]}>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{/* Base circle */}
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={allValuesZero ? emptyPieColor : pieBaseColor}
strokeWidth={strokeWidth}
/>
{/* Data segments */}
{!allValuesZero &&
data.map((item) => {
const strokeDasharray = calculateStrokeDashArray(
item.value,
totalValue,
circumference
);
const strokeDashoffset = calculateStrokeDashOffset(
cumulativePercentage,
totalValue,
circumference
);
cumulativePercentage += item.value; // Update for next segment
return (
<Circle
key={item.id}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={item.color}
strokeWidth={strokeWidth}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinecap="butt" // or "round" if preferred for segment ends
/>
);
})}
{/* Center Text */}
<SvgText
x={center}
y={center - (centerValueStyle?.fontSize || styles.defaultCenterTotalLabel.fontSize || 16) / 2} // Adjust y based on font size
fill={ centerValueStyle?.color || styles.defaultCenterValue.color}
fontSize={centerValueStyle?.fontSize || styles.defaultCenterValue.fontSize}
fontWeight={centerValueStyle?.fontWeight || styles.defaultCenterValue.fontWeight}
textAnchor="middle"
>
{formatValue(totalValue)}
</SvgText>
<SvgText
x={center}
y={center + ( centerTotalLabelStyle?.fontSize || styles.defaultCenterValue.fontSize) / 2} // Adjust y based on font size
fill={ centerTotalLabelStyle?.color || styles.defaultCenterTotalLabel.color}
fontSize={ centerTotalLabelStyle?.fontSize || styles.defaultCenterTotalLabel.fontSize}
fontWeight={ centerTotalLabelStyle?.fontWeight || styles.defaultCenterTotalLabel.fontWeight}
textAnchor="middle"
>
{totalLabel}
</SvgText>
</Svg>
</View>
{data.length > 0 && (
<View style={[styles.defaultLegendContainer, legendContainerStyle]}>
{[legendColumn1, legendColumn2].map((column, colIndex) =>
column.length > 0 ? ( // Render column only if it has items
<View key={`legend-col-${colIndex}`} style={[styles.defaultLegendColumn, legendColumnStyle]}>
{column.map((item) => {
const percentage = allValuesZero ? 0 : (item.value / totalValue) * 100;
return (
<View
key={`legend-${item.id}`}
style={[styles.defaultLegendItemContainer, legendItemContainerStyle]}
>
<View style={[styles.defaultLegendItemDetails, legendItemDetailsStyle]}>
<Text style={[styles.defaultLegendItemLabel, legendItemLabelStyle, { color: item.color }]}>
{item.label}
</Text>
<Text style={[styles.defaultLegendItemValue, legendItemValueStyle, { color: item.color }]}>
{`${formatValue(item.value)}${valueSuffix}`}
</Text>
<Text style={[styles.defaultLegendItemPercentage, legendItemPercentageStyle, { color: item.color }]}>
{`${percentage.toFixed(2)}%`}
</Text>
</View>
</View>
);
})}
</View>
) : null
)}
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
defaultContainer: {
padding: 16,
borderRadius: 10,
alignItems: 'center',
},
defaultTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 16,
color: '#FFFFFF',
},
defaultCenterSection: {
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
defaultCenterValue: {
fontSize: 32,
fontWeight: '800',
color: '#FFFFFF',
},
defaultCenterTotalLabel: {
fontSize: 16,
fontWeight: '400',
color: '#FFFFFF',
},
defaultLegendContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
},
defaultLegendColumn: {
flex: 1,
},
defaultLegendItemContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 12,
},
defaultLegendItemDetails: {
flex: 1,
},
defaultLegendItemLabel: {
fontSize: 12,
fontWeight: '400',
// color applied dynamically
},
defaultLegendItemValue: {
fontSize: 14,
fontWeight: 'bold',
},
defaultLegendItemPercentage: {
fontSize: 12,
fontWeight: '400',
},
});
export default PieChartInsights;
Usage
Provide an array of PieChartDataItem objects to the data prop, and optionally customize its appearance and labels using other props like <PieChartInsights data={chartData} title="Activity" valueSuffix=" Points" />
.
MyInsightsPage.tsx
import React from "react";
import { SafeAreaView, ScrollView, StyleSheet, View, Text } from "react-native";
import PieChartInsights, {
PieChartDataItem,
} from "@/components/ui/PieChartInsights";
import { useAppColors } from "@/hooks/useAppColors";
// Example utility if you still have it and want to use it
const formatCompactNumberCustom = (value: number): string => {
if (value > 9999) return `${(value / 1000).toFixed(1)}k`;
return value.toString();
};
const MyInsightsPage = () => {
const appColors = useAppColors();
// Sample data for the pie chart
// In a real app, this would come from your state or API transformation logic
const chartData: PieChartDataItem[] = [
{ id: 'interacting', label: 'Interacting with Content', value: 15000, color: '#4A90E2' }, // Bright Blue
{ id: 'creating', label: 'Creating Content', value: 2500, color: '#50E3C2' }, // Teal
{ id: 'tasks', label: 'Tasks Completed', value: 800, color: '#F5A623' }, // Orange
{ id: 'rewards', label: 'Referral Rewards', value: 1200, color: '#BD10E0' }, // Purple
{ id: 'share', label: 'Sharing Activity', value: 1800, color: '#7ED321' }, // Lime Green
{ id: 'referral', label: 'New User Referrals', value: 2200, color: '#D0021B' },
// Add more items if needed
];
const chartDataEmpty: PieChartDataItem[] = [
{ id: "interacting", label: "Interacting", value: 0, color: "#4A90E2" },
{ id: "creating", label: "Creating", value: 0, color: "#50E3C2" },
];
return (
<SafeAreaView style={{ flex: 1 }}>
<ScrollView contentContainerStyle={screenStyles.container}>
<Text style={[screenStyles.header, { color: appColors.Neutral900 }]}>
My Activity Insights
</Text>
{/* here you could add like a skeleton state I can but I want to keep the component standlone */}
<PieChartInsights
title="Yesterday's Earned Points"
data={chartData}
valueSuffix=" Points"
formatValue={formatCompactNumberCustom}
containerStyle={{
backgroundColor: appColors.Neutral50,
marginVertical: 20,
width: "90%",
}}
titleStyle={{ color: appColors.Neutral900, fontSize: 20 }}
centerValueStyle={{
color: appColors.Neutral900,
fontSize: 30,
fontWeight: "600",
}}
centerTotalLabelStyle={{
color: appColors.Neutral500,
fontSize: 12,
fontWeight: "400",
}}
pieBaseColor={appColors.Neutral90}
emptyPieColor={appColors.Neutral100}
legendItemLabelStyle={{ fontSize: 13 }}
legendItemValueStyle={{ fontWeight: "600" }}
/>
<Text
style={[
screenStyles.header,
{ color: appColors.Neutral900, marginTop: 20 },
]}
>
Empty Chart Example
</Text>
<PieChartInsights
title="No Activity Yet"
data={chartDataEmpty}
valueSuffix=" Points"
containerStyle={{
backgroundColor: appColors.Neutral50,
marginVertical: 20,
width: "90%",
}}
titleStyle={{ color: appColors.Neutral900 }}
/>
<Text
style={[
screenStyles.header,
{ color: appColors.Neutral900, marginTop: 20 },
]}
>
Chart with fewer items
</Text>
<PieChartInsights
title="Simple Breakdown"
data={chartData.slice(0, 2)} // Only first two items
valueSuffix=" USD"
containerStyle={{
backgroundColor: appColors.Neutral50,
marginVertical: 20,
width: "90%",
}}
titleStyle={{ color: appColors.Neutral900 }}
/>
</ScrollView>
</SafeAreaView>
);
};
const screenStyles = StyleSheet.create({
container: {
padding: 16,
alignItems: "center",
},
header: {
fontSize: 22,
fontWeight: "bold",
marginBottom: 10,
},
skeleton: {
height: 400,
width: "90%",
borderRadius: 4,
marginBottom: 8,
},
});
export default MyInsightsPage;
Props
Prop | Type | Default | Required | Description |
---|---|---|---|---|
data | PieChartDataItem[] | Yes | Array of data objects, each with id , label , value , and color . | |
title | string | No | Optional title displayed above the chart. | |
size | number | 240 | No | Diameter of the SVG canvas for the pie chart. |
strokeWidth | number | 30 | No | Thickness of the pie chart's arc segments. |
totalLabel | string | 'Total' | No | Label displayed below the total value in the chart's center. |
valueSuffix | string | '' | No | Suffix appended to numerical values in the legend and center (e.g., " Points"). |
formatValue | (value: number) => string | DefaultFormatValue | No | Custom function to format numerical values for display. |
containerStyle | StyleProp<ViewStyle> | No | Styles for the main component container. | |
titleStyle | StyleProp<TextStyle> | No | Styles for the chart title text. | |
centerSectionStyle | StyleProp<ViewStyle> | No | Styles for the View wrapping the SVG pie and center text. | |
centerValueStyle | svgTextStyle | No | Styles (fontSize , color , fontWeight ) for the total value text in the center. | |
centerTotalLabelStyle | svgTextStyle | No | Styles (fontSize , color , fontWeight ) for the total label text in the center. | |
pieBaseColor | string | '#F0F0F0' | No | Background color of the full circle track behind active segments. |
emptyPieColor | string | '#E0E0E0' | No | Color of the pie chart when data is empty or all values are zero. |
legendContainerStyle | StyleProp<ViewStyle> | No | Styles for the container holding all legend items. | |
legendColumnStyle | StyleProp<ViewStyle> | No | Styles applied to each of the two legend columns. | |
legendItemContainerStyle | StyleProp<ViewStyle> | No | Styles for the container of an individual legend item (label, value, percentage). | |
legendItemDetailsStyle | StyleProp<ViewStyle> | No | Styles for the View wrapping the text parts of a single legend item. | |
legendItemLabelStyle | StyleProp<TextStyle> | No | Styles for the label text of a legend item. | |
legendItemValueStyle | StyleProp<TextStyle> | No | Styles for the formatted value text of a legend item. | |
legendItemPercentageStyle | StyleProp<TextStyle> | No | Styles for the percentage text of a legend item. |
Note: svgTextStyle
is an object: { fontSize: number; color: string; fontWeight: string; }