{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "donut-chart",
  "type": "registry:item",
  "title": "Donut Chart",
  "description": "A ring of rounded slices that sweeps in; tap a slice to bring it forward and read its share in the middle.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@shopify/react-native-skia",
    "react-native-gesture-handler",
    "react-native-reanimated",
    "react-native-worklets"
  ],
  "registryDependencies": [
    "https://kinetik-ui.dev/r/kinetik-tokens.json",
    "https://kinetik-ui.dev/r/kinetik-motion.json",
    "https://kinetik-ui.dev/r/kinetik-haptics.json"
  ],
  "files": [
    {
      "path": "src/components/kinetik/donut-chart.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/donut-chart.tsx",
      "content": "import { Canvas, Path, Skia, type SkPath } from '@shopify/react-native-skia';\nimport { useEffect, useMemo, useState } from 'react';\nimport { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n  Easing,\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withSpring,\n  withTiming,\n  type SharedValue,\n} from 'react-native-reanimated';\nimport { scheduleOnRN } from 'react-native-worklets';\n\nimport { useHaptic } from '../../lib/kinetik/hooks/use-haptic';\nimport { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';\nimport { spring, timing } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type DonutSlice = { label: string; value: number; color?: string };\n\nexport type DonutChartProps = {\n  data: readonly DonutSlice[];\n  /** Diameter of the ring. Defaults to 200. */\n  size?: number;\n  /** Ring thickness. Defaults to 18. */\n  thickness?: number;\n  /** Gap between slices, in degrees. Defaults to 6. */\n  gap?: number;\n  /** Shown in the middle under the total. Defaults to 'Total'. */\n  centerLabel?: string;\n  formatValue?: (value: number) => string;\n  /** Called with the selected slice, or null when the selection clears. */\n  onSelect?: (index: number | null) => void;\n  /** A list of slices under the ring. Defaults to true. */\n  legend?: boolean;\n  /** A tick when a slice is selected. Defaults to true. */\n  haptics?: boolean;\n  /** A summary of what the chart shows, e.g. \"Spending by category\". */\n  accessibilityLabel: string;\n  style?: StyleProp<ViewStyle>;\n};\n\n/** Extra width a selected slice grows by. */\nconst LIFT = 7;\n\n/**\n * A ring of rounded slices that sweeps in around the clock. Tap a slice, or\n * its row in the legend, to bring it forward: it thickens, the others step\n * back, and the centre shows its share.\n */\nexport function DonutChart({\n  data,\n  size = 200,\n  thickness = 18,\n  gap = 6,\n  centerLabel = 'Total',\n  formatValue = (v) => String(v),\n  onSelect,\n  legend = true,\n  haptics = true,\n  accessibilityLabel,\n  style,\n}: DonutChartProps) {\n  const { colors, font, space, radius } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const [selected, setSelected] = useState<number | null>(null);\n\n  const total = data.reduce((s, d) => s + Math.max(0, d.value), 0);\n  const ringR = size / 2 - thickness / 2 - LIFT / 2 - 1;\n  // A monochrome ramp by default: the strongest ink, stepping back.\n  const ramp = [1, 0.62, 0.38, 0.22, 0.12];\n  const tint = (d: DonutSlice, i: number) =>\n    d.color ?? withAlpha(colors.ember, ramp[i % ramp.length]!);\n\n  // Keyed on the values, not the array: a new array with the same slices must not sweep again.\n  const signature = data.map((d) => `${d.label}:${d.value}`).join('|');\n  const slices = useMemo(() => {\n    // Round caps reach past the arc's ends; take them out of the sweep so the gaps stay even.\n    const cap = (thickness / 2 / ringR) * (180 / Math.PI);\n    const shares = data.map((d) => (total > 0 ? Math.max(0, d.value) / total : 0));\n    return shares.map((share, i) => {\n      const from = shares.slice(0, i).reduce((sum, s) => sum + s, 0);\n      const at = from + share;\n      const sweep = Math.max(0.5, share * 360 - gap - cap * 2);\n      const path = Skia.PathBuilder.Make()\n        .addArc(\n          { x: size / 2 - ringR, y: size / 2 - ringR, width: ringR * 2, height: ringR * 2 },\n          -90 + from * 360 + gap / 2 + cap,\n          sweep,\n        )\n        .detach();\n      return { path, from, to: at, share };\n    });\n    // `signature` stands in for `data`.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [signature, total, size, ringR, thickness, gap]);\n\n  const progress = useSharedValue(reduced ? 1 : 0);\n  useEffect(() => {\n    if (reduced) {\n      progress.set(1);\n      return;\n    }\n    progress.set(0);\n    progress.set(withTiming(1, { duration: 1100, easing: Easing.bezier(0.2, 0, 0, 1) }));\n  }, [slices, reduced, progress]);\n\n  const choose = (i: number | null) => {\n    const next = i === selected ? null : i;\n    setSelected(next);\n    if (next !== null) haptic('tick');\n    onSelect?.(next);\n  };\n\n  // Tap the ring to pick the slice under the finger; tap the middle to clear.\n  const tap = Gesture.Tap()\n    .withTestId('donut-chart')\n    .onEnd((e, ok) => {\n      if (!ok) return;\n      const dx = e.x - size / 2;\n      const dy = e.y - size / 2;\n      const dist = Math.sqrt(dx * dx + dy * dy);\n      if (dist < ringR - thickness) {\n        scheduleOnRN(choose, null);\n        return;\n      }\n      if (dist > ringR + thickness) return;\n      const turn = (((Math.atan2(dy, dx) * 180) / Math.PI + 90 + 360) % 360) / 360;\n      const i = slices.findIndex((s) => turn >= s.from && turn < s.to);\n      if (i >= 0) scheduleOnRN(choose, i);\n    });\n\n  const focus = selected === null ? null : data[selected];\n  const centerValue = focus ? formatValue(focus.value) : formatValue(total);\n  const centerCaption = focus\n    ? `${focus.label} · ${Math.round((Math.max(0, focus.value) / (total || 1)) * 100)}%`\n    : centerLabel;\n\n  return (\n    <View style={[{ gap: space[5] }, style]}>\n      <GestureDetector gesture={tap}>\n        <View\n          collapsable={false}\n          accessible\n          accessibilityRole=\"image\"\n          accessibilityLabel={`${accessibilityLabel}. ${data\n            .map((d) => `${d.label} ${formatValue(d.value)}`)\n            .join(', ')}`}\n          style={[styles.ring, { width: size, height: size }]}\n        >\n          <View pointerEvents=\"none\" style={StyleSheet.absoluteFill}>\n            <Canvas style={StyleSheet.absoluteFill}>\n              {slices.map((s, i) => (\n                <Slice\n                  key={i}\n                  path={s.path}\n                  from={s.from}\n                  to={s.to}\n                  color={tint(data[i]!, i)}\n                  thickness={thickness}\n                  progress={progress}\n                  state={selected === null ? 'rest' : selected === i ? 'focus' : 'back'}\n                  reduced={reduced}\n                />\n              ))}\n            </Canvas>\n          </View>\n          <View pointerEvents=\"none\" style={styles.center}>\n            <Center\n              value={centerValue}\n              caption={centerCaption}\n              big={Math.round(size * 0.15)}\n              reduced={reduced}\n            />\n          </View>\n        </View>\n      </GestureDetector>\n\n      {legend ? (\n        <View style={{ gap: 2 }}>\n          {data.map((d, i) => {\n            const on = selected === i;\n            return (\n              <Pressable\n                key={`${d.label}-${i}`}\n                accessibilityRole=\"button\"\n                accessibilityLabel={`${d.label}, ${formatValue(d.value)}`}\n                accessibilityState={{ selected: on }}\n                onPress={() => choose(i)}\n                style={[\n                  styles.row,\n                  {\n                    borderRadius: radius.md,\n                    backgroundColor: on ? colors.surfaceRaised : 'transparent',\n                    paddingHorizontal: space[3],\n                    opacity: selected === null || on ? 1 : 0.5,\n                  },\n                ]}\n              >\n                <View style={[styles.dot, { backgroundColor: tint(d, i) }]} />\n                <Text style={{ flex: 1, color: colors.text, fontSize: font.size.sm }}>\n                  {d.label}\n                </Text>\n                <Text\n                  style={{\n                    color: colors.textSubtle,\n                    fontSize: font.size.sm,\n                    fontVariant: ['tabular-nums'],\n                    marginRight: space[3],\n                  }}\n                >\n                  {Math.round((Math.max(0, d.value) / (total || 1)) * 100)}%\n                </Text>\n                <Text\n                  style={{\n                    color: colors.text,\n                    fontSize: font.size.sm,\n                    fontWeight: font.weight.semibold,\n                    fontVariant: ['tabular-nums'],\n                  }}\n                >\n                  {formatValue(d.value)}\n                </Text>\n              </Pressable>\n            );\n          })}\n        </View>\n      ) : null}\n    </View>\n  );\n}\n\nfunction Slice({\n  path,\n  from,\n  to,\n  color,\n  thickness,\n  progress,\n  state,\n  reduced,\n}: {\n  path: SkPath;\n  from: number;\n  to: number;\n  color: string;\n  thickness: number;\n  progress: SharedValue<number>;\n  state: 'rest' | 'focus' | 'back';\n  reduced: boolean;\n}) {\n  const width = useSharedValue(thickness);\n  const fade = useSharedValue(1);\n  useEffect(() => {\n    width.set(\n      withSpring(state === 'focus' ? thickness + LIFT : thickness, spring('bouncy', reduced)),\n    );\n    fade.set(withTiming(state === 'back' ? 0.3 : 1, timing('base', reduced)));\n  }, [state, thickness, reduced, width, fade]);\n\n  // Each slice draws during its own share of the sweep.\n  const end = useDerivedValue(() =>\n    Math.max(0, Math.min(1, (progress.get() - from) / Math.max(0.0001, to - from))),\n  );\n\n  return (\n    <Path\n      path={path}\n      style=\"stroke\"\n      strokeWidth={width}\n      strokeCap=\"round\"\n      color={color}\n      opacity={fade}\n      end={end}\n    />\n  );\n}\n\n/** The middle: the figure and its caption, rising in whenever they change. */\nfunction Center({\n  value,\n  caption,\n  big,\n  reduced,\n}: {\n  value: string;\n  caption: string;\n  big: number;\n  reduced: boolean;\n}) {\n  const { colors, font } = useKinetikTheme();\n  const t = useSharedValue(1);\n  useEffect(() => {\n    t.set(0);\n    t.set(withTiming(1, timing('base', reduced)));\n  }, [value, caption, reduced, t]);\n  const style = useAnimatedStyle(() => ({\n    opacity: t.get(),\n    transform: [{ translateY: (1 - t.get()) * 6 }],\n  }));\n  return (\n    <Animated.View style={[styles.centerInner, style]}>\n      <Text\n        style={{\n          color: colors.text,\n          fontSize: big,\n          fontWeight: font.weight.bold,\n          letterSpacing: -0.8,\n          fontVariant: ['tabular-nums'],\n        }}\n      >\n        {value}\n      </Text>\n      <Text\n        numberOfLines={1}\n        style={{ color: colors.textMuted, fontSize: font.size.xs, marginTop: 2 }}\n      >\n        {caption}\n      </Text>\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  ring: { alignSelf: 'center' },\n  center: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },\n  centerInner: { alignItems: 'center', maxWidth: '62%' },\n  row: { flexDirection: 'row', alignItems: 'center', gap: 10, height: minTouchTarget },\n  dot: { width: 10, height: 10, borderRadius: 5 },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "data",
    "web": "live",
    "perf": {
      "budget": "Slice paths are built once per data change; the sweep and the selection are shared values read by one Skia canvas.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 21,
          "p90Ms": 23,
          "baselineMs": 20,
          "jankyPercent": 2.69,
          "atRest": "idle",
          "memoryMb": 153
        }
      ]
    },
    "a11y": {
      "reducedMotion": "The ring appears complete and selection changes without the bounce.",
      "screenReader": "The ring reads as an image with every slice and value; legend rows are buttons with a selected state.",
      "touchTarget": "Legend rows are 44 pt tall and full width; the ring itself is also tappable."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { DonutChart } from '@/components/kinetik/donut-chart';\n\n<DonutChart\n  accessibilityLabel=\"Spending by category\"\n  data={[\n    { label: 'Rent', value: 1240 },\n    { label: 'Groceries', value: 420 },\n    { label: 'Transport', value: 160 },\n  ]}\n  formatValue={(v) => `$${v}`}\n/>;\n```\n\nSlices default to a monochrome ramp of the theme's strongest ink; give a slice a `color` to pick your own. The legend under the ring lists every slice with its share and value.\n\n## When to use\n\n- Parts of a whole, with a few slices: budgets, storage, time spent.\n- Summaries where the total matters as much as the split.\n\n## When not to use\n\n- More than about six slices; small ones become hard to see and to tap.\n- Comparing values that do not add up to a whole. Use Bar Chart.\n\n## Notes\n\n- Tap a slice or its legend row to select it; tap it again, or the middle of the ring, to clear.\n- Gaps stay even because the rounded caps are taken out of each slice's sweep.\n- The ring sweeps in whenever the data changes.\n"
  },
  "docs": "Prefer `npx kinetik-ui add donut-chart`: it installs dependencies with expo install, so versions match your Expo SDK."
}
