Files
invyone/frontend/lib/registry/components/table/internals/pivot/hooks/useVirtualScroll.ts
T
DDD1542 49b4cdf562 feat: 테이블 pivot 모드 본체 통째 흡수 (T3b)
5 viewMode 통합 세번째 단계 — Codex 권고 (Pivot 통째 흡수, 회귀 위험
차단) 그대로 적용. v2-pivot-grid 의 본체 + utils + components + hooks +
보조 타입을 모두 table/ 하위로 이전. 리팩토링 X (V2Date picker 패턴 동일).

table/types.ts 보조 타입 흡수
- PivotResult, PivotHeaderNode, PivotFlatRow, PivotFlatColumn, PivotCellValue,
  PivotCellData, PivotGridState, PivotGridProps
- PivotDataSourceType, PivotFilterCondition, PivotJoinConfig,
  PivotDataSourceConfig, PivotTotalsConfig, PivotFieldChooserConfig,
  PivotChartConfig, PivotConditionalFormatRule, PivotStyleConfig,
  PivotExportConfig
- PivotFieldConfig 에 filterValues / filterType / isCalculated /
  calculateFormula 누락 속성 추가
- TableConfig 에 pivot 보조 키 (pivotTotals / pivotStyle /
  pivotFieldChooser / pivotChart / pivotExportConfig)

table/utils/pivot/ 4개 파일 이전 (1505줄)
- pivotEngine.ts (812) — processPivotData / pathToKey / 헤더 트리 / 매트릭스 /
  10종 displayMode (runningTotal, percentDifferenceFromPrevious 등)
- aggregation.ts (180) — sum/count/avg/min/max/countDistinct + 포맷
- conditionalFormat.ts (311) — colorScale/dataBar/iconSet/cellValue 4 종
- exportExcel.ts (202) — Excel 내보내기 (xlsx)
- 옛 prefix(AggregationType 등) → Pivot prefix 일괄 정리

table/internals/pivot/components/ 7개 파일 이전 (2347줄)
- ContextMenu / DrillDownModal / FieldChooser / FieldPanel / FilterPopup /
  PivotChart / index

table/internals/pivot/hooks/ 3개 파일 이전 (570줄)
- usePivotState / useVirtualScroll / index

table/views/PivotView.tsx 신규 (PivotGridComponent.tsx 1963줄 통째 흡수)
- import 경로 일괄 정정 (../../types → ../types, ./utils → ../utils/pivot,
  ./components → ../internals/pivot/components, ./hooks →
  ../internals/pivot/hooks)
- 컴포넌트 이름 PivotGridComponent → PivotView
- 본체 로직 그대로 (리팩토링 X)

TableComponent.switch
- pivot 분기 placeholder 제거 → PivotView 호출
- DOM filter 에 pivotTotals/pivotStyle/pivotFieldChooser/pivotChart/
  pivotExportConfig 추가

13 files, +5400+ insertions. v2-pivot-grid/ 폴더 자체는 Phase T5 dead code
일괄 삭제에서 정리 예정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:23:39 +09:00

313 lines
7.7 KiB
TypeScript

