{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-08",
  "title": "Shift",
  "description": "Where visitors come from as a list, as bars or as change, where switching view makes every icon, figure and bar glide to its new place instead of being drawn again.",
  "dependencies": [
    "motion"
  ],
  "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/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-08.tsx",
      "content": "'use client';\n\nimport { motion, type Transition } from 'motion/react';\nimport { useState } from 'react';\n\nimport type { AutoplayStep } from '@/registry/ndeko/hooks/use-autoplay';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { hold } from '@/registry/ndeko/lib/demonstration';\nimport {\n  Link2Icon,\n  MailIcon,\n  MousePointerClickIcon,\n  SearchIcon,\n  Share2Icon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport {\n  EASE_IN_OUT_STRONG,\n  INSTANT,\n} from '@/registry/ndeko/lib/motion-presets';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Segments, type Segment } from '@/registry/ndeko/ui/segments';\n\nimport { ChartCard } from './_parts/chart-card';\nimport {\n  ROUNDED_ENDS,\n  ROUNDED_LEFT,\n  ROUNDED_RIGHT,\n  ROUNDED_TOP,\n} from './_parts/corners';\nimport { DataTable, type DataRow } from './_parts/data-table';\nimport { formatCount } from './_parts/format';\nimport { percentOf } from './_parts/scale';\nimport { useChartDemo } from './_parts/use-chart-demo';\nimport { WrappingRoll } from './_parts/wrapping-roll';\n\nexport type ShiftView = 'list' | 'bars' | 'change';\n\nexport type ShiftSource = {\n  id: string;\n  name: string;\n  icon: Icon;\n  value: number;\n  /** The change against the period before, as a whole percentage. Below zero when it fell. */\n  change: number;\n};\n\nexport type SourceShiftProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  sources: readonly ShiftSource[];\n  view: ShiftView;\n  onViewChange: (view: ShiftView) => void;\n  /** What the values count, in the plural: \"visits\". */\n  unit: string;\n  /** The period the values cover: \"this week\". */\n  period: string;\n  className?: string;\n};\n\nconst VIEWS: Segment<ShiftView>[] = [\n  { value: 'list', label: 'List' },\n  { value: 'bars', label: 'Bars' },\n  { value: 'change', label: 'Change' },\n];\n\n/** The height of the tallest bar when the sources stand side by side. */\nconst BAR_AREA_PX = 116;\n\nconst GLIDE: Transition = {\n  layout: { duration: 0.42, ease: EASE_IN_OUT_STRONG },\n};\n\nconst NO_GLIDE: Transition = { layout: INSTANT };\n\nfunction glideOf(shouldReduceMotion: boolean) {\n  if (shouldReduceMotion) {\n    return NO_GLIDE;\n  }\n  return GLIDE;\n}\n\ntype ViewClasses = {\n  list: string;\n  item: string;\n  icon: string;\n  name: string;\n  value: string;\n  change: string;\n  bar: string;\n  /** The line a change is measured from. Only the change view draws it. */\n  zero: string;\n};\n\n/**\n * One set of elements, three places for each of them. A view only says where an element sits, so\n * switching view moves what is already there instead of drawing it again.\n */\nconst VIEW_CLASSES: Record<ShiftView, ViewClasses> = {\n  list: {\n    list: 'flex flex-col',\n    item: 'grid grid-cols-[auto_minmax(0,1fr)_auto_2.5rem] items-center gap-x-2 gap-y-1 py-1.5 @xs:grid-cols-[auto_minmax(0,1fr)_auto_3rem] @xs:gap-x-3',\n    icon: 'row-span-2',\n    name: 'col-start-2 row-start-1 truncate text-[13px]',\n    value: 'col-start-3 row-start-1 text-[13px] font-medium',\n    change:\n      'text-muted-foreground col-start-4 row-start-1 justify-self-end text-xs',\n    bar: 'col-start-2 col-end-5 row-start-2 h-1 justify-self-start',\n    zero: 'hidden',\n  },\n  bars: {\n    list: 'grid items-end gap-1.5',\n    item: 'flex min-w-0 flex-col items-center gap-1.5',\n    icon: 'order-3',\n    name: 'sr-only order-4 text-center text-[11px] @sm:not-sr-only @sm:w-full @sm:truncate',\n    value: 'order-1 text-xs font-medium @sm:text-[13px]',\n    change: 'text-muted-foreground order-5 text-[11px]',\n    bar: 'order-2 w-6 max-w-full',\n    zero: 'hidden',\n  },\n  change: {\n    list: 'flex flex-col',\n    item: 'grid grid-cols-[auto_4.5rem_minmax(0,1fr)_2.5rem] items-center gap-x-2 py-1.5 @xs:grid-cols-[auto_5.5rem_minmax(0,1fr)_3rem] @xs:gap-x-3',\n    icon: 'row-span-2',\n    name: 'col-start-2 row-start-1 truncate text-[13px]',\n    value: 'text-muted-foreground col-start-2 row-start-2 text-[11px]',\n    change:\n      'col-start-4 row-span-2 row-start-1 justify-self-end text-[13px] font-medium',\n    bar: 'col-start-3 row-span-2 row-start-1 h-3 justify-self-start',\n    zero: 'bg-foreground/20 col-start-3 row-span-2 row-start-1 h-full w-px justify-self-center',\n  },\n};\n\nfunction largestOf(values: readonly number[]) {\n  let largest = 1;\n  for (const value of values) {\n    largest = Math.max(largest, value);\n  }\n  return largest;\n}\n\ntype Bounds = {\n  /** The largest value, and the largest change whichever way it went. */\n  value: number;\n  change: number;\n};\n\nfunction boundsOf(sources: readonly ShiftSource[]): Bounds {\n  const values = sources.map((source) => source.value);\n  const changes = sources.map((source) => Math.abs(source.change));\n  return { value: largestOf(values), change: largestOf(changes) };\n}\n\n/** A change grows from the middle of its track: to the right when it rose, to the left when it fell. */\nfunction changeBarOf(source: ShiftSource, bounds: Bounds): React.CSSProperties {\n  const size = Math.abs(source.change);\n  const reach = size / bounds.change;\n  const half = reach * 0.5;\n  const width = percentOf(half);\n  if (source.change >= 0) {\n    return { marginLeft: '50%', width, ...ROUNDED_RIGHT };\n  }\n  const start = 0.5 - half;\n  return { marginLeft: percentOf(start), width, ...ROUNDED_LEFT };\n}\n\nfunction barStyleOf(\n  source: ShiftSource,\n  view: ShiftView,\n  bounds: Bounds\n): React.CSSProperties {\n  const share = source.value / bounds.value;\n  if (view === 'bars') {\n    const height = share * BAR_AREA_PX;\n    return { height, ...ROUNDED_TOP };\n  }\n  if (view === 'change') {\n    return changeBarOf(source, bounds);\n  }\n  return { width: percentOf(share), ...ROUNDED_ENDS };\n}\n\n/** In the change view a bar says which way a source went, so it takes the colour of that way. */\nfunction barToneOf(source: ShiftSource, view: ShiftView) {\n  const hasFallen = source.change < 0;\n  const showsChange = view === 'change';\n  if (hasFallen && showsChange) {\n    return 'bg-destructive';\n  }\n  return 'bg-primary';\n}\n\nfunction changeTextOf(change: number) {\n  if (change > 0) {\n    return `+${change}%`;\n  }\n  return `${change}%`;\n}\n\n/** The sources in the order a view reads them: the largest first, or the fastest riser first. */\nfunction orderedFor(view: ShiftView, sources: readonly ShiftSource[]) {\n  const ordered = [...sources];\n  if (view === 'change') {\n    return ordered.sort((first, second) => second.change - first.change);\n  }\n  return ordered.sort((first, second) => second.value - first.value);\n}\n\ntype Reading = {\n  headline: string;\n  detail: string;\n};\n\nfunction fallOf(source: ShiftSource | undefined) {\n  const hasFallen = source !== undefined && source.change < 0;\n  if (!hasFallen) {\n    return 'No source fell';\n  }\n  const size = Math.abs(source.change);\n  return `${source.name} fell the most, down ${size}%`;\n}\n\n/** The view in two lines: what leads, then the figure behind it. */\nfunction readingOf(\n  view: ShiftView,\n  sources: readonly ShiftSource[],\n  unit: string,\n  period: string\n): Reading {\n  const byValue = orderedFor('list', sources);\n  const byChange = orderedFor('change', sources);\n  const largest = byValue.at(0);\n  const riser = byChange.at(0);\n  const faller = byChange.at(-1);\n  const hasSources = largest !== undefined && riser !== undefined;\n  if (!hasSources) {\n    return { headline: 'No source yet', detail: '' };\n  }\n  if (view === 'change') {\n    return {\n      headline: `${riser.name} grew the fastest, up ${riser.change}%`,\n      detail: fallOf(faller),\n    };\n  }\n  const amount = formatCount(largest.value);\n  return {\n    headline: `${largest.name} brings the most ${unit}`,\n    detail: `${amount} ${period}`,\n  };\n}\n\nfunction listStyleOf(view: ShiftView, count: number) {\n  if (view === 'bars') {\n    return { gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` };\n  }\n  return undefined;\n}\n\ntype SourceItemProps = {\n  source: ShiftSource;\n  view: ShiftView;\n  bounds: Bounds;\n  transition: Transition;\n};\n\n/** Every part of a source keeps its element from one view to the next, and glides to its new place. */\nfunction SourceItem({ source, view, bounds, transition }: SourceItemProps) {\n  const classes = VIEW_CLASSES[view];\n  const SourceIcon = source.icon;\n\n  return (\n    <li className={classes.item}>\n      <motion.span\n        layout='position'\n        layoutDependency={view}\n        transition={transition}\n        aria-hidden='true'\n        className={cn(\n          'keycap-3d text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md',\n          classes.icon\n        )}\n      >\n        <SourceIcon className='size-3.5' />\n      </motion.span>\n      <motion.span\n        layout='position'\n        layoutDependency={view}\n        transition={transition}\n        className={cn('min-w-0', classes.name)}\n      >\n        {source.name}\n      </motion.span>\n      <motion.span\n        layout='position'\n        layoutDependency={view}\n        transition={transition}\n        className={cn(\n          'tabular-nums transition-colors duration-200',\n          classes.value\n        )}\n      >\n        {formatCount(source.value)}\n      </motion.span>\n      <motion.span\n        layout='position'\n        layoutDependency={view}\n        transition={transition}\n        className={cn(\n          'tabular-nums transition-colors duration-200',\n          classes.change\n        )}\n      >\n        {changeTextOf(source.change)}\n      </motion.span>\n      <span aria-hidden='true' className={classes.zero} />\n      <motion.span\n        layout\n        layoutDependency={view}\n        transition={transition}\n        aria-hidden='true'\n        className={cn(\n          'transition-colors duration-300',\n          barToneOf(source, view),\n          classes.bar\n        )}\n        style={barStyleOf(source, view, bounds)}\n      />\n    </li>\n  );\n}\n\nexport function SourceShift({\n  title,\n  note,\n  sources,\n  view,\n  onViewChange,\n  unit,\n  period,\n  className,\n}: SourceShiftProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const transition = glideOf(shouldReduceMotion);\n  const bounds = boundsOf(sources);\n  const ordered = orderedFor(view, sources);\n  const reading = readingOf(view, sources, unit, period);\n  const classes = VIEW_CLASSES[view];\n  const summary = `${reading.headline}. ${reading.detail}.`;\n\n  const rows: DataRow[] = ordered.map((source) => ({\n    id: source.id,\n    label: source.name,\n    cells: [formatCount(source.value), changeTextOf(source.change)],\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={onViewChange}\n          label='How to read the sources'\n        />\n      }\n    >\n      <div className='flex flex-col'>\n        <p className='text-sm font-medium'>\n          <WrappingRoll text={reading.headline} />\n        </p>\n        <p className='text-muted-foreground text-xs tabular-nums'>\n          <WrappingRoll text={reading.detail} />\n        </p>\n      </div>\n      <ul className={classes.list} style={listStyleOf(view, ordered.length)}>\n        {ordered.map((source) => (\n          <SourceItem\n            key={source.id}\n            source={source}\n            view={view}\n            bounds={bounds}\n            transition={transition}\n          />\n        ))}\n      </ul>\n      <DataTable\n        caption={`${title}, ${period}`}\n        columns={['Source', unit, 'Change on the period before']}\n        rows={rows}\n      />\n    </ChartCard>\n  );\n}\n\nconst SOURCES: ShiftSource[] = [\n  { id: 'search', name: 'Search', icon: SearchIcon, value: 4212, change: 12 },\n  {\n    id: 'direct',\n    name: 'Direct',\n    icon: MousePointerClickIcon,\n    value: 2870,\n    change: 3,\n  },\n  {\n    id: 'referral',\n    name: 'Referral',\n    icon: Link2Icon,\n    value: 1640,\n    change: -4,\n  },\n  { id: 'social', name: 'Social', icon: Share2Icon, value: 1105, change: -9 },\n  {\n    id: 'newsletter',\n    name: 'Newsletter',\n    icon: MailIcon,\n    value: 760,\n    change: 31,\n  },\n];\n\nexport default function Chart08() {\n  const [view, setView] = useState<ShiftView>('list');\n\n  function resetDemo() {\n    setView('list');\n  }\n\n  const steps: AutoplayStep[] = [\n    { after: 2200, run: () => setView('bars') },\n    { after: 2800, run: () => setView('change') },\n    { after: 2800, run: () => setView('list') },\n    { after: 1200, run: hold },\n  ];\n  const { ref, 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-[23rem] w-full max-w-[26rem]'\n    >\n      <SourceShift\n        title='Where visitors come from'\n        note='Illustrative'\n        sources={SOURCES}\n        view={view}\n        onViewChange={setView}\n        unit='visits'\n        period='this week'\n        className='w-full'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/chart-08.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/corners.ts",
      "content": "/**\n * The four corners of a bar as numbers in a style object, where a layout animation can read them:\n * a bar that changes size keeps true corners instead of corners stretched with it.\n */\nexport type Corners = {\n  borderTopLeftRadius: number;\n  borderTopRightRadius: number;\n  borderBottomRightRadius: number;\n  borderBottomLeftRadius: number;\n};\n\n/** The end of a bar that carries its value is rounded by this much; its baseline end stays square. */\nconst END_RADIUS = 4;\n\nfunction cornersOf(\n  topLeft: number,\n  topRight: number,\n  bottomRight: number,\n  bottomLeft: number\n): Corners {\n  return {\n    borderTopLeftRadius: topLeft,\n    borderTopRightRadius: topRight,\n    borderBottomRightRadius: bottomRight,\n    borderBottomLeftRadius: bottomLeft,\n  };\n}\n\nexport const SQUARE = cornersOf(0, 0, 0, 0);\n\nexport const ROUNDED_TOP = cornersOf(END_RADIUS, END_RADIUS, 0, 0);\n\nexport const ROUNDED_BOTTOM = cornersOf(0, 0, END_RADIUS, END_RADIUS);\n\nexport const ROUNDED_RIGHT = cornersOf(0, END_RADIUS, END_RADIUS, 0);\n\nexport const ROUNDED_LEFT = cornersOf(END_RADIUS, 0, 0, END_RADIUS);\n\n/** A thin meter is rounded at both ends. */\nexport const ROUNDED_ENDS = cornersOf(2, 2, 2, 2);\n",
      "type": "registry:component",
      "target": "components/chart/_parts/corners.ts"
    },
    {
      "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/scale.ts",
      "content": "export type Domain = {\n  min: number;\n  max: number;\n};\n\nexport type Scale = Domain & {\n  step: number;\n  ticks: number[];\n};\n\nconst NICE_FACTORS = [1, 2, 5, 10];\n\nconst EMPTY_DOMAIN: Domain = { min: 0, max: 0 };\n\nexport function clamp(value: number, min: number, max: number) {\n  const atLeastMin = Math.max(value, min);\n  return Math.min(atLeastMin, max);\n}\n\nexport function lerp(from: number, to: number, amount: number) {\n  const span = to - from;\n  return from + span * amount;\n}\n\nexport function sumOf(values: readonly number[]) {\n  let total = 0;\n  for (const value of values) {\n    total += value;\n  }\n  return total;\n}\n\nexport function extentOf(values: readonly number[]): Domain {\n  if (values.length === 0) {\n    return EMPTY_DOMAIN;\n  }\n  let min = values[0];\n  let max = values[0];\n  for (const value of values) {\n    min = Math.min(min, value);\n    max = Math.max(max, value);\n  }\n  return { min, max };\n}\n\n/** Bars and areas are read from zero, so their domain always holds it. */\nexport function withZero(domain: Domain): Domain {\n  const min = Math.min(domain.min, 0);\n  const max = Math.max(domain.max, 0);\n  return { min, max };\n}\n\n/** Where a value sits in a domain, from 0 at its lowest to 1 at its highest. */\nexport function ratioOf(value: number, domain: Domain) {\n  const span = domain.max - domain.min;\n  if (span === 0) {\n    return 0;\n  }\n  const offset = value - domain.min;\n  return offset / span;\n}\n\n/** A flat series still needs room above and below it to be drawn. */\nfunction spanOf(domain: Domain) {\n  const span = domain.max - domain.min;\n  if (span > 0) {\n    return span;\n  }\n  const size = Math.abs(domain.max);\n  return Math.max(size, 1);\n}\n\nfunction niceStepOf(roughStep: number) {\n  const logarithm = Math.log10(roughStep);\n  const exponent = Math.floor(logarithm);\n  const magnitude = 10 ** exponent;\n  const factor = NICE_FACTORS.find((candidate) => {\n    const candidateStep = candidate * magnitude;\n    return candidateStep >= roughStep;\n  });\n  const safeFactor = factor ?? 10;\n  return safeFactor * magnitude;\n}\n\nfunction decimalsOf(step: number) {\n  const logarithm = Math.log10(step);\n  const exponent = Math.floor(logarithm);\n  return Math.max(0, -exponent);\n}\n\n/** Adding zero turns a negative zero into zero, which a number format would write as -0. */\nfunction roundedTo(value: number, decimals: number) {\n  const factor = 10 ** decimals;\n  const scaled = Math.round(value * factor);\n  const rounded = scaled / factor;\n  return rounded + 0;\n}\n\n/** A scale with round ticks that holds the whole domain, in at most `maxLines` steps. */\nexport function niceScaleOf(domain: Domain, maxLines: number): Scale {\n  const span = spanOf(domain);\n  const roughStep = span / maxLines;\n  const step = niceStepOf(roughStep);\n  const decimals = decimalsOf(step);\n  const stepsBelow = Math.floor(domain.min / step);\n  const lowest = stepsBelow * step;\n  const highestWanted = Math.max(domain.max, lowest + step);\n  const stepsAbove = Math.ceil(highestWanted / step);\n  const lineCount = stepsAbove - stepsBelow;\n  const tickCount = lineCount + 1;\n  const ticks = Array.from({ length: tickCount }, (_, index) => {\n    const tick = lowest + index * step;\n    return roundedTo(tick, decimals);\n  });\n  const lastIndex = ticks.length - 1;\n  return { min: ticks[0], max: ticks[lastIndex], step, ticks };\n}\n\n/** Columns share the plot equally and a mark sits in the middle of its column. */\nexport function columnCenterOf(index: number, count: number) {\n  const center = index + 0.5;\n  return center / count;\n}\n\nexport function columnIndexAt(ratio: number, count: number) {\n  const rawIndex = Math.floor(ratio * count);\n  const lastIndex = Math.max(count - 1, 0);\n  return clamp(rawIndex, 0, lastIndex);\n}\n\nexport function percentOf(share: number) {\n  const percent = share * 100;\n  return `${percent}%`;\n}\n\n/** The whole numbers that lead from one position to another: the start is left out, the end is kept. */\nexport function positionsTo(from: number, to: number) {\n  const gap = to - from;\n  const distance = Math.abs(gap);\n  const direction = Math.sign(gap);\n  return Array.from({ length: distance }, (_, index) => {\n    const travelled = index + 1;\n    const offset = travelled * direction;\n    return from + offset;\n  });\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/scale.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/use-chart-demo.ts",
      "content": "'use client';\n\nimport {\n  useAutoplay,\n  type AutoplayOptions,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\n\n/**\n * What every chart demo shares: the demonstration, the box that keeps its tallest height so a round\n * never moves the page, and a stop when the keyboard brings focus in. A person who tabs to a plot\n * reads it with the arrow keys from there, and has pressed no key inside the piece yet.\n */\nexport function useChartDemo(\n  steps: readonly AutoplayStep[],\n  options?: AutoplayOptions\n) {\n  const demo = useAutoplay(steps, options);\n  const { ref, takeOver } = demo;\n\n  useKeptHeight(ref);\n\n  function stopOnKeyboardFocus(event: React.FocusEvent<HTMLElement>) {\n    const isKeyboardFocus = event.target.matches(':focus-visible');\n    if (isKeyboardFocus) {\n      takeOver();\n    }\n  }\n\n  return { ...demo, stopOnKeyboardFocus };\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/use-chart-demo.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/use-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"
}