Skip to content

Multiple pie chart animations will drop frames #632

Description

@musicdav

Question

I don't think my use case should result in this level of animation dropouts, making the animation barely renderable.

WhatsApp.Video.2025-10-02.at.13.11.02.mp4

Background Info/Attempts

This is my code:

import { Badge } from '@/components/ui/badge';
import { Text } from '@/components/ui/text';
import { getUsageColor, monitorCardStyles } from '~/components/styles/MonitorCardStyles';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useTranslation } from '@/hooks/useTranslation';
import { useMonitoringStatus } from '@/lib/store';
import { ThreeXUIConfig } from '@/lib/types';
import { ArrowDown, ArrowUp } from 'lucide-react-native';
import React from 'react';
import { View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Pie, PolarChart } from 'victory-native';
import { SkeletonCard } from '~/components/SkeletonCard';

interface MonitorCardProps {
  config: ThreeXUIConfig;
  onPress?: (config: ThreeXUIConfig) => void;
  onLongPress?: (config: ThreeXUIConfig) => void;
}

interface AnimatedPieChartProps {
  data: Array<{ label: string; value: number; color: string }>;
  size: number;
  innerRadius?: number | string;
}

function AnimatedPieSlice({ slice }: { slice: any }) {
  return (
    <Pie.Slice
      animate={{
      type:'timing',
      duration:300
    }}
    />
  );
}

function AnimatedPieChart({ data, size, innerRadius = "85%" }: AnimatedPieChartProps) {
  return (
    <View style={{ width: size, height: size+5 }}>
      <PolarChart
        data={data}
        labelKey="label"
        valueKey="value"
        colorKey="color"
      >
        <Pie.Chart innerRadius={innerRadius}>
          {({ slice }) => <AnimatedPieSlice slice={slice} />}
        </Pie.Chart>
      </PolarChart>
    </View>
  );
}

