perf(timeline): add horizontal virtualization for allocation bars
Tracks scroll position via requestAnimationFrame to avoid re-renders on every pixel. Allocation bars outside the visible horizontal window (+ 10-column overscan) are skipped during render, reducing DOM nodes significantly at day zoom (365 days × 40px = 14,600px canvas). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,9 @@ interface TimelineResourcePanelProps {
|
||||
optimisticAllocations: TimelineVisualOverrides;
|
||||
suppressHoverInteractions: boolean;
|
||||
onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void;
|
||||
/** Horizontal virtualization: current scroll offset and visible container width */
|
||||
scrollLeft?: number;
|
||||
containerWidth?: number;
|
||||
// Layout from useTimelineLayout
|
||||
CELL_WIDTH: number;
|
||||
dates: Date[];
|
||||
@@ -130,6 +133,8 @@ function TimelineResourcePanelInner({
|
||||
optimisticAllocations,
|
||||
suppressHoverInteractions,
|
||||
onInlineEdit,
|
||||
scrollLeft = 0,
|
||||
containerWidth = 1200,
|
||||
CELL_WIDTH,
|
||||
dates,
|
||||
totalCanvasWidth,
|
||||
@@ -513,6 +518,8 @@ function TimelineResourcePanelInner({
|
||||
multiSelectState,
|
||||
suppressHoverInteractions,
|
||||
onInlineEdit,
|
||||
scrollLeft,
|
||||
containerWidth,
|
||||
)}
|
||||
{filters.showVacations &&
|
||||
renderVacationBlocks(
|
||||
@@ -619,7 +626,12 @@ function renderAllocBlocksFromData(
|
||||
multiSelectState: MultiSelectState,
|
||||
suppressHoverInteractions: boolean,
|
||||
onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void,
|
||||
scrollLeft = 0,
|
||||
containerWidth = 1200,
|
||||
) {
|
||||
const OVERSCAN_PX = 10 * CELL_WIDTH;
|
||||
const visibleLeft = scrollLeft - OVERSCAN_PX;
|
||||
const visibleRight = scrollLeft + containerWidth + OVERSCAN_PX;
|
||||
const anyDragActive = dragState.isDragging || allocDragState.isActive;
|
||||
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
||||
|
||||
@@ -764,6 +776,12 @@ function renderAllocBlocksFromData(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Horizontal virtualization: skip bars entirely outside the visible window
|
||||
const effectiveRight = segmentLeft + segmentWidth;
|
||||
if (effectiveRight < visibleLeft || segmentLeft > visibleRight) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleWidth = segmentWidth >= 48 ? 10 : 6;
|
||||
const dragInset = Math.min(handleWidth, Math.max(2, Math.floor(segmentWidth / 4)));
|
||||
const segmentInfo: AllocMouseDownInfo = {
|
||||
|
||||
@@ -678,6 +678,13 @@ function TimelineViewContent({
|
||||
};
|
||||
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Scroll-left tracking for horizontal virtualization ────────────────────
|
||||
// Updated via RAF so React state only updates after a frame, not on every
|
||||
// pixel of scroll. The ref gives instant reads inside event handlers.
|
||||
const scrollLeftRef = useRef(0);
|
||||
const scrollRafRef = useRef<number | null>(null);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
|
||||
function handleContainerScroll() {
|
||||
const el = scrollContainerRef.current;
|
||||
@@ -686,6 +693,13 @@ function TimelineViewContent({
|
||||
if (distanceFromRight < CELL_WIDTH * 40) {
|
||||
setViewDays((d) => d + 120);
|
||||
}
|
||||
scrollLeftRef.current = el.scrollLeft;
|
||||
if (scrollRafRef.current === null) {
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = null;
|
||||
setScrollLeft(scrollLeftRef.current);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
@@ -809,6 +823,8 @@ function TimelineViewContent({
|
||||
optimisticAllocations={optimisticAllocations}
|
||||
suppressHoverInteractions={hasActivePointerOverlay}
|
||||
{...(!isSelfServiceTimeline ? { onInlineEdit: (id: string, vals: { startDate: Date; endDate: Date; hoursPerDay: number }, rect: DOMRect) => setInlineEditTarget({ allocationId: id, ...vals, barRect: rect }) } : {})}
|
||||
scrollLeft={scrollLeft}
|
||||
containerWidth={scrollContainerRef.current?.clientWidth ?? 1200}
|
||||
CELL_WIDTH={CELL_WIDTH}
|
||||
dates={dates}
|
||||
totalCanvasWidth={totalCanvasWidth}
|
||||
|
||||
Reference in New Issue
Block a user