{
  "$schema": "/schemas/registry-item.json",
  "name": "interactive-chart",
  "type": "registry:ui",
  "title": "Interactive Chart",
  "description": "Animated SVG charts with tooltips, legends, scale controls and accessible data. See /docs/charts.",
  "categories": [
    "charts"
  ],
  "dependencies": [
    "echarts@5.6.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/monochrome/interactive-chart.tsx",
      "type": "registry:ui",
      "target": "components/ui/interactive-chart.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { ECharts, EChartsOption } from \"echarts\";\n\nexport type InteractiveChartProps = {\n  title: string;\n  description?: string;\n  option: EChartsOption;\n  /** A semantic table or equivalent textual data, available without JavaScript. */\n  table: ReactNode;\n  height?: number;\n  animate?: boolean;\n  className?: string;\n  /** Load an optional engine extension on the client, before initialization. */\n  loadExtension?: () => Promise<unknown>;\n};\n\nexport function InteractiveChart({ title, description, option, table, height = 360, animate = true, className = \"\", loadExtension }: InteractiveChartProps) {\n  const host = useRef<HTMLDivElement>(null);\n  const instance = useRef<ECharts | null>(null);\n  const latest = useRef(option);\n  latest.current = option;\n  const [status, setStatus] = useState(\"Chart loads when it enters view. Data is available below.\");\n  const [attempt, setAttempt] = useState(0);\n\n  useEffect(() => {\n    const element = host.current;\n    if (!element) return;\n    let cancelled = false;\n    let started = false;\n    let resize: ResizeObserver | undefined;\n    const motion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const apply = () => {\n      const current = latest.current;\n      instance.current?.setOption({\n        ...current,\n        aria: { enabled: true, ...(current.aria ?? {}) },\n        animation: animate && !motion.matches,\n        animationDuration: animate && !motion.matches ? (current.animationDuration ?? 750) : 0,\n        animationDurationUpdate: animate && !motion.matches ? (current.animationDurationUpdate ?? 400) : 0,\n      }, { notMerge: true });\n    };\n    async function start() {\n      if (started || cancelled || !element) return;\n      started = true;\n      setStatus(\"Loading chart…\");\n      try {\n        const engine = await import(\"echarts\");\n        if (cancelled) return;\n        if (loadExtension) await loadExtension();\n        if (cancelled) return;\n        instance.current = engine.init(element, undefined, { renderer: loadExtension ? \"canvas\" : \"svg\" });\n        apply();\n        resize = new ResizeObserver(() => instance.current?.resize());\n        resize.observe(element);\n        setStatus(\"\");\n      } catch {\n        instance.current?.dispose();\n        instance.current = null;\n        setStatus(\"The chart could not load. WebGL is required for 3D. You can still read the data below.\");\n      }\n    }\n    const observer = new IntersectionObserver(entries => {\n      if (entries.some(entry => entry.isIntersecting)) { observer.disconnect(); void start(); }\n    }, { threshold: 0.05 });\n    observer.observe(element);\n    motion.addEventListener(\"change\", apply);\n    return () => {\n      cancelled = true;\n      observer.disconnect();\n      resize?.disconnect();\n      motion.removeEventListener(\"change\", apply);\n      instance.current?.dispose();\n      instance.current = null;\n    };\n  }, [animate, loadExtension, attempt]);\n\n  useEffect(() => {\n    if (!instance.current) return;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    instance.current.setOption({ ...option, aria: { enabled: true, ...option.aria }, animation: animate && !reduced,\n      animationDuration: reduced || !animate ? 0 : (option.animationDuration ?? 750),\n      animationDurationUpdate: reduced || !animate ? 0 : (option.animationDurationUpdate ?? 400),\n    }, { notMerge: true });\n  }, [option, animate]);\n\n  return <figure className={`co-grid co-gap-3 ${className}`} style={{ margin: 0, minWidth: 0 }}>\n    <figcaption><strong>{title}</strong>{description && <p>{description}</p>}</figcaption>\n    <div ref={host} role=\"img\" aria-label={`${title}. ${description ?? \"\"} See the data table for values.`} style={{ width: \"100%\", height: Math.max(220, height), minWidth: 0 }} />\n    {status && <p role=\"status\">{status} {status.startsWith(\"The chart\") && <button type=\"button\" onClick={() => setAttempt(value => value + 1)}>Retry chart</button>}</p>}\n    <details><summary>View accessible data</summary><div style={{ overflowX: \"auto\", maxWidth: \"100%\" }}>{table}</div></details>\n  </figure>;\n}\n"
    },
    {
      "path": "registry/monochrome/chart-recipes.ts",
      "type": "registry:ui",
      "target": "components/ui/chart-recipes.ts",
      "content": "import type { EChartsOption } from \"echarts\";\n\n// Recipe data for co-* InteractiveChart containers; no browser runtime here.\nexport const chartTypes = [\"Bar\", \"Horizontal bar\", \"Grouped bar\", \"Stacked bar\", \"Waterfall\", \"Line\", \"Smooth line\", \"Step line\", \"Area\", \"Stacked area\", \"Mixed\", \"Scatter\", \"Bubble\", \"Pie\", \"Doughnut\", \"Rose\", \"Radar\", \"Polar bar\", \"Heatmap\", \"Calendar\", \"Treemap\", \"Sunburst\", \"Tree\", \"Sankey\", \"Funnel\", \"Gauge\", \"Boxplot\", \"Candlestick\", \"Graph\", \"Parallel\", \"Theme river\", \"3D bar\", \"3D scatter\", \"3D line\", \"3D surface\"] as const;\nexport type ChartType = typeof chartTypes[number];\nexport type RecipeSettings = { legend: boolean; title: boolean; subtitle: boolean; tooltip: boolean; grid: boolean; scale: \"value\" | \"log\"; scriptable: boolean; duration: number; zoom: boolean };\nexport const defaultSettings: RecipeSettings = { legend: true, title: true, subtitle: true, tooltip: true, grid: true, scale: \"value\", scriptable: false, duration: 750, zoom: false };\nconst labels = [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"];\nconst values = [24, 38, 31, 58, 46, 70, 62];\nconst colors = [\"#e04419\", \"#2563eb\", \"#137c66\", \"#8556b5\", \"#b37212\", \"#555e6f\", \"#c33770\"];\nexport function isCartesian(type: ChartType) { return [\"Bar\", \"Horizontal bar\", \"Grouped bar\", \"Stacked bar\", \"Waterfall\", \"Line\", \"Smooth line\", \"Step line\", \"Area\", \"Stacked area\", \"Mixed\", \"Scatter\", \"Bubble\"].includes(type); }\nexport function chartRecipe(type: ChartType, settings: RecipeSettings = defaultSettings): { option: EChartsOption; rows: (string | number)[][]; headers: string[]; note: string } {\n  let rows: (string | number)[][] = labels.map((label, i) => [label, values[i]]);\n  let headers = [\"Day\", \"Value\"];\n  let note = \"Illustrative data. Hover or tap a mark for values; click legend items to filter series.\";\n  const option: EChartsOption = {\n    color: colors, backgroundColor: \"#fff\", animationDuration: settings.duration, animationDurationUpdate: settings.duration,\n    title: { show: settings.title, text: `${type} chart`, subtext: settings.subtitle ? \"Illustrative data · Coordiation Charts\" : \"\", left: 12, textStyle: { fontSize: 18, color: \"#151515\" }, subtextStyle: { color: \"#626262\" } },\n    tooltip: { show: settings.tooltip, trigger: \"item\", renderMode: \"richText\", confine: true },\n    legend: { show: settings.legend, bottom: 0, type: \"scroll\" },\n    grid: { top: 85, left: 18, right: 22, bottom: settings.zoom ? 95 : 55, containLabel: true },\n    aria: { enabled: true, decal: { show: true } },\n  };\n  if (isCartesian(type)) {\n    const horizontal = type === \"Horizontal bar\";\n    const numeric = type === \"Scatter\" || type === \"Bubble\";\n    const axis = { type: settings.scale, splitLine: { show: settings.grid }, min: settings.scale === \"log\" ? 1 : undefined } as const;\n    option.xAxis = horizontal ? axis : numeric ? { type: \"value\", splitLine: { show: settings.grid } } : { type: \"category\", data: labels };\n    option.yAxis = horizontal ? { type: \"category\", data: labels } : axis;\n    const line = [\"Line\", \"Smooth line\", \"Step line\", \"Area\", \"Stacked area\"].includes(type);\n    const base: any = { name: \"Current\", type: numeric ? \"scatter\" : line ? \"line\" : \"bar\", data: values, barMaxWidth: 48, emphasis: { focus: \"series\" } };\n    if (line) { base.smooth = type === \"Smooth line\"; base.symbolSize = 9; if (type === \"Step line\") base.step = \"middle\"; if (type.includes(\"area\") || type === \"Area\") base.areaStyle = { opacity: 0.2 }; }\n    if (settings.scriptable) base.itemStyle = { color: (point: { value: number | number[] }) => Number(Array.isArray(point.value) ? point.value[1] : point.value) >= 50 ? \"#137c66\" : \"#e04419\" };\n    if (numeric) { base.data = values.map((value, i) => [i + 1, value, 8 + i * 3]); base.symbolSize = type === \"Bubble\" ? (value: number[]) => value[2] : 12; rows = base.data; headers = [\"X\", \"Y\", \"Size\"]; }\n    option.series = [base];\n    if ([\"Grouped bar\", \"Stacked bar\", \"Stacked area\", \"Mixed\"].includes(type)) {\n      const second = values.map(value => Math.round(value * 0.65));\n      const stacked = type.startsWith(\"Stacked\");\n      base.stack = stacked ? \"total\" : undefined;\n      option.series = [base, { name: \"Previous\", type: type === \"Mixed\" ? \"line\" : base.type, data: second, stack: base.stack, areaStyle: type === \"Stacked area\" ? { opacity: 0.2 } : undefined }];\n      rows = labels.map((label, i) => [label, values[i], second[i]]); headers = [\"Day\", \"Current\", \"Previous\"];\n    }\n    if (type === \"Waterfall\") {\n      const changes = [24, 14, -7, 27, -12, 24, -8]; let total = 0;\n      const offsets = changes.map(change => { const before = total; total += change; return Math.min(before, total); });\n      option.series = [{ name: \"Offset\", type: \"bar\", stack: \"flow\", silent: true, itemStyle: { color: \"transparent\" }, tooltip: { show: false }, data: offsets }, { name: \"Change magnitude\", type: \"bar\", stack: \"flow\", data: changes.map((change) => ({ value: Math.abs(change), itemStyle: { color: change < 0 ? \"#2563eb\" : colors[0] } })) }];\n      rows = labels.map((label, i) => [label, changes[i], values[i]]); headers = [\"Day\", \"Signed change\", \"Running total\"]; note = \"Waterfall bar height shows change magnitude; blue indicates decreases. The table includes signed changes and totals.\";\n    }\n    if (settings.zoom) option.dataZoom = [{ type: \"inside\" }, { type: \"slider\", bottom: 30 }];\n  } else if ([\"Pie\", \"Doughnut\", \"Rose\"].includes(type)) {\n    option.series = [{ name: \"Share\", type: \"pie\", radius: type === \"Doughnut\" ? [\"38%\", \"65%\"] : \"65%\", center: [\"50%\", \"53%\"], roseType: type === \"Rose\" ? \"area\" : undefined, label: { show: false }, data: labels.map((name, i) => ({ name, value: values[i] })) }];\n  } else if (type === \"Radar\") {\n    option.radar = { indicator: labels.map(name => ({ name, max: 100 })), radius: \"60%\", center: [\"50%\", \"55%\"] };\n    option.series = [{ type: \"radar\", data: [{ name: \"Current\", value: values, areaStyle: { opacity: 0.15 } }] }];\n  } else if (type === \"Polar bar\") {\n    option.polar = { radius: [20, \"65%\"], center: [\"50%\", \"55%\"] }; option.angleAxis = { type: \"category\", data: labels }; option.radiusAxis = {};\n    option.series = [{ type: \"bar\", coordinateSystem: \"polar\", name: \"Current\", data: values }];\n  } else if (type === \"Heatmap\") {\n    rows = labels.flatMap((label, x) => [\"AM\", \"PM\", \"Night\"].map((period, y) => [label, period, (x * 11 + y * 17) % 70])); headers = [\"Day\", \"Period\", \"Value\"];\n    option.xAxis = { type: \"category\", data: labels }; option.yAxis = { type: \"category\", data: [\"AM\", \"PM\", \"Night\"] };\n    option.visualMap = { min: 0, max: 70, calculable: true, orient: \"horizontal\", bottom: 0, left: \"center\", inRange: { color: [\"#fff1e9\", \"#d63b12\"] } };\n    option.series = [{ type: \"heatmap\", data: rows.map((row, i) => [Math.floor(i / 3), i % 3, row[2]]) }];\n  } else if (type === \"Calendar\") {\n    rows = Array.from({ length: 30 }, (_, i) => [`2026-06-${String(i + 1).padStart(2, \"0\")}`, (i * 13) % 80]); headers = [\"Date\", \"Value\"];\n    option.calendar = { range: \"2026-06\", top: 95, left: 45, right: 25, cellSize: [\"auto\", 32], yearLabel: { show: false } };\n    option.visualMap = { min: 0, max: 80, orient: \"horizontal\", bottom: 15, left: \"center\", inRange: { color: [\"#fff1e9\", \"#d63b12\"] } };\n    option.series = [{ type: \"heatmap\", coordinateSystem: \"calendar\", data: rows as any }];\n  } else if ([\"Treemap\", \"Sunburst\", \"Tree\"].includes(type)) {\n    const data = [{ name: \"Design\", children: [{ name: \"UI\", value: 32 }, { name: \"UX\", value: 24 }] }, { name: \"Code\", children: [{ name: \"Web\", value: 40 }, { name: \"API\", value: 28 }] }];\n    rows = [[\"Design\", \"UI\", 32], [\"Design\", \"UX\", 24], [\"Code\", \"Web\", 40], [\"Code\", \"API\", 28]]; headers = [\"Parent\", \"Leaf\", \"Value\"];\n    option.series = [{ type: type.toLowerCase(), data: type === \"Tree\" ? [{ name: \"Studio\", children: data }] : data, top: 85, bottom: 40, left: 45, right: 55, roam: false, label: { color: \"#111\" }, ...(type === \"Treemap\" ? { breadcrumb: { show: false }, nodeClick: false } : {}) } as any];\n  } else if (type === \"Sankey\" || type === \"Graph\") {\n    const links = [{ source: \"Visit\", target: \"Trial\", value: 60 }, { source: \"Trial\", target: \"Paid\", value: 35 }, { source: \"Visit\", target: \"Exit\", value: 40 }];\n    option.series = [{ type: type.toLowerCase(), layout: type === \"Graph\" ? \"circular\" : undefined, top: 95, bottom: 50, left: 45, right: 55, data: [\"Visit\", \"Trial\", \"Paid\", \"Exit\"].map(name => ({ name, symbolSize: 45 })), links, label: { show: true }, lineStyle: { color: \"source\", opacity: 0.5 } } as any];\n    rows = links.map(link => [link.source, link.target, link.value]); headers = [\"Source\", \"Target\", \"Value\"];\n  } else if (type === \"Funnel\") {\n    option.series = [{ type: \"funnel\", top: 85, bottom: 45, left: \"15%\", width: \"70%\", label: { position: \"inside\" }, data: [100, 75, 48, 30].map((value, i) => ({ name: [\"Visits\", \"Trials\", \"Active\", \"Paid\"][i], value })) }]; rows = [[\"Visits\", 100], [\"Trials\", 75], [\"Active\", 48], [\"Paid\", 30]]; headers = [\"Stage\", \"Value\"];\n  } else if (type === \"Gauge\") {\n    option.series = [{ type: \"gauge\", center: [\"50%\", \"58%\"], radius: \"66%\", progress: { show: true }, detail: { valueAnimation: true, formatter: \"{value}%\", fontSize: 24 }, data: [{ value: 72, name: \"Completion\" }] }]; rows = [[\"Completion (%)\", 72]];\n  } else if (type === \"Boxplot\" || type === \"Candlestick\") {\n    headers = type === \"Boxplot\" ? [\"Group\", \"Min\", \"Q1\", \"Median\", \"Q3\", \"Max\"] : [\"Day\", \"Open\", \"Close\", \"Low\", \"High\"];\n    const data = type === \"Boxplot\" ? [[10, 20, 30, 40, 60], [15, 24, 36, 48, 70], [12, 26, 34, 46, 62]] : [[24, 32, 20, 38], [32, 28, 25, 40], [28, 42, 24, 46]];\n    option.xAxis = { type: \"category\", data: labels.slice(0, 3) }; option.yAxis = { type: \"value\" }; option.series = [{ type: type.toLowerCase(), data } as any]; rows = data.map((row, i) => [labels[i], ...row]);\n  } else if (type === \"Parallel\") {\n    rows = [[20, 45, 70], [35, 65, 40], [60, 30, 80]]; headers = [\"Design\", \"Speed\", \"Reach\"];\n    option.parallel = { top: 90, bottom: 55, left: 45, right: 45 }; option.parallelAxis = headers.map((name, dim) => ({ dim, name, min: 0, max: 100 })); option.series = [{ type: \"parallel\", data: rows as number[][] }];\n  } else if (type === \"Theme river\") {\n    rows = Array.from({ length: 7 }, (_, i) => [`2026-06-0${i + 1}`, values[i], \"Design\"]).concat(Array.from({ length: 7 }, (_, i) => [`2026-06-0${i + 1}`, values[6 - i], \"Code\"])); headers = [\"Date\", \"Value\", \"Stream\"];\n    option.singleAxis = { type: \"time\", top: 95, bottom: 55 }; option.series = [{ type: \"themeRiver\", data: rows as any }];\n  } else if (type.startsWith(\"3D\")) {\n    rows = type === \"3D surface\" ? Array.from({ length: 17 }, (_, x) => Array.from({ length: 17 }, (_, y) => [(x - 8) / 2, (y - 8) / 2, Number((Math.sin(x / 3) * Math.cos(y / 3) * 3).toFixed(3))])).flat() : Array.from({ length: 25 }, (_, i) => [i % 5, Math.floor(i / 5), 5 + (i * 7) % 17]);\n    headers = [\"X\", \"Y\", \"Z\"]; note = \"Drag to rotate, scroll to zoom. WebGL required. Perspective is illustrative; use the data table for precise comparisons.\";\n    Object.assign(option, { xAxis3D: { type: \"value\" }, yAxis3D: { type: \"value\" }, zAxis3D: { type: \"value\" }, grid3D: { top: 60, height: \"80%\", boxWidth: 95, boxDepth: 75, boxHeight: 70, viewControl: { autoRotate: false, distance: 240 }, light: { main: { intensity: 1.2 }, ambient: { intensity: 0.5 } } }, series: [{ type: ({ \"3D bar\": \"bar3D\", \"3D scatter\": \"scatter3D\", \"3D line\": \"line3D\", \"3D surface\": \"surface\" } as Record<string, string>)[type], data: rows, shading: \"lambert\", symbolSize: 10, lineStyle: { width: 5 }, itemStyle: { opacity: 0.9 } }] });\n  }\n  return { option, rows, headers, note };\n}\n"
    }
  ],
  "docs": "Requires Coordiation CSS and React 18+. Install engine dependencies with npm install echarts@5.6.0. Guide and live preview: /docs/charts",
  "meta": {
    "framework": "Coordiation CSS",
    "prefix": "co-",
    "style": "monochrome",
    "status": "stable",
    "client": true,
    "export": "InteractiveChart",
    "accessibility": "Required semantic data fallback, lazy initialization, reduced-motion support, resize and unmount cleanup. Pointer tooltips are supplemental, not the only way to read data."
  }
}
