{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "funnel-chart-card",
  "title": "Funnel Chart Card",
  "description": "A conversion card with a flow funnel: a headline value with delta, a range picker that carries its own steps, and a tile per stage. Hovering a stage swaps the headline.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "card",
    "button",
    "dropdown-menu",
    "tooltip"
  ],
  "files": [
    {
      "path": "registry/dashboardcn/blocks/funnel-chart-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber, type NumberFormat } from \"@/registry/dashboardcn/lib/format\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardAction,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { DeltaBadge } from \"@/registry/dashboardcn/ui/delta-badge\"\nimport { FunnelChart, type FunnelChartProps, type FunnelStep } from \"@/registry/dashboardcn/ui/funnel-chart\"\nimport { MetricValue } from \"@/registry/dashboardcn/ui/metric-value\"\n\nexport interface FunnelRange {\n  value: string\n  label: string\n  /** Top of the funnel first. */\n  steps: FunnelStep[]\n  /** Change in the first step vs the previous range, as a fraction. */\n  delta?: number\n}\n\nexport interface FunnelChartCardProps\n  extends Omit<React.ComponentProps<typeof Card>, \"children\"> {\n  title: string\n  /** Top of the funnel first. Ignored when `ranges` is set. */\n  steps?: FunnelStep[]\n  /** Change in the first step, as a fraction. Ignored when `ranges` is set. */\n  delta?: number\n  /** Context for the delta, e.g. \"vs last week\". */\n  deltaLabel?: string\n  invertDelta?: boolean\n  /** Static text in the header, e.g. \"Last 30 days\". Replaced by the picker when `ranges` is set. */\n  caption?: string\n  /** Ranges for the header picker. Each carries its own steps. */\n  ranges?: FunnelRange[]\n  range?: string\n  defaultRange?: string\n  onRangeChange?: (value: string) => void\n  format?: NumberFormat\n  currency?: string\n  color?: FunnelChartProps[\"color\"]\n  shape?: FunnelChartProps[\"shape\"]\n  height?: FunnelChartProps[\"height\"]\n  neckWidth?: FunnelChartProps[\"neckWidth\"]\n  showPercentages?: FunnelChartProps[\"showPercentages\"]\n  showLabels?: FunnelChartProps[\"showLabels\"]\n  chartClassName?: string\n}\n\n/** A conversion card with a flow funnel: headline value, delta, and a range picker. Hovering a stage swaps the headline to that stage. */\nfunction FunnelChartCard({\n  title,\n  steps: stepsProp = [],\n  delta: deltaProp,\n  deltaLabel,\n  invertDelta,\n  caption,\n  ranges,\n  range: rangeProp,\n  defaultRange,\n  onRangeChange,\n  format = \"number\",\n  currency,\n  color,\n  shape,\n  height,\n  neckWidth,\n  showPercentages,\n  showLabels,\n  chartClassName,\n  className,\n  ...props\n}: FunnelChartCardProps) {\n  const [rangeState, setRangeState] = React.useState(defaultRange ?? ranges?.[0]?.value)\n  const range = rangeProp ?? rangeState\n  const setRange = (value: string) => {\n    setRangeState(value)\n    onRangeChange?.(value)\n  }\n  const current = ranges?.find((option) => option.value === range) ?? ranges?.[0]\n  const steps = current?.steps ?? stepsProp\n  const delta = current ? current.delta : deltaProp\n\n  const [active, setActive] = React.useState<number | null>(null)\n  const first = steps[0]\n  const hovered = active !== null ? steps[active] : undefined\n  const headline = hovered ?? first\n  const share = hovered && first && first.value > 0 ? hovered.value / first.value : null\n\n  const valueFormatter = React.useCallback(\n    (value: number) => formatNumber(value, { format, currency }),\n    [format, currency]\n  )\n\n  return (\n    <Card data-slot=\"funnel-chart-card\" className={cn(\"@container/card gap-4\", className)} {...props}>\n      <CardHeader className=\"@max-sm/card:[grid-template-columns:minmax(0,1fr)]!\">\n        <CardDescription>{title}</CardDescription>\n        <CardTitle className=\"text-3xl font-semibold tabular-nums tracking-tight\">\n          <MetricValue value={headline?.value ?? 0} format={format} currency={currency} />\n        </CardTitle>\n        <div className=\"flex h-5 items-center gap-1.5 text-sm\">\n          {hovered && share !== null ? (\n            <span className=\"text-muted-foreground truncate\">\n              {hovered.name}\n              {hovered !== first ? (\n                <>\n                  {\" · \"}\n                  <span className=\"text-foreground font-medium tabular-nums\">\n                    {formatNumber(share, { format: \"percent\", maximumFractionDigits: 0 })}\n                  </span>{\" \"}\n                  of {first?.name}\n                </>\n              ) : null}\n            </span>\n          ) : (\n            <>\n              {delta !== undefined ? (\n                <DeltaBadge delta={delta} invert={invertDelta} variant=\"soft\" />\n              ) : null}\n              {deltaLabel ? <span className=\"text-muted-foreground\">{deltaLabel}</span> : null}\n            </>\n          )}\n        </div>\n        {ranges?.length ? (\n          <CardAction className=\"@max-sm/card:[grid-area:auto]! @max-sm/card:justify-self-start!\">\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <Button variant=\"outline\" size=\"sm\">\n                  {current?.label}\n                  <ChevronDown className=\"text-muted-foreground\" />\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\">\n                <DropdownMenuRadioGroup value={range} onValueChange={setRange}>\n                  {ranges.map((option) => (\n                    <DropdownMenuRadioItem key={option.value} value={option.value}>\n                      {option.label}\n                    </DropdownMenuRadioItem>\n                  ))}\n                </DropdownMenuRadioGroup>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </CardAction>\n        ) : caption ? (\n          <CardAction className=\"@max-sm/card:[grid-area:auto]! @max-sm/card:justify-self-start!\">\n            <span className=\"text-muted-foreground text-sm\">{caption}</span>\n          </CardAction>\n        ) : null}\n      </CardHeader>\n      <CardContent>\n        <FunnelChart\n          variant=\"flow\"\n          steps={steps}\n          valueFormatter={valueFormatter}\n          color={color}\n          shape={shape}\n          height={height}\n          neckWidth={neckWidth}\n          showPercentages={showPercentages}\n          showLabels={showLabels}\n          activeIndex={active}\n          onActiveIndexChange={setActive}\n          className={chartClassName}\n        />\n      </CardContent>\n    </Card>\n  )\n}\n\nexport { FunnelChartCard }\n",
      "type": "registry:component"
    },
    {
      "path": "registry/dashboardcn/ui/funnel-chart.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber } from \"@/registry/dashboardcn/lib/format\"\n\nexport interface FunnelStep {\n  name: string\n  value: number\n  /** Any CSS color for this step in the flow variant. Defaults to chart-1 through chart-5 in order. */\n  color?: string\n}\n\nexport interface FunnelChartProps extends React.ComponentProps<\"div\"> {\n  steps: FunnelStep[]\n  valueFormatter?: (value: number) => string\n  /**\n   * Any CSS color. \"bars\" fades it step by step. \"flow\" paints every stage in\n   * it, in place of the per-stage palette.\n   */\n  color?: string\n  /** Show the drop-off between consecutive steps. Bars only. */\n  showDropoff?: boolean\n  /** \"bars\" stacks one bar per step; \"flow\" runs the steps left to right, each tapering into the next. */\n  variant?: \"bars\" | \"flow\"\n  /** Flow only. \"eased\" curves each neck; \"sharp\" draws straight trapezoids. */\n  shape?: \"eased\" | \"sharp\"\n  /** Flow only. Height of the stages in pixels. */\n  height?: number\n  /** Flow only. Width of the neck between stages in pixels. */\n  neckWidth?: number\n  /** Flow only. Show the share of the first step as a pill on each stage. */\n  showPercentages?: boolean\n  /** Flow only. Show a tile with the name and value under each stage. */\n  showLabels?: boolean\n  /** Flow only. Stage drawn at full strength while the rest dim. */\n  activeIndex?: number | null\n  onActiveIndexChange?: (index: number | null) => void\n}\n\nconst defaultColors = [\n  \"var(--chart-1)\",\n  \"var(--chart-2)\",\n  \"var(--chart-3)\",\n  \"var(--chart-4)\",\n  \"var(--chart-5)\",\n]\n\n// Round so server and client render identical path strings.\nconst round = (n: number) => Math.round(n * 1000) / 1000\n\n/** Path for the neck between two stages, in a 1x1 box stretched to fit. */\nfunction neckPath(from: number, to: number, shape: \"eased\" | \"sharp\") {\n  const t0 = round((1 - from) / 2)\n  const b0 = round(1 - t0)\n  const t1 = round((1 - to) / 2)\n  const b1 = round(1 - t1)\n  if (shape === \"sharp\") return `M0,${t0} L1,${t1} L1,${b1} L0,${b0} Z`\n  return `M0,${t0} C0.5,${t0} 0.5,${t1} 1,${t1} L1,${b1} C0.5,${b1} 0.5,${b0} 0,${b0} Z`\n}\n\nfunction FunnelBars({\n  steps,\n  valueFormatter,\n  color,\n  showDropoff,\n}: {\n  steps: FunnelStep[]\n  valueFormatter: (value: number) => string\n  color: string\n  showDropoff: boolean\n}) {\n  const first = steps[0]?.value ?? 0\n  const last = steps[steps.length - 1]?.value ?? 0\n  const overall = first > 0 ? last / first : 0\n\n  return (\n    <>\n      {steps.map((step, index) => {\n        const previous = index > 0 ? steps[index - 1]!.value : step.value\n        const ofFirst = first > 0 ? step.value / first : 0\n        const ofPrevious = previous > 0 ? step.value / previous : 0\n        const dropoff = 1 - ofPrevious\n\n        return (\n          <React.Fragment key={step.name}>\n            {showDropoff && index > 0 ? (\n              <div className=\"text-muted-foreground flex items-center gap-2 py-1 pl-3 text-xs\">\n                <span aria-hidden=\"true\" className=\"bg-border h-3 w-px\" />\n                <span className=\"tabular-nums\">\n                  {formatNumber(dropoff, { format: \"percent\" })} drop-off\n                </span>\n              </div>\n            ) : null}\n            <div data-slot=\"funnel-step\" className=\"flex flex-col gap-1.5\">\n              <div className=\"flex items-baseline justify-between gap-4 text-sm\">\n                <span className=\"flex items-center gap-2 truncate\">\n                  <span className=\"text-muted-foreground w-4 shrink-0 text-xs tabular-nums\">\n                    {index + 1}\n                  </span>\n                  <span className=\"truncate font-medium\">{step.name}</span>\n                </span>\n                <span className=\"flex shrink-0 items-baseline gap-2 tabular-nums\">\n                  <span className=\"font-medium\">{valueFormatter(step.value)}</span>\n                  <span className=\"text-muted-foreground w-12 text-right text-xs\">\n                    {formatNumber(ofFirst, { format: \"percent\" })}\n                  </span>\n                </span>\n              </div>\n              <div className=\"bg-muted h-6 w-full overflow-hidden rounded-md\">\n                <div\n                  className=\"h-full rounded-md transition-[width] duration-300\"\n                  style={{\n                    width: `${ofFirst * 100}%`,\n                    backgroundColor: color,\n                    opacity: 1 - index * (0.5 / Math.max(steps.length - 1, 1)),\n                  }}\n                />\n              </div>\n            </div>\n          </React.Fragment>\n        )\n      })}\n      {steps.length > 1 ? (\n        <div className=\"mt-2 flex items-center justify-between border-t pt-3 text-sm\">\n          <span className=\"text-muted-foreground\">Overall conversion</span>\n          <span className=\"font-semibold tabular-nums\">\n            {formatNumber(overall, { format: \"percent\" })}\n          </span>\n        </div>\n      ) : null}\n    </>\n  )\n}\n\nfunction FunnelFlow({\n  steps,\n  valueFormatter,\n  color,\n  shape,\n  height,\n  neckWidth,\n  showPercentages,\n  showLabels,\n  activeIndex,\n  onActiveIndexChange,\n}: {\n  steps: FunnelStep[]\n  valueFormatter: (value: number) => string\n  color?: string\n  shape: \"eased\" | \"sharp\"\n  height: number\n  neckWidth: number\n  showPercentages: boolean\n  showLabels: boolean\n  activeIndex?: number | null\n  onActiveIndexChange?: (index: number | null) => void\n}) {\n  const id = React.useId()\n  const [activeState, setActiveState] = React.useState<number | null>(null)\n  const active = activeIndex === undefined ? activeState : activeIndex\n  const setActive = (index: number | null) => {\n    setActiveState(index)\n    onActiveIndexChange?.(index)\n  }\n\n  const first = steps[0]?.value ?? 0\n  const stages = steps.map((step, index) => ({\n    ...step,\n    share: first > 0 ? Math.min(1, Math.max(0, step.value / first)) : 0,\n    fill: step.color ?? color ?? defaultColors[index % defaultColors.length],\n    // A single color fades toward the end of the funnel so stages stay distinct.\n    opacity: color && !step.color ? 1 - index * (0.6 / Math.max(steps.length - 1, 1)) : 1,\n  }))\n\n  return (\n    <>\n      <div\n        data-slot=\"funnel-flow\"\n        className=\"flex w-full items-stretch\"\n        style={{ height }}\n        onMouseLeave={() => setActive(null)}\n      >\n        {stages.map((stage, index) => {\n          const next = stages[index + 1]\n          const dimmed = active !== null && active !== index\n          const neckDimmed = active !== null && active !== index && active !== index + 1\n          const gradient = `${id}-${index}`\n          return (\n            <React.Fragment key={stage.name}>\n              <div\n                data-slot=\"funnel-stage\"\n                data-active={active === index || undefined}\n                onMouseEnter={() => setActive(index)}\n                className=\"relative flex min-w-0 flex-1 items-center transition-opacity duration-200\"\n                style={{ opacity: dimmed ? 0.35 : 1 }}\n              >\n                <div\n                  className={cn(\n                    \"w-full transition-[height] duration-500 ease-out\",\n                    index === 0 && \"rounded-l-lg\",\n                    index === stages.length - 1 && \"rounded-r-lg\"\n                  )}\n                  style={{\n                    height: `${round(stage.share * 100)}%`,\n                    minHeight: 4,\n                    backgroundColor: stage.fill,\n                    opacity: stage.opacity,\n                  }}\n                />\n                {showPercentages ? (\n                  <span className=\"bg-background/90 text-foreground ring-foreground/10 pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums shadow-xs ring-1 backdrop-blur-sm\">\n                    {formatNumber(stage.share, { format: \"percent\", maximumFractionDigits: 0 })}\n                  </span>\n                ) : null}\n              </div>\n              {next ? (\n                <svg\n                  aria-hidden=\"true\"\n                  data-slot=\"funnel-neck\"\n                  viewBox=\"0 0 1 1\"\n                  preserveAspectRatio=\"none\"\n                  className=\"h-full shrink-0 transition-opacity duration-200\"\n                  style={{ width: neckWidth, opacity: neckDimmed ? 0.35 : 1 }}\n                >\n                  <defs>\n                    <linearGradient id={gradient} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n                      <stop offset=\"0\" stopColor={stage.fill} stopOpacity={stage.opacity} />\n                      <stop offset=\"1\" stopColor={next.fill} stopOpacity={next.opacity} />\n                    </linearGradient>\n                  </defs>\n                  <path\n                    d={neckPath(stage.share, next.share, shape)}\n                    fill={`url(#${gradient})`}\n                    className=\"transition-[d] duration-500 ease-out\"\n                  />\n                </svg>\n              ) : null}\n            </React.Fragment>\n          )\n        })}\n      </div>\n      {showLabels ? (\n        <div\n          data-slot=\"funnel-labels\"\n          className=\"grid gap-2\"\n          style={{ gridTemplateColumns: `repeat(${stages.length}, minmax(0, 1fr))` }}\n        >\n          {stages.map((stage, index) => (\n            <div\n              key={stage.name}\n              data-active={active === index || undefined}\n              onMouseEnter={() => setActive(index)}\n              onMouseLeave={() => setActive(null)}\n              className={cn(\n                \"flex min-w-0 flex-col gap-0.5 rounded-lg border px-2.5 py-2 transition-[opacity,border-color] duration-200\",\n                active !== null && active !== index && \"opacity-50\",\n                active === index && \"border-foreground/20\"\n              )}\n            >\n              <span className=\"text-muted-foreground flex items-center gap-1.5 text-xs\">\n                <span\n                  aria-hidden=\"true\"\n                  className=\"size-2 shrink-0 rounded-full\"\n                  style={{ backgroundColor: stage.fill, opacity: stage.opacity }}\n                />\n                <span className=\"truncate\">{stage.name}</span>\n              </span>\n              <span className=\"truncate text-sm font-semibold tabular-nums\">\n                {valueFormatter(stage.value)}\n              </span>\n            </div>\n          ))}\n        </div>\n      ) : null}\n    </>\n  )\n}\n\n/** Step-by-step conversion, as stacked bars with drop-off or as a flow of tapering stages. */\nfunction FunnelChart({\n  steps,\n  valueFormatter = (value) => formatNumber(value),\n  color,\n  showDropoff = true,\n  variant = \"bars\",\n  shape = \"eased\",\n  height = 160,\n  neckWidth = 24,\n  showPercentages = true,\n  showLabels = true,\n  activeIndex,\n  onActiveIndexChange,\n  className,\n  ...props\n}: FunnelChartProps) {\n  return (\n    <div\n      data-slot=\"funnel-chart\"\n      data-variant={variant}\n      className={cn(\"flex flex-col\", variant === \"flow\" ? \"gap-3\" : \"gap-1\", className)}\n      {...props}\n    >\n      {variant === \"flow\" ? (\n        <FunnelFlow\n          steps={steps}\n          valueFormatter={valueFormatter}\n          color={color}\n          shape={shape}\n          height={height}\n          neckWidth={neckWidth}\n          showPercentages={showPercentages}\n          showLabels={showLabels}\n          activeIndex={activeIndex}\n          onActiveIndexChange={onActiveIndexChange}\n        />\n      ) : (\n        <FunnelBars\n          steps={steps}\n          valueFormatter={valueFormatter}\n          color={color ?? \"var(--chart-1)\"}\n          showDropoff={showDropoff}\n        />\n      )}\n    </div>\n  )\n}\n\nexport { FunnelChart }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/ui/delta-badge.tsx",
      "content": "import * as React from \"react\"\nimport { Minus, TrendingDown, TrendingUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatDelta } from \"@/registry/dashboardcn/lib/format\"\n\nexport type DeltaDirection = \"up\" | \"down\" | \"flat\"\n\nexport function getDeltaDirection(delta: number | undefined): DeltaDirection {\n  if (delta === undefined || delta === 0 || !Number.isFinite(delta)) return \"flat\"\n  return delta > 0 ? \"up\" : \"down\"\n}\n\nconst directionIcon: Record<DeltaDirection, React.ElementType> = {\n  up: TrendingUp,\n  down: TrendingDown,\n  flat: Minus,\n}\n\nexport interface DeltaBadgeProps extends React.ComponentProps<\"span\"> {\n  /** Fractional change, e.g. 0.124 for +12.4%. */\n  delta: number\n  /** Treat a decrease as good and an increase as bad (churn, latency, errors). */\n  invert?: boolean\n  variant?: \"outline\" | \"soft\" | \"text\"\n  showIcon?: boolean\n}\n\nfunction DeltaBadge({\n  delta,\n  invert = false,\n  variant = \"outline\",\n  showIcon = true,\n  className,\n  children,\n  ...props\n}: DeltaBadgeProps) {\n  const direction = getDeltaDirection(delta)\n  const positive = direction === \"flat\" ? null : (direction === \"up\") !== invert\n  const Icon = directionIcon[direction]\n\n  return (\n    <span\n      data-slot=\"delta-badge\"\n      data-direction={direction}\n      data-positive={positive}\n      className={cn(\n        \"inline-flex items-center gap-1 text-xs font-medium tabular-nums\",\n        variant === \"outline\" && \"rounded-md border px-1.5 py-0.5\",\n        variant === \"soft\" && \"rounded-md px-1.5 py-0.5\",\n        variant === \"soft\" && positive === true && \"bg-emerald-500/10\",\n        variant === \"soft\" && positive === false && \"bg-red-500/10\",\n        variant === \"soft\" && positive === null && \"bg-muted\",\n        positive === true && \"text-emerald-600 dark:text-emerald-400\",\n        positive === false && \"text-red-600 dark:text-red-400\",\n        positive === null && \"text-muted-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {showIcon ? <Icon className=\"size-3\" aria-hidden=\"true\" /> : null}\n      {children ?? formatDelta(delta)}\n    </span>\n  )\n}\n\nexport { DeltaBadge }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/ui/metric-value.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber, type NumberFormat } from \"@/registry/dashboardcn/lib/format\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\n/** Values at or above this are abbreviated by default. */\nexport const DEFAULT_COMPACT_FROM = 100_000\n\nexport interface MetricValueProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** Strings render as-is. */\n  value: number | string\n  format?: NumberFormat\n  currency?: string\n  maximumFractionDigits?: number\n  /** Values at or above this are abbreviated (e.g. $158K) with the full value in a tooltip. Set to Infinity to always show the full value. */\n  compactFrom?: number\n  /** Text after the number, e.g. a unit. */\n  suffix?: React.ReactNode\n}\n\n/** A formatted number. Large values are abbreviated and reveal the full value on hover. */\nfunction MetricValue({\n  value,\n  format,\n  currency,\n  maximumFractionDigits,\n  compactFrom = DEFAULT_COMPACT_FROM,\n  suffix,\n  className,\n  ...props\n}: MetricValueProps) {\n  const classes = cn(\"tabular-nums\", className)\n\n  if (typeof value !== \"number\") {\n    return (\n      <span data-slot=\"metric-value\" className={classes} {...props}>\n        {value}\n        {suffix}\n      </span>\n    )\n  }\n\n  const full = formatNumber(value, { format, currency, maximumFractionDigits })\n  const abbreviate = format !== \"percent\" && Math.abs(value) >= compactFrom\n  if (!abbreviate) {\n    return (\n      <span data-slot=\"metric-value\" className={classes} {...props}>\n        {full}\n        {suffix}\n      </span>\n    )\n  }\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span\n            data-slot=\"metric-value\"\n            data-compact=\"\"\n            className={cn(classes, \"cursor-default\")}\n            {...props}\n          >\n            {formatNumber(value, { format, currency, compact: true })}\n            {suffix}\n          </span>\n        </TooltipTrigger>\n        <TooltipContent className=\"tabular-nums\">\n          {full}\n          {suffix}\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n}\n\nexport { MetricValue }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/lib/format.ts",
      "content": "export type NumberFormat = \"number\" | \"compact\" | \"currency\" | \"percent\"\n\nexport interface FormatNumberOptions {\n  format?: NumberFormat\n  /** ISO 4217 code, used when format is \"currency\". Defaults to USD. */\n  currency?: string\n  locale?: string\n  maximumFractionDigits?: number\n  /** Abbreviate large values, e.g. \"$158K\" instead of \"$158,143\". */\n  compact?: boolean\n}\n\nconst formatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction getFormatter(locale: string, options: Intl.NumberFormatOptions) {\n  const key = locale + JSON.stringify(options)\n  let formatter = formatterCache.get(key)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, options)\n    formatterCache.set(key, formatter)\n  }\n  return formatter\n}\n\n/**\n * Format a metric value for display.\n *\n * formatNumber(1234567)                          -> \"1,234,567\"\n * formatNumber(1234567, { format: \"compact\" })   -> \"1.2M\"\n * formatNumber(48.2, { format: \"currency\" })     -> \"$48.20\"\n * formatNumber(158143, { format: \"currency\", compact: true }) -> \"$158K\"\n * formatNumber(0.124, { format: \"percent\" })     -> \"12.4%\"\n */\nexport function formatNumber(\n  value: number,\n  {\n    format = \"number\",\n    currency = \"USD\",\n    locale = \"en-US\",\n    maximumFractionDigits,\n    compact = false,\n  }: FormatNumberOptions = {}\n): string {\n  if (!Number.isFinite(value)) return \"—\"\n\n  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.\n  const compactOptions: Intl.NumberFormatOptions = compact\n    ? maximumFractionDigits === undefined\n      ? { notation: \"compact\", maximumSignificantDigits: 3 }\n      : { notation: \"compact\", maximumFractionDigits }\n    : {}\n  switch (format) {\n    case \"compact\":\n      return getFormatter(locale, {\n        notation: \"compact\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    case \"currency\":\n      return getFormatter(locale, {\n        style: \"currency\",\n        currency,\n        maximumFractionDigits: maximumFractionDigits ?? 2,\n        ...compactOptions,\n      }).format(value)\n    case \"percent\":\n      return getFormatter(locale, {\n        style: \"percent\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    default:\n      return getFormatter(locale, {\n        maximumFractionDigits: maximumFractionDigits ?? 0,\n        ...compactOptions,\n      }).format(value)\n  }\n}\n\n/**\n * Format a fractional change as a signed percentage.\n *\n * formatDelta(0.124)  -> \"+12.4%\"\n * formatDelta(-0.03)  -> \"-3.0%\"\n * formatDelta(0)      -> \"0.0%\"\n */\nexport function formatDelta(delta: number, locale = \"en-US\"): string {\n  if (!Number.isFinite(delta)) return \"—\"\n  return getFormatter(locale, {\n    style: \"percent\",\n    signDisplay: \"exceptZero\",\n    maximumFractionDigits: 1,\n    minimumFractionDigits: 1,\n  }).format(delta)\n}\n\n/**\n * Fractional change between two values. Returns 0 when there is no baseline.\n */\nexport function computeDelta(current: number, previous: number): number {\n  if (!previous) return 0\n  return (current - previous) / Math.abs(previous)\n}\n",
      "type": "registry:lib"
    }
  ],
  "categories": [
    "dashboard",
    "charts"
  ],
  "type": "registry:block"
}