{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gauge",
  "type": "registry:ui",
  "title": "Gauge",
  "description": "A radial sweep gauge whose arc springs to its value with a calibrated overshoot-and-settle while the center count ticks up in lockstep. Continuous, segmented-tick, and half-speedometer variants; threshold crossings shift the hue over 300ms instead of snapping.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/data/gauge/gauge.tsx",
      "type": "registry:ui",
      "target": "components/crucible/gauge.tsx",
      "content": "\"use client\";\n\nimport { motion, useInView, useMotionValueEvent, useSpring, useTransform, type MotionValue } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n/** A threshold stop: at or past `value`, the arc shifts to `color` (over ~300ms). */\nexport interface GaugeThreshold {\n  /** Value (in the same units as `value`/`max`) at which this band begins. */\n  value: number;\n  /** Arc color while the animated value sits in this band. */\n  color: string;\n}\n\nexport interface GaugeColors {\n  /** Unlit track / unlit segment color. @default \"#27272a\" */\n  track?: string;\n  /** Value arc color below the first threshold. @default \"#93c5fd\" */\n  value?: string;\n  /** Center count-up text color. @default \"#fafafa\" */\n  text?: string;\n}\n\nexport interface GaugeProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Current value. Clamped to [0, max] for the sweep and aria semantics. */\n  value: number;\n  /** Value at which the gauge reads full. @default 100 */\n  max?: number;\n  /** Arc style: continuous ring, discrete igniting ticks, or 180° speedometer. @default \"arc\" */\n  variant?: \"arc\" | \"segments\" | \"half\";\n  /** Outer diameter of the gauge in px (half variant is `size` wide, half as tall). @default 200 */\n  size?: number;\n  /** Stroke thickness of the arc in px. @default 10 */\n  thickness?: number;\n  /** Opening at the bottom of the ring in degrees (arc/segments variants; half is fixed at 180°). @default 80 */\n  gapAngle?: number;\n  /** Number of ticks in the segments variant. @default 24 */\n  segmentCount?: number;\n  /** Spring stiffness — higher sweeps faster. @default 80 */\n  stiffness?: number;\n  /** Spring damping — lower overshoots further past the mark before settling. @default 14 */\n  damping?: number;\n  /** Render the auto count-up value in the center (ignored when `children` is set). @default true */\n  showValue?: boolean;\n  /** Formats the center count-up (and aria-valuetext). @default v => String(Math.round(v)) */\n  formatValue?: (value: number) => string;\n  /** Custom center content — replaces the auto count-up entirely. */\n  children?: React.ReactNode;\n  /** Track / value-arc / center-text colors. */\n  colors?: GaugeColors;\n  /** Ascending color stops; the arc's hue shifts over ~300ms as the sweep crosses each. */\n  thresholds?: GaugeThreshold[];\n  /** Draw the hairline tick ring inside the arc (arc/half variants). @default true */\n  ticks?: boolean;\n  /** When the first sweep runs: on scroll into view, or immediately on mount. @default \"in-view\" */\n  trigger?: \"in-view\" | \"mount\";\n  /** Freezes the needle mid-flight; unpausing resumes toward the target. @default false */\n  paused?: boolean;\n  /** Accessible name for the meter. @default \"Gauge\" */\n  label?: string;\n}\n\nconst DEFAULT_TRACK = \"#27272a\";\nconst DEFAULT_VALUE = \"#93c5fd\";\nconst DEFAULT_TEXT = \"#fafafa\";\nconst TICK_COLOR = \"#52525b\";\nconst FLASH_COLOR = \"#f8fafc\";\n\n/** Point on a circle where 0° is 12 o'clock and angles grow clockwise. */\nfunction polarPoint(cx: number, cy: number, r: number, angle: number): [number, number] {\n  const rad = (angle * Math.PI) / 180;\n  return [round2(cx + r * Math.sin(rad)), round2(cy - r * Math.cos(rad))];\n}\n\nfunction round2(n: number): number {\n  return Math.round(n * 100) / 100;\n}\n\nfunction arcPath(cx: number, cy: number, r: number, startAngle: number, sweep: number): string {\n  const s = Math.min(Math.max(sweep, 0.01), 359.98);\n  const [x1, y1] = polarPoint(cx, cy, r, startAngle);\n  const [x2, y2] = polarPoint(cx, cy, r, startAngle + s);\n  const large = s > 180 ? 1 : 0;\n  return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`;\n}\n\nfunction clamp(n: number, min: number, max: number): number {\n  return Math.min(Math.max(n, min), max);\n}\n\n/** One tick of the segments variant. Ignites with a 1-frame bright flash that\n *  cools to the active band color; unlit ticks rest at the track color. Split\n *  out so each tick owns its lit state — the parent never re-renders per frame. */\nfunction GaugeSegment({\n  d,\n  thickness,\n  threshold,\n  progress,\n  litColor,\n  trackColor,\n  reducedMotion,\n}: {\n  d: string;\n  thickness: number;\n  threshold: number;\n  progress: MotionValue<number>;\n  litColor: string;\n  trackColor: string;\n  reducedMotion: boolean;\n}) {\n  const [lit, setLit] = React.useState(false);\n  useMotionValueEvent(progress, \"change\", (p) => setLit(p >= threshold));\n  // Sync on mount / threshold change (reduced-motion jump can precede subscription).\n  React.useEffect(() => {\n    setLit(progress.get() >= threshold);\n  }, [progress, threshold]);\n\n  return (\n    <g>\n      <motion.path\n        d={d}\n        fill=\"none\"\n        strokeWidth={thickness}\n        strokeLinecap=\"butt\"\n        initial={false}\n        animate={{ stroke: lit ? litColor : trackColor }}\n        transition={{ duration: reducedMotion ? 0 : 0.3, ease: \"easeOut\" }}\n      />\n      <SegmentFlash d={d} thickness={thickness} lit={lit} reducedMotion={reducedMotion} />\n    </g>\n  );\n}\n\n/** The ignition flash overlay, memoized so band-color re-renders never restart\n *  the flash keyframes — it only re-runs when `lit` actually flips. */\nconst SegmentFlash = React.memo(function SegmentFlash({\n  d,\n  thickness,\n  lit,\n  reducedMotion,\n}: {\n  d: string;\n  thickness: number;\n  lit: boolean;\n  reducedMotion: boolean;\n}) {\n  return (\n    <motion.path\n      d={d}\n      fill=\"none\"\n      strokeWidth={thickness}\n      strokeLinecap=\"butt\"\n      stroke={FLASH_COLOR}\n      initial={false}\n      animate={{ opacity: lit && !reducedMotion ? [0.95, 0] : 0 }}\n      transition={{ duration: 0.35, ease: \"easeOut\" }}\n    />\n  );\n});\n\n/**\n * Gauge — a radial sweep gauge off a machinist's bench: matte graphite track,\n * hairline tick ring, one cool accent arc. The arc springs to its value with a\n * calibrated overshoot-and-settle — mass and damping, not bounce — while the\n * center count ticks up in lockstep with the needle. Value changes re-sweep\n * from the current position, never from zero. Variants: continuous arc,\n * segmented ticks that ignite sequentially (each with a bright flash that\n * cools to steady state), and a 180° half-speedometer. Threshold crossings\n * shift the arc's hue over ~300ms rather than snapping. `role=\"meter\"` with\n * real aria value semantics; the center value is real text. Reduced motion\n * renders the arc at its final value with the count complete. One SVG arc via\n * stroke-dash + transforms — animates only on value change, zero idle work.\n */\nexport function Gauge({\n  value,\n  max = 100,\n  variant = \"arc\",\n  size = 200,\n  thickness = 10,\n  gapAngle = 80,\n  segmentCount = 24,\n  stiffness = 80,\n  damping = 14,\n  showValue = true,\n  formatValue,\n  children,\n  colors,\n  thresholds,\n  ticks = true,\n  trigger = \"in-view\",\n  paused = false,\n  label = \"Gauge\",\n  className,\n  style,\n  ...props\n}: GaugeProps) {\n  const reducedMotion = useReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const textRef = React.useRef<HTMLSpanElement>(null);\n\n  const trackColor = colors?.track ?? DEFAULT_TRACK;\n  const baseValueColor = colors?.value ?? DEFAULT_VALUE;\n  const textColor = colors?.text ?? DEFAULT_TEXT;\n\n  const fmt = React.useMemo(() => formatValue ?? ((v: number) => String(Math.round(v))), [formatValue]);\n\n  const safeMax = max > 0 ? max : 1;\n  const clampedValue = clamp(value, 0, safeMax);\n\n  // ---- Geometry (all deterministic, viewBox units = px) ----\n  const isHalf = variant === \"half\";\n  const startAngle = isHalf ? 270 : 180 + gapAngle / 2;\n  const sweep = isHalf ? 180 : clamp(360 - gapAngle, 10, 360);\n  const cx = size / 2;\n  const cy = size / 2;\n  const r = (size - thickness) / 2;\n  const svgHeight = isHalf ? size / 2 + thickness / 2 : size;\n  const trackPath = React.useMemo(() => arcPath(cx, cy, r, startAngle, sweep), [cx, cy, r, startAngle, sweep]);\n\n  // ---- Sweep spring: value changes re-target it, so every re-sweep starts\n  //      from the current needle position — never from zero. ----\n  const inViewArmed = useInView(rootRef, { once: true, amount: 0.4 });\n  const armed = trigger === \"mount\" || reducedMotion || inViewArmed;\n  const targetProgress = armed ? clampedValue / safeMax : 0;\n  const progress = useSpring(0, { stiffness, damping });\n\n  React.useEffect(() => {\n    if (reducedMotion) {\n      progress.jump(targetProgress);\n      return;\n    }\n    if (paused) {\n      progress.stop();\n      return;\n    }\n    progress.set(targetProgress);\n  }, [progress, targetProgress, paused, reducedMotion]);\n\n  // Count-up in lockstep with the sweep — written straight to the DOM so the\n  // component never re-renders per frame. Overshoot reads on the arc; the\n  // text clamps to [0, max] so it never announces an impossible number.\n  useMotionValueEvent(progress, \"change\", (p) => {\n    if (textRef.current) textRef.current.textContent = fmt(clamp(p, 0, 1) * safeMax);\n  });\n\n  // ---- Threshold bands: hue follows the ANIMATED value, so the shift lands\n  //      as the needle crosses the stop (and eases over ~300ms, never snaps). ----\n  const sortedStops = React.useMemo(\n    () => (thresholds ? [...thresholds].sort((a, b) => a.value - b.value) : []),\n    [thresholds]\n  );\n  const colorAt = React.useCallback(\n    (v: number) => {\n      let c = baseValueColor;\n      for (const stop of sortedStops) if (v >= stop.value) c = stop.color;\n      return c;\n    },\n    [baseValueColor, sortedStops]\n  );\n  const [activeColor, setActiveColor] = React.useState(baseValueColor);\n  useMotionValueEvent(progress, \"change\", (p) => {\n    setActiveColor(colorAt(clamp(p, 0, 1) * safeMax));\n  });\n  React.useEffect(() => {\n    setActiveColor(colorAt(clamp(progress.get(), 0, 1) * safeMax));\n  }, [colorAt, safeMax, progress]);\n\n  // Hide the round start-cap dot while the arc is empty.\n  const arcOpacity = useTransform(progress, (p) => (p > 0.003 ? 1 : 0));\n\n  // ---- Hairline tick ring (arc/half variants) ----\n  const tickLines = React.useMemo(() => {\n    if (!ticks || variant === \"segments\") return [];\n    const count = Math.max(2, Math.round(sweep / 7.5));\n    const outer = r - thickness / 2 - 3;\n    const lines: { x1: number; y1: number; x2: number; y2: number; major: boolean }[] = [];\n    for (let i = 0; i <= count; i++) {\n      const angle = startAngle + (i / count) * sweep;\n      const major = i % 4 === 0;\n      const inner = outer - (major ? 5 : 3);\n      const [x1, y1] = polarPoint(cx, cy, outer, angle);\n      const [x2, y2] = polarPoint(cx, cy, inner, angle);\n      lines.push({ x1, y1, x2, y2, major });\n    }\n    return lines;\n  }, [ticks, variant, sweep, r, thickness, startAngle, cx, cy]);\n\n  // ---- Segment tick paths ----\n  const segments = React.useMemo(() => {\n    if (variant !== \"segments\") return [];\n    const count = Math.max(2, Math.floor(segmentCount));\n    const segAngle = sweep / count;\n    const pad = Math.min(segAngle * 0.28, 6);\n    return Array.from({ length: count }, (_, i) => ({\n      d: arcPath(cx, cy, r, startAngle + i * segAngle + pad / 2, segAngle - pad),\n      threshold: (i + 0.5) / count,\n    }));\n  }, [variant, segmentCount, sweep, cx, cy, r, startAngle]);\n\n  const center =\n    children !== undefined ? (\n      children\n    ) : showValue ? (\n      <span\n        ref={textRef}\n        className=\"font-semibold tabular-nums leading-none tracking-tight\"\n        style={{ color: textColor, fontSize: Math.round(size * 0.18) }}\n      >\n        {fmt(clamp(progress.get(), 0, 1) * safeMax)}\n      </span>\n    ) : null;\n\n  return (\n    <div\n      ref={rootRef}\n      data-crucible=\"gauge\"\n      role=\"meter\"\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={safeMax}\n      aria-valuenow={clampedValue}\n      aria-valuetext={fmt(clampedValue)}\n      className={cn(\"relative inline-flex\", className)}\n      style={{ width: size, height: svgHeight, ...style }}\n      {...props}\n    >\n      <svg\n        aria-hidden\n        width={size}\n        height={svgHeight}\n        viewBox={`0 0 ${size} ${svgHeight}`}\n        className=\"block overflow-visible\"\n      >\n        {tickLines.map((t, i) => (\n          <line\n            key={i}\n            x1={t.x1}\n            y1={t.y1}\n            x2={t.x2}\n            y2={t.y2}\n            stroke={TICK_COLOR}\n            strokeWidth={1}\n            opacity={t.major ? 0.55 : 0.3}\n          />\n        ))}\n\n        {variant === \"segments\" ? (\n          segments.map((seg, i) => (\n            <GaugeSegment\n              key={i}\n              d={seg.d}\n              thickness={thickness}\n              threshold={seg.threshold}\n              progress={progress}\n              litColor={activeColor}\n              trackColor={trackColor}\n              reducedMotion={reducedMotion}\n            />\n          ))\n        ) : (\n          <>\n            <path\n              d={trackPath}\n              fill=\"none\"\n              stroke={trackColor}\n              strokeWidth={thickness}\n              strokeLinecap=\"round\"\n            />\n            <motion.path\n              d={trackPath}\n              fill=\"none\"\n              strokeWidth={thickness}\n              strokeLinecap=\"round\"\n              style={{ pathLength: progress, opacity: arcOpacity }}\n              initial={false}\n              animate={{ stroke: activeColor }}\n              transition={{ duration: reducedMotion ? 0 : 0.3, ease: \"easeOut\" }}\n            />\n          </>\n        )}\n      </svg>\n\n      {center !== null && (\n        <div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-0 flex justify-center\",\n            isHalf ? \"items-end\" : \"items-center\"\n          )}\n        >\n          {center}\n        </div>\n      )}\n    </div>\n  );\n}\n"
    }
  ]
}