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:
2026-04-09 13:26:38 +02:00
parent e75d966b8d
commit 7a5e98e2e9
2 changed files with 34 additions and 0 deletions
@@ -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}