UIPackage

Theme River

React chart
Edit on GitHub

Theme-river (streamgraph) wrapper around Apache ECharts. Stacked areas centred on a baseline along a time axis — good for topic-volume drift over time.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/charts.json

Or with the named registry: npx shadcn@latest add @uipkge-react/charts

Examples

Props

Name Type / Values Default Required
height number | string required
className string optional
focusable

wrappers (boxplot, candlestick, graph, parallel, sankey, tree, theme-river) ship a bare `w-full` frame -- pass `false` for those.

boolean true optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

TreemapNode
interface TreemapNode {
  name: string
  value?: number
  children?: TreemapNode[]
}
BoxRow
interface BoxRow {
  category: string
  /** [min, Q1, median, Q3, max] */
  values: [number, number, number, number, number]
}
Candle
interface Candle {
  date: string
  open: number
  close: number
  low: number
  high: number
}
GraphNode
interface GraphNode {
  name: string
  /** Optional category index (paints with chart-N colour). */
  category?: number
  /** Optional fixed marker size. Defaults to 28. */
  symbolSize?: number
}
GraphLink
interface GraphLink {
  source: string
  target: string
  /** Optional edge value (shows up in the tooltip + sizes the line on weighted layouts). */
  value?: number
}
ParallelAxis
interface ParallelAxis {
  name: string
  /** Set explicitly for fixed scales, otherwise computed from data. */
  min?: number
  max?: number
}
ParallelRow
interface ParallelRow {
  /** One value per axis, in the same order as `axes`. */
  values: number[]
  /** Optional name shown in the tooltip. */
  name?: string
  /** Optional series grouping (index → chart-N colour). */
  group?: number
}
SankeyLink
interface SankeyLink {
  source: string
  target: string
  value: number
}
SunNode
interface SunNode {
  name: string
  value?: number
  children?: SunNode[]
}
TreeNode
interface TreeNode {
  name: string
  value?: number
  children?: TreeNode[]
  /** Collapse this branch on initial render. */
  collapsed?: boolean
}
GaugeSegment
interface GaugeSegment {
  /** Relative size of the segment. Segments are normalised by their sum. */
  value: number
  /** Optional override; defaults to chart-1..N from the registry palette. */
  color?: string
  /** Optional label, surfaced via the `children` for consumers that want to
   *  render their own legend. */
  label?: string
}
FunnelStage
interface FunnelStage {
  name: string
  value: number
  /** Optional override; defaults to chart-1..N from the registry palette. */
  color?: string
}
ChartTheme
interface ChartTheme {
  colors: string[]
  textColor: string
  axisColor: string
  splitLineColor: string
  tooltipBg: string
  tooltipBorder: string
  tooltipText: string
}

Dependencies

