{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dumbbell-chart",
  "title": "Dumbbell Chart",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/charts/dumbbell-chart/DumbbellChart.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { ChartFrame, EChart } from '../shared'\nimport { useChartTheme, mergeOptionBlock } from '../useChartTheme'\n\n// DumbbellChart\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface DumbbellChartProps {\n  /** One row per category: compare `a` vs `b`. */\n  data: { label: string; a: number; b: number }[]\n  /** Series names for [a, b]. Default ['Before', 'After']. */\n  names?: [string, string]\n  height?: number | string\n  option?: any\n  className?: string\n  /** Accessible name announced for the chart image. Defaults to \"Chart\". */\n  ariaLabel?: string\n}\n\nexport const DumbbellChart = React.forwardRef<HTMLDivElement, DumbbellChartProps>(\n  ({ data, names = ['Before', 'After'], height = 300, option, className, ariaLabel }, ref) => {\n    const theme = useChartTheme()\n\n    const mergedOption = React.useMemo(() => {\n      const colorA = theme.colors[2]\n      const colorB = theme.colors[0]\n      const axisColor = theme.axisColor\n      const rows = data\n      const series = [\n        {\n          type: 'custom',\n          renderItem: (params: any, api: any) => {\n            const y = api.coord([0, params.dataIndex])[1]\n            const a = api.coord([api.value(0), params.dataIndex])\n            const b = api.coord([api.value(1), params.dataIndex])\n            return {\n              type: 'group',\n              children: [\n                {\n                  type: 'line',\n                  shape: { x1: a[0], y1: y, x2: b[0], y2: y },\n                  style: { stroke: axisColor, lineWidth: 2 },\n                },\n                { type: 'circle', shape: { cx: a[0], cy: y, r: 6 }, style: { fill: colorA } },\n                { type: 'circle', shape: { cx: b[0], cy: y, r: 6 }, style: { fill: colorB } },\n              ],\n            }\n          },\n          data: rows.map((d) => [d.a, d.b]),\n        },\n      ]\n      const userOption: any = option ?? {}\n      const {\n        series: userSeries,\n        xAxis: userXAxis,\n        yAxis: userYAxis,\n        grid: userGrid,\n        tooltip: userTooltip,\n        ...userRest\n      } = userOption\n      const mergedSeries = Array.isArray(userSeries)\n        ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) }))\n        : series\n      return {\n        color: theme.colors,\n        grid: mergeOptionBlock({ left: 16, right: 16, top: 32, bottom: 24, containLabel: true }, userGrid),\n        tooltip: mergeOptionBlock(\n          {\n            trigger: 'item',\n            backgroundColor: theme.tooltipBg,\n            borderColor: theme.tooltipBorder,\n            textStyle: { color: theme.tooltipText, fontSize: 12 },\n            formatter: (p: any) =>\n              `${rows[p.dataIndex]?.label}<br/>${names[0]}: ${p.value[0]}<br/>${names[1]}: ${p.value[1]}`,\n          },\n          userTooltip,\n        ),\n        legend: {\n          bottom: 0,\n          icon: 'circle',\n          itemWidth: 8,\n          itemHeight: 8,\n          textStyle: { fontSize: 11, color: theme.textColor },\n          data: [\n            { name: names[0], itemStyle: { color: colorA } },\n            { name: names[1], itemStyle: { color: colorB } },\n          ],\n        },\n        xAxis: mergeOptionBlock(\n          {\n            type: 'value',\n            splitLine: { lineStyle: { color: theme.splitLineColor } },\n            axisLabel: { color: theme.textColor, fontSize: 11 },\n          },\n          userXAxis,\n        ),\n        yAxis: mergeOptionBlock(\n          {\n            type: 'category',\n            inverse: true,\n            data: rows.map((d) => d.label),\n            axisLine: { lineStyle: { color: theme.axisColor } },\n            axisLabel: { color: theme.textColor, fontSize: 11 },\n            axisTick: { show: false },\n          },\n          userYAxis,\n        ),\n        series: mergedSeries,\n        ...userRest,\n      }\n    }, [data, names, option, theme])\n\n    return (\n      <ChartFrame ref={ref} height={height} className={className} ariaLabel={ariaLabel}>\n        <EChart option={mergedOption} />\n      </ChartFrame>\n    )\n  },\n)\nDumbbellChart.displayName = 'DumbbellChart'\n",
      "type": "registry:ui",
      "target": "~/components/ui/charts/dumbbell-chart/DumbbellChart.tsx"
    },
    {
      "path": "packages/registry-react/components/charts/dumbbell-chart/index.ts",
      "content": "export { DumbbellChart, type DumbbellChartProps } from './DumbbellChart'\n",
      "type": "registry:ui",
      "target": "~/components/ui/charts/dumbbell-chart/index.ts"
    },
    {
      "path": "packages/registry-react/components/charts/useChartTheme.ts",
      "content": "'use client'\n\nimport { useEffect, useState } from 'react'\n\n// Chart palette is driven by Tailwind v4 CSS variables (`--chart-1`..`--chart-5`,\n// `--muted-foreground`, `--border`, `--popover`, etc.) so dark/light flips\n// happen automatically when the consumer toggles their theme class. The\n// values resolve at runtime via `getComputedStyle`, so they pick up whatever\n// the consumer set in their own `tailwind.css` -- no fork required.\n//\n// We bump a module-level `themeKey` whenever `<html>` class/style changes (the\n// typical shadcn dark-mode pivot) and notify subscribed `useChartTheme()`\n// consumers so every chart re-resolves its colors and ECharts re-paints.\n\nlet themeKey = 0\nconst listeners = new Set<() => void>()\n\nfunction bump() {\n  themeKey++\n  listeners.forEach((l) => l())\n}\n\nif (typeof window !== 'undefined') {\n  // Bump once on the first paint so post-hydration getComputedStyle reads\n  // the *resolved* CSS values (during SSR-built bundles the very first\n  // read returns the fallbacks below).\n  requestAnimationFrame(bump)\n  new MutationObserver(bump).observe(document.documentElement, {\n    attributes: true,\n    attributeFilter: ['class', 'style', 'data-theme'],\n  })\n}\n\n// ECharts' canvas renderer does not accept OKLCH in every browser. Assigning\n// one of our Tailwind tokens to `fillStyle` can silently leave the sentinel\n// colour in place, turning a whole chart black. Convert OKLCH ourselves and\n// let the canvas normalize older CSS colour formats.\nlet _hexCanvas: CanvasRenderingContext2D | null = null\n\nexport function toCanvasColor(cssColor: string): string {\n  const value = cssColor.trim()\n  const match = value.match(\n    /^oklch\\(\\s*([+-]?(?:\\d+\\.?\\d*|\\.\\d+))(%?)\\s+([+-]?(?:\\d+\\.?\\d*|\\.\\d+))\\s+([+-]?(?:\\d+\\.?\\d*|\\.\\d+))(?:deg)?(?:\\s*\\/\\s*([+-]?(?:\\d+\\.?\\d*|\\.\\d+))(%?))?\\s*\\)$/i,\n  )\n\n  if (match) {\n    const lightness = Number(match[1]) / (match[2] === '%' ? 100 : 1)\n    const chroma = Number(match[3])\n    const hue = (Number(match[4]) * Math.PI) / 180\n    const alpha = match[5] == null ? 1 : Number(match[5]) / (match[6] === '%' ? 100 : 1)\n    const a = chroma * Math.cos(hue)\n    const b = chroma * Math.sin(hue)\n\n    const l = Math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3)\n    const m = Math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3)\n    const s = Math.pow(lightness - 0.0894841775 * a - 1.291485548 * b, 3)\n    const linear = [\n      4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,\n      -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,\n      -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,\n    ]\n    const rgb = linear.map((channel) => {\n      const encoded = channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055\n      return Math.round(Math.min(1, Math.max(0, encoded)) * 255)\n    })\n\n    return alpha < 1 ? `rgba(${rgb.join(', ')}, ${alpha})` : `rgb(${rgb.join(', ')})`\n  }\n\n  if (typeof document === 'undefined') return cssColor\n  if (!_hexCanvas) {\n    _hexCanvas = document.createElement('canvas').getContext('2d')\n  }\n  if (!_hexCanvas) return cssColor\n  _hexCanvas.fillStyle = '#010203'\n  _hexCanvas.fillStyle = value\n  const normalized = _hexCanvas.fillStyle as string\n  return normalized === '#010203' && value.toLowerCase() !== '#010203' ? value : normalized\n}\n\n// Convert any CSS color (hex, rgb, oklch, color()) + alpha 0..1 to a\n// canvas-safe rgba(r,g,b,a). `colorString + '40'` (8-digit hex alpha)\n// only works when `colorString` is `#rrggbb`; once tokens resolve to\n// oklch() post-hydration the gradient stops break and the canvas paint\n// throws every frame. Stay defensive and always return rgba.\nexport function toRgba(cssColor: string, alpha: number): string {\n  const normalized = toCanvasColor(cssColor)\n  if (normalized.startsWith('#') && normalized.length === 7) {\n    const r = parseInt(normalized.slice(1, 3), 16)\n    const g = parseInt(normalized.slice(3, 5), 16)\n    const b = parseInt(normalized.slice(5, 7), 16)\n    return `rgba(${r},${g},${b},${alpha})`\n  }\n  if (normalized.startsWith('rgba(')) {\n    return normalized.replace(/,\\s*[\\d.]+\\s*\\)$/, `,${alpha})`)\n  }\n  if (normalized.startsWith('rgb(')) {\n    return normalized.replace(/^rgb\\(/, 'rgba(').replace(/\\)$/, `,${alpha})`)\n  }\n  // Canvas refused to parse this color -- ship the original string and\n  // let ECharts complain (better than crashing the paint loop).\n  return cssColor\n}\n\nfunction resolveVar(name: string, fallback: string): string {\n  if (typeof window === 'undefined') return fallback\n  const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()\n  if (!v) return fallback\n  return toCanvasColor(v)\n}\n\n// SSR / pre-hydration fallback palette. Hex values picked to roughly\n// match the shadcn Neutral defaults in `tailwind.css` so the first paint\n// doesn't flicker.\nconst CHART_FALLBACK = ['#f59e0b', '#14b8a6', '#3b82f6', '#f97316', '#eab308']\n\n/** The resolved chart theme tokens. All values are canvas-safe hex/rgba. */\nexport interface ChartTheme {\n  colors: string[]\n  textColor: string\n  axisColor: string\n  splitLineColor: string\n  tooltipBg: string\n  tooltipBorder: string\n  tooltipText: string\n  bgColor: string\n  /** The app's accent, for the one highlighted element on a chart or map —\n   *  a selected route, a focused bar. Category series keep using `colors`. */\n  accentColor: string\n  /** Out-of-bounds / failure colour for charts that encode good vs bad\n   *  rather than a category — control limits, error series. CSS-level marks\n   *  can use `var(--destructive)` directly; this exists because ECharts needs\n   *  a resolved colour string on the canvas. */\n  dangerColor: string\n}\n\nfunction resolveTheme(): ChartTheme {\n  return {\n    colors: Array.from({ length: 5 }, (_, i) => resolveVar(`--chart-${i + 1}`, CHART_FALLBACK[i]!)),\n    textColor: resolveVar('--muted-foreground', '#888888'),\n    axisColor: resolveVar('--border', '#e5e5e5'),\n    splitLineColor: resolveVar('--border', '#f0f0f0'),\n    tooltipBg: resolveVar('--popover', 'rgba(255,255,255,0.96)'),\n    tooltipBorder: resolveVar('--border', '#e5e5e5'),\n    tooltipText: resolveVar('--popover-foreground', '#333333'),\n    bgColor: resolveVar('--card', resolveVar('--background', '#ffffff')),\n    accentColor: resolveVar('--primary', '#38bdf8'),\n    dangerColor: resolveVar('--destructive', '#dc2626'),\n  }\n}\n\n/**\n * Subscribe to the theme-token palette. Re-resolves (and re-renders the\n * consuming chart) whenever the consumer flips their dark-mode class on\n * `<html>`. The first client render resolves the real CSS values; SSR /\n * pre-hydration returns the fallback palette above.\n */\nexport function useChartTheme(): ChartTheme {\n  const [, setTick] = useState(themeKey)\n  const [theme, setTheme] = useState<ChartTheme>(() => resolveTheme())\n\n  useEffect(() => {\n    const update = () => {\n      setTick(themeKey)\n      setTheme(resolveTheme())\n    }\n    listeners.add(update)\n    // Resolve once on mount so the first client paint reads the real CSS\n    // values instead of the SSR fallbacks.\n    update()\n    return () => {\n      listeners.delete(update)\n    }\n  }, [])\n\n  return theme\n}\n\n// Two-level deep merge for ECharts option blocks (xAxis, yAxis, grid,\n// tooltip, legend, singleAxis, parallel, etc.). The top-level keys merge\n// shallowly, but one nested level (axisLabel, axisLine, splitLine, etc.)\n// merges shallowly too so a consumer passing `xAxis: { axisLabel: { fontSize: 9 } }`\n// doesn't blow away the wrapper's `color` + base font defaults on the same\n// axisLabel block. Arrays + primitives replace outright.\n//\n// This is the merge strategy the chart wrappers use to fold `option`\n// onto their computed base option without forcing consumers to spell out\n// every default they want to preserve.\nexport function mergeOptionBlock<T extends Record<string, any>>(base: T, user: Partial<T> | undefined): T {\n  if (!user) return base\n  const out: any = { ...base }\n  for (const k of Object.keys(user)) {\n    const bv = (base as any)[k]\n    const uv = (user as any)[k]\n    if (\n      bv != null &&\n      uv != null &&\n      typeof bv === 'object' &&\n      typeof uv === 'object' &&\n      !Array.isArray(bv) &&\n      !Array.isArray(uv)\n    ) {\n      out[k] = { ...bv, ...uv }\n    } else {\n      out[k] = uv\n    }\n  }\n  return out\n}\n\n// Default gauge stoplight: teal (safe) -> amber (warning) -> red (danger).\n// Pulled off saturated green and onto teal so the gauge ties back to the\n// dashboard palette; red is kept as the universal \"limit reached\" cue.\n// GaugeChart consumes this via its `thresholds` prop default; consumers\n// pass their own array to override. Static because gauges have semantic\n// meaning (green safe / red danger) that we deliberately don't theme-flip.\nexport const gaugeThresholds: [number, string][] = [\n  [0.6, '#14b8a6'],\n  [0.85, '#f59e0b'],\n  [1, '#dc2626'],\n]\n",
      "type": "registry:ui",
      "target": "~/components/ui/charts/useChartTheme.ts"
    },
    {
      "path": "packages/registry-react/components/charts/shared.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport * as echartsCore from 'echarts/core'\nimport { use } from 'echarts/core'\nimport { CanvasRenderer } from 'echarts/renderers'\nimport {\n  LineChart as EChartsLineChart,\n  BarChart as EChartsBarChart,\n  PieChart as EChartsPieChart,\n  ScatterChart as EChartsScatterChart,\n  EffectScatterChart as EChartsEffectScatter,\n  PictorialBarChart as EChartsPictorialBar,\n  RadarChart as EChartsRadarChart,\n  GaugeChart as EChartsGaugeChart,\n  HeatmapChart as EChartsHeatmap,\n  TreemapChart as EChartsTreemapChart,\n  FunnelChart as EChartsFunnelChart,\n  BoxplotChart as EChartsBoxplotChart,\n  CandlestickChart as EChartsCandlestickChart,\n  CustomChart as EChartsCustomChart,\n  ChordChart as EChartsChordChart,\n  MapChart as EChartsMapChart,\n  GraphChart as EChartsGraphChart,\n  ParallelChart as EChartsParallelChart,\n  SankeyChart as EChartsSankeyChart,\n  SunburstChart as EChartsSunburstChart,\n  ThemeRiverChart,\n  TreeChart as EChartsTreeChart,\n} from 'echarts/charts'\nimport {\n  GridComponent,\n  PolarComponent,\n  TooltipComponent,\n  LegendComponent,\n  MarkLineComponent,\n  MarkAreaComponent,\n  MarkPointComponent,\n  GraphicComponent,\n  RadarComponent,\n  VisualMapComponent,\n  CalendarComponent,\n  DataZoomComponent,\n  ParallelComponent,\n  SingleAxisComponent,\n  TitleComponent,\n} from 'echarts/components'\nimport ReactECharts from 'echarts-for-react/esm/core'\nimport { cn } from '@/lib/utils'\n\nexport function heightToStyle(height: number | string): string {\n  return /^\\d+$/.test(String(height)) ? `${height}px` : String(height)\n}\n\ninterface ChartFrameProps {\n  height: number | string\n  className?: string\n  /** Apply the accessible role/tabindex/focus-ring chrome. Some chart\n   *  wrappers ship a bare `w-full` frame -- pass `false` for those. */\n  focusable?: boolean\n  /** Accessible name for role=\"img\". Defaults to \"Chart\". */\n  ariaLabel?: string\n}\n\n/** Shared `<div>` wrapper around the ECharts canvas. */\nexport const ChartFrame = React.forwardRef<HTMLDivElement, ChartFrameProps & { children: React.ReactNode }>(\n  ({ height, className, focusable = true, ariaLabel, children }, ref) => (\n    <div\n      ref={ref}\n      role=\"img\"\n      aria-label={ariaLabel || 'Chart'}\n      tabIndex={focusable ? 0 : undefined}\n      style={{ height: heightToStyle(height) }}\n      className={\n        focusable\n          ? cn('focus-visible:ring-ring w-full focus-visible:ring-2 focus-visible:outline-none', className)\n          : cn('w-full', className)\n      }\n    >\n      {children}\n    </div>\n  ),\n)\nChartFrame.displayName = 'ChartFrame'\n\n/** ECharts canvas filling its parent frame. */\nexport function EChart({ option }: { option: any }) {\n  return (\n    <ReactECharts\n      echarts={echartsCore as any}\n      option={option}\n      notMerge\n      lazyUpdate\n      style={{ width: '100%', height: '100%' }}\n    />\n  )\n}\n\nexport const echartsCoreModule = echartsCore\n\n// Register every chart type the wrappers need once at module load.\nuse([\n  CanvasRenderer,\n  EChartsLineChart,\n  EChartsBarChart,\n  EChartsPieChart,\n  EChartsScatterChart,\n  EChartsEffectScatter,\n  EChartsPictorialBar,\n  EChartsRadarChart,\n  EChartsGaugeChart,\n  EChartsHeatmap,\n  EChartsTreemapChart,\n  EChartsFunnelChart,\n  EChartsBoxplotChart,\n  EChartsCandlestickChart,\n  EChartsCustomChart,\n  EChartsChordChart,\n  EChartsMapChart,\n  EChartsGraphChart,\n  EChartsParallelChart,\n  EChartsSankeyChart,\n  EChartsSunburstChart,\n  ThemeRiverChart,\n  EChartsTreeChart,\n  GridComponent,\n  PolarComponent,\n  TooltipComponent,\n  LegendComponent,\n  MarkLineComponent,\n  MarkAreaComponent,\n  MarkPointComponent,\n  GraphicComponent,\n  RadarComponent,\n  VisualMapComponent,\n  CalendarComponent,\n  DataZoomComponent,\n  ParallelComponent,\n  SingleAxisComponent,\n  TitleComponent,\n])\n",
      "type": "registry:ui",
      "target": "~/components/ui/charts/shared.tsx"
    }
  ],
  "dependencies": [
    "echarts",
    "echarts-for-react"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "React mirror of @uipkge/dumbbell-chart — see the Vue registry item for the canonical description.",
  "categories": [
    "chart"
  ]
}