import { createContext, useContext, useMemo } from 'react'; type CellTextProps = Omit< React.SVGAttributes, 'x' | 'y' | 'width' | 'height' >; type CellBoxProps = Omit< React.SVGAttributes, 'x' | 'y' | 'width' | 'height' | 'dx' | 'dy' >; interface StyleCell { cellTextProps?: CellTextProps; cellBoxProps?: CellBoxProps; } interface BaseSVGTableColumn extends StyleCell { header: string; width: number; minWidth?: number; rowSpanGroupKey?: keyof T; headerTextProps?: CellTextProps; headerBoxProps?: CellBoxProps; } interface AccessorKeyColumn extends BaseSVGTableColumn { accessorKey: string; } interface AccessorFuncColumn extends BaseSVGTableColumn { accessorFun: (row: T) => number | string; } export type SVGTableColumn = AccessorKeyColumn | AccessorFuncColumn; function isAccessorKeyColumn( column: SVGTableColumn, ): column is AccessorKeyColumn { return 'accessorKey' in column; } interface ColumnOptions { key: string; x: number; width: number; } type InternalColumns = SVGTableColumn & { _columnOptions: ColumnOptions }; interface SVGTableContextProps { rowHeight: number; } const SVGTableContext = createContext(null); function useSVGTable() { return useContext(SVGTableContext); } function mapColumns(columns: Array>) { let x = 0; const output: Array> = []; for (const column of columns) { const width = Math.max(column.width, column.minWidth || 0); const options: ColumnOptions = { width, x, key: crypto.randomUUID() }; output.push({ ...column, _columnOptions: options }); x += Math.max(column.width, column.minWidth || 0); } return { width: x, columns: output }; } interface FormatKeyOptions { rowIndex: number; columnKey: string; groupKey: string; } function formatKey(options: FormatKeyOptions) { const { rowIndex, columnKey, groupKey } = options; return `GroupKey[${groupKey || null}]-ColumnKey[${columnKey}]-RowIndex[${rowIndex}]`; } function mapRowsSpan(data: T[], columns: Array>) { const rowSpanMap = new Map(); const skipColumns = new Set(); // columns that should be skipped for (const col of columns) { if (!col.rowSpanGroupKey) continue; // Skip columns without row spanning let lastValue: string | null = null; let lastRowIndex = 0; let lastGroupKey: string | null = null; for (let rowIndex = 0; rowIndex < data.length; rowIndex++) { const row = data[rowIndex]; const groupKey = String(row?.[col.rowSpanGroupKey] || ''); const key = col._columnOptions.key; const value = isAccessorKeyColumn(col) ? (row as any)[col.accessorKey] : col.accessorFun(row); if ( value === lastValue && lastRowIndex !== null && groupKey === lastGroupKey ) { //skipped row columns const prevKey = formatKey({ groupKey, columnKey: key, rowIndex: lastRowIndex, }); rowSpanMap.set(prevKey, (rowSpanMap.get(prevKey) || 1) + 1); skipColumns.add(formatKey({ groupKey, columnKey: key, rowIndex })); } else { // Start a new rowspan lastValue = value; lastRowIndex = rowIndex; lastGroupKey = groupKey; rowSpanMap.set(formatKey({ groupKey, columnKey: key, rowIndex }), 1); } } } return { rowSpanMap, skipColumns }; } interface SVGTableProps { columns: Array>; rowHeight?: number; data: T[]; } export function SVGTable(props: SVGTableProps) { const { columns: externalColumns, data, rowHeight = 25 } = props; const { width, columns } = mapColumns(externalColumns); const tableOptions = useMemo(() => { return { rowHeight }; }, [rowHeight]); const { rowSpanMap, skipColumns } = useMemo(() => { return mapRowsSpan(data, columns); }, [columns, data]); return ( {columns.map((col) => { const { header, headerBoxProps, headerTextProps, _columnOptions: { key }, } = col; return ( ); })} {data.map((row, rowIndex) => { return ( {columns.map((col) => { const { cellBoxProps, cellTextProps, _columnOptions: { key: columnKey }, } = col; const groupKey = col.rowSpanGroupKey ? row?.[col.rowSpanGroupKey] : ''; const cellKey = formatKey({ groupKey: String(groupKey), columnKey, rowIndex, }); if (skipColumns.has(cellKey)) { return null; // Skip merged row } const rowSpan = rowSpanMap.get(cellKey) || 1; return ( ); })} ); })} ); } interface BaseColumnProps extends StyleCell { column: InternalColumns; rowSpan: number; } interface ColumnProps extends BaseColumnProps { value: string | number; } interface ValueColumnProps extends BaseColumnProps { row: T; } function ValueColumn(props: ValueColumnProps) { const { row, ...otherProps } = props; const column = otherProps.column; let value: number | string = ''; if (isAccessorKeyColumn(column)) { value = (row as any)[column.accessorKey]; } else { value = column.accessorFun(row); } return ; } function Column(props: ColumnProps) { const { value, column, cellTextProps, cellBoxProps, rowSpan } = props; const tableOptions = useSVGTable(); if (!tableOptions) return null; const { _columnOptions: { x, width }, } = column; const { rowHeight } = tableOptions; return ( {value} ); }