{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-16",
  "title": "Forecast what-if",
  "description": "Cash runway against the plan with a what-if typed in plain words that bends the forecast away from the plan as the runway rolls, where switching to Range fans the line out into a swarm of likely futures and the runway mark parts into a range.",
  "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/input.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/segments.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/use-timeouts.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/chart/chart-16.tsx",
      "content": "'use client';\n\nimport { useMemo, useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { WordRoll, type WordRollWord } from '@/components/core/word-roll';\nimport {\n  typingSteps,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport {\n  hold,\n  PRESS_LEAD_MS,\n  spokenUnless,\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';\nimport { Segments, type Segment } from '@/registry/ndeko/ui/segments';\n\nimport { positionLabelsOf } from './_parts/axis';\nimport { ChartCard } from './_parts/chart-card';\nimport { ChipButton } from './_parts/chip-button';\nimport { DataTable, type DataRow } from './_parts/data-table';\nimport { formatUsd, nounOf } from './_parts/format';\nimport { Legend, type LegendEntry } from './_parts/legend';\nimport { LINE_STROKE, MorphPath } from './_parts/morph-path';\nimport { linePathOf, VIEW_WIDTH, yOf, type Point } from './_parts/path';\nimport { PathLayer, PlotFrame } from './_parts/plot';\nimport { QuestionForm } from './_parts/question-form';\nimport { seededRandom, spreadOf } from './_parts/random';\nimport {\n  Readout,\n  ReadoutDot,\n  ReadoutRow,\n  ReadoutTitle,\n} from './_parts/readout';\nimport {\n  clamp,\n  columnCenterOf,\n  extentOf,\n  niceScaleOf,\n  withZero,\n  type Scale,\n} from './_parts/scale';\nimport { TodayMark } from './_parts/today-mark';\nimport { useChartDemo } from './_parts/use-chart-demo';\nimport { usePlotWidth } from './_parts/use-plot-width';\nimport { readingAloud, useReading } from './_parts/use-reading';\nimport { WrappingRoll } from './_parts/wrapping-roll';\n\ntype ValueFormat = (value: number) => string;\n\n/** One line for the forecast, or a swarm of the futures it could become. */\nexport type ForecastView = 'line' | 'range';\n\nexport type ForecastWhatIfProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  /** One label per month: the months that happened, then the months ahead. */\n  labels: readonly string[];\n  /** The cash at the end of each month that happened. The last one is today. */\n  actuals: readonly number[];\n  /** The cash the plan expects at the end of each month ahead. It may fall below zero: the chart stops at zero. */\n  plan: readonly number[];\n  /** The cash at the end of each month ahead with the what-if, or the plan when there is none. */\n  forecast: readonly number[];\n  /** How far the cash flow of a month can stray from the forecast: each likely future drifts and wanders within it. */\n  monthlySpread: number;\n  view: ForecastView;\n  onViewChange: (view: ForecastView) => void;\n  /** The what-if as it was understood: \"Two engineers from Mar 2026, $36K a month\". Null on the plan. */\n  whatIf: string | null;\n  /** Leaves the what-if and goes back to the plan. */\n  onClear: () => void;\n  question: string;\n  onQuestionChange: (question: string) => void;\n  onAsk: (question: string) => void;\n  /** What-ifs offered under the field. */\n  suggestions: readonly string[];\n  /** Said under the field when a what-if was not understood: what to try next. */\n  hint?: string | null;\n  formatValue?: ValueFormat;\n  /** Shows Try held down, for a demonstration that presses it. */\n  isTryPressed?: boolean;\n  className?: string;\n};\n\nconst PLOT_HEIGHT = 176;\n\nconst MAX_LINES = 4;\n\nconst FALLBACK_WIDTH = 420;\n\n/** How many likely futures the range is read from, and the share of them left out at each end. */\nconst FUTURE_COUNT = 30;\n\nconst TAIL_SHARE = 0.1;\n\nconst TAILS_IN_TEN = TAIL_SHARE * 20;\n\nconst KEPT_SHARE_IN_TEN = 10 - TAILS_IN_TEN;\n\nconst KEPT_IN_TEN = Math.round(KEPT_SHARE_IN_TEN);\n\nconst LIKELY_WORDS = `in ${KEPT_IN_TEN} of 10 likely futures`;\n\n/** The part of a future's spread that is surprise from month to month; the rest is a drift that builds up. */\nconst SURPRISE_SHARE = 0.4;\n\n/** Spreads the seeds of the futures over the whole range of the generator, so no two of them start alike. */\nconst SEED_STEP = 2_654_435_761;\n\nconst CASH_COLOR = 'var(--primary)';\n\nconst PLAN_COLOR = 'color-mix(in oklab, var(--foreground) 35%, transparent)';\n\nconst FORECAST_DASH = 'stroke-primary [stroke-dasharray:5_5]';\n\nconst FUTURE_STROKE =\n  'stroke-primary fill-none stroke-1 [stroke-linecap:round] [stroke-linejoin:round]';\n\nconst GLIDE =\n  'transition-[transform,opacity] duration-300 ease-(--ease-out-strong)';\n\nconst OUT_DOT =\n  'bg-primary ring-card absolute top-0 left-0 size-2.5 -translate-1/2 rounded-full ring-2';\n\nconst UNIT = 'text-muted-foreground text-[13px] font-normal';\n\nconst ON_PLAN = 'No what-if yet, the forecast follows the plan';\n\nconst VIEWS: Segment<ForecastView>[] = [\n  { value: 'line', label: 'Line' },\n  { value: 'range', label: 'Range' },\n];\n\nconst LEGEND: LegendEntry[] = [\n  { id: 'cash', label: 'Cash', color: CASH_COLOR },\n  { id: 'plan', label: 'Plan', color: PLAN_COLOR },\n];\n\nconst TABLE_COLUMNS = [\n  'Month',\n  'Cash',\n  'Plan',\n  'Forecast',\n  'Likely low',\n  'Likely high',\n];\n\nconst RANKS = Array.from({ length: FUTURE_COUNT }, (_, rank) => rank);\n\n/** Cash that has run out stays at zero: it does not come back on its own. */\nfunction flooredOf(balances: readonly number[]) {\n  let isOut = false;\n  return balances.map((balance) => {\n    if (balance <= 0) {\n      isOut = true;\n    }\n    if (isOut) {\n      return 0;\n    }\n    return balance;\n  });\n}\n\n/** The whole months the cash lasts, or null when it lasts past the last month drawn. */\nfunction runwayOf(balances: readonly number[]) {\n  const outIndex = balances.findIndex((balance) => balance <= 0);\n  if (outIndex === -1) {\n    return null;\n  }\n  return outIndex;\n}\n\n/**\n * Where a future sits among the others, from -1 to 1: most of them near the middle and a few at the\n * edges, so the heart of the swarm looks solid and its edges thin.\n */\nfunction placeOf(rank: number) {\n  const middle = rank + 0.5;\n  const share = middle / FUTURE_COUNT;\n  if (share < 0.5) {\n    const doubled = share * 2;\n    const rise = Math.sqrt(doubled);\n    return rise - 1;\n  }\n  const left = 1 - share;\n  const doubled = left * 2;\n  const fall = Math.sqrt(doubled);\n  return 1 - fall;\n}\n\n/** One likely future: the forecast plus a drift that builds month after month, and small surprises on the way. */\nfunction futureOf(forecast: readonly number[], spread: number, rank: number) {\n  const order = rank + 1;\n  const seed = Math.imul(order, SEED_STEP);\n  const next = seededRandom(seed);\n  const place = placeOf(rank);\n  const drift = place * spread;\n  const surpriseSize = spread * SURPRISE_SHARE;\n  let strayed = 0;\n  return forecast.map((balance) => {\n    const surprise = spreadOf(next) * surpriseSize;\n    const step = drift + surprise;\n    strayed += step;\n    return balance + strayed;\n  });\n}\n\nfunction futuresOf(forecast: readonly number[], spread: number) {\n  return RANKS.map((rank) => futureOf(forecast, spread, rank));\n}\n\ntype RunwayRange = {\n  /** Null when even that future lasts past the last month drawn. */\n  low: number | null;\n  high: number | null;\n};\n\nfunction lastingOf(runway: number | null) {\n  if (runway === null) {\n    return Number.POSITIVE_INFINITY;\n  }\n  return runway;\n}\n\nfunction byLasting(first: number | null, second: number | null) {\n  const firstLasting = lastingOf(first);\n  const secondLasting = lastingOf(second);\n  if (firstLasting === secondLasting) {\n    return 0;\n  }\n  return firstLasting - secondLasting;\n}\n\nfunction tailCountOf(count: number) {\n  const tail = count * TAIL_SHARE;\n  return Math.floor(tail);\n}\n\n/** The runways most futures fall between, once the tenth that runs out soonest and the tenth that lasts longest are left out. */\nfunction runwayRangeOf(futures: readonly (readonly number[])[]): RunwayRange {\n  const runways = futures.map((future) => runwayOf(future));\n  const ordered = [...runways].sort(byLasting);\n  const tail = tailCountOf(ordered.length);\n  const lastIndex = ordered.length - 1;\n  const highIndex = lastIndex - tail;\n  return { low: ordered[tail] ?? null, high: ordered[highIndex] ?? null };\n}\n\ntype Band = {\n  low: number;\n  high: number;\n};\n\nfunction bandAt(futures: readonly (readonly number[])[], month: number): Band {\n  const values = futures.map((future) => future[month] ?? 0);\n  const ordered = [...values].sort((first, second) => first - second);\n  const tail = tailCountOf(ordered.length);\n  const lastIndex = ordered.length - 1;\n  const highIndex = lastIndex - tail;\n  return { low: ordered[tail] ?? 0, high: ordered[highIndex] ?? 0 };\n}\n\ntype Months = {\n  value: number;\n  /** True when the cash lasts past the last month drawn: the figure is that many months and more. */\n  isBeyond: boolean;\n};\n\ntype Runway = {\n  line: Months;\n  low: Months;\n  high: Months;\n};\n\nfunction monthsOf(runway: number | null, horizon: number): Months {\n  if (runway === null) {\n    return { value: horizon, isBeyond: true };\n  }\n  return { value: runway, isBeyond: false };\n}\n\nfunction runwayModelOf(\n  line: number | null,\n  range: RunwayRange,\n  horizon: number\n): Runway {\n  return {\n    line: monthsOf(line, horizon),\n    low: monthsOf(range.low, horizon),\n    high: monthsOf(range.high, horizon),\n  };\n}\n\nfunction plusOf(months: Months) {\n  if (months.isBeyond) {\n    return '+';\n  }\n  return undefined;\n}\n\nfunction monthsTextOf(months: Months) {\n  const plus = plusOf(months) ?? '';\n  return `${months.value}${plus}`;\n}\n\nfunction isSameMonths(first: Months, second: Months) {\n  const isSameValue = first.value === second.value;\n  const isSameReach = first.isBeyond === second.isBeyond;\n  return isSameValue && isSameReach;\n}\n\n/** A range whose two ends meet is said as one figure. */\nfunction isSpread(runway: Runway) {\n  return !isSameMonths(runway.low, runway.high);\n}\n\nfunction figureWordOf(key: string, months: Months): WordRollWord {\n  return {\n    key,\n    text: monthsTextOf(months),\n    node: <MotionNumber value={months.value} suffix={plusOf(months)} />,\n  };\n}\n\nfunction unitWordOf(months: Months): WordRollWord {\n  const unit = nounOf(months.value, 'month', 'months');\n  return {\n    key: 'unit',\n    text: unit,\n    node: <span className={UNIT}>{unit}</span>,\n  };\n}\n\nconst TO_WORD: WordRollWord = {\n  key: 'to',\n  text: 'to',\n  node: <span className={UNIT}>to</span>,\n};\n\n/** The runway in words that roll: the figure keeps its place and its digits turn, the words around it come and go. */\nfunction runwayWordsOf(view: ForecastView, runway: Runway): WordRollWord[] {\n  if (view === 'line') {\n    return [figureWordOf('low', runway.line), unitWordOf(runway.line)];\n  }\n  if (!isSpread(runway)) {\n    return [figureWordOf('low', runway.low), unitWordOf(runway.low)];\n  }\n  return [\n    figureWordOf('low', runway.low),\n    TO_WORD,\n    figureWordOf('high', runway.high),\n    unitWordOf(runway.high),\n  ];\n}\n\nfunction leadOf(view: ForecastView, runway: Runway) {\n  if (view === 'line') {\n    return runway.line.value;\n  }\n  return runway.low.value;\n}\n\nfunction wordTextOf(word: WordRollWord) {\n  if (typeof word === 'string') {\n    return word;\n  }\n  return word.text;\n}\n\nfunction spokenWordsOf(words: readonly WordRollWord[]) {\n  const texts = words.map((word) => wordTextOf(word));\n  return texts.join(' ');\n}\n\nfunction planFigureOf(view: ForecastView, plan: Runway) {\n  if (view === 'line') {\n    return monthsTextOf(plan.line);\n  }\n  if (!isSpread(plan)) {\n    return monthsTextOf(plan.low);\n  }\n  const low = monthsTextOf(plan.low);\n  const high = monthsTextOf(plan.high);\n  return `${low} to ${high}`;\n}\n\nfunction lineComparisonOf(hasWhatIf: boolean, plan: Runway) {\n  if (!hasWhatIf) {\n    return 'on the current plan';\n  }\n  const figure = planFigureOf('line', plan);\n  return `was ${figure} on plan`;\n}\n\nfunction rangeComparisonOf(hasWhatIf: boolean, plan: Runway) {\n  if (!hasWhatIf) {\n    return LIKELY_WORDS;\n  }\n  const figure = planFigureOf('range', plan);\n  return `${LIKELY_WORDS}, was ${figure}`;\n}\n\nfunction comparisonOf(view: ForecastView, hasWhatIf: boolean, plan: Runway) {\n  if (view === 'line') {\n    return lineComparisonOf(hasWhatIf, plan);\n  }\n  return rangeComparisonOf(hasWhatIf, plan);\n}\n\ntype Plot = {\n  count: number;\n  scale: Scale;\n};\n\nfunction pointAt(index: number, value: number, plot: Plot): Point {\n  const share = columnCenterOf(index, plot.count);\n  const x = share * VIEW_WIDTH;\n  const y = yOf(value, plot.scale, PLOT_HEIGHT);\n  return { x, y };\n}\n\nfunction pathFrom(firstIndex: number, values: readonly number[], plot: Plot) {\n  const points = values.map((value, offset) => {\n    const index = firstIndex + offset;\n    return pointAt(index, value, plot);\n  });\n  return linePathOf(points);\n}\n\ntype Start = {\n  index: number;\n  cash: number;\n};\n\nfunction aheadPathOf(start: Start, ahead: readonly number[], plot: Plot) {\n  const values = [start.cash, ...ahead];\n  return pathFrom(start.index, values, plot);\n}\n\nfunction scaleOf(\n  actuals: readonly number[],\n  plan: readonly number[],\n  forecast: readonly number[],\n  futures: readonly (readonly number[])[]\n) {\n  const futureValues = futures.flat();\n  const values = [...actuals, ...plan, ...forecast, ...futureValues];\n  const extent = extentOf(values);\n  const domain = withZero(extent);\n  return niceScaleOf(domain, MAX_LINES);\n}\n\n/** On the line view the futures lie folded on the forecast, so switching view spreads them out from it. */\nfunction futureShapeOf(view: ForecastView, future: string, forecast: string) {\n  if (view === 'range') {\n    return future;\n  }\n  return forecast;\n}\n\nfunction shownIn(view: ForecastView, shownView: ForecastView) {\n  if (view === shownView) {\n    return undefined;\n  }\n  return 'opacity-0';\n}\n\nfunction futureToneOf(view: ForecastView) {\n  if (view === 'range') {\n    return 'opacity-20';\n  }\n  return 'opacity-0';\n}\n\n/** Where the month the cash runs out sits across the plot, or null when it lasts past the last month. */\nfunction outShareOf(runway: number | null, todayIndex: number, count: number) {\n  if (runway === null) {\n    return null;\n  }\n  const monthsAhead = runway + 1;\n  const outIndex = todayIndex + monthsAhead;\n  const lastIndex = count - 1;\n  const index = Math.min(outIndex, lastIndex);\n  return columnCenterOf(index, count);\n}\n\ntype OutSpan = {\n  from: number | null;\n  to: number | null;\n};\n\nfunction outSpanOf(\n  view: ForecastView,\n  runway: number | null,\n  range: RunwayRange,\n  todayIndex: number,\n  count: number\n): OutSpan {\n  if (view === 'line') {\n    const share = outShareOf(runway, todayIndex, count);\n    return { from: share, to: share };\n  }\n  const from = outShareOf(range.low, todayIndex, count);\n  const to = outShareOf(range.high, todayIndex, count);\n  return { from, to };\n}\n\nfunction fadedUnless(isShown: boolean) {\n  if (isShown) {\n    return undefined;\n  }\n  return 'opacity-0';\n}\n\ntype OutMarksProps = {\n  span: OutSpan;\n  /** Pixels from the top of the plot down to zero. */\n  zeroTop: number;\n};\n\n/**\n * Where the cash runs out, on the zero line: one mark under the line, or the soonest and the latest\n * of the likely futures with a bar between them. The marks only move, so the one mark parts in two.\n * When the latest futures outlast the plot, the bar runs to its edge.\n */\nfunction OutMarks({ span, zeroTop }: OutMarksProps) {\n  const hasFrom = span.from !== null;\n  const hasTo = span.to !== null;\n  const from = span.from ?? 0;\n  const to = span.to ?? 1;\n  const reach = to - from;\n  const fromPercent = from * 100;\n  const toPercent = to * 100;\n\n  return (\n    <div\n      aria-hidden='true'\n      className='pointer-events-none absolute inset-x-0 top-0 h-0'\n      style={{ transform: `translateY(${zeroTop}px)` }}\n    >\n      <span\n        className={cn(\n          'bg-primary/30 absolute top-0 left-0 h-1 w-full origin-left -translate-y-1/2',\n          GLIDE,\n          fadedUnless(hasFrom)\n        )}\n        style={{ transform: `translateX(${fromPercent}cqw) scaleX(${reach})` }}\n      />\n      <span\n        className={cn(OUT_DOT, GLIDE, fadedUnless(hasFrom))}\n        style={{ transform: `translateX(${fromPercent}cqw)` }}\n      />\n      <span\n        className={cn(OUT_DOT, GLIDE, fadedUnless(hasTo))}\n        style={{ transform: `translateX(${toPercent}cqw)` }}\n      />\n    </div>\n  );\n}\n\ntype PastMonth = {\n  kind: 'past';\n  label: string;\n  cash: number;\n};\n\ntype MonthAhead = {\n  kind: 'ahead';\n  label: string;\n  plan: number;\n  forecast: number;\n  likely: Band;\n};\n\ntype MonthReading = PastMonth | MonthAhead;\n\ntype Model = {\n  labels: readonly string[];\n  actuals: readonly number[];\n  todayIndex: number;\n  plan: readonly number[];\n  forecast: readonly number[];\n  futures: readonly (readonly number[])[];\n};\n\nfunction readingAt(index: number, model: Model): MonthReading {\n  const label = model.labels[index] ?? '';\n  if (index <= model.todayIndex) {\n    const cash = model.actuals[index] ?? 0;\n    return { kind: 'past', label, cash };\n  }\n  const gap = index - model.todayIndex;\n  const month = gap - 1;\n  return {\n    kind: 'ahead',\n    label,\n    plan: model.plan[month] ?? 0,\n    forecast: model.forecast[month] ?? 0,\n    likely: bandAt(model.futures, month),\n  };\n}\n\ntype Spot = {\n  top: number;\n  color: string;\n};\n\nfunction spotsOf(reading: MonthReading, view: ForecastView, scale: Scale) {\n  if (reading.kind === 'past') {\n    const cashSpot: Spot = {\n      top: yOf(reading.cash, scale, PLOT_HEIGHT),\n      color: CASH_COLOR,\n    };\n    return [cashSpot];\n  }\n  const planSpot: Spot = {\n    top: yOf(reading.plan, scale, PLOT_HEIGHT),\n    color: PLAN_COLOR,\n  };\n  if (view === 'range') {\n    return [planSpot];\n  }\n  const forecastSpot: Spot = {\n    top: yOf(reading.forecast, scale, PLOT_HEIGHT),\n    color: CASH_COLOR,\n  };\n  return [forecastSpot, planSpot];\n}\n\nfunction likelyTopOf(reading: MonthReading, view: ForecastView, scale: Scale) {\n  if (view === 'line') {\n    return null;\n  }\n  if (reading.kind === 'past') {\n    return null;\n  }\n  return yOf(reading.likely.high, scale, PLOT_HEIGHT);\n}\n\n/** The bubble sits above the highest thing read at that month, the top of the likely range included. */\nfunction bubbleTopOf(\n  reading: MonthReading,\n  view: ForecastView,\n  spots: readonly Spot[],\n  scale: Scale\n) {\n  const tops = spots.map((spot) => spot.top);\n  const likelyTop = likelyTopOf(reading, view, scale);\n  if (likelyTop !== null) {\n    tops.push(likelyTop);\n  }\n  return Math.min(...tops);\n}\n\nconst FIGURE_TEXT = 'text-foreground font-medium tabular-nums';\n\ntype ReadoutLinesProps = {\n  reading: MonthReading;\n  view: ForecastView;\n  format: ValueFormat;\n};\n\nfunction ReadoutLines({ reading, view, format }: ReadoutLinesProps) {\n  if (reading.kind === 'past') {\n    return (\n      <ReadoutRow value={format(reading.cash)} name='Cash' color={CASH_COLOR} />\n    );\n  }\n  const planRow = (\n    <ReadoutRow value={format(reading.plan)} name='Plan' color={PLAN_COLOR} />\n  );\n  if (view === 'range') {\n    return (\n      <>\n        <span className='text-muted-foreground'>\n          Likely{' '}\n          <span className={FIGURE_TEXT}>{format(reading.likely.low)}</span> to{' '}\n          <span className={FIGURE_TEXT}>{format(reading.likely.high)}</span>\n        </span>\n        {planRow}\n      </>\n    );\n  }\n  return (\n    <>\n      <ReadoutRow\n        value={format(reading.forecast)}\n        name='Forecast'\n        color={CASH_COLOR}\n      />\n      {planRow}\n    </>\n  );\n}\n\nfunction spokenReadingOf(\n  reading: MonthReading,\n  view: ForecastView,\n  format: ValueFormat\n) {\n  if (reading.kind === 'past') {\n    const cash = format(reading.cash);\n    return `${reading.label}: cash ${cash}`;\n  }\n  const plan = format(reading.plan);\n  if (view === 'range') {\n    const low = format(reading.likely.low);\n    const high = format(reading.likely.high);\n    return `${reading.label}: likely ${low} to ${high}, plan ${plan}`;\n  }\n  const forecast = format(reading.forecast);\n  return `${reading.label}: forecast ${forecast}, plan ${plan}`;\n}\n\nfunction cellsOf(reading: MonthReading, format: ValueFormat) {\n  if (reading.kind === 'past') {\n    return [format(reading.cash), '', '', '', ''];\n  }\n  return [\n    '',\n    format(reading.plan),\n    format(reading.forecast),\n    format(reading.likely.low),\n    format(reading.likely.high),\n  ];\n}\n\nfunction whatIfToneOf(isOnPlan: boolean) {\n  if (isOnPlan) {\n    return 'text-muted-foreground';\n  }\n  return 'text-foreground';\n}\n\ntype WhatIfLineProps = {\n  whatIf: string | null;\n  onClear: () => void;\n};\n\n/** What the words were understood as, so a person sees what the forecast now holds. */\nfunction WhatIfLine({ whatIf, onClear }: WhatIfLineProps) {\n  const isOnPlan = whatIf === null;\n  const text = whatIf ?? ON_PLAN;\n\n  return (\n    <div className='flex min-h-7 flex-wrap items-center justify-between gap-x-3 gap-y-1'>\n      <p\n        className={cn(\n          'min-w-0 text-xs transition-colors duration-200',\n          whatIfToneOf(isOnPlan)\n        )}\n      >\n        <WrappingRoll text={text} />\n      </p>\n      <Button\n        type='button'\n        variant='ghost'\n        size='sm'\n        disabled={isOnPlan}\n        onClick={onClear}\n        className='text-muted-foreground shrink-0'\n      >\n        Back to plan\n      </Button>\n    </div>\n  );\n}\n\nexport function ForecastWhatIf({\n  title,\n  note,\n  labels,\n  actuals,\n  plan,\n  forecast,\n  monthlySpread,\n  view,\n  onViewChange,\n  whatIf,\n  onClear,\n  question,\n  onQuestionChange,\n  onAsk,\n  suggestions,\n  hint = null,\n  formatValue = formatUsd,\n  isTryPressed = false,\n  className,\n}: ForecastWhatIfProps) {\n  const { plotRef, width } = usePlotWidth(FALLBACK_WIDTH);\n  const [hasActed, setHasActed] = useState(false);\n  const horizon = plan.length;\n  const count = actuals.length + horizon;\n  const safeCount = Math.max(count, 1);\n  const lastIndex = Math.max(count - 1, 0);\n  const todayIndex = Math.max(actuals.length - 1, 0);\n  const today = actuals.at(-1) ?? 0;\n  const hasWhatIf = whatIf !== null;\n  const rawFutures = useMemo(\n    () => futuresOf(forecast, monthlySpread),\n    [forecast, monthlySpread]\n  );\n  const planFutures = useMemo(\n    () => futuresOf(plan, monthlySpread),\n    [plan, monthlySpread]\n  );\n  const floorPlan = flooredOf(plan);\n  const floorForecast = flooredOf(forecast);\n  const futures = rawFutures.map((future) => flooredOf(future));\n  const forecastRunway = runwayOf(forecast);\n  const forecastRange = runwayRangeOf(rawFutures);\n  const runway = runwayModelOf(forecastRunway, forecastRange, horizon);\n  const planLine = runwayOf(plan);\n  const planRange = runwayRangeOf(planFutures);\n  const planRunway = runwayModelOf(planLine, planRange, horizon);\n  const scale = scaleOf(actuals, floorPlan, floorForecast, futures);\n  const plot: Plot = { count: safeCount, scale };\n  const start: Start = { index: todayIndex, cash: today };\n  const actualPath = pathFrom(0, actuals, plot);\n  const planPath = aheadPathOf(start, floorPlan, plot);\n  const forecastPath = aheadPathOf(start, floorForecast, plot);\n  const futurePaths = futures.map((future) => aheadPathOf(start, future, plot));\n  const todayShare = columnCenterOf(todayIndex, safeCount);\n  const todayTop = yOf(today, scale, PLOT_HEIGHT);\n  const zeroTop = yOf(0, scale, PLOT_HEIGHT);\n  const outSpan = outSpanOf(\n    view,\n    forecastRunway,\n    forecastRange,\n    todayIndex,\n    safeCount\n  );\n  const runwayWords = runwayWordsOf(view, runway);\n  const runwayText = spokenWordsOf(runwayWords);\n  const comparison = comparisonOf(view, hasWhatIf, planRunway);\n  const sentence = `Runway ${runwayText}, ${comparison}`;\n  const model: Model = {\n    labels,\n    actuals,\n    todayIndex,\n    plan: floorPlan,\n    forecast: floorForecast,\n    futures,\n  };\n  const reading = useReading({ count, defaultIndex: lastIndex });\n  const shownIndex = reading.shownIndex;\n  const shown = readingAt(shownIndex, model);\n  const spots = spotsOf(shown, view, scale);\n  const bubbleTop = bubbleTopOf(shown, view, spots, scale);\n  const spokenReading = spokenReadingOf(shown, view, formatValue);\n  const canTry = question.trim() !== '';\n  const spoken = hint ?? sentence;\n  const firstLabel = labels[0] ?? '';\n  const todayLabel = labels[todayIndex] ?? '';\n  const lastLabel = labels[lastIndex] ?? '';\n  const summary = `Cash from ${firstLabel} to ${todayLabel}, then the plan and the forecast to ${lastLabel}. ${sentence}.`;\n\n  const rows: DataRow[] = labels.map((label, index) => {\n    const row = readingAt(index, model);\n    return { id: `${index}-${label}`, label, cells: cellsOf(row, formatValue) };\n  });\n\n  /** What a person does is said aloud; what a demonstration does stays silent. */\n  function askByHand(text: string) {\n    setHasActed(true);\n    onAsk(text);\n  }\n\n  function clearByHand() {\n    setHasActed(true);\n    onClear();\n  }\n\n  function changeViewByHand(next: ForecastView) {\n    setHasActed(true);\n    onViewChange(next);\n  }\n\n  return (\n    <ChartCard\n      title={title}\n      note={note}\n      summary={summary}\n      className={className}\n      actions={\n        <Segments\n          value={view}\n          segments={VIEWS}\n          onValueChange={changeViewByHand}\n          label='How to draw the forecast'\n        />\n      }\n    >\n      <div className='flex flex-col gap-2'>\n        <QuestionForm\n          value={question}\n          onValueChange={onQuestionChange}\n          onSubmit={askByHand}\n          label='A what-if to try, in plain words'\n          placeholder='What if we raise $1M in June?'\n          submitLabel='Try'\n          isSubmitDisabled={!canTry}\n          isSubmitPressed={isTryPressed}\n          hint={hint}\n        />\n        <div\n          role='group'\n          aria-label='What-ifs to try'\n          className='flex flex-wrap gap-1.5'\n        >\n          {suggestions.map((suggestion) => (\n            <ChipButton key={suggestion} onClick={() => askByHand(suggestion)}>\n              {suggestion}\n            </ChipButton>\n          ))}\n        </div>\n      </div>\n      <div className='flex flex-col'>\n        <WhatIfLine whatIf={whatIf} onClear={clearByHand} />\n        <p className='text-2xl leading-8 font-semibold'>\n          <WordRoll words={runwayWords} value={leadOf(view, runway)} />\n        </p>\n        <p className='text-muted-foreground text-xs'>\n          <WrappingRoll text={comparison} />\n        </p>\n      </div>\n      <PlotFrame\n        label={`${title}. Use the arrow keys to read each month.`}\n        scale={scale}\n        height={PLOT_HEIGHT}\n        formatTick={formatValue}\n        axisLabels={positionLabelsOf(labels, width, todayIndex)}\n        plotRef={plotRef}\n        plotProps={reading.handlers}\n      >\n        <PathLayer height={PLOT_HEIGHT}>\n          {futurePaths.map((path, rank) => (\n            <MorphPath\n              key={rank}\n              d={futureShapeOf(view, path, forecastPath)}\n              className={cn(\n                FUTURE_STROKE,\n                'transition-opacity duration-300',\n                futureToneOf(view)\n              )}\n            />\n          ))}\n          <MorphPath\n            d={planPath}\n            className={LINE_STROKE}\n            style={{ stroke: PLAN_COLOR }}\n          />\n          <MorphPath\n            d={forecastPath}\n            className={cn(\n              LINE_STROKE,\n              FORECAST_DASH,\n              'transition-opacity duration-200',\n              shownIn(view, 'line')\n            )}\n          />\n          <MorphPath\n            d={actualPath}\n            className={cn(LINE_STROKE, 'stroke-primary')}\n          />\n        </PathLayer>\n        <TodayMark ratio={todayShare} top={todayTop} />\n        <OutMarks span={outSpan} zeroTop={zeroTop} />\n        <Readout\n          ratio={columnCenterOf(shownIndex, safeCount)}\n          top={bubbleTop}\n          isVisible={reading.isActive}\n          isGliding={reading.isGliding}\n          marks={spots.map((spot) => (\n            <ReadoutDot key={spot.color} top={spot.top} color={spot.color} />\n          ))}\n        >\n          <ReadoutTitle>{shown.label}</ReadoutTitle>\n          <ReadoutLines reading={shown} view={view} format={formatValue} />\n        </Readout>\n      </PlotFrame>\n      <Legend entries={LEGEND} label='What the lines are' />\n      <LiveRegion message={spokenUnless(!hasActed, spoken)} />\n      <LiveRegion\n        message={readingAloud(reading.isReadingByKeys, spokenReading)}\n      />\n      <DataTable caption={title} columns={TABLE_COLUMNS} rows={rows} />\n    </ChartCard>\n  );\n}\n\n/** A change to the plan, read from a sentence. */\ntype WhatIf = {\n  /** What the words were understood as, in a few words. */\n  label: string;\n  /** The month ahead it starts in, from 1 for the month after today. */\n  fromMonth: number;\n  /** What it does to the cash every month from then on: below zero when it costs. */\n  monthly: number;\n  /** What it does to the cash once, in its first month. */\n  once: number;\n};\n\nconst PAST_MONTHS = 6;\n\nconst HORIZON = 24;\n\nconst FIRST_YEAR = 2025;\n\n/** July, counted from 0 for January. */\nconst FIRST_CALENDAR_MONTH = 6;\n\nconst TODAY_INDEX = PAST_MONTHS - 1;\n\nconst TODAY_CALENDAR_MONTH = FIRST_CALENDAR_MONTH + TODAY_INDEX;\n\nconst MONTHS_IN_YEAR = 12;\n\nconst MONTH_LABEL = new Intl.DateTimeFormat('en-US', {\n  month: 'short',\n  year: 'numeric',\n  timeZone: 'UTC',\n});\n\nfunction monthLabelsOf(year: number, firstMonth: number, count: number) {\n  return Array.from({ length: count }, (_, offset) => {\n    const month = firstMonth + offset;\n    const time = Date.UTC(year, month, 1);\n    return MONTH_LABEL.format(time);\n  });\n}\n\nconst MONTH_COUNT = PAST_MONTHS + HORIZON;\n\nconst LABELS = monthLabelsOf(FIRST_YEAR, FIRST_CALENDAR_MONTH, MONTH_COUNT);\n\nconst ACTUALS = [\n  3_250_000, 3_090_000, 2_950_000, 2_790_000, 2_610_000, 2_400_000,\n];\n\nconst CASH_TODAY = ACTUALS[TODAY_INDEX];\n\n/** What the plan burns in its first month ahead, and how much less each month after as revenue grows. */\nconst BURN = 150_000;\n\nconst BURN_EASING = 1_500;\n\nconst MONTHLY_SPREAD = 30_000;\n\n/** The figures the rules work with: a fully loaded hire, monthly revenue, and what customers pay a month. */\nconst HEAD_COST = 18_000;\n\nconst REVENUE = 210_000;\n\nconst BIGGEST_CUSTOMER = 70_000;\n\nconst AVERAGE_CUSTOMER = 6_000;\n\nconst DEFAULT_PERCENT = 10;\n\nfunction planOf(cash: number) {\n  const plan: number[] = [];\n  let balance = cash;\n  for (let month = 1; month <= HORIZON; month += 1) {\n    const easing = BURN_EASING * month;\n    const burn = BURN - easing;\n    balance -= burn;\n    plan.push(balance);\n  }\n  return plan;\n}\n\nconst PLAN = planOf(CASH_TODAY);\n\nfunction impactAt(whatIf: WhatIf, month: number) {\n  const monthsBefore = month - whatIf.fromMonth;\n  const monthsIn = monthsBefore + 1;\n  if (monthsIn <= 0) {\n    return 0;\n  }\n  const flow = whatIf.monthly * monthsIn;\n  return flow + whatIf.once;\n}\n\nfunction forecastOf(whatIf: WhatIf | null) {\n  if (!whatIf) {\n    return PLAN;\n  }\n  return PLAN.map((balance, index) => {\n    const month = index + 1;\n    const impact = impactAt(whatIf, month);\n    return balance + impact;\n  });\n}\n\nconst CALENDAR_MONTHS = [\n  'january',\n  'february',\n  'march',\n  'april',\n  'may',\n  'june',\n  'july',\n  'august',\n  'september',\n  'october',\n  'november',\n  'december',\n];\n\nconst NUMBER_WORDS = new Map([\n  ['a', 1],\n  ['an', 1],\n  ['one', 1],\n  ['two', 2],\n  ['three', 3],\n  ['four', 4],\n  ['five', 5],\n  ['six', 6],\n  ['seven', 7],\n  ['eight', 8],\n  ['nine', 9],\n  ['ten', 10],\n]);\n\nconst COUNT_NAMES = [\n  'zero',\n  'one',\n  'two',\n  'three',\n  'four',\n  'five',\n  'six',\n  'seven',\n  'eight',\n  'nine',\n  'ten',\n];\n\nconst AMOUNT_UNITS = new Map([\n  ['k', 1_000],\n  ['thousand', 1_000],\n  ['m', 1_000_000],\n  ['million', 1_000_000],\n]);\n\nconst WORD_PATTERN = /[a-z]+|\\d+(?:\\.\\d+)?/g;\n\nconst PERCENT_PATTERN = /(\\d+(?:\\.\\d+)?) ?(?:%|percent)/;\n\nconst SIGNED_AMOUNT_PATTERN = /\\$ ?(\\d+(?:\\.\\d+)?) ?(k|m|thousand|million)?/;\n\nconst UNIT_AMOUNT_PATTERN = /(\\d+(?:\\.\\d+)?) ?(k|m|thousand|million)\\b/;\n\ntype Role = {\n  one: string;\n  many: string;\n};\n\nconst PEOPLE: Role = { one: 'person', many: 'people' };\n\nconst ROLES: Role[] = [\n  { one: 'engineer', many: 'engineers' },\n  { one: 'developer', many: 'developers' },\n  { one: 'designer', many: 'designers' },\n  { one: 'marketer', many: 'marketers' },\n  { one: 'salesperson', many: 'salespeople' },\n  PEOPLE,\n];\n\nconst PRICE_WORDS = ['price', 'prices', 'pricing'];\n\nconst PRICE_DOWN_WORDS = ['lower', 'cut', 'drop', 'reduce', 'down', 'discount'];\n\nconst HIRE_WORDS = ['hire', 'hiring', 'add', 'adding', 'recruit', 'onboard'];\n\nconst LOSS_WORDS = ['lose', 'losing', 'lost', 'churn', 'churns'];\n\nconst WIN_WORDS = ['win', 'winning', 'sign', 'signing', 'land', 'landing'];\n\nconst CUSTOMER_WORDS = ['customer', 'customers', 'client', 'clients'];\n\nconst BIGGEST_WORDS = ['biggest', 'largest', 'top', 'main', 'big'];\n\nconst CUT_WORDS = ['cut', 'cutting', 'reduce', 'reducing', 'trim', 'save'];\n\nconst SPEND_WORDS = [\n  'spend',\n  'spending',\n  'cost',\n  'costs',\n  'burn',\n  'budget',\n  'ads',\n  'marketing',\n];\n\nconst FUNDING_WORDS = ['raise', 'raising', 'funding', 'round', 'loan'];\n\nfunction wordsIn(text: string) {\n  const matches = text.match(WORD_PATTERN);\n  return matches ?? [];\n}\n\nfunction hasAny(tokens: readonly string[], candidates: readonly string[]) {\n  return tokens.some((token) => candidates.includes(token));\n}\n\nfunction countOf(token: string | undefined) {\n  if (token === undefined) {\n    return null;\n  }\n  const named = NUMBER_WORDS.get(token);\n  if (named !== undefined) {\n    return named;\n  }\n  const digits = Number(token);\n  const isWhole = Number.isInteger(digits);\n  const isPositive = digits > 0;\n  const isCount = isWhole && isPositive;\n  if (isCount) {\n    return digits;\n  }\n  return null;\n}\n\n/** The count said just before a word, one adjective allowed between them: \"two engineers\", \"3 senior designers\". */\nfunction countBefore(tokens: readonly string[], index: number) {\n  const nearestIndex = index - 1;\n  const nearest = countOf(tokens[nearestIndex]);\n  if (nearest !== null) {\n    return nearest;\n  }\n  const furtherIndex = index - 2;\n  const further = countOf(tokens[furtherIndex]);\n  if (further !== null) {\n    return further;\n  }\n  return 1;\n}\n\nfunction countNameOf(count: number) {\n  const name = COUNT_NAMES.at(count);\n  if (name !== undefined) {\n    return name;\n  }\n  return String(count);\n}\n\nfunction countTitleOf(count: number) {\n  const name = countNameOf(count);\n  const first = name.charAt(0);\n  const rest = name.slice(1);\n  const capital = first.toUpperCase();\n  return `${capital}${rest}`;\n}\n\nfunction calendarMonthOf(token: string) {\n  const isLongEnough = token.length >= 3;\n  if (!isLongEnough) {\n    return -1;\n  }\n  return CALENDAR_MONTHS.findIndex((name) => name.startsWith(token));\n}\n\n/** How many months ahead a named month is: March means the next March to come. */\nfunction monthsAheadOf(calendarMonth: number) {\n  const shifted = calendarMonth - TODAY_CALENDAR_MONTH;\n  const wrapped = shifted + MONTHS_IN_YEAR;\n  const ahead = wrapped % MONTHS_IN_YEAR;\n  if (ahead === 0) {\n    return MONTHS_IN_YEAR;\n  }\n  return ahead;\n}\n\n/** The month a what-if starts in: a month it names, \"in 3 months\", or else the next month. */\nfunction startMonthOf(tokens: readonly string[]) {\n  for (const token of tokens) {\n    const calendarMonth = calendarMonthOf(token);\n    if (calendarMonth >= 0) {\n      return monthsAheadOf(calendarMonth);\n    }\n  }\n  const monthsIndex = tokens.indexOf('months');\n  const countIndex = monthsIndex - 1;\n  const count = countOf(tokens[countIndex]);\n  if (count !== null) {\n    return clamp(count, 1, HORIZON);\n  }\n  return 1;\n}\n\nfunction monthLabelOf(fromMonth: number) {\n  const index = TODAY_INDEX + fromMonth;\n  return LABELS[index] ?? '';\n}\n\nfunction percentIn(text: string) {\n  const match = PERCENT_PATTERN.exec(text);\n  if (!match) {\n    return null;\n  }\n  return Number(match[1]);\n}\n\n/** An amount with a dollar sign, or with a unit such as 20k or 1m. */\nfunction amountMatchOf(text: string) {\n  const signed = SIGNED_AMOUNT_PATTERN.exec(text);\n  if (signed) {\n    return signed;\n  }\n  return UNIT_AMOUNT_PATTERN.exec(text);\n}\n\nfunction amountIn(text: string) {\n  const match = amountMatchOf(text);\n  if (!match) {\n    return null;\n  }\n  const figure = Number(match[1]);\n  const unit = AMOUNT_UNITS.get(match[2]) ?? 1;\n  return figure * unit;\n}\n\nfunction signOf(isDown: boolean) {\n  if (isDown) {\n    return -1;\n  }\n  return 1;\n}\n\nfunction wayOf(isDown: boolean) {\n  if (isDown) {\n    return 'down';\n  }\n  return 'up';\n}\n\n/** A sentence as the rules read it: the text in lower case, and its words and figures in order. */\ntype Sentence = {\n  text: string;\n  tokens: readonly string[];\n};\n\nfunction priceChangeOf({ text, tokens }: Sentence) {\n  const isAboutPrices = hasAny(tokens, PRICE_WORDS);\n  if (!isAboutPrices) {\n    return null;\n  }\n  const percent = percentIn(text) ?? DEFAULT_PERCENT;\n  const isDown = hasAny(tokens, PRICE_DOWN_WORDS);\n  const share = percent / 100;\n  const change = REVENUE * share;\n  const sign = signOf(isDown);\n  const fromMonth = startMonthOf(tokens);\n  const way = wayOf(isDown);\n  const month = monthLabelOf(fromMonth);\n  const amount = formatUsd(change);\n  const whatIf: WhatIf = {\n    label: `Prices ${way} ${percent}% from ${month}, ${amount} a month`,\n    fromMonth,\n    monthly: sign * change,\n    once: 0,\n  };\n  return whatIf;\n}\n\ntype FoundRole = {\n  role: Role;\n  index: number;\n};\n\nfunction roleIn(tokens: readonly string[]): FoundRole | null {\n  for (const [index, token] of tokens.entries()) {\n    const role = ROLES.find((candidate) => {\n      const isOne = candidate.one === token;\n      const isMany = candidate.many === token;\n      return isOne || isMany;\n    });\n    if (role) {\n      return { role, index };\n    }\n  }\n  return null;\n}\n\n/** The first count said after the word that hires, when no role is named: \"hire two\". */\nfunction countAfterHire(tokens: readonly string[]) {\n  const hireIndex = tokens.findIndex((token) => HIRE_WORDS.includes(token));\n  const afterIndex = hireIndex + 1;\n  const after = tokens.slice(afterIndex);\n  for (const token of after) {\n    const count = countOf(token);\n    if (count !== null) {\n      return count;\n    }\n  }\n  return 1;\n}\n\nfunction hireOf({ tokens }: Sentence) {\n  const isHiring = hasAny(tokens, HIRE_WORDS);\n  if (!isHiring) {\n    return null;\n  }\n  const found = roleIn(tokens);\n  const role = found?.role ?? PEOPLE;\n  let count = countAfterHire(tokens);\n  if (found) {\n    count = countBefore(tokens, found.index);\n  }\n  const cost = count * HEAD_COST;\n  const fromMonth = startMonthOf(tokens);\n  const who = countTitleOf(count);\n  const noun = nounOf(count, role.one, role.many);\n  const month = monthLabelOf(fromMonth);\n  const amount = formatUsd(cost);\n  const whatIf: WhatIf = {\n    label: `${who} ${noun} from ${month}, ${amount} a month`,\n    fromMonth,\n    monthly: -cost,\n    once: 0,\n  };\n  return whatIf;\n}\n\nfunction bigCustomerOf(isLoss: boolean) {\n  if (isLoss) {\n    return 'Biggest customer';\n  }\n  return 'A big customer';\n}\n\nfunction customersOf(tokens: readonly string[], isLoss: boolean) {\n  const isBiggest = hasAny(tokens, BIGGEST_WORDS);\n  if (isBiggest) {\n    return { amount: BIGGEST_CUSTOMER, name: bigCustomerOf(isLoss) };\n  }\n  const customerIndex = tokens.findIndex((token) =>\n    CUSTOMER_WORDS.includes(token)\n  );\n  const count = countBefore(tokens, customerIndex);\n  const amount = count * AVERAGE_CUSTOMER;\n  const who = countTitleOf(count);\n  const noun = nounOf(count, 'customer', 'customers');\n  return { amount, name: `${who} ${noun}` };\n}\n\nfunction customerChangeOf({ tokens }: Sentence) {\n  const isAboutCustomers = hasAny(tokens, CUSTOMER_WORDS);\n  const isLoss = hasAny(tokens, LOSS_WORDS);\n  const isWin = hasAny(tokens, WIN_WORDS);\n  const isChange = isLoss || isWin;\n  const isCustomerChange = isAboutCustomers && isChange;\n  if (!isCustomerChange) {\n    return null;\n  }\n  const customers = customersOf(tokens, isLoss);\n  const sign = signOf(isLoss);\n  const fromMonth = startMonthOf(tokens);\n  const month = monthLabelOf(fromMonth);\n  const amount = formatUsd(customers.amount);\n  const outcome = outcomeOf(isLoss);\n  const whatIf: WhatIf = {\n    label: `${customers.name} ${outcome} from ${month}, ${amount} a month`,\n    fromMonth,\n    monthly: sign * customers.amount,\n    once: 0,\n  };\n  return whatIf;\n}\n\nfunction outcomeOf(isLoss: boolean) {\n  if (isLoss) {\n    return 'lost';\n  }\n  return 'won';\n}\n\n/** A saving said as an amount, as a share of the burn, or a tenth of the burn when no figure is given. */\nfunction savingOf(text: string) {\n  const amount = amountIn(text);\n  if (amount !== null) {\n    return amount;\n  }\n  const percent = percentIn(text) ?? DEFAULT_PERCENT;\n  const share = percent / 100;\n  return BURN * share;\n}\n\nfunction spendingCutOf({ text, tokens }: Sentence) {\n  const isCut = hasAny(tokens, CUT_WORDS);\n  const isAboutSpending = hasAny(tokens, SPEND_WORDS);\n  const isSpendingCut = isCut && isAboutSpending;\n  if (!isSpendingCut) {\n    return null;\n  }\n  const saving = savingOf(text);\n  const fromMonth = startMonthOf(tokens);\n  const month = monthLabelOf(fromMonth);\n  const amount = formatUsd(saving);\n  const whatIf: WhatIf = {\n    label: `Spending down ${amount} a month from ${month}`,\n    fromMonth,\n    monthly: saving,\n    once: 0,\n  };\n  return whatIf;\n}\n\nfunction fundingOf({ text, tokens }: Sentence) {\n  const isFunding = hasAny(tokens, FUNDING_WORDS);\n  const amount = amountIn(text);\n  const hasAmount = amount !== null;\n  const isRaise = isFunding && hasAmount;\n  if (!isRaise) {\n    return null;\n  }\n  const fromMonth = startMonthOf(tokens);\n  const month = monthLabelOf(fromMonth);\n  const figure = formatUsd(amount);\n  const whatIf: WhatIf = {\n    label: `${figure} raised in ${month}`,\n    fromMonth,\n    monthly: 0,\n    once: amount,\n  };\n  return whatIf;\n}\n\ntype Rule = (sentence: Sentence) => WhatIf | null;\n\n/** The first rule that recognises the sentence reads it. Prices come first, since \"raise prices\" is not a raise. */\nconst RULES: Rule[] = [\n  priceChangeOf,\n  hireOf,\n  customerChangeOf,\n  spendingCutOf,\n  fundingOf,\n];\n\n/** Longer than any what-if: the patterns never read more than this, however much is pasted. */\nconst MAX_SENTENCE_LENGTH = 200;\n\nfunction whatIfOf(words: string) {\n  const lowered = words.toLowerCase();\n  const text = lowered.slice(0, MAX_SENTENCE_LENGTH);\n  const tokens = wordsIn(text);\n  const sentence: Sentence = { text, tokens };\n  for (const rule of RULES) {\n    const whatIf = rule(sentence);\n    if (whatIf) {\n      return whatIf;\n    }\n  }\n  return null;\n}\n\nconst SUGGESTIONS = [\n  'Hire two engineers in March',\n  'Lose our biggest customer',\n  'Raise prices 10% in May',\n];\n\nconst NOT_UNDERSTOOD =\n  'Not understood. Try a hire, a customer lost or won, a price change, a spending cut or a raise';\n\nconst RESTING_TEXT = 'hire two engineers in March';\n\nconst DEMO_TEXT = 'lose our biggest customer';\n\nconst RESTING_WHAT_IF = whatIfOf(RESTING_TEXT);\n\ntype PressKey = 'try';\n\nexport default function Chart16() {\n  const [view, setView] = useState<ForecastView>('line');\n  const [whatIf, setWhatIf] = useState<WhatIf | null>(RESTING_WHAT_IF);\n  const [question, setQuestion] = useState('');\n  const [hint, setHint] = useState<string | null>(null);\n  const { pressed, press } = usePress<PressKey>();\n  const forecast = forecastOf(whatIf);\n  const whatIfLabel = whatIf?.label ?? null;\n\n  function tryWhatIf(text: string) {\n    const next = whatIfOf(text);\n    if (!next) {\n      setHint(NOT_UNDERSTOOD);\n      return;\n    }\n    setHint(null);\n    setQuestion('');\n    setWhatIf(next);\n  }\n\n  function changeQuestion(text: string) {\n    setHint(null);\n    setQuestion(text);\n  }\n\n  function backToPlan() {\n    setHint(null);\n    setWhatIf(null);\n  }\n\n  function resetDemo() {\n    setView('line');\n    setWhatIf(RESTING_WHAT_IF);\n    setQuestion('');\n    setHint(null);\n  }\n\n  const steps: AutoplayStep[] = [\n    { after: 1600, run: () => setView('range') },\n    { after: 3000, run: () => setView('line') },\n    ...typingSteps(DEMO_TEXT, setQuestion, { startAfter: 1200 }),\n    { after: 420, run: () => press('try') },\n    { after: PRESS_LEAD_MS, run: () => tryWhatIf(DEMO_TEXT) },\n    { after: 2600, run: () => setView('range') },\n    { after: 3000, run: () => setView('line') },\n    ...typingSteps(RESTING_TEXT, setQuestion, { startAfter: 1200 }),\n    { after: 420, run: () => press('try') },\n    { after: PRESS_LEAD_MS, run: () => tryWhatIf(RESTING_TEXT) },\n    { after: 2000, 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-[31rem] w-full max-w-[36rem]'\n    >\n      <ForecastWhatIf\n        title='Cash runway'\n        note='Illustrative'\n        labels={LABELS}\n        actuals={ACTUALS}\n        plan={PLAN}\n        forecast={forecast}\n        monthlySpread={MONTHLY_SPREAD}\n        view={view}\n        onViewChange={setView}\n        whatIf={whatIfLabel}\n        onClear={backToPlan}\n        question={question}\n        onQuestionChange={changeQuestion}\n        onAsk={tryWhatIf}\n        suggestions={SUGGESTIONS}\n        hint={hint}\n        isTryPressed={pressed === 'try'}\n        className='w-full'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/chart-16.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/chip-button.tsx",
      "content": "'use client';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\n\ntype ChipButtonProps = {\n  onClick: () => void;\n  /** Only for a chip that can be the current one, such as a question of the history. */\n  isCurrent?: boolean;\n  children: React.ReactNode;\n};\n\nfunction currentOf(isCurrent: boolean | undefined) {\n  if (isCurrent) {\n    return 'bg-foreground/[0.08] text-foreground';\n  }\n  return 'text-muted-foreground';\n}\n\n/** A suggestion or a past entry as a pill: one press puts it to work. */\nexport function ChipButton({ onClick, isCurrent, children }: ChipButtonProps) {\n  return (\n    <button\n      type='button'\n      aria-pressed={isCurrent}\n      onClick={onClick}\n      className={cn(\n        'hairline-ring ring-foreground/12 hover:bg-foreground/5 hover:text-foreground focus-visible:ring-ring/25 max-w-full cursor-pointer truncate rounded-full px-2.5 py-1 text-start text-xs outline-none',\n        'transition-[color,background-color,scale] duration-150 ease-(--ease-out-strong) focus-visible:ring-[3px] active:scale-[0.97]',\n        currentOf(isCurrent)\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/chip-button.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/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/legend.tsx",
      "content": "'use client';\n\nimport { pressedQuietOf } from '@/registry/ndeko/lib/demonstration';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nexport type LegendEntry = {\n  id: string;\n  label: string;\n  /** The colour of the mark. The label keeps a text colour. */\n  color: string;\n  /** A series taken off the plot keeps its place and its colour, faded. */\n  isOff?: boolean;\n};\n\nfunction keyOpacityOf(isOff: boolean | undefined) {\n  if (isOff) {\n    return 'opacity-30';\n  }\n  return undefined;\n}\n\nfunction labelToneOf(isOff: boolean | undefined) {\n  if (isOff) {\n    return 'text-muted-foreground';\n  }\n  return 'text-foreground';\n}\n\nfunction LegendKey({ entry }: { entry: LegendEntry }) {\n  return (\n    <span\n      aria-hidden='true'\n      className={cn(\n        'h-0.5 w-3 shrink-0 rounded-full transition-opacity duration-150',\n        keyOpacityOf(entry.isOff)\n      )}\n      style={{ backgroundColor: entry.color }}\n    />\n  );\n}\n\ntype LegendProps = {\n  entries: readonly LegendEntry[];\n  /** What the list names, for screen readers. */\n  label: string;\n  /** With it each entry is a button that takes its series off the plot and puts it back. */\n  onToggle?: (id: string) => void;\n  /** The entry a demonstration is pressing. */\n  pressedId?: string | null;\n  className?: string;\n};\n\nexport function Legend({\n  entries,\n  label,\n  onToggle,\n  pressedId = null,\n  className,\n}: LegendProps) {\n  return (\n    <ul\n      aria-label={label}\n      className={cn('-mx-1.5 flex flex-wrap gap-x-1 gap-y-0.5', className)}\n    >\n      {entries.map((entry) => (\n        <li key={entry.id}>\n          {onToggle && (\n            <button\n              type='button'\n              aria-pressed={!entry.isOff}\n              onClick={() => onToggle(entry.id)}\n              className={cn(\n                'hover:bg-foreground/5 focus-visible:ring-ring/25 flex h-7 cursor-pointer items-center gap-2 rounded-md px-1.5 text-xs outline-none',\n                'transition-[color,background-color,scale] duration-150 ease-(--ease-out-strong) focus-visible:ring-[3px] active:scale-[0.97]',\n                labelToneOf(entry.isOff),\n                pressedQuietOf(entry.id === pressedId)\n              )}\n            >\n              <LegendKey entry={entry} />\n              {entry.label}\n            </button>\n          )}\n          {!onToggle && (\n            <span className='flex h-7 items-center gap-2 px-1.5 text-xs'>\n              <LegendKey entry={entry} />\n              {entry.label}\n            </span>\n          )}\n        </li>\n      ))}\n    </ul>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/legend.tsx"
    },
    {
      "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/question-form.tsx",
      "content": "'use client';\n\nimport { useId } from 'react';\n\nimport { pressedSolidOf } from '@/registry/ndeko/lib/demonstration';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { Input } from '@/registry/ndeko/ui/input';\n\ntype QuestionFormProps = {\n  value: string;\n  onValueChange: (value: string) => void;\n  onSubmit: (value: string) => void;\n  /** What the field is for, for screen readers. */\n  label: string;\n  placeholder: string;\n  submitLabel: string;\n  isSubmitDisabled: boolean;\n  /** Shows the button held down, for a demonstration that presses it. */\n  isSubmitPressed?: boolean;\n  /** Said under the field after an entry that gave nothing: what to try next. */\n  hint?: string | null;\n};\n\nfunction describedByOf(hasHint: boolean, hintId: string) {\n  if (hasHint) {\n    return hintId;\n  }\n  return undefined;\n}\n\n/** A field of one line and its button, with the hint an entry that gave nothing leaves under it. */\nexport function QuestionForm({\n  value,\n  onValueChange,\n  onSubmit,\n  label,\n  placeholder,\n  submitLabel,\n  isSubmitDisabled,\n  isSubmitPressed = false,\n  hint = null,\n}: QuestionFormProps) {\n  const fieldId = useId();\n  const hintId = useId();\n  const hasHint = hint !== null;\n\n  function submit(event: React.FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    onSubmit(value);\n  }\n\n  return (\n    <>\n      <form onSubmit={submit} className='flex items-center gap-2'>\n        <label htmlFor={fieldId} className='sr-only'>\n          {label}\n        </label>\n        <Input\n          id={fieldId}\n          value={value}\n          onChange={(event) => onValueChange(event.target.value)}\n          placeholder={placeholder}\n          autoComplete='off'\n          aria-describedby={describedByOf(hasHint, hintId)}\n          className='min-w-0 flex-1'\n        />\n        <Button\n          type='submit'\n          disabled={isSubmitDisabled}\n          className={pressedSolidOf(isSubmitPressed)}\n        >\n          {submitLabel}\n        </Button>\n      </form>\n      {hasHint && (\n        <p id={hintId} className='text-muted-foreground text-xs text-pretty'>\n          {hint}\n        </p>\n      )}\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/question-form.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/random.ts",
      "content": "const UINT_RANGE = 4_294_967_296;\n\nconst MULTIPLIER = 1_664_525;\n\nconst INCREMENT = 1_013_904_223;\n\n/**\n * Numbers between 0 and 1 drawn from a seed, the same on the server and in the browser, so sample\n * data never changes between two renders.\n */\nexport function seededRandom(seed: number) {\n  let state = seed >>> 0;\n\n  return function next() {\n    const multiplied = Math.imul(state, MULTIPLIER);\n    state = (multiplied + INCREMENT) >>> 0;\n    return state / UINT_RANGE;\n  };\n}\n\n/** A draw between -1 and 1 that favours small steps, from two flat draws. */\nexport function spreadOf(next: () => number) {\n  const first = next();\n  const second = next();\n  return first + second - 1;\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/random.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/readout.tsx",
      "content": "import { cn } from '@/registry/ndeko/lib/utils';\n\nconst GLIDE = 'transition-transform duration-200 ease-(--ease-out-strong)';\n\n/** A mark on the crosshair travels with the reading, and appears in place when the reading opens. */\nconst MARK_GLIDE =\n  'group-data-gliding/readout:transition-transform group-data-gliding/readout:duration-200 group-data-gliding/readout:ease-(--ease-out-strong)';\n\nfunction glideOf(isGliding: boolean) {\n  if (isGliding) {\n    return GLIDE;\n  }\n  return undefined;\n}\n\nfunction fadedUnless(isVisible: boolean) {\n  if (isVisible) {\n    return undefined;\n  }\n  return 'opacity-0';\n}\n\nfunction glidingAttributeOf(isGliding: boolean) {\n  if (isGliding) {\n    return true;\n  }\n  return undefined;\n}\n\ntype ReadoutProps = {\n  /** From 0 at the left edge of the plot to 1 at its right edge. */\n  ratio: number;\n  /** Pixels from the top of the plot down to the point the bubble sits on. */\n  top: number;\n  isVisible: boolean;\n  /** True while the reading moves to another position: the bubble travels. False when it opens. */\n  isGliding: boolean;\n  hasCrosshair?: boolean;\n  /** What is drawn on the crosshair itself, such as the dot of each series. */\n  marks?: React.ReactNode;\n  children: React.ReactNode;\n};\n\n/**\n * The one readout of a plot: a crosshair, the marks on it and a bubble. The plot is a query\n * container (`@container`), so every position is a share of its width and nothing is measured. The\n * bubble slides from its left edge to its right edge as it crosses the plot, so it never leaves it.\n * It stays mounted to fade out where it was.\n */\nexport function Readout({\n  ratio,\n  top,\n  isVisible,\n  isGliding,\n  hasCrosshair = true,\n  marks,\n  children,\n}: ReadoutProps) {\n  const glide = glideOf(isGliding);\n  const faded = fadedUnless(isVisible);\n  const share = ratio * 100;\n  const column = { transform: `translateX(${share}cqw)` };\n  const row = { transform: `translateY(${top}px)` };\n  const slide = { transform: `translateX(-${share}%)` };\n\n  return (\n    <div\n      aria-hidden='true'\n      data-gliding={glidingAttributeOf(isGliding)}\n      className='group/readout pointer-events-none absolute inset-0 z-10'\n    >\n      <div\n        className={cn('absolute inset-y-0 left-0 w-0', glide)}\n        style={column}\n      >\n        {hasCrosshair && (\n          <span\n            className={cn(\n              'bg-foreground/15 absolute inset-y-0 left-0 w-px -translate-x-1/2 transition-opacity duration-150',\n              faded\n            )}\n          />\n        )}\n        <div\n          className={cn(\n            'absolute inset-y-0 left-0 w-0 transition-opacity duration-150',\n            faded\n          )}\n        >\n          {marks}\n        </div>\n        <div className={cn('absolute top-0 left-0 size-0', glide)} style={row}>\n          <div\n            className={cn('absolute bottom-2.5 left-0 w-max', glide)}\n            style={slide}\n          >\n            <div\n              className={cn(\n                'surface-raised flex max-w-[min(14rem,100cqw)] origin-bottom flex-col gap-1 rounded-lg px-2.5 py-1.5 text-xs',\n                'transition-[opacity,scale] duration-[125ms] ease-(--ease-out-strong)',\n                !isVisible && 'scale-[0.97] opacity-0'\n              )}\n            >\n              {children}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function ReadoutTitle({ children }: { children: React.ReactNode }) {\n  return <span className='text-muted-foreground'>{children}</span>;\n}\n\ntype ReadoutRowProps = {\n  value: string;\n  name: string;\n  /** The colour of the series, drawn as a short stroke. Text never wears it. */\n  color?: string;\n};\n\nexport function ReadoutRow({ value, name, color }: ReadoutRowProps) {\n  return (\n    <span className='flex items-center gap-2'>\n      {color && (\n        <span\n          className='h-0.5 w-2.5 shrink-0 rounded-full'\n          style={{ backgroundColor: color }}\n        />\n      )}\n      <span className='text-[13px] font-medium tabular-nums'>{value}</span>\n      <span className='text-muted-foreground min-w-0 truncate'>{name}</span>\n    </span>\n  );\n}\n\ntype ReadoutDotProps = {\n  /** Pixels from the top of the plot. */\n  top: number;\n  color: string;\n};\n\n/** The mark of one series where the crosshair meets it, ringed in the colour of the surface. */\nexport function ReadoutDot({ top, color }: ReadoutDotProps) {\n  const style = {\n    backgroundColor: color,\n    transform: `translate(-50%, ${top}px)`,\n  };\n\n  return (\n    <span\n      className={cn(\n        'ring-card absolute -top-1 left-0 size-2 rounded-full ring-2',\n        MARK_GLIDE\n      )}\n      style={style}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/readout.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/today-mark.tsx",
      "content": "import { percentOf } from './scale';\n\ntype TodayMarkProps = {\n  /** From 0 at the left edge of the plot to 1 at its right edge. */\n  ratio: number;\n  /** Pixels from the top of the plot down to the value today. */\n  top: number;\n};\n\n/** Where what happened ends and what might happen starts: a hairline, its word, and the value both leave from. */\nexport function TodayMark({ ratio, top }: TodayMarkProps) {\n  return (\n    <div\n      aria-hidden='true'\n      className='pointer-events-none absolute inset-y-0 w-0'\n      style={{ left: percentOf(ratio) }}\n    >\n      <span className='hairline-l border-foreground/25 absolute inset-y-0 left-0' />\n      <span className='text-muted-foreground absolute top-0 right-1.5 text-[11px] whitespace-nowrap'>\n        Today\n      </span>\n      <span\n        className='bg-primary ring-card absolute left-0 size-2 -translate-1/2 rounded-full ring-2'\n        style={{ top }}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/today-mark.tsx"
    },
    {
      "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-plot-width.ts",
      "content": "'use client';\n\nimport { useLayoutEffect, useState } from 'react';\n\n/**\n * The width of a plot as it resizes, for what depends on the room it has: how many labels fit, how\n * many marks, which way the bars run. Shapes do not need it: they are drawn in shares of the plot.\n */\nexport function usePlotWidth(fallback: number) {\n  const [target, setTarget] = useState<HTMLElement | null>(null);\n  const [width, setWidth] = useState(fallback);\n\n  useLayoutEffect(() => {\n    if (!target) {\n      return;\n    }\n    const observer = new ResizeObserver(([entry]) => {\n      const box = entry.contentBoxSize[0];\n      setWidth(box.inlineSize);\n    });\n    observer.observe(target);\n    return () => observer.disconnect();\n  }, [target]);\n\n  return { plotRef: setTarget, width };\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/use-plot-width.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/use-reading.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\nimport { clamp, columnIndexAt } from './scale';\n\ntype ReadingOptions = {\n  /** How many positions the plot has from left to right. */\n  count: number;\n  /** Where the reading opens the first time the plot takes keyboard focus. */\n  defaultIndex: number;\n  /** A position shown from outside while nobody reads the plot, for a demonstration. */\n  guideIndex?: number | null;\n  /** Enter, Space or a click on a position. */\n  onSelect?: (index: number) => void;\n};\n\nfunction columnIndexFrom(\n  event: React.PointerEvent<HTMLElement> | React.MouseEvent<HTMLElement>,\n  count: number\n) {\n  const box = event.currentTarget.getBoundingClientRect();\n  const offset = event.clientX - box.left;\n  const ratio = offset / box.width;\n  return columnIndexAt(ratio, count);\n}\n\nexport function steppedIndexOf(key: string, current: number, count: number) {\n  const lastIndex = Math.max(count - 1, 0);\n  if (key === 'ArrowRight') {\n    const next = current + 1;\n    return Math.min(next, lastIndex);\n  }\n  if (key === 'ArrowLeft') {\n    const previous = current - 1;\n    return Math.max(previous, 0);\n  }\n  if (key === 'Home') {\n    return 0;\n  }\n  if (key === 'End') {\n    return lastIndex;\n  }\n  return null;\n}\n\nexport function activeIndexOf(isActive: boolean, shownIndex: number) {\n  if (isActive) {\n    return shownIndex;\n  }\n  return null;\n}\n\n/** A reading is said aloud only while the arrow keys move it: a pointer and a demonstration stay silent. */\nexport function readingAloud(isReadingByKeys: boolean, reading: string) {\n  if (isReadingByKeys) {\n    return reading;\n  }\n  return '';\n}\n\nfunction isSelectKey(key: string) {\n  const isEnter = key === 'Enter';\n  const isSpace = key === ' ';\n  return isEnter || isSpace;\n}\n\n/**\n * One reading position for a plot that is one tab stop. The pointer wins while it is over the plot,\n * then the arrow keys, then the position a demonstration points at. A reading that opens appears in\n * place; one that moves glides, which `isGliding` tells the readout.\n *\n * A plot read by columns spreads `handlers`. A plot whose marks are their own hit targets spreads\n * `markHandlers` and calls `pointTo` from each mark.\n */\nexport function useReading({\n  count,\n  defaultIndex,\n  guideIndex = null,\n  onSelect,\n}: ReadingOptions) {\n  const [pointerIndex, setPointerIndex] = useState<number | null>(null);\n  const [keyIndex, setKeyIndex] = useState<number | null>(null);\n  const [lastIndex, setLastIndex] = useState(defaultIndex);\n  const [isGliding, setIsGliding] = useState(false);\n  const [seenGuide, setSeenGuide] = useState(guideIndex);\n  const hasNewGuide = seenGuide !== guideIndex;\n\n  if (hasNewGuide) {\n    const wasGuided = seenGuide !== null;\n    const isGuided = guideIndex !== null;\n    setSeenGuide(guideIndex);\n    setIsGliding(wasGuided && isGuided);\n    if (guideIndex !== null) {\n      setLastIndex(guideIndex);\n    }\n  }\n\n  const activeIndex = pointerIndex ?? keyIndex ?? guideIndex;\n  const isActive = activeIndex !== null;\n  const isPointing = pointerIndex !== null;\n  const isReadingByKeys = keyIndex !== null;\n  const wantedIndex = activeIndex ?? lastIndex;\n  const highestIndex = Math.max(count - 1, 0);\n  const shownIndex = clamp(wantedIndex, 0, highestIndex);\n\n  function pointAt(event: React.PointerEvent<HTMLElement>) {\n    const index = columnIndexFrom(event, count);\n    setIsGliding(isPointing);\n    setPointerIndex(index);\n    setLastIndex(index);\n  }\n\n  /** Null when the pointer is over the plot and on no mark. */\n  function pointTo(index: number | null) {\n    if (index === null) {\n      setPointerIndex(null);\n      return;\n    }\n    setIsGliding(isPointing);\n    setPointerIndex(index);\n    setLastIndex(index);\n  }\n\n  function leave(event: React.PointerEvent<HTMLElement>) {\n    if (event.pointerType === 'mouse') {\n      setPointerIndex(null);\n    }\n  }\n\n  function focus(event: React.FocusEvent<HTMLElement>) {\n    const isKeyboardFocus = event.currentTarget.matches(':focus-visible');\n    if (isKeyboardFocus) {\n      setIsGliding(false);\n      setKeyIndex(shownIndex);\n    }\n  }\n\n  function blur() {\n    setKeyIndex(null);\n    setPointerIndex(null);\n  }\n\n  function pressKey(event: React.KeyboardEvent<HTMLElement>) {\n    if (event.key === 'Escape') {\n      setKeyIndex(null);\n      return;\n    }\n    if (isSelectKey(event.key)) {\n      if (onSelect) {\n        event.preventDefault();\n        setKeyIndex(shownIndex);\n        onSelect(shownIndex);\n      }\n      return;\n    }\n    const next = steppedIndexOf(event.key, shownIndex, count);\n    if (next === null) {\n      return;\n    }\n    event.preventDefault();\n    setIsGliding(isActive);\n    setPointerIndex(null);\n    setKeyIndex(next);\n    setLastIndex(next);\n  }\n\n  function click(event: React.MouseEvent<HTMLElement>) {\n    if (!onSelect) {\n      return;\n    }\n    const index = columnIndexFrom(event, count);\n    onSelect(index);\n  }\n\n  const handlers = {\n    onPointerMove: pointAt,\n    onPointerDown: pointAt,\n    onPointerLeave: leave,\n    onFocus: focus,\n    onBlur: blur,\n    onKeyDown: pressKey,\n    onClick: click,\n  };\n\n  const markHandlers = {\n    onPointerLeave: leave,\n    onFocus: focus,\n    onBlur: blur,\n    onKeyDown: pressKey,\n  };\n\n  return {\n    shownIndex,\n    isActive,\n    isGliding,\n    isPointing,\n    isReadingByKeys,\n    handlers,\n    markHandlers,\n    pointTo,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/use-reading.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/use-word-stream.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\n\nexport type StreamStatus = 'idle' | 'thinking' | 'streaming' | 'done';\n\nexport type WordStream = {\n  status: StreamStatus;\n  words: readonly string[];\n  /** How many of the words have arrived. */\n  shown: number;\n};\n\ntype StreamTiming = {\n  /** Milliseconds before the first word. */\n  thinkMs?: number;\n  /** Milliseconds between two words. */\n  wordMs?: number;\n};\n\nconst IDLE: WordStream = { status: 'idle', words: [], shown: 0 };\n\nexport function wordsOf(text: string) {\n  const trimmed = text.trim();\n  if (trimmed === '') {\n    return [];\n  }\n  return trimmed.split(/\\s+/);\n}\n\n/** A text that is already there, with nothing left to arrive. */\nexport function arrivedStreamOf(text: string): WordStream {\n  const words = wordsOf(text);\n  return { status: 'done', words, shown: words.length };\n}\n\nexport function textOf(stream: WordStream) {\n  return stream.words.join(' ');\n}\n\n/** What a screen reader hears: the whole text once it has arrived, never each word on its way. */\nexport function spokenStreamOf(stream: WordStream) {\n  if (stream.status === 'done') {\n    return textOf(stream);\n  }\n  return '';\n}\n\nfunction withShown(shown: number) {\n  return function reveal(current: WordStream): WordStream {\n    return { ...current, shown };\n  };\n}\n\nfunction withStatus(status: StreamStatus) {\n  return function advance(current: WordStream): WordStream {\n    return { ...current, status };\n  };\n}\n\n/**\n * A text that arrives the way a model writes it: a moment of thought, then word after word. A real\n * product replaces it with its own stream and keeps the same `WordStream` shape.\n */\nexport function useWordStream(initial: WordStream = IDLE) {\n  const [stream, setStream] = useState(initial);\n  const { schedule, clearAll } = useTimeouts();\n\n  function start(\n    text: string,\n    { thinkMs = 900, wordMs = 55 }: StreamTiming = {}\n  ) {\n    clearAll();\n    const words = wordsOf(text);\n    setStream({ status: 'thinking', words, shown: 0 });\n    schedule(() => setStream(withStatus('streaming')), thinkMs);\n    for (let shown = 1; shown <= words.length; shown += 1) {\n      const elapsed = shown * wordMs;\n      schedule(() => setStream(withShown(shown)), thinkMs + elapsed);\n    }\n    const streamMs = words.length * wordMs;\n    const lastWordAt = thinkMs + streamMs;\n    const settleMs = wordMs * 2;\n    schedule(() => setStream(withStatus('done')), lastWordAt + settleMs);\n  }\n\n  function show(next: WordStream) {\n    clearAll();\n    setStream(next);\n  }\n\n  return { stream, start, show };\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/use-word-stream.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/wrapping-roll.tsx",
      "content": "'use client';\n\nimport { WordRoll } from '@/components/core/word-roll';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nimport { wordsOf } from './use-word-stream';\n\ntype WrappingRollProps = {\n  text: string;\n  /** The words roll up when it grows and down when it falls. */\n  value?: number;\n  className?: string;\n};\n\n/**\n * A sentence that rolls and still wraps on a narrow piece. A `WordRoll` keeps all it holds on one\n * line, so each word gets its own and the line breaks between them.\n */\nexport function WrappingRoll({ text, value, className }: WrappingRollProps) {\n  const words = wordsOf(text);\n\n  return (\n    <span className={cn('block', className)}>\n      <span className='sr-only'>{text}</span>\n      <span aria-hidden='true' className='flex flex-wrap gap-x-[0.25em]'>\n        {words.map((word, index) => (\n          <WordRoll key={index} text={word} value={value} />\n        ))}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/wrapping-roll.tsx"
    }
  ],
  "categories": [
    "components",
    "chart"
  ],
  "type": "registry:component"
}