{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-02",
  "title": "Bar chart",
  "description": "Bars side by side or stacked from a zero baseline, lying down when the room is narrow, with a readout on each bar, where every bar slides to its place when the arrangement changes.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/agent-run.json",
    "https://ui.ndekocode.dev/r/badge.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/motion-presets.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/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/chart/chart-02.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 type { SeriesSlot } from '@/registry/ndeko/lib/agent-run';\nimport { hold } from '@/registry/ndeko/lib/demonstration';\nimport { EASE_OUT_STRONG, INSTANT } from '@/registry/ndeko/lib/motion-presets';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { Segments, type Segment } from '@/registry/ndeko/ui/segments';\n\nimport { CategoryAxis, gridToneOf, type AxisLabel } from './_parts/axis';\nimport { ChartCard } from './_parts/chart-card';\nimport {\n  ROUNDED_BOTTOM,\n  ROUNDED_LEFT,\n  ROUNDED_RIGHT,\n  ROUNDED_TOP,\n  SQUARE,\n} from './_parts/corners';\nimport { DataTable, type DataRow } from './_parts/data-table';\nimport { walkSteps, whileDemonstrating } from './_parts/demo';\nimport { formatCompact, formatCount, formatSigned } from './_parts/format';\nimport { Legend, type LegendEntry } from './_parts/legend';\nimport { PLOT_SURFACE, PlotFrame } from './_parts/plot';\nimport { Readout, ReadoutRow, ReadoutTitle } from './_parts/readout';\nimport {\n  clamp,\n  columnCenterOf,\n  extentOf,\n  niceScaleOf,\n  percentOf,\n  ratioOf,\n  sumOf,\n  withZero,\n  type Domain,\n  type Scale,\n} from './_parts/scale';\nimport { markColorOf } from './_parts/series-color';\nimport { useChartDemo } from './_parts/use-chart-demo';\nimport { usePlotWidth } from './_parts/use-plot-width';\nimport { activeIndexOf, readingAloud, useReading } from './_parts/use-reading';\n\nexport type BarSeries = {\n  id: string;\n  name: string;\n  /** Fixed per series, so a colour always names the same thing whatever else is shown. */\n  series: SeriesSlot;\n  /** One value per category. A value below zero hangs under the baseline. */\n  values: readonly number[];\n};\n\nexport type BarMode = 'grouped' | 'stacked';\n\ntype ValueFormat = (value: number) => string;\n\nexport type BarChartProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  categories: readonly string[];\n  series: readonly BarSeries[];\n  /** Side by side to compare the series, or one on the other to read the total. */\n  mode: BarMode;\n  /** What one category is, for screen readers: \"quarter\". */\n  categoryNoun?: string;\n  formatValue?: ValueFormat;\n  formatTick?: ValueFormat;\n  /** A figure or a sentence above the plot. */\n  headline?: React.ReactNode;\n  /** Controls beside the title, such as the switch between the two modes. */\n  actions?: React.ReactNode;\n  /** Shows the readout of a bar without a pointer, for a demonstration. Bars count across the categories, then across the series. */\n  guideIndex?: number | null;\n  className?: string;\n};\n\ntype Orientation = 'vertical' | 'horizontal';\n\ntype Bar = {\n  id: string;\n  categoryIndex: number;\n  seriesIndex: number;\n  value: number;\n  /** Across the categories and along the values, as shares of the plot. */\n  bandStart: number;\n  bandSize: number;\n  valueStart: number;\n  valueSize: number;\n  /** Only the end that carries the value is rounded: the baseline end and the joints of a stack stay square. */\n  hasRoundedEnd: boolean;\n};\n\ntype Layout = {\n  /** The plot in pixels: across the categories, and along the values. */\n  bandLength: number;\n  valueLength: number;\n  scale: Scale;\n};\n\nconst PLOT_HEIGHT = 176;\n\nconst ROW_HEIGHT = 48;\n\nconst MAX_THICKNESS = 24;\n\nconst MIN_THICKNESS = 3;\n\nconst GAP_PX = 2;\n\n/** How much of its band a group of bars takes, and a stack. */\nconst GROUP_SHARE = 0.72;\n\nconst STACK_SHARE = 0.5;\n\nconst MAX_LINES = 4;\n\n/** The value axis and its gap, taken from the width of the piece to know the width of the plot. */\nconst AXIS_ROOM_PX = 44;\n\n/** Below this width the categories no longer fit side by side: the bars lie down, one row each. */\nconst HORIZONTAL_BELOW_PX = 352;\n\nconst FALLBACK_WIDTH = 480;\n\nconst REARRANGE: Transition = {\n  layout: { duration: 0.35, ease: EASE_OUT_STRONG },\n};\n\nconst NO_REARRANGE: Transition = { layout: INSTANT };\n\nfunction rearrangeOf(shouldReduceMotion: boolean) {\n  if (shouldReduceMotion) {\n    return NO_REARRANGE;\n  }\n  return REARRANGE;\n}\n\nfunction orientationOf(width: number): Orientation {\n  if (width < HORIZONTAL_BELOW_PX) {\n    return 'horizontal';\n  }\n  return 'vertical';\n}\n\nfunction valueAt(entry: BarSeries, categoryIndex: number) {\n  return entry.values[categoryIndex] ?? 0;\n}\n\nfunction stackedExtentOf(\n  categoryCount: number,\n  series: readonly BarSeries[]\n): Domain {\n  let min = 0;\n  let max = 0;\n  for (let category = 0; category < categoryCount; category += 1) {\n    let rise = 0;\n    let fall = 0;\n    for (const entry of series) {\n      const value = valueAt(entry, category);\n      rise += Math.max(value, 0);\n      fall += Math.min(value, 0);\n    }\n    max = Math.max(max, rise);\n    min = Math.min(min, fall);\n  }\n  return { min, max };\n}\n\nfunction extentFor(\n  mode: BarMode,\n  categoryCount: number,\n  series: readonly BarSeries[]\n) {\n  if (mode === 'stacked') {\n    return stackedExtentOf(categoryCount, series);\n  }\n  const values = series.flatMap((entry) => entry.values);\n  return extentOf(values);\n}\n\nfunction scaleOf(\n  mode: BarMode,\n  categoryCount: number,\n  series: readonly BarSeries[]\n) {\n  const extent = extentFor(mode, categoryCount, series);\n  const domain = withZero(extent);\n  return niceScaleOf(domain, MAX_LINES);\n}\n\nfunction layoutOf(\n  orientation: Orientation,\n  categoryCount: number,\n  plotWidth: number,\n  scale: Scale\n): Layout {\n  if (orientation === 'horizontal') {\n    const rowCount = Math.max(categoryCount, 1);\n    const bandLength = rowCount * ROW_HEIGHT;\n    return { bandLength, valueLength: plotWidth, scale };\n  }\n  return { bandLength: plotWidth, valueLength: PLOT_HEIGHT, scale };\n}\n\nfunction groupThicknessOf(bandPx: number, seriesCount: number) {\n  const room = bandPx * GROUP_SHARE;\n  const gapCount = seriesCount - 1;\n  const gaps = gapCount * GAP_PX;\n  const barsRoom = room - gaps;\n  const each = barsRoom / seriesCount;\n  return clamp(each, MIN_THICKNESS, MAX_THICKNESS);\n}\n\nfunction stackThicknessOf(bandPx: number) {\n  const room = bandPx * STACK_SHARE;\n  return clamp(room, MIN_THICKNESS, MAX_THICKNESS);\n}\n\nfunction groupedBarsOf(\n  categoryCount: number,\n  series: readonly BarSeries[],\n  layout: Layout\n) {\n  const bandPx = layout.bandLength / categoryCount;\n  const thickness = groupThicknessOf(bandPx, series.length);\n  const pitch = thickness + GAP_PX;\n  const pitches = series.length * pitch;\n  const groupPx = pitches - GAP_PX;\n  const halfGroupPx = groupPx / 2;\n  const bandSize = thickness / layout.bandLength;\n  const bars: Bar[] = [];\n  for (let category = 0; category < categoryCount; category += 1) {\n    const centerShare = columnCenterOf(category, categoryCount);\n    const centerPx = centerShare * layout.bandLength;\n    const groupStartPx = centerPx - halfGroupPx;\n    for (const [seriesIndex, entry] of series.entries()) {\n      const value = valueAt(entry, category);\n      const offsetPx = seriesIndex * pitch;\n      const startPx = groupStartPx + offsetPx;\n      const low = Math.min(value, 0);\n      const high = Math.max(value, 0);\n      const valueStart = ratioOf(low, layout.scale);\n      const valueEnd = ratioOf(high, layout.scale);\n      bars.push({\n        id: `${category}-${entry.id}`,\n        categoryIndex: category,\n        seriesIndex,\n        value,\n        bandStart: startPx / layout.bandLength,\n        bandSize,\n        valueStart,\n        valueSize: valueEnd - valueStart,\n        hasRoundedEnd: true,\n      });\n    }\n  }\n  return bars;\n}\n\n/** Where a value sits in its stack: values above zero pile up from the baseline, values below hang from it. */\nfunction segmentOf(value: number, rise: number, fall: number) {\n  if (value >= 0) {\n    const high = rise + value;\n    const touchesBaseline = rise === 0;\n    return { low: rise, high, touchesBaseline };\n  }\n  const low = fall + value;\n  const touchesBaseline = fall === 0;\n  return { low, high: fall, touchesBaseline };\n}\n\nfunction stackedBarsOf(\n  categoryCount: number,\n  series: readonly BarSeries[],\n  layout: Layout\n) {\n  const bandPx = layout.bandLength / categoryCount;\n  const thickness = stackThicknessOf(bandPx);\n  const halfThickness = thickness / 2;\n  const bandSize = thickness / layout.bandLength;\n  const gapShare = GAP_PX / layout.valueLength;\n  const bars: Bar[] = [];\n  for (let category = 0; category < categoryCount; category += 1) {\n    const centerShare = columnCenterOf(category, categoryCount);\n    const centerPx = centerShare * layout.bandLength;\n    const startPx = centerPx - halfThickness;\n    let rise = 0;\n    let fall = 0;\n    let highest: Bar | null = null;\n    let lowest: Bar | null = null;\n    for (const [seriesIndex, entry] of series.entries()) {\n      const value = valueAt(entry, category);\n      const segment = segmentOf(value, rise, fall);\n      let valueStart = ratioOf(segment.low, layout.scale);\n      let valueEnd = ratioOf(segment.high, layout.scale);\n      const isRise = value >= 0;\n      const leavesGap = !segment.touchesBaseline;\n      if (leavesGap && isRise) {\n        valueStart += gapShare;\n      }\n      if (leavesGap && !isRise) {\n        valueEnd -= gapShare;\n      }\n      const size = valueEnd - valueStart;\n      const bar: Bar = {\n        id: `${category}-${entry.id}`,\n        categoryIndex: category,\n        seriesIndex,\n        value,\n        bandStart: startPx / layout.bandLength,\n        bandSize,\n        valueStart,\n        valueSize: Math.max(size, 0),\n        hasRoundedEnd: false,\n      };\n      if (value > 0) {\n        rise = segment.high;\n        highest = bar;\n      }\n      if (value < 0) {\n        fall = segment.low;\n        lowest = bar;\n      }\n      bars.push(bar);\n    }\n    if (highest) {\n      highest.hasRoundedEnd = true;\n    }\n    if (lowest) {\n      lowest.hasRoundedEnd = true;\n    }\n  }\n  return bars;\n}\n\nfunction barsOf(\n  mode: BarMode,\n  categoryCount: number,\n  series: readonly BarSeries[],\n  layout: Layout\n) {\n  const isEmpty = categoryCount === 0 || series.length === 0;\n  if (isEmpty) {\n    return [];\n  }\n  if (mode === 'stacked') {\n    return stackedBarsOf(categoryCount, series, layout);\n  }\n  return groupedBarsOf(categoryCount, series, layout);\n}\n\n/** How far the high end of a bar is from the top of the value axis, as a share of it. */\nfunction fromTopOf(bar: Bar) {\n  const valueEnd = bar.valueStart + bar.valueSize;\n  return 1 - valueEnd;\n}\n\nfunction boxOf(bar: Bar, orientation: Orientation): React.CSSProperties {\n  const bandStart = percentOf(bar.bandStart);\n  const bandSize = percentOf(bar.bandSize);\n  const valueStart = percentOf(bar.valueStart);\n  const valueSize = percentOf(bar.valueSize);\n  if (orientation === 'horizontal') {\n    return {\n      top: bandStart,\n      height: bandSize,\n      left: valueStart,\n      width: valueSize,\n    };\n  }\n  const fromTop = fromTopOf(bar);\n  return {\n    left: bandStart,\n    width: bandSize,\n    top: percentOf(fromTop),\n    height: valueSize,\n  };\n}\n\nfunction roundedEndOf(bar: Bar, orientation: Orientation) {\n  if (!bar.hasRoundedEnd) {\n    return SQUARE;\n  }\n  const isRise = bar.value >= 0;\n  const isHorizontal = orientation === 'horizontal';\n  if (isHorizontal && isRise) {\n    return ROUNDED_RIGHT;\n  }\n  if (isHorizontal) {\n    return ROUNDED_LEFT;\n  }\n  if (isRise) {\n    return ROUNDED_TOP;\n  }\n  return ROUNDED_BOTTOM;\n}\n\n/** Where the readout points: above an upright bar, and above the far end of a bar that lies down. */\nfunction anchorOf(bar: Bar, orientation: Orientation, layout: Layout) {\n  if (orientation === 'horizontal') {\n    const ratio = bar.valueStart + bar.valueSize;\n    const top = bar.bandStart * layout.bandLength;\n    return { ratio, top };\n  }\n  const halfBand = bar.bandSize / 2;\n  const ratio = bar.bandStart + halfBand;\n  const fromTop = fromTopOf(bar);\n  const top = fromTop * layout.valueLength;\n  return { ratio, top };\n}\n\nconst NO_ANCHOR = { ratio: 0, top: 0 };\n\nfunction dimmedOf(isDimmed: boolean) {\n  if (isDimmed) {\n    return 'opacity-55';\n  }\n  return undefined;\n}\n\n/** Bars rearrange when the mode or a value changes, never when the piece is resized. */\nfunction layoutKeyOf(mode: BarMode, series: readonly BarSeries[]) {\n  const values = series.map((entry) => entry.values.join(','));\n  return `${mode}|${values.join('|')}`;\n}\n\ntype BarMarksProps = {\n  bars: readonly Bar[];\n  series: readonly BarSeries[];\n  orientation: Orientation;\n  layoutKey: string;\n  /** The bar being read, or null when none is. */\n  activeIndex: number | null;\n  onPoint: (index: number) => void;\n};\n\nfunction BarMarks({\n  bars,\n  series,\n  orientation,\n  layoutKey,\n  activeIndex,\n  onPoint,\n}: BarMarksProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const transition = rearrangeOf(shouldReduceMotion);\n  const isReading = activeIndex !== null;\n\n  return (\n    <div aria-hidden='true' className='absolute inset-0'>\n      {bars.map((bar, index) => {\n        const entry = series[bar.seriesIndex];\n        const isDimmed = isReading && index !== activeIndex;\n\n        return (\n          <motion.span\n            key={bar.id}\n            layout\n            layoutDependency={layoutKey}\n            transition={transition}\n            onPointerEnter={() => onPoint(index)}\n            className={cn(\n              'absolute transition-opacity duration-150',\n              dimmedOf(isDimmed)\n            )}\n            style={{\n              ...boxOf(bar, orientation),\n              ...roundedEndOf(bar, orientation),\n              backgroundColor: markColorOf(entry.series, series.length),\n            }}\n          >\n            <span className='absolute top-1/2 left-1/2 size-full min-h-6 min-w-6 -translate-1/2' />\n          </motion.span>\n        );\n      })}\n    </div>\n  );\n}\n\ntype HorizontalFrameProps = {\n  label: string;\n  categories: readonly string[];\n  scale: Scale;\n  height: number;\n  formatTick: ValueFormat;\n  plotProps: React.HTMLAttributes<HTMLDivElement>;\n  children: React.ReactNode;\n};\n\n/** The plot lying down: one row per category, the values running from left to right. */\nfunction HorizontalFrame({\n  label,\n  categories,\n  scale,\n  height,\n  formatTick,\n  plotProps,\n  children,\n}: HorizontalFrameProps) {\n  const tickLabels: AxisLabel[] = scale.ticks.map((tick) => ({\n    id: String(tick),\n    text: formatTick(tick),\n    ratio: ratioOf(tick, scale),\n  }));\n\n  return (\n    <div className='grid grid-cols-[auto_minmax(0,1fr)] gap-x-2'>\n      <div aria-hidden='true' className='flex flex-col' style={{ height }}>\n        {categories.map((category, index) => (\n          <span\n            key={`${index}-${category}`}\n            className='text-muted-foreground flex min-h-0 flex-1 items-center justify-end text-[11px]'\n          >\n            {category}\n          </span>\n        ))}\n      </div>\n      <div\n        role='group'\n        tabIndex={0}\n        aria-label={label}\n        className={PLOT_SURFACE}\n        style={{ height }}\n        {...plotProps}\n      >\n        <div\n          aria-hidden='true'\n          className='pointer-events-none absolute inset-0'\n        >\n          {scale.ticks.map((tick) => (\n            <span\n              key={tick}\n              className={cn(\n                'hairline-l absolute inset-y-0',\n                gridToneOf(tick, scale)\n              )}\n              style={{ left: percentOf(ratioOf(tick, scale)) }}\n            />\n          ))}\n        </div>\n        {children}\n      </div>\n      <span aria-hidden='true' />\n      <CategoryAxis labels={tickLabels} className='mt-2' />\n    </div>\n  );\n}\n\nfunction modeWordsOf(mode: BarMode) {\n  if (mode === 'stacked') {\n    return 'stacked on one another';\n  }\n  return 'side by side';\n}\n\nfunction summaryOf(\n  categories: readonly string[],\n  series: readonly BarSeries[],\n  mode: BarMode\n) {\n  const hasData = categories.length > 0 && series.length > 0;\n  if (!hasData) {\n    return 'Nothing to draw yet.';\n  }\n  const names = series.map((entry) => entry.name);\n  const nameList = names.join(', ');\n  const first = categories[0];\n  const last = categories[categories.length - 1];\n  const modeWords = modeWordsOf(mode);\n  return `${nameList} from ${first} to ${last}, drawn ${modeWords}.`;\n}\n\nfunction formattedAt(\n  entry: BarSeries,\n  categoryIndex: number,\n  format: ValueFormat\n) {\n  const value = valueAt(entry, categoryIndex);\n  return format(value);\n}\n\nexport function BarChart({\n  title,\n  note,\n  categories,\n  series,\n  mode,\n  categoryNoun = 'category',\n  formatValue = formatCount,\n  formatTick = formatCompact,\n  headline,\n  actions,\n  guideIndex = null,\n  className,\n}: BarChartProps) {\n  const { plotRef: frameRef, width: frameWidth } = usePlotWidth(FALLBACK_WIDTH);\n  const orientation = orientationOf(frameWidth);\n  const categoryCount = categories.length;\n  const scale = scaleOf(mode, categoryCount, series);\n  const roomForPlot = frameWidth - AXIS_ROOM_PX;\n  const plotWidth = Math.max(roomForPlot, 1);\n  const layout = layoutOf(orientation, categoryCount, plotWidth, scale);\n  const bars = barsOf(mode, categoryCount, series, layout);\n  const reading = useReading({\n    count: bars.length,\n    defaultIndex: 0,\n    guideIndex,\n  });\n  const shownBar = bars.at(reading.shownIndex);\n  const shownSeries = series.at(shownBar?.seriesIndex ?? 0);\n  const shownCategory = categories[shownBar?.categoryIndex ?? 0] ?? '';\n  const shownName = shownSeries?.name ?? '';\n  const shownValue = formatValue(shownBar?.value ?? 0);\n  const anchor = anchorFor(shownBar, orientation, layout);\n  const isReadoutShown = reading.isActive && shownBar !== undefined;\n  const layoutKey = layoutKeyOf(mode, series);\n  const plotLabel = `${title}. Use the arrow keys to read each bar.`;\n  const summary = summaryOf(categories, series, mode);\n  const spokenReading = `${shownCategory}, ${shownName}: ${shownValue}`;\n\n  const axisLabels: AxisLabel[] = categories.map((category, index) => ({\n    id: `${index}-${category}`,\n    text: category,\n    ratio: columnCenterOf(index, categoryCount),\n  }));\n\n  const legend: LegendEntry[] = series.map((entry) => ({\n    id: entry.id,\n    label: entry.name,\n    color: markColorOf(entry.series, series.length),\n  }));\n\n  const rows: DataRow[] = categories.map((category, index) => ({\n    id: `${index}-${category}`,\n    label: category,\n    cells: series.map((entry) => formattedAt(entry, index, formatValue)),\n  }));\n\n  const marks = (\n    <>\n      <BarMarks\n        bars={bars}\n        series={series}\n        orientation={orientation}\n        layoutKey={layoutKey}\n        activeIndex={activeIndexOf(reading.isActive, reading.shownIndex)}\n        onPoint={reading.pointTo}\n      />\n      <Readout\n        ratio={anchor.ratio}\n        top={anchor.top}\n        isVisible={isReadoutShown}\n        isGliding={reading.isGliding}\n        hasCrosshair={false}\n      >\n        <ReadoutTitle>{shownCategory}</ReadoutTitle>\n        <ReadoutRow\n          value={shownValue}\n          name={shownName}\n          color={colorOf(shownSeries, series.length)}\n        />\n      </Readout>\n    </>\n  );\n\n  return (\n    <ChartCard\n      title={title}\n      note={note}\n      summary={summary}\n      actions={actions}\n      className={className}\n    >\n      {headline}\n      <div ref={frameRef}>\n        {orientation === 'vertical' && (\n          <PlotFrame\n            label={plotLabel}\n            scale={scale}\n            height={PLOT_HEIGHT}\n            formatTick={formatTick}\n            axisLabels={axisLabels}\n            axisAlign='center'\n            plotProps={reading.markHandlers}\n          >\n            {marks}\n          </PlotFrame>\n        )}\n        {orientation === 'horizontal' && (\n          <HorizontalFrame\n            label={plotLabel}\n            categories={categories}\n            scale={scale}\n            height={layout.bandLength}\n            formatTick={formatTick}\n            plotProps={reading.markHandlers}\n          >\n            {marks}\n          </HorizontalFrame>\n        )}\n      </div>\n      <Legend entries={legend} label='Series' />\n      <LiveRegion\n        message={readingAloud(reading.isReadingByKeys, spokenReading)}\n      />\n      <DataTable\n        caption={title}\n        columns={[categoryNoun, ...series.map((entry) => entry.name)]}\n        rows={rows}\n      />\n    </ChartCard>\n  );\n}\n\nfunction anchorFor(\n  bar: Bar | undefined,\n  orientation: Orientation,\n  layout: Layout\n) {\n  if (!bar) {\n    return NO_ANCHOR;\n  }\n  return anchorOf(bar, orientation, layout);\n}\n\nfunction colorOf(entry: BarSeries | undefined, seriesCount: number) {\n  if (!entry) {\n    return undefined;\n  }\n  return markColorOf(entry.series, seriesCount);\n}\n\nconst QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'];\n\nconst PLANS: BarSeries[] = [\n  { id: 'starter', name: 'Starter', series: 1, values: [34, 12, -9, 16] },\n  { id: 'team', name: 'Team', series: 2, values: [22, 31, 27, 38] },\n  { id: 'business', name: 'Business', series: 3, values: [9, 18, 26, 41] },\n];\n\nconst MODES: Segment<BarMode>[] = [\n  { value: 'grouped', label: 'Grouped' },\n  { value: 'stacked', label: 'Stacked' },\n];\n\ntype PlanTotal = {\n  plan: BarSeries;\n  total: number;\n};\n\nfunction planTotalsOf(plans: readonly BarSeries[]): PlanTotal[] {\n  return plans.map((plan) => ({ plan, total: sumOf(plan.values) }));\n}\n\nfunction leaderOf(totals: readonly PlanTotal[]) {\n  let leader = totals[0];\n  for (const candidate of totals) {\n    if (candidate.total > leader.total) {\n      leader = candidate;\n    }\n  }\n  return leader;\n}\n\ntype Loss = {\n  plan: BarSeries;\n  quarter: string;\n  seats: number;\n};\n\n/** The worst quarter of any plan, when one of them closed below zero. */\nfunction deepestLossOf(plans: readonly BarSeries[]) {\n  let deepest: Loss | null = null;\n  for (const plan of plans) {\n    for (const [index, value] of plan.values.entries()) {\n      const isLoss = value < 0;\n      const isDeeper = deepest === null || value < deepest.seats;\n      if (isLoss && isDeeper) {\n        deepest = { plan, quarter: QUARTERS[index], seats: value };\n      }\n    }\n  }\n  return deepest;\n}\n\nconst PLAN_TOTALS = planTotalsOf(PLANS);\n\nconst ALL_SEATS = PLANS.flatMap((plan) => plan.values);\n\nconst NET_SEATS = sumOf(ALL_SEATS);\n\nconst LEADER = leaderOf(PLAN_TOTALS);\n\nconst DEEPEST_LOSS = deepestLossOf(PLANS);\n\nfunction lossSentenceOf(loss: Loss | null) {\n  if (!loss) {\n    return 'No plan lost seats in any quarter.';\n  }\n  const seats = Math.abs(loss.seats);\n  return `${loss.plan.name} lost ${seats} in ${loss.quarter}.`;\n}\n\nfunction Headline() {\n  return (\n    <div className='flex flex-col gap-0.5'>\n      <p className='flex items-baseline gap-1.5'>\n        <span className='text-2xl leading-8 font-semibold tabular-nums'>\n          {formatSigned(NET_SEATS)}\n        </span>\n        <span className='text-muted-foreground text-[13px]'>\n          net seats this year\n        </span>\n      </p>\n      <p className='text-muted-foreground text-xs text-pretty'>\n        {LEADER.plan.name} added the most, {formatCount(LEADER.total)}.{' '}\n        {lossSentenceOf(DEEPEST_LOSS)}\n      </p>\n    </div>\n  );\n}\n\nconst WALK_GROUPED = [0, 1, 2, 6, 7, 8];\n\nconst WALK_STACKED = [6, 7, 8, 9, 10, 11];\n\nexport default function Chart02() {\n  const [mode, setMode] = useState<BarMode>('grouped');\n  const [guide, setGuide] = useState<number | null>(null);\n\n  function changeMode(next: BarMode) {\n    setGuide(null);\n    setMode(next);\n  }\n\n  function resetDemo() {\n    setMode('grouped');\n    setGuide(null);\n  }\n\n  const steps: AutoplayStep[] = [\n    ...walkSteps(WALK_GROUPED, setGuide, { startAfter: 1100, stepMs: 380 }),\n    { after: 1000, run: () => changeMode('stacked') },\n    ...walkSteps(WALK_STACKED, setGuide, { startAfter: 1300, stepMs: 380 }),\n    { after: 1000, run: () => changeMode('grouped') },\n    { after: 1400, run: hold },\n  ];\n  const { ref, isAutoplaying, stopOnKeyboardFocus } = useChartDemo(steps, {\n    loopAfter: 1800,\n    onReset: resetDemo,\n  });\n\n  return (\n    <div\n      ref={ref}\n      onFocus={stopOnKeyboardFocus}\n      className='flex min-h-[24.5rem] w-full max-w-[34rem]'\n    >\n      <BarChart\n        title='Net new seats by plan'\n        note='Illustrative'\n        categories={QUARTERS}\n        series={PLANS}\n        mode={mode}\n        categoryNoun='quarter'\n        formatValue={formatSigned}\n        headline={<Headline />}\n        actions={\n          <Segments\n            value={mode}\n            segments={MODES}\n            onValueChange={changeMode}\n            label='Arrangement of the bars'\n          />\n        }\n        guideIndex={whileDemonstrating(isAutoplaying, guide)}\n        className='w-full'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/chart-02.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/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/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/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/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/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/series-color.ts",
      "content": "import { seriesColorOf, type SeriesSlot } from '@/registry/ndeko/lib/agent-run';\n\n/** A chart of one series draws it in the primary colour; with more, each one wears its own series colour. */\nexport function markColorOf(slot: SeriesSlot, seriesCount: number) {\n  if (seriesCount === 1) {\n    return 'var(--primary)';\n  }\n  return seriesColorOf(slot);\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/series-color.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-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"
    }
  ],
  "categories": [
    "components",
    "chart"
  ],
  "type": "registry:component"
}