{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-10",
  "title": "Dot histogram",
  "description": "A distribution stacked from dots with a target pin you drag, flick or move with the arrow keys, where the pin catches on each column and the dots past it turn red one column after the other while the sentence on top counts them.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/badge.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/motion-word-roll.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-timeouts.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/chart/chart-10.tsx",
      "content": "'use client';\n\nimport { useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { WordRoll } from '@/components/core/word-roll';\nimport type { AutoplayStep } from '@/registry/ndeko/hooks/use-autoplay';\nimport { hold } from '@/registry/ndeko/lib/demonstration';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nimport { CategoryAxis, LABEL_ROOM_PX, type AxisLabel } from './_parts/axis';\nimport { ChartCard } from './_parts/chart-card';\nimport { DataTable, type DataRow } from './_parts/data-table';\nimport { walkSteps } from './_parts/demo';\nimport { formatCount } from './_parts/format';\nimport { PLOT_SURFACE } from './_parts/plot';\nimport { clamp, positionsTo, sumOf } from './_parts/scale';\nimport { useChartDemo } from './_parts/use-chart-demo';\nimport { usePlotWidth } from './_parts/use-plot-width';\nimport { useStepDrag } from './_parts/use-step-drag';\n\nexport type HistogramBin = {\n  /** Where the bin starts and where it ends, in the unit of the measure. */\n  from: number;\n  to: number;\n  /** How many readings fell in it. */\n  count: number;\n};\n\nexport type DotHistogramProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  /** The bins from the lowest to the highest, each as wide as the others. */\n  bins: readonly HistogramBin[];\n  /** How many readings one dot stands for. */\n  perDot: number;\n  /** The edge the target sits on: 0 is before the first bin, `bins.length` is after the last. */\n  targetEdge: number;\n  onTargetEdgeChange: (edge: number) => void;\n  /** The unit of the measure, written after a figure: \"ms\". */\n  unit: string;\n  /** What is counted, in the plural: \"requests\". */\n  noun: string;\n  className?: string;\n};\n\nconst DOT_PX = 10;\n\nconst DOT_GAP_PX = 2;\n\nconst DOT_PITCH_PX = DOT_PX + DOT_GAP_PX;\n\n/** Room above the tallest column for the label of the target. */\nconst PIN_ROOM_PX = 28;\n\n/** Columns change colour one after the other, starting from the one beside the pin. */\nconst STAGGER_MS = 30;\n\n/** One label of the axis every this many edges: the first step that leaves each label its room, so the values stay round. */\nconst LABEL_STEPS = [4, 8];\n\nconst WIDEST_LABEL_STEP = 16;\n\nconst FALLBACK_WIDTH = 420;\n\nfunction labelStepFor(binCount: number, plotWidth: number) {\n  for (const step of LABEL_STEPS) {\n    const gapCount = Math.floor(binCount / step);\n    const labelCount = gapCount + 1;\n    const room = plotWidth / labelCount;\n    if (room >= LABEL_ROOM_PX) {\n      return step;\n    }\n  }\n  return WIDEST_LABEL_STEP;\n}\n\nfunction dotCountOf(bin: HistogramBin, perDot: number) {\n  const dots = bin.count / perDot;\n  return Math.round(dots);\n}\n\nfunction tallestOf(bins: readonly HistogramBin[], perDot: number) {\n  let tallest = 1;\n  for (const bin of bins) {\n    const dots = dotCountOf(bin, perDot);\n    tallest = Math.max(tallest, dots);\n  }\n  return tallest;\n}\n\n/** The value an edge sits on: the start of the bin after it, or the end of the last bin. */\nfunction edgeValueOf(bins: readonly HistogramBin[], edge: number) {\n  const bin = bins[edge];\n  if (bin) {\n    return bin.from;\n  }\n  const last = bins.at(-1);\n  return last?.to ?? 0;\n}\n\nfunction countPast(bins: readonly HistogramBin[], edge: number) {\n  const past = bins.slice(edge);\n  const counts = past.map((bin) => bin.count);\n  return sumOf(counts);\n}\n\n/** How many columns lie between a column and the pin, on whichever side of it the column is. */\nfunction distanceFromPin(column: number, edge: number) {\n  if (column >= edge) {\n    return column - edge;\n  }\n  const lastBefore = edge - 1;\n  return lastBefore - column;\n}\n\nfunction dotToneOf(isPastTarget: boolean) {\n  if (isPastTarget) {\n    return 'bg-destructive';\n  }\n  return 'bg-primary';\n}\n\ntype DotColumnProps = {\n  dotCount: number;\n  isPastTarget: boolean;\n  /** How many columns away from the pin this one is: the further, the later it changes colour. */\n  distance: number;\n};\n\nfunction DotColumn({ dotCount, isPastTarget, distance }: DotColumnProps) {\n  const dots = Array.from({ length: dotCount }, (_, index) => index);\n  const delayMs = distance * STAGGER_MS;\n\n  return (\n    <span className='flex min-w-0 flex-1 flex-col-reverse items-center gap-0.5'>\n      {dots.map((dot) => (\n        <span\n          key={dot}\n          className={cn(\n            'size-2.5 shrink-0 rounded-full transition-colors duration-200 ease-(--ease-out-strong)',\n            dotToneOf(isPastTarget)\n          )}\n          style={{ transitionDelay: `${delayMs}ms` }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction axisLabelsOf(\n  bins: readonly HistogramBin[],\n  unit: string,\n  plotWidth: number\n) {\n  const edgeCount = bins.length + 1;\n  const edges = Array.from({ length: edgeCount }, (_, edge) => edge);\n  const step = labelStepFor(bins.length, plotWidth);\n  const written = edges.filter((edge) => edge % step === 0);\n  return written.map((edge): AxisLabel => {\n    const edgeValue = edgeValueOf(bins, edge);\n    const value = formatCount(edgeValue);\n    const isLast = edge === bins.length;\n    return {\n      id: String(edge),\n      text: labelTextOf(value, unit, isLast),\n      ratio: edge / bins.length,\n    };\n  });\n}\n\n/** Only the last label carries the unit, so the axis is not a row of the same two letters. */\nfunction labelTextOf(value: string, unit: string, isLast: boolean) {\n  if (isLast) {\n    return `${value}${unit}`;\n  }\n  return value;\n}\n\nfunction rangeTextOf(bin: HistogramBin, unit: string) {\n  const from = formatCount(bin.from);\n  const to = formatCount(bin.to);\n  return `${from} to ${to}${unit}`;\n}\n\nfunction standingOf(isPastTarget: boolean) {\n  if (isPastTarget) {\n    return 'Slower than the target';\n  }\n  return 'Within the target';\n}\n\nexport function DotHistogram({\n  title,\n  note,\n  bins,\n  perDot,\n  targetEdge,\n  onTargetEdgeChange,\n  unit,\n  noun,\n  className,\n}: DotHistogramProps) {\n  const { plotRef, width } = usePlotWidth(FALLBACK_WIDTH);\n  const binCount = Math.max(bins.length, 1);\n  const edge = clamp(targetEdge, 0, bins.length);\n  const drag = useStepDrag({\n    count: bins.length + 1,\n    index: edge,\n    onIndexChange: onTargetEdgeChange,\n    placement: 'edges',\n  });\n  const tallest = tallestOf(bins, perDot);\n  const columnsHeight = tallest * DOT_PITCH_PX;\n  const plotHeight = columnsHeight + PIN_ROOM_PX;\n  const counts = bins.map((bin) => bin.count);\n  const counted = sumOf(counts);\n  const total = Math.max(counted, 1);\n  const slowCount = countPast(bins, edge);\n  const slowShare = slowCount / total;\n  const slowPercent = Math.round(slowShare * 100);\n  const edgeValue = edgeValueOf(bins, edge);\n  const targetValue = formatCount(edgeValue);\n  const targetText = `${targetValue}${unit}`;\n  const sentence = `${slowPercent}% of ${noun} are slower than ${targetText}`;\n  const pinRatio = edge / binCount;\n  const pinShare = pinRatio * 100;\n\n  const rows: DataRow[] = bins.map((bin, column) => ({\n    id: String(bin.from),\n    label: rangeTextOf(bin, unit),\n    cells: [formatCount(bin.count), standingOf(column >= edge)],\n  }));\n\n  return (\n    <ChartCard\n      title={title}\n      note={note}\n      summary={`${sentence}. One dot is ${perDot} ${noun}.`}\n      className={className}\n    >\n      <p className='flex flex-wrap items-baseline gap-x-1.5'>\n        <MotionNumber\n          value={slowPercent}\n          suffix='%'\n          className='text-2xl leading-8 font-semibold'\n        />\n        <span className='text-muted-foreground text-[13px]'>\n          of {noun} are slower than{' '}\n          <WordRoll\n            text={targetText}\n            value={edge}\n            className='text-foreground font-medium tabular-nums'\n          />\n        </span>\n      </p>\n      <div className='flex flex-col'>\n        <div\n          ref={plotRef}\n          role='slider'\n          tabIndex={0}\n          aria-label={`Target, in ${unit}`}\n          aria-orientation='horizontal'\n          aria-valuemin={0}\n          aria-valuemax={bins.length}\n          aria-valuenow={edge}\n          aria-valuetext={sentence}\n          className={cn(\n            PLOT_SURFACE,\n            'flex cursor-ew-resize items-end select-none'\n          )}\n          style={{ height: plotHeight }}\n          {...drag.sliderProps}\n        >\n          {bins.map((bin, column) => (\n            <DotColumn\n              key={bin.from}\n              dotCount={dotCountOf(bin, perDot)}\n              isPastTarget={column >= edge}\n              distance={distanceFromPin(column, edge)}\n            />\n          ))}\n          <span\n            aria-hidden='true'\n            className='pointer-events-none absolute inset-y-0 left-0 w-0 transition-transform duration-150 ease-(--ease-out-strong)'\n            style={{ transform: `translateX(${pinShare}cqw)` }}\n          >\n            <span className='bg-foreground absolute inset-y-0 left-0 w-0.5 -translate-x-1/2 rounded-full' />\n            <span className='bg-foreground ring-card absolute -bottom-1.5 left-0 size-3 -translate-x-1/2 rounded-full ring-2' />\n            <span\n              className='bg-foreground text-background absolute top-0 left-0 flex h-5 w-max items-center rounded-md px-1.5 text-[11px] font-medium tabular-nums transition-transform duration-150 ease-(--ease-out-strong)'\n              style={{ transform: `translateX(-${pinShare}%)` }}\n            >\n              {targetText}\n            </span>\n          </span>\n        </div>\n        <span aria-hidden='true' className='hairline-t border-border' />\n        <CategoryAxis\n          labels={axisLabelsOf(bins, unit, width)}\n          className='mt-2'\n        />\n      </div>\n      <p className='text-muted-foreground text-xs'>\n        One dot is {perDot} {noun}. Drag the pin, or use the arrow keys, to move\n        the target\n      </p>\n      <DataTable\n        caption={title}\n        columns={['Range', noun, 'Against the target']}\n        rows={rows}\n      />\n    </ChartCard>\n  );\n}\n\nconst BIN_WIDTH_MS = 100;\n\nconst REQUEST_COUNTS = [\n  20, 60, 140, 200, 220, 180, 140, 120, 100, 80, 60, 60, 40, 40, 20, 20,\n];\n\nconst BINS: HistogramBin[] = REQUEST_COUNTS.map((count, index) => {\n  const from = index * BIN_WIDTH_MS;\n  const to = from + BIN_WIDTH_MS;\n  return { from, to, count };\n});\n\nconst RESTING_EDGE = 8;\n\nconst STRICT_EDGE = 5;\n\nconst LOOSE_EDGE = 12;\n\nexport default function Chart10() {\n  const [targetEdge, setTargetEdge] = useState(RESTING_EDGE);\n\n  function resetDemo() {\n    setTargetEdge(RESTING_EDGE);\n  }\n\n  const toStrict = positionsTo(RESTING_EDGE, STRICT_EDGE);\n  const toLoose = positionsTo(STRICT_EDGE, LOOSE_EDGE);\n  const toResting = positionsTo(LOOSE_EDGE, RESTING_EDGE);\n  const steps: AutoplayStep[] = [\n    ...walkSteps(toStrict, setTargetEdge, { startAfter: 1500, stepMs: 260 }),\n    ...walkSteps(toLoose, setTargetEdge, { startAfter: 1800, stepMs: 110 }),\n    ...walkSteps(toResting, setTargetEdge, { startAfter: 1800, stepMs: 260 }),\n    { after: 1400, 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-[21.5rem] w-full max-w-[30rem]'\n    >\n      <DotHistogram\n        title='Response time'\n        note='Illustrative'\n        bins={BINS}\n        perDot={20}\n        targetEdge={targetEdge}\n        onTargetEdgeChange={setTargetEdge}\n        unit='ms'\n        noun='requests'\n        className='w-full'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/chart-10.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/axis.tsx",
      "content": "import { cn } from '@/registry/ndeko/lib/utils';\n\nimport { yOf } from './path';\nimport { columnCenterOf, percentOf, type Scale } from './scale';\n\n/** The least room one label of the category axis needs, text and breathing space. */\nexport const LABEL_ROOM_PX = 56;\n\ntype ValueAxisProps = {\n  scale: Scale;\n  height: number;\n  format: (value: number) => string;\n  className?: string;\n};\n\n/** The figures of the value axis, one per gridline, in a column beside the plot. */\nexport function ValueAxis({\n  scale,\n  height,\n  format,\n  className,\n}: ValueAxisProps) {\n  return (\n    <div\n      aria-hidden='true'\n      className={cn('relative w-9 shrink-0', className)}\n      style={{ height }}\n    >\n      {scale.ticks.map((tick) => (\n        <span\n          key={tick}\n          className='text-muted-foreground absolute right-0 -translate-y-1/2 text-[11px] tabular-nums'\n          style={{ top: yOf(tick, scale, height) }}\n        >\n          {format(tick)}\n        </span>\n      ))}\n    </div>\n  );\n}\n\n/** Zero is drawn stronger when values go below it, since bars start there. */\nexport function gridToneOf(tick: number, scale: Scale) {\n  const isZero = tick === 0;\n  const crossesZero = scale.min < 0;\n  if (isZero && crossesZero) {\n    return 'border-foreground/25';\n  }\n  return 'border-border';\n}\n\ntype GridLinesProps = {\n  scale: Scale;\n  height: number;\n};\n\nexport function GridLines({ scale, height }: GridLinesProps) {\n  return (\n    <div aria-hidden='true' className='pointer-events-none absolute inset-0'>\n      {scale.ticks.map((tick) => (\n        <span\n          key={tick}\n          className={cn(\n            'hairline-t absolute inset-x-0',\n            gridToneOf(tick, scale)\n          )}\n          style={{ top: yOf(tick, scale, height) }}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport type AxisLabel = {\n  id: string;\n  text: string;\n  /** From 0 at the left edge of the plot to 1 at its right edge. */\n  ratio: number;\n  isStrong?: boolean;\n};\n\n/** Every how many positions a label is written, so the labels that are written never touch. */\nexport function labelStepOf(count: number, plotWidth: number) {\n  const fitting = Math.floor(plotWidth / LABEL_ROOM_PX);\n  const labelCount = Math.max(fitting, 1);\n  const step = Math.ceil(count / labelCount);\n  return Math.max(step, 1);\n}\n\n/** The labels kept when one in `step` is written, counted back from the last so the latest is always named. */\nexport function spacedLabelsOf(labels: readonly AxisLabel[], step: number) {\n  const lastIndex = labels.length - 1;\n  return labels.filter((_, index) => {\n    const fromLast = lastIndex - index;\n    return fromLast % step === 0;\n  });\n}\n\n/**\n * One label per position, under the middle of its column, written as often as the room allows. The\n * one at `strongIndex`, such as today, is set stronger.\n */\nexport function positionLabelsOf(\n  labels: readonly string[],\n  plotWidth: number,\n  strongIndex?: number\n) {\n  const all: AxisLabel[] = labels.map((text, index) => ({\n    id: `${index}-${text}`,\n    text,\n    ratio: columnCenterOf(index, labels.length),\n    isStrong: index === strongIndex,\n  }));\n  const step = labelStepOf(labels.length, plotWidth);\n  return spacedLabelsOf(all, step);\n}\n\nfunction strongOf(isStrong: boolean | undefined) {\n  if (isStrong) {\n    return 'text-foreground font-medium';\n  }\n  return 'text-muted-foreground';\n}\n\ntype LabelAlign = 'spread' | 'center';\n\n/**\n * A spread label slides from its left edge to its right edge as it crosses the plot, like the\n * readout, so the first and the last never stick out of the piece. A centred label sits under its\n * mark, for the few wide columns of a bar chart.\n */\nfunction labelStyleOf(\n  label: AxisLabel,\n  align: LabelAlign\n): React.CSSProperties {\n  const left = percentOf(label.ratio);\n  if (align === 'center') {\n    return { left, transform: 'translateX(-50%)' };\n  }\n  const shift = percentOf(-label.ratio);\n  return { left, transform: `translateX(${shift})` };\n}\n\ntype CategoryAxisProps = {\n  labels: readonly AxisLabel[];\n  align?: LabelAlign;\n  className?: string;\n};\n\nexport function CategoryAxis({\n  labels,\n  align = 'spread',\n  className,\n}: CategoryAxisProps) {\n  return (\n    <div aria-hidden='true' className={cn('relative h-4', className)}>\n      {labels.map((label) => (\n        <span\n          key={label.id}\n          className={cn(\n            'absolute top-0 text-[11px] whitespace-nowrap tabular-nums',\n            strongOf(label.isStrong)\n          )}\n          style={labelStyleOf(label, align)}\n        >\n          {label.text}\n        </span>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/axis.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/chart-card.tsx",
      "content": "import { useId } from 'react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Badge } from '@/registry/ndeko/ui/badge';\n\ntype ChartCardProps = {\n  title: string;\n  /** A short mark beside the title, such as \"Illustrative\" over sample data. */\n  note?: string;\n  /** One line that says what the chart shows, read by screen readers with the title. */\n  summary: string;\n  /** Controls that change what the chart draws: a period, a view. */\n  actions?: React.ReactNode;\n  className?: string;\n  children: React.ReactNode;\n};\n\n/**\n * The surface every chart sits on. It is a query container, so a chart lays itself out by the\n * room it has, and it is positioned, so the hidden table of a chart never widens the page.\n */\nexport function ChartCard({\n  title,\n  note,\n  summary,\n  actions,\n  className,\n  children,\n}: ChartCardProps) {\n  const titleId = useId();\n  const summaryId = useId();\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      aria-describedby={summaryId}\n      className={cn(\n        'bg-card hairline-ring ring-border @container relative flex flex-col gap-4 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]',\n        className\n      )}\n    >\n      <header className='flex min-h-7 flex-wrap items-center justify-between gap-x-3 gap-y-2'>\n        <div className='flex min-w-0 items-center gap-2'>\n          <h3 id={titleId} className='text-sm font-medium text-balance'>\n            {title}\n          </h3>\n          {note && <Badge variant='secondary'>{note}</Badge>}\n        </div>\n        {actions}\n      </header>\n      {children}\n      <p id={summaryId} className='sr-only'>\n        {summary}\n      </p>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/chart-card.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/data-table.tsx",
      "content": "export type DataRow = {\n  id: string;\n  /** The heading of the row: a day, a category, a step. */\n  label: string;\n  cells: readonly string[];\n};\n\ntype DataTableProps = {\n  caption: string;\n  /** The heading of the label column first, then one per cell. */\n  columns: readonly string[];\n  rows: readonly DataRow[];\n};\n\n/**\n * The numbers a chart draws, as a table for screen readers. It is taken out of the flow, so the\n * piece it sits in has to be positioned: a hidden box inside an area that scrolls sideways widens\n * the page otherwise.\n */\nexport function DataTable({ caption, columns, rows }: DataTableProps) {\n  return (\n    <table className='sr-only'>\n      <caption>{caption}</caption>\n      <thead>\n        <tr>\n          {columns.map((column) => (\n            <th key={column} scope='col'>\n              {column}\n            </th>\n          ))}\n        </tr>\n      </thead>\n      <tbody>\n        {rows.map((row) => (\n          <tr key={row.id}>\n            <th scope='row'>{row.label}</th>\n            {row.cells.map((cell, index) => (\n              <td key={index}>{cell}</td>\n            ))}\n          </tr>\n        ))}\n      </tbody>\n    </table>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/data-table.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/demo.ts",
      "content": "import type { AutoplayStep } from '@/registry/ndeko/hooks/use-autoplay';\nimport { keyDelayOf } from '@/registry/ndeko/lib/demonstration';\n\ntype WalkOptions = {\n  /** Milliseconds before the first position. */\n  startAfter?: number;\n  /** Milliseconds between two positions. */\n  stepMs?: number;\n};\n\n/** One step per position, so a reading crosses a plot the way a pointer does. */\nexport function walkSteps<Position>(\n  positions: readonly Position[],\n  moveTo: (position: Position) => void,\n  { startAfter = 700, stepMs = 240 }: WalkOptions = {}\n): AutoplayStep[] {\n  return positions.map((position, index) => {\n    const after = keyDelayOf(index, startAfter, stepMs);\n    return { after, run: () => moveTo(position) };\n  });\n}\n\n/** What a demonstration points at lets go the moment a person takes over. */\nexport function whileDemonstrating<Value>(\n  isDemonstrating: boolean,\n  value: Value | null\n) {\n  if (isDemonstrating) {\n    return value;\n  }\n  return null;\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/demo.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/format.ts",
      "content": "const LOCALE = 'en-US';\n\nexport const COUNT_FORMAT: Intl.NumberFormatOptions = {\n  maximumFractionDigits: 0,\n};\n\nexport const SIGNED_FORMAT: Intl.NumberFormatOptions = {\n  maximumFractionDigits: 0,\n  signDisplay: 'exceptZero',\n};\n\nexport const USD_COMPACT_FORMAT: Intl.NumberFormatOptions = {\n  style: 'currency',\n  currency: 'USD',\n  notation: 'compact',\n  maximumFractionDigits: 1,\n};\n\nconst COMPACT_FORMAT: Intl.NumberFormatOptions = {\n  notation: 'compact',\n  maximumFractionDigits: 1,\n};\n\nconst COUNT = new Intl.NumberFormat(LOCALE, COUNT_FORMAT);\n\nconst SIGNED = new Intl.NumberFormat(LOCALE, SIGNED_FORMAT);\n\nconst COMPACT = new Intl.NumberFormat(LOCALE, COMPACT_FORMAT);\n\nconst USD_COMPACT = new Intl.NumberFormat(LOCALE, USD_COMPACT_FORMAT);\n\nconst DAY = new Intl.DateTimeFormat(LOCALE, {\n  month: 'short',\n  day: 'numeric',\n  timeZone: 'UTC',\n});\n\nconst MS_PER_DAY = 86_400_000;\n\nexport function formatCount(value: number) {\n  return COUNT.format(value);\n}\n\nexport function formatSigned(value: number) {\n  return SIGNED.format(value);\n}\n\nexport function formatCompact(value: number) {\n  return COMPACT.format(value);\n}\n\nexport function formatUsd(value: number) {\n  return USD_COMPACT.format(value);\n}\n\nexport function formatShare(share: number) {\n  const percent = Math.round(share * 100);\n  return `${percent}%`;\n}\n\n/** A start at zero has no percentage to grow from: it reads as no change. */\nexport function percentChangeOf(current: number, previous: number) {\n  if (previous === 0) {\n    return 0;\n  }\n  const difference = current - previous;\n  const ratio = difference / Math.abs(previous);\n  return Math.round(ratio * 100);\n}\n\nexport function changeWordsOf(percent: number, reference: string) {\n  if (percent === 0) {\n    return `level with ${reference}`;\n  }\n  const size = Math.abs(percent);\n  if (percent > 0) {\n    return `up ${size}% on ${reference}`;\n  }\n  return `down ${size}% on ${reference}`;\n}\n\nexport function nounOf(count: number, singular: string, plural: string) {\n  if (count === 1) {\n    return singular;\n  }\n  return plural;\n}\n\n/**\n * One label per day, or per `everyDays` days for a weekly series. Days are counted in UTC so the\n * server and the browser write the same label.\n */\nexport function dayLabelsOf(firstDay: string, count: number, everyDays = 1) {\n  const start = Date.parse(`${firstDay}T00:00:00Z`);\n  const stride = everyDays * MS_PER_DAY;\n  return Array.from({ length: count }, (_, index) => {\n    const elapsed = index * stride;\n    return DAY.format(start + elapsed);\n  });\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/format.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/path.ts",
      "content": "import { columnCenterOf, lerp, ratioOf, type Domain } from './scale';\n\nexport type Point = {\n  x: number;\n  y: number;\n};\n\n/** Paths are drawn in a box this wide and stretched to the plot, so a change of width never redraws them. */\nexport const VIEW_WIDTH = 1000;\n\nfunction commandOf(point: Point, index: number) {\n  const x = point.x.toFixed(1);\n  const y = point.y.toFixed(1);\n  if (index === 0) {\n    return `M${x} ${y}`;\n  }\n  return `L${x} ${y}`;\n}\n\nexport function linePathOf(points: readonly Point[]) {\n  const commands = points.map((point, index) => commandOf(point, index));\n  return commands.join(' ');\n}\n\n/** The line closed down (or up) to a level, for the wash under a line. */\nexport function areaPathOf(points: readonly Point[], levelY: number) {\n  if (points.length === 0) {\n    return '';\n  }\n  const line = linePathOf(points);\n  const lastIndex = points.length - 1;\n  const firstX = points[0].x.toFixed(1);\n  const lastX = points[lastIndex].x.toFixed(1);\n  const level = levelY.toFixed(1);\n  return `${line} L${lastX} ${level} L${firstX} ${level} Z`;\n}\n\n/** The surface between two lines that share their x positions, for a range around a line. */\nexport function bandPathOf(upper: readonly Point[], lower: readonly Point[]) {\n  if (upper.length === 0) {\n    return '';\n  }\n  const backwards = [...lower].reverse();\n  const outline = [...upper, ...backwards];\n  const line = linePathOf(outline);\n  return `${line} Z`;\n}\n\n/** The pixel height of a value in a plot: the top of the domain is 0, its bottom is the plot height. */\nexport function yOf(value: number, domain: Domain, height: number) {\n  const ratio = ratioOf(value, domain);\n  const fromBottom = ratio * height;\n  return height - fromBottom;\n}\n\n/** One point per value, each in the middle of its column. */\nexport function plotPointsOf(\n  values: readonly number[],\n  domain: Domain,\n  height: number\n): Point[] {\n  return values.map((value, index) => {\n    const share = columnCenterOf(index, values.length);\n    const x = share * VIEW_WIDTH;\n    const y = yOf(value, domain, height);\n    return { x, y };\n  });\n}\n\n/** How many of the pieces lie before a segment when they are shared out evenly. */\nfunction piecesBefore(\n  segment: number,\n  segmentCount: number,\n  pieceCount: number\n) {\n  const share = segment / segmentCount;\n  return Math.floor(share * pieceCount);\n}\n\n/**\n * The same line through exactly `count` points: each segment is cut into equal parts, so two lines\n * of different lengths share one structure and one can glide into the other.\n */\nexport function densifyOf(points: readonly Point[], count: number): Point[] {\n  const segmentCount = points.length - 1;\n  const hasSegments = segmentCount >= 1;\n  const hasRoom = count > points.length;\n  if (!hasSegments || !hasRoom) {\n    return [...points];\n  }\n  const pieceCount = count - 1;\n  const dense: Point[] = [points[0]];\n  for (let segment = 0; segment < segmentCount; segment += 1) {\n    const from = points[segment];\n    const to = points[segment + 1];\n    const start = piecesBefore(segment, segmentCount, pieceCount);\n    const end = piecesBefore(segment + 1, segmentCount, pieceCount);\n    const parts = end - start;\n    for (let part = 1; part <= parts; part += 1) {\n      const amount = part / parts;\n      const x = lerp(from.x, to.x, amount);\n      const y = lerp(from.y, to.y, amount);\n      dense.push({ x, y });\n    }\n  }\n  return dense;\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/path.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/plot.tsx",
      "content": "import { cn } from '@/registry/ndeko/lib/utils';\n\nimport { CategoryAxis, GridLines, ValueAxis, type AxisLabel } from './axis';\nimport { VIEW_WIDTH } from './path';\nimport type { Scale } from './scale';\n\n/**\n * A plot is a query container, so the readout and the marks inside it are placed in shares of its\n * width, and it lets the page scroll vertically under a finger while a sideways move reads values.\n */\nexport const PLOT_SURFACE =\n  'focus-visible:ring-ring/25 @container relative touch-pan-y rounded-md outline-none focus-visible:ring-[3px]';\n\ntype PlotFrameProps = {\n  /** The name of the plot and how to read it, for screen readers. */\n  label: string;\n  scale: Scale;\n  height: number;\n  formatTick: (value: number) => string;\n  axisLabels: readonly AxisLabel[];\n  axisAlign?: 'spread' | 'center';\n  /** Receives the plot element, for a chart that measures the room it has. */\n  plotRef?: (element: HTMLDivElement | null) => void;\n  /** The handlers of the reading: the plot is the one tab stop of the chart. */\n  plotProps?: React.HTMLAttributes<HTMLDivElement>;\n  plotClassName?: string;\n  /** For a chart whose marks carry their own figures, which can drop the axis on a narrow piece. */\n  valueAxisClassName?: string;\n  /** Labels laid out by the chart itself, in place of one row of `axisLabels`. */\n  categoryAxis?: React.ReactNode;\n  className?: string;\n  children: React.ReactNode;\n};\n\n/**\n * A plot between its value axis on the left and its category axis underneath. Each part has its own\n * cell: placed in turn, a plot whose value axis is hidden would fall into the axis column, which is\n * only as wide as its content, and nothing in a plot has a width of its own, so every mark would\n * stack at the left edge.\n */\nexport function PlotFrame({\n  label,\n  scale,\n  height,\n  formatTick,\n  axisLabels,\n  axisAlign,\n  plotRef,\n  plotProps,\n  plotClassName,\n  valueAxisClassName,\n  categoryAxis,\n  className,\n  children,\n}: PlotFrameProps) {\n  return (\n    <div\n      className={cn('grid grid-cols-[auto_minmax(0,1fr)] gap-x-2', className)}\n    >\n      <ValueAxis\n        scale={scale}\n        height={height}\n        format={formatTick}\n        className={cn('col-start-1 row-start-1', valueAxisClassName)}\n      />\n      <div\n        ref={plotRef}\n        role='group'\n        tabIndex={0}\n        aria-label={label}\n        className={cn(PLOT_SURFACE, 'col-start-2 row-start-1', plotClassName)}\n        style={{ height }}\n        {...plotProps}\n      >\n        <GridLines scale={scale} height={height} />\n        {children}\n      </div>\n      <div className='col-start-2 row-start-2 min-w-0'>\n        {categoryAxis}\n        {!categoryAxis && (\n          <CategoryAxis\n            labels={axisLabels}\n            align={axisAlign}\n            className='mt-2'\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\ntype PathLayerProps = {\n  height: number;\n  className?: string;\n  children: React.ReactNode;\n};\n\n/**\n * The layer the paths are drawn in: `VIEW_WIDTH` units across whatever the width of the plot, and\n * real pixels down. Strokes keep their thickness because each path opts out of the stretch.\n */\nexport function PathLayer({ height, className, children }: PathLayerProps) {\n  return (\n    <svg\n      aria-hidden='true'\n      viewBox={`0 0 ${VIEW_WIDTH} ${height}`}\n      preserveAspectRatio='none'\n      className={cn(\n        'pointer-events-none absolute inset-0 size-full overflow-visible',\n        className\n      )}\n    >\n      {children}\n    </svg>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/plot.tsx"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/scale.ts",
      "content": "export type Domain = {\n  min: number;\n  max: number;\n};\n\nexport type Scale = Domain & {\n  step: number;\n  ticks: number[];\n};\n\nconst NICE_FACTORS = [1, 2, 5, 10];\n\nconst EMPTY_DOMAIN: Domain = { min: 0, max: 0 };\n\nexport function clamp(value: number, min: number, max: number) {\n  const atLeastMin = Math.max(value, min);\n  return Math.min(atLeastMin, max);\n}\n\nexport function lerp(from: number, to: number, amount: number) {\n  const span = to - from;\n  return from + span * amount;\n}\n\nexport function sumOf(values: readonly number[]) {\n  let total = 0;\n  for (const value of values) {\n    total += value;\n  }\n  return total;\n}\n\nexport function extentOf(values: readonly number[]): Domain {\n  if (values.length === 0) {\n    return EMPTY_DOMAIN;\n  }\n  let min = values[0];\n  let max = values[0];\n  for (const value of values) {\n    min = Math.min(min, value);\n    max = Math.max(max, value);\n  }\n  return { min, max };\n}\n\n/** Bars and areas are read from zero, so their domain always holds it. */\nexport function withZero(domain: Domain): Domain {\n  const min = Math.min(domain.min, 0);\n  const max = Math.max(domain.max, 0);\n  return { min, max };\n}\n\n/** Where a value sits in a domain, from 0 at its lowest to 1 at its highest. */\nexport function ratioOf(value: number, domain: Domain) {\n  const span = domain.max - domain.min;\n  if (span === 0) {\n    return 0;\n  }\n  const offset = value - domain.min;\n  return offset / span;\n}\n\n/** A flat series still needs room above and below it to be drawn. */\nfunction spanOf(domain: Domain) {\n  const span = domain.max - domain.min;\n  if (span > 0) {\n    return span;\n  }\n  const size = Math.abs(domain.max);\n  return Math.max(size, 1);\n}\n\nfunction niceStepOf(roughStep: number) {\n  const logarithm = Math.log10(roughStep);\n  const exponent = Math.floor(logarithm);\n  const magnitude = 10 ** exponent;\n  const factor = NICE_FACTORS.find((candidate) => {\n    const candidateStep = candidate * magnitude;\n    return candidateStep >= roughStep;\n  });\n  const safeFactor = factor ?? 10;\n  return safeFactor * magnitude;\n}\n\nfunction decimalsOf(step: number) {\n  const logarithm = Math.log10(step);\n  const exponent = Math.floor(logarithm);\n  return Math.max(0, -exponent);\n}\n\n/** Adding zero turns a negative zero into zero, which a number format would write as -0. */\nfunction roundedTo(value: number, decimals: number) {\n  const factor = 10 ** decimals;\n  const scaled = Math.round(value * factor);\n  const rounded = scaled / factor;\n  return rounded + 0;\n}\n\n/** A scale with round ticks that holds the whole domain, in at most `maxLines` steps. */\nexport function niceScaleOf(domain: Domain, maxLines: number): Scale {\n  const span = spanOf(domain);\n  const roughStep = span / maxLines;\n  const step = niceStepOf(roughStep);\n  const decimals = decimalsOf(step);\n  const stepsBelow = Math.floor(domain.min / step);\n  const lowest = stepsBelow * step;\n  const highestWanted = Math.max(domain.max, lowest + step);\n  const stepsAbove = Math.ceil(highestWanted / step);\n  const lineCount = stepsAbove - stepsBelow;\n  const tickCount = lineCount + 1;\n  const ticks = Array.from({ length: tickCount }, (_, index) => {\n    const tick = lowest + index * step;\n    return roundedTo(tick, decimals);\n  });\n  const lastIndex = ticks.length - 1;\n  return { min: ticks[0], max: ticks[lastIndex], step, ticks };\n}\n\n/** Columns share the plot equally and a mark sits in the middle of its column. */\nexport function columnCenterOf(index: number, count: number) {\n  const center = index + 0.5;\n  return center / count;\n}\n\nexport function columnIndexAt(ratio: number, count: number) {\n  const rawIndex = Math.floor(ratio * count);\n  const lastIndex = Math.max(count - 1, 0);\n  return clamp(rawIndex, 0, lastIndex);\n}\n\nexport function percentOf(share: number) {\n  const percent = share * 100;\n  return `${percent}%`;\n}\n\n/** The whole numbers that lead from one position to another: the start is left out, the end is kept. */\nexport function positionsTo(from: number, to: number) {\n  const gap = to - from;\n  const distance = Math.abs(gap);\n  const direction = Math.sign(gap);\n  return Array.from({ length: distance }, (_, index) => {\n    const travelled = index + 1;\n    const offset = travelled * direction;\n    return from + offset;\n  });\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/scale.ts"
    },
    {
      "path": "registry/ndeko/components/chart/_parts/use-chart-demo.ts",
      "content": "'use client';\n\nimport {\n  useAutoplay,\n  type AutoplayOptions,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\n\n/**\n * What every chart demo shares: the demonstration, the box that keeps its tallest height so a round\n * never moves the page, and a stop when the keyboard brings focus in. A person who tabs to a plot\n * reads it with the arrow keys from there, and has pressed no key inside the piece yet.\n */\nexport function useChartDemo(\n  steps: readonly AutoplayStep[],\n  options?: AutoplayOptions\n) {\n  const demo = useAutoplay(steps, options);\n  const { ref, takeOver } = demo;\n\n  useKeptHeight(ref);\n\n  function stopOnKeyboardFocus(event: React.FocusEvent<HTMLElement>) {\n    const isKeyboardFocus = event.target.matches(':focus-visible');\n    if (isKeyboardFocus) {\n      takeOver();\n    }\n  }\n\n  return { ...demo, stopOnKeyboardFocus };\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/use-chart-demo.ts"
    },
    {
      "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-step-drag.ts",
      "content": "'use client';\n\nimport { useRef, useState } from 'react';\n\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\n\nimport { clamp, lerp, positionsTo } from './scale';\n\n/** `columns` share the track equally and the handle sits in the middle of one; `edges` are the lines between columns, both ends included. */\ntype Placement = 'columns' | 'edges';\n\ntype StepDragOptions = {\n  /** How many positions the handle can rest on. */\n  count: number;\n  index: number;\n  onIndexChange: (index: number) => void;\n  placement?: Placement;\n};\n\ntype Sample = {\n  time: number;\n  position: number;\n};\n\n/** After the hand lets go, the handle travels as far as its speed would carry it in this time. */\nconst MOMENTUM_MS = 140;\n\n/** A hand that stopped this long before letting go carries nothing. */\nconst STILL_AFTER_MS = 90;\n\nconst FIRST_COAST_MS = 36;\n\n/** Each position of a coast takes this much longer than the one before, so the handle slows to a stop. */\nconst COAST_SLOWDOWN = 1.28;\n\nconst PAGE_STEP = 3;\n\n/** How much of the newest reading of the speed is kept: one sample alone is too jumpy. */\nconst SPEED_SMOOTHING = 0.6;\n\nfunction positionFrom(\n  event: React.PointerEvent<HTMLElement>,\n  count: number,\n  placement: Placement\n) {\n  const box = event.currentTarget.getBoundingClientRect();\n  const offset = event.clientX - box.left;\n  const ratio = offset / box.width;\n  if (placement === 'edges') {\n    const lastEdge = count - 1;\n    return ratio * lastEdge;\n  }\n  const column = ratio * count;\n  return column - 0.5;\n}\n\nfunction keyTargetOf(key: string, index: number, lastIndex: number) {\n  const isForward = key === 'ArrowRight' || key === 'ArrowUp';\n  const isBackward = key === 'ArrowLeft' || key === 'ArrowDown';\n  if (isForward) {\n    return index + 1;\n  }\n  if (isBackward) {\n    return index - 1;\n  }\n  if (key === 'PageUp') {\n    return index + PAGE_STEP;\n  }\n  if (key === 'PageDown') {\n    return index - PAGE_STEP;\n  }\n  if (key === 'Home') {\n    return 0;\n  }\n  if (key === 'End') {\n    return lastIndex;\n  }\n  return null;\n}\n\n/**\n * A handle that moves from one position to the next under a hand: it catches on each position, it\n * keeps going for a moment when it is flicked, and the arrow keys move it one position at a time.\n * Spread `sliderProps` on the track, which is the one tab stop.\n */\nexport function useStepDrag({\n  count,\n  index,\n  onIndexChange,\n  placement = 'columns',\n}: StepDragOptions) {\n  const { schedule, clearAll } = useTimeouts();\n  const [isDragging, setIsDragging] = useState(false);\n  const lastSample = useRef<Sample | null>(null);\n  const speed = useRef(0);\n  const lastIndex = Math.max(count - 1, 0);\n\n  function indexAt(position: number) {\n    const nearest = Math.round(position);\n    return clamp(nearest, 0, lastIndex);\n  }\n\n  function moveTo(next: number) {\n    if (next !== index) {\n      onIndexChange(next);\n    }\n  }\n\n  /** Goes to a position through every one on the way, each a little later than the last. */\n  function coastTo(target: number) {\n    clearAll();\n    const end = clamp(target, 0, lastIndex);\n    const stops = positionsTo(index, end);\n    let delay = 0;\n    let pace = FIRST_COAST_MS;\n    for (const stop of stops) {\n      delay += pace;\n      pace *= COAST_SLOWDOWN;\n      schedule(() => onIndexChange(stop), delay);\n    }\n  }\n\n  function startDrag(event: React.PointerEvent<HTMLElement>) {\n    if (event.button !== 0) {\n      return;\n    }\n    clearAll();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const position = positionFrom(event, count, placement);\n    lastSample.current = { time: event.timeStamp, position };\n    speed.current = 0;\n    setIsDragging(true);\n    const next = indexAt(position);\n    moveTo(next);\n  }\n\n  function drag(event: React.PointerEvent<HTMLElement>) {\n    const previous = lastSample.current;\n    if (!previous) {\n      return;\n    }\n    const position = positionFrom(event, count, placement);\n    const elapsed = event.timeStamp - previous.time;\n    if (elapsed > 0) {\n      const moved = position - previous.position;\n      const latest = moved / elapsed;\n      speed.current = lerp(speed.current, latest, SPEED_SMOOTHING);\n    }\n    lastSample.current = { time: event.timeStamp, position };\n    const next = indexAt(position);\n    moveTo(next);\n  }\n\n  function endDrag(event: React.PointerEvent<HTMLElement>) {\n    const previous = lastSample.current;\n    if (!previous) {\n      return;\n    }\n    lastSample.current = null;\n    setIsDragging(false);\n    const stillFor = event.timeStamp - previous.time;\n    if (stillFor > STILL_AFTER_MS) {\n      return;\n    }\n    const carried = Math.round(speed.current * MOMENTUM_MS);\n    if (carried !== 0) {\n      coastTo(index + carried);\n    }\n  }\n\n  function cancelDrag() {\n    lastSample.current = null;\n    setIsDragging(false);\n  }\n\n  function pressKey(event: React.KeyboardEvent<HTMLElement>) {\n    const target = keyTargetOf(event.key, index, lastIndex);\n    if (target === null) {\n      return;\n    }\n    event.preventDefault();\n    clearAll();\n    const next = clamp(target, 0, lastIndex);\n    moveTo(next);\n  }\n\n  const sliderProps = {\n    onPointerDown: startDrag,\n    onPointerMove: drag,\n    onPointerUp: endDrag,\n    onPointerCancel: cancelDrag,\n    onKeyDown: pressKey,\n  };\n\n  return { isDragging, flickTo: coastTo, sliderProps };\n}\n",
      "type": "registry:component",
      "target": "components/chart/_parts/use-step-drag.ts"
    }
  ],
  "categories": [
    "components",
    "chart"
  ],
  "type": "registry:component"
}