{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-07",
  "title": "Metric explainer",
  "description": "A figure with its change in words, a reading of why it moved that streams in when asked, and its drivers as bars around zero, where pointing at a driver lights its share of the total.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/badge.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/morph-button.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/skeleton.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-timeouts.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/chart/chart-07.tsx",
      "content": "'use client';\n\nimport { useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport type { AutoplayStep } from '@/registry/ndeko/hooks/use-autoplay';\nimport {\n  hold,\n  PRESS_LEAD_MS,\n  pressedQuietOf,\n  spokenUnless,\n  usePress,\n} from '@/registry/ndeko/lib/demonstration';\nimport {\n  MinusIcon,\n  TrendingDownIcon,\n  TrendingUpIcon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { MorphButton, type MorphState } from '@/registry/ndeko/ui/morph-button';\nimport { Skeleton } from '@/registry/ndeko/ui/skeleton';\n\nimport { ChartCard } from './_parts/chart-card';\nimport { DataTable, type DataRow } from './_parts/data-table';\nimport { walkSteps, whileDemonstrating } from './_parts/demo';\nimport {\n  changeWordsOf,\n  COUNT_FORMAT,\n  formatCount,\n  formatShare,\n  formatSigned,\n  percentChangeOf,\n} from './_parts/format';\nimport { percentOf, sumOf } from './_parts/scale';\nimport { StreamedWords } from './_parts/streamed-words';\nimport { useChartDemo } from './_parts/use-chart-demo';\nimport {\n  arrivedStreamOf,\n  spokenStreamOf,\n  textOf,\n  useWordStream,\n  wordsOf,\n  type WordStream,\n} from './_parts/use-word-stream';\n\nexport type MetricDriver = {\n  id: string;\n  name: string;\n  /** How much of the change this driver accounts for, in the unit of the figure. Below zero when it pulled the figure down. */\n  contribution: number;\n};\n\nexport type MetricExplainerProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  value: number;\n  previous: number;\n  /** What the figure counts, in the plural: \"teams\". */\n  unit: string;\n  /** What the figure is compared with: \"last week\". */\n  reference: string;\n  drivers: readonly MetricDriver[];\n  /** The reading of the change, as it arrives from the model. */\n  explanation: WordStream;\n  /** Explain, then Regenerate once a reading is there. */\n  onExplain: () => void;\n  /** Lights a driver from outside, for a demonstration or a control beside the piece. */\n  spotlightId?: string | null;\n  /** Shows the button held down, for a demonstration that presses it. */\n  isExplainPressed?: boolean;\n  className?: string;\n};\n\ntype Ranked = {\n  driver: MetricDriver;\n  /** Its part of everything that moved, gains and losses alike, from 0 to 1. */\n  share: number;\n};\n\ntype Reach = {\n  /** The largest gain and the largest loss, both as positive sizes. */\n  gain: number;\n  loss: number;\n};\n\ntype Trend = 'up' | 'down' | 'level';\n\nconst TREND_ICON: Record<Trend, Icon> = {\n  up: TrendingUpIcon,\n  down: TrendingDownIcon,\n  level: MinusIcon,\n};\n\nfunction trendOf(difference: number): Trend {\n  if (difference > 0) {\n    return 'up';\n  }\n  if (difference < 0) {\n    return 'down';\n  }\n  return 'level';\n}\n\nfunction sizeOf(driver: MetricDriver) {\n  return Math.abs(driver.contribution);\n}\n\n/** Drivers in the order of how much each one counted, whichever way it pushed. */\nfunction rankedOf(drivers: readonly MetricDriver[]): Ranked[] {\n  const sizes = drivers.map((driver) => sizeOf(driver));\n  const total = sumOf(sizes);\n  const moved = Math.max(total, 1);\n  const ranked = drivers.map((driver) => {\n    const share = sizeOf(driver) / moved;\n    return { driver, share };\n  });\n  return ranked.sort((first, second) => second.share - first.share);\n}\n\nfunction reachOf(drivers: readonly MetricDriver[]): Reach {\n  let gain = 0;\n  let loss = 0;\n  for (const driver of drivers) {\n    gain = Math.max(gain, driver.contribution);\n    loss = Math.max(loss, -driver.contribution);\n  }\n  return { gain, loss };\n}\n\nfunction totalsOf(drivers: readonly MetricDriver[]) {\n  let gains = 0;\n  let losses = 0;\n  for (const driver of drivers) {\n    gains += Math.max(driver.contribution, 0);\n    losses += Math.min(driver.contribution, 0);\n  }\n  return { gains, losses };\n}\n\n/** Where zero sits between the largest loss on the left and the largest gain on the right. */\nfunction zeroShareOf(reach: Reach) {\n  const span = reach.gain + reach.loss;\n  if (span === 0) {\n    return 0;\n  }\n  return reach.loss / span;\n}\n\nfunction isGain(driver: MetricDriver) {\n  return driver.contribution >= 0;\n}\n\nfunction toneOf(driver: MetricDriver) {\n  if (isGain(driver)) {\n    return 'bg-primary';\n  }\n  return 'bg-destructive';\n}\n\n/** How much of its side a bar leaves empty, as a percentage the fill is slid by. */\nfunction hiddenPercentOf(driver: MetricDriver, reach: Reach) {\n  const limit = sideLimitOf(driver, reach);\n  if (limit === 0) {\n    return 100;\n  }\n  const filled = sizeOf(driver) / limit;\n  const hidden = 1 - filled;\n  return hidden * 100;\n}\n\nfunction sideLimitOf(driver: MetricDriver, reach: Reach) {\n  if (isGain(driver)) {\n    return reach.gain;\n  }\n  return reach.loss;\n}\n\nconst SLIDE = 'transition-transform duration-300 ease-(--ease-out-strong)';\n\ntype ContributionBarProps = {\n  driver: MetricDriver;\n  reach: Reach;\n};\n\n/**\n * A bar that leaves from zero: to the right for a gain, to the left for a loss. The fill slides out\n * of a slot that clips it, so its rounded end never stretches.\n */\nfunction ContributionBar({ driver, reach }: ContributionBarProps) {\n  const zeroShare = zeroShareOf(reach);\n  const zero = percentOf(zeroShare);\n  const hidden = hiddenPercentOf(driver, reach);\n  const gains = isGain(driver);\n\n  return (\n    <span aria-hidden='true' className='relative block h-3'>\n      {gains && (\n        <span\n          className='absolute inset-y-0 right-0 overflow-hidden'\n          style={{ left: zero }}\n        >\n          <span\n            className={cn('bg-primary absolute inset-0 rounded-e-[4px]', SLIDE)}\n            style={{ transform: `translateX(-${hidden}%)` }}\n          />\n        </span>\n      )}\n      {!gains && (\n        <span\n          className='absolute inset-y-0 left-0 overflow-hidden'\n          style={{ width: zero }}\n        >\n          <span\n            className={cn(\n              'bg-destructive absolute inset-0 rounded-s-[4px]',\n              SLIDE\n            )}\n            style={{ transform: `translateX(${hidden}%)` }}\n          />\n        </span>\n      )}\n      <span\n        className='hairline-l border-foreground/25 absolute -inset-y-1'\n        style={{ left: zero }}\n      />\n    </span>\n  );\n}\n\nfunction litRowOf(isLit: boolean) {\n  if (isLit) {\n    return 'bg-foreground/[0.03]';\n  }\n  return undefined;\n}\n\ntype DriverRowProps = {\n  ranked: Ranked;\n  reach: Reach;\n  isPinned: boolean;\n  /** Its part of the total is the one lit now, whoever pointed at it. */\n  isLit: boolean;\n  onPreview: (id: string | null) => void;\n  onTogglePin: (id: string) => void;\n};\n\nfunction DriverRow({\n  ranked,\n  reach,\n  isPinned,\n  isLit,\n  onPreview,\n  onTogglePin,\n}: DriverRowProps) {\n  const { driver, share } = ranked;\n\n  function preview() {\n    onPreview(driver.id);\n  }\n\n  function clearPreview() {\n    onPreview(null);\n  }\n\n  return (\n    <button\n      type='button'\n      aria-pressed={isPinned}\n      onPointerEnter={preview}\n      onPointerLeave={clearPreview}\n      onFocus={preview}\n      onBlur={clearPreview}\n      onClick={() => onTogglePin(driver.id)}\n      className={cn(\n        'hover:bg-foreground/[0.03] focus-visible:ring-ring/25 aria-pressed:bg-foreground/[0.05] grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 rounded-md px-2 py-1.5 text-start text-[13px] transition-colors duration-150 outline-none focus-visible:ring-[3px]',\n        '@sm:h-8 @sm:grid-cols-[minmax(0,6.5rem)_minmax(0,1fr)_auto] @sm:py-0',\n        litRowOf(isLit)\n      )}\n    >\n      <span className='col-start-1 row-start-1 truncate'>{driver.name}</span>\n      <span className='col-span-2 row-start-2 @sm:col-span-1 @sm:col-start-2 @sm:row-start-1'>\n        <ContributionBar driver={driver} reach={reach} />\n      </span>\n      <span className='col-start-2 row-start-1 flex items-baseline gap-2 tabular-nums @sm:col-start-3'>\n        <span className='w-9 text-end font-medium'>\n          {formatSigned(driver.contribution)}\n        </span>\n        <span className='text-muted-foreground w-8 text-end text-xs'>\n          {formatShare(share)}\n        </span>\n      </span>\n    </button>\n  );\n}\n\nfunction dimmedOf(isDimmed: boolean) {\n  if (isDimmed) {\n    return 'opacity-30';\n  }\n  return undefined;\n}\n\n/** Gains first, then losses, so the bar reads as what was added and what was taken away. */\nfunction gainsFirst(ranked: readonly Ranked[]) {\n  const gains = ranked.filter((entry) => isGain(entry.driver));\n  const losses = ranked.filter((entry) => !isGain(entry.driver));\n  return [...gains, ...losses];\n}\n\ntype TotalBarProps = {\n  ranked: readonly Ranked[];\n  activeId: string | null;\n};\n\n/** Everything that moved as one bar, each driver a segment as wide as its share. */\nfunction TotalBar({ ranked, activeId }: TotalBarProps) {\n  const segments = gainsFirst(ranked);\n  const hasActive = activeId !== null;\n\n  return (\n    <span aria-hidden='true' className='flex h-2.5 gap-0.5'>\n      {segments.map((entry) => {\n        const isDimmed = hasActive && entry.driver.id !== activeId;\n\n        return (\n          <span\n            key={entry.driver.id}\n            className={cn(\n              'min-w-0.5 basis-0 rounded-[2px] transition-opacity duration-150',\n              toneOf(entry.driver),\n              dimmedOf(isDimmed)\n            )}\n            style={{ flexGrow: entry.share }}\n          />\n        );\n      })}\n    </span>\n  );\n}\n\nconst EXPLAIN_LABELS: Record<MorphState, string> = {\n  idle: 'Explain',\n  busy: 'Reading',\n  done: 'Read',\n};\n\nconst REGENERATE_LABELS: Record<MorphState, string> = {\n  idle: 'Regenerate',\n  busy: 'Reading',\n  done: 'Read',\n};\n\nfunction labelsOf(explanation: WordStream) {\n  if (explanation.words.length === 0) {\n    return EXPLAIN_LABELS;\n  }\n  return REGENERATE_LABELS;\n}\n\nfunction buttonStateOf(explanation: WordStream): MorphState {\n  const isThinking = explanation.status === 'thinking';\n  const isStreaming = explanation.status === 'streaming';\n  if (isThinking || isStreaming) {\n    return 'busy';\n  }\n  return 'idle';\n}\n\ntype WhyItMovedProps = {\n  explanation: WordStream;\n  onExplain: () => void;\n  isExplainPressed: boolean;\n};\n\nfunction WhyItMoved({\n  explanation,\n  onExplain,\n  isExplainPressed,\n}: WhyItMovedProps) {\n  const isIdle = explanation.status === 'idle';\n  const isThinking = explanation.status === 'thinking';\n  const isStreaming = explanation.status === 'streaming';\n  const hasWords = !isIdle && !isThinking;\n  const hasText = explanation.words.length > 0;\n\n  return (\n    <div className='bg-foreground/[0.04] flex flex-col gap-2 rounded-lg p-3'>\n      <div className='flex items-center justify-between gap-2'>\n        <h4 className='text-xs font-medium'>Why it moved</h4>\n        <MorphButton\n          type='button'\n          size='sm'\n          variant='outline'\n          state={buttonStateOf(explanation)}\n          labels={labelsOf(explanation)}\n          onClick={onExplain}\n          className={pressedQuietOf(isExplainPressed)}\n        />\n      </div>\n      <div className='grid min-h-[3.75rem] text-[13px] leading-5 text-pretty'>\n        {isIdle && (\n          <p className='text-muted-foreground'>\n            Ask for a reading of what moved this figure\n          </p>\n        )}\n        {hasText && (\n          <p aria-hidden='true' className='invisible col-start-1 row-start-1'>\n            {textOf(explanation)}\n          </p>\n        )}\n        {isThinking && (\n          <div className='col-start-1 row-start-1 flex flex-col gap-2 pt-1'>\n            <Skeleton className='h-3 w-full' />\n            <Skeleton className='h-3 w-4/5' />\n          </div>\n        )}\n        {hasWords && (\n          <p className='col-start-1 row-start-1'>\n            <StreamedWords\n              words={explanation.words}\n              shown={explanation.shown}\n              isStreaming={isStreaming}\n            />\n          </p>\n        )}\n      </div>\n    </div>\n  );\n}\n\n/** What a person points at comes first, then what they pinned, then what was lit from outside. */\nfunction activeIdOf(\n  preview: string | null,\n  pinned: string | null,\n  spotlight: string | null\n) {\n  return preview ?? pinned ?? spotlight;\n}\n\nfunction captionOf(\n  active: Ranked | undefined,\n  drivers: readonly MetricDriver[],\n  unit: string\n) {\n  if (active) {\n    const amount = formatSigned(active.driver.contribution);\n    const share = formatShare(active.share);\n    return `${active.driver.name}: ${amount} ${unit}, ${share} of what moved`;\n  }\n  const totals = totalsOf(drivers);\n  const gains = formatSigned(totals.gains);\n  const losses = formatSigned(totals.losses);\n  return `Gains ${gains} · Losses ${losses}`;\n}\n\nexport function MetricExplainer({\n  title,\n  note,\n  value,\n  previous,\n  unit,\n  reference,\n  drivers,\n  explanation,\n  onExplain,\n  spotlightId = null,\n  isExplainPressed = false,\n  className,\n}: MetricExplainerProps) {\n  const [previewId, setPreviewId] = useState<string | null>(null);\n  const [pinnedId, setPinnedId] = useState<string | null>(null);\n  const [hasAsked, setHasAsked] = useState(false);\n  const difference = value - previous;\n  const percent = percentChangeOf(value, previous);\n  const change = changeWordsOf(percent, reference);\n  const trend = trendOf(difference);\n  const TrendIcon = TREND_ICON[trend];\n  const ranked = rankedOf(drivers);\n  const reach = reachOf(drivers);\n  const activeId = activeIdOf(previewId, pinnedId, spotlightId);\n  const active = ranked.find((entry) => entry.driver.id === activeId);\n  const figure = formatCount(value);\n  const spokenReading = spokenStreamOf(explanation);\n  const summary = `${figure} ${unit}, ${change}, with ${drivers.length} drivers ranked by how much each one counted.`;\n\n  const rows: DataRow[] = ranked.map((entry) => ({\n    id: entry.driver.id,\n    label: entry.driver.name,\n    cells: [formatSigned(entry.driver.contribution), formatShare(entry.share)],\n  }));\n\n  /** A reading is said aloud once a person has asked for one: a demonstration stays silent. */\n  function explainByHand() {\n    setHasAsked(true);\n    onExplain();\n  }\n\n  function togglePin(id: string) {\n    setPinnedId((current) => {\n      if (current === id) {\n        return null;\n      }\n      return id;\n    });\n  }\n\n  return (\n    <ChartCard\n      title={title}\n      note={note}\n      summary={summary}\n      className={className}\n    >\n      <div className='flex flex-col gap-1'>\n        <p className='flex flex-wrap items-baseline gap-x-2'>\n          <MotionNumber\n            value={value}\n            format={COUNT_FORMAT}\n            className='text-[28px] leading-9 font-semibold'\n          />\n          <span className='text-muted-foreground text-[13px]'>{unit}</span>\n        </p>\n        <p className='text-muted-foreground flex items-center gap-1.5 text-xs'>\n          <TrendIcon className='size-3.5 shrink-0' />\n          <span>\n            {change}{' '}\n            <span className='tabular-nums'>({formatSigned(difference)})</span>\n          </span>\n        </p>\n      </div>\n      <WhyItMoved\n        explanation={explanation}\n        onExplain={explainByHand}\n        isExplainPressed={isExplainPressed}\n      />\n      <div className='flex flex-col gap-1.5'>\n        <TotalBar ranked={ranked} activeId={activeId} />\n        <p className='text-muted-foreground min-h-8 text-xs text-pretty tabular-nums @sm:min-h-0'>\n          {captionOf(active, drivers, unit)}\n        </p>\n      </div>\n      <div className='flex flex-col gap-1'>\n        <h4 className='text-xs font-medium'>What moved it</h4>\n        <ul className='-mx-2 flex flex-col'>\n          {ranked.map((entry) => (\n            <li key={entry.driver.id}>\n              <DriverRow\n                ranked={entry}\n                reach={reach}\n                isPinned={pinnedId === entry.driver.id}\n                isLit={activeId === entry.driver.id}\n                onPreview={setPreviewId}\n                onTogglePin={togglePin}\n              />\n            </li>\n          ))}\n        </ul>\n      </div>\n      <LiveRegion message={spokenUnless(!hasAsked, spokenReading)} />\n      <DataTable\n        caption={`What moved ${title}`}\n        columns={['Driver', `Contribution in ${unit}`, 'Share of what moved']}\n        rows={rows}\n      />\n    </ChartCard>\n  );\n}\n\nconst DRIVERS: MetricDriver[] = [\n  { id: 'signups', name: 'New signups', contribution: 48 },\n  { id: 'returning', name: 'Returning teams', contribution: 39 },\n  { id: 'mobile', name: 'Mobile release', contribution: 21 },\n  { id: 'season', name: 'Seasonal dip', contribution: -18 },\n  { id: 'trials', name: 'Trials that ended', contribution: -15 },\n];\n\nconst DRIVER_IDS = DRIVERS.map((driver) => driver.id);\n\nconst CONTRIBUTIONS = DRIVERS.map((driver) => driver.contribution);\n\nconst ACTIVE_TEAMS = 1284;\n\nconst TEAMS_LAST_WEEK = ACTIVE_TEAMS - sumOf(CONTRIBUTIONS);\n\nconst READINGS = [\n  'Most of the lift came from new signups after the launch on Tuesday, and from teams that came back once the mobile release fixed sign-in. A seasonal dip and a handful of trials that ended held the week back by 33 teams.',\n  'New signups and returning teams added 87 active teams between them, and the mobile release brought 21 more. Without the seasonal dip and the trials that ended, the week would have closed 33 teams higher.',\n];\n\nconst FIRST_READING = arrivedStreamOf(READINGS[0]);\n\nconst THINK_MS = 900;\n\nconst WORD_MS = 55;\n\nconst SECOND_READING_WORDS = wordsOf(READINGS[1]);\n\nconst WRITING_MS = SECOND_READING_WORDS.length * WORD_MS;\n\n/** The round waits for the last word of the second reading, then leaves it time to be read. */\nconst READ_AFTER_MS = THINK_MS + WRITING_MS + 2000;\n\ntype PressKey = 'explain';\n\nexport default function Chart07() {\n  const [turn, setTurn] = useState(0);\n  const [spotlight, setSpotlight] = useState<string | null>(null);\n  const { stream, start, show } = useWordStream(FIRST_READING);\n  const { pressed, press } = usePress<PressKey>();\n\n  function explain() {\n    const following = turn + 1;\n    const next = following % READINGS.length;\n    setTurn(next);\n    start(READINGS[next], { thinkMs: THINK_MS, wordMs: WORD_MS });\n  }\n\n  function resetDemo() {\n    setTurn(0);\n    setSpotlight(null);\n    show(FIRST_READING);\n  }\n\n  const steps: AutoplayStep[] = [\n    ...walkSteps(DRIVER_IDS, setSpotlight, { startAfter: 1300, stepMs: 750 }),\n    { after: 900, run: () => setSpotlight(null) },\n    { after: 600, run: () => press('explain') },\n    { after: PRESS_LEAD_MS, run: explain },\n    { after: READ_AFTER_MS, run: hold },\n  ];\n  const { ref, isAutoplaying, stopOnKeyboardFocus } = useChartDemo(steps, {\n    loopAfter: 1600,\n    onReset: resetDemo,\n  });\n\n  return (\n    <div\n      ref={ref}\n      onFocus={stopOnKeyboardFocus}\n      className='flex min-h-[32.5rem] w-full max-w-[30rem]'\n    >\n      <MetricExplainer\n        title='Weekly active teams'\n        note='Illustrative'\n        value={ACTIVE_TEAMS}\n        previous={TEAMS_LAST_WEEK}\n        unit='teams'\n        reference='last week'\n        drivers={DRIVERS}\n        explanation={stream}\n        onExplain={explain}\n        spotlightId={whileDemonstrating(isAutoplaying, spotlight)}\n        isExplainPressed={pressed === 'explain'}\n        className='w-full'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/chart-07.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/chart-card.tsx",
      "content": "import { useId } from 'react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Badge } from '@/registry/ndeko/ui/badge';\n\ntype ChartCardProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  /** One line that says what the chart shows, read by screen readers with the title. */\n  summary: string;\n  /** Controls that change what the chart draws: a period, a view. */\n  actions?: React.ReactNode;\n  className?: string;\n  children: React.ReactNode;\n};\n\n/**\n * The surface every chart sits on. It is a query container, so a chart lays itself out by the\n * room it has, and it is positioned, so the hidden table of a chart never widens the page.\n */\nexport function ChartCard({\n  title,\n  note,\n  summary,\n  actions,\n  className,\n  children,\n}: ChartCardProps) {\n  const titleId = useId();\n  const summaryId = useId();\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      aria-describedby={summaryId}\n      className={cn(\n        'bg-card hairline-ring ring-border @container relative flex flex-col gap-4 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]',\n        className\n      )}\n    >\n      <header className='flex min-h-7 flex-wrap items-center justify-between gap-x-3 gap-y-2'>\n        <div className='flex min-w-0 items-center gap-2'>\n          <h3 id={titleId} className='text-sm font-medium text-balance'>\n            {title}\n          </h3>\n          {note && <Badge variant='secondary'>{note}</Badge>}\n        </div>\n        {actions}\n      </header>\n      {children}\n      <p id={summaryId} className='sr-only'>\n        {summary}\n      </p>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/chart-card.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/data-table.tsx",
      "content": "export type DataRow = {\n  id: string;\n  /** The heading of the row: a day, a category, a step. */\n  label: string;\n  cells: readonly string[];\n};\n\ntype DataTableProps = {\n  caption: string;\n  /** The heading of the label column first, then one per cell. */\n  columns: readonly string[];\n  rows: readonly DataRow[];\n};\n\n/**\n * The numbers a chart draws, as a table for screen readers. It is taken out of the flow, so the\n * piece it sits in has to be positioned: a hidden box inside an area that scrolls sideways widens\n * the page otherwise.\n */\nexport function DataTable({ caption, columns, rows }: DataTableProps) {\n  return (\n    <table className='sr-only'>\n      <caption>{caption}</caption>\n      <thead>\n        <tr>\n          {columns.map((column) => (\n            <th key={column} scope='col'>\n              {column}\n            </th>\n          ))}\n        </tr>\n      </thead>\n      <tbody>\n        {rows.map((row) => (\n          <tr key={row.id}>\n            <th scope='row'>{row.label}</th>\n            {row.cells.map((cell, index) => (\n              <td key={index}>{cell}</td>\n            ))}\n          </tr>\n        ))}\n      </tbody>\n    </table>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/data-table.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/demo.ts",
      "content": "import type { AutoplayStep } from '@/registry/ndeko/hooks/use-autoplay';\nimport { keyDelayOf } from '@/registry/ndeko/lib/demonstration';\n\ntype WalkOptions = {\n  /** Milliseconds before the first position. */\n  startAfter?: number;\n  /** Milliseconds between two positions. */\n  stepMs?: number;\n};\n\n/** One step per position, so a reading crosses a plot the way a pointer does. */\nexport function walkSteps<Position>(\n  positions: readonly Position[],\n  moveTo: (position: Position) => void,\n  { startAfter = 700, stepMs = 240 }: WalkOptions = {}\n): AutoplayStep[] {\n  return positions.map((position, index) => {\n    const after = keyDelayOf(index, startAfter, stepMs);\n    return { after, run: () => moveTo(position) };\n  });\n}\n\n/** What a demonstration points at lets go the moment a person takes over. */\nexport function whileDemonstrating<Value>(\n  isDemonstrating: boolean,\n  value: Value | null\n) {\n  if (isDemonstrating) {\n    return value;\n  }\n  return null;\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/demo.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/format.ts",
      "content": "const LOCALE = 'en-US';\n\nexport const COUNT_FORMAT: Intl.NumberFormatOptions = {\n  maximumFractionDigits: 0,\n};\n\nexport const SIGNED_FORMAT: Intl.NumberFormatOptions = {\n  maximumFractionDigits: 0,\n  signDisplay: 'exceptZero',\n};\n\nexport const USD_COMPACT_FORMAT: Intl.NumberFormatOptions = {\n  style: 'currency',\n  currency: 'USD',\n  notation: 'compact',\n  maximumFractionDigits: 1,\n};\n\nconst COMPACT_FORMAT: Intl.NumberFormatOptions = {\n  notation: 'compact',\n  maximumFractionDigits: 1,\n};\n\nconst COUNT = new Intl.NumberFormat(LOCALE, COUNT_FORMAT);\n\nconst SIGNED = new Intl.NumberFormat(LOCALE, SIGNED_FORMAT);\n\nconst COMPACT = new Intl.NumberFormat(LOCALE, COMPACT_FORMAT);\n\nconst USD_COMPACT = new Intl.NumberFormat(LOCALE, USD_COMPACT_FORMAT);\n\nconst DAY = new Intl.DateTimeFormat(LOCALE, {\n  month: 'short',\n  day: 'numeric',\n  timeZone: 'UTC',\n});\n\nconst MS_PER_DAY = 86_400_000;\n\nexport function formatCount(value: number) {\n  return COUNT.format(value);\n}\n\nexport function formatSigned(value: number) {\n  return SIGNED.format(value);\n}\n\nexport function formatCompact(value: number) {\n  return COMPACT.format(value);\n}\n\nexport function formatUsd(value: number) {\n  return USD_COMPACT.format(value);\n}\n\nexport function formatShare(share: number) {\n  const percent = Math.round(share * 100);\n  return `${percent}%`;\n}\n\n/** A start at zero has no percentage to grow from: it reads as no change. */\nexport function percentChangeOf(current: number, previous: number) {\n  if (previous === 0) {\n    return 0;\n  }\n  const difference = current - previous;\n  const ratio = difference / Math.abs(previous);\n  return Math.round(ratio * 100);\n}\n\nexport function changeWordsOf(percent: number, reference: string) {\n  if (percent === 0) {\n    return `level with ${reference}`;\n  }\n  const size = Math.abs(percent);\n  if (percent > 0) {\n    return `up ${size}% on ${reference}`;\n  }\n  return `down ${size}% on ${reference}`;\n}\n\nexport function nounOf(count: number, singular: string, plural: string) {\n  if (count === 1) {\n    return singular;\n  }\n  return plural;\n}\n\n/**\n * One label per day, or per `everyDays` days for a weekly series. Days are counted in UTC so the\n * server and the browser write the same label.\n */\nexport function dayLabelsOf(firstDay: string, count: number, everyDays = 1) {\n  const start = Date.parse(`${firstDay}T00:00:00Z`);\n  const stride = everyDays * MS_PER_DAY;\n  return Array.from({ length: count }, (_, index) => {\n    const elapsed = index * stride;\n    return DAY.format(start + elapsed);\n  });\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/format.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/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/streamed-words.tsx",
      "content": "type StreamedWordsProps = {\n  words: readonly string[];\n  /** How many of the words have arrived. */\n  shown: number;\n  /** Draws a caret after the last word while more are coming. */\n  isStreaming?: boolean;\n  className?: string;\n};\n\n/** Each word fades in as it mounts, so a streamed line settles word by word with no script per frame. */\nexport function StreamedWords({\n  words,\n  shown,\n  isStreaming = false,\n  className,\n}: StreamedWordsProps) {\n  const visible = words.slice(0, shown);\n\n  return (\n    <span className={className}>\n      {visible.map((word, index) => (\n        <span\n          key={index}\n          className='transition-opacity duration-300 ease-(--ease-out-strong) starting:opacity-0'\n        >\n          {word}{' '}\n        </span>\n      ))}\n      {isStreaming && (\n        <span\n          aria-hidden='true'\n          className='bg-foreground/50 inline-block h-[1em] w-[2px] animate-pulse rounded-full align-[-0.15em]'\n        />\n      )}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/streamed-words.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-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"
    }
  ],
  "categories": [
    "components",
    "chart"
  ],
  "type": "registry:component"
}