function SimpleThreeXUIMonitorCard({ config, onPress, onLongPress }: MonitorCardProps) {
  const { t } = useTranslation();
  const backgroundColor = useThemeColor({}, 'background');
  const borderColor = useThemeColor({}, 'border');
  const textColor = useThemeColor({}, 'text');

  const status = useMonitoringStatus(config.id);

  const tapGesture = Gesture.Tap().runOnJS(true)
    .onStart(() => {
      if (onPress) {
        onPress(config);
      }
    });

  const longPressGesture = Gesture.LongPress().runOnJS(true)
    .onStart(() => {
      if (onLongPress) {
        onLongPress(config);
      }
    });

  const combinedGesture = Gesture.Exclusive(longPressGesture, tapGesture);

  if (!status) {
    return (
      <GestureDetector gesture={combinedGesture}>
        <SkeletonCard />
      </GestureDetector>
    );
  }

  const formatUptime = (seconds: number): string => {
    const days = Math.floor(seconds / 86400);
    const hours = Math.floor((seconds % 86400) / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);

    if (days > 0) {
      return `${days}d ${hours}h`;
    } else if (hours > 0) {
      return `${hours}h ${minutes}m`;
    } else {
      return `${minutes}m`;
    }
  };

  const formatBytes = (bytes: number): string => {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
  };

  const formatSpeed = (bytesPerSecond: number): string => {
    return formatBytes(bytesPerSecond) + '/s';
  };

  const safeNumber = (value: any): number => {
    if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
      return value;
    }
    return 0;
  };

  return (
    <GestureDetector gesture={combinedGesture}>
      <View style={[monitorCardStyles.card, { backgroundColor, borderColor }]}>
        <View style={monitorCardStyles.header}>
          <View style={monitorCardStyles.titleContainer}>
            <View style={monitorCardStyles.titleRow}>
              <Text style={[monitorCardStyles.title, { color: textColor }]}>{config.name}</Text>
              <Badge variant="secondary" style={monitorCardStyles.ipBadge}>
                <Text style={monitorCardStyles.ipBadgeText}>3X-UI</Text>
              </Badge>
            </View>
          </View>
          <Badge
            variant={status.isOnline ? "default" : "destructive"}
            style={monitorCardStyles.statusBadge}
          >
            <Text style={monitorCardStyles.badgeText}>
              {status.isOnline
                ? status.serverStatus ? formatUptime(status.serverStatus.uptime) : 'Online'
                : 'Offline'
              }
            </Text>
          </Badge>
        </View>
            <View style={monitorCardStyles.chartsContainer}>
            <View style={monitorCardStyles.chartItem}>
              <View style={monitorCardStyles.pieContainer}>
                <AnimatedPieChart
                  data={status.serverStatus ? [
                    {
                      label: 'Used',
                      value: Math.max(safeNumber(status.serverStatus.cpu), 0.1),
                      color: getUsageColor(safeNumber(status.serverStatus.cpu))
                    },
                    {
                      label: 'Free',
                      value: 100 - Math.max(safeNumber(status.serverStatus.cpu), 0.1),
                      color: '#e5e7eb'
                    }
                  ] : [
                    {
                      label: 'No Data',
                      value: 1,
                      color: '#f3f4f6'
                    },{
                      label: 'No Data',
                      value: 100,
                      color: '#f3f4f6'
                    }
                  ]}
                  size={90}
                />
                <View style={monitorCardStyles.chartInnerContent}>
                  <Text style={[monitorCardStyles.chartInnerLabel, { color: textColor }]}>CPU</Text>
                  <Text style={[monitorCardStyles.percentageText, { color: textColor }]}>
                    {status.serverStatus ? `${safeNumber(status.serverStatus.cpu).toFixed(1)}%` : 'No data'}
                  </Text>
                </View>
              </View>
            </View>

            <View style={monitorCardStyles.chartItem}>
              <View style={monitorCardStyles.pieContainer}>
                <AnimatedPieChart
                  data={status.serverStatus ? (() => {
                    const memCurrent = safeNumber(status.serverStatus.mem.current);
                    const memTotal = safeNumber(status.serverStatus.mem.total);
                    const memPercentage = memTotal > 0 ? (memCurrent / memTotal) * 100 : 0;
                    return [
                      {
                        label: 'Used',
                        value: Math.max(memPercentage, 0.1),
                        color: getUsageColor(memPercentage)
                      },
                      {
                        label: 'Free',
                        value: 100 - Math.max(memPercentage, 0.1),
                        color: '#e5e7eb'
                      }
                    ];
                  })() : [
                    {
                      label: 'No Data',
                      value: 1,
                      color: '#f3f4f6'
                    },{
                      label: 'No Data',
                      value: 100,
                      color: '#f3f4f6'
                    }
                  ]}
                  size={90}
                />
                <View style={monitorCardStyles.chartInnerContent}>
                  <Text style={[monitorCardStyles.chartInnerLabel, { color: textColor }]}>Memory</Text>
                  <Text style={[monitorCardStyles.percentageText, { color: textColor }]}>
                    {status.serverStatus ? (() => {
                      const memCurrent = safeNumber(status.serverStatus.mem.current);
                      const memTotal = safeNumber(status.serverStatus.mem.total);
                      const memPercentage = memTotal > 0 ? (memCurrent / memTotal) * 100 : 0;
                      return `${memPercentage.toFixed(1)}%`;
                    })() : 'No data'}
                  </Text>
                </View>
              </View>
            </View>

            <View style={monitorCardStyles.chartItem}>
              <View style={monitorCardStyles.pieContainer}>
                <AnimatedPieChart
                  data={status.serverStatus ? (() => {
                    const diskCurrent = safeNumber(status.serverStatus.disk.current);
                    const diskTotal = safeNumber(status.serverStatus.disk.total);
                    const diskPercentage = diskTotal > 0 ? (diskCurrent / diskTotal) * 100 : 0;
                    return [
                      {
                        label: 'Used',
                        value: Math.max(diskPercentage, 0.1),
                        color: getUsageColor(diskPercentage)
                      },
                      {
                        label: 'Free',
                        value: 100 - Math.max(diskPercentage, 0.1),
                        color: '#e5e7eb'
                      }
                    ];
                  })() : [
                    {
                      label: 'No Data',
                      value: 1,
                      color: '#f3f4f6'
                    },{
                      label: 'No Data',
                      value: 100,
                      color: '#f3f4f6'
                    }
                  ]}
                  size={90}
                />
                <View style={monitorCardStyles.chartInnerContent}>
                  <Text style={[monitorCardStyles.chartInnerLabel, { color: textColor }]}>Disk</Text>
                  <Text style={[monitorCardStyles.percentageText, { color: textColor }]}>
                    {status.serverStatus ? (() => {
                      const diskCurrent = safeNumber(status.serverStatus.disk.current);
                      const diskTotal = safeNumber(status.serverStatus.disk.total);
                      const diskPercentage = diskTotal > 0 ? (diskCurrent / diskTotal) * 100 : 0;
                      return `${diskPercentage.toFixed(1)}%`;
                    })() : 'No data'}
                  </Text>
                </View>
              </View>
            </View>
          </View>
          <View style={monitorCardStyles.networkStats}>
            {status.serverStatus ? (
              <View style={monitorCardStyles.networkTrafficRow}>
                <View style={monitorCardStyles.networkTrafficItem}>
                  <ArrowUp strokeWidth={3} size={14} color={textColor} style={monitorCardStyles.networkIcon} />
                  <Text style={[monitorCardStyles.networkTrafficText, { color: textColor }]}>
                    {formatSpeed(safeNumber(status.serverStatus.netIO.up))}    {formatBytes(safeNumber(status.serverStatus.netTraffic.sent))}
                  </Text>
                </View>
                <View style={monitorCardStyles.networkTrafficItem}>
                  <ArrowDown strokeWidth={3} size={14} color={textColor} style={monitorCardStyles.networkIcon} />
                  <Text style={[monitorCardStyles.networkTrafficText, { color: textColor }]}>
                    {formatSpeed(safeNumber(status.serverStatus.netIO.down))}    {formatBytes(safeNumber(status.serverStatus.netTraffic.recv))}
                  </Text>
                </View>
              </View>
            ) : (
              <View style={monitorCardStyles.networkTrafficRow}>
                <View style={monitorCardStyles.networkTrafficItem}>
                  <ArrowUp strokeWidth={3} size={14} color={textColor} style={monitorCardStyles.networkIcon} />
                  <Text style={[monitorCardStyles.networkTrafficText, { color: textColor }]}>No data</Text>
                </View>
                <View style={monitorCardStyles.networkTrafficItem}>
                  <ArrowDown strokeWidth={3} size={14} color={textColor} style={monitorCardStyles.networkIcon} />
                  <Text style={[monitorCardStyles.networkTrafficText, { color: textColor }]}>No data</Text>
                </View>
              </View>
            )}
          </View>
      </View>
    </GestureDetector>
  );
}

export default React.memo(SimpleThreeXUIMonitorCard, (prev, next) => {
  return (
    prev.config.id === next.config.id &&
    prev.config.name === next.config.name &&
    prev.onPress === next.onPress &&
    prev.onLongPress === next.onLongPress
  );
});

This is the dependency version:

"react-native": "0.79.5",
"@shopify/react-native-skia": "^2.2.3",
"react-native-reanimated": "~3.17.4",
"victory-native": "^41.19.2"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions