49b4cdf562
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>
313 lines
7.7 KiB
TypeScript
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;
|
|
|