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

PropTypeDefaultRequiredDescription
dataPieChartDataItem[]YesArray of data objects, each with id, label, value, and color.
titlestringNoOptional title displayed above the chart.
sizenumber240NoDiameter of the SVG canvas for the pie chart.
strokeWidthnumber30NoThickness of the pie chart's arc segments.
totalLabelstring'Total'NoLabel displayed below the total value in the chart's center.
valueSuffixstring''NoSuffix appended to numerical values in the legend and center (e.g., " Points").
formatValue(value: number) => stringDefaultFormatValueNoCustom function to format numerical values for display.
containerStyleStyleProp<ViewStyle>NoStyles for the main component container.
titleStyleStyleProp<TextStyle>NoStyles for the chart title text.
centerSectionStyleStyleProp<ViewStyle>NoStyles for the View wrapping the SVG pie and center text.
centerValueStylesvgTextStyleNoStyles (fontSize, color, fontWeight) for the total value text in the center.
centerTotalLabelStylesvgTextStyleNoStyles (fontSize, color, fontWeight) for the total label text in the center.
pieBaseColorstring'#F0F0F0'NoBackground color of the full circle track behind active segments.
emptyPieColorstring'#E0E0E0'NoColor of the pie chart when data is empty or all values are zero.
legendContainerStyleStyleProp<ViewStyle>NoStyles for the container holding all legend items.
legendColumnStyleStyleProp<ViewStyle>NoStyles applied to each of the two legend columns.
legendItemContainerStyleStyleProp<ViewStyle>NoStyles for the container of an individual legend item (label, value, percentage).
legendItemDetailsStyleStyleProp<ViewStyle>NoStyles for the View wrapping the text parts of a single legend item.
legendItemLabelStyleStyleProp<TextStyle>NoStyles for the label text of a legend item.
legendItemValueStyleStyleProp<TextStyle>NoStyles for the formatted value text of a legend item.
legendItemPercentageStyleStyleProp<TextStyle>NoStyles for the percentage text of a legend item.

Note: svgTextStyle is an object: { fontSize: number; color: string; fontWeight: string; }