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;
|
optimisticAllocations: TimelineVisualOverrides;
|
||||||
suppressHoverInteractions: boolean;
|
suppressHoverInteractions: boolean;
|
||||||
onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void;
|
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
|
// Layout from useTimelineLayout
|
||||||
CELL_WIDTH: number;
|
CELL_WIDTH: number;
|
||||||
dates: Date[];
|
dates: Date[];
|
||||||
@@ -130,6 +133,8 @@ function TimelineResourcePanelInner({
|
|||||||
optimisticAllocations,
|
optimisticAllocations,
|
||||||
suppressHoverInteractions,
|
suppressHoverInteractions,
|
||||||
onInlineEdit,
|
onInlineEdit,
|
||||||
|
scrollLeft = 0,
|
||||||
|
containerWidth = 1200,
|
||||||
CELL_WIDTH,
|
CELL_WIDTH,
|
||||||
dates,
|
dates,
|
||||||
totalCanvasWidth,
|
totalCanvasWidth,
|
||||||
@@ -513,6 +518,8 @@ function TimelineResourcePanelInner({
|
|||||||
multiSelectState,
|
multiSelectState,
|
||||||
suppressHoverInteractions,
|
suppressHoverInteractions,
|
||||||
onInlineEdit,
|
onInlineEdit,
|
||||||
|
scrollLeft,
|
||||||
|
containerWidth,
|
||||||
)}
|
)}
|
||||||
{filters.showVacations &&
|
{filters.showVacations &&
|
||||||
renderVacationBlocks(
|
renderVacationBlocks(
|
||||||
@@ -619,7 +626,12 @@ function renderAllocBlocksFromData(
|
|||||||
multiSelectState: MultiSelectState,
|
multiSelectState: MultiSelectState,
|
||||||
suppressHoverInteractions: boolean,
|
suppressHoverInteractions: boolean,
|
||||||
onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void,
|
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 anyDragActive = dragState.isDragging || allocDragState.isActive;
|
||||||
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
||||||
|
|
||||||
@@ -764,6 +776,12 @@ function renderAllocBlocksFromData(
|
|||||||
return [];
|
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 handleWidth = segmentWidth >= 48 ? 10 : 6;
|
||||||
const dragInset = Math.min(handleWidth, Math.max(2, Math.floor(segmentWidth / 4)));
|
const dragInset = Math.min(handleWidth, Math.max(2, Math.floor(segmentWidth / 4)));
|
||||||
const segmentInfo: AllocMouseDownInfo = {
|
const segmentInfo: AllocMouseDownInfo = {
|
||||||
|
|||||||
@@ -678,6 +678,13 @@ function TimelineViewContent({
|
|||||||
};
|
};
|
||||||
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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 ─────────────────────────────────────
|
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
|
||||||
function handleContainerScroll() {
|
function handleContainerScroll() {
|
||||||
const el = scrollContainerRef.current;
|
const el = scrollContainerRef.current;
|
||||||
@@ -686,6 +693,13 @@ function TimelineViewContent({
|
|||||||
if (distanceFromRight < CELL_WIDTH * 40) {
|
if (distanceFromRight < CELL_WIDTH * 40) {
|
||||||
setViewDays((d) => d + 120);
|
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) => {
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
@@ -809,6 +823,8 @@ function TimelineViewContent({
|
|||||||
optimisticAllocations={optimisticAllocations}
|
optimisticAllocations={optimisticAllocations}
|
||||||
suppressHoverInteractions={hasActivePointerOverlay}
|
suppressHoverInteractions={hasActivePointerOverlay}
|
||||||
{...(!isSelfServiceTimeline ? { onInlineEdit: (id: string, vals: { startDate: Date; endDate: Date; hoursPerDay: number }, rect: DOMRect) => setInlineEditTarget({ allocationId: id, ...vals, barRect: rect }) } : {})}
|
{...(!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}
|
CELL_WIDTH={CELL_WIDTH}
|
||||||
dates={dates}
|
dates={dates}
|
||||||
totalCanvasWidth={totalCanvasWidth}
|
totalCanvasWidth={totalCanvasWidth}
|
||||||
|
|||||||
Reference in New Issue
Block a user