{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-06",
  "title": "Goal numerals",
  "description": "Four goals as soft bars under large percentages and a month scrubber that moves them all as one, where a bar that reaches its goal gives a little, then breaks through in amber with the words Over goal.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/badge.json",
    "https://ui.ndekocode.dev/r/chat-motion.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/motion-word-roll.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-timeouts.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/chart/chart-06.tsx",
      "content": "'use client';\n\nimport { useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { WordRoll } from '@/components/core/word-roll';\nimport type { AutoplayStep } from '@/registry/ndeko/hooks/use-autoplay';\nimport { hold } from '@/registry/ndeko/lib/demonstration';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Badge } from '@/registry/ndeko/ui/badge';\nimport { Swap } from '@/registry/ndeko/ui/chat-motion';\n\nimport { ChartCard } from './_parts/chart-card';\nimport { DataTable, type DataRow } from './_parts/data-table';\nimport { walkSteps } from './_parts/demo';\nimport { clamp, positionsTo } from './_parts/scale';\nimport { useChartDemo } from './_parts/use-chart-demo';\nimport { useStepDrag } from './_parts/use-step-drag';\n\nexport type GoalTrack = {\n  id: string;\n  name: string;\n  /** Progress at each period as a share of the goal: 1 is the goal met, 1.12 is 12% past it. */\n  progress: readonly number[];\n};\n\nexport type GoalNumeralsProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  /** One short label per period: the scrubber writes them. */\n  periods: readonly string[];\n  goals: readonly GoalTrack[];\n  periodIndex: number;\n  onPeriodIndexChange: (index: number) => void;\n  /** What the scrubber moves through, for screen readers: \"Month\". */\n  periodLabel?: string;\n  className?: string;\n};\n\n/** One pixel is one point of progress: the track ends at the goal, and what goes past it sticks out above. */\nconst GOAL_PX = 100;\n\nconst OVER_ROOM_PX = 28;\n\nconst GAP_PX = 2;\n\n/** Room above the goal for the bar to give a little when it reaches it. */\nconst RUBBER_PX = 3;\n\nconst BAR_HEIGHT = GOAL_PX + GAP_PX + OVER_ROOM_PX;\n\nconst HOLD_HEIGHT = GOAL_PX + RUBBER_PX;\n\ntype Standing = 'under' | 'met' | 'over';\n\nfunction standingOf(percent: number): Standing {\n  if (percent > 100) {\n    return 'over';\n  }\n  if (percent === 100) {\n    return 'met';\n  }\n  return 'under';\n}\n\nfunction percentAt(goal: GoalTrack, periodIndex: number) {\n  const share = goal.progress[periodIndex] ?? 0;\n  return Math.round(share * 100);\n}\n\nfunction percentTextAt(goal: GoalTrack, periodIndex: number) {\n  const percent = percentAt(goal, periodIndex);\n  return `${percent}%`;\n}\n\n/**\n * A bar that arrives at its goal goes a few pixels past it and comes back, like something that\n * meets resistance: the one place where an easing overshoots, and it is cut at `RUBBER_PX`.\n */\nfunction arrivalOf(standing: Standing) {\n  if (standing === 'under') {\n    return 'ease-(--ease-out-strong)';\n  }\n  return 'ease-[cubic-bezier(0.3,1.5,0.6,1)]';\n}\n\n/** Once a bar is past its goal its top is a joint, not its end: the part above carries the rounded end. */\nfunction capOf(standing: Standing) {\n  if (standing === 'over') {\n    return 'rounded-t-none';\n  }\n  return 'rounded-t-[4px]';\n}\n\n/** The part past the goal waits for the bar to reach the goal, then breaks through. */\nfunction breakthroughOf(standing: Standing) {\n  if (standing === 'over') {\n    return 'delay-150';\n  }\n  return 'delay-0';\n}\n\ntype GoalColumnProps = {\n  goal: GoalTrack;\n  percent: number;\n};\n\nfunction GoalColumn({ goal, percent }: GoalColumnProps) {\n  const standing = standingOf(percent);\n  const reached = clamp(percent, 0, 100);\n  const hiddenPercent = 100 - reached;\n  const pastGoal = percent - 100;\n  const overPx = clamp(pastGoal, 0, OVER_ROOM_PX);\n  const hiddenOverPx = OVER_ROOM_PX - overPx;\n\n  return (\n    <li className='flex min-w-0 flex-col items-center gap-1.5'>\n      <span className='sr-only'>{goal.name}</span>\n      <MotionNumber\n        value={percent}\n        suffix='%'\n        className='text-base leading-6 font-semibold @xs:text-xl @xs:leading-7 @md:text-2xl'\n      />\n      <span className='flex h-5 items-center'>\n        <Swap swapKey={standing}>\n          {standing === 'over' && (\n            <Badge variant='warning'>\n              <span aria-hidden='true' className='@xs:hidden'>\n                Over\n              </span>\n              <span className='sr-only @xs:not-sr-only'>Over goal</span>\n            </Badge>\n          )}\n          {standing === 'met' && (\n            <Badge variant='success'>\n              <span aria-hidden='true' className='@xs:hidden'>\n                Met\n              </span>\n              <span className='sr-only @xs:not-sr-only'>Goal met</span>\n            </Badge>\n          )}\n        </Swap>\n      </span>\n      <span\n        aria-hidden='true'\n        className='relative w-6'\n        style={{ height: BAR_HEIGHT }}\n      >\n        <span\n          className='bg-foreground/[0.05] absolute inset-x-0 bottom-0 rounded-t-[4px]'\n          style={{ height: GOAL_PX }}\n        />\n        <span\n          className='absolute inset-x-0 bottom-0 overflow-hidden'\n          style={{ height: HOLD_HEIGHT }}\n        >\n          <span\n            className={cn(\n              'bg-primary absolute inset-x-0 bottom-0 transition-[transform,border-radius] duration-300',\n              arrivalOf(standing),\n              capOf(standing)\n            )}\n            style={{\n              height: GOAL_PX,\n              transform: `translateY(${hiddenPercent}%)`,\n            }}\n          />\n        </span>\n        <span\n          className='absolute inset-x-0 top-0 overflow-hidden'\n          style={{ height: OVER_ROOM_PX }}\n        >\n          <span\n            className={cn(\n              'bg-warning absolute inset-0 rounded-t-[4px] transition-transform duration-300 ease-(--ease-out-strong)',\n              breakthroughOf(standing)\n            )}\n            style={{ transform: `translateY(${hiddenOverPx}px)` }}\n          />\n        </span>\n      </span>\n      <span\n        aria-hidden='true'\n        className='text-muted-foreground w-full text-center text-[11px] leading-4 text-balance @xs:text-xs'\n      >\n        {goal.name}\n      </span>\n    </li>\n  );\n}\n\nconst THUMB =\n  'bg-card shadow-[0_1px_2px_rgb(0_0_0/0.08)] hairline-ring ring-foreground/10 dark:bg-foreground/[0.09] dark:ring-foreground/15';\n\nfunction cursorOf(isDragging: boolean) {\n  if (isDragging) {\n    return 'cursor-grabbing';\n  }\n  return 'cursor-grab';\n}\n\n/** A narrow scrubber writes each period by its first letter, as month scales do: J F M A. */\nfunction initialOf(period: string) {\n  return period.charAt(0);\n}\n\nfunction periodToneOf(isCurrent: boolean) {\n  if (isCurrent) {\n    return 'text-foreground font-medium';\n  }\n  return 'text-muted-foreground';\n}\n\ntype PeriodScrubberProps = {\n  periods: readonly string[];\n  index: number;\n  onIndexChange: (index: number) => void;\n  label: string;\n};\n\n/** A slider with one stop per period: the thumb catches on each, and keeps going when it is flicked. */\nfunction PeriodScrubber({\n  periods,\n  index,\n  onIndexChange,\n  label,\n}: PeriodScrubberProps) {\n  const count = Math.max(periods.length, 1);\n  const drag = useStepDrag({ count, index, onIndexChange });\n  const columnShare = 100 / count;\n  const offset = index * columnShare;\n  const thumbStyle = {\n    width: `${columnShare}cqw`,\n    transform: `translateX(${offset}cqw)`,\n  };\n\n  return (\n    <div\n      role='slider'\n      tabIndex={0}\n      aria-label={label}\n      aria-orientation='horizontal'\n      aria-valuemin={0}\n      aria-valuemax={count - 1}\n      aria-valuenow={index}\n      aria-valuetext={periods[index]}\n      className={cn(\n        'bg-foreground/[0.05] focus-visible:ring-ring/25 @container relative flex h-8 touch-pan-y items-center rounded-lg outline-none select-none focus-visible:ring-[3px]',\n        cursorOf(drag.isDragging)\n      )}\n      {...drag.sliderProps}\n    >\n      <span\n        aria-hidden='true'\n        className='pointer-events-none absolute inset-y-0.5 left-0 p-px transition-transform duration-150 ease-(--ease-out-strong)'\n        style={thumbStyle}\n      >\n        <span className={cn('block size-full rounded-md', THUMB)} />\n      </span>\n      {periods.map((period, periodIndex) => (\n        <span\n          key={`${periodIndex}-${period}`}\n          aria-hidden='true'\n          className={cn(\n            'pointer-events-none relative min-w-0 flex-1 text-center text-[11px] transition-colors duration-150',\n            periodToneOf(periodIndex === index)\n          )}\n        >\n          <span className='@xs:hidden'>{initialOf(period)}</span>\n          <span className='hidden @xs:inline'>{period}</span>\n        </span>\n      ))}\n    </div>\n  );\n}\n\nfunction reachedCountOf(percents: readonly number[]) {\n  const reached = percents.filter((percent) => percent >= 100);\n  return reached.length;\n}\n\nfunction sentenceOf(period: string, reachedCount: number, goalCount: number) {\n  return `By ${period}, ${reachedCount} of ${goalCount} goals reached`;\n}\n\n/**\n * Every sentence the scrubber can show. Given to the roll, its line keeps the widest one, so the\n * month and the count roll on each step without the width being animated frame by frame.\n */\nfunction sentencesOf(periods: readonly string[], goalCount: number) {\n  const sentences: string[] = [];\n  for (const period of periods) {\n    for (let reached = 0; reached <= goalCount; reached += 1) {\n      const sentence = sentenceOf(period, reached, goalCount);\n      sentences.push(sentence);\n    }\n  }\n  return sentences;\n}\n\nexport function GoalNumerals({\n  title,\n  note,\n  periods,\n  goals,\n  periodIndex,\n  onPeriodIndexChange,\n  periodLabel = 'Period',\n  className,\n}: GoalNumeralsProps) {\n  const lastIndex = Math.max(periods.length - 1, 0);\n  const index = clamp(periodIndex, 0, lastIndex);\n  const period = periods[index] ?? '';\n  const percents = goals.map((goal) => percentAt(goal, index));\n  const reachedCount = reachedCountOf(percents);\n  const sentence = sentenceOf(period, reachedCount, goals.length);\n  const sentences = sentencesOf(periods, goals.length);\n  const readings = goals.map((goal) => {\n    const percent = percentTextAt(goal, index);\n    return `${goal.name} ${percent}`;\n  });\n  const readingList = readings.join(', ');\n  const summary = `Progress by ${period}: ${readingList}.`;\n  const columns = {\n    gridTemplateColumns: `repeat(${goals.length}, minmax(0, 1fr))`,\n  };\n\n  const rows: DataRow[] = periods.map((label, rowIndex) => ({\n    id: `${rowIndex}-${label}`,\n    label,\n    cells: goals.map((goal) => percentTextAt(goal, rowIndex)),\n  }));\n\n  return (\n    <ChartCard\n      title={title}\n      note={note}\n      summary={summary}\n      className={className}\n    >\n      <p className='text-muted-foreground text-sm'>\n        <WordRoll text={sentence} labels={sentences} value={index} />\n      </p>\n      <ul className='grid gap-2' style={columns}>\n        {goals.map((goal, goalIndex) => (\n          <GoalColumn key={goal.id} goal={goal} percent={percents[goalIndex]} />\n        ))}\n      </ul>\n      <PeriodScrubber\n        periods={periods}\n        index={index}\n        onIndexChange={onPeriodIndexChange}\n        label={periodLabel}\n      />\n      <DataTable\n        caption={title}\n        columns={[periodLabel, ...goals.map((goal) => goal.name)]}\n        rows={rows}\n      />\n    </ChartCard>\n  );\n}\n\nconst MONTHS = [\n  'Jan',\n  'Feb',\n  'Mar',\n  'Apr',\n  'May',\n  'Jun',\n  'Jul',\n  'Aug',\n  'Sep',\n  'Oct',\n  'Nov',\n  'Dec',\n];\n\nconst GOALS: GoalTrack[] = [\n  {\n    id: 'revenue',\n    name: 'Revenue',\n    progress: [\n      0.06, 0.13, 0.21, 0.3, 0.39, 0.49, 0.58, 0.68, 0.79, 0.91, 1, 1.12,\n    ],\n  },\n  {\n    id: 'customers',\n    name: 'New signups',\n    progress: [\n      0.09, 0.19, 0.3, 0.42, 0.55, 0.67, 0.78, 0.9, 1.04, 1.17, 1.26, 1.31,\n    ],\n  },\n  {\n    id: 'retention',\n    name: 'Renewals',\n    progress: [\n      0.08, 0.15, 0.22, 0.3, 0.37, 0.45, 0.52, 0.6, 0.67, 0.75, 0.82, 0.89,\n    ],\n  },\n  {\n    id: 'support',\n    name: 'Help articles',\n    progress: [\n      0.07, 0.16, 0.24, 0.31, 0.4, 0.48, 0.59, 0.66, 0.74, 0.83, 0.94, 1,\n    ],\n  },\n];\n\nconst SEPTEMBER = 8;\n\nconst DECEMBER = 11;\n\nconst JUNE = 5;\n\nexport default function Chart06() {\n  const [periodIndex, setPeriodIndex] = useState(SEPTEMBER);\n\n  function resetDemo() {\n    setPeriodIndex(SEPTEMBER);\n  }\n\n  const toYearEnd = positionsTo(SEPTEMBER, DECEMBER);\n  const backToJune = positionsTo(DECEMBER, JUNE);\n  const toSeptember = positionsTo(JUNE, SEPTEMBER);\n  const steps: AutoplayStep[] = [\n    ...walkSteps(toYearEnd, setPeriodIndex, { startAfter: 1500, stepMs: 620 }),\n    ...walkSteps(backToJune, setPeriodIndex, { startAfter: 2200, stepMs: 130 }),\n    ...walkSteps(toSeptember, setPeriodIndex, {\n      startAfter: 1600,\n      stepMs: 420,\n    }),\n    { after: 1200, run: hold },\n  ];\n  const { ref, stopOnKeyboardFocus } = useChartDemo(steps, {\n    loopAfter: 1800,\n    onReset: resetDemo,\n  });\n\n  return (\n    <div\n      ref={ref}\n      onFocus={stopOnKeyboardFocus}\n      className='flex min-h-[23.5rem] w-full max-w-[26rem]'\n    >\n      <GoalNumerals\n        title='Goals for the year'\n        note='Illustrative'\n        periods={MONTHS}\n        goals={GOALS}\n        periodIndex={periodIndex}\n        onPeriodIndexChange={setPeriodIndex}\n        periodLabel='Month'\n        className='w-full'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/chart-06.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/chart-card.tsx",
      "content": "import { useId } from 'react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Badge } from '@/registry/ndeko/ui/badge';\n\ntype ChartCardProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  /** One line that says what the chart shows, read by screen readers with the title. */\n  summary: string;\n  /** Controls that change what the chart draws: a period, a view. */\n  actions?: React.ReactNode;\n  className?: string;\n  children: React.ReactNode;\n};\n\n/**\n * The surface every chart sits on. It is a query container, so a chart lays itself out by the\n * room it has, and it is positioned, so the hidden table of a chart never widens the page.\n */\nexport function ChartCard({\n  title,\n  note,\n  summary,\n  actions,\n  className,\n  children,\n}: ChartCardProps) {\n  const titleId = useId();\n  const summaryId = useId();\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      aria-describedby={summaryId}\n      className={cn(\n        'bg-card hairline-ring ring-border @container relative flex flex-col gap-4 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]',\n        className\n      )}\n    >\n      <header className='flex min-h-7 flex-wrap items-center justify-between gap-x-3 gap-y-2'>\n        <div className='flex min-w-0 items-center gap-2'>\n          <h3 id={titleId} className='text-sm font-medium text-balance'>\n            {title}\n          </h3>\n          {note && <Badge variant='secondary'>{note}</Badge>}\n        </div>\n        {actions}\n      </header>\n      {children}\n      <p id={summaryId} className='sr-only'>\n        {summary}\n      </p>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/chart-card.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/data-table.tsx",
      "content": "export type DataRow = {\n  id: string;\n  /** The heading of the row: a day, a category, a step. */\n  label: string;\n  cells: readonly string[];\n};\n\ntype DataTableProps = {\n  caption: string;\n  /** The heading of the label column first, then one per cell. */\n  columns: readonly string[];\n  rows: readonly DataRow[];\n};\n\n/**\n * The numbers a chart draws, as a table for screen readers. It is taken out of the flow, so the\n * piece it sits in has to be positioned: a hidden box inside an area that scrolls sideways widens\n * the page otherwise.\n */\nexport function DataTable({ caption, columns, rows }: DataTableProps) {\n  return (\n    <table className='sr-only'>\n      <caption>{caption}</caption>\n      <thead>\n        <tr>\n          {columns.map((column) => (\n            <th key={column} scope='col'>\n              {column}\n            </th>\n          ))}\n        </tr>\n      </thead>\n      <tbody>\n        {rows.map((row) => (\n          <tr key={row.id}>\n            <th scope='row'>{row.label}</th>\n            {row.cells.map((cell, index) => (\n              <td key={index}>{cell}</td>\n            ))}\n          </tr>\n        ))}\n      </tbody>\n    </table>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/data-table.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/demo.ts",
      "content": "import type { AutoplayStep } from '@/registry/ndeko/hooks/use-autoplay';\nimport { keyDelayOf } from '@/registry/ndeko/lib/demonstration';\n\ntype WalkOptions = {\n  /** Milliseconds before the first position. */\n  startAfter?: number;\n  /** Milliseconds between two positions. */\n  stepMs?: number;\n};\n\n/** One step per position, so a reading crosses a plot the way a pointer does. */\nexport function walkSteps<Position>(\n  positions: readonly Position[],\n  moveTo: (position: Position) => void,\n  { startAfter = 700, stepMs = 240 }: WalkOptions = {}\n): AutoplayStep[] {\n  return positions.map((position, index) => {\n    const after = keyDelayOf(index, startAfter, stepMs);\n    return { after, run: () => moveTo(position) };\n  });\n}\n\n/** What a demonstration points at lets go the moment a person takes over. */\nexport function whileDemonstrating<Value>(\n  isDemonstrating: boolean,\n  value: Value | null\n) {\n  if (isDemonstrating) {\n    return value;\n  }\n  return null;\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/demo.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/scale.ts",
      "content": "export type Domain = {\n  min: number;\n  max: number;\n};\n\nexport type Scale = Domain & {\n  step: number;\n  ticks: number[];\n};\n\nconst NICE_FACTORS = [1, 2, 5, 10];\n\nconst EMPTY_DOMAIN: Domain = { min: 0, max: 0 };\n\nexport function clamp(value: number, min: number, max: number) {\n  const atLeastMin = Math.max(value, min);\n  return Math.min(atLeastMin, max);\n}\n\nexport function lerp(from: number, to: number, amount: number) {\n  const span = to - from;\n  return from + span * amount;\n}\n\nexport function sumOf(values: readonly number[]) {\n  let total = 0;\n  for (const value of values) {\n    total += value;\n  }\n  return total;\n}\n\nexport function extentOf(values: readonly number[]): Domain {\n  if (values.length === 0) {\n    return EMPTY_DOMAIN;\n  }\n  let min = values[0];\n  let max = values[0];\n  for (const value of values) {\n    min = Math.min(min, value);\n    max = Math.max(max, value);\n  }\n  return { min, max };\n}\n\n/** Bars and areas are read from zero, so their domain always holds it. */\nexport function withZero(domain: Domain): Domain {\n  const min = Math.min(domain.min, 0);\n  const max = Math.max(domain.max, 0);\n  return { min, max };\n}\n\n/** Where a value sits in a domain, from 0 at its lowest to 1 at its highest. */\nexport function ratioOf(value: number, domain: Domain) {\n  const span = domain.max - domain.min;\n  if (span === 0) {\n    return 0;\n  }\n  const offset = value - domain.min;\n  return offset / span;\n}\n\n/** A flat series still needs room above and below it to be drawn. */\nfunction spanOf(domain: Domain) {\n  const span = domain.max - domain.min;\n  if (span > 0) {\n    return span;\n  }\n  const size = Math.abs(domain.max);\n  return Math.max(size, 1);\n}\n\nfunction niceStepOf(roughStep: number) {\n  const logarithm = Math.log10(roughStep);\n  const exponent = Math.floor(logarithm);\n  const magnitude = 10 ** exponent;\n  const factor = NICE_FACTORS.find((candidate) => {\n    const candidateStep = candidate * magnitude;\n    return candidateStep >= roughStep;\n  });\n  const safeFactor = factor ?? 10;\n  return safeFactor * magnitude;\n}\n\nfunction decimalsOf(step: number) {\n  const logarithm = Math.log10(step);\n  const exponent = Math.floor(logarithm);\n  return Math.max(0, -exponent);\n}\n\n/** Adding zero turns a negative zero into zero, which a number format would write as -0. */\nfunction roundedTo(value: number, decimals: number) {\n  const factor = 10 ** decimals;\n  const scaled = Math.round(value * factor);\n  const rounded = scaled / factor;\n  return rounded + 0;\n}\n\n/** A scale with round ticks that holds the whole domain, in at most `maxLines` steps. */\nexport function niceScaleOf(domain: Domain, maxLines: number): Scale {\n  const span = spanOf(domain);\n  const roughStep = span / maxLines;\n  const step = niceStepOf(roughStep);\n  const decimals = decimalsOf(step);\n  const stepsBelow = Math.floor(domain.min / step);\n  const lowest = stepsBelow * step;\n  const highestWanted = Math.max(domain.max, lowest + step);\n  const stepsAbove = Math.ceil(highestWanted / step);\n  const lineCount = stepsAbove - stepsBelow;\n  const tickCount = lineCount + 1;\n  const ticks = Array.from({ length: tickCount }, (_, index) => {\n    const tick = lowest + index * step;\n    return roundedTo(tick, decimals);\n  });\n  const lastIndex = ticks.length - 1;\n  return { min: ticks[0], max: ticks[lastIndex], step, ticks };\n}\n\n/** Columns share the plot equally and a mark sits in the middle of its column. */\nexport function columnCenterOf(index: number, count: number) {\n  const center = index + 0.5;\n  return center / count;\n}\n\nexport function columnIndexAt(ratio: number, count: number) {\n  const rawIndex = Math.floor(ratio * count);\n  const lastIndex = Math.max(count - 1, 0);\n  return clamp(rawIndex, 0, lastIndex);\n}\n\nexport function percentOf(share: number) {\n  const percent = share * 100;\n  return `${percent}%`;\n}\n\n/** The whole numbers that lead from one position to another: the start is left out, the end is kept. */\nexport function positionsTo(from: number, to: number) {\n  const gap = to - from;\n  const distance = Math.abs(gap);\n  const direction = Math.sign(gap);\n  return Array.from({ length: distance }, (_, index) => {\n    const travelled = index + 1;\n    const offset = travelled * direction;\n    return from + offset;\n  });\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/scale.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/use-chart-demo.ts",
      "content": "'use client';\n\nimport {\n  useAutoplay,\n  type AutoplayOptions,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\n\n/**\n * What every chart demo shares: the demonstration, the box that keeps its tallest height so a round\n * never moves the page, and a stop when the keyboard brings focus in. A person who tabs to a plot\n * reads it with the arrow keys from there, and has pressed no key inside the piece yet.\n */\nexport function useChartDemo(\n  steps: readonly AutoplayStep[],\n  options?: AutoplayOptions\n) {\n  const demo = useAutoplay(steps, options);\n  const { ref, takeOver } = demo;\n\n  useKeptHeight(ref);\n\n  function stopOnKeyboardFocus(event: React.FocusEvent<HTMLElement>) {\n    const isKeyboardFocus = event.target.matches(':focus-visible');\n    if (isKeyboardFocus) {\n      takeOver();\n    }\n  }\n\n  return { ...demo, stopOnKeyboardFocus };\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/use-chart-demo.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/use-step-drag.ts",
      "content": "'use client';\n\nimport { useRef, useState } from 'react';\n\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\n\nimport { clamp, lerp, positionsTo } from './scale';\n\n/** `columns` share the track equally and the handle sits in the middle of one; `edges` are the lines between columns, both ends included. */\ntype Placement = 'columns' | 'edges';\n\ntype StepDragOptions = {\n  /** How many positions the handle can rest on. */\n  count: number;\n  index: number;\n  onIndexChange: (index: number) => void;\n  placement?: Placement;\n};\n\ntype Sample = {\n  time: number;\n  position: number;\n};\n\n/** After the hand lets go, the handle travels as far as its speed would carry it in this time. */\nconst MOMENTUM_MS = 140;\n\n/** A hand that stopped this long before letting go carries nothing. */\nconst STILL_AFTER_MS = 90;\n\nconst FIRST_COAST_MS = 36;\n\n/** Each position of a coast takes this much longer than the one before, so the handle slows to a stop. */\nconst COAST_SLOWDOWN = 1.28;\n\nconst PAGE_STEP = 3;\n\n/** How much of the newest reading of the speed is kept: one sample alone is too jumpy. */\nconst SPEED_SMOOTHING = 0.6;\n\nfunction positionFrom(\n  event: React.PointerEvent<HTMLElement>,\n  count: number,\n  placement: Placement\n) {\n  const box = event.currentTarget.getBoundingClientRect();\n  const offset = event.clientX - box.left;\n  const ratio = offset / box.width;\n  if (placement === 'edges') {\n    const lastEdge = count - 1;\n    return ratio * lastEdge;\n  }\n  const column = ratio * count;\n  return column - 0.5;\n}\n\nfunction keyTargetOf(key: string, index: number, lastIndex: number) {\n  const isForward = key === 'ArrowRight' || key === 'ArrowUp';\n  const isBackward = key === 'ArrowLeft' || key === 'ArrowDown';\n  if (isForward) {\n    return index + 1;\n  }\n  if (isBackward) {\n    return index - 1;\n  }\n  if (key === 'PageUp') {\n    return index + PAGE_STEP;\n  }\n  if (key === 'PageDown') {\n    return index - PAGE_STEP;\n  }\n  if (key === 'Home') {\n    return 0;\n  }\n  if (key === 'End') {\n    return lastIndex;\n  }\n  return null;\n}\n\n/**\n * A handle that moves from one position to the next under a hand: it catches on each position, it\n * keeps going for a moment when it is flicked, and the arrow keys move it one position at a time.\n * Spread `sliderProps` on the track, which is the one tab stop.\n */\nexport function useStepDrag({\n  count,\n  index,\n  onIndexChange,\n  placement = 'columns',\n}: StepDragOptions) {\n  const { schedule, clearAll } = useTimeouts();\n  const [isDragging, setIsDragging] = useState(false);\n  const lastSample = useRef<Sample | null>(null);\n  const speed = useRef(0);\n  const lastIndex = Math.max(count - 1, 0);\n\n  function indexAt(position: number) {\n    const nearest = Math.round(position);\n    return clamp(nearest, 0, lastIndex);\n  }\n\n  function moveTo(next: number) {\n    if (next !== index) {\n      onIndexChange(next);\n    }\n  }\n\n  /** Goes to a position through every one on the way, each a little later than the last. */\n  function coastTo(target: number) {\n    clearAll();\n    const end = clamp(target, 0, lastIndex);\n    const stops = positionsTo(index, end);\n    let delay = 0;\n    let pace = FIRST_COAST_MS;\n    for (const stop of stops) {\n      delay += pace;\n      pace *= COAST_SLOWDOWN;\n      schedule(() => onIndexChange(stop), delay);\n    }\n  }\n\n  function startDrag(event: React.PointerEvent<HTMLElement>) {\n    if (event.button !== 0) {\n      return;\n    }\n    clearAll();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const position = positionFrom(event, count, placement);\n    lastSample.current = { time: event.timeStamp, position };\n    speed.current = 0;\n    setIsDragging(true);\n    const next = indexAt(position);\n    moveTo(next);\n  }\n\n  function drag(event: React.PointerEvent<HTMLElement>) {\n    const previous = lastSample.current;\n    if (!previous) {\n      return;\n    }\n    const position = positionFrom(event, count, placement);\n    const elapsed = event.timeStamp - previous.time;\n    if (elapsed > 0) {\n      const moved = position - previous.position;\n      const latest = moved / elapsed;\n      speed.current = lerp(speed.current, latest, SPEED_SMOOTHING);\n    }\n    lastSample.current = { time: event.timeStamp, position };\n    const next = indexAt(position);\n    moveTo(next);\n  }\n\n  function endDrag(event: React.PointerEvent<HTMLElement>) {\n    const previous = lastSample.current;\n    if (!previous) {\n      return;\n    }\n    lastSample.current = null;\n    setIsDragging(false);\n    const stillFor = event.timeStamp - previous.time;\n    if (stillFor > STILL_AFTER_MS) {\n      return;\n    }\n    const carried = Math.round(speed.current * MOMENTUM_MS);\n    if (carried !== 0) {\n      coastTo(index + carried);\n    }\n  }\n\n  function cancelDrag() {\n    lastSample.current = null;\n    setIsDragging(false);\n  }\n\n  function pressKey(event: React.KeyboardEvent<HTMLElement>) {\n    const target = keyTargetOf(event.key, index, lastIndex);\n    if (target === null) {\n      return;\n    }\n    event.preventDefault();\n    clearAll();\n    const next = clamp(target, 0, lastIndex);\n    moveTo(next);\n  }\n\n  const sliderProps = {\n    onPointerDown: startDrag,\n    onPointerMove: drag,\n    onPointerUp: endDrag,\n    onPointerCancel: cancelDrag,\n    onKeyDown: pressKey,\n  };\n\n  return { isDragging, flickTo: coastTo, sliderProps };\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/use-step-drag.ts"
    }
  ],
  "categories": [
    "components",
    "chart"
  ],
  "type": "registry:component"
}