|
| 1 | +import React, { useRef, useState, useCallback, useEffect } from 'react' |
| 2 | +import { PivotViewComponent, Inject, FieldList, Toolbar } from '@syncfusion/ej2-react-pivotview' |
| 3 | +import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns' |
| 4 | +import D3GroupedBar from './D3GroupedBar.jsx' |
| 5 | +import D3Line from './D3Line.jsx' |
| 6 | +import D3StackedBar from './D3StackedBar.jsx' |
| 7 | +import D3Area from './D3Area.jsx' |
| 8 | +import D3Pie from './D3Pie.jsx' |
| 9 | +import D3Donut from './D3Donut.jsx' |
| 10 | +import D3Scatter from './D3Scatter.jsx' |
| 11 | + |
| 12 | +// Larger mock dataset: SaaS KPIs by Year/Region/Product with two measures |
| 13 | +const regions = ['NA', 'EU', 'APAC'] |
| 14 | +const products = ['Analytics', 'Platform', 'Security', 'Mobile'] |
| 15 | +const years = ['2021','2022','2023','2024','2025'] |
| 16 | + |
| 17 | +function generateData() { |
| 18 | + const data = [] |
| 19 | + for (const y of years) { |
| 20 | + for (const r of regions) { |
| 21 | + for (const p of products) { |
| 22 | + // Simple deterministic pseudo-random based on string char codes |
| 23 | + const seed = (y.charCodeAt(0) + r.charCodeAt(0) + p.charCodeAt(0)) % 97 |
| 24 | + const base = 150000 + (seed % 50) * 5000 |
| 25 | + const growth = (Number(y) - 2021) * 12000 |
| 26 | + const seasonal = ['Analytics','Platform'].includes(p) ? 20000 : 0 |
| 27 | + const regional = r === 'NA' ? 25000 : r === 'EU' ? 15000 : 12000 |
| 28 | + const amount = base + growth + seasonal + regional |
| 29 | + const quantity = Math.round(amount / 200) |
| 30 | + data.push({ Year: y, Region: r, Product: p, Amount: amount, Quantity: quantity }) |
| 31 | + } |
| 32 | + } |
| 33 | + } |
| 34 | + return data |
| 35 | +} |
| 36 | + |
| 37 | +const rawData = generateData() |
| 38 | + |
| 39 | +// Extract chart-ready model directly from PivotView's engine (enginePopulated) |
| 40 | +function extractFromEngine(pivotView) { |
| 41 | + const engine = pivotView?.engineModule |
| 42 | + const pivotValues = engine?.pivotValues |
| 43 | + if (!pivotValues) return { rowKeys: [], colKeys: [], valueOf: () => 0, valueField: '', valueType: '' } |
| 44 | + |
| 45 | + // Find the deepest column header row (leaf columns) |
| 46 | + let headerRowIndex = -1 |
| 47 | + for (let r = 0; r < pivotValues.length; r++) { |
| 48 | + const row = pivotValues[r] |
| 49 | + if (row && row.some((c) => c && c.axis === 'column')) headerRowIndex = r |
| 50 | + else if (headerRowIndex >= 0) break |
| 51 | + } |
| 52 | + const headerRow = headerRowIndex >= 0 ? pivotValues[headerRowIndex] : [] |
| 53 | + |
| 54 | + // Gather column keys and their column indices |
| 55 | + const colMap = [] // [{key, colIndex}] |
| 56 | + for (let c = 0; c < headerRow.length; c++) { |
| 57 | + const cell = headerRow[c] |
| 58 | + if (!cell || cell.axis !== 'column') continue |
| 59 | + if (cell.type && String(cell.type).toLowerCase().includes('grand')) continue |
| 60 | + const key = cell.formattedText || cell.value || cell.memberCaption || cell.memberUniqueName || `C${c}` |
| 61 | + colMap.push({ key: String(key), colIndex: c }) |
| 62 | + } |
| 63 | + const colKeys = colMap.map((m) => m.key) |
| 64 | + |
| 65 | + // Parse data rows: those that contain row headers then value cells |
| 66 | + const rows = [] // rowKeys |
| 67 | + const matrix = new Map() // `${rk}||${ck}` -> value |
| 68 | + |
| 69 | + for (let r = headerRowIndex + 1; r < pivotValues.length; r++) { |
| 70 | + const row = pivotValues[r] |
| 71 | + if (!row) continue |
| 72 | + // Build row key by concatenating leading row-header cells |
| 73 | + const parts = [] |
| 74 | + for (let c = 0; c < row.length; c++) { |
| 75 | + const cell = row[c] |
| 76 | + if (!cell) continue |
| 77 | + if (cell.axis === 'row') { |
| 78 | + if (cell.type && String(cell.type).toLowerCase().includes('grand')) { parts.length = 0; break } |
| 79 | + const txt = cell.formattedText || cell.value || cell.memberCaption || cell.memberUniqueName |
| 80 | + if (txt !== undefined && txt !== null) parts.push(String(txt)) |
| 81 | + } else { |
| 82 | + // first non-row header -> data begins |
| 83 | + break |
| 84 | + } |
| 85 | + } |
| 86 | + if (!parts.length) continue |
| 87 | + const rk = parts.join(' / ') |
| 88 | + if (!rows.includes(rk)) rows.push(rk) |
| 89 | + |
| 90 | + // Read values at collected column indices |
| 91 | + for (const { key: ck, colIndex } of colMap) { |
| 92 | + const cell = row[colIndex] |
| 93 | + if (!cell) continue |
| 94 | + if (cell.value == null || isNaN(Number(cell.value))) continue |
| 95 | + matrix.set(`${rk}||${ck}`, Number(cell.value)) |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + const valueOf = (rk, ck) => matrix.get(`${rk}||${ck}`) || 0 |
| 100 | + |
| 101 | + // Derive value field and type from dataSourceSettings |
| 102 | + const v = pivotView?.dataSourceSettings?.values?.[0] |
| 103 | + const valueField = v?.name || '' |
| 104 | + const valueType = v?.type || '' |
| 105 | + |
| 106 | + return { rowKeys: rows, colKeys, valueOf, valueField, valueType } |
| 107 | +} |
| 108 | + |
| 109 | +export default function App() { |
| 110 | + const pivotRef = useRef(null) |
| 111 | + |
| 112 | + // Pivot configuration |
| 113 | + const [dataSourceSettings, setDataSourceSettings] = useState({ |
| 114 | + dataSource: rawData, |
| 115 | + expandAll: false, |
| 116 | + enableSorting: true, |
| 117 | + rows: [{ name: 'Year' }], |
| 118 | + columns: [{ name: 'Product' }, { name: 'Region' }], |
| 119 | + values: [ |
| 120 | + { name: 'Amount', caption: 'Revenue ($)', type: 'Sum' }, |
| 121 | + ], |
| 122 | + formatSettings: [{ name: 'Amount', format: 'C0' }], |
| 123 | + filterSettings: [ |
| 124 | + // Example filter: include only USA and Germany (both already present here) |
| 125 | + // { name: 'Country', type: 'Include', items: ['USA', 'Germany'] }, |
| 126 | + ], |
| 127 | + drilledMembers: [], |
| 128 | + }) |
| 129 | + |
| 130 | + const [chartModel, setChartModel] = useState({ rowKeys: [], colKeys: [], valueOf: () => 0, valueField: 'Amount', valueType: 'Sum' }) |
| 131 | + const [chartType, setChartType] = useState('bar') // 'bar' | 'line' | 'stacked' | 'area' | 'pie' | 'donut' | 'scatter' |
| 132 | + const [measure, setMeasure] = useState('Amount') // 'Amount' | 'Quantity' |
| 133 | + const chartWrapRef = useRef(null) |
| 134 | + const [chartWidth, setChartWidth] = useState(700) |
| 135 | + const [refreshTrigger, setRefreshTrigger] = useState(0) |
| 136 | + |
| 137 | + // Use enginePopulated to pull pivot values directly from engine |
| 138 | + const onEnginePopulated = useCallback(() => { |
| 139 | + if (!pivotRef.current) return |
| 140 | + const model = extractFromEngine(pivotRef.current) |
| 141 | + setChartModel({ ...model }) |
| 142 | + }, []) |
| 143 | + |
| 144 | + // Initialize once on mount and observe chart container for responsive width |
| 145 | + useEffect(() => { |
| 146 | + if (pivotRef.current) setChartModel(extractFromEngine(pivotRef.current)) |
| 147 | + const el = chartWrapRef.current |
| 148 | + if (!el) return |
| 149 | + const update = () => setChartWidth(Math.max(320, el.clientWidth - 24)) |
| 150 | + update() |
| 151 | + const ro = new ResizeObserver(update) |
| 152 | + ro.observe(el) |
| 153 | + return () => ro.disconnect() |
| 154 | + }, []) |
| 155 | + |
| 156 | + // Trigger chart update when dataSourceSettings changes (including measure change) |
| 157 | + useEffect(() => { |
| 158 | + setRefreshTrigger(prev => prev + 1) |
| 159 | + }, [dataSourceSettings]) |
| 160 | + |
| 161 | + // Extract chart data whenever refresh trigger changes |
| 162 | + useEffect(() => { |
| 163 | + if (pivotRef.current && refreshTrigger > 0) { |
| 164 | + // Give pivot time to refresh |
| 165 | + const timer = setTimeout(() => { |
| 166 | + const model = extractFromEngine(pivotRef.current) |
| 167 | + setChartModel({ ...model }) |
| 168 | + }, 150) |
| 169 | + return () => clearTimeout(timer) |
| 170 | + } |
| 171 | + }, [refreshTrigger]) |
| 172 | + |
| 173 | + return ( |
| 174 | + <div className="app-shell"> |
| 175 | + <h1 style={{fontSize:20, marginBottom:4}}>Syncfusion React Pivot Table + D3.js Custom Chart</h1> |
| 176 | + <p className="subtitle" style={{marginBottom:8}}>Two-way interaction: pivot changes update the D3 chart automatically.</p> |
| 177 | + |
| 178 | + <div className="controls"> |
| 179 | + <DropDownListComponent |
| 180 | + width={160} |
| 181 | + dataSource={[ |
| 182 | + {text:'Bar', value:'bar'}, |
| 183 | + {text:'Line', value:'line'}, |
| 184 | + {text:'Stacked Bar', value:'stacked'}, |
| 185 | + {text:'Area', value:'area'}, |
| 186 | + {text:'Pie', value:'pie'}, |
| 187 | + {text:'Donut', value:'donut'}, |
| 188 | + {text:'Scatter', value:'scatter'} |
| 189 | + ]} |
| 190 | + fields={{ text:'text', value:'value' }} |
| 191 | + value={chartType} |
| 192 | + change={(e)=> setChartType(e.value)} |
| 193 | + placeholder="Chart type" |
| 194 | + /> |
| 195 | + <DropDownListComponent |
| 196 | + width={170} |
| 197 | + dataSource={[{text:'Revenue ($)', value:'Amount'}, {text:'Quantity', value:'Quantity'}]} |
| 198 | + fields={{ text:'text', value:'value' }} |
| 199 | + value={measure} |
| 200 | + change={(e)=>{ |
| 201 | + const m = e.value |
| 202 | + setMeasure(m) |
| 203 | + setDataSourceSettings(prev => ({ |
| 204 | + ...prev, |
| 205 | + values: [{ name: m, caption: m === 'Amount' ? 'Revenue ($)' : 'Units', type: 'Sum' }], |
| 206 | + formatSettings: m === 'Amount' ? [{ name: 'Amount', format: 'C0' }] : [{ name: 'Quantity', format: 'N0' }] |
| 207 | + })) |
| 208 | + }} |
| 209 | + placeholder="Measure" |
| 210 | + /> |
| 211 | + </div> |
| 212 | + |
| 213 | + <div className="layout"> |
| 214 | + <div className="panel pivot"> |
| 215 | + <PivotViewComponent |
| 216 | + id="PivotView" |
| 217 | + ref={pivotRef} |
| 218 | + width={'100%'} |
| 219 | + height={'520px'} |
| 220 | + dataSourceSettings={dataSourceSettings} |
| 221 | + showFieldList={true} |
| 222 | + showToolbar={true} |
| 223 | + toolbar={['FieldList']} |
| 224 | + enginePopulated={onEnginePopulated} |
| 225 | + dataBound={onEnginePopulated} |
| 226 | + > |
| 227 | + <Inject services={[FieldList, Toolbar]} /> |
| 228 | + </PivotViewComponent> |
| 229 | + </div> |
| 230 | + <div className="panel chart" ref={chartWrapRef}> |
| 231 | + {chartType === 'bar' ? ( |
| 232 | + <D3GroupedBar |
| 233 | + title={`${chartModel.valueType} of ${chartModel.valueField}`} |
| 234 | + data={chartModel} |
| 235 | + height={520} |
| 236 | + width={chartWidth} |
| 237 | + /> |
| 238 | + ) : chartType === 'line' ? ( |
| 239 | + <D3Line |
| 240 | + title={`${chartModel.valueType} of ${chartModel.valueField}`} |
| 241 | + data={chartModel} |
| 242 | + height={520} |
| 243 | + width={chartWidth} |
| 244 | + /> |
| 245 | + ) : chartType === 'stacked' ? ( |
| 246 | + <D3StackedBar |
| 247 | + title={`${chartModel.valueType} of ${chartModel.valueField}`} |
| 248 | + data={chartModel} |
| 249 | + height={520} |
| 250 | + width={chartWidth} |
| 251 | + /> |
| 252 | + ) : chartType === 'area' ? ( |
| 253 | + <D3Area |
| 254 | + title={`${chartModel.valueType} of ${chartModel.valueField}`} |
| 255 | + data={chartModel} |
| 256 | + height={520} |
| 257 | + width={chartWidth} |
| 258 | + /> |
| 259 | + ) : chartType === 'pie' ? ( |
| 260 | + <D3Pie |
| 261 | + title={`${chartModel.valueType} of ${chartModel.valueField}`} |
| 262 | + data={chartModel} |
| 263 | + height={520} |
| 264 | + width={chartWidth} |
| 265 | + /> |
| 266 | + ) : chartType === 'donut' ? ( |
| 267 | + <D3Donut |
| 268 | + title={`${chartModel.valueType} of ${chartModel.valueField}`} |
| 269 | + data={chartModel} |
| 270 | + height={520} |
| 271 | + width={chartWidth} |
| 272 | + /> |
| 273 | + ) : chartType === 'scatter' ? ( |
| 274 | + <D3Scatter |
| 275 | + title={`${chartModel.valueType} of ${chartModel.valueField}`} |
| 276 | + data={chartModel} |
| 277 | + height={520} |
| 278 | + width={chartWidth} |
| 279 | + /> |
| 280 | + ) : ( |
| 281 | + <D3GroupedBar |
| 282 | + title={`${chartModel.valueType} of ${chartModel.valueField}`} |
| 283 | + data={chartModel} |
| 284 | + height={520} |
| 285 | + width={chartWidth} |
| 286 | + /> |
| 287 | + )} |
| 288 | + |
| 289 | + </div> |
| 290 | + </div> |
| 291 | + |
| 292 | + <footer className="note"> |
| 293 | + <strong>Tip:</strong> Use the Field List to change rows/columns/filters. The chart reflects the current pivot. |
| 294 | + </footer> |
| 295 | + </div> |
| 296 | + ) |
| 297 | +} |
0 commit comments