{
  "name": "activity-terrain",
  "type": "registry:component",
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Activity Terrain\n * @version: 1.0.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst DAYS = 7;\nconst GAP = 0.18;\n// Tallest column, in cells.\nconst MAX_HEIGHT = 4.2;\nconst DEG = Math.PI / 180;\n// Tilt of the terrain view; flat is looking straight down.\nconst PITCH_TERRAIN = 56 * DEG;\nconst YAW_TERRAIN = 18 * DEG;\n// How far it turns before the edges start pulling back.\nconst YAW_LIMIT = 38 * DEG;\nconst PITCH_LIMIT = 66 * DEG;\n// Drag, in radians per px.\nconst DRAG_YAW = 0.32 * DEG;\nconst DRAG_PITCH = 0.36 * DEG;\n// Spin keeps going after a flick and slows on this half-life, in seconds.\nconst SPIN_HALF_LIFE = 0.16;\n// The speed that coasts from straight on to the terrain's resting turn.\nconst SPIN_IN = (YAW_TERRAIN * Math.LN2) / SPIN_HALF_LIFE;\n// Springs, per second squared and per second.\nconst PITCH_STIFFNESS = 140;\nconst PITCH_DAMPING = 2 * Math.sqrt(PITCH_STIFFNESS);\nconst EDGE_STIFFNESS = 180;\nconst BUMP_STIFFNESS = 400;\nconst BUMP_DAMPING = 2 * Math.sqrt(BUMP_STIFFNESS) * 0.7;\nconst BUMP = 0.45;\n// Columns rise in a wave from where you grabbed; the far end lags by this\n// share of the whole rise.\nconst WAVE = 0.7;\nconst PADDING = 28;\nconst REST_EPSILON = 0.0005;\nconst LABELS = [\"Mon\", \"\", \"Wed\", \"\", \"Fri\", \"\", \"\"];\n\nexport interface ActivityTerrainProps {\n  /** Count per day, oldest first. */\n  data?: number[];\n  /** The day of the last value, as YYYY-MM-DD. */\n  endDate?: string;\n  title?: string;\n  /** Start in the flat, heatmap view. */\n  defaultFlat?: boolean;\n  className?: string;\n}\n\ninterface Cell {\n  index: number;\n  col: number;\n  row: number;\n  count: number;\n  level: number;\n  height: number;\n  date: Date;\n}\n\ninterface Pose {\n  yaw: number;\n  pitch: number;\n  scale: number;\n  cx: number;\n  cy: number;\n}\n\ntype Rgb = [number, number, number];\n\ninterface Palette {\n  levels: Rgb[];\n  accent: Rgb;\n  text: string;\n  font: string;\n}\n\nconst clamp = (v: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, v));\n\n// A gentle overshoot, so each column pops as it lands.\nfunction easeOutBack(t: number) {\n  const c = 1.4;\n  const u = t - 1;\n  return 1 + (c + 1) * u * u * u + c * u * u;\n}\n\n// Resistance past an edge: the further out, the less it moves.\nfunction rubber(value: number, min: number, max: number) {\n  if (value < min) {\n    return min - 0.18 * (1 - Math.exp((value - min) / 0.18));\n  }\n  if (value > max) {\n    return max + 0.18 * (1 - Math.exp((max - value) / 0.18));\n  }\n  return value;\n}\n\n// Deterministic demo data: busy weekdays, quiet weekends, a few streaks.\nfunction demoData(weeks: number) {\n  let seed = 7;\n  const random = () => {\n    seed = (seed * 16_807) % 2_147_483_647;\n    return seed / 2_147_483_647;\n  };\n  const out: number[] = [];\n  for (let i = 0; i < weeks * DAYS; i++) {\n    const weekend = i % DAYS >= 5;\n    const season = 0.55 + 0.45 * Math.sin(i / 23);\n    const streak = random() > 0.82 ? 2.2 : 1;\n    const quiet = random() < (weekend ? 0.55 : 0.12);\n    out.push(\n      quiet ? 0 : Math.round((weekend ? 3 : 9) * season * streak * random())\n    );\n  }\n  return out;\n}\n\nconst DEFAULT_DATA = demoData(26);\n\nfunction parseDay(value: string) {\n  const [y, m, d] = value.split(\"-\").map(Number);\n  return new Date(Date.UTC(y, m - 1, d));\n}\n\nfunction buildCells(data: number[], endDate: string) {\n  const end = parseDay(endDate);\n  const start = new Date(end.getTime() - (data.length - 1) * 86_400_000);\n  // Rows run Monday to Sunday.\n  const offset = (start.getUTCDay() + 6) % 7;\n  const max = Math.max(1, ...data);\n  return data.map((count, index): Cell => {\n    const slot = index + offset;\n    const norm = count / max;\n    return {\n      index,\n      col: Math.floor(slot / DAYS),\n      row: slot % DAYS,\n      count,\n      level: count === 0 ? 0 : Math.ceil(norm * 4),\n      height: 0.06 + norm ** 0.85 * MAX_HEIGHT,\n      date: new Date(start.getTime() + index * 86_400_000),\n    };\n  });\n}\n\nfunction hexToRgb(value: string): Rgb {\n  const hex = value.trim().replace(\"#\", \"\");\n  const n = Number.parseInt(\n    hex.length === 3 ? hex.replace(/./g, \"$&$&\") : hex,\n    16\n  );\n  return [Math.floor(n / 65_536) % 256, Math.floor(n / 256) % 256, n % 256];\n}\n\nfunction shade([r, g, b]: Rgb, amount: number) {\n  const k = 1 - amount;\n  return `rgb(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)})`;\n}\n\nfunction readPalette(node: HTMLElement): Palette {\n  const style = getComputedStyle(node);\n  const read = (name: string) => style.getPropertyValue(name) || \"#888888\";\n  return {\n    levels: [0, 1, 2, 3, 4].map((l) => hexToRgb(read(`--terrain-${l}`))),\n    accent: hexToRgb(read(\"--terrain-accent\")),\n    text: read(\"--terrain-text\"),\n    font: style.fontFamily,\n  };\n}\n\nfunction project(pose: Pose, x: number, y: number, z: number) {\n  const cos = Math.cos(pose.yaw);\n  const sin = Math.sin(pose.yaw);\n  const xr = x * cos - z * sin;\n  const zr = x * sin + z * cos;\n  return [\n    pose.cx + xr * pose.scale,\n    pose.cy +\n      (zr * Math.cos(pose.pitch) - y * Math.sin(pose.pitch)) * pose.scale,\n  ];\n}\n\n/** The faces of one column that face the camera, top last. */\nfunction visibleFaces(\n  pose: Pose,\n  x0: number,\n  z0: number,\n  h: number\n): { points: number[][]; tone: number }[] {\n  const x1 = x0 + 1 - GAP;\n  const z1 = z0 + 1 - GAP;\n  const p = (x: number, y: number, z: number) => project(pose, x, y, z);\n  const faces: { points: number[][]; tone: number }[] = [];\n  const sin = Math.sin(pose.yaw);\n  const cos = Math.cos(pose.yaw);\n  if (h > 0.02 && pose.pitch > 0.01) {\n    // Of the four walls, the ones whose outward side points at the camera.\n    if (cos > 0) {\n      faces.push({\n        points: [p(x0, 0, z1), p(x1, 0, z1), p(x1, h, z1), p(x0, h, z1)],\n        tone: 0.22,\n      });\n    } else {\n      faces.push({\n        points: [p(x0, 0, z0), p(x1, 0, z0), p(x1, h, z0), p(x0, h, z0)],\n        tone: 0.22,\n      });\n    }\n    if (sin > 0.001) {\n      faces.push({\n        points: [p(x1, 0, z0), p(x1, 0, z1), p(x1, h, z1), p(x1, h, z0)],\n        tone: 0.38,\n      });\n    } else if (sin < -0.001) {\n      faces.push({\n        points: [p(x0, 0, z0), p(x0, 0, z1), p(x0, h, z1), p(x0, h, z0)],\n        tone: 0.38,\n      });\n    }\n  }\n  faces.push({\n    points: [p(x0, h, z0), p(x1, h, z0), p(x1, h, z1), p(x0, h, z1)],\n    tone: 0,\n  });\n  return faces;\n}\n\nfunction inside(points: number[][], x: number, y: number) {\n  let hit = false;\n  for (let i = 0, j = points.length - 1; i < points.length; j = i++) {\n    const [xi, yi] = points[i];\n    const [xj, yj] = points[j];\n    if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {\n      hit = !hit;\n    }\n  }\n  return hit;\n}\n\nfunction polygon(ctx: CanvasRenderingContext2D, points: number[][]) {\n  ctx.beginPath();\n  ctx.moveTo(points[0][0], points[0][1]);\n  for (let i = 1; i < points.length; i++) {\n    ctx.lineTo(points[i][0], points[i][1]);\n  }\n  ctx.closePath();\n}\n\nconst formatDay = (date: Date) =>\n  date.toLocaleDateString(\"en-US\", {\n    weekday: \"short\",\n    month: \"short\",\n    day: \"numeric\",\n    timeZone: \"UTC\",\n  });\nconst formatMonth = (date: Date) =>\n  date.toLocaleDateString(\"en-US\", { month: \"short\", timeZone: \"UTC\" });\n\nexport default function ActivityTerrain({\n  data = DEFAULT_DATA,\n  endDate = \"2026-10-11\",\n  title = \"Activity\",\n  defaultFlat = false,\n  className,\n}: ActivityTerrainProps) {\n  const cells = useMemo(() => buildCells(data, endDate), [data, endDate]);\n  const weeks = (cells.at(-1)?.col ?? 0) + 1;\n  const total = useMemo(() => data.reduce((sum, n) => sum + n, 0), [data]);\n  const [flat, setFlat] = useState(defaultFlat);\n  const [active, setActive] = useState<Cell | null>(null);\n  const [dragging, setDragging] = useState(false);\n\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const paletteRef = useRef<Palette | null>(null);\n  const sim = useRef({\n    yaw: 0,\n    yawSpeed: 0,\n    // The intro tilts it up from flat once it's on screen.\n    pitch: 0,\n    pitchSpeed: 0,\n    pitchTarget: defaultFlat ? 0 : PITCH_TERRAIN,\n    bumps: new Float64Array(cells.length),\n    bumpSpeeds: new Float64Array(cells.length),\n    hovered: -1,\n    origin: { col: 0, row: 3 },\n    width: 0,\n    height: 0,\n    dpr: 1,\n    drag: null as null | {\n      id: number;\n      x: number;\n      y: number;\n      yaw: number;\n      pitch: number;\n      moved: boolean;\n      samples: { t: number; x: number; y: number }[];\n    },\n    raf: 0,\n    last: 0,\n    visible: false,\n    started: false,\n    reduceMotion: false,\n  });\n  const cellsRef = useRef(cells);\n  cellsRef.current = cells;\n  if (sim.current.bumps.length !== cells.length) {\n    sim.current.bumps = new Float64Array(cells.length);\n    sim.current.bumpSpeeds = new Float64Array(cells.length);\n  }\n\n  // How tall each column stands right now: the rise travels as a wave out\n  // from the origin, and each column overshoots a touch as it lands.\n  const columnHeight = useCallback(\n    (cell: Cell, lift: number) => {\n      const s = sim.current;\n      const span = Math.hypot(weeks, DAYS) || 1;\n      const d =\n        Math.hypot(cell.col - s.origin.col, cell.row - s.origin.row) / span;\n      const local = clamp(lift * (1 + WAVE) - d * WAVE, 0, 1);\n      return cell.height * easeOutBack(local) + s.bumps[cell.index];\n    },\n    [weeks]\n  );\n\n  const pose = useCallback((): Pose & { lift: number } => {\n    const s = sim.current;\n    const lift = clamp(s.pitch / PITCH_TERRAIN, 0, 1.2);\n    // Fit the whole board, at its current turn and tilt, into the canvas.\n    const w = weeks;\n    const corners = [\n      [0, 0],\n      [w, 0],\n      [0, DAYS],\n      [w, DAYS],\n    ];\n    const probe: Pose = { yaw: s.yaw, pitch: s.pitch, scale: 1, cx: 0, cy: 0 };\n    let minX = Number.POSITIVE_INFINITY;\n    let maxX = Number.NEGATIVE_INFINITY;\n    let minY = Number.POSITIVE_INFINITY;\n    let maxY = Number.NEGATIVE_INFINITY;\n    for (const [x, z] of corners) {\n      for (const y of [0, MAX_HEIGHT * Math.min(lift, 1)]) {\n        const [px, py] = project(probe, x - w / 2, y, z - DAYS / 2);\n        minX = Math.min(minX, px);\n        maxX = Math.max(maxX, px);\n        minY = Math.min(minY, py);\n        maxY = Math.max(maxY, py);\n      }\n    }\n    const scale = Math.min(\n      (s.width - PADDING * 2) / (maxX - minX),\n      (s.height - PADDING * 2) / (maxY - minY)\n    );\n    return {\n      yaw: s.yaw,\n      pitch: s.pitch,\n      scale,\n      cx: s.width / 2 - ((minX + maxX) / 2) * scale,\n      cy: s.height / 2 - ((minY + maxY) / 2) * scale + 6,\n      lift,\n    };\n  }, [weeks]);\n\n  // Back to front: the further a column's base, the earlier it's drawn.\n  const ordered = useCallback(\n    (p: Pose) => {\n      const sin = Math.sin(p.yaw);\n      const cos = Math.cos(p.yaw);\n      const depth = (c: Cell) =>\n        (c.col + 0.5 - weeks / 2) * sin + (c.row + 0.5 - DAYS / 2) * cos;\n      return [...cellsRef.current].sort((a, b) => depth(a) - depth(b));\n    },\n    [weeks]\n  );\n\n  const drawLabels = useCallback(\n    (\n      ctx: CanvasRenderingContext2D,\n      p: Pose,\n      lift: number,\n      palette: Palette\n    ) => {\n      // Labels lie on the ground, so they'd hide behind the columns; they\n      // fade out as the terrain rises, and the readout names each day.\n      const alpha = clamp(1 - lift * 1.6, 0, 1);\n      if (alpha === 0) {\n        return;\n      }\n      const list = cellsRef.current;\n      ctx.globalAlpha = alpha;\n      ctx.fillStyle = palette.text;\n      ctx.font = `11px ${palette.font}`;\n      ctx.textBaseline = \"middle\";\n      ctx.textAlign = \"left\";\n      let month = -1;\n      for (const cell of list) {\n        if (cell.row !== 0 && cell.index !== 0) {\n          continue;\n        }\n        const m = cell.date.getUTCMonth();\n        if (m !== month && cell.col < weeks - 1) {\n          month = m;\n          const [x, y] = project(p, cell.col - weeks / 2, 0, -0.75 - DAYS / 2);\n          ctx.fillText(formatMonth(cell.date), x, y);\n        }\n      }\n      ctx.textAlign = \"right\";\n      for (let row = 0; row < DAYS; row++) {\n        if (LABELS[row]) {\n          const [x, y] = project(p, -0.35 - weeks / 2, 0, row + 0.4 - DAYS / 2);\n          ctx.fillText(LABELS[row], x, y);\n        }\n      }\n      ctx.globalAlpha = 1;\n    },\n    [weeks]\n  );\n\n  const draw = useCallback(() => {\n    const s = sim.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!(canvas && ctx && s.width)) {\n      return;\n    }\n    // Colours come from CSS, read once per theme rather than every frame.\n    paletteRef.current ??= readPalette(canvas);\n    const palette = paletteRef.current;\n    const p = pose();\n    ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);\n    ctx.clearRect(0, 0, s.width, s.height);\n    drawLabels(ctx, p, p.lift, palette);\n    ctx.lineJoin = \"round\";\n    for (const cell of ordered(p)) {\n      const h = columnHeight(cell, p.lift);\n      const color =\n        cell.index === s.hovered ? palette.accent : palette.levels[cell.level];\n      for (const face of visibleFaces(\n        p,\n        cell.col - weeks / 2,\n        cell.row - DAYS / 2,\n        h\n      )) {\n        polygon(ctx, face.points);\n        const fill = shade(color, face.tone);\n        ctx.fillStyle = fill;\n        ctx.fill();\n        // A hairline in the same colour closes the seams between faces.\n        ctx.strokeStyle = fill;\n        ctx.lineWidth = 0.6;\n        ctx.stroke();\n      }\n    }\n  }, [columnHeight, drawLabels, ordered, pose, weeks]);\n\n  const hitTest = useCallback(\n    (x: number, y: number) => {\n      const p = pose();\n      const list = ordered(p);\n      for (let i = list.length - 1; i >= 0; i--) {\n        const cell = list[i];\n        const h = columnHeight(cell, p.lift);\n        for (const face of visibleFaces(\n          p,\n          cell.col - weeks / 2,\n          cell.row - DAYS / 2,\n          h\n        )) {\n          if (inside(face.points, x, y)) {\n            return cell;\n          }\n        }\n      }\n      return null;\n    },\n    [columnHeight, ordered, pose, weeks]\n  );\n\n  const stepSprings = useCallback((dt: number) => {\n    const s = sim.current;\n    let energy = 0;\n    if (!s.drag) {\n      // Spin coasts, then the edge pulls it back in range.\n      s.yawSpeed *= 0.5 ** (dt / SPIN_HALF_LIFE);\n      let target = s.yaw;\n      if (s.pitchTarget === 0) {\n        // Flat is always square on.\n        target = 0;\n      } else if (s.yaw > YAW_LIMIT) {\n        target = YAW_LIMIT;\n      } else if (s.yaw < -YAW_LIMIT) {\n        target = -YAW_LIMIT;\n      }\n      if (target !== s.yaw) {\n        s.yawSpeed +=\n          ((target - s.yaw) * EDGE_STIFFNESS -\n            s.yawSpeed * 2 * Math.sqrt(EDGE_STIFFNESS)) *\n          dt;\n      }\n      s.yaw += s.yawSpeed * dt;\n      energy = Math.max(energy, Math.abs(s.yawSpeed), Math.abs(target - s.yaw));\n\n      const force =\n        (s.pitchTarget - s.pitch) * PITCH_STIFFNESS -\n        s.pitchSpeed * PITCH_DAMPING;\n      s.pitchSpeed += force * dt;\n      s.pitch += s.pitchSpeed * dt;\n      energy = Math.max(\n        energy,\n        Math.abs(s.pitchSpeed),\n        Math.abs(s.pitchTarget - s.pitch)\n      );\n    }\n    for (let i = 0; i < s.bumps.length; i++) {\n      const target = i === s.hovered && s.pitch > 0.1 ? BUMP : 0;\n      s.bumpSpeeds[i] +=\n        ((target - s.bumps[i]) * BUMP_STIFFNESS -\n          s.bumpSpeeds[i] * BUMP_DAMPING) *\n        dt;\n      s.bumps[i] += s.bumpSpeeds[i] * dt;\n      energy = Math.max(\n        energy,\n        Math.abs(s.bumpSpeeds[i]) * 0.1,\n        Math.abs(target - s.bumps[i])\n      );\n    }\n    return energy;\n  }, []);\n\n  const tick = useCallback(\n    (now: number) => {\n      const s = sim.current;\n      s.raf = 0;\n      const dt = s.last ? Math.min((now - s.last) / 1000, 1 / 30) : 1 / 60;\n      s.last = now;\n      const energy = stepSprings(dt);\n      draw();\n      if ((s.drag || energy > REST_EPSILON) && s.visible) {\n        s.raf = requestAnimationFrame(tick);\n      } else {\n        s.last = 0;\n      }\n    },\n    [draw, stepSprings]\n  );\n\n  // Swing round from straight on as it tilts up, coasting to a stop at\n  // the resting turn.\n  const turnIn = useCallback(() => {\n    const s = sim.current;\n    if (Math.abs(s.yaw) > 2 * DEG) {\n      return;\n    }\n    if (s.reduceMotion) {\n      s.yaw = YAW_TERRAIN;\n    } else {\n      s.yawSpeed = SPIN_IN;\n    }\n  }, []);\n\n  const wake = useCallback(() => {\n    const s = sim.current;\n    if (s.reduceMotion) {\n      // No travel: everything lands where it's going.\n      s.pitch = s.pitchTarget;\n      s.yaw = clamp(s.yaw, -YAW_LIMIT, YAW_LIMIT);\n      s.pitchSpeed = 0;\n      s.yawSpeed = 0;\n      s.bumps.fill(0);\n      draw();\n      return;\n    }\n    if (!s.raf && s.visible) {\n      s.raf = requestAnimationFrame(tick);\n    }\n  }, [draw, tick]);\n\n  useEffect(() => {\n    const s = sim.current;\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n    const motion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    s.reduceMotion = motion.matches;\n    const resize = new ResizeObserver(([entry]) => {\n      s.width = entry.contentRect.width;\n      s.height = entry.contentRect.height;\n      s.dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(s.width * s.dpr);\n      canvas.height = Math.round(s.height * s.dpr);\n      draw();\n    });\n    resize.observe(canvas);\n\n    let onScreen = false;\n    const update = () => {\n      s.visible = onScreen && !document.hidden;\n      if (s.visible) {\n        if (!s.started) {\n          s.started = true;\n          if (s.pitchTarget > 0) {\n            turnIn();\n          }\n        }\n        wake();\n      } else if (s.raf) {\n        cancelAnimationFrame(s.raf);\n        s.raf = 0;\n        s.last = 0;\n      }\n    };\n    const intersection = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting;\n      update();\n    });\n    intersection.observe(canvas);\n    document.addEventListener(\"visibilitychange\", update);\n\n    // Repaint when the theme flips, since the canvas reads its colours.\n    const repaint = () => {\n      paletteRef.current = null;\n      draw();\n    };\n    const theme = new MutationObserver(repaint);\n    theme.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    });\n    const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    scheme.addEventListener(\"change\", repaint);\n\n    return () => {\n      resize.disconnect();\n      intersection.disconnect();\n      theme.disconnect();\n      scheme.removeEventListener(\"change\", repaint);\n      document.removeEventListener(\"visibilitychange\", update);\n      cancelAnimationFrame(s.raf);\n      s.raf = 0;\n    };\n  }, [draw, turnIn, wake]);\n\n  const setView = (\n    nextFlat: boolean,\n    origin?: { col: number; row: number }\n  ) => {\n    const s = sim.current;\n    setFlat(nextFlat);\n    s.origin = origin ?? { col: nextFlat ? weeks : 0, row: 3 };\n    s.pitchTarget = nextFlat ? 0 : PITCH_TERRAIN;\n    s.yawSpeed = 0;\n    if (!nextFlat) {\n      turnIn();\n    }\n    wake();\n  };\n\n  const localPoint = (event: PointerEvent) => {\n    const rect = canvasRef.current?.getBoundingClientRect();\n    return rect\n      ? { x: event.clientX - rect.left, y: event.clientY - rect.top }\n      : { x: 0, y: 0 };\n  };\n\n  const hover = (cell: Cell | null) => {\n    const s = sim.current;\n    const index = cell ? cell.index : -1;\n    if (index !== s.hovered) {\n      s.hovered = index;\n      setActive(cell);\n      if (s.reduceMotion) {\n        draw();\n      } else {\n        wake();\n      }\n    }\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => {\n    if (event.pointerType === \"mouse\" && event.button !== 0) {\n      return;\n    }\n    const s = sim.current;\n    const { x, y } = localPoint(event);\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const cell = hitTest(x, y);\n    if (cell) {\n      s.origin = { col: cell.col, row: cell.row };\n    }\n    s.drag = {\n      id: event.pointerId,\n      x: event.clientX,\n      y: event.clientY,\n      yaw: s.yaw,\n      pitch: s.pitch,\n      moved: false,\n      samples: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }],\n    };\n    s.yawSpeed = 0;\n    s.pitchSpeed = 0;\n    setDragging(true);\n    hover(cell);\n    wake();\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => {\n    const s = sim.current;\n    const drag = s.drag;\n    if (!drag || drag.id !== event.pointerId) {\n      if (event.pointerType === \"mouse\") {\n        const { x, y } = localPoint(event);\n        hover(hitTest(x, y));\n      }\n      return;\n    }\n    const dx = event.clientX - drag.x;\n    const dy = event.clientY - drag.y;\n    if (!drag.moved && Math.hypot(dx, dy) > 4) {\n      drag.moved = true;\n    }\n    if (!drag.moved) {\n      return;\n    }\n    s.yaw = rubber(drag.yaw + dx * DRAG_YAW, -YAW_LIMIT, YAW_LIMIT);\n    s.pitch = rubber(drag.pitch + dy * DRAG_PITCH, 0, PITCH_LIMIT);\n    drag.samples.push({\n      t: event.timeStamp,\n      x: event.clientX,\n      y: event.clientY,\n    });\n    if (drag.samples.length > 5) {\n      drag.samples.shift();\n    }\n    if (s.reduceMotion) {\n      draw();\n    } else {\n      wake();\n    }\n  };\n\n  const handlePointerUp = (event: PointerEvent<HTMLCanvasElement>) => {\n    const s = sim.current;\n    const drag = s.drag;\n    if (!drag || drag.id !== event.pointerId) {\n      return;\n    }\n    s.drag = null;\n    setDragging(false);\n    if (!drag.moved) {\n      wake();\n      return;\n    }\n    // Velocity from the last few samples, in px per second.\n    const first = drag.samples[0];\n    const lastSample = drag.samples.at(-1) ?? first;\n    const dt = Math.max((lastSample.t - first.t) / 1000, 0.016);\n    const vx = (lastSample.x - first.x) / dt;\n    const vy = (lastSample.y - first.y) / dt;\n    const recent = event.timeStamp - lastSample.t < 80;\n    s.yawSpeed = recent ? vx * DRAG_YAW : 0;\n    s.pitchSpeed = recent ? vy * DRAG_PITCH : 0;\n    // Land on whichever view the throw is heading for.\n    const projected = s.pitch + s.pitchSpeed * 0.15;\n    const nextFlat = projected < PITCH_TERRAIN / 2;\n    setFlat(nextFlat);\n    s.pitchTarget = nextFlat ? 0 : PITCH_TERRAIN;\n    wake();\n  };\n\n  const handlePointerLeave = (event: PointerEvent<HTMLCanvasElement>) => {\n    if (!sim.current.drag && event.pointerType === \"mouse\") {\n      hover(null);\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLCanvasElement>) => {\n    const moves: Record<string, [number, number]> = {\n      ArrowLeft: [-1, 0],\n      ArrowRight: [1, 0],\n      ArrowUp: [0, -1],\n      ArrowDown: [0, 1],\n    };\n    const move = moves[event.key];\n    if (!move) {\n      return;\n    }\n    event.preventDefault();\n    const list = cellsRef.current;\n    const current = list[sim.current.hovered] ?? list.at(-1);\n    if (!current) {\n      return;\n    }\n    const col = current.col + move[0];\n    const row = current.row + move[1];\n    const next = list.find((c) => c.col === col && c.row === row);\n    hover(next ?? current);\n  };\n\n  const readout = active\n    ? `${active.count} ${active.count === 1 ? \"contribution\" : \"contributions\"} on ${formatDay(active.date)}`\n    : `${total.toLocaleString(\"en-US\")} contributions in the last ${weeks} weeks`;\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-2xl select-none rounded-3xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100\",\n        \"[--terrain-0:#f4f4f5] [--terrain-1:#d4d4d8] [--terrain-2:#a1a1aa] [--terrain-3:#52525b] [--terrain-4:#18181b] [--terrain-accent:#10b981] [--terrain-text:#a1a1aa]\",\n        \"dark:[--terrain-0:#27272a] dark:[--terrain-1:#3f3f46] dark:[--terrain-2:#71717a] dark:[--terrain-3:#a1a1aa] dark:[--terrain-4:#f4f4f5] dark:[--terrain-accent:#34d399] dark:[--terrain-text:#71717a]\",\n        className\n      )}\n    >\n      <div className=\"flex items-start justify-between gap-4 p-5 pb-0\">\n        <div className=\"min-w-0\">\n          <p className=\"font-medium text-sm\">{title}</p>\n          <p\n            aria-live=\"polite\"\n            className=\"mt-0.5 truncate text-sm text-zinc-500 tabular-nums dark:text-zinc-400\"\n          >\n            {readout}\n          </p>\n        </div>\n        <div className=\"flex shrink-0 rounded-full bg-zinc-100 p-0.5 text-xs dark:bg-zinc-900\">\n          {(\n            [\n              [\"Flat\", true],\n              [\"Terrain\", false],\n            ] as const\n          ).map(([name, value]) => (\n            <button\n              aria-pressed={flat === value}\n              className={cn(\n                \"rounded-full px-3 py-1 font-medium outline-none transition-colors duration-200 ease-out focus-visible:ring-2 focus-visible:ring-zinc-400\",\n                flat === value\n                  ? \"bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-zinc-50\"\n                  : \"text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100\"\n              )}\n              key={name}\n              onClick={() => flat !== value && setView(value)}\n              type=\"button\"\n            >\n              {name}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <canvas\n        aria-label={`${title}: ${total} contributions over ${weeks} weeks. Use the arrow keys to read each day.`}\n        className={cn(\n          \"block h-72 w-full touch-pan-y rounded-b-3xl outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-inset\",\n          dragging ? \"cursor-grabbing\" : \"cursor-grab\"\n        )}\n        onKeyDown={handleKeyDown}\n        onLostPointerCapture={handlePointerUp}\n        onPointerCancel={handlePointerUp}\n        onPointerDown={handlePointerDown}\n        onPointerLeave={handlePointerLeave}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        ref={canvasRef}\n        role=\"img\"\n        tabIndex={0}\n      />\n\n      <div className=\"flex items-center justify-end gap-1 px-5 pb-4 text-xs text-zinc-500 dark:text-zinc-400\">\n        <span className=\"mr-1\">Less</span>\n        {[0, 1, 2, 3, 4].map((level) => (\n          <span\n            className=\"size-2.5 rounded-[3px]\"\n            key={level}\n            style={{ background: `var(--terrain-${level})` }}\n          />\n        ))}\n        <span className=\"ml-1\">More</span>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/activity-terrain.tsx",
      "target": "components/kokonutui/activity-terrain.tsx"
    }
  ]
}