{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-funnel-chart","type":"registry:component","title":"Funnel Chart (Motion)","description":"FunnelChart with motion: when the data changes, every number turns on odometer wheels while the bars and the flow between them glide to their new heights.","dependencies":["motion"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/motion-odometer.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/funnel-chart.json"],"files":[{"path":"registry/dashral/motion/funnel-chart.tsx","type":"registry:file","target":"components/dashral/motion/funnel-chart.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { motion } from \"motion/react\"\nimport type { FunnelChartProps } from \"../funnel-chart\"\nimport { useLocale } from \"../lib/locale\"\nimport { cn } from \"../lib/utils\"\n\nimport { Odometer } from \"../lib/motion-odometer\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\n\nexport type { FunnelChartProps, FunnelChartDatum } from \"../funnel-chart\"\n\n/** A bar takes this share of its column; the rest is the flow to the next step. As the kit's FunnelChart has it. */\nconst BAR = 0.56\nconst RADIUS = 6\n/** Seconds between one column's arrival and the next. */\nconst STEP = 0.06\n\n/**\n * The kit's FunnelChart with motion: the same props and look. When the data changes, every value, share and drop turns\n * on odometer wheels, each digit as far as it travelled, while the bars and the flow between them glide to their new\n * heights. On arrival the bars rise one after another and each value rises into its line with its bar. With reduced\n * motion it's simply there and new data shows at once.\n */\nexport function FunnelChart({\n  data,\n  height = 200,\n  color = \"var(--chart-1)\",\n  percent: percentOf = \"first\",\n  showLost = true,\n  locale: localeProp,\n  formatOptions,\n  className,\n  classNames,\n  \"aria-label\": ariaLabel,\n  ...props\n}: FunnelChartProps) {\n  const reduced = useReducedMotion()\n  const { t, locale: contextLocale, dir } = useLocale()\n  const locale = localeProp ?? contextLocale\n  const format = React.useMemo(() => new Intl.NumberFormat(locale, formatOptions), [locale, formatOptions])\n  const percent = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 }), [locale])\n  const values = data.map((d) => Math.max(0, d.value))\n  const full = Math.max(0, ...values)\n  const top = (i: number) => (full ? height - (values[i]! / full) * height : height)\n  const ratio = (part: number, whole: number) => (whole ? part / whole : 0)\n  const squared = (k: number) => showLost && k > 0 && values[k - 1]! > values[k]!\n  const corner = (k: number) => (squared(k) ? 0 : Math.min(RADIUS, height - top(k)))\n\n  // Arrival: a column comes up from below after the one before it; with reduced motion, nothing moves.\n  const arrive = (i: number, from: Record<string, string | number>) =>\n    reduced ? { initial: false as const } : { initial: from, transition: { duration: duration.considered, ease: ease.enter, delay: i * STEP } }\n  // Changes after arrival: heights glide, without overshoot.\n  const glide = reduced ? { duration: 0 } : spring.glide\n\n  if (data.length === 0) {\n    return (\n      <div data-slot=\"funnel-chart\" className={cn(\"grid place-items-center text-sm text-muted-foreground\", className)} style={{ height }} {...props}>\n        {t(\"noData\")}\n      </div>\n    )\n  }\n\n  return (\n    <div data-slot=\"funnel-chart\" role=\"figure\" aria-label={ariaLabel ?? t(\"funnelChart\")} className={cn(\"w-full\", className)} {...props}>\n      <ol className=\"grid grid-rows-[auto_auto] gap-x-0 gap-y-3\" style={{ gridTemplateColumns: `repeat(${data.length}, minmax(0, 1fr))` }}>\n        {data.map((d, i) => {\n          const value = values[i]!\n          const previous = i > 0 ? values[i - 1]! : undefined\n          const lost = previous === undefined ? 0 : Math.max(0, previous - value)\n          const base = percentOf === \"previous\" && previous !== undefined ? previous : values[0]!\n          const barTop = top(i)\n          const lostTop = previous === undefined ? barTop : top(i - 1)\n          const left = ((1 - BAR) / 2) * 100\n          const right = 100 - left\n          const nextTop = i < data.length - 1 ? top(i + 1) : null\n          const end = 100 + left\n          const middle = (right + end) / 2\n          const hasLost = showLost && previous !== undefined && lost > 0\n          return (\n            <li key={d.key ?? d.label} className={cn(\"row-span-2 grid grid-rows-subgrid\", classNames?.step)}>\n              <div className=\"grid min-w-0 content-start gap-0.5 px-1 text-center\">\n                <motion.span className=\"truncate text-xs text-muted-foreground\" animate={{ opacity: 1, y: 0 }} {...arrive(i, { opacity: 0, y: 4 })}>\n                  {d.label}\n                </motion.span>\n                {/* The value rises into its line with its bar, then turns on wheels when the data changes. */}\n                <span className=\"-mb-[0.12em] flex justify-center overflow-hidden pb-[0.12em] text-base font-semibold tabular-nums\">\n                  <motion.span className=\"flex\" animate={{ y: 0 }} {...arrive(i, { y: \"110%\" })}>\n                    <Odometer value={d.value} format={format} />\n                  </motion.span>\n                </span>\n                <motion.span className=\"flex justify-center text-xs text-muted-foreground tabular-nums\" animate={{ opacity: 1, y: 0 }} {...arrive(i + 1, { opacity: 0, y: 4 })}>\n                  <Odometer value={ratio(value, base)} format={percent} />\n                </motion.span>\n                {previous !== undefined && (\n                  <span className=\"sr-only\">{t(\"funnelFrom\", { rate: percent.format(ratio(value, previous)), from: data[i - 1]!.label, lost: format.format(lost) })}</span>\n                )}\n              </div>\n\n              <div aria-hidden className=\"relative\" style={{ height }}>\n                <motion.svg\n                  width=\"100%\"\n                  height={height}\n                  viewBox={`0 0 100 ${height}`}\n                  preserveAspectRatio=\"none\"\n                  className={cn(\"absolute inset-0 overflow-visible\", dir === \"rtl\" && \"-scale-x-100\")}\n                  animate={{ opacity: 1 }}\n                  {...arrive(i + 1, { opacity: 0 })}\n                >\n                  {nextTop !== null && (\n                    <motion.path\n                      initial={false}\n                      animate={{ d: `M50,${barTop}L${right},${barTop}C${middle},${barTop} ${middle},${nextTop} ${end},${nextTop}L150,${nextTop}L150,${height}L50,${height}Z` }}\n                      transition={glide}\n                      fill={color}\n                      fillOpacity={0.12}\n                    />\n                  )}\n                </motion.svg>\n\n                {hasLost && (\n                  <motion.div\n                    className=\"@container absolute grid place-items-center overflow-hidden border border-b-0 border-dashed border-muted-foreground/35 text-[11px] text-muted-foreground tabular-nums\"\n                    style={{ left: `${left}%`, right: `${left}%`, borderTopLeftRadius: RADIUS, borderTopRightRadius: RADIUS }}\n                    initial={reduced ? false : { top: lostTop, height: barTop - lostTop, opacity: 0 }}\n                    animate={{ top: lostTop, height: barTop - lostTop, opacity: 1 }}\n                    transition={{ ...glide, opacity: reduced ? { duration: 0 } : { duration: duration.considered, ease: ease.enter, delay: (i + 1) * STEP } }}\n                  >\n                    {barTop - lostTop >= 18 && (\n                      <span className=\"flex px-0.5 @max-[3.75rem]:px-0 @max-[3.75rem]:text-[10px]\">\n                        −<Odometer value={lost} format={format} />\n                      </span>\n                    )}\n                  </motion.div>\n                )}\n\n                <motion.div\n                  className=\"absolute bottom-0 overflow-hidden\"\n                  style={{ left: `${left}%`, right: `${left}%`, borderTopLeftRadius: corner(i), borderTopRightRadius: corner(i) }}\n                  initial={false}\n                  animate={{ top: barTop }}\n                  transition={glide}\n                >\n                  <motion.div className={cn(\"h-full\", classNames?.bar)} style={{ background: color }} animate={{ y: 0 }} {...arrive(i, { y: \"100%\" })} />\n                </motion.div>\n              </div>\n            </li>\n          )\n        })}\n      </ol>\n    </div>\n  )\n}\n"}]}