{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radial-gauge",
  "type": "registry:ui",
  "title": "Radial Gauge",
  "description": "A semi or three-quarter dial whose needle springs between values, with labelled threshold zones.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/radial-gauge.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useMotionValueEvent, useReducedMotion, useSpring } from \"motion/react\";\n\nexport interface GaugeThreshold {\n  /** Zone starts at this value and runs to the next threshold (or max). */\n  value: number;\n  color: string;\n  /** Status text shown while the value is in this zone, e.g. \"Normal\". */\n  label: string;\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface RadialGaugeProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  value: number;\n  /** Accessible name and caption, e.g. \"CPU load\". */\n  label: string;\n  min?: number;\n  max?: number;\n  /** Sweep of the dial. */\n  arc?: \"semi\" | \"three-quarter\";\n  /** Show a needle, a filled value arc, or both. */\n  indicator?: \"needle\" | \"arc\" | \"both\";\n  /** Coloured zones with status labels. */\n  thresholds?: GaugeThreshold[];\n  /** Number of major intervals (labelled ticks = ticks + 1). */\n  ticks?: number;\n  /** Print values next to the major ticks. */\n  tickLabels?: boolean;\n  /** Unit shown after the readout, e.g. \"%\" or \"°C\". */\n  unit?: string;\n  /** Formats the readout, tick labels and aria-valuetext. */\n  format?: (value: number) => string;\n  /** Spring that carries the needle from the previous value to the new one. */\n  spring?: Spring;\n  /** Needle colour, and the value arc colour when there are no thresholds. */\n  accent?: string;\n  /** Maximum width in px (the gauge scales down with its container). */\n  size?: number;\n}\n\n/* -------------------------------- geometry -------------------------------- */\n\nconst CX = 100;\nconst CY = 100;\nconst R = 76;\nconst TRACK = 10;\n\nconst ARCS = {\n  semi: { start: -90, end: 90, viewBox: \"0 6 200 140\", readoutY: 120, height: 140, top: 6 },\n  \"three-quarter\": { start: -135, end: 135, viewBox: \"0 6 200 172\", readoutY: 150, height: 172, top: 6 },\n} as const;\n\n/**\n * Point at `angle` degrees (0 = up, clockwise) on a circle of radius `r`. Rounded, because trig\n * results can differ in the last digit between the server and the browser (hydration mismatch).\n */\nfunction polar(r: number, angle: number) {\n  const a = (angle * Math.PI) / 180;\n  const round = (n: number) => Math.round(n * 100) / 100;\n  return [round(CX + r * Math.sin(a)), round(CY - r * Math.cos(a))] as const;\n}\n\nfunction arcPath(r: number, from: number, to: number) {\n  const [x0, y0] = polar(r, from);\n  const [x1, y1] = polar(r, to);\n  const large = to - from > 180 ? 1 : 0;\n  return `M${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)}`;\n}\n\nconst defaultFormat = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 0 });\n\n/**\n * A semi or three-quarter dial whose needle and value arc spring from the previous value to the\n * new one. Threshold zones carry a text status (never colour alone) and the root is a real\n * `role=\"meter\"` with value, range and value text.\n */\nexport function RadialGauge({\n  value,\n  label,\n  min = 0,\n  max = 100,\n  arc = \"three-quarter\",\n  indicator = \"both\",\n  thresholds = [],\n  ticks = 5,\n  tickLabels = true,\n  unit = \"\",\n  format,\n  spring = { stiffness: 120, damping: 16, mass: 1 },\n  accent = \"#FF7448\",\n  size = 280,\n  className = \"\",\n  style,\n  ...props\n}: RadialGaugeProps) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const needleRef = React.useRef<SVGGElement>(null);\n  const valueArcRef = React.useRef<SVGPathElement>(null);\n  const readoutRef = React.useRef<HTMLSpanElement>(null);\n  const captionId = React.useId();\n  // Attributes animated imperatively are rendered from the first value only, so React never resets them.\n  const [initialValue] = React.useState(value);\n\n  const geometry = ARCS[arc] ?? ARCS[\"three-quarter\"];\n  const span = Math.max(1e-9, max - min);\n  const clampValue = (v: number) => Math.min(max, Math.max(min, v));\n  const toAngle = (v: number) => geometry.start + ((clampValue(v) - min) / span) * (geometry.end - geometry.start);\n  const fmt = format ?? ((v: number) => defaultFormat.format(v));\n\n  const zones = [...thresholds].sort((a, b) => a.value - b.value);\n  const zoneAt = (v: number) => {\n    let zone: GaugeThreshold | undefined;\n    for (const t of zones) if (v >= t.value) zone = t;\n    return zone ?? zones[0];\n  };\n  const colourAt = (v: number) => zoneAt(v)?.color ?? accent;\n\n  const paint = (v: number) => {\n    const angle = toAngle(v);\n    needleRef.current?.setAttribute(\"transform\", `rotate(${angle.toFixed(3)} ${CX} ${CY})`);\n    const arcNode = valueArcRef.current;\n    if (arcNode) {\n      const p = (clampValue(v) - min) / span;\n      arcNode.setAttribute(\"stroke-dasharray\", `${p.toFixed(4)} 2`);\n      arcNode.setAttribute(\"stroke\", colourAt(v));\n    }\n    if (readoutRef.current) readoutRef.current.textContent = fmt(v);\n  };\n\n  const springValue = useSpring(value, spring);\n  useMotionValueEvent(springValue, \"change\", paint);\n\n  // Spring to every new value (jump with reduced motion), from wherever the needle currently is.\n  React.useEffect(() => {\n    if (reduceMotion) springValue.jump(value);\n    else springValue.set(value);\n  }, [value, reduceMotion, springValue]);\n\n  // Repaint at the current position when geometry or formatting changes.\n  React.useLayoutEffect(() => {\n    paint(springValue.get());\n  });\n\n  const zone = zoneAt(value);\n  const valueText = `${fmt(value)}${unit ? ` ${unit}` : \"\"}${zone ? `, ${zone.label}` : \"\"}`;\n\n  // Ticks: majors at every interval, minors between them.\n  const majors = Math.max(1, Math.round(ticks));\n  const minorPerMajor = majors <= 5 ? 4 : majors <= 8 ? 1 : 0;\n  const tickMarks: { angle: number; major: boolean; v: number }[] = [];\n  for (let i = 0; i <= majors; i++) {\n    const v = min + (span * i) / majors;\n    tickMarks.push({ angle: toAngle(v), major: true, v });\n    if (i < majors) {\n      for (let m = 1; m <= minorPerMajor; m++) {\n        const mv = v + (span / majors) * (m / (minorPerMajor + 1));\n        tickMarks.push({ angle: toAngle(mv), major: false, v: mv });\n      }\n    }\n  }\n\n  const showNeedle = indicator !== \"arc\";\n  const showArc = indicator !== \"needle\";\n  const initialAngle = toAngle(initialValue);\n  const initialP = (clampValue(initialValue) - min) / span;\n\n  return (\n    <div\n      role=\"meter\"\n      aria-valuenow={clampValue(value)}\n      aria-valuemin={min}\n      aria-valuemax={max}\n      aria-valuetext={valueText}\n      aria-labelledby={captionId}\n      className={`relative flex w-full flex-col items-center text-zinc-950 dark:text-zinc-50 ${className}`}\n      style={{ [\"--rg-accent\" as string]: accent, maxWidth: size, ...style }}\n      {...props}\n    >\n      <div className=\"@container relative w-full\">\n        <svg viewBox={geometry.viewBox} className=\"block w-full overflow-visible\" aria-hidden=\"true\">\n          {/* Track */}\n          <path\n            d={arcPath(R, geometry.start, geometry.end)}\n            fill=\"none\"\n            strokeWidth={TRACK}\n            className=\"stroke-zinc-900/[0.07] dark:stroke-white/[0.08]\"\n          />\n          {/* Threshold zones, as a thin band outside the track */}\n          {zones.map((t, i) => {\n            const from = toAngle(Math.max(min, t.value));\n            const to = toAngle(i < zones.length - 1 ? zones[i + 1].value : max);\n            if (to - from < 0.5) return null;\n            return (\n              <path\n                key={`${t.value}-${i}`}\n                d={arcPath(R + TRACK / 2 + 4, from + 0.6, to - 0.6)}\n                fill=\"none\"\n                stroke={t.color}\n                strokeWidth={2.5}\n              />\n            );\n          })}\n          {/* Value arc */}\n          {showArc ? (\n            <path\n              ref={valueArcRef}\n              d={arcPath(R, geometry.start, geometry.end)}\n              fill=\"none\"\n              pathLength={1}\n              strokeWidth={TRACK}\n              stroke={colourAt(initialValue)}\n              strokeDasharray={`${initialP.toFixed(4)} 2`}\n            />\n          ) : null}\n          {/* Ticks */}\n          {tickMarks.map((t, i) => {\n            const [x0, y0] = polar(R - TRACK / 2 - 3, t.angle);\n            const [x1, y1] = polar(R - TRACK / 2 - (t.major ? 9 : 5.5), t.angle);\n            return (\n              <line\n                key={i}\n                x1={x0}\n                y1={y0}\n                x2={x1}\n                y2={y1}\n                strokeWidth={t.major ? 1.2 : 0.8}\n                strokeLinecap=\"round\"\n                className={t.major ? \"stroke-zinc-900/45 dark:stroke-white/45\" : \"stroke-zinc-900/20 dark:stroke-white/20\"}\n              />\n            );\n          })}\n          {tickLabels\n            ? tickMarks\n                .filter((t) => t.major)\n                .map((t, i) => {\n                  const [x, y] = polar(R - TRACK / 2 - 17, t.angle);\n                  return (\n                    <text\n                      key={i}\n                      x={x}\n                      y={y}\n                      textAnchor=\"middle\"\n                      dominantBaseline=\"central\"\n                      className=\"fill-zinc-500 font-mono text-[7px] tabular-nums dark:fill-zinc-400\"\n                    >\n                      {fmt(t.v)}\n                    </text>\n                  );\n                })\n            : null}\n          {/* Needle */}\n          {showNeedle ? (\n            <g ref={needleRef} transform={`rotate(${initialAngle.toFixed(3)} ${CX} ${CY})`}>\n              <path\n                d={`M${CX - 1.6},${CY} L${CX},${CY - (R - TRACK / 2 - 12)} L${CX + 1.6},${CY} Z`}\n                fill=\"var(--rg-accent)\"\n              />\n            </g>\n          ) : null}\n          {showNeedle ? (\n            <>\n              <circle cx={CX} cy={CY} r={5.5} fill=\"var(--rg-accent)\" />\n              <circle cx={CX} cy={CY} r={2} className=\"fill-white dark:fill-zinc-950\" />\n            </>\n          ) : null}\n        </svg>\n\n        {/* Readout */}\n        <div\n          className=\"pointer-events-none absolute inset-x-0 flex -translate-y-1/2 flex-col items-center\"\n          style={{ top: `${((geometry.readoutY - geometry.top) / geometry.height) * 100}%` }}\n          aria-hidden=\"true\"\n        >\n          <span className=\"flex items-baseline gap-1\">\n            <span\n              ref={readoutRef}\n              className=\"text-[clamp(1.5rem,9cqi,2.5rem)] leading-none font-semibold tracking-[-0.04em] tabular-nums\"\n            >\n              {fmt(initialValue)}\n            </span>\n            {unit ? <span className=\"font-mono text-xs text-zinc-500 dark:text-zinc-400\">{unit}</span> : null}\n          </span>\n        </div>\n      </div>\n\n      <div className=\"mt-2 flex flex-col items-center gap-1.5\">\n        {zone ? (\n          <span className=\"inline-flex items-center gap-1.5 rounded-full border border-zinc-900/10 px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-700 dark:border-white/10 dark:text-zinc-300\">\n            <span className=\"size-1.5 rounded-full\" style={{ backgroundColor: zone.color }} aria-hidden=\"true\" />\n            {zone.label}\n          </span>\n        ) : null}\n        <span id={captionId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n          {label}\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/radial-gauge"
}
