강력한 셀 확장 (itemRender)
itemRender로 Canvas 차트, 설비 부하 히트맵, 상태 게이지와 행 단위 액션을 한 Grid에 구성하는 실전 패턴을 설명합니다.
import * as React from 'react';
import { BGrid, BGridColumn, BGridDataItem } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import './ItemRenderExample.css';
type CenterStatus = '정상' | '관찰' | '대응 필요';
interface FulfillmentCenter {
centerId: string;
centerName: string;
region: string;
status: CenterStatus;
throughput: number[];
utilization: number[];
sla: number;
alerts: number;
updatedAt: string;
}
const initialRows: BGridDataItem<FulfillmentCenter>[] = [
[
'FC-SEO-01',
'서울 동부 센터',
'수도권',
'정상',
[72, 78, 76, 84, 88, 91, 96, 102],
[42, 51, 64, 72, 78, 83, 76, 69, 62, 58, 47, 39],
98,
0,
'10:42',
],
[
'FC-GMP-02',
'김포 허브',
'수도권',
'관찰',
[96, 92, 99, 104, 101, 112, 108, 118],
[55, 62, 74, 88, 94, 96, 91, 85, 72, 61, 53, 48],
91,
2,
'10:41',
],
[
'FC-ICN-01',
'인천 항공 센터',
'수도권',
'정상',
[64, 70, 74, 72, 79, 81, 86, 89],
[31, 37, 42, 48, 55, 61, 66, 63, 54, 46, 39, 34],
96,
0,
'10:40',
],
[
'FC-DAE-01',
'대전 중앙 허브',
'충청권',
'대응 필요',
[122, 119, 116, 111, 106, 101, 95, 88],
[68, 78, 86, 92, 97, 99, 96, 91, 84, 76, 67, 59],
82,
5,
'10:39',
],
[
'FC-BUS-02',
'부산 남부 센터',
'영남권',
'정상',
[51, 55, 54, 61, 65, 69, 72, 76],
[28, 34, 39, 45, 52, 58, 62, 57, 49, 42, 35, 30],
97,
0,
'10:38',
],
[
'FC-DAE-03',
'대구 라스트마일',
'영남권',
'관찰',
[83, 87, 91, 96, 94, 101, 98, 104],
[44, 52, 61, 73, 84, 89, 86, 79, 68, 57, 49, 41],
89,
3,
'10:37',
],
[
'FC-GWJ-01',
'광주 서부 센터',
'호남권',
'정상',
[46, 48, 52, 55, 58, 63, 61, 67],
[25, 29, 35, 41, 48, 54, 59, 55, 46, 38, 32, 27],
95,
0,
'10:36',
],
[
'FC-JEJ-01',
'제주 배송 거점',
'제주권',
'관찰',
[38, 41, 39, 45, 51, 48, 56, 53],
[22, 28, 34, 47, 64, 72, 68, 55, 43, 35, 29, 24],
90,
1,
'10:35',
],
].map(([centerId, centerName, region, status, throughput, utilization, sla, alerts, updatedAt]) => ({
values: {
centerId: String(centerId),
centerName: String(centerName),
region: String(region),
status: status as CenterStatus,
throughput: throughput as number[],
utilization: utilization as number[],
sla: Number(sla),
alerts: Number(alerts),
updatedAt: String(updatedAt),
},
}));
const statusTone: Record<CenterStatus, string> = {
정상: 'healthy',
관찰: 'watch',
'대응 필요': 'critical',
};
const CenterIdentity = React.memo(function CenterIdentity({ values }: { values: FulfillmentCenter }) {
return (
<div className='item-render-center'>
<span className='item-render-center__mark' aria-hidden='true'>
{values.region.slice(0, 1)}
</span>
<span className='item-render-center__copy'>
<strong>{values.centerName}</strong>
<small>
{values.centerId} · {values.region}
</small>
</span>
</div>
);
});
const SparklineCanvas = React.memo(function SparklineCanvas({
values,
status,
}: {
values: number[];
status: CenterStatus;
}) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const latest = values[values.length - 1];
const previous = values[values.length - 2];
const delta = latest - previous;
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
const width = 118;
const height = 28;
const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
const color = status === '대응 필요' ? '#dc2626' : status === '관찰' ? '#d97706' : '#2563eb';
const min = Math.min(...values);
const max = Math.max(...values);
const range = Math.max(1, max - min);
const points = values.map((value, index) => ({
x: 2 + (index / (values.length - 1)) * (width - 4),
y: height - 3 - ((value - min) / range) * (height - 7),
}));
context.beginPath();
points.forEach((point, index) =>
index === 0 ? context.moveTo(point.x, point.y) : context.lineTo(point.x, point.y),
);
context.lineTo(points[points.length - 1].x, height - 2);
context.lineTo(points[0].x, height - 2);
context.closePath();
const gradient = context.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, `${color}42`);
gradient.addColorStop(1, `${color}05`);
context.fillStyle = gradient;
context.fill();
context.beginPath();
points.forEach((point, index) =>
index === 0 ? context.moveTo(point.x, point.y) : context.lineTo(point.x, point.y),
);
context.strokeStyle = color;
context.lineWidth = 1.8;
context.lineJoin = 'round';
context.lineCap = 'round';
context.stroke();
const last = points[points.length - 1];
context.beginPath();
context.arc(last.x, last.y, 2.5, 0, Math.PI * 2);
context.fillStyle = color;
context.fill();
}, [status, values]);
return (
<div className='item-render-trend'>
<canvas
ref={canvasRef}
className='item-render-trend__canvas'
role='img'
aria-label={`최근 8개 구간 처리량 ${values.join(', ')}`}
/>
<span className='item-render-trend__metric'>
<strong>{latest}</strong>
<small className={delta >= 0 ? 'is-up' : 'is-down'}>
{delta >= 0 ? '+' : ''}
{delta}
</small>
</span>
</div>
);
});
const UtilizationCanvas = React.memo(function UtilizationCanvas({ values }: { values: number[] }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const peak = Math.max(...values);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
const width = 144;
const height = 18;
const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
const gap = 2;
const blockWidth = (width - gap * (values.length - 1)) / values.length;
values.forEach((value, index) => {
const lightness = 96 - Math.min(1, value / 100) * 48;
context.fillStyle = `hsl(${value >= 90 ? 4 : value >= 75 ? 36 : 216} 82% ${lightness}%)`;
context.beginPath();
context.roundRect(index * (blockWidth + gap), 1, blockWidth, height - 2, 2);
context.fill();
});
}, [values]);
return (
<div className='item-render-heatmap'>
<canvas
ref={canvasRef}
className='item-render-heatmap__canvas'
role='img'
aria-label={`시간대별 설비 부하 ${values.join(', ')} 퍼센트`}
/>
<span>
최고 <strong>{peak}%</strong>
</span>
</div>
);
});
const SlaGauge = React.memo(function SlaGauge({ value }: { value: number }) {
const tone = value >= 95 ? 'healthy' : value >= 88 ? 'watch' : 'critical';
return (
<div
className='item-render-gauge'
role='progressbar'
aria-label='출고 SLA 달성률'
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
>
<span
className={`item-render-gauge__ring item-render-gauge__ring--${tone}`}
style={{ '--gauge-value': `${value * 3.6}deg` } as React.CSSProperties}
>
<strong>{value}</strong>
</span>
<span className='item-render-gauge__label'>
SLA<small>{value >= 95 ? '안정' : value >= 88 ? '주의' : '위험'}</small>
</span>
</div>
);
});
function ItemRenderExample() {
const [rows, setRows] = React.useState(initialRows);
const [onlyAttention, setOnlyAttention] = React.useState(false);
const [selectedCenter, setSelectedCenter] = React.useState('행을 선택하면 센터 정보가 표시됩니다.');
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const acknowledgeAlerts = React.useCallback((centerId: string) => {
setRows(current =>
current.map(row =>
row.values.centerId === centerId ? { ...row, values: { ...row.values, alerts: 0, status: '정상' } } : row,
),
);
}, []);
const columns = React.useMemo<BGridColumn<FulfillmentCenter>[]>(
() => [
{
key: 'centerName',
label: '물류 거점',
width: 195,
itemRender: ({ values }) => <CenterIdentity values={values} />,
getClipboardText: ({ values }) => `${values.centerName} (${values.centerId})`,
},
{
key: 'status',
label: '운영 상태',
width: 105,
align: 'center',
itemRender: ({ value }) => (
<span className={`item-render-status item-render-status--${statusTone[value as CenterStatus]}`}>
<i aria-hidden='true' />
{String(value)}
</span>
),
},
{
key: 'throughput',
label: '시간당 처리량 추이',
width: 205,
itemRender: ({ values }) => <SparklineCanvas values={values.throughput} status={values.status} />,
getClipboardText: ({ values }) => `${values.throughput[values.throughput.length - 1]} orders/h`,
},
{
key: 'utilization',
label: '12시간 설비 부하',
width: 205,
itemRender: ({ value }) => <UtilizationCanvas values={value as number[]} />,
getClipboardText: ({ value }) => (value as number[]).join(','),
},
{
key: 'sla',
label: '출고 SLA',
width: 125,
align: 'center',
itemRender: ({ value }) => <SlaGauge value={Number(value)} />,
getClipboardText: ({ value }) => `${value}%`,
},
{
key: 'alerts',
label: '이상 대응',
width: 150,
align: 'center',
itemRender: ({ values }) =>
values.alerts > 0 ? (
<button
type='button'
className='item-render-action'
aria-label={`${values.centerName} 알림 ${values.alerts}건 확인 처리`}
onClick={event => {
event.stopPropagation();
acknowledgeAlerts(values.centerId);
}}
>
알림 {values.alerts}건 확인
</button>
) : (
<span className='item-render-clear'>이상 없음</span>
),
getClipboardText: ({ values }) => (values.alerts > 0 ? `알림 ${values.alerts}건` : '이상 없음'),
},
{ key: 'updatedAt', label: '갱신', width: 80, align: 'center' },
],
[acknowledgeAlerts],
);
const displayedRows = React.useMemo(
() => (onlyAttention ? rows.filter(row => row.values.alerts > 0) : rows),
[onlyAttention, rows],
);
return (
<div className='item-render-example'>
<div className='item-render-example__toolbar'>
<div>
<span className='item-render-example__eyebrow'>FULFILLMENT CONTROL TOWER</span>
<strong>셀 안에 운영 대시보드를 구성합니다</strong>
<small>Canvas 2종 · 복합 React UI · 행 단위 액션</small>
</div>
<button
type='button'
className='item-render-example__filter'
aria-pressed={onlyAttention}
onClick={() => setOnlyAttention(value => !value)}
>
<span aria-hidden='true' />
이상 거점만 보기
<b>{rows.filter(row => row.values.alerts > 0).length}</b>
</button>
</div>
<DataGridContainer ref={containerRef} className='item-render-example__grid-container'>
<BGrid<FulfillmentCenter>
className='item-render-dashboard-grid'
width={width}
height={height}
columns={columns}
data={displayedRows}
rowKey='centerId'
headerHeight={38}
itemHeight={44}
itemPadding={4}
frozenColumnIndex={1}
variant='vertical-bordered'
cellSelectionOptions={{ enabled: true }}
cellNavigationOptions={{ enabled: true, defaultActiveCell: { rowIndex: 0, columnIndex: 0 } }}
onClick={({ item }) =>
setSelectedCenter(`${item.values.centerName} · SLA ${item.values.sla}% · ${item.values.updatedAt} 갱신`)
}
status={{
content: `${displayedRows.length}개 거점 · ${onlyAttention ? '이상 대응 대상' : '전체 운영 현황'}`,
}}
/>
</DataGridContainer>
<div className='item-render-example__footer' aria-live='polite'>
<span className='item-render-example__live-dot' aria-hidden='true' />
{selectedCenter}
</div>
</div>
);
}
export default ItemRenderExample;.item-render-example {
--item-render-ink: #0f172a;
--item-render-muted: #64748b;
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
color: var(--item-render-ink);
}
.item-render-example__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #dbe2ea;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #eff6ff 100%);
}
.item-render-example__toolbar > div,
.item-render-example__toolbar strong,
.item-render-example__toolbar small {
display: block;
}
.item-render-example__eyebrow {
display: block;
margin-bottom: 2px;
color: #2563eb;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.1em;
}
.item-render-example__toolbar strong {
font-size: 14px;
}
.item-render-example__toolbar small {
margin-top: 2px;
color: var(--item-render-muted);
font-size: 11px;
}
.item-render-example__filter {
display: inline-flex;
min-height: 36px;
flex: 0 0 auto;
align-items: center;
gap: 7px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 9px;
color: #334155;
background: #ffffff;
font: inherit;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.item-render-example__filter:hover,
.item-render-example__filter[aria-pressed='true'] {
border-color: #93c5fd;
color: #1d4ed8;
background: #eff6ff;
}
.item-render-example__filter > span,
.item-render-example__live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #f59e0b;
}
.item-render-example__filter b {
display: inline-grid;
min-width: 19px;
height: 19px;
place-items: center;
border-radius: 999px;
color: #ffffff;
background: #2563eb;
font-size: 10px;
}
.item-render-example .item-render-example__grid-container {
height: 430px;
}
.item-render-example__footer {
display: flex;
min-height: 22px;
align-items: center;
gap: 7px;
color: var(--item-render-muted);
font-size: 11px;
}
.item-render-example__live-dot {
flex: 0 0 auto;
background: #22c55e;
box-shadow: 0 0 0 3px #dcfce7;
}
[role='grid'].item-render-dashboard-grid .item-render-center,
[role='grid'].item-render-dashboard-grid .item-render-trend,
[role='grid'].item-render-dashboard-grid .item-render-heatmap,
[role='grid'].item-render-dashboard-grid .item-render-gauge {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
line-height: normal;
}
[role='grid'].item-render-dashboard-grid .item-render-center {
gap: 9px;
}
.item-render-center__mark {
display: inline-grid;
width: 30px;
height: 30px;
flex: 0 0 auto;
place-items: center;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1d4ed8;
background: #eff6ff;
font-size: 11px;
font-weight: 800;
}
.item-render-center__copy,
.item-render-center__copy strong,
.item-render-center__copy small {
display: block;
min-width: 0;
}
.item-render-center__copy strong {
overflow: hidden;
color: #0f172a;
font-size: 12px;
text-overflow: ellipsis;
}
.item-render-center__copy small {
margin-top: 2px;
overflow: hidden;
color: #64748b;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 9px;
text-overflow: ellipsis;
}
.item-render-status {
display: inline-flex;
min-width: 68px;
align-items: center;
justify-content: center;
gap: 5px;
padding: 3px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 750;
line-height: normal;
}
.item-render-status i {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.item-render-status--healthy {
color: #15803d;
background: #dcfce7;
}
.item-render-status--watch {
color: #b45309;
background: #fef3c7;
}
.item-render-status--critical {
color: #b91c1c;
background: #fee2e2;
}
[role='grid'].item-render-dashboard-grid .item-render-trend {
gap: 8px;
}
.item-render-trend__canvas {
width: 118px;
height: 28px;
flex: 0 0 auto;
}
.item-render-trend__metric,
.item-render-trend__metric strong,
.item-render-trend__metric small {
display: block;
}
.item-render-trend__metric strong {
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.item-render-trend__metric small {
margin-top: 1px;
font-size: 9px;
font-weight: 700;
}
.item-render-trend__metric .is-up {
color: #2563eb;
}
.item-render-trend__metric .is-down {
color: #dc2626;
}
[role='grid'].item-render-dashboard-grid .item-render-heatmap {
gap: 8px;
}
.item-render-heatmap__canvas {
width: 144px;
height: 18px;
flex: 0 0 auto;
}
.item-render-heatmap > span {
color: #64748b;
font-size: 9px;
}
.item-render-heatmap strong {
display: block;
color: #0f172a;
font-size: 10px;
}
[role='grid'].item-render-dashboard-grid .item-render-gauge {
justify-content: center;
gap: 7px;
}
.item-render-gauge__ring {
position: relative;
display: inline-grid;
width: 32px;
height: 32px;
flex: 0 0 auto;
place-items: center;
border-radius: 50%;
background: conic-gradient(#2563eb var(--gauge-value), #e2e8f0 0);
}
.item-render-gauge__ring::after {
position: absolute;
inset: 4px;
border-radius: 50%;
content: '';
background: #ffffff;
}
.item-render-gauge__ring--watch {
background: conic-gradient(#d97706 var(--gauge-value), #e2e8f0 0);
}
.item-render-gauge__ring--critical {
background: conic-gradient(#dc2626 var(--gauge-value), #e2e8f0 0);
}
.item-render-gauge__ring strong {
position: relative;
z-index: 1;
color: #0f172a;
font-size: 9px;
}
.item-render-gauge__label,
.item-render-gauge__label small {
display: block;
text-align: left;
}
.item-render-gauge__label {
color: #475569;
font-size: 9px;
font-weight: 700;
}
.item-render-gauge__label small {
margin-top: 1px;
color: #94a3b8;
font-size: 8px;
}
.item-render-action {
min-height: 28px;
padding: 0 9px;
border: 1px solid #fecaca;
border-radius: 7px;
color: #b91c1c;
background: #fff7f7;
font: inherit;
font-size: 10px;
font-weight: 700;
line-height: normal;
cursor: pointer;
}
.item-render-action:hover {
border-color: #fca5a5;
background: #fee2e2;
}
.item-render-action:focus-visible,
.item-render-example__filter:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.item-render-clear {
color: #15803d;
font-size: 10px;
font-weight: 700;
line-height: normal;
}
@media (max-width: 640px) {
.item-render-example__toolbar {
align-items: stretch;
flex-direction: column;
}
.item-render-example__filter {
justify-content: center;
}
.item-render-example .item-render-example__grid-container {
height: 410px;
}
}import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* BGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .bgrid-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}텍스트 셀을 작은 애플리케이션으로 확장하기
itemRender는 값을 문자열로 포맷하는 함수에 머물지 않습니다. 현재 행의 values, 셀의 value, 행·컬럼 인덱스와 편집 제어 함수를 받아 임의의 React 노드를 반환하므로, 보통의 formatter 기반 Grid에서 만들기 까다로운 시각화와 상호작용을 셀 안에 배치할 수 있습니다.
위 물류 관제 예제는 동일한 행 데이터에서 다음 UI를 렌더링합니다.
| 셀 | 구현 | 일반 텍스트 셀보다 어려운 점 |
|---|---|---|
| 물류 거점 | 아이콘, 이름, 코드가 결합된 복합 셀 | 한 값이 아니라 여러 행 필드를 함께 사용합니다. |
| 처리량 추이 | 고해상도 Canvas 스파크라인 | 배열을 좌표로 변환하고 기기 픽셀 비율에 맞춰 그립니다. |
| 설비 부하 | 12구간 Canvas 히트맵 | 수치 구간에 따라 색상과 블록을 동적으로 계산합니다. |
| 출고 SLA | CSS 원형 게이지와 상태 텍스트 | 수치, 상태, 접근성 값을 하나의 컴포넌트로 묶습니다. |
| 이상 대응 | 행 상태를 변경하는 버튼 | 셀 클릭과 버튼 클릭의 이벤트 경계를 관리합니다. |
이상 거점만 보기를 누르면 React 상태로 표시 행을 좁힐 수 있고, 알림 N건 확인은 해당 행만 immutable하게 갱신합니다. Canvas는 지속적으로 애니메이션하지 않고 데이터가 바뀔 때만 다시 그립니다.
핵심 패턴: 콜백에서 컴포넌트를 반환합니다
itemRender 자체에서 Hook을 호출하지 말고, Hook을 사용하는 React 컴포넌트를 반환하세요. 이렇게 하면 React의 Hook 규칙을 지키면서 Canvas 수명주기와 memoization을 독립적으로 관리할 수 있습니다.
const SparklineCanvas = React.memo(function SparklineCanvas({ values }: { values: number[] }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) return;
// devicePixelRatio를 반영한 뒤 values를 좌표로 변환해 그립니다.
}, [values]);
return <canvas ref={canvasRef} role='img' aria-label={`처리량 ${values.join(', ')}`} />;
});
const columns: BGridColumn<FulfillmentCenter>[] = [
{
key: 'throughput',
label: '시간당 처리량 추이',
width: 205,
itemRender: ({ values }) => <SparklineCanvas values={values.throughput} />,
getClipboardText: ({ values }) => `${values.throughput.at(-1)} orders/h`,
},
];
실제 Canvas 좌표 계산, 색상 구간, 액션 버튼까지 포함한 전체 구현은 이 페이지의 소스 패널에서 확인할 수 있습니다.
전달되는 렌더링 컨텍스트
| 속성 | 활용 예 |
|---|---|
value |
현재 column.key가 가리키는 값으로 단일 차트나 게이지를 만듭니다. |
values |
같은 행의 여러 필드를 조합해 이름+코드, 값+상태 같은 복합 셀을 만듭니다. |
item |
values와 함께 행의 status, checked 같은 Grid 상태를 확인합니다. |
index, columnIndex |
시각화의 접근성 이름이나 셀별 진단 정보에 위치를 포함합니다. |
handleSave, handleCancel, handleMove |
editable 컬럼의 커스텀 편집 흐름을 제어합니다. |
표시 컴포넌트와 편집 UI의 역할은 다릅니다. 평상시의 복합 표시는 itemRender, 입력과 저장 수명주기는 editor 또는 편집 제어 함수로 분리하는 편이 명확합니다.
Canvas와 가상 스크롤을 함께 쓸 때
BeautifulGrid의 가상 스크롤은 현재 뷰포트에 필요한 행만 DOM에 유지합니다. 따라서 Canvas 셀도 화면에 들어올 때 mount되고 벗어나면 unmount될 수 있습니다.
- 좌표 변환이나 색상 구간처럼 반복되는 계산은 렌더 전에 가공하거나 작은 컴포넌트 안에 한정합니다.
- 컬럼 배열은
useMemo, 이벤트 함수는useCallback, 무거운 셀 컴포넌트는React.memo를 사용합니다. - 지속적인
requestAnimationFrame보다 데이터가 변경될 때 한 번 다시 그리는 방식을 우선합니다. - Canvas의 CSS 크기와 실제 픽셀 크기를
devicePixelRatio에 맞춰 선명도를 유지합니다. - 행 높이는 콘텐츠에 맞게 고정하고 목표 데이터 규모에서 실제 스크롤을 확인합니다.
Canvas가 많다고 항상 느린 것은 아니지만, 셀마다 별도 그래픽 컨텍스트가 생깁니다. 수천 개 Canvas를 한 번에 DOM에 두는 구조가 아니라 가상 스크롤 범위 안에서 필요한 셀만 mount되는지 확인해야 합니다.
검색·복사·접근성은 별도로 정의합니다
시각화된 DOM이나 Canvas 픽셀은 Grid의 검색·복사 문자열이 아닙니다. 화면 표시와 데이터 의미가 다르면 다음 계약을 함께 구성하세요.
getClipboardText: 배열이나 객체 대신 사용자가 이해할 복사 문자열을 반환합니다.getSearchText: 검색 UI를 사용하는 경우 차트가 의미하는 값이나 라벨을 검색 문자열로 제공합니다.- Canvas에
role="img"와 데이터 의미를 요약한aria-label을 제공합니다. - 상태는 색상만으로 구분하지 말고
정상,관찰,대응 필요같은 텍스트를 함께 표시합니다. - 셀 내부 버튼에는 구체적인
aria-label을 붙이고, 행 클릭을 함께 실행하지 않을 때event.stopPropagation()을 호출합니다.
적합한 사용 사례와 경계
itemRender가 특히 유용한 화면은 운영 관제, 생산 설비 상태, 포트폴리오 변화, 품질 검사 결과, 재고 위험도처럼 행 비교와 작은 시각화가 동시에 중요한 Grid입니다.
반대로 여러 셀을 연결한 대형 차트, 자유 배치 대시보드, 고주사율 애니메이션은 Grid 셀보다 독립된 차트 영역에 두는 편이 낫습니다. itemRender의 강점은 각 행의 문맥을 잃지 않으면서 필요한 정보 밀도와 조작성을 높이는 데 있습니다.