{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "line-chart",
  "type": "registry:item",
  "title": "Line Chart",
  "description": "A smooth line chart that draws itself in; drag across it and a guide, a dot and a label follow the finger.",
  "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/line-chart.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/line-chart.tsx",
      "content": "import {\n  Canvas,\n  Circle,\n  DashPathEffect,\n  Group,\n  Line,\n  LinearGradient,\n  Path,\n  Skia,\n  rect,\n  vec,\n} from '@shopify/react-native-skia';\nimport { useEffect, useMemo, useState } from 'react';\nimport {\n  StyleSheet,\n  Text,\n  View,\n  type AccessibilityActionEvent,\n  type StyleProp,\n  type ViewStyle,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n  Easing,\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withDelay,\n  withRepeat,\n  withTiming,\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 { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type LineChartProps = {\n  /** Values, oldest first. At least two. */\n  data: readonly number[];\n  /** One label per value (dates, months). Shown while scrubbing and under the chart's ends. */\n  labels?: readonly string[];\n  /** Height of the chart. Defaults to 200. */\n  height?: number;\n  /** Line colour. Defaults to the theme ember. */\n  color?: string;\n  /** Fill the area under the line with a fading gradient. Defaults to true. */\n  area?: boolean;\n  formatValue?: (value: number) => string;\n  /** Called with the index under the finger, and null when it lifts. */\n  onScrub?: (index: number | null) => void;\n  /** A tick as the finger moves from point to point. Defaults to true. */\n  haptics?: boolean;\n  /** A summary of what the chart shows, e.g. \"Revenue, last 12 months\". */\n  accessibilityLabel: string;\n  style?: StyleProp<ViewStyle>;\n};\n\n/** Room above the plot for the scrub label. */\nconst TOP = 40;\nconst BOTTOM = 10;\nconst SIDE = 6;\n/** Pulses of the latest point after the line draws in. */\nconst PULSES = 3;\n\ntype Point = { x: number; y: number };\n\n/**\n * Monotone cubic interpolation (Fritsch–Carlson): a smooth curve through\n * every point that never overshoots, so a dip never draws below its value.\n */\nfunction curve(points: Point[]): string {\n  const n = points.length;\n  if (n < 2) return '';\n  const dx: number[] = [];\n  const slope: number[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    dx.push(points[i + 1]!.x - points[i]!.x);\n    slope.push((points[i + 1]!.y - points[i]!.y) / (dx[i] || 1));\n  }\n  const tangent: number[] = [slope[0]!];\n  for (let i = 1; i < n - 1; i++) {\n    const a = slope[i - 1]!;\n    const b = slope[i]!;\n    tangent.push(\n      a * b <= 0\n        ? 0\n        : (3 * (dx[i - 1]! + dx[i]!)) /\n            ((2 * dx[i]! + dx[i - 1]!) / a + (dx[i]! + 2 * dx[i - 1]!) / b),\n    );\n  }\n  tangent.push(slope[n - 2]!);\n  let d = `M${points[0]!.x} ${points[0]!.y}`;\n  for (let i = 0; i < n - 1; i++) {\n    const p = points[i]!;\n    const q = points[i + 1]!;\n    const h = dx[i]! / 3;\n    d += ` C${p.x + h} ${p.y + tangent[i]! * h} ${q.x - h} ${q.y - tangent[i + 1]! * h} ${q.x} ${q.y}`;\n  }\n  return d;\n}\n\n/**\n * A smooth line chart that draws itself in. Drag across it to read any point:\n * a guide, a dot and a label follow the finger. Once drawn, the latest\n * point pulses softly a few times.\n */\nexport function LineChart({\n  data,\n  labels,\n  height = 200,\n  color,\n  area = true,\n  formatValue = (v) => String(Math.round(v * 100) / 100),\n  onScrub,\n  haptics = true,\n  accessibilityLabel,\n  style,\n}: LineChartProps) {\n  const { colors, font, radius, space } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const ink = color ?? colors.ember;\n\n  const [width, setWidth] = useState(0);\n  const [active, setActive] = useState<number | null>(null);\n  const [labelW, setLabelW] = useState(0);\n\n  // Keyed on the values, not the array: a new array with the same numbers must not redraw.\n  const signature = data.join(',');\n  const { points, line, fill } = useMemo(() => {\n    const n = data.length;\n    if (width === 0 || n < 2) return { points: [] as Point[], line: null, fill: null };\n    const lo = Math.min(...data);\n    const hi = Math.max(...data);\n    const pad = (hi - lo) * 0.08 || 1;\n    const top = TOP;\n    const bottom = height - BOTTOM;\n    const pts = data.map((v, i) => ({\n      x: SIDE + (i / (n - 1)) * (width - SIDE * 2),\n      y: bottom - ((v - (lo - pad)) / (hi + pad - (lo - pad))) * (bottom - top),\n    }));\n    const d = curve(pts);\n    const last = pts[n - 1]!;\n    return {\n      points: pts,\n      line: Skia.Path.MakeFromSVGString(d),\n      fill: Skia.Path.MakeFromSVGString(`${d} L${last.x} ${height} L${pts[0]!.x} ${height} Z`),\n    };\n    // `signature` stands in for `data`.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [signature, width, height]);\n\n  // Draw in whenever the data or the size changes.\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  }, [line, reduced, progress]);\n\n  // The latest point pulses a few times once the line is drawn, then rests, so\n  // a chart that is only being looked at costs no frames.\n  const pulse = useSharedValue(1);\n  useEffect(() => {\n    if (reduced) return;\n    pulse.set(0);\n    pulse.set(\n      withDelay(\n        1100,\n        withRepeat(\n          withTiming(1, { duration: 1600, easing: Easing.out(Easing.quad) }),\n          PULSES,\n          false,\n        ),\n      ),\n    );\n  }, [line, reduced, pulse]);\n\n  const index = useSharedValue(-1);\n  const n = points.length;\n\n  const pick = (x: number) => {\n    'worklet';\n    if (n < 2) return -1;\n    const t = (x - SIDE) / (width - SIDE * 2);\n    return Math.max(0, Math.min(n - 1, Math.round(t * (n - 1))));\n  };\n\n  const report = (i: number) => {\n    setActive(i < 0 ? null : i);\n    if (i >= 0) haptic('tick');\n    onScrub?.(i < 0 ? null : i);\n  };\n  const move = (i: number) => {\n    'worklet';\n    if (i === index.get()) return;\n    index.set(i);\n    scheduleOnRN(report, i);\n  };\n\n  const pan = Gesture.Pan()\n    .withTestId('line-chart')\n    .enabled(n >= 2)\n    .minDistance(0)\n    .activeOffsetX([-2, 2])\n    .failOffsetY([-14, 14])\n    .onBegin((e) => {\n      move(pick(e.x));\n    })\n    .onStart((e) => {\n      move(pick(e.x));\n    })\n    .onUpdate((e) => {\n      move(pick(e.x));\n    })\n    .onFinalize(() => {\n      move(-1);\n    });\n\n  // Scrub marker and resting dot, read on the UI thread.\n  const marker = useDerivedValue(() => {\n    const i = index.get();\n    const p = points[i < 0 ? n - 1 : i];\n    return p ?? { x: -100, y: -100 };\n  });\n  const markerC = useDerivedValue(() => vec(marker.get().x, marker.get().y));\n  const guideFrom = useDerivedValue(() => vec(marker.get().x, TOP - 6));\n  const guideTo = useDerivedValue(() => vec(marker.get().x, height));\n  const scrubbing = useDerivedValue(() => (index.get() >= 0 ? 1 : 0));\n  const resting = useDerivedValue(() => (index.get() >= 0 ? 0 : progress.get() >= 0.999 ? 1 : 0));\n  const haloR = useDerivedValue(() => 5 + pulse.get() * 12);\n  const haloO = useDerivedValue(() => (1 - pulse.get()) * 0.35 * resting.get());\n  const reveal = useDerivedValue(() => rect(0, 0, width * progress.get(), height));\n\n  const bubble = useAnimatedStyle(() => {\n    const x = marker.get().x - labelW / 2;\n    return {\n      opacity: withTiming(index.get() >= 0 ? 1 : 0, { duration: 120 }),\n      transform: [{ translateX: Math.max(0, Math.min(width - labelW, x)) }],\n    };\n  });\n\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (n < 2) return;\n    const current = active ?? n - 1;\n    const next =\n      e.nativeEvent.actionName === 'increment'\n        ? Math.min(n - 1, current + 1)\n        : Math.max(0, current - 1);\n    setActive(next);\n    onScrub?.(next);\n  };\n\n  const shown = active ?? n - 1;\n  const valueText =\n    n > 0\n      ? `${labels?.[shown] ? `${labels[shown]}: ` : ''}${formatValue(data[shown]!)}`\n      : undefined;\n\n  return (\n    <View style={style}>\n      <GestureDetector gesture={pan}>\n        <View\n          collapsable={false}\n          accessible\n          accessibilityRole=\"adjustable\"\n          accessibilityLabel={accessibilityLabel}\n          accessibilityValue={valueText ? { text: valueText } : undefined}\n          accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}\n          onAccessibilityAction={onAccessibilityAction}\n          onLayout={(e) => setWidth(e.nativeEvent.layout.width)}\n          style={{ height }}\n        >\n          {/* Wrapped so touches reach the gesture on the web, where the canvas ignores pointerEvents. */}\n          <View pointerEvents=\"none\" style={StyleSheet.absoluteFill}>\n            <Canvas style={StyleSheet.absoluteFill}>\n              {[0, 1, 2].map((g) => {\n                const y = TOP + ((height - BOTTOM - TOP) * g) / 2;\n                return (\n                  <Line\n                    key={g}\n                    p1={vec(0, y)}\n                    p2={vec(width, y)}\n                    color={colors.border}\n                    strokeWidth={1}\n                  >\n                    <DashPathEffect intervals={[3, 5]} />\n                  </Line>\n                );\n              })}\n              {area && fill ? (\n                <Group clip={reveal}>\n                  <Path path={fill}>\n                    <LinearGradient\n                      start={vec(0, TOP)}\n                      end={vec(0, height)}\n                      colors={[withAlpha(ink, 0.22), withAlpha(ink, 0)]}\n                    />\n                  </Path>\n                </Group>\n              ) : null}\n              {line ? (\n                <Path\n                  path={line}\n                  style=\"stroke\"\n                  strokeWidth={2.25}\n                  strokeCap=\"round\"\n                  strokeJoin=\"round\"\n                  color={ink}\n                  end={progress}\n                />\n              ) : null}\n              <Group opacity={scrubbing}>\n                <Line p1={guideFrom} p2={guideTo} color={withAlpha(ink, 0.35)} strokeWidth={1}>\n                  <DashPathEffect intervals={[2, 3]} />\n                </Line>\n                <Circle c={markerC} r={11} color={withAlpha(ink, 0.16)} />\n              </Group>\n              <Circle c={markerC} r={haloR} color={ink} opacity={haloO} />\n              <Group opacity={progress}>\n                <Circle c={markerC} r={5.5} color={colors.surface} />\n                <Circle c={markerC} r={3.75} color={ink} />\n              </Group>\n            </Canvas>\n          </View>\n          <Animated.View\n            pointerEvents=\"none\"\n            onLayout={(e) => setLabelW(e.nativeEvent.layout.width)}\n            style={[\n              styles.bubble,\n              {\n                backgroundColor: colors.text,\n                borderRadius: radius.sm,\n                paddingHorizontal: space[2],\n              },\n              bubble,\n            ]}\n          >\n            <Text\n              style={{\n                color: colors.background,\n                fontSize: font.size.xs,\n                fontWeight: font.weight.bold,\n                fontVariant: ['tabular-nums'],\n              }}\n            >\n              {active !== null ? formatValue(data[active]!) : ' '}\n              {active !== null && labels?.[active] ? (\n                <Text\n                  style={{ fontWeight: font.weight.regular, opacity: 0.7 }}\n                >{`  ${labels[active]}`}</Text>\n              ) : null}\n            </Text>\n          </Animated.View>\n        </View>\n      </GestureDetector>\n      {labels && labels.length > 1 ? (\n        <View style={[styles.axis, { marginTop: space[2] }]}>\n          {[labels[0], labels[Math.floor((labels.length - 1) / 2)], labels[labels.length - 1]].map(\n            (l, i) => (\n              <Text key={i} style={{ color: colors.textSubtle, fontSize: font.size.xs }}>\n                {l}\n              </Text>\n            ),\n          )}\n        </View>\n      ) : null}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  bubble: { position: 'absolute', top: 4, left: 0, paddingVertical: 5 },\n  axis: { flexDirection: 'row', justifyContent: 'space-between' },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "data",
    "web": "live",
    "perf": {
      "budget": "The curve is built once per data change; drawing in, scrubbing and three pulses of the latest point are shared values read by one Skia canvas. Nothing is drawn at rest.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 21,
          "p90Ms": 32,
          "baselineMs": 20,
          "jankyPercent": 9.36,
          "atRest": "idle",
          "memoryMb": 162,
          "notes": "Pulses three times after drawing in, then draws nothing at rest (0 frames in 5 s). It used to pulse forever."
        }
      ]
    },
    "a11y": {
      "reducedMotion": "The line appears complete, without drawing in, and the latest point does not pulse.",
      "screenReader": "Adjustable: swipe up and down to step through the points; each reads its label and value.",
      "touchTarget": "The whole chart is the scrub area."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { LineChart } from '@/components/kinetik/line-chart';\n\n<LineChart\n  accessibilityLabel=\"Revenue, last 12 months\"\n  data={revenue}\n  labels={months}\n  formatValue={(v) => `$${(v / 1000).toFixed(1)}k`}\n  onScrub={(i) => setFocus(i)}\n/>;\n```\n\nThe chart fills its parent's width. The curve passes through every value without overshooting, so a dip never draws below its real number. `onScrub` reports the index under the finger (and `null` when it lifts), which is handy for a headline figure above the chart.\n\n## When to use\n\n- Trends over time: revenue, steps, heart rate, prices.\n- Dashboards where people want to read the exact value at a moment.\n\n## When not to use\n\n- Comparing separate categories. Use Bar Chart.\n- Dense data with thousands of points; down-sample first.\n\n## Notes\n\n- The line draws in whenever the data changes, with the area revealed behind it.\n- The latest point pulses a few times after the line draws in, a quiet \"this is live\", then rests so a chart on screen costs no frames.\n- Screen readers step through the points with the adjustable increment and decrement actions.\n"
  },
  "docs": "Prefer `npx kinetik-ui add line-chart`: it installs dependencies with expo install, so versions match your Expo SDK."
}
