ERP-node/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts

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>;
}
// ==================== 훅 ====================
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>;
}
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;