f18777c365
Reduces unnecessary re-renders by separating the monolithic 20+ property context into TimelineDataContext, TimelineViewContext, and TimelineDisplayContext. Panel components now subscribe only to the slices they need. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
585 lines
22 KiB
TypeScript
585 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { clsx } from "clsx";
|
|
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import {
|
|
useTimelineData,
|
|
useTimelineView,
|
|
useTimelineDisplay,
|
|
type TimelineAssignmentEntry,
|
|
} from "./TimelineContext.js";
|
|
import { applyVisualOverrides, type TimelineVisualOverrides } from "./allocationVisualState.js";
|
|
import { ConflictOverlay } from "./ConflictOverlay.js";
|
|
import { computeSubLanes } from "./utils.js";
|
|
import {
|
|
TimelineTooltip,
|
|
type HeatmapHoverData,
|
|
type VacationHoverData,
|
|
} from "./TimelineTooltip.js";
|
|
import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
|
|
import type {
|
|
DragState,
|
|
AllocDragState,
|
|
RangeState,
|
|
ShiftPreviewData,
|
|
MultiSelectState,
|
|
} from "~/hooks/useTimelineDrag.js";
|
|
import {
|
|
buildVacationBlocksByResource,
|
|
renderVacationBlocks,
|
|
renderRangeOverlay,
|
|
renderOverbookingBlink,
|
|
type VacationBlockInfo,
|
|
} from "./renderHelpers.js";
|
|
import {
|
|
cancelHoverFrame,
|
|
scheduleVacationHoverUpdate,
|
|
updateTooltipPosition,
|
|
} from "./timelineHover.js";
|
|
import { buildResourceHeatmapHover } from "./timelineHeatmap.js";
|
|
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
|
|
import { isAllocationScheduledOnDate } from "./timelineAvailability.js";
|
|
import {
|
|
renderAllocBlocksFromData,
|
|
renderLoadGraph,
|
|
renderHeatmapOverlay,
|
|
renderDailyBars,
|
|
type AllocBlockData,
|
|
type AllocMouseDownInfo,
|
|
} from "./timelineResourceRender.js";
|
|
|
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
|
|
interface TimelineResourcePanelProps {
|
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
dragState: DragState;
|
|
allocDragState: AllocDragState;
|
|
rangeState: RangeState;
|
|
shiftPreview: ShiftPreviewData | null;
|
|
contextResourceIds: string[];
|
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void;
|
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void;
|
|
onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void;
|
|
onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void;
|
|
onAllocationContextMenu: (
|
|
info: { allocationId: string; projectId: string; contextDate?: Date },
|
|
anchorX: number,
|
|
anchorY: number,
|
|
) => void;
|
|
multiSelectState: MultiSelectState;
|
|
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[];
|
|
totalCanvasWidth: number;
|
|
toLeft: (date: Date) => number;
|
|
toWidth: (start: Date, end: Date) => number;
|
|
gridLines: React.ReactNode;
|
|
xToDate: (clientX: number, rect: DOMRect) => Date;
|
|
}
|
|
|
|
export interface RowMouseDownInfo {
|
|
resourceId: string;
|
|
startDate: Date;
|
|
suggestedProjectId?: string;
|
|
}
|
|
|
|
// ─── Component ──────────────────────────────────────────────────────────────
|
|
|
|
function TimelineResourcePanelInner({
|
|
scrollContainerRef,
|
|
dragState,
|
|
allocDragState,
|
|
rangeState,
|
|
shiftPreview,
|
|
contextResourceIds,
|
|
onAllocMouseDown,
|
|
onAllocTouchStart,
|
|
onRowMouseDown,
|
|
onRowTouchStart,
|
|
onAllocationContextMenu,
|
|
multiSelectState,
|
|
optimisticAllocations,
|
|
suppressHoverInteractions,
|
|
onInlineEdit,
|
|
scrollLeft = 0,
|
|
containerWidth = 1200,
|
|
CELL_WIDTH,
|
|
dates,
|
|
totalCanvasWidth,
|
|
toLeft,
|
|
toWidth,
|
|
gridLines,
|
|
xToDate,
|
|
}: TimelineResourcePanelProps) {
|
|
const { resources, allocsByResource, vacationsByResource } = useTimelineData();
|
|
const { filters, viewStart, viewEnd, activeFilterCount } = useTimelineView();
|
|
const { displayMode, heatmapScheme, blinkOverbookedDays } = useTimelineDisplay();
|
|
|
|
// ─── Heatmap hover state ────────────────────────────────────────────────────
|
|
const heatmapRafRef = useRef<number | null>(null);
|
|
const lastHeatmapDayRef = useRef<number>(-1);
|
|
const vacationHoverRafRef = useRef<number | null>(null);
|
|
const hoveredVacationKeyRef = useRef<string | null>(null);
|
|
const pendingHeatmapRef = useRef<{
|
|
clientX: number;
|
|
rect: DOMRect;
|
|
allocs: TimelineAssignmentEntry[];
|
|
} | null>(null);
|
|
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
|
|
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
|
|
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
|
|
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
|
|
|
|
const [heatmapHover, setHeatmapHover] = useState<HeatmapHoverData | null>(null);
|
|
const [vacationHover, setVacationHover] = useState<VacationHoverData | null>(null);
|
|
|
|
// ─── Virtual row list ────────────────────────────────────────────────────────
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: resources.length,
|
|
getScrollElement: () => scrollContainerRef.current,
|
|
estimateSize: () => ROW_HEIGHT,
|
|
overscan: 5,
|
|
});
|
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
const totalRowHeight = rowVirtualizer.getTotalSize();
|
|
|
|
const visualAllocsByResource = useMemo(() => {
|
|
if (optimisticAllocations.size === 0) return allocsByResource;
|
|
|
|
const next = new Map<string, TimelineAssignmentEntry[]>();
|
|
for (const [resourceId, allocs] of allocsByResource) {
|
|
next.set(resourceId, applyVisualOverrides(allocs, optimisticAllocations));
|
|
}
|
|
return next;
|
|
}, [allocsByResource, optimisticAllocations]);
|
|
|
|
const resourceCapacityById = useMemo(
|
|
() => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates),
|
|
[dates, vacationsByResource, visualAllocsByResource],
|
|
);
|
|
|
|
// ─── Memo 1: resourceRows — which rows to render ─────────────────────────
|
|
// (virtualizer handles which subset is visible; this memo just pre-computes
|
|
// per-row data that the render loop needs)
|
|
const resourceRows = useMemo(() => {
|
|
const contextSet = new Set(contextResourceIds);
|
|
return resources.map((resource) => {
|
|
const allocs = visualAllocsByResource.get(resource.id) ?? [];
|
|
const isContextResource = contextSet.has(resource.id);
|
|
return { resource, allocs, isContextResource };
|
|
});
|
|
}, [resources, visualAllocsByResource, contextResourceIds]);
|
|
|
|
// ─── Memo 2: vacationBlocks — vacation bar positions per resource ─────────
|
|
const vacationBlocksByResource = useMemo(
|
|
() =>
|
|
buildVacationBlocksByResource(
|
|
vacationsByResource,
|
|
filters.showVacations,
|
|
toLeft,
|
|
toWidth,
|
|
CELL_WIDTH,
|
|
totalCanvasWidth,
|
|
filters.showWeekends,
|
|
),
|
|
[
|
|
vacationsByResource,
|
|
toLeft,
|
|
toWidth,
|
|
CELL_WIDTH,
|
|
totalCanvasWidth,
|
|
filters.showVacations,
|
|
filters.showWeekends,
|
|
],
|
|
);
|
|
|
|
// ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ──
|
|
// (Bar mode computes differently per-day, so we only pre-compute for strip.)
|
|
const assignmentBlocksByResource = useMemo(() => {
|
|
if (displayMode === "bar")
|
|
return new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
|
|
|
|
const result = new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
|
|
for (const { resource, allocs } of resourceRows) {
|
|
if (allocs.length === 0) continue;
|
|
|
|
const subLaneMap = computeSubLanes(
|
|
allocs.map((a) => ({
|
|
id: a.id,
|
|
startDate: new Date(a.startDate),
|
|
endDate: new Date(a.endDate),
|
|
})),
|
|
);
|
|
const laneCount = subLaneMap.size > 0 ? Math.max(...subLaneMap.values()) + 1 : 1;
|
|
const blockData: AllocBlockData[] = allocs.map((alloc) => ({
|
|
alloc,
|
|
lane: subLaneMap.get(alloc.id) ?? 0,
|
|
}));
|
|
result.set(resource.id, { laneCount, blockData });
|
|
}
|
|
return result;
|
|
}, [displayMode, resourceRows]);
|
|
|
|
// ─── Memo 4: utilization per resource for row background tint ───────────
|
|
const utilizationByResource = useMemo(() => {
|
|
const result = new Map<string, number>(); // resourceId -> avg utilization pct
|
|
for (const { resource, allocs } of resourceRows) {
|
|
if (allocs.length === 0) continue;
|
|
const capacity = resourceCapacityById.get(resource.id);
|
|
let totalPct = 0;
|
|
let dayCount = 0;
|
|
for (let dayIndex = 0; dayIndex < dates.length; dayIndex++) {
|
|
const date = dates[dayIndex];
|
|
if (!date) continue;
|
|
const bookingFactor = capacity?.bookingFactorsByDay[dayIndex] ?? 1;
|
|
const capacityHours = capacity?.capacityHoursByDay[dayIndex] ?? 8;
|
|
let dayH = 0;
|
|
for (const a of allocs) {
|
|
if (isAllocationScheduledOnDate(a, date)) {
|
|
dayH += a.hoursPerDay * bookingFactor;
|
|
}
|
|
}
|
|
if (dayH > 0 && capacityHours > 0) {
|
|
totalPct += (dayH / capacityHours) * 100;
|
|
dayCount++;
|
|
}
|
|
}
|
|
if (dayCount > 0) {
|
|
result.set(resource.id, totalPct / dayCount);
|
|
}
|
|
}
|
|
return result;
|
|
}, [dates, resourceCapacityById, resourceRows]);
|
|
|
|
// ─── Heatmap row hover handler ────────────────────────────────────────────
|
|
const handleRowHeatmapMove = useCallback(
|
|
(e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
|
|
updateTooltipPosition(heatmapTooltipPosRef, heatmapTooltipRef, e.clientX, e.clientY, 16, -52);
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
|
if (dayIndex === lastHeatmapDayRef.current) return;
|
|
|
|
pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs };
|
|
if (heatmapRafRef.current !== null) return;
|
|
|
|
heatmapRafRef.current = requestAnimationFrame(() => {
|
|
heatmapRafRef.current = null;
|
|
const pending = pendingHeatmapRef.current;
|
|
pendingHeatmapRef.current = null;
|
|
if (!pending) return;
|
|
|
|
const { clientX, rect: r, allocs: a } = pending;
|
|
const dayIdx = Math.floor((clientX - r.left) / CELL_WIDTH);
|
|
const date = dates[dayIdx];
|
|
if (!date) {
|
|
lastHeatmapDayRef.current = -1;
|
|
startTransition(() => setHeatmapHover(null));
|
|
return;
|
|
}
|
|
lastHeatmapDayRef.current = dayIdx;
|
|
|
|
const resourceId = a[0]?.resourceId ?? null;
|
|
const capacity = resourceId ? resourceCapacityById.get(resourceId) : undefined;
|
|
const nextHeatmap = buildResourceHeatmapHover(date, a, {
|
|
...(capacity?.capacityHoursByDay[dayIdx] !== undefined
|
|
? { capacityHours: capacity.capacityHoursByDay[dayIdx] }
|
|
: {}),
|
|
...(capacity?.bookingFactorsByDay[dayIdx] !== undefined
|
|
? { bookingFactor: capacity.bookingFactorsByDay[dayIdx] }
|
|
: {}),
|
|
});
|
|
startTransition(() => {
|
|
setHeatmapHover(nextHeatmap);
|
|
});
|
|
});
|
|
},
|
|
[CELL_WIDTH, dates, resourceCapacityById],
|
|
);
|
|
|
|
// ─── Vacation hover ───────────────────────────────────────────────────────
|
|
const handleRowVacationHover = useCallback(
|
|
(e: React.MouseEvent, resourceId: string) => {
|
|
updateTooltipPosition(
|
|
vacationTooltipPosRef,
|
|
vacationTooltipRef,
|
|
e.clientX,
|
|
e.clientY,
|
|
14,
|
|
-8,
|
|
);
|
|
scheduleVacationHoverUpdate({
|
|
frameRef: vacationHoverRafRef,
|
|
hoveredKeyRef: hoveredVacationKeyRef,
|
|
resourceId,
|
|
clientX: e.clientX,
|
|
rect: e.currentTarget.getBoundingClientRect(),
|
|
xToDate,
|
|
vacations: vacationsByResource.get(resourceId) ?? [],
|
|
onHoverChange: (hit) => {
|
|
startTransition(() => {
|
|
setVacationHover(hit);
|
|
});
|
|
},
|
|
});
|
|
},
|
|
[vacationsByResource, xToDate],
|
|
);
|
|
|
|
const clearHoverTooltips = useCallback(() => {
|
|
cancelHoverFrame(heatmapRafRef);
|
|
cancelHoverFrame(vacationHoverRafRef);
|
|
|
|
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
|
|
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
|
|
|
|
lastHeatmapDayRef.current = -1;
|
|
hoveredVacationKeyRef.current = null;
|
|
|
|
if (shouldClearHeatmap || shouldClearVacation) {
|
|
startTransition(() => {
|
|
if (shouldClearHeatmap) setHeatmapHover(null);
|
|
if (shouldClearVacation) setVacationHover(null);
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// ─── Cleanup rAF on unmount ───────────────────────────────────────────────
|
|
useEffect(
|
|
() => () => {
|
|
cancelHoverFrame(heatmapRafRef);
|
|
cancelHoverFrame(vacationHoverRafRef);
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!suppressHoverInteractions) return;
|
|
clearHoverTooltips();
|
|
}, [clearHoverTooltips, suppressHoverInteractions]);
|
|
|
|
// ─── Render helpers ───────────────────────────────────────────────────────
|
|
|
|
if (resources.length === 0) {
|
|
return (
|
|
<div className="text-center py-16 text-gray-400">
|
|
No allocations in this time range{activeFilterCount > 0 && " (filtered)"}.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
height: totalRowHeight,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{virtualItems.map((virtualRow) => {
|
|
const rowData = resourceRows[virtualRow.index];
|
|
if (!rowData) return null;
|
|
const { resource, allocs, isContextResource } = rowData;
|
|
const inBarMode = displayMode === "bar";
|
|
const precomputed = assignmentBlocksByResource.get(resource.id);
|
|
const laneCount = inBarMode ? 1 : (precomputed?.laneCount ?? 1);
|
|
const rowHeight = inBarMode
|
|
? ROW_HEIGHT
|
|
: Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
|
|
|
// Utilization background tint — only highlight over-utilization
|
|
const utilPct = utilizationByResource.get(resource.id) ?? 0;
|
|
const utilBg =
|
|
utilPct > 100
|
|
? "rgba(254,202,202,0.18)" // red tint for over-utilized
|
|
: undefined;
|
|
|
|
return (
|
|
<div
|
|
key={resource.id}
|
|
data-index={virtualRow.index}
|
|
ref={rowVirtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
<div
|
|
className={clsx(
|
|
"flex border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50/40 dark:hover:bg-[rgb(var(--surface-elevated)/0.3)] group transition-colors",
|
|
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
|
|
)}
|
|
style={{ height: rowHeight, ...(utilBg ? { backgroundColor: utilBg } : {}) }}
|
|
>
|
|
{/* Label column */}
|
|
<div
|
|
className={clsx(
|
|
"flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center px-4 gap-2.5 bg-white dark:bg-[rgb(var(--surface-card))] sticky left-0 z-30 group-hover:bg-gray-50 dark:group-hover:bg-[rgb(var(--surface-elevated))]",
|
|
dragState.isDragging && isContextResource && "bg-brand-50 dark:bg-brand-950/40",
|
|
)}
|
|
style={{ width: LABEL_WIDTH }}
|
|
>
|
|
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
|
|
{resource.displayName.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0" data-resource-hover-id={resource.id}>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
|
{resource.displayName}
|
|
</div>
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
|
{resource.chapter ?? resource.eid}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row canvas */}
|
|
<div
|
|
data-testid="timeline-resource-row-canvas"
|
|
data-resource-id={resource.id}
|
|
data-resource-eid={resource.eid}
|
|
data-resource-name={resource.displayName}
|
|
className="relative overflow-hidden touch-none"
|
|
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
|
|
onMouseDown={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const date = xToDate(e.clientX, rect);
|
|
onRowMouseDown(e, { resourceId: resource.id, startDate: date });
|
|
}}
|
|
onTouchStart={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
|
|
onRowTouchStart(e, { resourceId: resource.id, startDate: date });
|
|
}}
|
|
onMouseMove={(e) => {
|
|
if (suppressHoverInteractions) return;
|
|
handleRowHeatmapMove(e, allocs);
|
|
handleRowVacationHover(e, resource.id);
|
|
}}
|
|
onMouseLeave={clearHoverTooltips}
|
|
>
|
|
{gridLines}
|
|
{inBarMode
|
|
? renderDailyBars(
|
|
allocs,
|
|
rowHeight,
|
|
CELL_WIDTH,
|
|
dates,
|
|
allocDragState,
|
|
onAllocMouseDown,
|
|
onAllocTouchStart,
|
|
onAllocationContextMenu,
|
|
toLeft,
|
|
toWidth,
|
|
totalCanvasWidth,
|
|
resourceCapacityById.get(resource.id),
|
|
multiSelectState,
|
|
suppressHoverInteractions,
|
|
)
|
|
: renderAllocBlocksFromData(
|
|
precomputed?.blockData ?? [],
|
|
allocs,
|
|
dragState,
|
|
allocDragState,
|
|
toLeft,
|
|
toWidth,
|
|
CELL_WIDTH,
|
|
totalCanvasWidth,
|
|
onAllocMouseDown,
|
|
onAllocTouchStart,
|
|
onAllocationContextMenu,
|
|
multiSelectState,
|
|
suppressHoverInteractions,
|
|
onInlineEdit,
|
|
scrollLeft,
|
|
containerWidth,
|
|
optimisticAllocations.size > 0
|
|
? new Set(optimisticAllocations.keys())
|
|
: undefined,
|
|
)}
|
|
{filters.showVacations &&
|
|
renderVacationBlocks(vacationBlocksByResource.get(resource.id) ?? [], rowHeight)}
|
|
{displayMode === "strip" &&
|
|
renderLoadGraph(allocs, dates, CELL_WIDTH, resourceCapacityById.get(resource.id))}
|
|
{displayMode === "heatmap" &&
|
|
renderHeatmapOverlay(
|
|
allocs,
|
|
dates,
|
|
CELL_WIDTH,
|
|
heatmapScheme,
|
|
resourceCapacityById.get(resource.id),
|
|
)}
|
|
{blinkOverbookedDays &&
|
|
renderOverbookingBlink(
|
|
allocs,
|
|
dates,
|
|
CELL_WIDTH,
|
|
resourceCapacityById.get(resource.id)?.capacityHoursByDay,
|
|
resourceCapacityById.get(resource.id)?.bookingFactorsByDay,
|
|
)}
|
|
{renderRangeOverlay(
|
|
rangeState,
|
|
resource.id,
|
|
rowHeight,
|
|
toLeft,
|
|
toWidth,
|
|
CELL_WIDTH,
|
|
)}
|
|
|
|
{dragState.isDragging &&
|
|
dragState.projectId &&
|
|
shiftPreview &&
|
|
!shiftPreview.valid &&
|
|
shiftPreview.conflictCount > 0 &&
|
|
allocs.some((a) => a.projectId === dragState.projectId) && (
|
|
<ConflictOverlay
|
|
left={toLeft(dragState.currentStartDate ?? viewStart) + 2}
|
|
width={
|
|
toWidth(
|
|
dragState.currentStartDate ?? viewStart,
|
|
dragState.currentEndDate ?? viewEnd,
|
|
) - 4
|
|
}
|
|
height={rowHeight - 8}
|
|
type="availability"
|
|
message={`${shiftPreview.conflictCount} conflict(s)`}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Tooltips rendered inside the panel so they live near their data source */}
|
|
<TimelineTooltip
|
|
heatmapTooltipRef={heatmapTooltipRef}
|
|
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
|
vacationTooltipRef={vacationTooltipRef}
|
|
vacationTooltipPos={vacationTooltipPosRef.current}
|
|
heatmapHover={heatmapHover}
|
|
vacationHover={vacationHover}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ResourcePanelTooltips removed — now uses shared TimelineTooltip component
|
|
|
|
export const TimelineResourcePanel = memo(TimelineResourcePanelInner);
|
|
|
|
// ─── Re-export types for consumers ──────────────────────────────────────────
|
|
export type { AllocBlockData, AllocMouseDownInfo } from "./timelineResourceRender.js";
|
|
export type { VacationBlockInfo } from "./renderHelpers.js";
|