"use client";
/**
* Virtual Scroll 훅
* 대용량 피벗 데이터의 가상 스크롤 처리
*/
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
// ==================== 타입 ====================
export interface VirtualScrollOptions {
itemCount: number; // 전체 아이템 수
itemHeight: number; // 각 아이템 높이 (px)
containerHeight: number; // 컨테이너 높이 (px)
overscan?: number; // 버퍼 아이템 수 (기본: 5)
}
export interface VirtualScrollResult {
// 현재 보여야 할 아이템 범위
startIndex: number;
endIndex: number;
// 가상 스크롤 관련 값
totalHeight: number; // 전체 높이
offsetTop: number; // 상단 오프셋
// 보여지는 아이템 목록
visibleItems: number[];
// 이벤트 핸들러
onScroll: (scrollTop: number) => void;
// 컨테이너 ref
containerRef: React.RefObject<HTMLDivElement | null>;
}
// ==================== 훅 ====================
export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult {
const {
itemCount,
itemHeight,
containerHeight,
overscan = 5,
} = options;
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
// 보이는 아이템 수
const visibleCount = Math.ceil(containerHeight / itemHeight);
// 시작/끝 인덱스 계산
const { startIndex, endIndex } = useMemo(() => {
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const end = Math.min(
itemCount - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
);
return { startIndex: start, endIndex: end };
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
// 전체 높이
const totalHeight = itemCount * itemHeight;
// 상단 오프셋
const offsetTop = startIndex * itemHeight;
// 보이는 아이템 인덱스 배열
const visibleItems = useMemo(() => {
const items: number[] = [];
for (let i = startIndex; i <= endIndex; i++) {
items.push(i);
}
return items;
}, [startIndex, endIndex]);
// 스크롤 핸들러
const onScroll = useCallback((newScrollTop: number) => {
setScrollTop(newScrollTop);
}, []);
// 스크롤 이벤트 리스너
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
setScrollTop(container.scrollTop);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, []);
return {
startIndex,
endIndex,
totalHeight,
offsetTop,
visibleItems,
onScroll,
containerRef,
};
}
// ==================== 열 가상 스크롤 ====================
export interface VirtualColumnScrollOptions {
columnCount: number; // 전체 열 수
columnWidth: number; // 각 열 너비 (px)
containerWidth: number; // 컨테이너 너비 (px)
overscan?: number;
}
export interface VirtualColumnScrollResult {
startIndex: number;
endIndex: number;
totalWidth: number;
offsetLeft: number;
visibleColumns: number[];
onScroll: (scrollLeft: number) => void;
}
export function useVirtualColumnScroll(
options: VirtualColumnScrollOptions
): VirtualColumnScrollResult {
const {
columnCount,
columnWidth,
containerWidth,
overscan = 3,
} = options;
const [scrollLeft, setScrollLeft] = useState(0);
const { startIndex, endIndex } = useMemo(() => {
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan);
const end = Math.min(
columnCount - 1,
Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan
);
return { startIndex: start, endIndex: end };
}, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]);
const totalWidth = columnCount * columnWidth;
const offsetLeft = startIndex * columnWidth;
const visibleColumns = useMemo(() => {
const cols: number[] = [];
for (let i = startIndex; i <= endIndex; i++) {
cols.push(i);
}
return cols;
}, [startIndex, endIndex]);
const onScroll = useCallback((newScrollLeft: number) => {
setScrollLeft(newScrollLeft);
}, []);
return {
startIndex,
endIndex,
totalWidth,
offsetLeft,
visibleColumns,
onScroll,
};
}
// ==================== 2D 가상 스크롤 (행 + 열) ====================
export interface Virtual2DScrollOptions {
rowCount: number;
columnCount: number;
rowHeight: number;
columnWidth: number;
containerHeight: number;
containerWidth: number;
rowOverscan?: number;
columnOverscan?: number;
}
export interface Virtual2DScrollResult {
// 행 범위
rowStartIndex: number;
rowEndIndex: number;
totalHeight: number;
offsetTop: number;
visibleRows: number[];
// 열 범위
columnStartIndex: number;
columnEndIndex: number;
totalWidth: number;
offsetLeft: number;
visibleColumns: number[];
// 스크롤 핸들러
onScroll: (scrollTop: number, scrollLeft: number) => void;
// 컨테이너 ref
containerRef: React.RefObject<HTMLDivElement | null>;
}
export function useVirtual2DScroll(
options: Virtual2DScrollOptions
): Virtual2DScrollResult {
const {
rowCount,
columnCount,
rowHeight,
columnWidth,
containerHeight,
containerWidth,
rowOverscan = 5,
columnOverscan = 3,
} = options;
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
// 행 계산
const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => {
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan);
const end = Math.min(
rowCount - 1,
Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan
);
const rows: number[] = [];
for (let i = start; i <= end; i++) {
rows.push(i);
}
return {
rowStartIndex: start,
rowEndIndex: end,
visibleRows: rows,
};
}, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]);
// 열 계산
const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => {
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan);
const end = Math.min(
columnCount - 1,
Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan
);
const cols: number[] = [];
for (let i = start; i <= end; i++) {
cols.push(i);
}
return {
columnStartIndex: start,
columnEndIndex: end,
visibleColumns: cols,
};
}, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]);
const totalHeight = rowCount * rowHeight;
const totalWidth = columnCount * columnWidth;
const offsetTop = rowStartIndex * rowHeight;
const offsetLeft = columnStartIndex * columnWidth;
const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => {
setScrollTop(newScrollTop);
setScrollLeft(newScrollLeft);
}, []);
// 스크롤 이벤트 리스너
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
setScrollTop(container.scrollTop);
setScrollLeft(container.scrollLeft);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, []);
return {
rowStartIndex,
rowEndIndex,
totalHeight,
offsetTop,
visibleRows,
columnStartIndex,
columnEndIndex,
totalWidth,
offsetLeft,
visibleColumns,
onScroll,
containerRef,
};
}
export default useVirtualScroll;