{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "heat-grid",
  "type": "registry:item",
  "title": "Heat Grid",
  "description": "A calendar grid of days shaded by activity; the cells pop in as a wave and a drag reads any day.",
  "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/heat-grid.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/heat-grid.tsx",
      "content": "import { Canvas, Path, RoundedRect, Skia, type SkPath } 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 {\n  Easing,\n  useDerivedValue,\n  useSharedValue,\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 { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type HeatGridProps = {\n  /** One value per cell, oldest first, filling each column top to bottom. */\n  data: readonly number[];\n  /** Cells per column. Defaults to 7, a week. */\n  rows?: number;\n  /** Labels down the left, one per row; empty strings leave a row unlabelled. */\n  rowLabels?: readonly string[];\n  /** Describes one cell for the readout and screen readers, e.g. \"Tue 14 Oct · 3 rides\". */\n  describe?: (index: number, value: number) => string;\n  /** The readout while nothing is selected. Defaults to the total. */\n  summary?: string;\n  /** Colour of the busiest cells. Defaults to the theme azure. */\n  color?: string;\n  /** Space between cells. Defaults to 3. */\n  gap?: number;\n  /** Called with the cell under the finger, or null when the selection clears. */\n  onSelect?: (index: number | null) => void;\n  /** A tick as the finger moves from cell to cell. Defaults to true. */\n  haptics?: boolean;\n  /** What the grid shows, e.g. \"Rides, last 20 weeks\". */\n  accessibilityLabel: string;\n  style?: StyleProp<ViewStyle>;\n};\n\n/** Shades of the colour for levels 1–4; level 0 is the empty track. */\nconst SHADES = [0.26, 0.48, 0.72, 1];\nconst LABEL_W = 30;\n\ntype Cell = { x: number; y: number; delay: number };\n\n/**\n * A calendar grid of days shaded by how much happened on each. The cells pop\n * in as a diagonal wave; drag across the grid to read any day.\n */\nexport function HeatGrid({\n  data,\n  rows = 7,\n  rowLabels = ['Mon', '', 'Wed', '', 'Fri', '', ''],\n  describe = (i, v) => `Day ${i + 1}: ${v}`,\n  summary,\n  color,\n  gap = 3,\n  onSelect,\n  haptics = true,\n  accessibilityLabel,\n  style,\n}: HeatGridProps) {\n  const { colors, font, space } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const ink = color ?? colors.azure;\n\n  const [width, setWidth] = useState(0);\n  const [selected, setSelected] = useState<number | null>(null);\n\n  const n = data.length;\n  const cols = Math.max(1, Math.ceil(n / rows));\n  const labelled = rowLabels.some(Boolean);\n  const left = labelled ? LABEL_W : 0;\n  const cell = Math.max(6, Math.min(22, Math.floor((width - left - gap * (cols - 1)) / cols)));\n  const pitch = cell + gap;\n  const height = rows * cell + (rows - 1) * gap;\n\n  // Keyed on the values, not the array: a new array with the same numbers must not replay.\n  const signature = data.join(',');\n  const levels = useMemo(() => {\n    const max = Math.max(0, ...data);\n    const out: Cell[][] = [[], [], [], [], []];\n    if (width === 0) return out;\n    data.forEach((v, i) => {\n      const col = Math.floor(i / rows);\n      const row = i % rows;\n      const level = v <= 0 || max === 0 ? 0 : 1 + Math.min(3, Math.floor((v / max) * 4 - 1e-9));\n      out[level]!.push({\n        x: left + col * pitch,\n        y: row * pitch,\n        // A diagonal wave from the top-left corner.\n        delay: ((col + row * 0.7) / (cols + rows * 0.7)) * 0.62,\n      });\n    });\n    return out;\n    // `signature` stands in for `data`.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [signature, rows, cols, left, pitch, width]);\n\n  const total = data.reduce((s, v) => s + Math.max(0, v), 0);\n\n  const sized = width > 0;\n  const clock = useSharedValue(reduced ? 1 : 0);\n  useEffect(() => {\n    if (!sized) return;\n    if (reduced) {\n      clock.set(1);\n      return;\n    }\n    clock.set(0);\n    clock.set(withTiming(1, { duration: 1200, easing: Easing.linear }));\n    // Replays when the data changes or the grid first gets its size.\n  }, [signature, sized, reduced, clock]);\n\n  const choose = (i: number | null) => {\n    setSelected(i);\n    if (i !== null) haptic('tick');\n    onSelect?.(i);\n  };\n  const hover = useSharedValue(-1);\n  const move = (x: number, y: number, clear: boolean) => {\n    'worklet';\n    const col = Math.floor((x - left + gap / 2) / pitch);\n    const row = Math.floor((y + gap / 2) / pitch);\n    const i = col * rows + row;\n    const valid = col >= 0 && col < cols && row >= 0 && row < rows && i < n;\n    if (!valid) return;\n    // A tap on the selected cell clears it; a drag only ever selects.\n    const next = clear && i === hover.get() ? -1 : i;\n    if (next === hover.get()) return;\n    hover.set(next);\n    scheduleOnRN(choose, next < 0 ? null : next);\n  };\n\n  const pan = Gesture.Pan()\n    .withTestId('heat-grid')\n    .minDistance(0)\n    .onBegin((e) => {\n      move(e.x, e.y, true);\n    })\n    .onUpdate((e) => {\n      move(e.x, e.y, false);\n    });\n\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (n === 0) return;\n    const at = current ?? n - 1;\n    const next =\n      e.nativeEvent.actionName === 'increment' ? Math.min(n - 1, at + 1) : Math.max(0, at - 1);\n    hover.set(next);\n    choose(next);\n  };\n\n  // A selection past the end of new, shorter data no longer exists.\n  const current = selected !== null && selected < n ? selected : null;\n  const readout =\n    current === null ? (summary ?? `${total} in total`) : describe(current, data[current]!);\n  const sel =\n    current === null\n      ? null\n      : { x: left + Math.floor(current / rows) * pitch, y: (current % rows) * pitch };\n  const fills = [colors.track, ...SHADES.map((a) => withAlpha(ink, a))];\n  const corner = Math.max(2, cell * 0.24);\n\n  return (\n    <View style={[{ gap: space[3] }, style]}>\n      <Text\n        style={{\n          color: current === null ? colors.textMuted : colors.text,\n          fontSize: font.size.sm,\n          fontWeight: current === null ? font.weight.regular : font.weight.semibold,\n          fontVariant: ['tabular-nums'],\n        }}\n      >\n        {readout}\n      </Text>\n      <GestureDetector gesture={pan}>\n        <View\n          collapsable={false}\n          accessible\n          accessibilityRole=\"adjustable\"\n          accessibilityLabel={accessibilityLabel}\n          accessibilityValue={{ text: readout }}\n          accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}\n          onAccessibilityAction={onAccessibilityAction}\n          onLayout={(e) => setWidth(e.nativeEvent.layout.width)}\n          // Until it is measured, a rough height keeps the layout from jumping.\n          style={{ height: width > 0 ? height : rows * 14 }}\n        >\n          {labelled ? (\n            <View pointerEvents=\"none\" style={[styles.labels, { width: left }]}>\n              {Array.from({ length: rows }, (_, r) => (\n                <Text\n                  key={r}\n                  style={[\n                    styles.label,\n                    { top: r * pitch, height: cell, lineHeight: cell, color: colors.textSubtle },\n                  ]}\n                >\n                  {rowLabels[r] ?? ''}\n                </Text>\n              ))}\n            </View>\n          ) : null}\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              {levels.map((cells, level) => (\n                <Level\n                  key={level}\n                  cells={cells}\n                  size={cell}\n                  corner={corner}\n                  clock={clock}\n                  color={fills[level]!}\n                />\n              ))}\n              {sel ? (\n                <RoundedRect\n                  x={sel.x - 1.5}\n                  y={sel.y - 1.5}\n                  width={cell + 3}\n                  height={cell + 3}\n                  r={corner + 1.5}\n                  style=\"stroke\"\n                  strokeWidth={1.5}\n                  color={colors.text}\n                />\n              ) : null}\n            </Canvas>\n          </View>\n        </View>\n      </GestureDetector>\n      <View style={[styles.key, { paddingLeft: left }]}>\n        <Text style={{ color: colors.textSubtle, fontSize: font.size.xs }}>Less</Text>\n        {fills.map((f, i) => (\n          <View key={i} style={{ width: 10, height: 10, borderRadius: 2.5, backgroundColor: f }} />\n        ))}\n        <Text style={{ color: colors.textSubtle, fontSize: font.size.xs }}>More</Text>\n      </View>\n    </View>\n  );\n}\n\n/** Every cell of one shade, as one path, grown in by the shared clock. */\nfunction Level({\n  cells,\n  size,\n  corner,\n  clock,\n  color,\n}: {\n  cells: Cell[];\n  size: number;\n  corner: number;\n  clock: SharedValue<number>;\n  color: string;\n}) {\n  const path = useDerivedValue<SkPath>(() => {\n    const t = clock.get();\n    const b = Skia.PathBuilder.Make();\n    for (const c of cells) {\n      const local = Math.max(0, Math.min(1, (t - c.delay) / 0.38));\n      if (local <= 0) continue;\n      // Ease out with a touch of overshoot, so each cell pops.\n      const k = local - 1;\n      const grow = 1 + 2.2 * k * k * k + 1.2 * k * k;\n      const s = size * Math.max(0, grow);\n      const o = (size - s) / 2;\n      b.addRRect(Skia.RRectXY(Skia.XYWHRect(c.x + o, c.y + o, s, s), corner, corner));\n    }\n    return b.detach();\n  });\n  return <Path path={path} color={color} />;\n}\n\nconst styles = StyleSheet.create({\n  labels: { position: 'absolute', left: 0, top: 0, bottom: 0 },\n  label: { position: 'absolute', left: 0, fontSize: 10 },\n  key: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 4 },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "data",
    "web": "live",
    "perf": {
      "budget": "Five paths, one per shade, rebuilt on the UI thread only during the intro wave; at rest the canvas is static.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 17,
          "p90Ms": 19,
          "baselineMs": 20,
          "jankyPercent": 7.66,
          "atRest": "idle",
          "memoryMb": 159
        }
      ]
    },
    "a11y": {
      "reducedMotion": "Cells appear at once, without the wave.",
      "screenReader": "An adjustable element: swipe up or down to step through days; the value reads the chosen day, or the total.",
      "touchTarget": "The whole grid; cells are chosen by dragging, so their small size does not matter."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { HeatGrid } from '@/components/kinetik/heat-grid';\n\n<HeatGrid\n  accessibilityLabel=\"Rides, last 20 weeks\"\n  data={ridesPerDay}\n  describe={(i, v) => `${formatDay(start, i)} · ${v} rides`}\n  summary={`${total} rides in 20 weeks`}\n/>;\n```\n\n`data` fills each column top to bottom, so with the default seven rows the first value should fall on the first day of a week. Cells are sized to fit the width, between 6 and 22 pt.\n\n## When to use\n\n- Habits and streaks over weeks or months: workouts, commits, practice, sales days.\n- Spotting rhythms, such as quiet weekends or a busy month, that a line chart flattens.\n\n## When not to use\n\n- Exact comparisons between days. Shades are bands, not values; use a bar chart.\n- Fewer than about three weeks of data. The pattern needs room to show.\n\n## Notes\n\n- Values are split into four shades relative to the busiest day; zero is the empty track.\n- Drag across the grid to read any day; tap the chosen day again to clear it.\n- With reduced motion the cells appear at once.\n"
  },
  "docs": "Prefer `npx kinetik-ui add heat-grid`: it installs dependencies with expo install, so versions match your Expo SDK."
}