Files (3)

  • components/ui/charts/Charts.tsx 68.4 kB
    'use client'
    
    import * as React from 'react'
    import * as echartsCore from 'echarts/core'
    import { use } from 'echarts/core'
    import { CanvasRenderer } from 'echarts/renderers'
    import {
      LineChart as EChartsLineChart,
      BarChart as EChartsBarChart,
      PieChart as EChartsPieChart,
      ScatterChart as EChartsScatterChart,
      RadarChart as EChartsRadarChart,
      GaugeChart as EChartsGaugeChart,
      HeatmapChart as EChartsHeatmap,
      TreemapChart as EChartsTreemapChart,
      FunnelChart as EChartsFunnelChart,
      BoxplotChart as EChartsBoxplotChart,
      CandlestickChart as EChartsCandlestickChart,
      GraphChart as EChartsGraphChart,
      ParallelChart as EChartsParallelChart,
      SankeyChart as EChartsSankeyChart,
      SunburstChart as EChartsSunburstChart,
      ThemeRiverChart,
      TreeChart as EChartsTreeChart,
    } from 'echarts/charts'
    import {
      GridComponent,
      TooltipComponent,
      LegendComponent,
      RadarComponent,
      VisualMapComponent,
      CalendarComponent,
      DataZoomComponent,
      ParallelComponent,
      SingleAxisComponent,
    } from 'echarts/components'
    import ReactECharts from 'echarts-for-react/lib/core'
    import { cn } from '@/lib/utils'
    import {
      useChartTheme,
      toRgba,
      mergeOptionBlock,
      gaugeThresholds,
    } from './useChartTheme'
    
    // ─────────────────────────────────────────────────────────────────────────
    // Shared frame
    // ─────────────────────────────────────────────────────────────────────────
    
    function heightToStyle(height: number | string): string {
      return /^\d+$/.test(String(height)) ? `${height}px` : String(height)
    }
    
    interface ChartFrameProps {
      height: number | string
      className?: string
      /** Apply the accessible role/tabindex/focus-ring chrome. Some chart
       *  wrappers (boxplot, candlestick, graph, parallel, sankey, tree,
       *  theme-river) ship a bare `w-full` frame -- pass `false` for those. */
      focusable?: boolean
    }
    
    /** Shared `<div>` wrapper around the ECharts canvas. forwardRef so callers
     *  taking a `className` can also grab the container node. */
    const ChartFrame = React.forwardRef<HTMLDivElement, ChartFrameProps & { children: React.ReactNode }>(
      ({ height, className, focusable = true, children }, ref) => (
        <div
          ref={ref}
          role={focusable ? 'img' : undefined}
          tabIndex={focusable ? 0 : undefined}
          style={{ height: heightToStyle(height) }}
          className={
            focusable
              ? cn('focus-visible:ring-ring w-full focus-visible:ring-2 focus-visible:outline-none', className)
              : cn('w-full', className)
          }
        >
          {children}
        </div>
      ),
    )
    ChartFrame.displayName = 'ChartFrame'
    
    /** ECharts canvas filling its parent frame. `notMerge` keeps option swaps
     *  clean (the Vue wrappers recompute the full option every render).
     *  `echarts-for-react/lib/core` is the tree-shakeable build: it renders
     *  whatever the passed `echarts` core module has registered via `use()` —
     *  we register every chart type + component the wrappers need at module
     *  load below. ReactECharts auto-resizes on container resize internally. */
    function EChart({ option }: { option: any }) {
      return (
        <ReactECharts
          echarts={echartsCore as any}
          option={option}
          notMerge
          lazyUpdate
          style={{ width: '100%', height: '100%' }}
        />
      )
    }
    
    // `echarts-for-react/lib/core` expects an `echarts` instance with the chart
    // types registered. We register everything the wrappers need once at module
    // load, then hand ReactECharts the core module via the `echarts` prop.
    use([
      CanvasRenderer,
      EChartsLineChart,
      EChartsBarChart,
      EChartsPieChart,
      EChartsScatterChart,
      EChartsRadarChart,
      EChartsGaugeChart,
      EChartsHeatmap,
      EChartsTreemapChart,
      EChartsFunnelChart,
      EChartsBoxplotChart,
      EChartsCandlestickChart,
      EChartsGraphChart,
      EChartsParallelChart,
      EChartsSankeyChart,
      EChartsSunburstChart,
      ThemeRiverChart,
      EChartsTreeChart,
      GridComponent,
      TooltipComponent,
      LegendComponent,
      RadarComponent,
      VisualMapComponent,
      CalendarComponent,
      DataZoomComponent,
      ParallelComponent,
      SingleAxisComponent,
    ])
    
    // ─────────────────────────────────────────────────────────────────────────
    // AreaChart
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface AreaChartProps {
      data: Record<string, any>[]
      xField?: string
      yField?: string | string[]
      height?: number | string
      option?: any
      className?: string
    }
    
    export const AreaChart = React.forwardRef<HTMLDivElement, AreaChartProps>(
      ({ data, xField = 'x', yField = 'y', height = 300, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const fields = Array.isArray(yField) ? yField : [yField]
          const xData = data.map((d) => d[xField])
    
          const series = fields.map((field, i) => ({
            name: field,
            type: 'line',
            smooth: true,
            symbol: 'none',
            areaStyle: { opacity: 0.15 },
            lineStyle: { width: 2 },
            itemStyle: { color: theme.colors[i % theme.colors.length] },
            data: data.map((d) => d[field]),
          }))
    
          const userOption: any = option ?? {}
          const {
            series: userSeries,
            xAxis: userXAxis,
            yAxis: userYAxis,
            grid: userGrid,
            tooltip: userTooltip,
            legend: userLegend,
            ...userRest
          } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          const baseLegend: any =
            fields.length > 1
              ? {
                  bottom: 0,
                  icon: 'circle',
                  itemWidth: 8,
                  itemHeight: 8,
                  textStyle: { fontSize: 11, color: theme.textColor },
                }
              : { show: false }
    
          return {
            color: theme.colors,
            grid: mergeOptionBlock(
              { left: 16, right: 16, top: 24, bottom: fields.length > 1 ? 32 : 24, containLabel: true },
              userGrid,
            ),
            tooltip: mergeOptionBlock(
              {
                trigger: 'axis',
                backgroundColor: theme.tooltipBg,
                borderColor: theme.tooltipBorder,
                textStyle: { color: theme.tooltipText, fontSize: 12 },
              },
              userTooltip,
            ),
            legend: userLegend?.show === false ? undefined : mergeOptionBlock(baseLegend, userLegend),
            xAxis: mergeOptionBlock(
              {
                type: 'category',
                data: xData,
                axisLine: { lineStyle: { color: theme.axisColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisTick: { show: false },
              },
              userXAxis,
            ),
            yAxis: mergeOptionBlock(
              {
                type: 'value',
                splitLine: { lineStyle: { color: theme.splitLineColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisLine: { show: false },
                axisTick: { show: false },
              },
              userYAxis,
            ),
            series: mergedSeries,
            ...userRest,
          }
        }, [data, xField, yField, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    AreaChart.displayName = 'AreaChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // BarChart
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface BarChartProps {
      data: Record<string, any>[]
      xField?: string
      yField?: string | string[]
      height?: number | string
      option?: any
      className?: string
    }
    
    export const BarChart = React.forwardRef<HTMLDivElement, BarChartProps>(
      ({ data, xField = 'x', yField = 'y', height = 300, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const fields = Array.isArray(yField) ? yField : [yField]
          const xData = data.map((d) => d[xField])
    
          const series = fields.map((field, i) => ({
            name: field,
            type: 'bar',
            barMaxWidth: 32,
            itemStyle: { color: theme.colors[i % theme.colors.length] },
            data: data.map((d) => d[field]),
          }))
    
          const userOption: any = option ?? {}
          const {
            series: userSeries,
            xAxis: userXAxis,
            yAxis: userYAxis,
            grid: userGrid,
            tooltip: userTooltip,
            legend: userLegend,
            ...userRest
          } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          const baseLegend: any =
            fields.length > 1
              ? {
                  bottom: 0,
                  icon: 'circle',
                  itemWidth: 8,
                  itemHeight: 8,
                  textStyle: { fontSize: 11, color: theme.textColor },
                }
              : { show: false }
    
          return {
            color: theme.colors,
            grid: mergeOptionBlock(
              { left: 16, right: 16, top: 24, bottom: fields.length > 1 ? 32 : 24, containLabel: true },
              userGrid,
            ),
            tooltip: mergeOptionBlock(
              {
                trigger: 'axis',
                backgroundColor: theme.tooltipBg,
                borderColor: theme.tooltipBorder,
                textStyle: { color: theme.tooltipText, fontSize: 12 },
              },
              userTooltip,
            ),
            legend: userLegend?.show === false ? undefined : mergeOptionBlock(baseLegend, userLegend),
            xAxis: mergeOptionBlock(
              {
                type: 'category',
                data: xData,
                axisLine: { lineStyle: { color: theme.axisColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisTick: { show: false },
              },
              userXAxis,
            ),
            yAxis: mergeOptionBlock(
              {
                type: 'value',
                splitLine: { lineStyle: { color: theme.splitLineColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisLine: { show: false },
                axisTick: { show: false },
              },
              userYAxis,
            ),
            series: mergedSeries,
            ...userRest,
          }
        }, [data, xField, yField, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    BarChart.displayName = 'BarChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // LineChart
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface LineChartProps {
      data: Record<string, any>[]
      xField?: string
      yField?: string | string[]
      height?: number | string
      option?: any
      className?: string
    }
    
    export const LineChart = React.forwardRef<HTMLDivElement, LineChartProps>(
      ({ data, xField = 'x', yField = 'y', height = 300, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const fields = Array.isArray(yField) ? yField : [yField]
          const xData = data.map((d) => d[xField])
    
          const series = fields.map((field, i) => ({
            name: field,
            type: 'line',
            smooth: true,
            symbol: 'circle',
            symbolSize: 6,
            lineStyle: { width: 2 },
            itemStyle: { color: theme.colors[i % theme.colors.length] },
            data: data.map((d) => d[field]),
          }))
    
          const userOption: any = option ?? {}
          const {
            series: userSeries,
            xAxis: userXAxis,
            yAxis: userYAxis,
            grid: userGrid,
            tooltip: userTooltip,
            legend: userLegend,
            ...userRest
          } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          const baseLegend =
            fields.length > 1
              ? {
                  bottom: 0,
                  icon: 'circle',
                  itemWidth: 8,
                  itemHeight: 8,
                  textStyle: { fontSize: 11, color: theme.textColor },
                }
              : undefined
    
          return {
            color: theme.colors,
            grid: mergeOptionBlock(
              { left: 16, right: 16, top: 24, bottom: fields.length > 1 ? 32 : 24, containLabel: true },
              userGrid,
            ),
            tooltip: mergeOptionBlock(
              {
                trigger: 'axis',
                backgroundColor: theme.tooltipBg,
                borderColor: theme.tooltipBorder,
                textStyle: { color: theme.tooltipText, fontSize: 12 },
              },
              userTooltip,
            ),
            legend: userLegend?.show === false ? undefined : mergeOptionBlock(baseLegend ?? {}, userLegend),
            xAxis: mergeOptionBlock(
              {
                type: 'category',
                data: xData,
                axisLine: { lineStyle: { color: theme.axisColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisTick: { show: false },
              },
              userXAxis,
            ),
            yAxis: mergeOptionBlock(
              {
                type: 'value',
                splitLine: { lineStyle: { color: theme.splitLineColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisLine: { show: false },
                axisTick: { show: false },
              },
              userYAxis,
            ),
            series: mergedSeries,
            ...userRest,
          }
        }, [data, xField, yField, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    LineChart.displayName = 'LineChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // Sparkline
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface SparklineProps {
      data: number[]
      color?: string
      height?: number | string
      option?: any
      className?: string
    }
    
    export const Sparkline = React.forwardRef<HTMLDivElement, SparklineProps>(
      ({ data, color: colorProp, height = 40, option, className }, ref) => {
        const theme = useChartTheme()
        const color = colorProp ?? theme.colors[1]
    
        const mergedOption = React.useMemo(() => {
          const series = [
            {
              type: 'line',
              smooth: true,
              // Show a dot only at the last datapoint so the eye can find the
              // current value; intermediate dots clutter at sparkline density.
              showSymbol: false,
              showAllSymbol: false,
              symbol: 'circle',
              symbolSize: 5,
              endLabel: { show: false },
              lineStyle: { width: 1.75, color },
              itemStyle: { color, borderColor: color, borderWidth: 0 },
              data: data.map((v, i) => ({
                value: v,
                symbol: i === data.length - 1 ? 'circle' : 'none',
                symbolSize: i === data.length - 1 ? 5 : 0,
              })),
              areaStyle: {
                color: {
                  type: 'linear',
                  x: 0,
                  y: 0,
                  x2: 0,
                  y2: 1,
                  colorStops: [
                    { offset: 0, color: toRgba(color!, 0.18) },
                    { offset: 1, color: toRgba(color!, 0) },
                  ],
                },
              },
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            grid: { left: 0, right: 0, top: 2, bottom: 2 },
            xAxis: { type: 'category', show: false, data: data.map((_, i) => i) },
            yAxis: { type: 'value', show: false, min: (value: any) => value.min * 0.9 },
            tooltip: { show: false },
            series: mergedSeries,
            ...userRest,
          }
        }, [data, color, option])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    Sparkline.displayName = 'Sparkline'
    
    // ─────────────────────────────────────────────────────────────────────────
    // PieChart
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface PieChartProps {
      data: Record<string, any>[]
      nameField?: string
      valueField?: string
      height?: number | string
      donut?: boolean
      option?: any
      className?: string
    }
    
    export const PieChart = React.forwardRef<HTMLDivElement, PieChartProps>(
      ({ data, nameField = 'name', valueField = 'value', height = 300, donut = false, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const chartData = data.map((d) => ({
            name: d[nameField],
            value: d[valueField],
          }))
    
          const series = [
            {
              type: 'pie',
              radius: donut ? ['45%', '70%'] : '65%',
              center: ['50%', '45%'],
              itemStyle: { borderWidth: 0 },
              label: { show: false },
              data: chartData,
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              trigger: 'item',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
              formatter: '{b}: {c} ({d}%)',
            },
            legend: {
              bottom: 0,
              icon: 'circle',
              itemWidth: 8,
              itemHeight: 8,
              textStyle: { fontSize: 11 },
            },
            series: mergedSeries,
            ...userRest,
          }
        }, [data, nameField, valueField, donut, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    PieChart.displayName = 'PieChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // ScatterChart
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface ScatterChartProps {
      data: Record<string, any>[]
      xField?: string
      yField?: string
      sizeField?: string
      categoryField?: string
      height?: number | string
      option?: any
      className?: string
    }
    
    export const ScatterChart = React.forwardRef<HTMLDivElement, ScatterChartProps>(
      ({ data, xField = 'x', yField = 'y', sizeField, categoryField, height = 300, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const categories = categoryField ? [...new Set(data.map((d) => d[categoryField]))] : ['default']
    
          const series = categories.map((cat, i) => ({
            name: cat,
            type: 'scatter',
            symbolSize: (val: any[]) => (sizeField ? Math.sqrt(val[2]) * 3 + 4 : 10),
            itemStyle: { color: theme.colors[i % theme.colors.length] },
            data: categoryField
              ? data
                  .filter((d) => d[categoryField] === cat)
                  .map((d) => [d[xField], d[yField], sizeField ? d[sizeField] : 0])
              : data.map((d) => [d[xField], d[yField], sizeField ? d[sizeField] : 0]),
          }))
    
          const userOption: any = option ?? {}
          const {
            series: userSeries,
            xAxis: userXAxis,
            yAxis: userYAxis,
            grid: userGrid,
            tooltip: userTooltip,
            legend: userLegend,
            ...userRest
          } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          const baseLegend =
            categories.length > 1
              ? {
                  bottom: 0,
                  icon: 'circle',
                  itemWidth: 8,
                  itemHeight: 8,
                  textStyle: { fontSize: 11, color: theme.textColor },
                }
              : undefined
    
          return {
            color: theme.colors,
            grid: mergeOptionBlock(
              { left: 16, right: 16, top: 24, bottom: categories.length > 1 ? 32 : 24, containLabel: true },
              userGrid,
            ),
            tooltip: mergeOptionBlock(
              {
                trigger: 'item',
                backgroundColor: theme.tooltipBg,
                borderColor: theme.tooltipBorder,
                textStyle: { color: theme.tooltipText, fontSize: 12 },
                formatter: (params: any) =>
                  `${params.seriesName}<br/>${xField}: ${params.value[0]}<br/>${yField}: ${params.value[1]}`,
              },
              userTooltip,
            ),
            legend: userLegend?.show === false ? undefined : mergeOptionBlock(baseLegend ?? {}, userLegend),
            xAxis: mergeOptionBlock(
              {
                type: 'value',
                splitLine: { lineStyle: { color: theme.splitLineColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisLine: { lineStyle: { color: theme.axisColor } },
                axisTick: { show: false },
                scale: true,
              },
              userXAxis,
            ),
            yAxis: mergeOptionBlock(
              {
                type: 'value',
                splitLine: { lineStyle: { color: theme.splitLineColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisLine: { show: false },
                axisTick: { show: false },
                scale: true,
              },
              userYAxis,
            ),
            series: mergedSeries,
            ...userRest,
          }
        }, [data, xField, yField, sizeField, categoryField, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    ScatterChart.displayName = 'ScatterChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // RadarChart
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface RadarChartProps {
      indicators: { name: string; max: number }[]
      data: { name: string; value: number[] }[]
      height?: number | string
      option?: any
      className?: string
    }
    
    export const RadarChart = React.forwardRef<HTMLDivElement, RadarChartProps>(
      ({ indicators, data, height = 300, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const series = [
            {
              type: 'radar',
              data: data.map((d, i) => ({
                ...d,
                itemStyle: { color: theme.colors[i % theme.colors.length] },
                areaStyle: { opacity: 0.15 },
                lineStyle: { width: 2 },
              })),
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              trigger: 'item',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            legend: {
              bottom: 0,
              icon: 'circle',
              itemWidth: 8,
              itemHeight: 8,
              textStyle: { fontSize: 11 },
            },
            radar: {
              indicator: indicators,
              radius: '60%',
              center: ['50%', '45%'],
              axisName: { fontSize: 11 },
              splitArea: { areaStyle: { color: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.05)'] } },
            },
            series: mergedSeries,
            ...userRest,
          }
        }, [indicators, data, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    RadarChart.displayName = 'RadarChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // GaugeChart
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface GaugeChartProps {
      value: number
      min?: number
      max?: number
      unit?: string
      label?: string
      height?: number | string
      /** Colour stops as [percentage, hex] pairs. Default: teal->amber->red,
       *  pulled from `gaugeThresholds` in useChartTheme so the safe-zone
       *  colour ties back to the dashboard palette. Pass your own to override. */
      thresholds?: [number, string][]
      option?: any
      className?: string
    }
    
    export const GaugeChart = React.forwardRef<HTMLDivElement, GaugeChartProps>(
      (
        { value, min = 0, max = 100, unit = '', label, height = 220, thresholds = gaugeThresholds, option, className },
        ref,
      ) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const series = [
            {
              type: 'gauge',
              min,
              max,
              center: ['50%', '60%'],
              radius: '85%',
              startAngle: 200,
              endAngle: -20,
              progress: { show: true, width: 14 },
              pointer: { show: true, length: '55%', width: 4 },
              axisLine: {
                lineStyle: {
                  width: 14,
                  color: thresholds.map(([stop, color]) => [stop, color] as [number, string]),
                },
              },
              axisTick: { distance: -22, length: 4, lineStyle: { color: theme.textColor, width: 1 } },
              splitLine: { distance: -26, length: 8, lineStyle: { color: theme.textColor, width: 2 } },
              axisLabel: { color: theme.textColor, fontSize: 10, distance: -34 },
              anchor: { show: false },
              title: {
                offsetCenter: [0, '88%'],
                color: theme.textColor,
                fontSize: 11,
                fontWeight: 500,
              },
              detail: {
                valueAnimation: true,
                formatter: `{value}${unit ? ' ' + unit : ''}`,
                color: theme.colors[0],
                fontSize: 28,
                fontWeight: 700,
                offsetCenter: [0, '40%'],
              },
              data: [{ value, name: label ?? '' }],
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            tooltip: {
              formatter: '{b}: {c}' + (unit ? ` ${unit}` : ''),
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            series: mergedSeries,
            ...userRest,
          }
        }, [value, min, max, unit, label, thresholds, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    GaugeChart.displayName = 'GaugeChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // Heatmap
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface HeatmapProps {
      data: [number, number, number][] // [xIndex, yIndex, value]
      xLabels: string[]
      yLabels: string[]
      height?: number | string
      min?: number
      max?: number
      option?: any
      className?: string
    }
    
    export const Heatmap = React.forwardRef<HTMLDivElement, HeatmapProps>(
      ({ data, xLabels, yLabels, height = 300, min, max, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const values = data.map((d) => d[2])
          const computedMin = min ?? Math.min(...values)
          const computedMax = max ?? Math.max(...values)
    
          return {
            grid: { left: 16, right: 16, top: 16, bottom: 16, containLabel: true },
            tooltip: {
              position: 'top',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
              formatter: (params: any) =>
                `${yLabels[params.value[1]]} / ${xLabels[params.value[0]]}: ${params.value[2]}`,
            },
            xAxis: {
              type: 'category',
              data: xLabels,
              splitArea: { show: true },
              axisLabel: { fontSize: 11 },
              axisTick: { show: false },
            },
            yAxis: {
              type: 'category',
              data: yLabels,
              splitArea: { show: true },
              axisLabel: { fontSize: 11 },
              axisTick: { show: false },
            },
            visualMap: {
              min: computedMin,
              max: computedMax,
              calculable: true,
              orient: 'horizontal',
              left: 'center',
              bottom: 0,
              itemWidth: 12,
              itemHeight: 80,
              inRange: {
                color: ['#e0f2fe', '#3b82f6', '#1e3a8a'],
              },
              textStyle: { fontSize: 10 },
            },
            series: (() => {
              const series = [
                {
                  type: 'heatmap',
                  data,
                  label: { show: false },
                  itemStyle: { borderRadius: 2, borderWidth: 0 },
                  emphasis: {
                    itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.2)' },
                  },
                },
              ]
              const userSeries = (option as any)?.series
              return Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
            })(),
            // visualMap / tooltip / axes overrides still spread on top -- strip
            // `series` so it doesn't clobber the merged result above.
            ...(() => {
              const { series: _, ...rest } = (option as any) ?? {}
              return rest
            })(),
          }
        }, [data, xLabels, yLabels, min, max, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    Heatmap.displayName = 'Heatmap'
    
    // ─────────────────────────────────────────────────────────────────────────
    // CalendarHeatmap
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface CalendarHeatmapProps {
      /** [date string YYYY-MM-DD, value] tuples. */
      data: [string, number][]
      range: string | [string, string]
      height?: number | string
      /** Cell colour ramp [from, to] - default: chart-1 from light to saturated. */
      colorRange?: [string, string]
      option?: any
      className?: string
    }
    
    export const CalendarHeatmap = React.forwardRef<HTMLDivElement, CalendarHeatmapProps>(
      ({ data, range, height = 200, colorRange = ['#fef3c7', '#d97706'], option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const maxValue = data.reduce((m, [, v]) => Math.max(m, v), 0) || 1
    
          return {
            color: theme.colors,
            tooltip: {
              position: 'top',
              formatter: (p: any) => `<strong>${p.value[0]}</strong><br>${p.value[1]} contributions`,
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            visualMap: {
              show: false,
              min: 0,
              max: maxValue,
              inRange: { color: colorRange },
            },
            calendar: {
              top: 24,
              left: 36,
              right: 12,
              cellSize: ['auto', 14],
              range,
              itemStyle: { color: theme.splitLineColor, borderWidth: 0 },
              splitLine: { show: false },
              dayLabel: {
                color: theme.textColor,
                fontSize: 10,
                firstDay: 1,
                nameMap: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],
              },
              monthLabel: { color: theme.textColor, fontSize: 10, fontWeight: 600 },
              yearLabel: { show: false },
            },
            series: (() => {
              const series = [{ type: 'heatmap', coordinateSystem: 'calendar', data }]
              const userSeries = (option as any)?.series
              return Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
            })(),
            // Strip `series` from the rest spread so the merge above isn't clobbered.
            ...(() => {
              const { series: _, ...rest } = (option as any) ?? {}
              return rest
            })(),
          }
        }, [data, range, colorRange, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    CalendarHeatmap.displayName = 'CalendarHeatmap'
    
    // ─────────────────────────────────────────────────────────────────────────
    // TreemapChart
    // ─────────────────────────────────────────────────────────────────────────
    
    interface TreemapNode {
      name: string
      value?: number
      children?: TreemapNode[]
    }
    
    export interface TreemapChartProps {
      data: TreemapNode[]
      height?: number | string
      /** Show breadcrumb at top when drilling into a sub-tree. Default false. */
      showBreadcrumb?: boolean
      option?: any
      className?: string
    }
    
    export const TreemapChart = React.forwardRef<HTMLDivElement, TreemapChartProps>(
      ({ data, height = 320, showBreadcrumb = false, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const series = [
            {
              type: 'treemap',
              left: 0,
              top: 0,
              right: 0,
              bottom: 0,
              width: 'auto',
              height: 'auto',
              roam: false,
              nodeClick: false,
              breadcrumb: { show: showBreadcrumb, height: 0 },
              label: {
                show: true,
                formatter: ({ name, value }: any) => (value ? `{b|${name}}\n{v|${value}}` : name),
                rich: {
                  b: { color: '#fff', fontSize: 11, fontWeight: 600, lineHeight: 14 },
                  v: { color: 'rgba(255,255,255,0.85)', fontSize: 10, fontWeight: 500, lineHeight: 12 },
                },
                overflow: 'truncate',
                ellipsis: '',
              },
              labelLayout: { hideOverlap: false },
              upperLabel: { show: false },
              itemStyle: { borderWidth: 0, gapWidth: 2 },
              colorSaturation: [0.45, 0.7],
              data,
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              formatter: (info: any) => {
                const parts = info.treePathInfo.map((n: any) => n.name).filter(Boolean)
                return `<strong>${parts.join(' / ')}</strong><br>${info.value?.toLocaleString?.() ?? info.value}`
              },
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            series: mergedSeries,
            ...userRest,
          }
        }, [data, showBreadcrumb, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    TreemapChart.displayName = 'TreemapChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // FunnelChart
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface FunnelChartProps {
      data: { name: string; value: number }[]
      height?: number | string
      showLabels?: boolean
      showLegend?: boolean
      /** ECharts option escape hatch -- merged on top of the computed option. */
      option?: any
      className?: string
    }
    
    export const FunnelChart = React.forwardRef<HTMLDivElement, FunnelChartProps>(
      ({ data, height = 300, showLabels = true, showLegend = false, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const series = [
            {
              type: 'funnel',
              left: '8%',
              right: '8%',
              top: 12,
              bottom: showLegend ? 32 : 12,
              sort: 'descending',
              minSize: '20%',
              maxSize: '100%',
              funnelAlign: 'center',
              gap: 2,
              label: {
                show: showLabels,
                position: 'inside',
                color: '#fff',
                fontSize: 11,
                fontWeight: 600,
              },
              labelLine: { length: 8, lineStyle: { width: 1, type: 'solid' } },
              itemStyle: { borderWidth: 0 },
              emphasis: { label: { fontSize: 13, fontWeight: 700 } },
              data,
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              trigger: 'item',
              formatter: '{b}: {c} ({d}%)',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            legend: showLegend
              ? {
                  bottom: 0,
                  icon: 'circle',
                  itemWidth: 8,
                  itemHeight: 8,
                  textStyle: { fontSize: 11, color: theme.textColor },
                }
              : undefined,
            series: mergedSeries,
            ...userRest,
          }
        }, [data, showLabels, showLegend, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    FunnelChart.displayName = 'FunnelChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // BoxplotChart
    // ─────────────────────────────────────────────────────────────────────────
    
    interface BoxRow {
      category: string
      /** [min, Q1, median, Q3, max] */
      values: [number, number, number, number, number]
    }
    
    export interface BoxplotChartProps {
      data: BoxRow[]
      /** Render horizontally (categories on y-axis). Default false. */
      horizontal?: boolean
      height?: number | string
      option?: any
      className?: string
    }
    
    export const BoxplotChart = React.forwardRef<HTMLDivElement, BoxplotChartProps>(
      ({ data, horizontal = false, height = 320, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const cats = data.map((d) => d.category)
          const values = data.map((d) => d.values)
    
          const valueAxis = {
            type: 'value' as const,
            scale: true,
            splitLine: { lineStyle: { color: theme.splitLineColor } },
            axisLabel: { color: theme.textColor, fontSize: 11 },
            axisLine: { lineStyle: { color: theme.axisColor } },
            axisTick: { show: false },
          }
          const catAxis = {
            type: 'category' as const,
            data: cats,
            axisLine: { lineStyle: { color: theme.axisColor } },
            axisLabel: { color: theme.textColor, fontSize: 11 },
            axisTick: { show: false },
          }
    
          const series = [
            {
              type: 'boxplot',
              data: values,
              itemStyle: { color: theme.colors[0], borderColor: theme.colors[1] },
            },
          ]
    
          const userOption: any = option ?? {}
          const {
            series: userSeries,
            xAxis: userXAxis,
            yAxis: userYAxis,
            grid: userGrid,
            tooltip: userTooltip,
            ...userRest
          } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            grid: mergeOptionBlock({ left: 16, right: 16, top: 24, bottom: 24, containLabel: true }, userGrid),
            tooltip: mergeOptionBlock(
              {
                trigger: 'item',
                backgroundColor: theme.tooltipBg,
                borderColor: theme.tooltipBorder,
                textStyle: { color: theme.tooltipText, fontSize: 12 },
              },
              userTooltip,
            ),
            xAxis: mergeOptionBlock(horizontal ? valueAxis : catAxis, userXAxis),
            yAxis: mergeOptionBlock(horizontal ? catAxis : valueAxis, userYAxis),
            series: mergedSeries,
            ...userRest,
          }
        }, [data, horizontal, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className} focusable={false}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    BoxplotChart.displayName = 'BoxplotChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // CandlestickChart
    // ─────────────────────────────────────────────────────────────────────────
    
    interface Candle {
      date: string
      open: number
      close: number
      low: number
      high: number
    }
    
    export interface CandlestickChartProps {
      data: Candle[]
      height?: number | string
      /** Show the bottom data-zoom slider. Default false. */
      zoom?: boolean
      option?: any
      className?: string
    }
    
    export const CandlestickChart = React.forwardRef<HTMLDivElement, CandlestickChartProps>(
      ({ data, height = 320, zoom = false, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          // ECharts candle data shape: [open, close, low, high].
          const candleData = data.map((c) => [c.open, c.close, c.low, c.high])
          const dates = data.map((c) => c.date)
    
          const series = [
            {
              type: 'candlestick',
              data: candleData,
              itemStyle: {
                // Bullish (close >= open) -> teal (chart-2). Bearish -> orange (chart-4).
                color: theme.colors[1],
                color0: theme.colors[3],
                borderColor: theme.colors[1],
                borderColor0: theme.colors[3],
              },
            },
          ]
    
          const userOption: any = option ?? {}
          const {
            series: userSeries,
            xAxis: userXAxis,
            yAxis: userYAxis,
            grid: userGrid,
            tooltip: userTooltip,
            ...userRest
          } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            grid: mergeOptionBlock(
              { left: 16, right: 16, top: 24, bottom: zoom ? 60 : 24, containLabel: true },
              userGrid,
            ),
            tooltip: mergeOptionBlock(
              {
                trigger: 'axis',
                axisPointer: { type: 'cross' },
                backgroundColor: theme.tooltipBg,
                borderColor: theme.tooltipBorder,
                textStyle: { color: theme.tooltipText, fontSize: 12 },
              },
              userTooltip,
            ),
            xAxis: mergeOptionBlock(
              {
                type: 'category',
                data: dates,
                axisLine: { lineStyle: { color: theme.axisColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisTick: { show: false },
              },
              userXAxis,
            ),
            yAxis: mergeOptionBlock(
              {
                type: 'value',
                scale: true,
                splitLine: { lineStyle: { color: theme.splitLineColor } },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisLine: { show: false },
                axisTick: { show: false },
              },
              userYAxis,
            ),
            dataZoom: zoom ? [{ type: 'slider', bottom: 8, height: 18 }, { type: 'inside' }] : undefined,
            series: mergedSeries,
            ...userRest,
          }
        }, [data, zoom, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className} focusable={false}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    CandlestickChart.displayName = 'CandlestickChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // GraphChart
    // ─────────────────────────────────────────────────────────────────────────
    
    interface GraphNode {
      name: string
      /** Optional category index (paints with chart-N colour). */
      category?: number
      /** Optional fixed marker size. Defaults to 28. */
      symbolSize?: number
    }
    interface GraphLink {
      source: string
      target: string
      /** Optional edge value (shows up in the tooltip + sizes the line on weighted layouts). */
      value?: number
    }
    
    export interface GraphChartProps {
      nodes: GraphNode[]
      links: GraphLink[]
      /** Optional category labels rendered in the legend. */
      categories?: string[]
      /** Layout engine. `force` is force-directed (default), `circular` arranges
       *  on a ring, `none` lets you place nodes manually via `x`/`y`. */
      layout?: 'force' | 'circular' | 'none'
      /** Allow click-and-drag pan + scroll zoom. Default false. */
      roam?: boolean
      /** Draw arrowheads on the target end. Default true. */
      directed?: boolean
      height?: number | string
      option?: any
      className?: string
    }
    
    export const GraphChart = React.forwardRef<HTMLDivElement, GraphChartProps>(
      ({ nodes, links, categories, layout = 'force', roam = false, directed = true, height = 380, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const series = [
            {
              type: 'graph',
              layout,
              roam,
              symbolSize: 28,
              label: { show: true, fontSize: 11, color: theme.textColor },
              edgeSymbol: directed ? (['none', 'arrow'] as [string, string]) : (['none', 'none'] as [string, string]),
              edgeSymbolSize: [0, 6],
              force: { repulsion: 220, edgeLength: 90 },
              lineStyle: { color: theme.axisColor, curveness: 0.15, width: 1 },
              emphasis: { focus: 'adjacency' as const, lineStyle: { width: 2 } },
              categories: categories?.map((name) => ({ name })),
              data: nodes.map((n) => ({
                ...n,
                itemStyle:
                  typeof n.category === 'number'
                    ? { color: theme.colors[n.category % theme.colors.length] }
                    : undefined,
              })),
              links,
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              trigger: 'item',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            legend: categories?.length
              ? {
                  bottom: 0,
                  icon: 'circle',
                  itemWidth: 8,
                  itemHeight: 8,
                  textStyle: { fontSize: 11, color: theme.textColor },
                }
              : undefined,
            series: mergedSeries,
            ...userRest,
          }
        }, [nodes, links, categories, layout, roam, directed, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className} focusable={false}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    GraphChart.displayName = 'GraphChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // ParallelChart
    // ─────────────────────────────────────────────────────────────────────────
    
    interface ParallelAxis {
      name: string
      /** Set explicitly for fixed scales, otherwise computed from data. */
      min?: number
      max?: number
    }
    interface ParallelRow {
      /** One value per axis, in the same order as `axes`. */
      values: number[]
      /** Optional name shown in the tooltip. */
      name?: string
      /** Optional series grouping (index → chart-N colour). */
      group?: number
    }
    
    export interface ParallelChartProps {
      axes: ParallelAxis[]
      data: ParallelRow[]
      /** Optional group labels (shown in legend). */
      groups?: string[]
      height?: number | string
      option?: any
      className?: string
    }
    
    export const ParallelChart = React.forwardRef<HTMLDivElement, ParallelChartProps>(
      ({ axes, data, groups, height = 360, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          // Build one series per group so the legend can toggle them.
          const groupList = groups ?? ['series']
          const series = groupList.map((name, gi) => ({
            name,
            type: 'parallel' as const,
            lineStyle: { width: 1, opacity: 0.6 },
            data: data
              .filter((r) => (typeof r.group === 'number' ? r.group === gi : gi === 0))
              .map((r) => ({ value: r.values, name: r.name })),
          }))
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              trigger: 'item',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            legend: groups?.length
              ? {
                  bottom: 0,
                  icon: 'circle',
                  itemWidth: 8,
                  itemHeight: 8,
                  textStyle: { fontSize: 11, color: theme.textColor },
                }
              : undefined,
            parallelAxis: axes.map((a, dim) => ({
              dim,
              name: a.name,
              min: a.min,
              max: a.max,
              nameTextStyle: { fontSize: 11, color: theme.textColor },
              axisLine: { lineStyle: { color: theme.axisColor } },
              axisLabel: { color: theme.textColor, fontSize: 11 },
            })),
            parallel: {
              left: 36,
              right: 24,
              top: 36,
              bottom: groups?.length ? 36 : 24,
              parallelAxisDefault: { axisLine: { lineStyle: { color: theme.axisColor } } },
            },
            series: mergedSeries,
            ...userRest,
          }
        }, [axes, data, groups, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className} focusable={false}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    ParallelChart.displayName = 'ParallelChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // SankeyChart
    // ─────────────────────────────────────────────────────────────────────────
    
    interface SankeyLink {
      source: string
      target: string
      value: number
    }
    
    export interface SankeyChartProps {
      /** Node names. If omitted, derived from the union of link sources + targets. */
      nodes?: string[]
      /** `{ source, target, value }` edges between nodes. */
      links: SankeyLink[]
      height?: number | string
      /** Curvature of the link ribbons. 0 = straight, 1 = max curve. */
      curveness?: number
      option?: any
      className?: string
    }
    
    export const SankeyChart = React.forwardRef<HTMLDivElement, SankeyChartProps>(
      ({ nodes, links, height = 360, curveness = 0.5, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const nodeNames = nodes ?? Array.from(new Set(links.flatMap((l) => [l.source, l.target])))
    
          const series = [
            {
              type: 'sankey',
              left: 16,
              right: 72,
              top: 12,
              bottom: 12,
              data: nodeNames.map((name) => ({ name })),
              links,
              lineStyle: { color: 'gradient', curveness },
              label: { fontSize: 11, color: theme.tooltipText },
              emphasis: { focus: 'adjacency' },
              itemStyle: { borderWidth: 0 },
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              trigger: 'item',
              triggerOn: 'mousemove',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            series: mergedSeries,
            ...userRest,
          }
        }, [nodes, links, curveness, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className} focusable={false}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    SankeyChart.displayName = 'SankeyChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // SunburstChart
    // ─────────────────────────────────────────────────────────────────────────
    
    interface SunNode {
      name: string
      value?: number
      children?: SunNode[]
    }
    
    export interface SunburstChartProps {
      /** Hierarchical data — top-level array, each node has `name`, optional `value`, optional `children`. */
      data: SunNode[]
      height?: number | string
      /** Inner / outer radius as percentages of the smaller container side. Default `['12%', '90%']`. */
      radius?: [string, string]
      option?: any
      className?: string
    }
    
    export const SunburstChart = React.forwardRef<HTMLDivElement, SunburstChartProps>(
      ({ data, height = 360, radius = ['12%', '90%'], option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const series = [
            {
              type: 'sunburst',
              radius,
              data,
              label: { rotate: 'radial' as const, fontSize: 11 },
              itemStyle: { borderColor: '#fff', borderWidth: 1 },
              emphasis: { focus: 'ancestor' as const },
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              trigger: 'item',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            series: mergedSeries,
            ...userRest,
          }
        }, [data, radius, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    SunburstChart.displayName = 'SunburstChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // ThemeRiver
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface ThemeRiverProps {
      /** `[time, value, series]` tuples. Time can be a date string or number. */
      data: [string | number, number, string][]
      height?: number | string
      option?: any
      className?: string
    }
    
    export const ThemeRiver = React.forwardRef<HTMLDivElement, ThemeRiverProps>(
      ({ data, height = 320, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const series = [
            {
              type: 'themeRiver',
              data,
              label: { show: false },
              emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.2)' } },
            },
          ]
    
          const userOption: any = option ?? {}
          const {
            series: userSeries,
            singleAxis: userSingleAxis,
            tooltip: userTooltip,
            legend: userLegend,
            ...userRest
          } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: mergeOptionBlock(
              {
                trigger: 'axis',
                axisPointer: { type: 'line', lineStyle: { color: theme.axisColor, opacity: 0.8 } },
                backgroundColor: theme.tooltipBg,
                borderColor: theme.tooltipBorder,
                textStyle: { color: theme.tooltipText, fontSize: 12 },
              },
              userTooltip,
            ),
            legend:
              userLegend?.show === false
                ? undefined
                : mergeOptionBlock(
                    {
                      bottom: 0,
                      icon: 'circle',
                      itemWidth: 8,
                      itemHeight: 8,
                      textStyle: { fontSize: 11, color: theme.textColor },
                    },
                    userLegend,
                  ),
            singleAxis: mergeOptionBlock(
              {
                top: 12,
                bottom: 40,
                type: 'time',
                axisTick: { show: false },
                axisLabel: { color: theme.textColor, fontSize: 11 },
                axisLine: { lineStyle: { color: theme.axisColor } },
              },
              userSingleAxis,
            ),
            series: mergedSeries,
            ...userRest,
          }
        }, [data, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className} focusable={false}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    ThemeRiver.displayName = 'ThemeRiver'
    
    // ─────────────────────────────────────────────────────────────────────────
    // TreeChart
    // ─────────────────────────────────────────────────────────────────────────
    
    interface TreeNode {
      name: string
      value?: number
      children?: TreeNode[]
      /** Collapse this branch on initial render. */
      collapsed?: boolean
    }
    
    export interface TreeChartProps {
      data: TreeNode
      /** `LR` (left-to-right, default), `TB` (top-down), `RL`, `BT`, or `radial`. */
      orient?: 'LR' | 'TB' | 'RL' | 'BT' | 'radial'
      /** Allow click-and-drag pan. Default false. */
      roam?: boolean
      height?: number | string
      option?: any
      className?: string
    }
    
    export const TreeChart = React.forwardRef<HTMLDivElement, TreeChartProps>(
      ({ data, orient = 'LR', roam = false, height = 380, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const layout = orient === 'radial' ? ('radial' as const) : ('orthogonal' as const)
          const series = [
            {
              type: 'tree',
              data: [data],
              layout,
              orient: layout === 'orthogonal' ? orient : undefined,
              roam,
              symbol: 'circle',
              symbolSize: 10,
              initialTreeDepth: -1,
              top: 16,
              bottom: 16,
              left: layout === 'radial' ? '5%' : 16,
              right: layout === 'radial' ? '5%' : 60,
              label: {
                fontSize: 11,
                color: theme.textColor,
                position: layout === 'radial' ? ('inside' as const) : ('right' as const),
                verticalAlign: 'middle' as const,
                align: layout === 'radial' ? ('center' as const) : ('left' as const),
                distance: 6,
              },
              leaves: {
                label: { position: layout === 'radial' ? ('inside' as const) : ('right' as const) },
              },
              lineStyle: { color: theme.axisColor, width: 1.5, curveness: 0.5 },
              emphasis: { focus: 'descendant' as const },
              itemStyle: { color: theme.colors[0], borderColor: theme.colors[0] },
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              trigger: 'item',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            series: mergedSeries,
            ...userRest,
          }
        }, [data, orient, roam, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className} focusable={false}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    TreeChart.displayName = 'TreeChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // RawChart
    // ─────────────────────────────────────────────────────────────────────────
    
    export interface RawChartProps {
      option: any
      height?: number | string
      /** Auto-resize on container width change. Default true. */
      autoresize?: boolean
      className?: string
    }
    
    /**
     * Raw escape hatch. The opinionated wrappers (AreaChart, BarChart,
     * FunnelChart, ...) cover the common cases with sensible defaults +
     * a `data` prop. When you need a chart type they don't wrap -- or a
     * level of customisation the wrappers can't expose without leaking
     * ECharts internals -- reach for this component and pass a complete
     * ECharts option object.
     *
     * Every chart type the registry wraps is already `use()`-registered by
     * importing this module, so you can pass any of them via `option`.
     * Theme tokens are available from `useChartTheme()` (and `toRgba`,
     * `mergeOptionBlock` from `./useChartTheme`) -- weave them into your
     * option for visual consistency with the rest of the registry's charts.
     */
    export const RawChart = React.forwardRef<HTMLDivElement, RawChartProps>(
      ({ option, height = 300, autoresize = true, className }, ref) => (
        <ChartFrame ref={ref} height={height} className={className}>
          <ReactECharts
            echarts={echartsCore as any}
            option={option}
            notMerge
            lazyUpdate
            // ReactECharts resizes with its container by default; opt out by
            // disabling the resize observer when `autoresize` is false.
            opts={autoresize ? undefined : { width: 'auto', height: 'auto' }}
            style={{ width: '100%', height: '100%' }}
          />
        </ChartFrame>
      ),
    )
    RawChart.displayName = 'RawChart'
    
    // ─────────────────────────────────────────────────────────────────────────
    // SegmentedGauge — pure SVG, no ECharts
    // ─────────────────────────────────────────────────────────────────────────
    
    interface GaugeSegment {
      /** Relative size of the segment. Segments are normalised by their sum. */
      value: number
      /** Optional override; defaults to chart-1..N from the registry palette. */
      color?: string
      /** Optional label, surfaced via the `children` for consumers that want to
       *  render their own legend. */
      label?: string
    }
    
    export interface SegmentedGaugeProps {
      segments: GaugeSegment[]
      /** Container height (px when numeric, raw CSS when string). Default 200. */
      height?: number | string
      /** Stroke width of the arc in SVG units. Default 18. */
      stroke?: number
      /** Angular gap between segments, in degrees. Default 4. */
      gap?: number
      /** Optional fallback palette when `color` is omitted on a segment. */
      colors?: string[]
      /** Show a faint background track behind the arc. Default true. */
      showTrack?: boolean
      className?: string
      /** Rendered into the dish centre (the Vue `center` slot). */
      children?: React.ReactNode
    }
    
    // SVG geometry. The viewBox uses the centre + radius + stroke so the
    // canvas grows with the stroke width and the centre content can sit
    // underneath without overlapping the arc.
    const SG_CX = 140
    const SG_CY = 124
    const SG_R = 100
    
    function sgPolar(angleDeg: number) {
      const a = ((angleDeg - 90) * Math.PI) / 180
      return [SG_CX + SG_R * Math.cos(a), SG_CY + SG_R * Math.sin(a)] as const
    }
    
    function sgArcPath(startA: number, endA: number) {
      const [sx, sy] = sgPolar(startA)
      const [ex, ey] = sgPolar(endA)
      const largeArc = endA - startA > 180 ? 1 : 0
      return `M ${sx.toFixed(2)} ${sy.toFixed(2)} A ${SG_R} ${SG_R} 0 ${largeArc} 1 ${ex.toFixed(2)} ${ey.toFixed(2)}`
    }
    
    export const SegmentedGauge = React.forwardRef<HTMLDivElement, SegmentedGaugeProps>(
      (
        {
          segments,
          height = 200,
          stroke = 18,
          gap = 4,
          colors = ['#3b82f6', '#0ea5e9', '#34d399', '#facc15', '#fb7185', '#a855f7'],
          showTrack = true,
          className,
          children,
        },
        ref,
      ) => {
        const startAngle = 180
        const sweep = 180
    
        const arcs = React.useMemo(() => {
          const total = segments.reduce((acc, s) => acc + s.value, 0) || 1
          let cursor = startAngle
          return segments.map((s, i) => {
            const span = (s.value / total) * sweep
            const isLast = i === segments.length - 1
            const segEnd = cursor + span - (isLast ? 0 : gap)
            const arc = { d: sgArcPath(cursor, segEnd), color: s.color ?? colors[i % colors.length] }
            cursor = cursor + span
            return arc
          })
        }, [segments, gap, colors])
    
        const trackPath = sgArcPath(startAngle, startAngle + sweep)
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="segmented-gauge"
            tabIndex={0}
            style={{ height: heightToStyle(height) }}
            className={cn(
              'focus-visible:ring-ring relative w-full focus-visible:ring-2 focus-visible:outline-none',
              className,
            )}
          >
            <svg
              viewBox={`0 0 ${SG_CX * 2} ${SG_CY + stroke}`}
              className="block h-full w-full"
              preserveAspectRatio="xMidYMid meet"
              role="img"
            >
              {showTrack && (
                <path
                  d={trackPath}
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={stroke}
                  strokeLinecap="round"
                  className="text-muted/40"
                  opacity="0.35"
                />
              )}
              {arcs.map((a, i) => (
                <path key={i} d={a.d} fill="none" stroke={a.color} strokeWidth={stroke} strokeLinecap="round" />
              ))}
            </svg>
            {children != null && (
              <div className="pointer-events-none absolute inset-x-0 bottom-[8%] flex flex-col items-center">
                {children}
              </div>
            )}
          </div>
        )
      },
    )
    SegmentedGauge.displayName = 'SegmentedGauge'
    
    // ─────────────────────────────────────────────────────────────────────────
    // SmoothFunnel — pure SVG, no ECharts
    // ─────────────────────────────────────────────────────────────────────────
    
    interface FunnelStage {
      name: string
      value: number
      /** Optional override; defaults to chart-1..N from the registry palette. */
      color?: string
    }
    
    export interface SmoothFunnelProps {
      data: FunnelStage[]
      /** Container height (px when numeric, raw CSS when string). Defaults to 240. */
      height?: number | string
      /** Show the percent pill on each stage. Defaults to true. */
      showLabels?: boolean
      /** Minimum segment height in px so tail stages stay visible at tiny percents. Default 18. */
      minHeight?: number
      /** Optional fallback palette when `color` is omitted on a stage. */
      colors?: string[]
      className?: string
    }
    
    // SVG geometry. Width/height are virtual (the SVG fits to container
    // via viewBox). The aspect ratio (W:H ≈ 4:1) matches the horizontal
    // "flow" layout consumers typically want for a 4-6 stage funnel; if
    // you need taller bands, override `height` and the curves stretch
    // vertically without distorting horizontally.
    const SF_W = 720
    const SF_H = 180
    const SF_CY = SF_H / 2
    
    export const SmoothFunnel = React.forwardRef<HTMLDivElement, SmoothFunnelProps>(
      (
        {
          data,
          height = 240,
          showLabels = true,
          minHeight = 18,
          colors = ['#3b82f6', '#a855f7', '#34d399', '#facc15', '#fb7185', '#06b6d4'],
          className,
        },
        ref,
      ) => {
        const segments = React.useMemo(() => {
          const stages = data
          const n = stages.length
          if (n === 0) return []
          const segW = SF_W / n
          const max = Math.max(...stages.map((s) => s.value))
          const pctOf = (v: number) => (max > 0 ? (v / max) * 100 : 0)
          const heightFor = (pct: number) => Math.max((pct / 100) * SF_H, minHeight)
    
          return stages.map((s, i) => {
            const next = stages[i + 1] ?? s
            const startPct = pctOf(s.value)
            const endPct = pctOf(next.value)
            const h0 = heightFor(startPct)
            const h1 = heightFor(endPct)
            const x0 = i * segW
            const x1 = x0 + segW
            const yTop0 = SF_CY - h0 / 2
            const yTop1 = SF_CY - h1 / 2
            const yBot0 = SF_CY + h0 / 2
            const yBot1 = SF_CY + h1 / 2
    
            // Cubic bezier control points at 38% / 62% of segment width produce
            // a soft S-curve transition between stages rather than the
            // trapezoidal default of an ECharts funnel.
            const cx1 = x0 + segW * 0.38
            const cx2 = x0 + segW * 0.62
    
            const d = [
              `M ${x0.toFixed(1)} ${yTop0.toFixed(1)}`,
              `C ${cx1.toFixed(1)} ${yTop0.toFixed(1)}, ${cx2.toFixed(1)} ${yTop1.toFixed(1)}, ${x1.toFixed(1)} ${yTop1.toFixed(1)}`,
              `L ${x1.toFixed(1)} ${yBot1.toFixed(1)}`,
              `C ${cx2.toFixed(1)} ${yBot1.toFixed(1)}, ${cx1.toFixed(1)} ${yBot0.toFixed(1)}, ${x0.toFixed(1)} ${yBot0.toFixed(1)}`,
              'Z',
            ].join(' ')
    
            return {
              d,
              color: s.color ?? colors[i % colors.length],
              percent: startPct,
              name: s.name,
              value: s.value,
              labelX: x0 + segW * 0.42,
              labelY: SF_CY,
            }
          })
        }, [data, minHeight, colors])
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="smooth-funnel"
            tabIndex={0}
            style={{ height: heightToStyle(height) }}
            className={cn('focus-visible:ring-ring w-full focus-visible:ring-2 focus-visible:outline-none', className)}
          >
            <svg viewBox={`0 0 ${SF_W} ${SF_H}`} className="block h-full w-full" preserveAspectRatio="none" role="img">
              {segments.map((seg, i) => (
                <g key={i}>
                  <path d={seg.d} fill={seg.color} />
                  {showLabels && (
                    <foreignObject x={seg.labelX - 28} y={seg.labelY - 12} width={56} height={24}>
                      <div className="bg-background text-foreground inline-flex h-6 items-center rounded-full border px-2 text-[11px] font-semibold shadow-sm">
                        {Math.round(seg.percent * 10) / 10}%
                      </div>
                    </foreignObject>
                  )}
                </g>
              ))}
            </svg>
          </div>
        )
      },
    )
    SmoothFunnel.displayName = 'SmoothFunnel'
  • components/ui/charts/useChartTheme.ts 7 kB
    'use client'
    
    import { useEffect, useState } from 'react'
    
    // Chart palette is driven by Tailwind v4 CSS variables (`--chart-1`..`--chart-5`,
    // `--muted-foreground`, `--border`, `--popover`, etc.) so dark/light flips
    // happen automatically when the consumer toggles their theme class. The
    // values resolve at runtime via `getComputedStyle`, so they pick up whatever
    // the consumer set in their own `tailwind.css` -- no fork required.
    //
    // We bump a module-level `themeKey` whenever `<html>` class/style changes (the
    // typical shadcn dark-mode pivot) and notify subscribed `useChartTheme()`
    // consumers so every chart re-resolves its colors and ECharts re-paints.
    
    let themeKey = 0
    const listeners = new Set<() => void>()
    
    function bump() {
      themeKey++
      listeners.forEach((l) => l())
    }
    
    if (typeof window !== 'undefined') {
      // Bump once on the first paint so post-hydration getComputedStyle reads
      // the *resolved* CSS values (during SSR-built bundles the very first
      // read returns the fallbacks below).
      requestAnimationFrame(bump)
      new MutationObserver(bump).observe(document.documentElement, {
        attributes: true,
        attributeFilter: ['class', 'style', 'data-theme'],
      })
    }
    
    // Lazy canvas context used to normalize any CSS color string (including
    // `oklch(...)`, `oklab(...)`, `color(display-p3 ...)`) into a hex / rgba
    // string ECharts' canvas renderer can consume. Without this, code that
    // does `color + '40'` (8-digit hex alpha trick) produces invalid color
    // strings like `oklch(...)40` and the canvas API throws.
    let _hexCanvas: CanvasRenderingContext2D | null = null
    function toHex(cssColor: string): string {
      if (typeof document === 'undefined') return cssColor
      if (!_hexCanvas) {
        _hexCanvas = document.createElement('canvas').getContext('2d')
      }
      if (!_hexCanvas) return cssColor
      // Reset, then assign; the browser normalizes whatever it accepted into
      // the canonical hex/rgba form when read back.
      _hexCanvas.fillStyle = '#000'
      _hexCanvas.fillStyle = cssColor
      return _hexCanvas.fillStyle as string
    }
    
    // Convert any CSS color (hex, rgb, oklch, color()) + alpha 0..1 to a
    // canvas-safe rgba(r,g,b,a). `colorString + '40'` (8-digit hex alpha)
    // only works when `colorString` is `#rrggbb`; once tokens resolve to
    // oklch() post-hydration the gradient stops break and the canvas paint
    // throws every frame. Stay defensive and always return rgba.
    export function toRgba(cssColor: string, alpha: number): string {
      if (typeof document === 'undefined') return cssColor
      if (!_hexCanvas) {
        _hexCanvas = document.createElement('canvas').getContext('2d')
      }
      if (!_hexCanvas) return cssColor
      _hexCanvas.fillStyle = '#000'
      _hexCanvas.fillStyle = cssColor
      const normalized = _hexCanvas.fillStyle as string
      if (normalized.startsWith('#') && normalized.length === 7) {
        const r = parseInt(normalized.slice(1, 3), 16)
        const g = parseInt(normalized.slice(3, 5), 16)
        const b = parseInt(normalized.slice(5, 7), 16)
        return `rgba(${r},${g},${b},${alpha})`
      }
      if (normalized.startsWith('rgba(')) {
        return normalized.replace(/,\s*[\d.]+\s*\)$/, `,${alpha})`)
      }
      if (normalized.startsWith('rgb(')) {
        return normalized.replace(/^rgb\(/, 'rgba(').replace(/\)$/, `,${alpha})`)
      }
      // Canvas refused to parse this color -- ship the original string and
      // let ECharts complain (better than crashing the paint loop).
      return cssColor
    }
    
    function resolveVar(name: string, fallback: string): string {
      if (typeof window === 'undefined') return fallback
      const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
      if (!v) return fallback
      return toHex(v)
    }
    
    // SSR / pre-hydration fallback palette. Hex values picked to roughly
    // match the shadcn Neutral defaults in `tailwind.css` so the first paint
    // doesn't flicker.
    const CHART_FALLBACK = ['#f59e0b', '#14b8a6', '#3b82f6', '#f97316', '#eab308']
    
    /** The resolved chart theme tokens. All values are canvas-safe hex/rgba. */
    export interface ChartTheme {
      colors: string[]
      textColor: string
      axisColor: string
      splitLineColor: string
      tooltipBg: string
      tooltipBorder: string
      tooltipText: string
    }
    
    function resolveTheme(): ChartTheme {
      return {
        colors: Array.from({ length: 5 }, (_, i) => resolveVar(`--chart-${i + 1}`, CHART_FALLBACK[i]!)),
        textColor: resolveVar('--muted-foreground', '#888888'),
        axisColor: resolveVar('--border', '#e5e5e5'),
        splitLineColor: resolveVar('--border', '#f0f0f0'),
        tooltipBg: resolveVar('--popover', 'rgba(255,255,255,0.96)'),
        tooltipBorder: resolveVar('--border', '#e5e5e5'),
        tooltipText: resolveVar('--popover-foreground', '#333333'),
      }
    }
    
    /**
     * Subscribe to the theme-token palette. Re-resolves (and re-renders the
     * consuming chart) whenever the consumer flips their dark-mode class on
     * `<html>`. The first client render resolves the real CSS values; SSR /
     * pre-hydration returns the fallback palette above.
     */
    export function useChartTheme(): ChartTheme {
      const [, setTick] = useState(themeKey)
      const [theme, setTheme] = useState<ChartTheme>(() => resolveTheme())
    
      useEffect(() => {
        const update = () => {
          setTick(themeKey)
          setTheme(resolveTheme())
        }
        listeners.add(update)
        // Resolve once on mount so the first client paint reads the real CSS
        // values instead of the SSR fallbacks.
        update()
        return () => {
          listeners.delete(update)
        }
      }, [])
    
      return theme
    }
    
    // Two-level deep merge for ECharts option blocks (xAxis, yAxis, grid,
    // tooltip, legend, singleAxis, parallel, etc.). The top-level keys merge
    // shallowly, but one nested level (axisLabel, axisLine, splitLine, etc.)
    // merges shallowly too so a consumer passing `xAxis: { axisLabel: { fontSize: 9 } }`
    // doesn't blow away the wrapper's `color` + base font defaults on the same
    // axisLabel block. Arrays + primitives replace outright.
    //
    // This is the merge strategy the chart wrappers use to fold `option`
    // onto their computed base option without forcing consumers to spell out
    // every default they want to preserve.
    export function mergeOptionBlock<T extends Record<string, any>>(base: T, user: Partial<T> | undefined): T {
      if (!user) return base
      const out: any = { ...base }
      for (const k of Object.keys(user)) {
        const bv = (base as any)[k]
        const uv = (user as any)[k]
        if (
          bv != null &&
          uv != null &&
          typeof bv === 'object' &&
          typeof uv === 'object' &&
          !Array.isArray(bv) &&
          !Array.isArray(uv)
        ) {
          out[k] = { ...bv, ...uv }
        } else {
          out[k] = uv
        }
      }
      return out
    }
    
    // Default gauge stoplight: teal (safe) -> amber (warning) -> red (danger).
    // Pulled off saturated green and onto teal so the gauge ties back to the
    // dashboard palette; red is kept as the universal "limit reached" cue.
    // GaugeChart consumes this via its `thresholds` prop default; consumers
    // pass their own array to override. Static because gauges have semantic
    // meaning (green safe / red danger) that we deliberately don't theme-flip.
    export const gaugeThresholds: [number, string][] = [
      [0.6, '#14b8a6'],
      [0.85, '#f59e0b'],
      [1, '#dc2626'],
    ]
  • components/ui/charts/index.ts 1 kB
    export {
      AreaChart,
      type AreaChartProps,
      BarChart,
      type BarChartProps,
      LineChart,
      type LineChartProps,
      Sparkline,
      type SparklineProps,
      PieChart,
      type PieChartProps,
      ScatterChart,
      type ScatterChartProps,
      RadarChart,
      type RadarChartProps,
      GaugeChart,
      type GaugeChartProps,
      Heatmap,
      type HeatmapProps,
      CalendarHeatmap,
      type CalendarHeatmapProps,
      TreemapChart,
      type TreemapChartProps,
      FunnelChart,
      type FunnelChartProps,
      BoxplotChart,
      type BoxplotChartProps,
      CandlestickChart,
      type CandlestickChartProps,
      GraphChart,
      type GraphChartProps,
      ParallelChart,
      type ParallelChartProps,
      SankeyChart,
      type SankeyChartProps,
      SunburstChart,
      type SunburstChartProps,
      ThemeRiver,
      type ThemeRiverProps,
      TreeChart,
      type TreeChartProps,
      RawChart,
      type RawChartProps,
      SegmentedGauge,
      type SegmentedGaugeProps,
      SmoothFunnel,
      type SmoothFunnelProps,
    } from './Charts'
    
    export {
      useChartTheme,
      type ChartTheme,
      toRgba,
      mergeOptionBlock,
      gaugeThresholds,
    } from './useChartTheme'

Raw manifest: https://react.uipkge.dev/r/react/theme-river.json