import type { ChartAggregation, ChartConfig, ChartFieldInfo, ChartType, ResolvedTheme, } from '@smallwebco/tinypivot-core' /** * TinyPivot React - Chart Builder Component * Drag-and-drop chart configuration with ApexCharts rendering */ import type { ApexOptions } from 'apexcharts' import { analyzeFieldsForChart, CHART_AGGREGATIONS, CHART_COLORS, CHART_TYPES, createDefaultChartConfig, getChartGuidance, isChartConfigValid, processChartData, processChartDataForHeatmap, processChartDataForPie, processChartDataForScatter, } from '@smallwebco/tinypivot-core' import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react' // Lazy load ApexCharts to avoid SSR issues (window not defined in Node.js) const Chart = React.lazy(() => import('react-apexcharts')) interface ChartBuilderProps { data: Record[] theme?: ResolvedTheme fieldRoleOverrides?: Record onConfigChange?: (config: ChartConfig) => void } export function ChartBuilder({ data, theme = 'light', fieldRoleOverrides, onConfigChange, }: ChartBuilderProps) { // Chart configuration state const [chartConfig, setChartConfig] = useState(createDefaultChartConfig()) // Drag state const [draggingField, setDraggingField] = useState(null) const [dragOverZone, setDragOverZone] = useState(null) // Field analysis (applies consumer overrides when provided) const fieldInfos = useMemo(() => analyzeFieldsForChart(data, fieldRoleOverrides), [data, fieldRoleOverrides]) // Separate fields by role const dimensions = useMemo( () => fieldInfos.filter(f => f.role === 'dimension' || f.role === 'temporal'), [fieldInfos], ) const measures = useMemo( () => fieldInfos.filter(f => f.role === 'measure'), [fieldInfos], ) // Current guidance message const guidance = useMemo(() => getChartGuidance(chartConfig), [chartConfig]) // Check if chart is ready to render const chartIsValid = useMemo(() => isChartConfigValid(chartConfig), [chartConfig]) // Get currently selected chart type info const selectedChartType = useMemo( () => CHART_TYPES.find(ct => ct.type === chartConfig.type), [chartConfig.type], ) // Check if scatter/bubble needs numeric fields const isScatterType = useMemo( () => ['scatter', 'bubble'].includes(chartConfig.type), [chartConfig.type], ) const isHeatmapType = useMemo( () => chartConfig.type === 'heatmap', [chartConfig.type], ) // Dynamic zone labels based on chart type const zoneLabels = useMemo(() => { const type = chartConfig.type switch (type) { case 'scatter': case 'bubble': return { xAxis: 'X-Axis (measure)', xAxisPlaceholder: 'Drop a measure', yAxis: 'Y-Axis (measure)', yAxisPlaceholder: 'Drop a measure', series: 'Color by (optional)', seriesPlaceholder: 'Group points by dimension', showSize: type === 'bubble', showSeries: true, } case 'heatmap': return { xAxis: 'X-Axis (dimension)', xAxisPlaceholder: 'Drop a dimension', yAxis: 'Y-Axis (dimension)', yAxisPlaceholder: 'Drop a dimension', series: 'Value / Intensity', seriesPlaceholder: 'Drop a measure for color intensity', showSize: false, showSeries: true, } case 'pie': case 'donut': return { xAxis: 'Slices (dimension)', xAxisPlaceholder: 'Drop a dimension', yAxis: 'Values (measure)', yAxisPlaceholder: 'Drop a measure', series: '', seriesPlaceholder: '', showSize: false, showSeries: false, } case 'radar': return { xAxis: 'Axes (dimension)', xAxisPlaceholder: 'Drop a dimension', yAxis: 'Values (measure)', yAxisPlaceholder: 'Drop a measure', series: 'Compare by (optional)', seriesPlaceholder: 'Group by dimension', showSize: false, showSeries: true, } case 'stackedBar': return { xAxis: 'X-Axis (dimension)', xAxisPlaceholder: 'Drop a dimension', yAxis: 'Y-Axis (measure)', yAxisPlaceholder: 'Drop a measure', series: 'Series (stacking field)', seriesPlaceholder: 'Drop a dimension to stack by', showSize: false, showSeries: true, } default: // bar, line, area return { xAxis: 'X-Axis (dimension)', xAxisPlaceholder: 'Drop a dimension', yAxis: 'Y-Axis (measure)', yAxisPlaceholder: 'Drop a measure', series: 'Color / Series (optional)', seriesPlaceholder: 'Group by dimension', showSize: false, showSeries: true, } } }, [chartConfig.type]) // Drag handlers const handleDragStart = useCallback((field: ChartFieldInfo, event: React.DragEvent) => { setDraggingField(field) event.dataTransfer?.setData('text/plain', field.field) }, []) const handleDragEnd = useCallback(() => { setDraggingField(null) setDragOverZone(null) }, []) const handleDragOver = useCallback((zone: string, event: React.DragEvent) => { event.preventDefault() setDragOverZone(zone) }, []) const handleDragLeave = useCallback(() => { setDragOverZone(null) }, []) const handleDrop = useCallback((zone: string, event: React.DragEvent) => { event.preventDefault() setDragOverZone(null) if (!draggingField) return const field = draggingField const chartField = { field: field.field, label: field.label, role: field.role, aggregation: field.role === 'measure' ? 'sum' as ChartAggregation : undefined, } let newConfig = { ...chartConfig } switch (zone) { case 'xAxis': newConfig = { ...newConfig, xAxis: chartField } break case 'yAxis': newConfig = { ...newConfig, yAxis: chartField } break case 'series': newConfig = { ...newConfig, seriesField: chartField } break case 'size': newConfig = { ...newConfig, sizeField: chartField } break case 'color': newConfig = { ...newConfig, colorField: chartField } break } setChartConfig(newConfig) onConfigChange?.(newConfig) }, [chartConfig, draggingField, onConfigChange]) const removeField = useCallback((zone: string) => { let newConfig = { ...chartConfig } switch (zone) { case 'xAxis': newConfig = { ...newConfig, xAxis: undefined } break case 'yAxis': newConfig = { ...newConfig, yAxis: undefined } break case 'series': newConfig = { ...newConfig, seriesField: undefined } break case 'size': newConfig = { ...newConfig, sizeField: undefined } break case 'color': newConfig = { ...newConfig, colorField: undefined } break } setChartConfig(newConfig) onConfigChange?.(newConfig) }, [chartConfig, onConfigChange]) const selectChartType = useCallback((type: ChartType) => { const newConfig = { ...chartConfig, type } setChartConfig(newConfig) onConfigChange?.(newConfig) }, [chartConfig, onConfigChange]) const updateAggregation = useCallback((zone: string, aggregation: ChartAggregation) => { let field switch (zone) { case 'xAxis': field = chartConfig.xAxis break case 'yAxis': field = chartConfig.yAxis break case 'size': field = chartConfig.sizeField break case 'color': field = chartConfig.colorField break default: return } if (!field) return const updated = { ...field, aggregation } let newConfig = { ...chartConfig } switch (zone) { case 'xAxis': newConfig = { ...newConfig, xAxis: updated } break case 'yAxis': newConfig = { ...newConfig, yAxis: updated } break case 'size': newConfig = { ...newConfig, sizeField: updated } break case 'color': newConfig = { ...newConfig, colorField: updated } break } setChartConfig(newConfig) onConfigChange?.(newConfig) }, [chartConfig, onConfigChange]) // Chart rendering helpers type ApexChartType = 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'heatmap' | 'bubble' const getApexChartType = useCallback((type: ChartType): ApexChartType => { const mapping: Record = { bar: 'bar', stackedBar: 'bar', line: 'line', area: 'area', pie: 'pie', donut: 'donut', radar: 'radar', scatter: 'scatter', bubble: 'bubble', heatmap: 'heatmap', } return mapping[type] || 'bar' }, []) const formatValue = useCallback((val: unknown, format?: string, decimals?: number): string => { // Handle non-number values (ApexCharts sometimes passes strings or undefined) if (val === null || val === undefined) return '' if (typeof val !== 'number') return String(val) if (Number.isNaN(val)) return '' const dec = decimals ?? 0 if (format === 'percent') { return `${val.toFixed(dec)}%` } if (format === 'currency') { return `$${val.toLocaleString(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec })}` } if (Math.abs(val) >= 1000) { return val.toLocaleString(undefined, { maximumFractionDigits: dec }) } return val.toFixed(dec) }, []) // Chart options const chartOptions = useMemo(() => { const isDark = theme === 'dark' || (typeof theme === 'string' && theme.endsWith('-dark')) const config = chartConfig const options = config.options || {} const baseOptions: ApexOptions = { chart: { type: getApexChartType(config.type), background: 'transparent', foreColor: isDark ? '#e2e8f0' : '#334155', toolbar: { show: true, tools: { download: true, selection: false, zoom: options.enableZoom ?? false, zoomin: options.enableZoom ?? false, zoomout: options.enableZoom ?? false, pan: false, reset: options.enableZoom ?? false, }, export: { csv: { filename: 'chart-data' }, svg: { filename: 'chart' }, png: { filename: 'chart' }, }, }, animations: { enabled: options.animated ?? true, speed: 400, dynamicAnimation: { enabled: true, speed: 300 }, }, fontFamily: 'system-ui, -apple-system, sans-serif', }, colors: options.colors || CHART_COLORS, theme: { mode: isDark ? 'dark' : 'light', }, grid: { show: options.showGrid ?? true, borderColor: isDark ? '#334155' : '#e2e8f0', }, legend: { show: options.showLegend ?? true, position: options.legendPosition || 'top', }, dataLabels: { enabled: options.showDataLabels ?? false, }, tooltip: { theme: isDark ? 'dark' : 'light', style: { fontSize: '12px', }, }, stroke: { curve: 'smooth', width: config.type === 'line' ? 3 : config.type === 'area' ? 2 : 0, }, fill: { opacity: config.type === 'area' ? 0.4 : 1, }, } // Add axis titles if (config.xAxis) { baseOptions.xaxis = { ...baseOptions.xaxis, title: { text: options.xAxisTitle || config.xAxis.label }, labels: { style: { colors: isDark ? '#94a3b8' : '#64748b' }, }, } } if (config.yAxis && !['pie', 'donut', 'radar'].includes(config.type)) { baseOptions.yaxis = { title: { text: options.yAxisTitle || config.yAxis.label }, labels: { style: { colors: isDark ? '#94a3b8' : '#64748b' }, formatter: (val: number) => formatValue(val, options.valueFormat, options.decimals), }, } } // Chart title if (options.title) { baseOptions.title = { text: options.title, style: { fontSize: '16px', fontWeight: '600', color: isDark ? '#e2e8f0' : '#334155', }, } } // Stacking — forced on for stackedBar, optional for bar/area if (config.type === 'stackedBar' || (options.stacked && ['bar', 'area'].includes(config.type))) { baseOptions.chart!.stacked = true } // Pie/Donut specific if (config.type === 'pie' || config.type === 'donut') { baseOptions.plotOptions = { pie: { donut: { size: config.type === 'donut' ? '55%' : '0%', labels: { show: config.type === 'donut', total: { show: true, label: 'Total', formatter: (w) => { const total = w.globals.seriesTotals.reduce((a: number, b: number) => a + b, 0) return formatValue(total, options.valueFormat, options.decimals) }, }, }, }, }, } } // Radar specific if (config.type === 'radar') { baseOptions.plotOptions = { radar: { polygons: { strokeColors: isDark ? '#334155' : '#e2e8f0', fill: { colors: isDark ? ['#1e293b', '#0f172a'] : ['#f8fafc', '#f1f5f9'] }, }, }, } } return baseOptions }, [chartConfig, theme, getApexChartType, formatValue]) const chartSeries = useMemo(() => { const config = chartConfig if (!chartIsValid) return [] // Process based on chart type if (config.type === 'pie' || config.type === 'donut') { const chartData = processChartDataForPie(data, config) return chartData.series[0]?.data || [] } if (config.type === 'scatter' || config.type === 'bubble') { const scatterData = processChartDataForScatter(data, config) return scatterData.series } if (config.type === 'heatmap') { const heatmapData = processChartDataForHeatmap(data, config) return heatmapData.series } // Standard charts (bar, line, area, etc.) const chartData = processChartData(data, config) return chartData.series }, [data, chartConfig, chartIsValid]) const chartLabels = useMemo(() => { const config = chartConfig if (!chartIsValid) return [] if (config.type === 'pie' || config.type === 'donut') { const chartData = processChartDataForPie(data, config) return chartData.categories } const chartData = processChartData(data, config) return chartData.categories }, [data, chartConfig, chartIsValid]) // Update xaxis categories in options const chartOptionsWithCategories = useMemo(() => { const options = { ...chartOptions } const config = chartConfig // Heatmap, scatter, bubble have x values in data itself if (!['pie', 'donut', 'scatter', 'bubble', 'heatmap'].includes(config.type)) { options.xaxis = { ...options.xaxis, categories: chartLabels, } } if (config.type === 'pie' || config.type === 'donut') { options.labels = chartLabels } // Heatmap specific options if (config.type === 'heatmap') { options.chart = { ...options.chart, type: 'heatmap', } options.xaxis = { ...options.xaxis, type: 'category', } options.dataLabels = { enabled: true, style: { colors: ['#fff'], fontSize: '10px', }, formatter: (val: unknown) => { if (val === null || val === undefined) return '' if (typeof val !== 'number') return String(val) if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M` if (val >= 1000) return `${(val / 1000).toFixed(0)}K` return Math.round(val).toLocaleString() }, } options.plotOptions = { heatmap: { shadeIntensity: 0.5, radius: 2, enableShades: true, colorScale: { inverse: false, }, }, } // Use a single color that varies by intensity options.colors = ['#6366f1'] // Disable legend for heatmap (color scale is self-explanatory) options.legend = { show: false } } return options }, [chartOptions, chartConfig, chartLabels]) // Icons for chart types (inline SVG paths) const getChartIcon = useCallback((type: ChartType): string => { const icons: Record = { bar: 'M3 3v18h18V3H3zm4 14H5v-6h2v6zm4 0H9V7h2v10zm4 0h-2V9h2v8zm4 0h-2v-4h2v4z', stackedBar: 'M3 3v18h18V3H3zm4 14H5v-3h2v3zm0-4H5v-3h2v3zm4 4H9v-5h2v5zm0-6H9v-4h2v4zm4 6h-2v-3h2v3zm0-4h-2v-5h2v5zm4 4h-2v-2h2v2zm0-3h-2v-2h2v2z', line: 'M3.5 18.5l6-6 4 4 8-8M14.5 8.5h6v6', area: 'M3 17l6-6 4 4 8-8v10H3z', pie: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8v8l5.66 5.66C14.28 19.04 13.18 20 12 20z', donut: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z', scatter: 'M7 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm5-6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm5 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-3 4a2 2 0 1 0 0-4 2 2 0 0 0 0 4z', bubble: 'M7 14a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm5-5a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm5 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', heatmap: 'M3 3h4v4H3V3zm6 0h4v4H9V3zm6 0h4v4h-4V3zM3 9h4v4H3V9zm6 0h4v4H9V9zm6 0h4v4h-4V9zM3 15h4v4H3v-4zm6 0h4v4H9v-4zm6 0h4v4h-4v-4z', radar: 'M12 2L4 6v6c0 5.55 3.84 10.74 8 12 4.16-1.26 8-6.45 8-12V6l-8-4zm0 3.18l6 3v5.09c0 4.08-2.76 7.91-6 9.14V5.18z', } return icons[type] || icons.bar }, []) // Emit initial config on mount useEffect(() => { onConfigChange?.(chartConfig) // Only run on mount - intentionally not including dependencies }, []) return (
{/* Chart Type Selector */}
{CHART_TYPES.map(ct => ( ))}
{/* Field Lists */}

