/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * GanttTaskBar — renders a single Gantt row's bar (or diamond for milestones) * plus its drag hit-zones and completion overlay. Extracted from * GanttTimeline so the orchestrator there stays focused on layout / ticks / * cursor / arrows. * * Memoized on its own props so panning the playback cursor across a * schedule with hundreds of rows doesn't re-diff every bar every frame. */ import { memo } from 'react'; import type { ScheduleTaskInfo } from '@ifc-lite/parser'; import { timeToX, formatDateTime } from './schedule-utils'; import { GANTT_ROW_HEIGHT } from './GanttTaskTree'; export interface GanttTaskBarProps { task: ScheduleTaskInfo; rowIndex: number; /** Task start as epoch ms (already parsed by the parent's taskEpochs map). */ start: number; /** Task finish as epoch ms. */ finish: number; rangeStart: number; rangeEnd: number; pixelWidth: number; playbackTime: number; isSelected: boolean; isDragging: boolean; onHover: (globalId: string | null) => void; onSelect: (globalId: string, multi: boolean) => void; onPointerDown: ( e: React.PointerEvent, taskGlobalId: string, mode: 'shift' | 'resize-start' | 'resize-finish', ) => void; } export const GanttTaskBar = memo(function GanttTaskBar({ task, rowIndex, start, finish, rangeStart, rangeEnd, pixelWidth, playbackTime, isSelected, isDragging, onHover, onSelect, onPointerDown, }: GanttTaskBarProps) { const y = rowIndex * GANTT_ROW_HEIGHT; const barX = timeToX(start, rangeStart, rangeEnd, pixelWidth); const barX2 = timeToX(finish, rangeStart, rangeEnd, pixelWidth); const barWidth = Math.max(task.isMilestone ? 0 : 2, barX2 - barX); const isActive = playbackTime >= start && playbackTime <= finish; const isDone = playbackTime > finish; const isPending = !isActive && !isDone; const isCritical = task.taskTime?.isCritical ?? false; if (task.isMilestone) { const cx = barX; const cy = y + GANTT_ROW_HEIGHT / 2; const s = 6; return ( onHover(task.globalId)} onMouseLeave={() => onHover(null)} onClick={(e) => { e.stopPropagation(); onSelect(task.globalId, e.shiftKey || e.ctrlKey || e.metaKey); }} className="cursor-pointer" > {task.name || task.globalId} {'\n'} {formatDateTime(start)} ); } // Edge hit zones for resize. Minimum 4 px wide so we stay // clickable even on very short bars; capped at 25 % of the // bar width so on bars < 20 px the whole bar becomes a shift // zone (you can still resize via the Inspector). const edgeZone = Math.min(8, Math.max(4, Math.floor(barWidth * 0.25))); const showEdgeHandles = barWidth >= edgeZone * 2 + 4; const barTop = y + 6; const barH = GANTT_ROW_HEIGHT - 12; return ( onHover(task.globalId)} onMouseLeave={() => onHover(null)} onClick={(e) => { e.stopPropagation(); onSelect(task.globalId, e.shiftKey || e.ctrlKey || e.metaKey); }} > {task.taskTime?.completion !== undefined && ( )} {/* Shift hit-zone: the interior of the bar. Draws no fill (the visible fill rect above handles that) but owns the pointer events that map to drag-body. */} onPointerDown(e, task.globalId, 'shift')} /> {/* Edge resize hit-zones. Only render when the bar is wide enough for separate zones — otherwise the whole bar is a shift zone and resize goes through the Inspector. */} {showEdgeHandles && ( <> onPointerDown(e, task.globalId, 'resize-start')} /> onPointerDown(e, task.globalId, 'resize-finish')} /> )} {task.name || task.globalId} {'\n'} {formatDateTime(start)} → {formatDateTime(finish)} ); });