{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dial",
  "title": "Dial",
  "description": "A fat arc of rounded segments that light up to a value with a marker in the signal colour, or a three-zone gauge with a needle; read-only or a slider you can drag or key.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/dial/dial.tsx",
      "content": "// Dial: a fat arc of rounded segments that light up to the value, with a small marker in the signal colour riding\n// the outside and the figure in the middle. Give it `zones` and it becomes the smaller three-state gauge instead: the\n// arc is coloured by zone, a needle points at the value, and a status line and zone chips sit beside it.\n//\n// How it works: the arc runs from -120° to +120° and is cut into `segments` pills. Segments up to the value light\n// one after another (14 ms apart, never longer than 300 ms in all), and the marker or needle turns to the value. It\n// is read-only by default. Pass `onValueChange` (or `adjustable`) and it becomes a slider: arrow keys step, Page Up\n// and Page Down step ten times, Home and End jump to the ends, and dragging along the arc sets the value.\n//\n// Screen readers: read-only it is a meter, \"Monthly budget used, 77 percent of the monthly budget\"; adjustable it is\n// a slider with the same value text, so each key press is read by the slider itself and nothing else is announced.\n// The figure, the ends and the zone chips are hidden because the value text already says them.\n//\n// Motion: segments only change opacity and the marker only turns (transform). Under reduced motion the value is\n// drawn at once.\nimport {\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from 'react'\nimport { cn } from '@/lib/utils'\nimport { BigNumber } from '@/registry/manniche/chart-kit/chart-kit'\nimport { EASE_CSS, formatValue, seriesColor, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'\nimport { useChartFrame } from '@/registry/manniche/chart-kit/use-chart'\n\nexport type DialZone = {\n  /** The zone's name, shown on its chip and read with the value: \"On track\". */\n  label: string\n  /** Where the zone ends, in the dial's units. The first zone starts at `min`; the last should end at `max`. */\n  to: number\n  /** Any CSS colour. Defaults to the series colours in order; use --success and --destructive for good and bad. */\n  color?: string\n}\n\nexport type DialLabels = {\n  /** The status line of a zoned dial. Default `${label}: ${zone}` with the zone in lower case. */\n  status?: (zone: string, label: string) => string\n}\n\nexport type DialProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'defaultValue' | 'onChange'> & {\n  /** The value. Pass it with `onValueChange` to control an adjustable dial, or alone for a read-only gauge. */\n  value?: number\n  /** The starting value when uncontrolled. Default `min`. */\n  defaultValue?: number\n  /** Called with the new value when a key or a drag changes it. Passing it makes the dial adjustable. */\n  onValueChange?: (value: number) => void\n  /** Make the dial a slider even without `onValueChange` (uncontrolled). Default: true when `onValueChange` is set. */\n  adjustable?: boolean\n  /** What the dial measures, such as \"Monthly budget used\". Its accessible name. */\n  label: string\n  /** The value at the left end. Default 0. */\n  min?: number\n  /** The value at the right end. Default 100, or the end of the last zone. */\n  max?: number\n  /** How far one arrow key moves an adjustable dial. Default 1. */\n  step?: number\n  /** How the value is written. Default `{ suffix: '%' }`. */\n  format?: ValueFormat\n  /**\n   * The small line under the figure (\"of €1 800 budget\"). On a zoned dial it follows the value on the second line\n   * (\"0.92× your daily budget so far\").\n   */\n  caption?: ReactNode\n  /** The labels under the two ends of the arc. Default `min` and `max` in `format`; false hides them. */\n  ends?: [ReactNode, ReactNode] | false\n  /** How many segments make the arc. Default 24. */\n  segments?: number\n  /** Turns the dial into the zoned gauge: arc coloured by zone, a needle, a status line and zone chips. */\n  zones?: DialZone[]\n  /** The value as words for screen readers. Default: the value in `format`, plus the zone when there is one. */\n  valueText?: (value: number) => string\n  /** Words for other languages. */\n  labels?: DialLabels\n  /** \"compact\" draws a smaller dial. Inside a compact DataTile this happens on its own. */\n  density?: 'comfortable' | 'compact'\n}\n\n// The arc lives in a 220-wide box around a centre at (110, 112).\nconst CX = 110\nconst CY = 112\nconst A0 = -120\nconst A1 = 120\nconst f2 = (v: number) => Number(v.toFixed(2))\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))\n\nexport function Dial({\n  value,\n  defaultValue,\n  onValueChange,\n  adjustable,\n  label,\n  min = 0,\n  max: maxProp,\n  step = 1,\n  format = { suffix: '%' },\n  caption,\n  ends,\n  segments = 24,\n  zones,\n  valueText,\n  labels,\n  density,\n  className,\n  ...rest\n}: DialProps) {\n  const max = maxProp ?? (zones?.length ? zones[zones.length - 1].to : 100)\n  const span = max - min || 1\n  const { ref, drawn, reduced } = useChartFrame<HTMLDivElement>()\n  const [own, setOwn] = useState(() => clamp(defaultValue ?? min, min, max))\n  const v = clamp(value ?? own, min, max)\n  const canAdjust = adjustable ?? Boolean(onValueChange)\n  const dragging = useRef(false)\n\n  // Round to the step, counted from min, so 0.05 steps never drift to 0.9500000001.\n  const decimals = (String(step).split('.')[1] ?? '').length\n  const snap = (x: number) => clamp(Number((min + Math.round((x - min) / step) * step).toFixed(decimals)), min, max)\n  const commit = (x: number) => {\n    const next = snap(x)\n    if (next === v) return\n    if (value === undefined) setOwn(next)\n    onValueChange?.(next)\n  }\n\n  const zoneOf = (x: number) => {\n    if (!zones?.length) return -1\n    const i = zones.findIndex((z) => x <= z.to + 1e-9)\n    return i < 0 ? zones.length - 1 : i\n  }\n  const zi = zoneOf(v)\n  const zone = zi >= 0 ? zones?.[zi] : undefined\n  const text = valueText ? valueText(v) : `${formatValue(v, format)}${zone ? `, ${zone.label}` : ''}`\n\n  // Lit segments, and where the last change started, so they light in order away from it.\n  const shown = drawn ? v : min\n  const lit = Math.round(((shown - min) / span) * segments)\n  const [run, setRun] = useState({ lit, from: lit })\n  if (run.lit !== lit) setRun({ lit, from: run.lit })\n  const gap = Math.min(14, 300 / Math.max(1, Math.abs(run.lit - run.from)))\n  const delayOf = (k: number) => {\n    if (reduced) return 0\n    if (run.lit > run.from && k >= run.from && k < run.lit) return Math.round((k - run.from) * gap)\n    if (run.lit < run.from && k >= run.lit && k < run.from) return Math.round((run.from - 1 - k) * gap)\n    return 0\n  }\n  const angle = A0 + ((A1 - A0) * (shown - min)) / span\n\n  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    const by: Record<string, number> = {\n      ArrowRight: step,\n      ArrowUp: step,\n      ArrowLeft: -step,\n      ArrowDown: -step,\n      PageUp: step * 10,\n      PageDown: -step * 10,\n    }\n    if (e.key === 'Home') commit(min)\n    else if (e.key === 'End') commit(max)\n    else if (e.key in by) commit(v + by[e.key])\n    else return\n    e.preventDefault()\n  }\n\n  const fromPointer = (e: PointerEvent<SVGSVGElement>) => {\n    const box = e.currentTarget.getBoundingClientRect()\n    const vb = e.currentTarget.viewBox.baseVal\n    if (!box.width || !vb) return\n    const x = ((e.clientX - box.left) / box.width) * vb.width - CX\n    const y = ((e.clientY - box.top) / box.height) * vb.height - CY\n    // 0° at the top, clockwise; below the centre it snaps to the nearer end.\n    let a = (Math.atan2(x, -y) * 180) / Math.PI\n    if (a > A1) a = a > 180 - (180 - A1) / 2 ? A0 : A1\n    if (a < A0) a = a < -180 + (180 + A0) / 2 ? A1 : A0\n    commit(min + ((a - A0) / (A1 - A0)) * span)\n  }\n  const pointer = canAdjust\n    ? {\n        onPointerDown: (e: PointerEvent<SVGSVGElement>) => {\n          if (e.button !== 0) return\n          dragging.current = true\n          e.currentTarget.setPointerCapture(e.pointerId)\n          ;(e.currentTarget.parentElement as HTMLElement | null)?.focus({ preventScroll: true })\n          fromPointer(e)\n        },\n        onPointerMove: (e: PointerEvent<SVGSVGElement>) => {\n          if (dragging.current) fromPointer(e)\n        },\n        onPointerUp: () => {\n          dragging.current = false\n        },\n        onPointerCancel: () => {\n          dragging.current = false\n        },\n      }\n    : {}\n\n  // A drag that ends outside the window still lets go.\n  useEffect(() => {\n    const up = () => {\n      dragging.current = false\n    }\n    window.addEventListener('pointerup', up)\n    return () => window.removeEventListener('pointerup', up)\n  }, [])\n\n  const role = canAdjust\n    ? {\n        role: 'slider',\n        tabIndex: 0,\n        'aria-orientation': 'horizontal' as const,\n        onKeyDown: onKey,\n      }\n    : { role: 'meter' }\n  const aria = {\n    'aria-label': label,\n    'aria-valuemin': min,\n    'aria-valuemax': max,\n    'aria-valuenow': f2(v),\n    'aria-valuetext': text,\n  }\n  const turn = (deg: number): CSSProperties => ({\n    transformBox: 'view-box',\n    transformOrigin: `${CX}px ${CY}px`,\n    transform: `rotate(${f2(deg)}deg)`,\n    transition: reduced ? 'none' : `transform 300ms ${EASE_CSS}`,\n  })\n\n  const arc = (n: number, r0: number, len: number, w: number, fill: (k: number) => CSSProperties) =>\n    Array.from({ length: n }, (_, k) => {\n      const a = A0 + ((A1 - A0) * (k + 0.5)) / n\n      return (\n        <rect\n          key={k}\n          x={CX - w / 2}\n          y={CY - r0 - len}\n          width={w}\n          height={len}\n          rx={w / 2}\n          transform={`rotate(${f2(a)} ${CX} ${CY})`}\n          style={fill(k)}\n        />\n      )\n    })\n\n  if (zones?.length) {\n    const status = labels?.status ?? ((z: string, l: string) => `${l}: ${z.toLowerCase()}`)\n    return (\n      <div ref={ref} className={cn('@container w-full min-w-0', className)} {...rest}>\n        <div className=\"grid grid-cols-[96px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 [grid-template-areas:'fig_text'_'chips_chips'] @[22rem]:grid-cols-[132px_minmax(0,1fr)] @[22rem]:gap-x-[18px] @[22rem]:gap-y-2.5 @[22rem]:[grid-template-areas:'fig_text'_'fig_chips']\">\n          <div\n            {...role}\n            {...aria}\n            className={cn('relative self-center rounded-2xl [grid-area:fig]', canAdjust && 'cursor-pointer touch-none select-none')}\n          >\n            <svg viewBox=\"0 0 220 150\" aria-hidden className=\"block h-auto w-full overflow-visible\" {...pointer}>\n              {arc(segments, 66, 30, 13, (k) => {\n                const z = zoneOf(min + ((k + 0.5) / segments) * span)\n                return {\n                  fill: zones[z].color ?? seriesColor(z),\n                  opacity: drawn && z === zi ? 1 : 0.24,\n                  transition: reduced ? 'none' : `opacity 200ms ${EASE_CSS}`,\n                }\n              })}\n              <g style={turn(angle)}>\n                <line x1={CX} y1={CY} x2={CX} y2={54} stroke=\"var(--foreground)\" strokeWidth={7} strokeLinecap=\"round\" />\n                <circle cx={CX} cy={CY} r={11} fill=\"var(--foreground)\" />\n              </g>\n            </svg>\n          </div>\n          <div className=\"min-w-0 self-center [grid-area:text] @[22rem]:self-end\">\n            <p className=\"text-[15px] leading-[1.35] font-medium\">{zone ? status(zone.label, label) : label}</p>\n            <p className=\"mt-0.5 text-[13px] leading-normal text-muted-foreground\">\n              <span className=\"tabular-nums\">{formatValue(v, format)}</span>\n              {caption ? <> {caption}</> : null}\n            </p>\n          </div>\n          <div aria-hidden className=\"flex min-w-0 flex-wrap gap-1.5 self-start [grid-area:chips]\">\n            {zones.map((z, i) => (\n              <span\n                key={z.label}\n                className=\"relative isolate inline-flex h-[26px] items-center gap-1.5 rounded-full pr-2.5 pl-2 text-[12.5px] leading-none font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n              >\n                <span\n                  className=\"absolute inset-0 -z-10 rounded-full bg-foreground transition-opacity duration-200 ease-out-quint motion-reduce:transition-none\"\n                  style={{ opacity: i === zi ? 1 : 0 }}\n                />\n                <i className=\"size-2 rounded-full\" style={{ background: z.color ?? seriesColor(i) }} />\n                <span className={cn(i === zi && 'text-card')}>{z.label}</span>\n              </span>\n            ))}\n          </div>\n        </div>\n      </div>\n    )\n  }\n\n  const endLabels = ends === false ? null : (ends ?? [formatValue(min, format), formatValue(max, format)])\n  return (\n    <div ref={ref} className={cn('@container flex w-full min-w-0 flex-col items-center', className)} {...rest}>\n      <div\n        {...role}\n        {...aria}\n        className={cn(\n          'relative @container w-[min(100%,330px)] rounded-3xl',\n          'group-data-[density=compact]/tile:w-[min(100%,260px)]',\n          density === 'compact' && 'w-[min(100%,260px)]',\n          canAdjust && 'cursor-pointer touch-none select-none',\n        )}\n      >\n        <svg viewBox=\"0 0 220 186\" aria-hidden className=\"block h-auto w-full overflow-visible\" {...pointer}>\n          {arc(segments, 70, 30, 12, (k) => ({\n            fill: 'var(--foreground)',\n            opacity: k < lit ? 1 : 0.14,\n            transition: reduced ? 'none' : `opacity 200ms ${EASE_CSS} ${delayOf(k)}ms`,\n          }))}\n          <g style={turn(angle)}>\n            <path d=\"M110 2l7 -0.5 -7 9 -7 -9z\" fill=\"var(--primary)\" />\n          </g>\n        </svg>\n        <div aria-hidden className=\"pointer-events-none absolute inset-x-0 top-[44%] grid justify-items-center text-center\">\n          <span style={{ ['--dl-num' as string]: 'clamp(34px, 19.4cqi, 64px)' }}>\n            <BigNumber value={shown} format={format} size=\"xl\" roll={drawn} className=\"text-[length:var(--dl-num)]\" />\n          </span>\n          {caption && (\n            <span className=\"mt-1.5 max-w-[80%] text-[13px] leading-[1.3] font-medium text-balance text-muted-foreground\">\n              {caption}\n            </span>\n          )}\n        </div>\n        {endLabels && (\n          <div aria-hidden className=\"font-mono text-[11px] leading-none text-muted-foreground tabular-nums\">\n            <span className=\"absolute bottom-0 left-[15%] -translate-x-1/2\">{endLabels[0]}</span>\n            <span className=\"absolute bottom-0 left-[85%] -translate-x-1/2\">{endLabels[1]}</span>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport default Dial\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}