Dimensions (text/date)

{dimensions.map(field => (
handleDragStart(field, e)} onDragEnd={handleDragEnd} > {field.label} {field.role === 'temporal' ? 'date' : 'text'}
))} {dimensions.length === 0 && (
No dimension fields detected
)}

Measures (numbers)

{measures.map(field => (
handleDragStart(field, e)} onDragEnd={handleDragEnd} > {field.label} #
))} {measures.length === 0 && (
No numeric fields detected
)}
{/* Drop Zones */}
{/* X-Axis */}
handleDragOver('xAxis', e)} onDragLeave={handleDragLeave} onDrop={e => handleDrop('xAxis', e)} > {chartConfig.xAxis ? ( <> {chartConfig.xAxis.label} {isScatterType && chartConfig.xAxis.role === 'measure' && ( )} ) : ( {zoneLabels.xAxisPlaceholder} )}
{/* Y-Axis */}
handleDragOver('yAxis', e)} onDragLeave={handleDragLeave} onDrop={e => handleDrop('yAxis', e)} > {chartConfig.yAxis ? ( <> {chartConfig.yAxis.label} {chartConfig.yAxis.role === 'measure' && !isHeatmapType && ( )} ) : ( {zoneLabels.yAxisPlaceholder} )}
{/* Series / Color (conditional) */} {zoneLabels.showSeries && (
handleDragOver(isHeatmapType ? 'color' : 'series', e)} onDragLeave={handleDragLeave} onDrop={e => handleDrop(isHeatmapType ? 'color' : 'series', e)} > {(isHeatmapType ? chartConfig.colorField : chartConfig.seriesField) ? ( <> {isHeatmapType ? chartConfig.colorField?.label : chartConfig.seriesField?.label} {isHeatmapType && chartConfig.colorField?.role === 'measure' && ( )} ) : ( {zoneLabels.seriesPlaceholder} )}
)} {/* Size (for bubble charts) */} {zoneLabels.showSize && (
handleDragOver('size', e)} onDragLeave={handleDragLeave} onDrop={e => handleDrop('size', e)} > {chartConfig.sizeField ? ( <> {chartConfig.sizeField.label} {chartConfig.sizeField.role === 'measure' && ( )} ) : ( Drop a number for bubble size )}
)} {/* Guidance */}
{guidance}
{/* Chart Preview */}
{chartIsValid ? (
Loading chart...
)} >
) : (

Build your chart

Drag fields from the left panel to configure your visualization

{selectedChartType?.label} : {' '} {selectedChartType?.description}
)}
) }