{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "breakdown-dial",
  "title": "Breakdown dial",
  "description": "A card with a 270° dial of 42 ticks split between a few groups, the total in its centre, and a row per group with its count and share; hovering a group isolates its ticks.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/breakdown-dial/breakdown-dial.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport { Target, type LucideIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotionConfig } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type QueueSegment = {\n  id: string;\n  label: string;\n  value: number;\n  /** Any CSS colour. Defaults run ink → dim → faint → rule, darkest first. */\n  color?: string;\n};\n\nexport interface BreakdownDialProps {\n  /** The groups, best first. Every one keeps a row; only those above zero get ticks. */\n  segments: QueueSegment[];\n  /** The figure in the dial's centre and the base for each row's share. Defaults to the segments' sum. */\n  total?: number;\n  title?: ReactNode;\n  /** Header and centre icon. Default a target. */\n  icon?: LucideIcon;\n  /** Word above the centre figure. Default \"Total\". */\n  totalLabel?: string;\n  /** Formats counts in the rows, centre and tooltip. Default toLocaleString. */\n  valueFormatter?: (value: number) => string;\n  emptyTitle?: string;\n  emptyDescription?: string;\n  className?: string;\n}\n\n/** Darkest first, so the best group reads strongest and the remainder recedes. */\nconst DEFAULT_COLORS = [\"var(--ink)\", \"var(--dim)\", \"var(--faint)\", \"var(--rule)\"];\n\nconst TOTAL_TICKS = 42;\nconst START_ANGLE_DEG = 135; // bottom-left\nconst SWEEP_ANGLE_DEG = 270; // leaves the bottom open\nconst CX = 100;\nconst CY = 100;\nconst R_INNER = 68;\nconst R_OUTER = 84;\n\ntype Resolved = QueueSegment & { color: string; share: string };\n\nconst round3 = (n: number) => Math.round(n * 1000) / 1000;\n\nexport function BreakdownDial({\n  segments: segmentsProp,\n  total: totalProp,\n  title = \"Queue quality\",\n  icon: Icon = Target,\n  totalLabel = \"Total\",\n  valueFormatter = (value) => value.toLocaleString(),\n  emptyTitle = \"Nothing in the queue yet\",\n  emptyDescription = \"Groups will appear here once items are scored.\",\n  className,\n}: BreakdownDialProps) {\n  const cardId = useId().replace(/:/g, \"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const reduceMotion = useReducedMotionConfig() ?? false;\n  const [hoveredSegmentId, setHoveredSegmentId] = useState<string | null>(null);\n  const [hasEnteredView, setHasEnteredView] = useState(false);\n\n  useEffect(() => {\n    const node = rootRef.current;\n    if (!node) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setHasEnteredView(true);\n          observer.disconnect();\n        }\n      },\n      { threshold: 0.2 },\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const total = totalProp ?? segmentsProp.reduce((sum, s) => sum + Math.max(0, s.value), 0);\n\n  const segments = useMemo<Resolved[]>(\n    () =>\n      segmentsProp.map((s, i) => ({\n        ...s,\n        color: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length],\n        share: total > 0 ? `${Math.round((Math.max(0, s.value) / total) * 100)}%` : \"—\",\n      })),\n    [segmentsProp, total],\n  );\n\n  // Only groups with a value get ticks, so a single 100% group fills the dial.\n  const visibleSegments = useMemo(() => {\n    const filtered = segments.filter((s) => s.value > 0);\n    return filtered.length ? filtered : segments;\n  }, [segments]);\n\n  const ticks = useMemo(() => {\n    if (visibleSegments.length === 0) return [];\n    const totalVal = visibleSegments.reduce((sum, s) => sum + s.value, 0) || 1;\n    const ranges: { segment: Resolved; startTick: number; endTick: number }[] = [];\n    let acc = 0;\n    for (const seg of visibleSegments) {\n      const startTick = Math.round((acc / totalVal) * TOTAL_TICKS);\n      acc += seg.value;\n      ranges.push({ segment: seg, startTick, endTick: Math.round((acc / totalVal) * TOTAL_TICKS) });\n    }\n\n    return Array.from({ length: TOTAL_TICKS }, (_, i) => {\n      const angleRad = ((START_ANGLE_DEG + (i / (TOTAL_TICKS - 1)) * SWEEP_ANGLE_DEG) * Math.PI) / 180;\n      const cos = Math.cos(angleRad);\n      const sin = Math.sin(angleRad);\n      const segment =\n        ranges.find((range) => i >= range.startTick && i <= range.endTick)?.segment ?? ranges[0].segment;\n      return {\n        index: i,\n        x1: round3(CX + R_INNER * cos),\n        y1: round3(CY + R_INNER * sin),\n        x2: round3(CX + R_OUTER * cos),\n        y2: round3(CY + R_OUTER * sin),\n        segment,\n      };\n    });\n  }, [visibleSegments]);\n\n  const activeSegment = segments.find((s) => s.id === hoveredSegmentId) ?? visibleSegments[0];\n\n  // Only hide other ticks when the hovered group actually has ticks.\n  const isTicksHovered =\n    hoveredSegmentId !== null && visibleSegments.some((s) => s.id === hoveredSegmentId);\n\n  const revealed = hasEnteredView || reduceMotion;\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"relative flex w-full flex-col justify-between overflow-hidden rounded-panel border border-rule bg-card p-6 font-body shadow-sm transition-all\",\n        className,\n      )}\n    >\n      <div>\n        <div className=\"flex items-center gap-2.5 pb-4\">\n          <div className=\"flex h-7 w-7 items-center justify-center rounded-lg text-foreground\">\n            <Icon size={18} strokeWidth={2.2} className=\"opacity-80\" />\n          </div>\n          <h3 className=\"text-[20px] font-medium tracking-tighter text-foreground\">{title}</h3>\n        </div>\n\n        {total === 0 || segments.length === 0 ? (\n          <div className=\"flex flex-1 flex-col items-center justify-center gap-1 py-12 text-center\">\n            <p className=\"text-sm font-medium text-foreground\">{emptyTitle}</p>\n            <p className=\"text-xs text-muted-foreground\">{emptyDescription}</p>\n          </div>\n        ) : (\n          <>\n            <div className=\"relative mx-auto mt-4 flex aspect-square w-full max-w-[260px] items-center justify-center\">\n              <svg viewBox=\"0 0 200 200\" className=\"h-full w-full select-none overflow-visible\" aria-hidden=\"true\">\n                {ticks.map((tick) => {\n                  const isSegmentActive = activeSegment?.id === tick.segment.id;\n                  const opacity = !isTicksHovered ? 1 : isSegmentActive ? 1 : 0;\n                  return (\n                    <motion.line\n                      key={`tick-${cardId}-${tick.index}`}\n                      x1={tick.x1}\n                      y1={tick.y1}\n                      x2={tick.x2}\n                      y2={tick.y2}\n                      stroke={tick.segment.color}\n                      strokeWidth={3}\n                      strokeLinecap=\"round\"\n                      initial={reduceMotion ? false : { opacity: 0, pathLength: 0 }}\n                      animate={{\n                        opacity: revealed ? opacity : 0,\n                        pathLength: revealed ? 1 : 0,\n                        strokeWidth: isSegmentActive && isTicksHovered ? 3.5 : 3,\n                      }}\n                      transition={{\n                        opacity: { duration: 0.25 },\n                        pathLength: { duration: reduceMotion ? 0 : 0.55, ease: \"easeOut\" },\n                        strokeWidth: { duration: 0.2 },\n                      }}\n                      onPointerEnter={() => setHoveredSegmentId(tick.segment.id)}\n                      onPointerLeave={() => setHoveredSegmentId(null)}\n                    />\n                  );\n                })}\n              </svg>\n\n              <div className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center\">\n                <div className=\"flex h-9 w-9 items-center justify-center rounded-full bg-paper text-ink\">\n                  <Icon size={16} strokeWidth={2.2} />\n                </div>\n                <span className=\"mt-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground\">\n                  {totalLabel}\n                </span>\n                <span className=\"text-[26px] font-bold leading-none tracking-tight text-foreground tabular-nums\">\n                  {valueFormatter(total)}\n                </span>\n              </div>\n\n              <AnimatePresence>\n                {hoveredSegmentId && activeSegment && (\n                  <motion.div\n                    initial={{ opacity: 0, scale: 0.9, y: 4 }}\n                    animate={{ opacity: 1, scale: 1, y: 0 }}\n                    exit={{ opacity: 0, scale: 0.9, y: 4 }}\n                    transition={{ duration: 0.18, ease: \"easeOut\" }}\n                    className=\"pointer-events-none absolute bottom-8 right-0 z-30 min-w-[130px] rounded-xl border border-rule bg-popover/95 px-3 py-2 shadow-lg backdrop-blur-md\"\n                  >\n                    <div className=\"flex items-center gap-1.5\">\n                      <span className=\"h-2 w-2 rounded-full\" style={{ background: activeSegment.color }} />\n                      <span className=\"text-[12px] font-bold text-foreground\">{activeSegment.label}</span>\n                    </div>\n                    <div className=\"mt-1 text-[13px] font-semibold tabular-nums text-foreground\">\n                      {valueFormatter(activeSegment.value)}\n                    </div>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n\n            {/* Every group keeps its row, at 0% if empty, so the reader sees all the options. */}\n            <div className=\"mt-4 space-y-2 border-t border-rule-soft pt-4\">\n              {segments.map((seg) => (\n                <div\n                  key={seg.id}\n                  className={cn(\n                    \"flex cursor-pointer items-center justify-between rounded-xl px-2.5 py-1.5 transition-colors\",\n                    hoveredSegmentId === seg.id ? \"bg-paper\" : \"hover:bg-paper\",\n                  )}\n                  onPointerEnter={() => setHoveredSegmentId(seg.id)}\n                  onPointerLeave={() => setHoveredSegmentId(null)}\n                >\n                  <div className=\"flex items-center gap-2.5\">\n                    <span className=\"h-4 w-1.5 rounded-full\" style={{ background: seg.color }} />\n                    <span className=\"text-[13px] font-medium text-foreground\">{seg.label}</span>\n                  </div>\n                  <div className=\"flex items-center gap-3 text-[13px] tabular-nums\">\n                    <span className=\"font-bold text-foreground\">{valueFormatter(seg.value)}</span>\n                    <span className=\"text-[12px] font-medium text-muted-foreground\">{seg.share}</span>\n                  </div>\n                </div>\n              ))}\n            </div>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default BreakdownDial;\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use to show how one whole divides into three to five ranked groups (priority bands, statuses) at a glance, with the exact counts beside it. Not for values over time or for more than about five groups; use a chart or a list for those.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/dashboard/queue-quality-card.tsx"
    },
    "credits": []
  },
  "type": "registry:component"
}
