{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-13",
  "title": "Flow funnel",
  "description": "A funnel drawn as one smooth shape whose edges you pull, by hand or with the arrow keys, to try a better rate: everything downstream swells with it, the headline counts the customers gained, and the edge catches on the real rate on the way back.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/badge.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/motion-presets.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-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/chart/chart-13.tsx",
      "content": "'use client';\n\nimport { useRef, 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 {\n  hold,\n  PRESS_LEAD_MS,\n  pressedQuietOf,\n  usePress,\n} from '@/registry/ndeko/lib/demonstration';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\n\nimport { ChartCard } from './_parts/chart-card';\nimport { DataTable, type DataRow } from './_parts/data-table';\nimport { walkSteps } from './_parts/demo';\nimport {\n  COUNT_FORMAT,\n  formatCount,\n  formatSigned,\n  SIGNED_FORMAT,\n} from './_parts/format';\nimport { MorphPath } from './_parts/morph-path';\nimport { VIEW_WIDTH } from './_parts/path';\nimport { PathLayer } from './_parts/plot';\nimport { clamp, columnCenterOf, percentOf, positionsTo } from './_parts/scale';\nimport { useChartDemo } from './_parts/use-chart-demo';\n\nexport type FunnelStep = {\n  id: string;\n  name: string;\n  /** The share of the step before that reaches this one, in percent. The first step is where everyone enters: its rate is not read. */\n  rate: number;\n};\n\n/** The rate tried for a step, in percent, by step id. A step that is not listed keeps its real rate. */\nexport type FunnelTrial = Readonly<Record<string, number>>;\n\nexport type FlowFunnelProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  /** How many enter the first step. */\n  entrants: number;\n  steps: readonly FunnelStep[];\n  trial: FunnelTrial;\n  onTrialChange: (trial: FunnelTrial) => void;\n  /** What comes out of the last step, in the plural with its period: \"customers a month\". */\n  outcome: string;\n  /** Shows Reset held down, for a demonstration that presses it. */\n  isResetPressed?: boolean;\n  className?: string;\n};\n\ntype Level = {\n  step: FunnelStep;\n  realRate: number;\n  rate: number;\n  realCount: number;\n  count: number;\n};\n\nconst PLOT_HEIGHT = 150;\n\nconst CENTRE = PLOT_HEIGHT / 2;\n\n/** The share of a column, on each of its sides, that the curve to the next column takes. */\nconst JOIN = 0.26;\n\n/** How far a rate can be tried below and above the real one, in points. */\nconst ROOM_BELOW = 15;\n\nconst ROOM_ABOVE = 25;\n\n/** A rate within this many points of the real one catches on it, so the way back is easy to find. */\nconst DETENT = 1;\n\nconst PAGE_STEP = 5;\n\n/**\n * A point of rate takes this much of a drag on every edge. The edges of the later steps sit a few\n * pixels apart across their whole range, so a drag read against the shape would jump several points\n * a pixel there and the catch at the real rate could not be felt.\n */\nconst PX_PER_POINT = 2;\n\nconst NO_TRIAL: FunnelTrial = {};\n\nfunction rateOf(step: FunnelStep, trial: FunnelTrial) {\n  return trial[step.id] ?? step.rate;\n}\n\n/** How many of those who enter a step get through it at a rate in percent. */\nfunction passedOf(entering: number, rate: number) {\n  const share = rate / 100;\n  return entering * share;\n}\n\nfunction levelsOf(\n  entrants: number,\n  steps: readonly FunnelStep[],\n  trial: FunnelTrial\n) {\n  const levels: Level[] = [];\n  let realCount = entrants;\n  let count = entrants;\n  for (const [index, step] of steps.entries()) {\n    const rate = rateOf(step, trial);\n    if (index > 0) {\n      realCount = passedOf(realCount, step.rate);\n      count = passedOf(count, rate);\n    }\n    levels.push({\n      step,\n      realRate: step.rate,\n      rate,\n      realCount,\n      count,\n    });\n  }\n  return levels;\n}\n\nfunction limitsOf(step: FunnelStep) {\n  const min = Math.max(step.rate - ROOM_BELOW, 1);\n  const max = Math.min(step.rate + ROOM_ABOVE, 100);\n  return { min, max };\n}\n\n/** A whole rate inside the limits of its step, caught by the real rate when it is close to it. */\nfunction settledRateOf(step: FunnelStep, wanted: number) {\n  const limits = limitsOf(step);\n  const whole = Math.round(wanted);\n  const distance = Math.abs(whole - step.rate);\n  if (distance <= DETENT) {\n    return step.rate;\n  }\n  return clamp(whole, limits.min, limits.max);\n}\n\n/** The trial with one rate changed. A rate back at its real value leaves the trial. */\nfunction trialWith(trial: FunnelTrial, step: FunnelStep, rate: number) {\n  const next: Record<string, number> = { ...trial };\n  if (rate === step.rate) {\n    delete next[step.id];\n    return next;\n  }\n  next[step.id] = rate;\n  return next;\n}\n\nfunction topOf(share: number) {\n  const half = share * CENTRE;\n  return CENTRE - half;\n}\n\nfunction bottomOf(share: number) {\n  const half = share * CENTRE;\n  return CENTRE + half;\n}\n\nfunction xy(x: number, y: number) {\n  const left = x.toFixed(1);\n  const top = y.toFixed(1);\n  return `${left} ${top}`;\n}\n\n/** A curve from the level of one column to the level of its neighbour, across the line between them. */\nfunction joinTo(boundary: number, fromY: number, toX: number, toY: number) {\n  const leaving = xy(boundary, fromY);\n  const arriving = xy(boundary, toY);\n  const end = xy(toX, toY);\n  return `C${leaving} ${arriving} ${end}`;\n}\n\nfunction lineTo(x: number, y: number) {\n  const end = xy(x, y);\n  return `L${end}`;\n}\n\n/**\n * One closed shape through every step: flat across each column, curved between two columns. The\n * top edge runs left to right and the bottom edge comes back, with the same number of points\n * whatever the shares, so one funnel can glide into another.\n */\nfunction funnelPathOf(shares: readonly number[]) {\n  const count = shares.length;\n  if (count === 0) {\n    return '';\n  }\n  const column = VIEW_WIDTH / count;\n  const join = column * JOIN;\n  const lastIndex = count - 1;\n  const firstTop = topOf(shares[0]);\n  const start = xy(0, firstTop);\n  const commands = [`M${start}`];\n  for (let index = 0; index < count; index += 1) {\n    const top = topOf(shares[index]);\n    const isLast = index === lastIndex;\n    const columnEnd = (index + 1) * column;\n    const flatEnd = columnEnd - joinOf(isLast, join);\n    commands.push(lineTo(flatEnd, top));\n    if (!isLast) {\n      const nextTop = topOf(shares[index + 1]);\n      commands.push(joinTo(columnEnd, top, columnEnd + join, nextTop));\n    }\n  }\n  const lastBottom = bottomOf(shares[lastIndex]);\n  commands.push(lineTo(VIEW_WIDTH, lastBottom));\n  for (let index = lastIndex; index >= 0; index -= 1) {\n    const bottom = bottomOf(shares[index]);\n    const isFirst = index === 0;\n    const columnStart = index * column;\n    const flatStart = columnStart + joinOf(isFirst, join);\n    commands.push(lineTo(flatStart, bottom));\n    if (!isFirst) {\n      const previousBottom = bottomOf(shares[index - 1]);\n      commands.push(\n        joinTo(columnStart, bottom, columnStart - join, previousBottom)\n      );\n    }\n  }\n  commands.push('Z');\n  return commands.join(' ');\n}\n\n/** The first and the last column run flat to the edge of the plot: nothing joins them there. */\nfunction joinOf(isAtEdge: boolean, join: number) {\n  if (isAtEdge) {\n    return 0;\n  }\n  return join;\n}\n\nfunction keyTargetOf(key: string, rate: number, step: FunnelStep) {\n  const limits = limitsOf(step);\n  const isUp = key === 'ArrowUp' || key === 'ArrowRight';\n  const isDown = key === 'ArrowDown' || key === 'ArrowLeft';\n  if (isUp) {\n    return rate + 1;\n  }\n  if (isDown) {\n    return rate - 1;\n  }\n  if (key === 'PageUp') {\n    return rate + PAGE_STEP;\n  }\n  if (key === 'PageDown') {\n    return rate - PAGE_STEP;\n  }\n  if (key === 'Home') {\n    return limits.min;\n  }\n  if (key === 'End') {\n    return limits.max;\n  }\n  return null;\n}\n\ntype EdgeHandleProps = {\n  level: Level;\n  /** The share of everyone who entered that reaches this step, and where its column sits. */\n  share: number;\n  columnRatio: number;\n  /** True while any edge follows a hand: the handles then move with it, with no glide behind. */\n  isFollowing: boolean;\n  onRateChange: (step: FunnelStep, rate: number) => void;\n  onDraggingChange: (isDragging: boolean) => void;\n};\n\ntype DragStart = {\n  y: number;\n  rate: number;\n};\n\nfunction handleGlideOf(isFollowing: boolean) {\n  if (isFollowing) {\n    return undefined;\n  }\n  return 'transition-transform duration-300 ease-(--ease-out-strong)';\n}\n\n/** The edge of a step as a slider: dragged up it widens the step, which is a better rate. */\nfunction EdgeHandle({\n  level,\n  share,\n  columnRatio,\n  isFollowing,\n  onRateChange,\n  onDraggingChange,\n}: EdgeHandleProps) {\n  const dragStart = useRef<DragStart | null>(null);\n  const limits = limitsOf(level.step);\n  const style = {\n    left: percentOf(columnRatio),\n    transform: `translateY(${topOf(share)}px)`,\n  };\n\n  function rateAt(event: React.PointerEvent<HTMLElement>, start: DragStart) {\n    const raised = start.y - event.clientY;\n    const points = raised / PX_PER_POINT;\n    const wanted = start.rate + points;\n    return settledRateOf(level.step, wanted);\n  }\n\n  function startDrag(event: React.PointerEvent<HTMLElement>) {\n    if (event.button !== 0) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    dragStart.current = { y: event.clientY, rate: level.rate };\n    onDraggingChange(true);\n  }\n\n  function drag(event: React.PointerEvent<HTMLElement>) {\n    const start = dragStart.current;\n    if (!start) {\n      return;\n    }\n    const rate = rateAt(event, start);\n    if (rate !== level.rate) {\n      onRateChange(level.step, rate);\n    }\n  }\n\n  function endDrag() {\n    if (!dragStart.current) {\n      return;\n    }\n    dragStart.current = null;\n    onDraggingChange(false);\n  }\n\n  function pressKey(event: React.KeyboardEvent<HTMLElement>) {\n    const target = keyTargetOf(event.key, level.rate, level.step);\n    if (target === null) {\n      return;\n    }\n    event.preventDefault();\n    const rate = clamp(target, limits.min, limits.max);\n    onRateChange(level.step, rate);\n  }\n\n  return (\n    <span\n      role='slider'\n      tabIndex={0}\n      aria-label={`${level.step.name} rate`}\n      aria-orientation='vertical'\n      aria-valuemin={limits.min}\n      aria-valuemax={limits.max}\n      aria-valuenow={level.rate}\n      aria-valuetext={`${level.rate}%, the real rate is ${level.realRate}%`}\n      onPointerDown={startDrag}\n      onPointerMove={drag}\n      onPointerUp={endDrag}\n      onPointerCancel={endDrag}\n      onKeyDown={pressKey}\n      className={cn(\n        'group/handle absolute top-0 flex size-7 -translate-1/2 cursor-ns-resize touch-none items-center justify-center rounded-full outline-none',\n        handleGlideOf(isFollowing)\n      )}\n      style={style}\n    >\n      <span className='bg-card ring-foreground/40 group-hover/handle:ring-foreground/70 group-focus-visible/handle:ring-ring size-3 rounded-full shadow-[0_1px_2px_rgb(0_0_0/0.2)] ring-2 transition-[box-shadow,scale] duration-150 group-focus-visible/handle:ring-[3px] group-active/handle:scale-110' />\n    </span>\n  );\n}\n\nfunction wholeCountOf(level: Level) {\n  const whole = Math.round(level.count);\n  return formatCount(whole);\n}\n\nfunction rateTextOf(level: Level) {\n  if (level.rate === level.realRate) {\n    return `${level.rate}%`;\n  }\n  return `${level.rate}%, was ${level.realRate}%`;\n}\n\ntype HeadlineProps = {\n  gain: number;\n  realOutcome: number;\n  trialOutcome: number;\n  outcome: string;\n};\n\nfunction Headline({ gain, realOutcome, trialOutcome, outcome }: HeadlineProps) {\n  const isTrying = gain !== 0;\n  const real = formatCount(realOutcome);\n  const tried = formatCount(trialOutcome);\n\n  return (\n    <div className='flex flex-col gap-0.5'>\n      <p className='flex flex-wrap items-baseline gap-x-1.5'>\n        {isTrying && (\n          <MotionNumber\n            value={gain}\n            format={SIGNED_FORMAT}\n            className='text-2xl leading-8 font-semibold'\n          />\n        )}\n        {!isTrying && (\n          <MotionNumber\n            value={realOutcome}\n            format={COUNT_FORMAT}\n            className='text-2xl leading-8 font-semibold'\n          />\n        )}\n        <span className='text-muted-foreground text-[13px]'>{outcome}</span>\n      </p>\n      <p className='text-muted-foreground text-xs tabular-nums'>\n        {isTrying && <WordRoll text={`${tried} instead of ${real}`} />}\n        {!isTrying && 'At the real rates. Pull an edge to try a better one'}\n      </p>\n    </div>\n  );\n}\n\nexport function FlowFunnel({\n  title,\n  note,\n  entrants,\n  steps,\n  trial,\n  onTrialChange,\n  outcome,\n  isResetPressed = false,\n  className,\n}: FlowFunnelProps) {\n  const [isDragging, setIsDragging] = useState(false);\n  const [spoken, setSpoken] = useState('');\n  const levels = levelsOf(entrants, steps, trial);\n  const safeEntrants = Math.max(entrants, 1);\n  const realShares = levels.map((level) => level.realCount / safeEntrants);\n  const shares = levels.map((level) => level.count / safeEntrants);\n  const last = levels.at(-1);\n  const realOutcome = Math.round(last?.realCount ?? 0);\n  const trialOutcome = Math.round(last?.count ?? 0);\n  const gain = trialOutcome - realOutcome;\n  const isTrying = Object.keys(trial).length > 0;\n  const columnCount = Math.max(steps.length, 1);\n  const columns = {\n    gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,\n  };\n  const outcomeText = formatCount(trialOutcome);\n  const entrantText = formatCount(entrants);\n  const gainText = formatSigned(gain);\n  const summary = `${outcomeText} ${outcome} from ${entrantText} who enter, ${gainText} against the real rates.`;\n\n  /** The outcome is said when a person lets go of an edge or moves it with a key, never on every point of a drag. */\n  function say(nextTrial: FunnelTrial) {\n    const nextLevels = levelsOf(entrants, steps, nextTrial);\n    const nextLast = nextLevels.at(-1);\n    const nextOutcome = Math.round(nextLast?.count ?? 0);\n    const nextGain = formatSigned(nextOutcome - realOutcome);\n    setSpoken(`${nextGain} ${outcome}`);\n  }\n\n  function changeRate(step: FunnelStep, rate: number) {\n    const nextTrial = trialWith(trial, step, rate);\n    onTrialChange(nextTrial);\n    if (!isDragging) {\n      say(nextTrial);\n    }\n  }\n\n  function changeDragging(next: boolean) {\n    setIsDragging(next);\n    if (!next) {\n      say(trial);\n    }\n  }\n\n  function reset() {\n    onTrialChange(NO_TRIAL);\n    say(NO_TRIAL);\n  }\n\n  const rows: DataRow[] = levels.map((level) => ({\n    id: level.step.id,\n    label: level.step.name,\n    cells: [wholeCountOf(level), `${level.rate}%`, `${level.realRate}%`],\n  }));\n\n  return (\n    <ChartCard\n      title={title}\n      note={note}\n      summary={summary}\n      className={className}\n      actions={\n        <Button\n          type='button'\n          variant='ghost'\n          size='sm'\n          disabled={!isTrying}\n          onClick={reset}\n          className={pressedQuietOf(isResetPressed)}\n        >\n          Reset\n        </Button>\n      }\n    >\n      <Headline\n        gain={gain}\n        realOutcome={realOutcome}\n        trialOutcome={trialOutcome}\n        outcome={outcome}\n      />\n      <div className='flex flex-col gap-2'>\n        <div className='relative' style={{ height: PLOT_HEIGHT }}>\n          <PathLayer height={PLOT_HEIGHT}>\n            <MorphPath\n              d={funnelPathOf(realShares)}\n              className='fill-foreground/[0.07]'\n            />\n            <MorphPath\n              d={funnelPathOf(shares)}\n              isInstant={isDragging}\n              className='fill-primary/25'\n            />\n          </PathLayer>\n          {levels.map((level, index) => {\n            if (index === 0) {\n              return null;\n            }\n            return (\n              <EdgeHandle\n                key={level.step.id}\n                level={level}\n                share={shares[index]}\n                columnRatio={columnCenterOf(index, levels.length)}\n                isFollowing={isDragging}\n                onRateChange={changeRate}\n                onDraggingChange={changeDragging}\n              />\n            );\n          })}\n        </div>\n        <ol className='flex flex-col gap-1 @sm:grid' style={columns}>\n          {levels.map((level, index) => (\n            <li\n              key={level.step.id}\n              className='grid min-w-0 grid-cols-[minmax(0,1fr)_auto_6rem] items-baseline gap-x-2 @sm:flex @sm:flex-col @sm:items-center @sm:text-center'\n            >\n              <span className='text-muted-foreground truncate text-xs @sm:w-full'>\n                {level.step.name}\n              </span>\n              <MotionNumber\n                value={Math.round(level.count)}\n                format={COUNT_FORMAT}\n                className='text-[13px] font-medium'\n              />\n              {index > 0 && (\n                <span className='text-muted-foreground text-end text-[11px] text-pretty tabular-nums @sm:text-center'>\n                  {rateTextOf(level)}\n                </span>\n              )}\n            </li>\n          ))}\n        </ol>\n      </div>\n      <LiveRegion message={spoken} />\n      <DataTable\n        caption={title}\n        columns={['Step', 'Count', 'Rate tried', 'Real rate']}\n        rows={rows}\n      />\n    </ChartCard>\n  );\n}\n\nconst STEPS: FunnelStep[] = [\n  { id: 'visitors', name: 'Visitors', rate: 100 },\n  { id: 'signups', name: 'Signups', rate: 55 },\n  { id: 'activated', name: 'Activated', rate: 65 },\n  { id: 'trials', name: 'Trials', rate: 60 },\n  { id: 'customers', name: 'Customers', rate: 50 },\n];\n\nconst VISITORS = 4000;\n\n/** The picture the piece rests on: one edge already pulled, so the gain is on the page. */\nconst RESTING_TRIAL: FunnelTrial = { activated: 72 };\n\nconst PULL_STEP_MS = 110;\n\ntype PressKey = 'reset';\n\nexport default function Chart13() {\n  const [trial, setTrial] = useState<FunnelTrial>(RESTING_TRIAL);\n  const { pressed, press } = usePress<PressKey>();\n\n  function tryRate(stepId: string) {\n    return function tryAt(rate: number) {\n      setTrial((current) => ({ ...current, [stepId]: rate }));\n    };\n  }\n\n  function resetDemo() {\n    setTrial(RESTING_TRIAL);\n  }\n\n  const steps: AutoplayStep[] = [\n    { after: 2000, run: () => press('reset') },\n    { after: PRESS_LEAD_MS, run: () => setTrial(NO_TRIAL) },\n    ...walkSteps(positionsTo(65, 74), tryRate('activated'), {\n      startAfter: 1700,\n      stepMs: PULL_STEP_MS,\n    }),\n    ...walkSteps(positionsTo(50, 57), tryRate('customers'), {\n      startAfter: 2000,\n      stepMs: PULL_STEP_MS,\n    }),\n    { after: 2400, run: hold },\n  ];\n  const { ref, stopOnKeyboardFocus } = useChartDemo(steps, {\n    loopAfter: 1200,\n    onReset: resetDemo,\n  });\n\n  return (\n    <div\n      ref={ref}\n      onFocus={stopOnKeyboardFocus}\n      className='flex min-h-[23rem] w-full max-w-[34rem]'\n    >\n      <FlowFunnel\n        title='From visitor to customer'\n        note='Illustrative'\n        entrants={VISITORS}\n        steps={STEPS}\n        trial={trial}\n        onTrialChange={setTrial}\n        outcome='customers a month'\n        isResetPressed={pressed === 'reset'}\n        className='w-full'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/chart-13.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/axis.tsx",
      "content": "import { cn } from '@/registry/ndeko/lib/utils';\n\nimport { yOf } from './path';\nimport { columnCenterOf, percentOf, type Scale } from './scale';\n\n/** The least room one label of the category axis needs, text and breathing space. */\nexport const LABEL_ROOM_PX = 56;\n\ntype ValueAxisProps = {\n  scale: Scale;\n  height: number;\n  format: (value: number) => string;\n  className?: string;\n};\n\n/** The figures of the value axis, one per gridline, in a column beside the plot. */\nexport function ValueAxis({\n  scale,\n  height,\n  format,\n  className,\n}: ValueAxisProps) {\n  return (\n    <div\n      aria-hidden='true'\n      className={cn('relative w-9 shrink-0', className)}\n      style={{ height }}\n    >\n      {scale.ticks.map((tick) => (\n        <span\n          key={tick}\n          className='text-muted-foreground absolute right-0 -translate-y-1/2 text-[11px] tabular-nums'\n          style={{ top: yOf(tick, scale, height) }}\n        >\n          {format(tick)}\n        </span>\n      ))}\n    </div>\n  );\n}\n\n/** Zero is drawn stronger when values go below it, since bars start there. */\nexport function gridToneOf(tick: number, scale: Scale) {\n  const isZero = tick === 0;\n  const crossesZero = scale.min < 0;\n  if (isZero && crossesZero) {\n    return 'border-foreground/25';\n  }\n  return 'border-border';\n}\n\ntype GridLinesProps = {\n  scale: Scale;\n  height: number;\n};\n\nexport function GridLines({ scale, height }: GridLinesProps) {\n  return (\n    <div aria-hidden='true' className='pointer-events-none absolute inset-0'>\n      {scale.ticks.map((tick) => (\n        <span\n          key={tick}\n          className={cn(\n            'hairline-t absolute inset-x-0',\n            gridToneOf(tick, scale)\n          )}\n          style={{ top: yOf(tick, scale, height) }}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport type AxisLabel = {\n  id: string;\n  text: string;\n  /** From 0 at the left edge of the plot to 1 at its right edge. */\n  ratio: number;\n  isStrong?: boolean;\n};\n\n/** Every how many positions a label is written, so the labels that are written never touch. */\nexport function labelStepOf(count: number, plotWidth: number) {\n  const fitting = Math.floor(plotWidth / LABEL_ROOM_PX);\n  const labelCount = Math.max(fitting, 1);\n  const step = Math.ceil(count / labelCount);\n  return Math.max(step, 1);\n}\n\n/** The labels kept when one in `step` is written, counted back from the last so the latest is always named. */\nexport function spacedLabelsOf(labels: readonly AxisLabel[], step: number) {\n  const lastIndex = labels.length - 1;\n  return labels.filter((_, index) => {\n    const fromLast = lastIndex - index;\n    return fromLast % step === 0;\n  });\n}\n\n/**\n * One label per position, under the middle of its column, written as often as the room allows. The\n * one at `strongIndex`, such as today, is set stronger.\n */\nexport function positionLabelsOf(\n  labels: readonly string[],\n  plotWidth: number,\n  strongIndex?: number\n) {\n  const all: AxisLabel[] = labels.map((text, index) => ({\n    id: `${index}-${text}`,\n    text,\n    ratio: columnCenterOf(index, labels.length),\n    isStrong: index === strongIndex,\n  }));\n  const step = labelStepOf(labels.length, plotWidth);\n  return spacedLabelsOf(all, step);\n}\n\nfunction strongOf(isStrong: boolean | undefined) {\n  if (isStrong) {\n    return 'text-foreground font-medium';\n  }\n  return 'text-muted-foreground';\n}\n\ntype LabelAlign = 'spread' | 'center';\n\n/**\n * A spread label slides from its left edge to its right edge as it crosses the plot, like the\n * readout, so the first and the last never stick out of the piece. A centred label sits under its\n * mark, for the few wide columns of a bar chart.\n */\nfunction labelStyleOf(\n  label: AxisLabel,\n  align: LabelAlign\n): React.CSSProperties {\n  const left = percentOf(label.ratio);\n  if (align === 'center') {\n    return { left, transform: 'translateX(-50%)' };\n  }\n  const shift = percentOf(-label.ratio);\n  return { left, transform: `translateX(${shift})` };\n}\n\ntype CategoryAxisProps = {\n  labels: readonly AxisLabel[];\n  align?: LabelAlign;\n  className?: string;\n};\n\nexport function CategoryAxis({\n  labels,\n  align = 'spread',\n  className,\n}: CategoryAxisProps) {\n  return (\n    <div aria-hidden='true' className={cn('relative h-4', className)}>\n      {labels.map((label) => (\n        <span\n          key={label.id}\n          className={cn(\n            'absolute top-0 text-[11px] whitespace-nowrap tabular-nums',\n            strongOf(label.isStrong)\n          )}\n          style={labelStyleOf(label, align)}\n        >\n          {label.text}\n        </span>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/axis.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/format.ts",
      "content": "const LOCALE = 'en-US';\n\nexport const COUNT_FORMAT: Intl.NumberFormatOptions = {\n  maximumFractionDigits: 0,\n};\n\nexport const SIGNED_FORMAT: Intl.NumberFormatOptions = {\n  maximumFractionDigits: 0,\n  signDisplay: 'exceptZero',\n};\n\nexport const USD_COMPACT_FORMAT: Intl.NumberFormatOptions = {\n  style: 'currency',\n  currency: 'USD',\n  notation: 'compact',\n  maximumFractionDigits: 1,\n};\n\nconst COMPACT_FORMAT: Intl.NumberFormatOptions = {\n  notation: 'compact',\n  maximumFractionDigits: 1,\n};\n\nconst COUNT = new Intl.NumberFormat(LOCALE, COUNT_FORMAT);\n\nconst SIGNED = new Intl.NumberFormat(LOCALE, SIGNED_FORMAT);\n\nconst COMPACT = new Intl.NumberFormat(LOCALE, COMPACT_FORMAT);\n\nconst USD_COMPACT = new Intl.NumberFormat(LOCALE, USD_COMPACT_FORMAT);\n\nconst DAY = new Intl.DateTimeFormat(LOCALE, {\n  month: 'short',\n  day: 'numeric',\n  timeZone: 'UTC',\n});\n\nconst MS_PER_DAY = 86_400_000;\n\nexport function formatCount(value: number) {\n  return COUNT.format(value);\n}\n\nexport function formatSigned(value: number) {\n  return SIGNED.format(value);\n}\n\nexport function formatCompact(value: number) {\n  return COMPACT.format(value);\n}\n\nexport function formatUsd(value: number) {\n  return USD_COMPACT.format(value);\n}\n\nexport function formatShare(share: number) {\n  const percent = Math.round(share * 100);\n  return `${percent}%`;\n}\n\n/** A start at zero has no percentage to grow from: it reads as no change. */\nexport function percentChangeOf(current: number, previous: number) {\n  if (previous === 0) {\n    return 0;\n  }\n  const difference = current - previous;\n  const ratio = difference / Math.abs(previous);\n  return Math.round(ratio * 100);\n}\n\nexport function changeWordsOf(percent: number, reference: string) {\n  if (percent === 0) {\n    return `level with ${reference}`;\n  }\n  const size = Math.abs(percent);\n  if (percent > 0) {\n    return `up ${size}% on ${reference}`;\n  }\n  return `down ${size}% on ${reference}`;\n}\n\nexport function nounOf(count: number, singular: string, plural: string) {\n  if (count === 1) {\n    return singular;\n  }\n  return plural;\n}\n\n/**\n * One label per day, or per `everyDays` days for a weekly series. Days are counted in UTC so the\n * server and the browser write the same label.\n */\nexport function dayLabelsOf(firstDay: string, count: number, everyDays = 1) {\n  const start = Date.parse(`${firstDay}T00:00:00Z`);\n  const stride = everyDays * MS_PER_DAY;\n  return Array.from({ length: count }, (_, index) => {\n    const elapsed = index * stride;\n    return DAY.format(start + elapsed);\n  });\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/format.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/morph-path.tsx",
      "content": "'use client';\n\nimport { motion, type Transition } from 'motion/react';\nimport { useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { EASE_OUT_STRONG, INSTANT } from '@/registry/ndeko/lib/motion-presets';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nconst MORPH: Transition = { duration: 0.3, ease: EASE_OUT_STRONG };\n\n/** What is there on mount does not fade in: only a shape that replaced another one does. */\nconst SWAP_FADE =\n  'transition-opacity duration-200 ease-(--ease-out-strong) starting:opacity-0';\n\nfunction transitionOf(isInstant: boolean) {\n  if (isInstant) {\n    return INSTANT;\n  }\n  return MORPH;\n}\n\nfunction swapFadeOf(hasChanged: boolean) {\n  if (hasChanged) {\n    return SWAP_FADE;\n  }\n  return undefined;\n}\n\n/** False until the value changes for the first time. */\nfunction useHasChanged(value: string) {\n  const [firstValue] = useState(value);\n  const [hasChanged, setHasChanged] = useState(false);\n  const isFirstChange = value !== firstValue && !hasChanged;\n  if (isFirstChange) {\n    setHasChanged(true);\n  }\n  return hasChanged;\n}\n\ntype MorphPathProps = {\n  d: string;\n  /** True while the shape follows a hand: it is redrawn where the hand is, with no glide behind it. */\n  isInstant?: boolean;\n  className?: string;\n  style?: React.CSSProperties;\n  /** For a shape that is its own hit target. */\n  onPointerEnter?: () => void;\n};\n\n/**\n * A shape that glides to its new outline when the data changes, as long as both outlines have the\n * same number of points. Under reduced motion the new shape replaces the old one with a fade.\n */\nexport function MorphPath({\n  d,\n  isInstant = false,\n  className,\n  style,\n  onPointerEnter,\n}: MorphPathProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const hasChanged = useHasChanged(d);\n\n  if (shouldReduceMotion) {\n    return (\n      <path\n        key={d}\n        d={d}\n        vectorEffect='non-scaling-stroke'\n        className={cn(swapFadeOf(hasChanged), className)}\n        style={style}\n        onPointerEnter={onPointerEnter}\n      />\n    );\n  }\n\n  return (\n    <motion.path\n      initial={false}\n      animate={{ d }}\n      transition={transitionOf(isInstant)}\n      vectorEffect='non-scaling-stroke'\n      className={className}\n      style={style}\n      onPointerEnter={onPointerEnter}\n    />\n  );\n}\n\n/** The stroke of a line: 2px whatever the width of the plot, with round joins and ends. */\nexport const LINE_STROKE =\n  'fill-none stroke-2 [stroke-linecap:round] [stroke-linejoin:round]';\n",
      "type": "registry:component",
      "target": "components/chart/_parts/morph-path.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/path.ts",
      "content": "import { columnCenterOf, lerp, ratioOf, type Domain } from './scale';\n\nexport type Point = {\n  x: number;\n  y: number;\n};\n\n/** Paths are drawn in a box this wide and stretched to the plot, so a change of width never redraws them. */\nexport const VIEW_WIDTH = 1000;\n\nfunction commandOf(point: Point, index: number) {\n  const x = point.x.toFixed(1);\n  const y = point.y.toFixed(1);\n  if (index === 0) {\n    return `M${x} ${y}`;\n  }\n  return `L${x} ${y}`;\n}\n\nexport function linePathOf(points: readonly Point[]) {\n  const commands = points.map((point, index) => commandOf(point, index));\n  return commands.join(' ');\n}\n\n/** The line closed down (or up) to a level, for the wash under a line. */\nexport function areaPathOf(points: readonly Point[], levelY: number) {\n  if (points.length === 0) {\n    return '';\n  }\n  const line = linePathOf(points);\n  const lastIndex = points.length - 1;\n  const firstX = points[0].x.toFixed(1);\n  const lastX = points[lastIndex].x.toFixed(1);\n  const level = levelY.toFixed(1);\n  return `${line} L${lastX} ${level} L${firstX} ${level} Z`;\n}\n\n/** The surface between two lines that share their x positions, for a range around a line. */\nexport function bandPathOf(upper: readonly Point[], lower: readonly Point[]) {\n  if (upper.length === 0) {\n    return '';\n  }\n  const backwards = [...lower].reverse();\n  const outline = [...upper, ...backwards];\n  const line = linePathOf(outline);\n  return `${line} Z`;\n}\n\n/** The pixel height of a value in a plot: the top of the domain is 0, its bottom is the plot height. */\nexport function yOf(value: number, domain: Domain, height: number) {\n  const ratio = ratioOf(value, domain);\n  const fromBottom = ratio * height;\n  return height - fromBottom;\n}\n\n/** One point per value, each in the middle of its column. */\nexport function plotPointsOf(\n  values: readonly number[],\n  domain: Domain,\n  height: number\n): Point[] {\n  return values.map((value, index) => {\n    const share = columnCenterOf(index, values.length);\n    const x = share * VIEW_WIDTH;\n    const y = yOf(value, domain, height);\n    return { x, y };\n  });\n}\n\n/** How many of the pieces lie before a segment when they are shared out evenly. */\nfunction piecesBefore(\n  segment: number,\n  segmentCount: number,\n  pieceCount: number\n) {\n  const share = segment / segmentCount;\n  return Math.floor(share * pieceCount);\n}\n\n/**\n * The same line through exactly `count` points: each segment is cut into equal parts, so two lines\n * of different lengths share one structure and one can glide into the other.\n */\nexport function densifyOf(points: readonly Point[], count: number): Point[] {\n  const segmentCount = points.length - 1;\n  const hasSegments = segmentCount >= 1;\n  const hasRoom = count > points.length;\n  if (!hasSegments || !hasRoom) {\n    return [...points];\n  }\n  const pieceCount = count - 1;\n  const dense: Point[] = [points[0]];\n  for (let segment = 0; segment < segmentCount; segment += 1) {\n    const from = points[segment];\n    const to = points[segment + 1];\n    const start = piecesBefore(segment, segmentCount, pieceCount);\n    const end = piecesBefore(segment + 1, segmentCount, pieceCount);\n    const parts = end - start;\n    for (let part = 1; part <= parts; part += 1) {\n      const amount = part / parts;\n      const x = lerp(from.x, to.x, amount);\n      const y = lerp(from.y, to.y, amount);\n      dense.push({ x, y });\n    }\n  }\n  return dense;\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/path.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/plot.tsx",
      "content": "import { cn } from '@/registry/ndeko/lib/utils';\n\nimport { CategoryAxis, GridLines, ValueAxis, type AxisLabel } from './axis';\nimport { VIEW_WIDTH } from './path';\nimport type { Scale } from './scale';\n\n/**\n * A plot is a query container, so the readout and the marks inside it are placed in shares of its\n * width, and it lets the page scroll vertically under a finger while a sideways move reads values.\n */\nexport const PLOT_SURFACE =\n  'focus-visible:ring-ring/25 @container relative touch-pan-y rounded-md outline-none focus-visible:ring-[3px]';\n\ntype PlotFrameProps = {\n  /** The name of the plot and how to read it, for screen readers. */\n  label: string;\n  scale: Scale;\n  height: number;\n  formatTick: (value: number) => string;\n  axisLabels: readonly AxisLabel[];\n  axisAlign?: 'spread' | 'center';\n  /** Receives the plot element, for a chart that measures the room it has. */\n  plotRef?: (element: HTMLDivElement | null) => void;\n  /** The handlers of the reading: the plot is the one tab stop of the chart. */\n  plotProps?: React.HTMLAttributes<HTMLDivElement>;\n  plotClassName?: string;\n  /** For a chart whose marks carry their own figures, which can drop the axis on a narrow piece. */\n  valueAxisClassName?: string;\n  /** Labels laid out by the chart itself, in place of one row of `axisLabels`. */\n  categoryAxis?: React.ReactNode;\n  className?: string;\n  children: React.ReactNode;\n};\n\n/**\n * A plot between its value axis on the left and its category axis underneath. Each part has its own\n * cell: placed in turn, a plot whose value axis is hidden would fall into the axis column, which is\n * only as wide as its content, and nothing in a plot has a width of its own, so every mark would\n * stack at the left edge.\n */\nexport function PlotFrame({\n  label,\n  scale,\n  height,\n  formatTick,\n  axisLabels,\n  axisAlign,\n  plotRef,\n  plotProps,\n  plotClassName,\n  valueAxisClassName,\n  categoryAxis,\n  className,\n  children,\n}: PlotFrameProps) {\n  return (\n    <div\n      className={cn('grid grid-cols-[auto_minmax(0,1fr)] gap-x-2', className)}\n    >\n      <ValueAxis\n        scale={scale}\n        height={height}\n        format={formatTick}\n        className={cn('col-start-1 row-start-1', valueAxisClassName)}\n      />\n      <div\n        ref={plotRef}\n        role='group'\n        tabIndex={0}\n        aria-label={label}\n        className={cn(PLOT_SURFACE, 'col-start-2 row-start-1', plotClassName)}\n        style={{ height }}\n        {...plotProps}\n      >\n        <GridLines scale={scale} height={height} />\n        {children}\n      </div>\n      <div className='col-start-2 row-start-2 min-w-0'>\n        {categoryAxis}\n        {!categoryAxis && (\n          <CategoryAxis\n            labels={axisLabels}\n            align={axisAlign}\n            className='mt-2'\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\ntype PathLayerProps = {\n  height: number;\n  className?: string;\n  children: React.ReactNode;\n};\n\n/**\n * The layer the paths are drawn in: `VIEW_WIDTH` units across whatever the width of the plot, and\n * real pixels down. Strokes keep their thickness because each path opts out of the stretch.\n */\nexport function PathLayer({ height, className, children }: PathLayerProps) {\n  return (\n    <svg\n      aria-hidden='true'\n      viewBox={`0 0 ${VIEW_WIDTH} ${height}`}\n      preserveAspectRatio='none'\n      className={cn(\n        'pointer-events-none absolute inset-0 size-full overflow-visible',\n        className\n      )}\n    >\n      {children}\n    </svg>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/plot.tsx"
    },
    {
      "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"
    }
  ],
  "categories": [
    "components",
    "chart"
  ],
  "type": "registry:component"
}