317 lines
7.8 KiB
TypeScript
317 lines
7.8 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(() => {
|
|
// 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<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;
|
|
|