{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stacked-explorer",
  "type": "registry:component",
  "title": "Stacked Explorer",
  "description": "Twelve months of stacked bars you can re-cut by measure and by slice: segments settle at their new heights, hovering a month breaks it down, and the table below lights up the series under your pointer.",
  "categories": [
    "component",
    "Numbers"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/select.json"
  ],
  "files": [
    {
      "path": "registry/devl/stacked-explorer.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ExplorerMeasure {\n  id: string;\n  label: string;\n  format?: Intl.NumberFormatOptions;\n}\n\nexport interface ExplorerSlice {\n  id: string;\n  label: string;\n  series: string[];\n}\n\nexport interface ExplorerData {\n  periods: string[];\n  measures: ExplorerMeasure[];\n  slices: ExplorerSlice[];\n  values: Record<string, Record<string, number[][]>>;\n}\n\nexport interface StackedExplorerProps {\n  title?: string;\n  data?: ExplorerData;\n  defaultMeasure?: string;\n  defaultSlice?: string;\n  locale?: string;\n  className?: string;\n}\n\nconst COLORS = [\n  \"var(--color-chart-1)\",\n  \"var(--color-chart-2)\",\n  \"var(--color-chart-3)\",\n  \"var(--color-chart-4)\",\n  \"var(--color-chart-5)\",\n];\nconst HEIGHT = 176;\nconst AXIS = 36;\nconst PAD_TOP = 8;\nconst PAD_BOTTOM = 20;\nconst GAP = 2;\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\nfunction Face({ n, turn }: { n: number; turn: MotionValue<number> }) {\n  const y = useTransform(turn, (t) => {\n    let offset = (((n - t) % 10) + 10) % 10;\n    if (offset > 5) offset -= 10;\n    return `${offset * 100}%`;\n  });\n  return (\n    <motion.span className=\"absolute inset-x-0 top-0 block h-[1.15em] text-center leading-[1.15em]\" style={{ y }}>\n      {n}\n    </motion.span>\n  );\n}\n\nfunction Wheel({ digit, instant, delay }: { digit: number; instant: boolean; delay: number }) {\n  const reduce = useReducedMotion();\n  const turn = useMotionValue(digit);\n  const target = useRef(digit);\n  useEffect(() => {\n    let step = digit - (((target.current % 10) + 10) % 10);\n    if (step > 5) step -= 10;\n    else if (step < -5) step += 10;\n    target.current += step;\n    if (reduce || instant) turn.jump(target.current);\n    else if (step !== 0 || (!turn.isAnimating() && turn.get() !== target.current))\n      animate(turn, target.current, { type: \"spring\", stiffness: 280, damping: 34, delay });\n  }, [digit, instant, delay, reduce, turn]);\n  useEffect(() => () => turn.stop(), [turn]);\n  return (\n    <span className=\"relative inline-block h-[1.15em] overflow-hidden [mask-image:linear-gradient(transparent,black_20%,black_80%,transparent)]\">\n      <span className=\"invisible block leading-[1.15em]\">0</span>\n      {DIGITS.map((n) => (\n        <Face key={n} n={n} turn={turn} />\n      ))}\n    </span>\n  );\n}\n\nfunction Roll({\n  text,\n  className,\n  instant = false,\n  delay = 0,\n}: {\n  text: string;\n  className?: string;\n  instant?: boolean;\n  delay?: number;\n}) {\n  let placeFromRight = 0;\n  let other = 0;\n  const glyphs = [...text]\n    .reverse()\n    .map((char) => {\n      const isDigit = char >= \"0\" && char <= \"9\";\n      return { char, isDigit, key: isDigit ? `d${placeFromRight++}` : `s${other++}${char}` };\n    })\n    .reverse();\n  return (\n    <span className={cn(\"inline-flex whitespace-nowrap tabular-nums leading-none\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex\">\n        {glyphs.map((g) =>\n          g.isDigit ? (\n            <Wheel delay={delay} digit={Number(g.char)} instant={instant} key={g.key} />\n          ) : (\n            <span className=\"inline-block h-[1.15em] leading-[1.15em]\" key={g.key}>\n              {g.char === \" \" ? \" \" : g.char}\n            </span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n\nfunction mulberry(seed: number) {\n  return () => {\n    seed |= 0;\n    seed = (seed + 0x6d2b79f5) | 0;\n    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction buildDefaultData(): ExplorerData {\n  const rand = mulberry(7);\n  const periods = [\"Nov\", \"Dec\", \"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\"];\n  const measures: ExplorerMeasure[] = [\n    { id: \"revenue\", label: \"Revenue\", format: { style: \"currency\", currency: \"USD\", notation: \"compact\", minimumFractionDigits: 0, maximumFractionDigits: 1 } },\n    { id: \"orders\", label: \"Orders\", format: { maximumFractionDigits: 0 } },\n    { id: \"customers\", label: \"Customers\", format: { maximumFractionDigits: 0 } },\n  ];\n  const slices: ExplorerSlice[] = [\n    { id: \"region\", label: \"Region\", series: [\"Americas\", \"Europe\", \"Asia Pacific\", \"Africa\"] },\n    { id: \"plan\", label: \"Plan\", series: [\"Starter\", \"Pro\", \"Team\", \"Enterprise\"] },\n    { id: \"channel\", label: \"Channel\", series: [\"Direct\", \"Partners\", \"Marketplace\"] },\n  ];\n  const bases: Record<string, number> = { revenue: 182000, orders: 2400, customers: 860 };\n  const values: ExplorerData[\"values\"] = {};\n  for (const m of measures) {\n    const totals = periods.map((_, i) => bases[m.id] * (1 + i * 0.045) * (0.88 + rand() * 0.24));\n    values[m.id] = {};\n    for (const s of slices) {\n      const weights = s.series.map((_, k) => (s.series.length - k) * (0.7 + rand() * 0.6));\n      values[m.id][s.id] = s.series.map((_, k) =>\n        periods.map((_, i) => {\n          const drift = weights.map((w, j) => w * (1 + Math.sin(i / 2.4 + j * 1.7) * 0.22));\n          const sum = drift.reduce((a, b) => a + b, 0);\n          return Math.round((totals[i] * drift[k]) / sum);\n        }),\n      );\n    }\n  }\n  return { periods, measures, slices, values };\n}\n\nconst defaultData = buildDefaultData();\n\n// Formatted by hand so server and browser ICU data always agree.\nfunction compact(v: number) {\n  const units: [number, string][] = [\n    [1e9, \"B\"],\n    [1e6, \"M\"],\n    [1e3, \"K\"],\n  ];\n  for (const [size, suffix] of units) {\n    if (Math.abs(v) >= size) return `${Number((v / size).toFixed(1))}${suffix}`;\n  }\n  return String(Math.round(v));\n}\n\nfunction niceStep(raw: number) {\n  const pow = 10 ** Math.floor(Math.log10(raw));\n  const unit = raw / pow;\n  return (unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 2.5 ? 2.5 : unit <= 5 ? 5 : 10) * pow;\n}\n\nexport function StackedExplorer({\n  title = \"Breakdown\",\n  data = defaultData,\n  defaultMeasure,\n  defaultSlice,\n  locale = \"en-US\",\n  className,\n}: StackedExplorerProps) {\n  const reduce = useReducedMotion();\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(460);\n  const [measureId, setMeasureId] = useState(defaultMeasure ?? data.measures[0].id);\n  const [sliceId, setSliceId] = useState(defaultSlice ?? data.slices[0].id);\n  const [column, setColumn] = useState<number | null>(null);\n  const [seriesHover, setSeriesHover] = useState<number | null>(null);\n  const [scrubbing, setScrubbing] = useState(false);\n\n  useEffect(() => {\n    const el = wrapRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setWidth(Math.max(240, entry.contentRect.width)));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const measure = data.measures.find((m) => m.id === measureId) ?? data.measures[0];\n  const slice = data.slices.find((s) => s.id === sliceId) ?? data.slices[0];\n  const rows = data.values[measure.id]?.[slice.id] ?? [];\n  const periods = data.periods;\n  const fmt = useMemo(() => new Intl.NumberFormat(locale, measure.format), [locale, measure.format]);\n  const axisPrefix = measure.format?.style === \"currency\" ? (fmt.formatToParts(0).find((p) => p.type === \"currency\")?.value ?? \"\") : \"\";\n  const axisLabel = (v: number) => `${axisPrefix}${compact(v)}`;\n\n  const stackTotals = periods.map((_, p) => rows.reduce((sum, r) => sum + (r[p] ?? 0), 0));\n  const rawMax = Math.max(1, ...stackTotals);\n  const step = niceStep(rawMax / 3);\n  const max = Math.ceil(rawMax / step) * step;\n  const plotW = width - AXIS;\n  const plotH = HEIGHT - PAD_TOP - PAD_BOTTOM;\n  const slot = plotW / periods.length;\n  const barW = Math.min(28, slot * 0.62);\n  const labelEvery = slot < 34 ? 2 : 1;\n  const scale = (v: number) => (v / max) * plotH;\n  const baseY = PAD_TOP + plotH;\n  const grid = Array.from({ length: Math.round(max / step) + 1 }, (_, i) => i * step);\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 220, damping: 30 };\n\n  const seriesTotals = rows.map((r) => r.reduce((a, b) => a + b, 0));\n  const grand = seriesTotals.reduce((a, b) => a + b, 0);\n  const shown = column === null ? seriesTotals : rows.map((r) => r[column] ?? 0);\n  const shownTotal = shown.reduce((a, b) => a + b, 0);\n\n  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {\n    const n = periods.length;\n    setScrubbing(true);\n    if (e.key === \"ArrowRight\") setColumn((c) => Math.min(n - 1, (c ?? -1) + 1));\n    else if (e.key === \"ArrowLeft\") setColumn((c) => Math.max(0, (c ?? n) - 1));\n    else if (e.key === \"Escape\") setColumn(null);\n    else return;\n    e.preventDefault();\n  }\n\n  const tipX = column === null ? 0 : AXIS + slot * column + slot / 2;\n  const flip = tipX > width * 0.55;\n\n  return (\n    <div className={cn(\"w-full max-w-[540px] rounded-2xl border bg-card p-5 text-card-foreground shadow-xs/5\", className)}>\n      <div className=\"flex flex-wrap items-start justify-between gap-3\">\n        <div className=\"flex flex-col gap-1.5\">\n          <span className=\"text-muted-foreground text-sm\">\n            {title} · {measure.label} by {slice.label.toLowerCase()}\n          </span>\n          <Roll className=\"font-semibold text-3xl tracking-tight\" instant={scrubbing} text={fmt.format(column === null ? grand : shownTotal)} />\n          <span className=\"text-muted-foreground text-xs\">\n            {column === null ? `${periods[0]} – ${periods[periods.length - 1]}` : periods[column]}\n          </span>\n        </div>\n        <div className=\"flex gap-2\">\n          <Select\n            items={data.measures.map((m) => ({ value: m.id, label: m.label }))}\n            onValueChange={(v) => {\n              if (!v) return;\n              setScrubbing(false);\n              setMeasureId(String(v));\n            }}\n            value={measure.id}\n          >\n            <SelectTrigger aria-label=\"Measure\" className=\"w-auto min-w-28\" size=\"sm\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectPopup>\n              {data.measures.map((m) => (\n                <SelectItem key={m.id} value={m.id}>\n                  {m.label}\n                </SelectItem>\n              ))}\n            </SelectPopup>\n          </Select>\n          <Select\n            items={data.slices.map((s) => ({ value: s.id, label: `By ${s.label.toLowerCase()}` }))}\n            onValueChange={(v) => {\n              if (!v) return;\n              setScrubbing(false);\n              setSliceId(String(v));\n              setSeriesHover(null);\n            }}\n            value={slice.id}\n          >\n            <SelectTrigger aria-label=\"Slice\" className=\"w-auto min-w-32\" size=\"sm\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectPopup>\n              {data.slices.map((s) => (\n                <SelectItem key={s.id} value={s.id}>\n                  By {s.label.toLowerCase()}\n                </SelectItem>\n              ))}\n            </SelectPopup>\n          </Select>\n        </div>\n      </div>\n\n      <div\n        aria-label={`${measure.label} by ${slice.label}, stacked by month. Use the arrow keys to inspect a month.`}\n        className=\"relative mt-4 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        onBlur={() => setColumn(null)}\n        onKeyDown={onKeyDown}\n        onPointerLeave={() => {\n          setColumn(null);\n          setSeriesHover(null);\n        }}\n        ref={wrapRef}\n        role=\"application\"\n        tabIndex={0}\n      >\n        <svg aria-hidden className=\"block overflow-visible\" height={HEIGHT} width={width}>\n          {grid.map((v) => (\n            <motion.g animate={{ y: baseY - scale(v), opacity: 1 }} initial={{ y: baseY - scale(v), opacity: 0 }} key={v} transition={spring}>\n              <line className=\"stroke-border\" strokeDasharray={v === 0 ? undefined : \"2 4\"} x1={AXIS} x2={width} y1={0} y2={0} />\n              <text className=\"fill-muted-foreground text-[10px] tabular-nums\" dominantBaseline=\"middle\" x={0} y={0}>\n                {axisLabel(v)}\n              </text>\n            </motion.g>\n          ))}\n\n          {periods.map((label, p) => {\n            const x = AXIS + slot * p + (slot - barW) / 2;\n            let acc = 0;\n            const dim = column !== null && column !== p;\n            return (\n              <g key={label}>\n                <motion.g animate={{ opacity: dim ? 0.35 : 1 }} transition={{ duration: reduce ? 0 : 0.18 }}>\n                  <AnimatePresence initial={false}>\n                    {rows.map((r, s) => {\n                      const h = scale(r[p] ?? 0);\n                      const y = baseY - acc - h;\n                      acc += h;\n                      const segH = Math.max(0, h - GAP);\n                      const faded = seriesHover !== null && seriesHover !== s;\n                      return (\n                        <motion.rect\n                          animate={{ y, height: segH, opacity: faded ? 0.22 : 1 }}\n                          exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0 : 0.18, ease: \"easeOut\" } }}\n                          fill={COLORS[s % COLORS.length]}\n                          height={segH}\n                          initial={{ y: baseY, height: 0 }}\n                          key={s}\n                          rx={3}\n                          transition={{ ...spring, delay: reduce ? 0 : p * 0.012 + s * 0.02 }}\n                          width={barW}\n                          x={x}\n                          y={0}\n                        />\n                      );\n                    })}\n                  </AnimatePresence>\n                </motion.g>\n                <text\n                  className={cn(\n                    \"fill-muted-foreground text-[10px] transition-colors\",\n                    column === p && \"fill-foreground\",\n                    p % labelEvery !== 0 && column !== p && \"opacity-0\",\n                  )}\n                  textAnchor=\"middle\"\n                  x={AXIS + slot * p + slot / 2}\n                  y={HEIGHT - 4}\n                >\n                  {label}\n                </text>\n                <rect\n                  fill=\"transparent\"\n                  height={plotH + PAD_TOP}\n                  onPointerEnter={() => {\n                    setScrubbing(true);\n                    setColumn(p);\n                  }}\n                  onPointerLeave={() => setSeriesHover(null)}\n                  onPointerMove={(e) => {\n                    const rect = e.currentTarget.getBoundingClientRect();\n                    const fromBottom = rect.bottom - e.clientY;\n                    let sum = 0;\n                    let hit: number | null = null;\n                    rows.forEach((r, s) => {\n                      const h = scale(r[p] ?? 0);\n                      if (hit === null && fromBottom >= sum && fromBottom < sum + h) hit = s;\n                      sum += h;\n                    });\n                    setSeriesHover(hit);\n                  }}\n                  width={slot}\n                  x={AXIS + slot * p}\n                  y={0}\n                />\n              </g>\n            );\n          })}\n        </svg>\n\n        <AnimatePresence>\n          {column !== null && (\n            <motion.div\n              animate={{ opacity: 1, scale: 1, x: flip ? tipX - barW / 2 - 10 : tipX + barW / 2 + 10 }}\n              className={cn(\n                \"pointer-events-none absolute top-0 left-0 z-10 min-w-40 rounded-xl border bg-popover p-2.5 text-popover-foreground text-xs shadow-md/5 transition-[translate] duration-200 ease-out\",\n                flip && \"-translate-x-full\",\n              )}\n              exit={{ opacity: 0, scale: 0.96, transition: { duration: reduce ? 0 : 0.1 } }}\n              initial={{ opacity: 0, scale: 0.96, x: flip ? tipX - barW / 2 - 10 : tipX + barW / 2 + 10 }}\n              style={{ originX: flip ? 1 : 0, originY: 0 }}\n              transition={spring}\n            >\n              <div className=\"mb-1.5 flex items-center justify-between gap-3 font-medium\">\n                <span>{periods[column]}</span>\n                <span className=\"tabular-nums\">{fmt.format(stackTotals[column])}</span>\n              </div>\n              <div className=\"flex flex-col gap-1\">\n                {[...rows.keys()].reverse().map((s) => (\n                  <div\n                    className={cn(\n                      \"flex items-center gap-2 transition-opacity\",\n                      seriesHover !== null && seriesHover !== s && \"opacity-40\",\n                    )}\n                    key={s}\n                  >\n                    <span className=\"size-2 rounded-[calc(var(--radius)*0.3)]\" style={{ background: COLORS[s % COLORS.length] }} />\n                    <span className=\"text-muted-foreground\">{slice.series[s]}</span>\n                    <span className=\"ms-auto font-medium tabular-nums\">{fmt.format(rows[s][column] ?? 0)}</span>\n                  </div>\n                ))}\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <table className=\"mt-4 w-full text-sm\">\n        <caption className=\"sr-only\">\n          {measure.label} by {slice.label}\n          {column === null ? \"\" : `, ${periods[column]}`}\n        </caption>\n        <thead>\n          <tr className=\"border-b text-muted-foreground text-xs\">\n            <th className=\"h-8 px-2 text-left font-medium\" scope=\"col\">\n              {slice.label}\n            </th>\n            <th className=\"h-8 px-2 text-right font-medium\" scope=\"col\">\n              {column === null ? \"Total\" : periods[column]}\n            </th>\n            <th className=\"h-8 w-28 px-2 text-right font-medium sm:w-32\" scope=\"col\">\n              Share\n            </th>\n          </tr>\n        </thead>\n        <tbody>\n          <AnimatePresence initial={false}>\n            {[...rows.keys()].reverse().map((s) => {\n              const share = shownTotal ? shown[s] / shownTotal : 0;\n              const lit = seriesHover === s;\n              const faded = seriesHover !== null && !lit;\n              return (\n                <motion.tr\n                  animate={{ opacity: faded ? 0.45 : 1 }}\n                  className={cn(\n                    \"relative border-b outline-none transition-colors last:border-0 focus-visible:bg-accent focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2\",\n                    lit && \"bg-accent\",\n                  )}\n                  exit={{ opacity: 0 }}\n                  initial={{ opacity: 0 }}\n                  key={s}\n                  onBlur={() => setSeriesHover(null)}\n                  onFocus={() => setSeriesHover(s)}\n                  onPointerEnter={() => setSeriesHover(s)}\n                  onPointerLeave={() => setSeriesHover(null)}\n                  tabIndex={0}\n                  transition={{ duration: reduce ? 0 : 0.18 }}\n                >\n                  <td className=\"h-9 px-2\">\n                    <span className=\"flex items-center gap-2\">\n                      <motion.span\n                        animate={{ scale: lit ? 1.35 : 1 }}\n                        className=\"size-2.5 rounded-[calc(var(--radius)*0.3)]\"\n                        style={{ background: COLORS[s % COLORS.length] }}\n                        transition={reduce ? { duration: 0 } : { type: \"spring\", duration: 0.25, bounce: 0 }}\n                      />\n                      <span className={cn(\"whitespace-nowrap\", lit && \"font-medium\")}>{slice.series[s]}</span>\n                    </span>\n                  </td>\n                  <td className=\"h-9 px-2 text-right\">\n                    <Roll className=\"justify-end\" instant={scrubbing} text={fmt.format(shown[s] ?? 0)} />\n                  </td>\n                  <td className=\"h-9 px-2\">\n                    <span className=\"flex items-center justify-end gap-2\">\n                      <span className=\"h-1.5 w-10 overflow-hidden rounded-full bg-muted sm:w-16\">\n                        <motion.span\n                          animate={{ clipPath: `inset(0px ${(100 - share * 100).toFixed(2)}% 0px 0px round 999px)` }}\n                          className=\"block size-full\"\n                          initial={false}\n                          style={{ background: COLORS[s % COLORS.length] }}\n                          transition={scrubbing ? { duration: reduce ? 0 : 0.12, ease: \"easeOut\" } : spring}\n                        />\n                      </span>\n                      <span className=\"w-11 text-right text-muted-foreground text-xs tabular-nums\">{(share * 100).toFixed(1)}%</span>\n                    </span>\n                  </td>\n                </motion.tr>\n              );\n            })}\n          </AnimatePresence>\n        </tbody>\n      </table>\n    </div>\n  );\n}\n"
    }
  ]
}