"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; } // ==================== 훅 ==================== export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult { const { itemCount, itemHeight, containerHeight, overscan = 5, } = options; const containerRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); // 보이는 아이템 수 const visibleCount = Math.ceil(containerHeight / itemHeight); // 시작/끝 인덱스 계산 (음수 방지) const { startIndex, endIndex } = useMemo(() => { // itemCount가 0이면 빈 배열 if (itemCount === 0) { return { startIndex: 0, endIndex: -1 }; } 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: Math.max(start, end) }; // end가 start보다 작지 않도록 }, [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; } export function useVirtual2DScroll( options: Virtual2DScrollOptions ): Virtual2DScrollResult { const { rowCount, columnCount, rowHeight, columnWidth, containerHeight, containerWidth, rowOverscan = 5, columnOverscan = 3, } = options; const containerRef = useRef(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;