{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radar-chart",
  "type": "registry:item",
  "title": "Radar Chart",
  "description": "A radar chart whose shapes grow from the centre and morph to new values; tap the legend to compare.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@shopify/react-native-skia",
    "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/radar-chart.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/radar-chart.tsx",
      "content": "import { Canvas, Circle, Group, 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 {\n  Easing,\n  useDerivedValue,\n  useSharedValue,\n  withDelay,\n  withTiming,\n  type SharedValue,\n} from 'react-native-reanimated';\n\nimport { useHaptic } from '../../lib/kinetik/hooks/use-haptic';\nimport { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';\nimport { timing } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type RadarSeries = {\n  key: string;\n  label: string;\n  /** One value per axis, in the same order. */\n  values: readonly number[];\n  /** Defaults to the theme's azure, sky and success in turn. */\n  color?: string;\n};\n\nexport type RadarChartProps = {\n  /** Three to eight axis names, clockwise from the top. */\n  axes: readonly string[];\n  /** One to three series. */\n  series: readonly RadarSeries[];\n  /** The value at the outer ring. Defaults to the largest value. */\n  max?: number;\n  /** Diameter of the web, without labels. Defaults to 220. */\n  size?: number;\n  /** Rings in the web. Defaults to 4. */\n  rings?: number;\n  formatValue?: (value: number) => string;\n  /** Rows under the chart; tap one to show or hide its series. Defaults to true. */\n  legend?: boolean;\n  /** A tick when a series is shown or hidden. Defaults to true. */\n  haptics?: boolean;\n  /** What the chart compares, e.g. \"Two trails compared\". */\n  accessibilityLabel: string;\n  style?: StyleProp<ViewStyle>;\n};\n\n/** Room around the web for the axis names. */\nconst LABEL_ROOM = 44;\nconst LABEL_W = 84;\n\n/**\n * A radar chart whose shapes grow out from the centre, and morph from one\n * set of values to the next when the data changes. Tap a legend row to show\n * or hide a series.\n */\nexport function RadarChart({\n  axes,\n  series,\n  max,\n  size = 220,\n  rings = 4,\n  formatValue = (v) => String(Math.round(v)),\n  legend = true,\n  haptics = true,\n  accessibilityLabel,\n  style,\n}: RadarChartProps) {\n  const { colors, font, radius, space } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());\n\n  const n = axes.length;\n  const box = size + LABEL_ROOM * 2;\n  const c = box / 2;\n  const R = size / 2;\n  const top = max ?? Math.max(1, ...series.flatMap((s) => s.values));\n  const palette = [colors.azure, colors.sky, colors.success];\n  const tint = (s: RadarSeries, i: number) => s.color ?? palette[i % palette.length]!;\n\n  // Unit vectors for each axis, clockwise from 12 o'clock.\n  const spokes = useMemo(\n    () =>\n      Array.from({ length: n }, (_, i) => {\n        const a = -Math.PI / 2 + (i / n) * Math.PI * 2;\n        return { x: Math.cos(a), y: Math.sin(a) };\n      }),\n    [n],\n  );\n\n  const web = useMemo(() => {\n    const b = Skia.PathBuilder.Make();\n    for (let r = 1; r <= rings; r++) {\n      const k = (R * r) / rings;\n      spokes.forEach((s, i) =>\n        i === 0 ? b.moveTo(c + s.x * k, c + s.y * k) : b.lineTo(c + s.x * k, c + s.y * k),\n      );\n      b.close();\n    }\n    for (const s of spokes) {\n      b.moveTo(c, c);\n      b.lineTo(c + s.x * R, c + s.y * R);\n    }\n    return b.detach();\n  }, [spokes, rings, R, c]);\n\n  const toggle = (key: string) => {\n    haptic('tick');\n    setHidden((h) => {\n      const next = new Set(h);\n      if (next.has(key)) next.delete(key);\n      else next.add(key);\n      return next;\n    });\n  };\n\n  return (\n    <View style={[{ gap: space[3], alignItems: 'center' }, style]}>\n      <View\n        accessible\n        accessibilityRole=\"image\"\n        accessibilityLabel={`${accessibilityLabel}. ${series\n          .map(\n            (s) =>\n              `${s.label}: ${axes.map((a, i) => `${a} ${formatValue(s.values[i] ?? 0)}`).join(', ')}`,\n          )\n          .join('. ')}`}\n        style={{ width: box, height: box }}\n      >\n        <View pointerEvents=\"none\" style={StyleSheet.absoluteFill}>\n          <Canvas style={StyleSheet.absoluteFill}>\n            <Path path={web} style=\"stroke\" strokeWidth={1} color={colors.border} />\n            {series.map((s, i) => (\n              <Shape\n                key={s.key}\n                values={s.values}\n                spokes={spokes}\n                center={c}\n                radius={R}\n                top={top}\n                color={tint(s, i)}\n                surface={colors.surface}\n                shown={!hidden.has(s.key)}\n                delay={i * 120}\n                reduced={reduced}\n              />\n            ))}\n          </Canvas>\n        </View>\n        {axes.map((name, i) => {\n          const s = spokes[i]!;\n          const x = c + s.x * (R + 22);\n          const y = c + s.y * (R + 22);\n          return (\n            <Text\n              key={name}\n              numberOfLines={2}\n              style={[\n                styles.axis,\n                {\n                  left: x - LABEL_W / 2,\n                  top: y - 9,\n                  color: colors.textMuted,\n                  fontSize: font.size.xs,\n                  fontWeight: font.weight.medium,\n                },\n              ]}\n            >\n              {name}\n            </Text>\n          );\n        })}\n      </View>\n\n      {legend ? (\n        <View style={[styles.legend, { gap: space[2] }]}>\n          {series.map((s, i) => {\n            const on = !hidden.has(s.key);\n            return (\n              <Pressable\n                key={s.key}\n                onPress={() => toggle(s.key)}\n                accessibilityRole=\"switch\"\n                accessibilityLabel={s.label}\n                accessibilityState={{ checked: on }}\n                style={[\n                  styles.chip,\n                  {\n                    borderRadius: radius.pill,\n                    borderColor: on ? withAlpha(tint(s, i), 0.5) : colors.border,\n                    backgroundColor: on ? withAlpha(tint(s, i), 0.12) : 'transparent',\n                    paddingHorizontal: space[3],\n                  },\n                ]}\n              >\n                <View\n                  style={[\n                    styles.swatch,\n                    { backgroundColor: on ? tint(s, i) : 'transparent', borderColor: tint(s, i) },\n                  ]}\n                />\n                <Text\n                  style={{\n                    color: on ? colors.text : colors.textSubtle,\n                    fontSize: font.size.sm,\n                    fontWeight: font.weight.medium,\n                  }}\n                >\n                  {s.label}\n                </Text>\n              </Pressable>\n            );\n          })}\n        </View>\n      ) : null}\n    </View>\n  );\n}\n\n/** One series: grows out from the centre, then morphs whenever its values change. */\nfunction Shape({\n  values,\n  spokes,\n  center,\n  radius,\n  top,\n  color,\n  surface,\n  shown,\n  delay,\n  reduced,\n}: {\n  values: readonly number[];\n  spokes: readonly { x: number; y: number }[];\n  center: number;\n  radius: number;\n  top: number;\n  color: string;\n  surface: string;\n  shown: boolean;\n  delay: number;\n  reduced: boolean;\n}) {\n  // Shares of the outer ring; the first shape starts from the centre.\n  const to = spokes.map((_, i) => Math.max(0, Math.min(1.1, (values[i] ?? 0) / top)));\n  const signature = to.join(',');\n  const pair = useSharedValue({ from: spokes.map(() => 0), to });\n  const t = useSharedValue(reduced ? 1 : 0);\n  const fade = useSharedValue(shown ? 1 : 0);\n\n  useEffect(() => {\n    // Morph from wherever the shape is now, even mid-morph.\n    const k = t.get();\n    const now = pair.get();\n    const at = to.map((_, i) => {\n      const a = now.from[i] ?? 0;\n      return a + ((now.to[i] ?? 0) - a) * k;\n    });\n    pair.set({ from: at, to });\n    if (reduced) {\n      t.set(1);\n      return;\n    }\n    t.set(0);\n    const run = withTiming(1, { duration: 900, easing: Easing.bezier(0.2, 0, 0, 1) });\n    // Series grow out one after another the first time; later changes morph together.\n    const first = at.every((v) => v === 0);\n    t.set(first ? withDelay(delay, run) : run);\n    // `signature` stands in for `to`.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [signature, reduced]);\n  useEffect(() => {\n    fade.set(withTiming(shown ? 1 : 0, timing('base', reduced)));\n  }, [shown, reduced, fade]);\n\n  const points = useDerivedValue(() => {\n    const k = t.get();\n    const { from: a, to: b } = pair.get();\n    return spokes.map((s, i) => {\n      const v = (a[i] ?? 0) + ((b[i] ?? 0) - (a[i] ?? 0)) * k;\n      return { x: center + s.x * radius * v, y: center + s.y * radius * v };\n    });\n  });\n  const path = useDerivedValue<SkPath>(() => {\n    const b = Skia.PathBuilder.Make();\n    points.get().forEach((p, i) => (i === 0 ? b.moveTo(p.x, p.y) : b.lineTo(p.x, p.y)));\n    return b.close().detach();\n  });\n\n  return (\n    <Group opacity={fade}>\n      <Path path={path} color={withAlpha(color, 0.18)} />\n      <Path path={path} style=\"stroke\" strokeWidth={2} strokeJoin=\"round\" color={color} />\n      {spokes.map((_, i) => (\n        <Vertex key={i} index={i} points={points} color={color} surface={surface} />\n      ))}\n    </Group>\n  );\n}\n\nfunction Vertex({\n  index,\n  points,\n  color,\n  surface,\n}: {\n  index: number;\n  points: SharedValue<{ x: number; y: number }[]>;\n  color: string;\n  surface: string;\n}) {\n  const c = useDerivedValue(() => points.get()[index] ?? { x: 0, y: 0 });\n  return (\n    <>\n      <Circle c={c} r={4} color={surface} />\n      <Circle c={c} r={2.75} color={color} />\n    </>\n  );\n}\n\nconst styles = StyleSheet.create({\n  axis: { position: 'absolute', width: LABEL_W, textAlign: 'center' },\n  legend: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' },\n  chip: {\n    flexDirection: 'row',\n    alignItems: 'center',\n    gap: 8,\n    minHeight: minTouchTarget,\n    borderWidth: StyleSheet.hairlineWidth,\n  },\n  swatch: { width: 10, height: 10, borderRadius: 5, borderWidth: 1.5 },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "data",
    "web": "live",
    "perf": {
      "budget": "The web is built once per size. Each series is one path rebuilt from a handful of points on the UI thread while it grows or morphs, then static.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 18,
          "p90Ms": 32,
          "baselineMs": 20,
          "jankyPercent": 4.5,
          "atRest": "idle",
          "memoryMb": 153
        }
      ]
    },
    "a11y": {
      "reducedMotion": "Shapes appear at their values; new values replace the old without morphing.",
      "screenReader": "The chart reads as an image listing every series by axis; legend chips are switches.",
      "touchTarget": "Legend chips are at least 44 pt tall."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { RadarChart } from '@/components/kinetik/radar-chart';\n\n<RadarChart\n  accessibilityLabel=\"Two trails compared\"\n  axes={['Distance', 'Climb', 'Views', 'Shade', 'Water']}\n  series={[\n    { key: 'ridge', label: 'Ridge loop', values: [8, 9, 10, 3, 4] },\n    { key: 'lake', label: 'Lake path', values: [5, 2, 7, 8, 10] },\n  ]}\n  max={10}\n/>;\n```\n\nAxes run clockwise from the top. Pass `max` when the scale is fixed (scores out of 10); otherwise the largest value sits on the outer ring.\n\n## When to use\n\n- Comparing two or three things across the same handful of qualities: products, plans, routes, skills.\n- Profiles where the overall shape matters more than any single value.\n\n## When not to use\n\n- Precise comparisons on one quality. Use a bar chart.\n- More than eight axes or three series. The shapes turn into a tangle.\n\n## Notes\n\n- Changing `values` morphs each shape from where it is to the new values, even mid-morph.\n- Legend chips show and hide their series, so people can compare two at a time.\n- With reduced motion the shapes appear and change without moving.\n"
  },
  "docs": "Prefer `npx kinetik-ui add radar-chart`: it installs dependencies with expo install, so versions match your Expo SDK."
}
