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 = {