Raw Chart
raw-chart ui React mirror of @uipkge/raw-chart — see the Vue registry item for the canonical description.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://uipkge.dev/r/react/raw-chart.json $ npx shadcn@latest add https://uipkge.dev/r/react/raw-chart.json $ yarn dlx shadcn@latest add https://uipkge.dev/r/react/raw-chart.json $ bunx shadcn@latest add https://uipkge.dev/r/react/raw-chart.json Named registry:
npx shadcn@latest add @uipkge-react/raw-chart Installs to: components/ui/charts/raw-chart/ Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
option | any | — | required |
height | number | string | 300 | optional |
autoresize Auto-resize on container width change. Default true. | boolean | true | optional |
className | string | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
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
} npm dependencies
Used by
Files installed (4)
-
components/ui/charts/raw-chart/RawChart.tsx 10.1 kB
'use client' import * as React from 'react' import ReactECharts from 'echarts-for-react/lib/core' import { cn } from '@/lib/utils' import { ChartFrame, EChart, echartsCoreModule, heightToStyle } from '../shared' import { useChartTheme, mergeOptionBlock, toRgba, gaugeThresholds } from '../useChartTheme' // 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={echartsCoreModule 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/raw-chart/index.ts 0.1 kB
export { RawChart, type RawChartProps } from './RawChart' -
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/shared.tsx 3.1 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' export 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 ship a bare `w-full` frame -- pass `false` for those. */ focusable?: boolean } /** Shared `<div>` wrapper around the ECharts canvas. */ export 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. */ export function EChart({ option }: { option: any }) { return ( <ReactECharts echarts={echartsCore as any} option={option} notMerge lazyUpdate style={{ width: '100%', height: '100%' }} /> ) } export const echartsCoreModule = echartsCore // Register every chart type the wrappers need once at module load. 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, ])
Raw manifest: https://uipkge.dev/r/react/raw-chart.json